diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 3d09a18..2c0a20c 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -60,6 +60,7 @@ jobs: id: get_title run: | TITLE=$(head -n 1 modified/report.log) + tail -n +2 modified/report.log > modified/report.log.tmp echo "title=${TITLE}" >> $GITHUB_OUTPUT - name: Setup go @@ -73,7 +74,7 @@ jobs: uses: https://gitea.com/actions/release-action@main with: title: ${{ steps.get_title.outputs.title }} - body_path: modified/report.log + body_path: modified/report.log.tmp draft: true api_key: '${{secrets.RELEASE_TOKEN}}' files: |- diff --git a/config.json b/config.json index 335c5e7..e038f86 100644 --- a/config.json +++ b/config.json @@ -1,101 +1,12 @@ { + "tools": { + "apktool_jar_url": "https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_2.12.0.jar", + "apktool_wrapper_url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool" + }, "base": { - "tools": { - "apktool_jar_url": "https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_2.12.0.jar", - "apktool_wrapper_url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool" - }, "xml_ns": { "android": "http://schemas.android.com/apk/res/android", "app": "http://schemas.android.com/apk/res-auto" } - }, - "patches": { - "package_name": { - "enabled": true, - "new_package_name": "com.wowlikon.anixart" - }, - "compress": { - "enabled": true, - "remove_language_files": true, - "remove_AI_voiceover": true, - "remove_debug_lines": true, - "remove_drawable_files": false, - "remove_unknown_files": true, - "remove_unknown_files_keep_dirs": ["META-INF", "kotlin"], - "compress_png_files": true - }, - "change_server": { - "enabled": false, - "server": "https://anixarty.wowlikon.tech/modding" - }, - "color_theme": { - "enabled": true, - "colors": { - "primary": "#ccff00", - "secondary": "#ffffd700", - "background": "#ffffff", - "text": "#000000" - }, - "gradient": { - "angle": "135.0", - "from": "#ffff6060", - "to": "#ffccff00" - } - }, - "replace_navbar": { - "enabled": true, - "items": ["home", "discover", "feed", "bookmarks", "profile"] - }, - "custom_speed": { - "enabled": true, - "speeds": [0.0] - }, - "disable_ad": { - "enabled": true - }, - "disable_beta_banner": { - "enabled": true - }, - "insert_new": { - "enabled": true - }, - "settings_urls": { - "enabled": true, - "menu": { - "Мы в социальных сетях": [ - { - "title": "wowlikon", - "description": "Разработчик", - "url": "https://t.me/wowlikon", - "icon": "@drawable/ic_custom_telegram", - "icon_space_reserved": "false" - }, - { - "title": "Kentai Radiquum", - "description": "Разработчик", - "url": "https://t.me/radiquum", - "icon": "@drawable/ic_custom_telegram", - "icon_space_reserved": "false" - }, - { - "title": "Мы в Telegram", - "description": "Подпишитесь на канал, чтобы быть в курсе последних новостей.", - "url": "https://t.me/http_teapod", - "icon": "@drawable/ic_custom_telegram", - "icon_space_reserved": "false" - } - ], - "Прочее": [ - { - "title": "Помочь проекту", - "description": "Вы можете помочь нам в разработке мода, написании кода или тестировании.", - "url": "https://git.wowlikon.tech/anixart-mod", - "icon": "@drawable/ic_custom_crown", - "icon_space_reserved": "false" - } - ] - }, - "version": " by wowlikon" - } } } diff --git a/configs/change_server.json b/configs/change_server.json new file mode 100644 index 0000000..975b334 --- /dev/null +++ b/configs/change_server.json @@ -0,0 +1 @@ +{"enabled":true,"server":"https://anixarty.0x174.su/patch"} \ No newline at end of file diff --git a/configs/color_theme.json b/configs/color_theme.json new file mode 100644 index 0000000..d38eff4 --- /dev/null +++ b/configs/color_theme.json @@ -0,0 +1,17 @@ +{ + "enabled": true, + "logo": { + "gradient": { + "angle": 0.0, + "start_color": "#ffccff00", + "end_color": "#ffcccc00" + }, + "ears_color": "#ffd0d0d0" + }, + "colors": { + "primary": "#ccff00", + "secondary": "#ffcccc00", + "background": "#ffffff", + "text": "#000000" + } +} diff --git a/configs/compress.json b/configs/compress.json new file mode 100644 index 0000000..e6413ef --- /dev/null +++ b/configs/compress.json @@ -0,0 +1 @@ +{"enabled":true,"remove_language_files":true,"remove_AI_voiceover":true,"remove_debug_lines":false,"remove_drawable_files":false,"remove_unknown_files":true,"remove_unknown_files_keep_dirs":["META-INF","kotlin"],"compress_png_files":true} \ No newline at end of file diff --git a/configs/disable_ad.json b/configs/disable_ad.json new file mode 100644 index 0000000..310f75e --- /dev/null +++ b/configs/disable_ad.json @@ -0,0 +1 @@ +{"enabled":true} \ No newline at end of file diff --git a/configs/disable_beta_banner.json b/configs/disable_beta_banner.json new file mode 100644 index 0000000..310f75e --- /dev/null +++ b/configs/disable_beta_banner.json @@ -0,0 +1 @@ +{"enabled":true} \ No newline at end of file diff --git a/configs/insert_new.json b/configs/insert_new.json new file mode 100644 index 0000000..310f75e --- /dev/null +++ b/configs/insert_new.json @@ -0,0 +1 @@ +{"enabled":true} \ No newline at end of file diff --git a/configs/package_name.json b/configs/package_name.json new file mode 100644 index 0000000..4ecb05a --- /dev/null +++ b/configs/package_name.json @@ -0,0 +1 @@ +{"enabled":true,"package_name":"com.wowlikon.anixart"} \ No newline at end of file diff --git a/configs/replace_navbar.json b/configs/replace_navbar.json new file mode 100644 index 0000000..c055d4f --- /dev/null +++ b/configs/replace_navbar.json @@ -0,0 +1 @@ +{"enabled":true,"items":["home","discover","feed","bookmarks","profile"]} \ No newline at end of file diff --git a/configs/settings_urls.json b/configs/settings_urls.json new file mode 100644 index 0000000..bab4f7a --- /dev/null +++ b/configs/settings_urls.json @@ -0,0 +1 @@ +{"enabled":true,"version":" by wowlikon","menu":{"Мы в социальных сетях":[{"title":"wowlikon","description":"Разработчик","url":"https://t.me/wowlikon","icon":"@drawable/ic_custom_telegram","icon_space_reserved":"false"},{"title":"Kentai Radiquum","description":"Разработчик","url":"https://t.me/radiquum","icon":"@drawable/ic_custom_telegram","icon_space_reserved":"false"},{"title":"Мы в Telegram","description":"Подпишитесь на канал, чтобы быть в курсе последних новостей.","url":"https://t.me/http_teapod","icon":"@drawable/ic_custom_telegram","icon_space_reserved":"false"}],"Прочее":[{"title":"Помочь проекту","description":"Вы можете помочь нам в разработке мода, написании кода или тестировании.","url":"https://git.wowlikon.tech/anixart-mod","icon":"@drawable/ic_custom_crown","icon_space_reserved":"false"}]}} \ No newline at end of file diff --git a/main.py b/main.py index 3155c3a..970c5ef 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,4 @@ -from pathlib import Path -from typing import List +from typing import List, Dict, Any import httpx import typer @@ -14,80 +13,36 @@ from rich.progress import Progress from rich.prompt import Prompt from rich.table import Table +from utils.config import * +from utils.tools import * + # --- Paths --- -TOOLS = Path("tools") -ORIGINAL = Path("original") -MODIFIED = Path("modified") -DECOMPILED = Path("decompiled") -PATCHES = Path("patches") console = Console() app = typer.Typer() -# ======================= CONFIG ========================= -class ToolsConfig(BaseModel): - apktool_jar_url: str - apktool_wrapper_url: str +# ======================= PATCHING ========================= +class Patch: + def __init__(self, name: str, module): + self.name = name + self.module = module + self.applied = False + self.priority = getattr(module, "priority", 0) + try: + self.config = module.Config.model_validate_json((CONFIGS / f"{name}.json").read_text()) + except Exception as e: + console.print(f"[red]Ошибка при загрузке конфигурации патча {name}: {e}") + self.config = module.Config() - -class XmlNamespaces(BaseModel): - android: str - app: str - - -class BaseSection(BaseModel): - tools: ToolsConfig - xml_ns: XmlNamespaces - - -class Config(BaseModel): - base: BaseSection - patches: dict - - -def load_config() -> Config: - try: - return Config.model_validate_json(Path("config.json").read_text()) - except FileNotFoundError: - console.print("[red]Файл config.json не найден") - raise typer.Exit(1) - except ValidationError as e: - console.print("[red]Ошибка валидации config.json:", e) - raise typer.Exit(1) - - -# ======================= UTILS ========================= -def ensure_dirs(): - for d in [TOOLS, ORIGINAL, MODIFIED, DECOMPILED, PATCHES]: - d.mkdir(exist_ok=True) - - -def run(cmd: List[str], hide_output=True): - prog = local[cmd[0]][cmd[1:]] - try: - prog() if hide_output else prog & FG - except ProcessExecutionError as e: - console.print(f"[red]Ошибка при выполнении команды: {' '.join(cmd)}") - console.print(e.stderr) - raise typer.Exit(1) - - -def download(url: str, dest: Path): - console.print(f"[cyan]Скачивание {url} → {dest.name}") - - with httpx.Client(follow_redirects=True, timeout=60.0) as client: - with client.stream("GET", url) as response: - response.raise_for_status() - total = int(response.headers.get("Content-Length", 0)) - - dest.parent.mkdir(parents=True, exist_ok=True) - - with open(dest, "wb") as f, Progress(console=console) as progress: - task = progress.add_task("Загрузка", total=total if total else None) - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - progress.update(task, advance=len(chunk)) + def apply(self, conf: Dict[str, Any]) -> bool: + try: + self.applied = bool(self.module.apply(self.config, conf)) + return self.applied + except Exception as e: + console.print(f"[red]Ошибка в патче {self.name}: {e}") + traceback.print_exc() + return False # ======================= INIT ========================= @@ -95,13 +50,20 @@ def download(url: str, dest: Path): def init(): """Создание директорий и скачивание инструментов""" ensure_dirs() - conf = load_config() + conf = load_config(console) + + for f in PATCHES.glob("*.py"): + if f.name.startswith("todo_") or f.name == "__init__.py": + continue + patch = Patch(f.stem, __import__(f"patches.{f.stem}", fromlist=[""])) + json_string = patch.config.model_dump_json() + (CONFIGS / f"{patch.name}.json").write_text(json_string) if not (TOOLS / "apktool.jar").exists(): - download(conf.base.tools.apktool_jar_url, TOOLS / "apktool.jar") + download(console, conf.tools.apktool_jar_url, TOOLS / "apktool.jar") if not (TOOLS / "apktool").exists(): - download(conf.base.tools.apktool_wrapper_url, TOOLS / "apktool") + download(console, conf.tools.apktool_wrapper_url, TOOLS / "apktool") (TOOLS / "apktool").chmod(0o755) try: @@ -116,9 +78,9 @@ def init(): @app.command() def info(patch_name: str = ""): """Вывод информации о патче""" - conf = load_config().model_dump() + conf = load_config(console).model_dump() if patch_name: - patch = Patch(patch_name, __import__(f"patches.{patch_name}", fromlist=[''])) + patch = Patch(patch_name, __import__(f"patches.{patch_name}", fromlist=[""])) console.print(f"[green]Информация о патче {patch.name}:") console.print(f" [yellow]Приоритет: {patch.priority}") console.print(f" [yellow]Описание: {patch.module.__doc__}") @@ -128,30 +90,12 @@ def info(patch_name: str = ""): if f.name.startswith("todo_") or f.name == "__init__.py": continue name = f.stem - if conf['patches'].get(name,{}).get('enabled',True): + if conf["patches"].get(name, {}).get("enabled", True): console.print(f" [yellow]{name}: [green]✔ enabled") else: console.print(f" [yellow]{name}: [red]✘ disabled") -# ======================= PATCHING ========================= -class Patch: - def __init__(self, name: str, module): - self.name = name - self.module = module - self.applied = False - self.priority = getattr(module, "priority", 0) - - def apply(self, conf: dict) -> bool: - try: - self.applied = bool(self.module.apply(conf)) - return self.applied - except Exception as e: - console.print(f"[red]Ошибка в патче {self.name}: {e}") - traceback.print_exc() - return False - - def select_apk() -> Path: apks = [f for f in ORIGINAL.glob("*.apk")] if not apks: @@ -173,14 +117,12 @@ def select_apk() -> Path: def decompile(apk: Path): console.print("[yellow]Декомпиляция apk...") run( + console, [ "java", - "-jar", - str(TOOLS / "apktool.jar"), - "d", - "-f", - "-o", - str(DECOMPILED), + "-jar", str(TOOLS / "apktool.jar"), + "d", "-f", + "-o", str(DECOMPILED), str(apk), ] ) @@ -188,52 +130,51 @@ def decompile(apk: Path): def compile(apk: Path, patches: List[Patch]): console.print("[yellow]Сборка apk...") - out_apk = MODIFIED / apk.name + + with open(DECOMPILED / "apktool.yml", encoding="utf-8") as f: + meta = yaml.safe_load(f) + version_info = meta.get("versionInfo", {}) + version_code = version_info.get("versionCode", 0) + version_name = version_info.get("versionName", "unknown") + + filename_version = version_name.lower().replace(" ", "-").replace(".", "-") + out_apk = MODIFIED / f"Anixarty-mod-v{filename_version}.apk" aligned = out_apk.with_stem(out_apk.stem + "-aligned") signed = out_apk.with_stem(out_apk.stem + "-mod") run( + console, [ "java", - "-jar", - str(TOOLS / "apktool.jar"), - "b", - str(DECOMPILED), - "-o", - str(out_apk), + "-jar", str(TOOLS / "apktool.jar"), + "b", str(DECOMPILED), + "-o", str(out_apk), ] ) - run(["zipalign", "-v", "4", str(out_apk), str(aligned)]) run( + console, + ["zipalign", "-v", "4", str(out_apk), str(aligned)] + ) + run( + console, [ - "apksigner", - "sign", - "--v1-signing-enabled", - "false", - "--v2-signing-enabled", - "true", - "--v3-signing-enabled", - "true", - "--ks", - "keystore.jks", - "--ks-pass", - "file:keystore.pass", - "--out", - str(signed), + "apksigner", "sign", + "--v1-signing-enabled", "false", + "--v2-signing-enabled", "true", + "--v3-signing-enabled", "true", + "--ks", "keystore.jks", + "--ks-pass", "file:keystore.pass", + "--out", str(signed), str(aligned), ] ) - with open(DECOMPILED / "apktool.yml", encoding="utf-8") as f: - meta = yaml.safe_load(f) - version_str = " ".join( - f"{k}:{v}" for k, v in meta.get("versionInfo", {}).items() - ) + console.print("[green]✔ APK успешно собран и подписан") with open(MODIFIED / "report.log", "w", encoding="utf-8") as f: - f.write(f"anixart mod {version_str}\n") + f.write(f"Anixarty mod v {version_name} ({version_code})\n") for p in patches: - f.write(f"{p.name}: {'applied' if p.applied else 'failed'}\n") + f.write(f"{'✔' if p.applied else '✘'} {p.name}\n") @app.command() @@ -242,22 +183,21 @@ def build( verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), ): """Декомпиляция, патчи и сборка apk""" - conf = load_config().model_dump() + conf = load_config(console) apk = select_apk() decompile(apk) - patch_settings = conf.get("patches", {}) patch_objs: List[Patch] = [] + conf.base |= {"verbose": verbose} for f in PATCHES.glob("*.py"): if f.name.startswith("todo_") or f.name == "__init__.py": continue name = f.stem - settings = patch_settings.get(name, {}) - if not settings.get("enabled", True): + module = importlib.import_module(f"patches.{name}") + if not module.Config.model_validate_json((CONFIGS / f"{name}.json").read_text()): console.print(f"[yellow]≫ Пропускаем {name}") continue - module = importlib.import_module(f"patches.{name}") patch_objs.append(Patch(name, module)) patch_objs.sort(key=lambda p: p.priority, reverse=True) @@ -266,7 +206,7 @@ def build( with Progress() as progress: task = progress.add_task("Патчи", total=len(patch_objs)) for p in patch_objs: - ok = p.apply(patch_settings.get(p.name, {}) | conf.get("base", {})) + ok = p.apply(conf.base) progress.console.print(f"{'✔' if ok else '✘'} {p.name}") progress.advance(task) diff --git a/patches/change_server.py b/patches/change_server.py index 045ad2c..668dff9 100644 --- a/patches/change_server.py +++ b/patches/change_server.py @@ -3,7 +3,7 @@ "change_server": { "enabled": true, - "server": "https://anixarty.wowlikon.tech/modding" + "server": "https://anixarty.0x174.su/patch" } """ @@ -13,11 +13,18 @@ priority = 0 import json import requests from tqdm import tqdm +from typing import Dict, Any +from pydantic import Field +from utils.config import PatchConfig + +#Config +class Config(PatchConfig): + server: str = Field("https://anixarty.0x174.su/patch", description="URL сервера") # Patch -def apply(config: dict) -> bool: - response = requests.get(config['server']) +def apply(config: Config, base: Dict[str, Any]) -> bool: + response = requests.get(config.server) assert response.status_code == 200, f"Failed to fetch data {response.status_code} {response.text}" new_api = json.loads(response.text) diff --git a/patches/color_theme.py b/patches/color_theme.py index f763ccd..9add593 100644 --- a/patches/color_theme.py +++ b/patches/color_theme.py @@ -3,16 +3,19 @@ "color_theme": { "enabled": true, + "logo": { + "gradient": { + "angle": 0.0, + "start_color": "#ffccff00", + "end_color": "#ffcccc00" + }, + "ears_color": "#ffffd0d0" + }, "colors": { "primary": "#ccff00", - "secondary": "#ffffd700", + "secondary": "#ffcccc00", "background": "#ffffff", "text": "#000000" - }, - "gradient": { - "angle": "135.0", - "from": "#ffff6060", - "to": "#ffccff00" } } """ @@ -21,20 +24,40 @@ priority = 0 # imports from lxml import etree +from typing import Dict, Any +from pydantic import Field, BaseModel + +from utils.config import PatchConfig from utils.public import ( insert_after_public, insert_after_color, change_color, ) +#Config +class Gradient(BaseModel): + angle: float = Field(0.0, description="Угол градиента") + start_color: str = Field("#ffccff00", description="Начальный цвет градиента") + end_color: str = Field("#ffcccc00", description="Конечный цвет градиента") + +class Logo(BaseModel): + gradient: Gradient = Field(Gradient(), description="Настройки градиента") # type: ignore [reportCallIssue] + ears_color: str = Field("#ffd0d0d0", description="Цвет ушей логотипа") + +class Colors(BaseModel): + primary: str = Field("#ccff00", description="Основной цвет") + secondary: str = Field("#ffcccc00", description="Вторичный цвет") + background: str = Field("#ffffff", description="Фоновый цвет") + text: str = Field("#000000", description="Цвет текста") + +class Config(PatchConfig): + logo: Logo = Field(Logo(), description="Настройки цветов логотипа") # type: ignore [reportCallIssue] + colors: Colors = Field(Colors(), description="Настройки цветов") # type: ignore [reportCallIssue] # Patch -def apply(config: dict) -> bool: - main_color = config["colors"]["primary"] - splash_color = config["colors"]["secondary"] - gradient_angle = config["gradient"]["angle"] - gradient_from = config["gradient"]["from"] - gradient_to = config["gradient"]["to"] +def apply(config: Config, base: Dict[str, Any]) -> bool: + main_color = config.colors.primary + splash_color = config.colors.secondary # No connection alert coolor with open("./decompiled/assets/no_connection.html", "r", encoding="utf-8") as file: @@ -57,9 +80,9 @@ def apply(config: dict) -> bool: root = tree.getroot() # Change attributes with namespace - root.set(f"{{{config['xml_ns']['android']}}}angle", gradient_angle) - root.set(f"{{{config['xml_ns']['android']}}}startColor", gradient_from) - root.set(f"{{{config['xml_ns']['android']}}}endColor", gradient_to) + root.set(f"{{{base['xml_ns']['android']}}}angle", str(config.logo.gradient.angle)) + root.set(f"{{{base['xml_ns']['android']}}}startColor", config.logo.gradient.start_color) + root.set(f"{{{base['xml_ns']['android']}}}endColor", config.logo.gradient.end_color) # Save back tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") @@ -72,10 +95,12 @@ def apply(config: dict) -> bool: root = tree.getroot() # Finding "path" - for el in root.findall("path", namespaces=config["xml_ns"]): - name = el.get(f"{{{config['xml_ns']['android']}}}name") + for el in root.findall("path", namespaces=base["xml_ns"]): + name = el.get(f"{{{base['xml_ns']['android']}}}name") if name == "path": - el.set(f"{{{config['xml_ns']['android']}}}fillColor", splash_color) + el.set(f"{{{base['xml_ns']['android']}}}fillColor", config.colors.secondary) + elif name in ["path_1", "path_2"]: + el.set(f"{{{base['xml_ns']['android']}}}fillColor", config.logo.ears_color) # Save back tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") @@ -88,11 +113,11 @@ def apply(config: dict) -> bool: root = tree.getroot() # Change attributes with namespace - root.set(f"{{{config['xml_ns']['android']}}}angle", gradient_angle) - items = root.findall("item", namespaces=config['xml_ns']) + root.set(f"{{{base['xml_ns']['android']}}}angle", str(config.logo.gradient.angle)) + items = root.findall("item", namespaces=base['xml_ns']) assert len(items) == 2 - items[0].set(f"{{{config['xml_ns']['android']}}}color", gradient_from) - items[1].set(f"{{{config['xml_ns']['android']}}}color", gradient_to) + items[0].set(f"{{{base['xml_ns']['android']}}}color", config.logo.gradient.start_color) + items[1].set(f"{{{base['xml_ns']['android']}}}color", config.logo.gradient.end_color) # Save back tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") diff --git a/patches/compress.py b/patches/compress.py index 0ea85a5..a534f6e 100644 --- a/patches/compress.py +++ b/patches/compress.py @@ -34,28 +34,41 @@ import os import shutil import subprocess from tqdm import tqdm +from typing import Dict, List, Any +from pydantic import Field + +from utils.config import PatchConfig from utils.smali_parser import get_smali_lines, save_smali_lines +#Config +class Config(PatchConfig): + remove_language_files: bool = Field(True, description="Удаляет все языки кроме русского и английского") + remove_AI_voiceover: bool = Field(True, description="Заменяет ИИ озвучку маскота аниксы пустыми mp3 файлами") + remove_debug_lines: bool = Field(False, description="Удаляет строки `.line n` из smali файлов использованные для дебага") + remove_drawable_files: bool = Field(False, description="Удаляет неиспользованные drawable-* из директории decompiled/res") + remove_unknown_files: bool = Field(True, description="Удаляет файлы из директории decompiled/unknown") + remove_unknown_files_keep_dirs: List[str] = Field(["META-INF", "kotlin"], description="Оставляет указанные директории в decompiled/unknown") + compress_png_files: bool = Field(True, description="Сжимает PNG в директории decompiled/res") # Patch -def remove_unknown_files(config): +def remove_unknown_files(config: Config, base: Dict[str, Any]): path = "./decompiled/unknown" items = os.listdir(path) for item in items: item_path = f"{path}/{item}" if os.path.isfile(item_path): os.remove(item_path) - if config.get("verbose", False): + if base.get("verbose", False): tqdm.write(f"Удалён файл: {item_path}") elif os.path.isdir(item_path): - if item not in config["remove_unknown_files_keep_dirs"]: + if item not in config.remove_unknown_files_keep_dirs: shutil.rmtree(item_path) - if config.get("verbose", False): + if base.get("verbose", False): tqdm.write(f"Удалёна директория: {item_path}") return True -def remove_debug_lines(config): +def remove_debug_lines(config: Dict[str, Any]): for root, _, files in os.walk("./decompiled"): for filename in files: file_path = os.path.join(root, filename) @@ -72,14 +85,13 @@ def remove_debug_lines(config): return True -def compress_png(config, png_path: str): +def compress_png(config: Dict[str, Any], png_path: str): try: assert subprocess.run( [ "pngquant", "--force", - "--ext", - ".png", + "--ext", ".png", "--quality=65-90", png_path, ], @@ -93,7 +105,7 @@ def compress_png(config, png_path: str): return False -def compress_png_files(config): +def compress_png_files(config: Dict[str, Any]): compressed = [] for root, _, files in os.walk("./decompiled"): for file in files: @@ -103,7 +115,7 @@ def compress_png_files(config): return len(compressed) > 0 and any(compressed) -def remove_AI_voiceover(config): +def remove_AI_voiceover(config: Dict[str, Any]): blank = "./resources/blank.mp3" path = "./decompiled/res/raw" files = [ @@ -135,7 +147,7 @@ def remove_AI_voiceover(config): return True -def remove_language_files(config): +def remove_language_files(config: Dict[str, Any]): path = "./decompiled/res" folders = [ "values-af", @@ -236,7 +248,7 @@ def remove_language_files(config): return True -def remove_drawable_files(config): +def remove_drawable_files(config: Dict[str, Any]): path = "./decompiled/res" folders = [ "drawable-en-hdpi", @@ -269,29 +281,29 @@ def remove_drawable_files(config): return True -def apply(config) -> bool: - if config["remove_unknown_files"]: +def apply(config: Config, base: Dict[str, Any]) -> bool: + if config.remove_unknown_files: tqdm.write(f"Удаление неизвестных файлов...") - remove_unknown_files(config) + remove_unknown_files(config, base) - if config["remove_drawable_files"]: + if config.remove_drawable_files: tqdm.write(f"Удаление директорий drawable-xx...") - remove_drawable_files(config) + remove_drawable_files(base) - if config["compress_png_files"]: + if config.compress_png_files: tqdm.write(f"Сжатие PNG файлов...") - compress_png_files(config) + compress_png_files(base) - if config["remove_language_files"]: + if config.remove_language_files: tqdm.write(f"Удаление языков...") - remove_language_files(config) + remove_language_files(base) - if config["remove_AI_voiceover"]: + if config.remove_AI_voiceover: tqdm.write(f"Удаление ИИ озвучки...") - remove_AI_voiceover(config) + remove_AI_voiceover(base) - if config["remove_debug_lines"]: + if config.remove_debug_lines: tqdm.write(f"Удаление дебаг линий...") - remove_debug_lines(config) + remove_debug_lines(base) return True diff --git a/patches/disable_ad.py b/patches/disable_ad.py index 14cee1b..69f9a37 100644 --- a/patches/disable_ad.py +++ b/patches/disable_ad.py @@ -10,6 +10,10 @@ priority = 0 # imports import textwrap +from tqdm import tqdm +from typing import Dict, Any + +from utils.config import PatchConfig from utils.smali_parser import ( find_smali_method_end, find_smali_method_start, @@ -18,13 +22,17 @@ from utils.smali_parser import ( ) +#Config +class Config(PatchConfig): ... + + # Patch -def apply(config) -> bool: - replacement = textwrap.dedent("""\ +def apply(config: Config, base: Dict[str, Any]) -> bool: + replacement = [f'\t{line}\n' for line in textwrap.dedent("""\ .locals 0 const/4 p0, 0x1 return p0 - """).splitlines() + """).splitlines()] path = "./decompiled/smali_classes2/com/swiftsoft/anixartd/Prefs.smali" lines = get_smali_lines(path) diff --git a/patches/disable_beta_banner.py b/patches/disable_beta_banner.py index 6c3205f..9ab00d9 100644 --- a/patches/disable_beta_banner.py +++ b/patches/disable_beta_banner.py @@ -12,10 +12,16 @@ priority = 0 import os from tqdm import tqdm from lxml import etree +from typing import Dict, Any +from utils.config import PatchConfig +from utils.smali_parser import get_smali_lines, save_smali_lines + +#Config +class Config(PatchConfig): ... # Patch -def apply(config) -> bool: +def apply(config: Config, base: Dict[str, Any]) -> bool: attributes = [ "paddingTop", "paddingBottom", @@ -36,9 +42,9 @@ def apply(config) -> bool: root = tree.getroot() for attr in attributes: - if config["verbose"]: + if base.get("verbose", False): tqdm.write(f"set {attr} = 0.0dip") - root.set(f"{{{config["xml_ns"]['android']}}}{attr}", "0.0dip") + root.set(f"{{{base['xml_ns']['android']}}}{attr}", "0.0dip") tree.write( beta_banner_xml, pretty_print=True, xml_declaration=True, encoding="utf-8" diff --git a/patches/insert_new.py b/patches/insert_new.py index eddfc1e..552111f 100644 --- a/patches/insert_new.py +++ b/patches/insert_new.py @@ -11,11 +11,16 @@ priority = 0 # imports import os import shutil +from typing import Dict, Any +from utils.config import PatchConfig from utils.public import insert_after_public +#Config +class Config(PatchConfig): ... + # Patch -def apply(config: dict) -> bool: +def apply(config: Config, base: Dict[str, Any]) -> bool: # Mod first launch window shutil.copy("./resources/avatar.png", "./decompiled/assets/avatar.png") shutil.copy( diff --git a/patches/package_name.py b/patches/package_name.py index 1922cc0..4e33a0b 100644 --- a/patches/package_name.py +++ b/patches/package_name.py @@ -12,7 +12,15 @@ priority = -1 # imports import os from lxml import etree +from tqdm import tqdm +from typing import Dict, Any +from pydantic import Field +from utils.config import PatchConfig + +#Config +class Config(PatchConfig): + package_name: str = Field("com.wowlikon.anixart", description="Название пакета") # Patch def rename_dir(src, dst): @@ -20,7 +28,7 @@ def rename_dir(src, dst): os.rename(src, dst) -def apply(config: dict) -> bool: +def apply(config: Config, base: Dict[str, Any]) -> bool: for root, dirs, files in os.walk("./decompiled"): for filename in files: file_path = os.path.join(root, filename) @@ -31,11 +39,11 @@ def apply(config: dict) -> bool: file_contents = file.read() new_contents = file_contents.replace( - "com.swiftsoft.anixartd", config["new_package_name"] + "com.swiftsoft.anixartd", config.package_name ) new_contents = new_contents.replace( "com/swiftsoft/anixartd", - config["new_package_name"].replace(".", "/"), + config.package_name.replace(".", "/"), ) with open(file_path, "w", encoding="utf-8") as file: file.write(new_contents) @@ -46,7 +54,7 @@ def apply(config: dict) -> bool: rename_dir( "./decompiled/smali/com/swiftsoft/anixartd", os.path.join( - "./decompiled", "smali", config["new_package_name"].replace(".", "/") + "./decompiled", "smali", config.package_name.replace(".", "/") ), ) if os.path.exists("./decompiled/smali_classes2/com/swiftsoft/anixartd"): @@ -55,7 +63,7 @@ def apply(config: dict) -> bool: os.path.join( "./decompiled", "smali_classes2", - config["new_package_name"].replace(".", "/"), + config.package_name.replace(".", "/"), ), ) if os.path.exists("./decompiled/smali_classes4/com/swiftsoft"): @@ -64,7 +72,7 @@ def apply(config: dict) -> bool: os.path.join( "./decompiled", "smali_classes4", - "/".join(config["new_package_name"].split(".")[:-1]), + "/".join(config.package_name.split(".")[:-1]), ), ) @@ -88,7 +96,7 @@ def apply(config: dict) -> bool: new_contents = file_contents.replace( "com/swiftsoft", - "/".join(config["new_package_name"].split(".")[:-1]), + "/".join(config.package_name.split(".")[:-1]), ) with open(file_path, "w", encoding="utf-8") as file: file.write(new_contents) @@ -101,11 +109,8 @@ def apply(config: dict) -> bool: root = tree.getroot() last_linear = root.xpath("//LinearLayout/LinearLayout[4]")[0] - last_linear.set(f"{{{config['xml_ns']['android']}}}visibility", "gone") + last_linear.set(f"{{{base['xml_ns']['android']}}}visibility", "gone") tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") return True - - -# smali_classes2/com/wowlikon/anixart/utils/DeviceInfoUtil.smali: const-string v3, "\u0411\u0430\u0433-\u0440\u0435\u043f\u043e\u0440\u0442 9.0 BETA 5 (25062213)" diff --git a/patches/replace_navbar.py b/patches/replace_navbar.py index 9a8f14f..8d3f1be 100644 --- a/patches/replace_navbar.py +++ b/patches/replace_navbar.py @@ -12,36 +12,43 @@ priority = 0 # imports from lxml import etree from tqdm import tqdm +from typing import Dict, List, Any +from pydantic import Field +from utils.config import PatchConfig + +#Config +class Config(PatchConfig): + items: List[str] = Field(["home", "discover", "feed", "bookmarks", "profile"], description="Список элементов в панели навигации") # Patch -def apply(config: dict) -> bool: +def apply(config: Config, base: Dict[str, Any]) -> bool: file_path = "./decompiled/res/menu/bottom.xml" parser = etree.XMLParser(remove_blank_text=True) tree = etree.parse(file_path, parser) root = tree.getroot() - items = root.findall("item", namespaces=config['xml_ns']) + items = root.findall("item", namespaces=base['xml_ns']) def get_id_suffix(item): - full_id = item.get(f"{{{config['xml_ns']['android']}}}id") + full_id = item.get(f"{{{base['xml_ns']['android']}}}id") return full_id.split("tab_")[-1] if full_id else None items_by_id = {get_id_suffix(item): item for item in items} existing_order = [get_id_suffix(item) for item in items] ordered_items = [] - for key in config['items']: + for key in config.items: if key in items_by_id: ordered_items.append(items_by_id[key]) - extra = [i for i in items if get_id_suffix(i) not in config['items']] + extra = [i for i in items if get_id_suffix(i) not in config.items] if extra: tqdm.write("⚠Найдены лишние элементы: " + str([get_id_suffix(i) for i in extra])) ordered_items.extend(extra) - for i in root.findall("item", namespaces=config['xml_ns']): + for i in root.findall("item", namespaces=base['xml_ns']): root.remove(i) for item in ordered_items: diff --git a/patches/settings_urls.py b/patches/settings_urls.py index 66ec52f..5f6dfd4 100644 --- a/patches/settings_urls.py +++ b/patches/settings_urls.py @@ -26,8 +26,52 @@ priority = 0 # imports import shutil from lxml import etree +from tqdm import tqdm +from typing import Dict, List, Any +from pydantic import Field + +from utils.config import PatchConfig from utils.public import insert_after_public +#Config +DEFAULT_MENU = { + "Мы в социальных сетях": [ + { + "title": "wowlikon", + "description": "Разработчик", + "url": "https://t.me/wowlikon", + "icon": "@drawable/ic_custom_telegram", + "icon_space_reserved": "false" + }, + { + "title": "Kentai Radiquum", + "description": "Разработчик", + "url": "https://t.me/radiquum", + "icon": "@drawable/ic_custom_telegram", + "icon_space_reserved": "false" + }, + { + "title": "Мы в Telegram", + "description": "Подпишитесь на канал, чтобы быть в курсе последних новостей.", + "url": "https://t.me/http_teapod", + "icon": "@drawable/ic_custom_telegram", + "icon_space_reserved": "false" + } + ], + "Прочее": [ + { + "title": "Помочь проекту", + "description": "Вы можете помочь нам в разработке мода, написании кода или тестировании.", + "url": "https://git.wowlikon.tech/anixart-mod", + "icon": "@drawable/ic_custom_crown", + "icon_space_reserved": "false" + } + ] +} + +class Config(PatchConfig): + version: str = Field(" by wowlikon", description="Суффикс версии") + menu: Dict[str, List[Dict[str, str]]] = Field(DEFAULT_MENU, description="Меню") # Patch def make_category(ns, name, items): @@ -49,7 +93,7 @@ def make_category(ns, name, items): return cat -def apply(config: dict) -> bool: +def apply(config: Config, base: Dict[str, Any]) -> bool: shutil.copy( "./resources/ic_custom_crown.xml", "./decompiled/res/drawable/ic_custom_crown.xml", @@ -70,8 +114,8 @@ def apply(config: dict) -> bool: # Insert new PreferenceCategory before the last element last = root[-1] # last element pos = root.index(last) - for section, items in config["menu"].items(): - root.insert(pos, make_category(config["xml_ns"], section, items)) + for section, items in config.menu.items(): + root.insert(pos, make_category(base["xml_ns"], section, items)) pos += 1 # Save back @@ -86,7 +130,7 @@ def apply(config: dict) -> bool: with open(filepath, "r", encoding="utf-8") as file: for line in file.readlines(): if '"\u0412\u0435\u0440\u0441\u0438\u044f'.encode("unicode_escape").decode() in line: - content += line[:line.rindex('"')] + config["version"] + line[line.rindex('"'):] + content += line[:line.rindex('"')] + config.version + line[line.rindex('"'):] else: content += line with open(filepath, "w", encoding="utf-8") as file: diff --git a/patches/todo_custom_speed.py b/patches/todo_custom_speed.py index 7bd9ece..84859b8 100644 --- a/patches/todo_custom_speed.py +++ b/patches/todo_custom_speed.py @@ -10,19 +10,28 @@ priority = 0 # imports +from tqdm import tqdm +from typing import Dict, List, Any +from pydantic import Field + +from utils.config import PatchConfig from utils.smali_parser import float_to_hex from utils.public import ( insert_after_public, insert_after_id, ) +#Config +class Config(PatchConfig): + speeds: List[float] = Field([9.0], description="Список пользовательских скоростей воспроизведения") + # Patch -def apply(config: dict) -> bool: +def apply(config: Config, base: Dict[str, Any]) -> bool: assert float_to_hex(1.5) == "0x3fc00000" last = "speed75" - for speed in config.get("speeds", []): + for speed in config.speeds: insert_after_public(last, f"speed{int(float(speed)*10)}") insert_after_id(last, f"speed{int(float(speed)*10)}") last = f"speed{int(float(speed)*10)}" diff --git a/patches/todo_example.py b/patches/todo_example.py index dcc659d..d7922f6 100644 --- a/patches/todo_example.py +++ b/patches/todo_example.py @@ -5,22 +5,24 @@ Каждый патч должен независеть от других патчей и проверять себя при применении. Он не должен вернуть True, если есть проблемы. На данный момент каждый патч должен иметь функцию `apply`, которая принимает на вход конфигурацию и возвращает True или False. +И модель `Config`, которая наследуется от `PatchConfig` (поле `enabled` добавлять не нужно). +Это позволяет упростить конфигурирование и проверять типы данных, и делать значения по умолчанию. При успешном применении патча, функция apply должна вернуть True, иначе False. Ошибка будет интерпретирована как False. С выводом ошибки в консоль. Ещё патч должен иметь переменную `priority`, которая указывает приоритет патча, чем выше, тем раньше он будет применен. -Коротко о конфигурации. Она получается из `config.json` из config["patches"][patch_name]. -Дополнительно в основном методе `apply` кроме этого есть "verbose" и содержимое "base". -Эти поля передаются всем патчам. `verbose` может быть указан так-же как флаг запуска: +Коротко о конфигурации. Она состоит из двух частей `config` (на основе модели `Config` и из файла `configs/название_патча.json`). +И постоянной не типизированной переменной `base` из `config.json` и флага `verbose`. ``` python ./main.py build --verbose ``` -В конце файла должно быть описание конфигурации патча. +В конце docstring может быть дополнительное описание конфигурации патча (основное описание получается из модели `Config`). Это может быть как короткий фрагмент из названия патча и одной опции "enabled", которая обрабатывается в коде патчера. "todo_template": { - "enabled": true // Пример описания тк этот текст просто пример + "enabled": true, // Пример описания тк этот текст просто пример + "example": true // Пример кастомного параметра } """ @@ -28,11 +30,20 @@ priority = 0 # Приоритет патча, чем выше, тем раньш # imports from tqdm import tqdm +from typing import Dict, List, Any +from pydantic import Field + +from utils.config import PatchConfig + +#Config +class Config(PatchConfig): + example: bool = Field(True, description="Пример кастомного параметра") # Patch -def apply(config: dict) -> bool: # Анотации типов для удобства, читаемости и поддержки IDE +def apply(config: Config, base: Dict[str, Any]) -> bool: # Анотации типов для удобства, читаемости и поддержки IDE tqdm.write("Вывод информации через tqdm, чтобы не мешать прогресс-бару") - if config["verbose"]: + tqdm.write("Пример включен" if config.example else "Пример отключен") + if base["verbose"]: tqdm.write("Для вывода подробной и отладочной информации используйте флаг --verbose") return True diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000..2488f2c --- /dev/null +++ b/utils/config.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field, ValidationError +from rich.console import Console +from typing import Dict, Any +from pathlib import Path +import typer + + +class ToolsConfig(BaseModel): + apktool_jar_url: str + apktool_wrapper_url: str + + +class Config(BaseModel): + tools: ToolsConfig + base: Dict[str, Any] + + +class PatchConfig(BaseModel): + enabled: bool = Field(True, description="Включить или отключить патч") + + +def load_config(console: Console) -> Config: + try: + return Config.model_validate_json(Path("config.json").read_text()) + except FileNotFoundError: + console.print("[red]Файл config.json не найден") + raise typer.Exit(1) + except ValidationError as e: + console.print("[red]Ошибка валидации config.json:", e) + raise typer.Exit(1) diff --git a/utils/tools.py b/utils/tools.py new file mode 100644 index 0000000..9731e23 --- /dev/null +++ b/utils/tools.py @@ -0,0 +1,45 @@ +from plumbum import local, ProcessExecutionError +from rich.progress import Progress +from rich.console import Console +from pathlib import Path +from typing import List +import httpx +import typer + + +TOOLS = Path("tools") +ORIGINAL = Path("original") +MODIFIED = Path("modified") +DECOMPILED = Path("decompiled") +PATCHES = Path("patches") +CONFIGS = Path("configs") + + +def ensure_dirs(): + for d in [TOOLS, ORIGINAL, MODIFIED, DECOMPILED, PATCHES, CONFIGS]: + d.mkdir(exist_ok=True) + +def run(console: Console, cmd: List[str], hide_output=True): + prog = local[cmd[0]][cmd[1:]] + try: + prog() if hide_output else prog & FG # type: ignore [reportUndefinedVariable] + except ProcessExecutionError as e: + console.print(f"[red]Ошибка при выполнении команды: {' '.join(cmd)}") + console.print(e.stderr) + raise typer.Exit(1) + +def download(console: Console, url: str, dest: Path): + console.print(f"[cyan]Скачивание {url} → {dest.name}") + + with httpx.Client(follow_redirects=True, timeout=60.0) as client: + with client.stream("GET", url) as response: + response.raise_for_status() + total = int(response.headers.get("Content-Length", 0)) + + dest.parent.mkdir(parents=True, exist_ok=True) + + with open(dest, "wb") as f, Progress(console=console) as progress: + task = progress.add_task("Загрузка", total=total if total else None) + for chunk in response.iter_bytes(chunk_size=8192): + f.write(chunk) + progress.update(task, advance=len(chunk))