mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
170 lines
5.8 KiB
Python
170 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Refresh VoCat's offline PLMN name table from Android's carrier database.
|
|
|
|
The AOSP carrier ID table is maintained for Android's own carrier recognition.
|
|
Only unconstrained MCC/MNC records are used here: MVNO matches that also require
|
|
an SPN, IMSI prefix, GID or ICCID prefix must not rename the serving MNO.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import re
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FRONTEND_TABLE = ROOT / "web" / "src" / "lib" / "mccmnc.json"
|
|
BACKEND_TABLE = ROOT / "internal" / "device" / "mccmnc.json"
|
|
SOURCE_URL = (
|
|
"https://android.googlesource.com/platform/packages/providers/"
|
|
"TelephonyProvider/+/master/assets/latest_carrier_id/"
|
|
"carrier_list.textpb?format=TEXT"
|
|
)
|
|
|
|
# PLMNs for which modem firmware and older public tables commonly expose stale
|
|
# or blank names. These are kept as small, explicit corrections on top of the
|
|
# global AOSP dataset.
|
|
MANUAL_CARRIERS = {
|
|
"46000": "China Mobile",
|
|
"46002": "China Mobile",
|
|
"46004": "China Mobile",
|
|
"46007": "China Mobile",
|
|
"46008": "China Mobile",
|
|
"46020": "China Mobile",
|
|
"46001": "China Unicom",
|
|
"46006": "China Unicom",
|
|
"46009": "China Unicom",
|
|
"46010": "China Unicom",
|
|
"46003": "China Telecom",
|
|
"46005": "China Telecom",
|
|
"46011": "China Telecom",
|
|
"46012": "China Telecom",
|
|
"46015": "China Broadnet",
|
|
}
|
|
|
|
# Some territories share an MCC. Keep the PLMN-level ISO assignment where an
|
|
# MCC-only fallback cannot distinguish them.
|
|
ISO_OVERRIDES = {
|
|
"36251": "an",
|
|
"36269": "cw",
|
|
"36291": "an",
|
|
"64700": "re",
|
|
"64702": "re",
|
|
"64703": "re",
|
|
"64704": "re",
|
|
}
|
|
|
|
|
|
def braced_blocks(text: str, marker: str) -> list[str]:
|
|
result: list[str] = []
|
|
offset = 0
|
|
while True:
|
|
start = text.find(marker, offset)
|
|
if start < 0:
|
|
return result
|
|
brace = text.find("{", start + len(marker))
|
|
if brace < 0:
|
|
return result
|
|
depth = 0
|
|
quoted = False
|
|
escaped = False
|
|
for index in range(brace, len(text)):
|
|
char = text[index]
|
|
if quoted:
|
|
if escaped:
|
|
escaped = False
|
|
elif char == "\\":
|
|
escaped = True
|
|
elif char == '"':
|
|
quoted = False
|
|
continue
|
|
if char == '"':
|
|
quoted = True
|
|
elif char == "{":
|
|
depth += 1
|
|
elif char == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
result.append(text[brace + 1 : index])
|
|
offset = index + 1
|
|
break
|
|
else:
|
|
raise ValueError(f"unterminated {marker} block")
|
|
|
|
|
|
def textproto_string(block: str, field: str) -> str:
|
|
match = re.search(rf"^\s*{re.escape(field)}:\s*(\"(?:\\.|[^\"\\])*\")", block, re.M)
|
|
return json.loads(match.group(1)) if match else ""
|
|
|
|
|
|
def aosp_carriers(text: str) -> dict[str, str]:
|
|
carriers: dict[str, str] = {}
|
|
for carrier in braced_blocks(text, "carrier_id"):
|
|
name = textproto_string(carrier, "carrier_name").strip()
|
|
if not name:
|
|
continue
|
|
attributes = braced_blocks(carrier, "carrier_attribute")
|
|
for attribute in attributes:
|
|
fields = set(re.findall(r"^\s*([a-zA-Z0-9_]+)\s*:", attribute, re.M))
|
|
if fields - {"mccmnc_tuple"}:
|
|
continue
|
|
for plmn in re.findall(r'^\s*mccmnc_tuple:\s*"(\d{5,6})"', attribute, re.M):
|
|
carriers.setdefault(plmn, name)
|
|
|
|
# A few legacy entries put MCC/MNC directly in carrier_id. Remove the
|
|
# nested attributes before checking so constrained MVNO tuples do not
|
|
# leak into the generic map.
|
|
direct = carrier
|
|
for attribute in attributes:
|
|
direct = direct.replace("carrier_attribute {" + attribute + "}", "")
|
|
for plmn in re.findall(r'^\s*mccmnc_tuple:\s*"(\d{5,6})"', direct, re.M):
|
|
carriers.setdefault(plmn, name)
|
|
return carriers
|
|
|
|
|
|
def main() -> None:
|
|
with urllib.request.urlopen(SOURCE_URL, timeout=30) as response:
|
|
source = base64.b64decode(response.read()).decode("utf-8")
|
|
names = aosp_carriers(source)
|
|
table = json.loads(FRONTEND_TABLE.read_text(encoding="utf-8"))
|
|
countries: dict[str, str] = table["i"]
|
|
countries.update({str(mcc): "us" for mcc in range(310, 317)})
|
|
countries.update({"406": "in", "461": "cn"})
|
|
carriers: dict[str, list[str]] = table["c"]
|
|
for plmn, name in names.items():
|
|
previous = carriers.get(plmn)
|
|
iso = previous[1] if previous and len(previous) > 1 else countries.get(plmn[:3], "")
|
|
if plmn[:3] in {str(mcc) for mcc in range(310, 317)}:
|
|
iso = "us"
|
|
iso = ISO_OVERRIDES.get(plmn, iso)
|
|
carriers[plmn] = [name, iso]
|
|
for plmn, name in MANUAL_CARRIERS.items():
|
|
carriers[plmn] = [name, countries.get(plmn[:3], "")]
|
|
|
|
version_match = re.search(r"^version:\s*(\d+)", source, re.M)
|
|
output = {
|
|
"c": dict(sorted(carriers.items())),
|
|
"i": dict(sorted(countries.items())),
|
|
"t": sorted(set(table["t"])),
|
|
"meta": {
|
|
"source": "Android Open Source Project carrier_list.textpb",
|
|
"source_url": SOURCE_URL.removesuffix("?format=TEXT"),
|
|
"aosp_version": version_match.group(1) if version_match else "unknown",
|
|
"aosp_generic_records": len(names),
|
|
},
|
|
}
|
|
encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
FRONTEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
|
|
BACKEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
|
|
print(
|
|
f"updated {len(carriers)} PLMN records "
|
|
f"({len(names)} generic AOSP records, version {output['meta']['aosp_version']})"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|