Files
VoCat/scripts/update-carriers.py

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()