mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
114 lines
5.1 KiB
Python
114 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Rebuild an Android expansion OBB from the matching Apktool-decoded tree."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
|
|
def read_no_compress(config: Path) -> tuple[str, list[str]]:
|
|
lines = config.read_text(encoding="utf-8").splitlines()
|
|
name = next((line.split(":", 1)[1].strip().strip("'\"")
|
|
for line in lines if line.startswith("apkFileName:")), None)
|
|
if not name:
|
|
raise ValueError(f"{config} has no apkFileName")
|
|
patterns: list[str] = []
|
|
in_list = False
|
|
for line in lines:
|
|
if line == "doNotCompress:":
|
|
in_list = True
|
|
continue
|
|
if in_list and line.startswith("- "):
|
|
patterns.append(line[2:].strip().strip("'\"").replace("\\", "/"))
|
|
elif in_list and line and not line.startswith(" "):
|
|
break
|
|
if not patterns:
|
|
raise ValueError(f"{config} has no doNotCompress list")
|
|
return name, patterns
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("decoded_obb", help="decoded OBB root containing apktool.yml and assets/")
|
|
parser.add_argument("output", help="path for the rebuilt .obb")
|
|
parser.add_argument("--check-only", action="store_true", help="validate inputs and print a packing summary")
|
|
args = parser.parse_args()
|
|
|
|
root = Path(args.decoded_obb).resolve()
|
|
config = root / "apktool.yml"
|
|
assets = root / "assets"
|
|
if not config.is_file() or not assets.is_dir():
|
|
print("decoded OBB must contain apktool.yml and assets/", file=sys.stderr)
|
|
return 2
|
|
output = Path(args.output).resolve()
|
|
if output.exists() and not args.check_only:
|
|
print(f"refusing to overwrite existing output: {output}", file=sys.stderr)
|
|
return 2
|
|
|
|
try:
|
|
obb_name, patterns = read_no_compress(config)
|
|
files = sorted((path for path in assets.rglob("*") if path.is_file()),
|
|
key=lambda path: path.relative_to(root).as_posix())
|
|
names = {path.relative_to(root).as_posix(): path for path in files}
|
|
exact = {pattern for pattern in patterns if pattern in names}
|
|
extension_rules = {pattern.lower().lstrip(".") for pattern in patterns if "/" not in pattern and pattern not in names}
|
|
unknown = [pattern for pattern in patterns if pattern not in names and pattern not in extension_rules]
|
|
if unknown:
|
|
raise ValueError(f"unmatched doNotCompress entries: {unknown[:8]}")
|
|
stored = {name for name in names if name in exact or
|
|
Path(name).suffix.lower().lstrip(".") in extension_rules}
|
|
matched_extensions = {Path(name).suffix.lower().lstrip(".") for name in names
|
|
if Path(name).suffix.lower().lstrip(".") in extension_rules}
|
|
missing_extensions = sorted(extension_rules - matched_extensions)
|
|
if missing_extensions:
|
|
raise ValueError(f"doNotCompress extensions have no matching file: {missing_extensions}")
|
|
uncompressed_bytes = sum(path.stat().st_size for name, path in names.items() if name in stored)
|
|
total_bytes = sum(path.stat().st_size for path in files)
|
|
if output.name != obb_name:
|
|
raise ValueError(f"output filename must be {obb_name!r}, not {output.name!r}")
|
|
except (OSError, ValueError) as exc:
|
|
print(f"cannot package decoded OBB: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
print(f"source: {root}")
|
|
print(f"output: {output}")
|
|
print(f"entries: {len(files):,}; uncompressed bytes: {total_bytes:,}")
|
|
print(f"stored entries: {len(stored):,}; stored bytes: {uncompressed_bytes:,}; deflated entries: {len(files) - len(stored):,}")
|
|
if args.check_only:
|
|
return 0
|
|
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temporary_name = tempfile.mkstemp(prefix=output.name + ".", suffix=".tmp", dir=output.parent)
|
|
os.close(fd)
|
|
temporary = Path(temporary_name)
|
|
try:
|
|
with zipfile.ZipFile(temporary, "w", allowZip64=True, compression=zipfile.ZIP_DEFLATED,
|
|
compresslevel=6) as archive:
|
|
for path in files:
|
|
name = path.relative_to(root).as_posix()
|
|
archive.write(path, arcname=name,
|
|
compress_type=zipfile.ZIP_STORED if name in stored else zipfile.ZIP_DEFLATED,
|
|
compresslevel=None if name in stored else 6)
|
|
# Validate every CRC and path before publishing the archive at its final name.
|
|
with zipfile.ZipFile(temporary, "r") as archive:
|
|
bad_entry = archive.testzip()
|
|
if bad_entry is not None:
|
|
raise ValueError(f"archive CRC check failed at {bad_entry}")
|
|
if set(archive.namelist()) != set(names):
|
|
raise ValueError("archive entry list differs from the decoded assets tree")
|
|
os.replace(temporary, output)
|
|
except Exception:
|
|
temporary.unlink(missing_ok=True)
|
|
raise
|
|
|
|
print(f"created {output} ({output.stat().st_size:,} bytes); CRC and entry-list checks passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|