soul-browser/scripts/set-package-id.py
KaKi87 f774cf073b Fix Copy URL crash from a doubled testing R class package.
set-package-id.py replaced net.kaki87.soul2 inside already-rewritten
.net.kaki87.soul2.testing refs, so testing builds resolved
testing.testing.R$string.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 18:59:35 +02:00

257 lines
8 KiB
Python
Executable file

#!/usr/bin/env python3
"""Rewrite the Android application ID and refresh ACTION_* sparse-switch hashes."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
APP_DIR = ROOT / "app"
TEXT_GLOBS = (
"*.xml",
"*.yml",
"*.smali",
"*.txt",
"*.properties",
)
def java_hash(s: str) -> int:
h = 0
for ch in s:
h = (31 * h + ord(ch)) & 0xFFFFFFFF
if h >= 0x80000000:
h -= 0x100000000
return h
def fmt_smali_int(value: int) -> str:
if value < 0:
return f"-0x{-value:x}"
return f"0x{value:x}"
def package_to_descriptor(package: str) -> str:
return "L" + package.replace(".", "/") + "/"
# Application IDs this repo actually ships. rewrite_text() substitutes every
# known ID (longest first) so "net.kaki87.soul2" cannot turn an already-rewritten
# ".testing" reference into ".testing.testing".
KNOWN_PACKAGE_IDS = (
"net.kaki87.soul2.testing",
"net.kaki87.soul2",
)
def package_ids_to_rewrite(old: str, new: str) -> list[str]:
ids = {old, new, *KNOWN_PACKAGE_IDS}
extra: set[str] = set()
for pkg in ids:
last = pkg.rsplit(".", 1)[-1]
if last:
extra.add(f"{pkg}.{last}")
ids.update(extra)
return sorted(ids, key=len, reverse=True)
def rewrite_text(text: str, old: str, new: str) -> str:
"""Map every known/old application ID onto *new* without doubling prefixes."""
ids = package_ids_to_rewrite(old, new)
placeholders: list[tuple[str, str]] = []
for i, pkg in enumerate(ids):
desc_ph = f"\0D{i}\0"
pkg_ph = f"\0P{i}\0"
text = text.replace(package_to_descriptor(pkg), desc_ph)
text = text.replace(pkg, pkg_ph)
placeholders.append((desc_ph, pkg_ph))
new_desc = package_to_descriptor(new)
for desc_ph, pkg_ph in placeholders:
text = text.replace(desc_ph, new_desc)
text = text.replace(pkg_ph, new)
return text
def text_needs_rewrite(text: str, old: str, new: str) -> bool:
for pkg in package_ids_to_rewrite(old, new):
if pkg == new:
continue
if pkg in text or package_to_descriptor(pkg) in text:
return True
return False
def self_test() -> int:
cases = [
(
"Lnet/kaki87/soul2/R$string;",
"net.kaki87.soul2",
"net.kaki87.soul2.testing",
"Lnet/kaki87/soul2/testing/R$string;",
),
(
"Lnet/kaki87/soul2/testing/R$string;",
"net.kaki87.soul2",
"net.kaki87.soul2.testing",
"Lnet/kaki87/soul2/testing/R$string;",
),
(
"Lnet/kaki87/soul2/testing/testing/R$string;",
"net.kaki87.soul2",
"net.kaki87.soul2.testing",
"Lnet/kaki87/soul2/testing/R$string;",
),
(
"net.kaki87.soul2.testing",
"net.kaki87.soul2",
"net.kaki87.soul2.testing",
"net.kaki87.soul2.testing",
),
(
"net.kaki87.soul2.testing.testing",
"net.kaki87.soul2",
"net.kaki87.soul2.testing",
"net.kaki87.soul2.testing",
),
(
"Lnet/kaki87/soul2/testing/R$string;",
"net.kaki87.soul2",
"net.kaki87.soul2",
"Lnet/kaki87/soul2/R$string;",
),
(
"Lnet/kaki87/soul2/R$id; Lnet/kaki87/soul2/testing/R$string;",
"net.kaki87.soul2",
"net.kaki87.soul2.testing",
"Lnet/kaki87/soul2/testing/R$id; Lnet/kaki87/soul2/testing/R$string;",
),
]
failed = 0
for src, old, new, expected in cases:
got = rewrite_text(src, old, new)
if got != expected:
print(f"FAIL: {src!r} ({old} -> {new})\n got: {got!r}\n expected: {expected!r}", file=sys.stderr)
failed += 1
if failed:
print(f"{failed} self-test(s) failed", file=sys.stderr)
return 1
print(f"{len(cases)} self-tests passed")
return 0
def fix_sparse_switches(path: Path, text: str) -> str:
if ".sparse-switch" not in text or "sswitch_" not in text:
return text
# Map :sswitch_N -> compared const-string (nearest following const-string).
label_to_string: dict[str, str] = {}
for m in re.finditer(
r":(sswitch_\d+)\s*(?:\n\s*\.line[^\n]*)*\n\s*const-string\s+\w+,\s*\"([^\"]+)\"",
text,
):
label_to_string[m.group(1)] = m.group(2)
def repl(match: re.Match[str]) -> str:
body = match.group(1)
entries: list[tuple[int, str]] = []
for line in body.splitlines():
line = line.strip()
if not line or "->" not in line:
continue
left, right = [p.strip() for p in line.split("->", 1)]
label = right.lstrip(":")
if not label.startswith("sswitch_"):
label = right
key = label if label.startswith("sswitch_") else right.lstrip(":")
# Normalize label key
if key.startswith(":"):
key = key[1:]
const = label_to_string.get(key)
if const is None:
# Keep original numeric key if we cannot resolve.
try:
entries.append((int(left, 0), right if right.startswith(":") else f":{right}"))
except ValueError:
continue
continue
entries.append((java_hash(const), f":{key}"))
if not entries:
return match.group(0)
entries.sort(key=lambda item: item[0])
lines = [f" {fmt_smali_int(h)} -> {label}" for h, label in entries]
return ".sparse-switch\n" + "\n".join(lines) + "\n .end sparse-switch"
return re.sub(
r"\.sparse-switch\n(.*?)\n\s*\.end sparse-switch",
repl,
text,
flags=re.S,
)
def detect_current_package() -> str | None:
manifest = APP_DIR / "AndroidManifest.xml"
if not manifest.exists():
return None
m = re.search(r'\bpackage="([^"]+)"', manifest.read_text(encoding="utf-8"))
return m.group(1) if m else None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--to", help="Target application ID")
parser.add_argument(
"--from",
dest="frm",
default=None,
help="Current application ID (default: read from AndroidManifest.xml)",
)
parser.add_argument("--self-test", action="store_true", help="Run rewrite unit tests")
args = parser.parse_args()
if args.self_test:
return self_test()
if not args.to:
parser.error("--to is required")
new = args.to.strip()
old = (args.frm or detect_current_package() or "").strip()
if not old:
print("error: could not determine current package ID", file=sys.stderr)
return 1
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+", new):
print(f"error: invalid package ID: {new}", file=sys.stderr)
return 1
changed = 0
for pattern in TEXT_GLOBS:
for path in APP_DIR.rglob(pattern):
if not path.is_file():
continue
try:
original = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if text_needs_rewrite(original, old, new):
updated = rewrite_text(original, old, new)
updated = fix_sparse_switches(path, updated)
else:
# Still may need sparse-switch refresh after a prior partial rename.
updated = fix_sparse_switches(path, original)
if updated != original:
path.write_text(updated, encoding="utf-8")
changed += 1
if old == new:
print(f"package already {new} ({changed} files normalized)")
else:
print(f"Renamed package {old} -> {new} ({changed} files)")
return 0
if __name__ == "__main__":
sys.exit(main())