diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 7d8b9bb..5192e30 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -5,9 +5,6 @@ on: push: tags: - 'v*' - #schedule: # раз в 36 часов - # - cron: "0 0 */3 * *" # каждые 3 дня в 00:00 - # - cron: "0 12 */3 * *" # каждые 3 дня в 12:00 jobs: build: @@ -56,16 +53,16 @@ jobs: run: | python ./main.py build -f - - name: Чтение title из report.log + - name: Чтение title из report.md id: get_title run: | - TITLE=$(head -n 1 modified/report.log) + TITLE=$(head -n 1 modified/report.md) echo "title=${TITLE}" >> $GITHUB_OUTPUT - - name: Чтение body из report.log + - name: Чтение body из report.md id: get_body run: | - BODY=$(tail -n +2 modified/report.log) + BODY=$(tail -n +3 modified/report.md) echo "body<> $GITHUB_OUTPUT echo "$BODY" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT diff --git a/configs/change_server.json b/configs/change_server.json index 975b334..2fb8b89 100644 --- a/configs/change_server.json +++ b/configs/change_server.json @@ -1 +1,4 @@ -{"enabled":true,"server":"https://anixarty.0x174.su/patch"} \ No newline at end of file +{ + "enabled": false, + "server": "https://anixarty.0x174.su/patch" +} \ No newline at end of file diff --git a/configs/color_theme.json b/configs/color_theme.json index 01db6eb..e34a939 100644 --- a/configs/color_theme.json +++ b/configs/color_theme.json @@ -1 +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"}} \ No newline at end of file +{ + "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" + } +} \ No newline at end of file diff --git a/configs/comment_vote.json b/configs/comment_vote.json index e0caaa6..6373cc2 100644 --- a/configs/comment_vote.json +++ b/configs/comment_vote.json @@ -1 +1,6 @@ -{"enabled":true,"replace":true,"custom_icons":true,"icon_size":"18.0dip"} \ No newline at end of file +{ + "enabled": true, + "replace": true, + "custom_icons": true, + "icon_size": "18.0dip" +} \ No newline at end of file diff --git a/configs/compress.json b/configs/compress.json index e6413ef..3874c13 100644 --- a/configs/compress.json +++ b/configs/compress.json @@ -1 +1,13 @@ -{"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 +{ + "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 index 310f75e..37c8cef 100644 --- a/configs/disable_ad.json +++ b/configs/disable_ad.json @@ -1 +1,3 @@ -{"enabled":true} \ No newline at end of file +{ + "enabled": true +} \ No newline at end of file diff --git a/configs/disable_beta_banner.json b/configs/disable_beta_banner.json index 310f75e..37c8cef 100644 --- a/configs/disable_beta_banner.json +++ b/configs/disable_beta_banner.json @@ -1 +1,3 @@ -{"enabled":true} \ No newline at end of file +{ + "enabled": true +} \ No newline at end of file diff --git a/configs/package_name.json b/configs/package_name.json index 4ecb05a..41b8d8f 100644 --- a/configs/package_name.json +++ b/configs/package_name.json @@ -1 +1,4 @@ -{"enabled":true,"package_name":"com.wowlikon.anixart"} \ No newline at end of file +{ + "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 index c055d4f..e152c83 100644 --- a/configs/replace_navbar.json +++ b/configs/replace_navbar.json @@ -1 +1,10 @@ -{"enabled":true,"items":["home","discover","feed","bookmarks","profile"]} \ No newline at end of file +{ + "enabled": true, + "items": [ + "home", + "discover", + "feed", + "bookmarks", + "profile" + ] +} \ No newline at end of file diff --git a/configs/selectable_text.json b/configs/selectable_text.json index 310f75e..37c8cef 100644 --- a/configs/selectable_text.json +++ b/configs/selectable_text.json @@ -1 +1,3 @@ -{"enabled":true} \ No newline at end of file +{ + "enabled": true +} \ No newline at end of file diff --git a/configs/settings_urls.json b/configs/settings_urls.json index bab4f7a..8573714 100644 --- a/configs/settings_urls.json +++ b/configs/settings_urls.json @@ -1 +1,38 @@ -{"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 +{ + "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/configs/share_links.json b/configs/share_links.json index a5e118a..eb1bdd1 100644 --- a/configs/share_links.json +++ b/configs/share_links.json @@ -1 +1,9 @@ -{"enabled":true,"format":{"share_channel_text":"Канал: «%1$s»\n%2$schannel/%3$d","share_collection_text":"Коллекция: «%1$s»\n%2$scollection/%3$d","share_profile_text":"Профиль пользователя «%1$s»\n%2$sprofile/%3$d","share_release_text":"Релиз: «%1$s»\n%2$srelease/%3$d"}} \ No newline at end of file +{ + "enabled": true, + "format": { + "share_channel_text": "Канал: «%1$s»\n%2$schannel/%3$d", + "share_collection_text": "Коллекция: «%1$s»\n%2$scollection/%3$d", + "share_profile_text": "Профиль пользователя «%1$s»\n%2$sprofile/%3$d", + "share_release_text": "Релиз: «%1$s»\n%2$srelease/%3$d" + } +} \ No newline at end of file diff --git a/configs/welcome.json b/configs/welcome.json index a87e7c6..2985cb8 100644 --- a/configs/welcome.json +++ b/configs/welcome.json @@ -1 +1,9 @@ -{"enabled":true,"title":"Anixarty","description":"Описание","link_text":"МЫ В TELEGRAM","link_url":"https://t.me/http_teapod","skip_text":"Пропустить","title_bg_color":"#FFFFFF"} \ No newline at end of file +{ + "enabled": true, + "title": "Anixarty", + "description": "Описание", + "link_text": "МЫ В TELEGRAM", + "link_url": "https://t.me/http_teapod", + "skip_text": "Пропустить", + "title_bg_color": "#FFFFFF" +} \ No newline at end of file diff --git a/main.py b/main.py index 968d3cb..01c5ae7 100644 --- a/main.py +++ b/main.py @@ -1,241 +1,474 @@ -from typing import List, Dict, Any +__version__ = "1.0.0" +import shutil +from functools import wraps +from pathlib import Path +from typing import Any, Dict, List import typer -import importlib -import traceback -import yaml - -from plumbum import local, ProcessExecutionError +from plumbum import ProcessExecutionError, local from rich.console import Console from rich.progress import Progress from rich.prompt import Prompt +from rich.table import Table -from utils.config import * -from utils.tools import * - +from utils.apk import APKMeta, APKProcessor +from utils.config import Config, PatchTemplate, load_config +from utils.info import print_model_fields, print_model_table +from utils.patch_manager import (BuildError, ConfigError, PatcherError, + PatchManager, handle_errors) +from utils.tools import (CONFIGS, DECOMPILED, MODIFIED, ORIGINAL, PATCHES, + TOOLS, download, ensure_dirs, run, select_apk) console = Console() -app = typer.Typer() +app = typer.Typer( + name="anixarty-patcher", + help="Инструмент для модификации Anixarty APK", + add_completion=False, +) -# ======================= PATCHING ========================= -class Patch: - def __init__(self, name: str, module): - self.name = name - self.module = module - self.applied = False - self.priority = getattr(module, "priority", 0) +from datetime import datetime + + +def generate_report( + apk_path: Path, + meta: APKMeta, + patches: List[PatchTemplate], + manager: PatchManager, +) -> None: + """Генерирует отчёт о сборке в формате Markdown""" + report_path = MODIFIED / "report.md" + + applied_count = sum(1 for p in patches if p.applied) + applied_patches = [p for p in patches if p.applied] + failed_patches = [p for p in patches if not p.applied] + + applied_patches.sort(key=lambda p: p.priority, reverse=True) + failed_patches.sort(key=lambda p: p.priority, reverse=True) + + def get_patch_info(patch: PatchTemplate) -> Dict[str, str]: + """Получает описание и автора патча из модуля""" + info = {"doc": "", "author": "-"} try: - self.config = module.Config.model_validate_json((CONFIGS / f"{name}.json").read_text()) - except Exception as e: - console.print(f"[red]Ошибка при загрузке конфигурации патча {name}: {e}") - console.print(f"[yellow]Используются значения по умолчанию") - self.config = module.Config() + patch_module = manager.load_patch_module(patch.name) + doc = patch_module.__doc__ + if doc: + info["doc"] = doc.strip().split("\n")[0] + author = getattr(patch_module, "__author__", "") + if author: + info["author"] = f"`{author}`" + except Exception: + pass + return info - 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 + lines = [] + lines.append(f"Anixarty {meta.version_name} (build {meta.version_code})") + lines.append("") + lines.append("## 📦 Информация о сборке") + lines.append("") + lines.append("| Параметр | Значение |") + lines.append("|----------|----------|") + lines.append(f"| Версия | `{meta.version_name}` |") + lines.append(f"| Код версии | `{meta.version_code}` |") + lines.append(f"| Пакет | `{meta.package}` |") + lines.append(f"| Файл | `{apk_path.name}` |") + lines.append(f"| Дата сборки | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |") + lines.append("") -# ========================= INIT ========================= -@app.command() -def init(): - """Создание директорий и скачивание инструментов""" - ensure_dirs() - conf = load_config(console) + lines.append("## 🔧 Применённые патчи") + lines.append("") - 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(console, conf.tools.apktool_jar_url, TOOLS / "apktool.jar") - - if not (TOOLS / "apktool").exists(): - download(console, conf.tools.apktool_wrapper_url, TOOLS / "apktool") - (TOOLS / "apktool").chmod(0o755) - - try: - local["java"]["-version"]() - console.print("[green]Java найдена") - except ProcessExecutionError: - console.print("[red]Java не установлена") - raise typer.Exit(1) - - -# ========================= INFO ========================= -@app.command() -def info(patch_name: str = ""): - """Вывод информации о патче""" - conf = load_config(console).model_dump() - if patch_name: - 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__}") - - console.print(f"[blue]Поля конфигурации") - for field_name, field_info in type(patch.config).model_fields.items(): - field_data = { - 'type': field_info.annotation.__name__, - 'description': field_info.description, - 'default': field_info.default, - 'json_schema_extra': field_info.json_schema_extra, - } - console.print(f'{field_name} {field_data}') - console.print("\n[blue]" + "="*50 + "\n") + if applied_patches: + lines.append( + f"> ✅ Успешно применено: **{applied_count}** из **{len(patches)}**" + ) + lines.append("") + lines.append("| Патч | Приоритет | Автор | Описание |") + lines.append("|------|:---------:|-------|----------|") + for p in applied_patches: + info = get_patch_info(p) + lines.append( + f"| ✅ `{p.name}` | {p.priority} | {info['author']} | {info['doc']} |" + ) else: - conf = load_config(console) - console.print("[cyan]Список патчей:") - patch_list = [] - for f in PATCHES.glob("*.py"): - if f.name == "__init__.py": continue - if f.name.startswith("todo_"): - try: priority = __import__(f"patches.{f.stem}.priority", fromlist=[""]) - except: priority = None - patch_list.append((priority, f" [{priority}] [yellow]{f.stem}: [yellow]⚠ в разработке")) - continue - patch = Patch(f.stem, __import__(f"patches.{f.stem}", fromlist=[""])) - if patch.config.enabled: patch_list.append((patch.priority, f" [{patch.priority}] [yellow]{f.stem}: [green]✔ включен")) - else: patch_list.append((patch.priority, f" [{patch.priority}] [yellow]{f.stem}: [red]✘ выключен")) - for _, patch in sorted(patch_list, key=lambda x: (x[0] is None, x[0]), reverse=True): console.print(patch) + lines.append("> ⚠️ Нет применённых патчей") + lines.append("") -# ========================= UTIL ========================= -def select_apk() -> Path: - apks = [f for f in ORIGINAL.glob("*.apk")] - if not apks: - console.print("[red]Нет apk-файлов в папке original") - raise typer.Exit(1) + if failed_patches: + lines.append("## ❌ Ошибки") + lines.append("") + lines.append("| Патч | Приоритет | Автор | Описание |") + lines.append("|------|:---------:|-------|----------|") - if len(apks) == 1: - console.print(f"[green]Выбран {apks[0].name}") - return apks[0] + for p in failed_patches: + info = get_patch_info(p) + lines.append( + f"| ❌ `{p.name}` | {p.priority} | {info['author']} | {info['doc']} |" + ) - options = {str(i): apk for i, apk in enumerate(apks, 1)} - for k, v in options.items(): - console.print(f"{k}. {v.name}") + lines.append("") - choice = Prompt.ask("Выберите номер", choices=list(options.keys())) - return options[choice] - - -def decompile(apk: Path): - console.print("[yellow]Декомпиляция apk...") - run( - console, - [ - "java", - "-jar", str(TOOLS / "apktool.jar"), - "d", "-f", - "-o", str(DECOMPILED), - str(apk), - ] + lines.append("---") + lines.append("") + lines.append( + "*Собрано с помощью [anixarty-patcher](https://git.0x174.su/anixart-mod/patcher)*" ) - -def compile(apk: Path, patches: List[Patch]): - console.print("[yellow]Сборка apk...") - - 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), - ] - ) - 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), - str(aligned), - ] - ) - - console.print("[green]✔ APK успешно собран и подписан") - - with open(MODIFIED / "report.log", "w", encoding="utf-8") as f: - f.write(f"Anixarty mod v {version_name} ({version_code})\n") - for p in patches: - f.write(f"{'✔' if p.applied else '✘'} {p.name}\n") + report_path.write_text("\n".join(lines), encoding="utf-8") + console.print(f"[dim]Отчёт сохранён: {report_path}[/dim]") -# ========================= BUILD ========================= +# ========================= COMMANDS ========================= @app.command() -def build( - force: bool = typer.Option(False, "--force", "-f", help="Принудительная сборка"), - verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), -): - """Декомпиляция, патчи и сборка apk""" +@handle_errors +def init(): + """Инициализация: создание директорий и скачивание инструментов""" + ensure_dirs() + conf = load_config(console) - apk = select_apk() - decompile(apk) - patch_objs: List[Patch] = [] - conf.base |= {"verbose": verbose} + # Проверка Java + console.print("[cyan]Проверка Java...") + try: + local["java"]["-version"].run(retcode=None) + console.print("[green]✔ Java найдена") + except ProcessExecutionError: + raise PatcherError("Java не установлена. Установите JDK 11+") - for f in PATCHES.glob("*.py"): - if f.name.startswith("todo_") or f.name == "__init__.py": - continue - name = f.stem - 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 - patch_objs.append(Patch(name, module)) + # Скачивание apktool + apktool_jar = TOOLS / "apktool.jar" + if not apktool_jar.exists(): + download(console, conf.tools.apktool_jar_url, apktool_jar) + else: + console.print(f"[dim]✔ {apktool_jar.name} уже существует[/dim]") - patch_objs.sort(key=lambda p: p.priority, reverse=True) + # Скачивание apktool wrapper + apktool_wrapper = TOOLS / "apktool" + if not apktool_wrapper.exists(): + download(console, conf.tools.apktool_wrapper_url, apktool_wrapper) + apktool_wrapper.chmod(0o755) + else: + console.print(f"[dim]✔ {apktool_wrapper.name} уже существует[/dim]") - console.print("[cyan]Применение патчей") - with Progress() as progress: - task = progress.add_task("Патчи", total=len(patch_objs)) - for p in patch_objs: - ok = p.apply(conf.base) - progress.console.print(f"{'✔' if ok else '✘'} {p.name}") + # Проверка zipalign и apksigner + for tool in ["zipalign", "apksigner"]: + try: + local[tool]["--version"].run(retcode=None) + console.print(f"[green]✔ {tool} найден") + except Exception: + console.print(f"[yellow]⚠ {tool} не найден в PATH") + + # Проверка keystore + if not Path("keystore.jks").exists(): + console.print("[yellow]⚠ keystore.jks не найден. Создайте его командой:") + console.print( + "[dim] keytool -genkey -v -keystore keystore.jks -keyalg RSA " + "-keysize 2048 -validity 10000 -alias key[/dim]" + ) + + # Инициализация конфигов патчей + console.print("\n[cyan]Инициализация конфигураций патчей...") + manager = PatchManager(console) + + for name in manager.discover_patches(): + patch = manager.load_patch(name) + config_path = CONFIGS / f"{name}.json" + if not config_path.exists(): + patch.save_config() + console.print(f" [green]✔ {name}.json создан") + else: + console.print(f" [dim]✔ {name}.json существует[/dim]") + + console.print("\n[green]✔ Инициализация завершена") + + +@app.command("list") +@handle_errors +def list_patches(): + """Показать список всех патчей""" + manager = PatchManager(console) + all_patches = manager.discover_all() + + table = Table(title="Доступные патчи") + table.add_column("Приоритет", justify="center", style="cyan") + table.add_column("Название", style="yellow") + table.add_column("Статус", justify="center") + table.add_column("Автор", style="magenta") + table.add_column("Версия", style="yellow") + table.add_column("Описание") + + patch_rows = [] + + for name in all_patches["ready"]: + try: + patch = manager.load_patch(name) + status = "[green]✔ вкл[/green]" if patch.enabled else "[red]✘ выкл[/red]" + patch_class = manager.load_patch_class(name) + priority = getattr(patch_class, "priority", 0) + patch_module = manager.load_patch_module(name) + author = getattr(patch_module, "__author__", "") + version = getattr(patch_module, "__version__", "") + description = (patch_module.__doc__ or "").strip().split("\n")[0] + patch_rows.append( + (patch.priority, name, status, author, version, description) + ) + except Exception as e: + raise e + patch_rows.append((0, name, "[red]⚠ ошибка[/red]", "", "", str(e)[:40])) + + for name in all_patches["todo"]: + try: + patch_class = manager.load_patch_class(name) + priority = getattr(patch_class, "priority", 0) + patch_module = manager.load_patch_module(name) + author = getattr(patch_module, "__author__", "") + version = getattr(patch_module, "__version__", "") + description = (patch_module.__doc__ or "").strip().split("\n")[0] + patch_rows.append( + ( + priority, + name, + "[yellow]⚠ todo[/yellow]", + author, + version, + description, + ) + ) + except Exception: + patch_rows.append((0, name, "[yellow]⚠ todo[/yellow]", "", "", "")) + + patch_rows.sort(key=lambda x: x[0], reverse=True) + + for priority, name, status, author, version, desc in patch_rows: + table.add_row(str(priority), name, status, author, version, desc[:50]) + + console.print(table) + + +@app.command() +@handle_errors +def info( + patch_name: str = typer.Argument(..., help="Имя патча"), + tree: bool = typer.Option(False, "--tree", "-t", help="Древовидный вывод полей"), +): + """Показать подробную информацию о патче""" + manager = PatchManager(console) + + all_patches = manager.discover_all() + all_names = all_patches["ready"] + all_patches["todo"] + + if patch_name not in all_names: + raise PatcherError(f"Патч '{patch_name}' не найден") + + patch_class = manager.load_patch_class(patch_name) + + console.print(f"\n[bold cyan]Патч: {patch_name}[/bold cyan]") + console.print("-" * 50) + + if patch_class.__doc__: + console.print(f"[white]{patch_class.__doc__.strip()}[/white]\n") + + is_todo = patch_name in all_patches["todo"] + if is_todo: + console.print("[yellow]Статус: в разработке[/yellow]\n") + else: + patch = manager.load_patch(patch_name) + status = "[green]включён[/green]" if patch.enabled else "[red]выключен[/red]" + console.print(f"Статус: {status}") + console.print(f"Приоритет: [cyan]{patch.priority}[/cyan]\n") + + console.print("[bold]Поля конфигурации:[/bold]") + + if tree: + print_model_fields(console, patch_class) + else: + table = print_model_table(console, patch_class) + console.print(table) + + table = Table(show_header=True) + table.add_column("Поле", style="yellow") + table.add_column("Тип", style="cyan") + table.add_column("По умолчанию") + table.add_column("Описание") + + for field_name, field_info in patch_class.model_fields.items(): + field_type = getattr( + field_info.annotation, "__name__", str(field_info.annotation) + ) + default = str(field_info.default) if field_info.default is not None else "-" + description = field_info.description or "" + table.add_row(field_name, field_type, default, description) + + console.print(table) + + if not is_todo: + config_path = CONFIGS / f"{patch_name}.json" + if config_path.exists(): + console.print(f"\n[bold]Текущая конфигурация[/bold] ({config_path}):") + console.print(config_path.read_text()) + + +@app.command() +@handle_errors +def enable(patch_name: str = typer.Argument(..., help="Имя патча")): + """Включить патч""" + manager = PatchManager(console) + + if patch_name not in manager.discover_patches(): + raise PatcherError(f"Патч '{patch_name}' не найден") + + patch = manager.load_patch(patch_name) + patch.enabled = True + patch.save_config() + + console.print(f"[green]✔ Патч {patch_name} включён") + + +@app.command() +@handle_errors +def disable(patch_name: str = typer.Argument(..., help="Имя патча")): + """Выключить патч""" + manager = PatchManager(console) + + if patch_name not in manager.discover_patches(): + raise PatcherError(f"Патч '{patch_name}' не найден") + + patch = manager.load_patch(patch_name) + patch.enabled = False + patch.save_config() + + console.print(f"[yellow]✔ Патч {patch_name} выключен") + + +@app.command() +@handle_errors +def build( + force: bool = typer.Option( + False, "--force", "-f", help="Принудительная сборка при ошибках" + ), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), + skip_compile: bool = typer.Option( + False, "--skip-compile", "-s", help="Пропустить компиляцию (только патчи)" + ), +): + """Декомпиляция, применение патчей и сборка APK""" + conf = load_config(console) + + apk_processor = APKProcessor(console, TOOLS) + + apk = select_apk(console) + apk_processor.decompile(apk, DECOMPILED) + + manager = PatchManager(console) + patches = manager.load_enabled_patches() + + if not patches: + console.print("[yellow]Нет включённых патчей") + if not force: + raise typer.Exit(0) + + base_config = conf.base.copy() + base_config["verbose"] = verbose + base_config["decompiled"] = str(DECOMPILED) + + console.print(f"\n[cyan]Применение патчей ({len(patches)})...[/cyan]") + + with Progress(console=console) as progress: + task = progress.add_task("Патчи", total=len(patches)) + + for patch in patches: + success = patch.safe_apply(base_config) + status = "[green]✔[/green]" if success else "[red]✘[/red]" + progress.console.print(f" {status} [{patch.priority:2d}] {patch.name}") progress.advance(task) - successes = sum(p.applied for p in patch_objs) - if successes == len(patch_objs): - compile(apk, patch_objs) - elif successes > 0 and ( - force or Prompt.ask("Продолжить сборку?", choices=["y", "n"]) == "y" - ): - compile(apk, patch_objs) + applied = sum(1 for p in patches if p.applied) + failed = len(patches) - applied + + console.print() + if failed == 0: + console.print(f"[green]✔ Все патчи применены ({applied}/{len(patches)})") + else: + console.print( + f"[yellow]⚠ Применено: {applied}/{len(patches)}, ошибок: {failed}" + ) + + if skip_compile: + console.print("[yellow]Компиляция пропущена (--skip-compile)") + return + + should_compile = ( + failed == 0 + or force + or Prompt.ask( + "\nПродолжить сборку несмотря на ошибки?", choices=["y", "n"], default="n" + ) + == "y" + ) + + if should_compile: + console.print() + signed_apk, meta = apk_processor.build_and_sign( + source=DECOMPILED, + output_dir=MODIFIED, + ) + generate_report(signed_apk, meta, patches, manager) else: console.print("[red]Сборка отменена") raise typer.Exit(1) -if __name__ == "__main__": app() +@app.command() +@handle_errors +def clean( + all_dirs: bool = typer.Option( + False, "--all", "-a", help="Очистить все директории включая modified и configs" + ) +): + """Очистка временных файлов""" + dirs_to_clean = [DECOMPILED] + + if all_dirs: + dirs_to_clean.extend([MODIFIED, CONFIGS]) + + for d in dirs_to_clean: + if d.exists(): + shutil.rmtree(d) + d.mkdir() + console.print(f"[yellow]✔ Очищено: {d}") + else: + console.print(f"[dim]≫ Пропущено (не существует): {d}[/dim]") + + console.print("[green]✔ Очистка завершена") + + +@app.command() +@handle_errors +def config(): + """Показать текущую конфигурацию""" + conf = load_config(console) + + console.print("[bold cyan]Конфигурация (config.json):[/bold cyan]\n") + + console.print("[yellow]Tools:[/yellow]") + console.print(f" apktool_jar_url: {conf.tools.apktool_jar_url}") + console.print(f" apktool_wrapper_url: {conf.tools.apktool_wrapper_url}") + + if conf.base: + console.print("\n[yellow]Base:[/yellow]") + for key, value in conf.base.items(): + console.print(f" {key}: {value}") + + +@app.command() +@handle_errors +def version(): + """Показать версию инструмента""" + console.print(f"[cyan]anixarty-patcher[/cyan] v{__version__}") + + +if __name__ == "__main__": + app() diff --git a/patches/__init__.py b/patches/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/patches/change_server.py b/patches/change_server.py index 543d716..7cf1fdb 100644 --- a/patches/change_server.py +++ b/patches/change_server.py @@ -6,58 +6,73 @@ } """ -priority = 0 - -# imports +__author__ = "wowlikon " +__version__ = "1.0.0" import json +from typing import Any, Dict + import requests -from tqdm import tqdm -from typing import Dict, Any from pydantic import Field +from tqdm import tqdm -from utils.config import PatchConfig +from utils.config import PatchTemplate -#Config -class Config(PatchConfig): + +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) server: str = Field("https://anixarty.0x174.su/patch", description="URL сервера") -# Patch -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}" + def apply(self, base: Dict[str, Any]) -> bool: + response = requests.get(self.server) # Получаем данные для патча + assert ( + response.status_code == 200 + ), f"Failed to fetch data {response.status_code} {response.text}" - new_api = json.loads(response.text) - for item in new_api['modifications']: # Применяем замены API - tqdm.write(f"Изменение {item['file']}") - filepath = './decompiled/smali_classes2/com/swiftsoft/anixartd/network/api/'+item['file'] + new_api = json.loads(response.text) + for item in new_api["modifications"]: # Применяем замены API + tqdm.write(f"Изменение {item['file']}") + filepath = ( + "./decompiled/smali_classes2/com/swiftsoft/anixartd/network/api/" + + item["file"] + ) - with open(filepath, 'r') as f: + with open(filepath, "r") as f: + content = f.read() + + with open(filepath, "w") as f: + if content.count(item["src"]) == 0: + tqdm.write(f"⚠ Не найдено {item['src']}") + f.write(content.replace(item["src"], item["dst"])) + + tqdm.write( + f"Изменение Github ссылки" + ) # Обновление ссылки на поиск серверов в Github + filepath = "./decompiled/smali_classes2/com/swiftsoft/anixartd/utils/anixnet/GithubPagesNetFetcher.smali" + + with open(filepath, "r") as f: content = f.read() - with open(filepath, 'w') as f: - if content.count(item['src']) == 0: - tqdm.write(f"⚠ Не найдено {item['src']}") - f.write(content.replace(item['src'], item['dst'])) + with open(filepath, "w") as f: + f.write( + content.replace( + 'const-string v1, "https://anixhelper.github.io/pages/urls.json"', + f'const-string v1, "{new_api["gh"]}"', + ) + ) - tqdm.write(f"Изменение Github ссылки") # Обновление ссылки на поиск серверов в Github - filepath = './decompiled/smali_classes2/com/swiftsoft/anixartd/utils/anixnet/GithubPagesNetFetcher.smali' + tqdm.write( + "Удаление динамического выбора сервера" + ) # Отключение автовыбора сервера + filepath = "./decompiled/smali_classes2/com/swiftsoft/anixartd/DaggerApp_HiltComponents_SingletonC$SingletonCImpl$SwitchingProvider.smali" - with open(filepath, 'r') as f: - content = f.read() + content = "" + with open(filepath, "r") as f: + for line in f.readlines(): + if "addInterceptor" in line: + continue + content += line - with open(filepath, 'w') as f: - f.write(content.replace('const-string v1, "https://anixhelper.github.io/pages/urls.json"', f'const-string v1, "{new_api["gh"]}"')) + with open(filepath, "w") as f: + f.write(content) - tqdm.write("Удаление динамического выбора сервера") # Отключение автовыбора сервера - filepath = './decompiled/smali_classes2/com/swiftsoft/anixartd/DaggerApp_HiltComponents_SingletonC$SingletonCImpl$SwitchingProvider.smali' - - content = "" - with open(filepath, 'r') as f: - for line in f.readlines(): - if "addInterceptor" in line: continue - content += line - - with open(filepath, 'w') as f: - f.write(content) - - return True + return True diff --git a/patches/color_theme.py b/patches/color_theme.py index ed8f1cb..43aabd1 100644 --- a/patches/color_theme.py +++ b/patches/color_theme.py @@ -19,127 +19,189 @@ } """ -priority = 0 +__author__ = "wowlikon " +__version__ = "1.0.0" +from typing import Any, Dict -# imports from lxml import etree -from typing import Dict, Any -from pydantic import Field, BaseModel +from pydantic import BaseModel, Field, model_validator + +from utils.config import PatchTemplate +from utils.public import change_color, insert_after_color, insert_after_public -from utils.config import PatchConfig -from utils.public import ( - insert_after_public, - insert_after_color, - change_color, -) -#Config class Gradient(BaseModel): + priority: int = Field(frozen=True, exclude=True, default=0) 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] + gradient: Gradient = Field( + default_factory=Gradient, description="Настройки градиента" + ) 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: Config, base: Dict[str, Any]) -> bool: - main_color = config.colors.primary - splash_color = config.colors.secondary +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) + logo: Logo = Field(default_factory=Logo, description="Настройки цветов логотипа") + colors: Colors = Field(default_factory=Colors, description="Настройки цветов") - # Обновление сообщения об отсутствии подключения - with open("./decompiled/assets/no_connection.html", "r", encoding="utf-8") as file: - file_contents = file.read() + @model_validator(mode="before") + @classmethod + def validate_nested(cls, data): + if isinstance(data, dict): + if "logo" in data and isinstance(data["logo"], dict): + data["logo"] = Logo(**data["logo"]) + if "colors" in data and isinstance(data["colors"], dict): + data["colors"] = Colors(**data["colors"]) + return data - new_contents = file_contents.replace("#f04e4e", main_color) + def hex_to_lottie(hex_color: str) -> tuple[float, float, float]: + hex_color = hex_color.lstrip("#") + hex_color = hex_color[2:] if len(hex_color) == 8 else hex_color + return ( + int(hex_color[:2], 16) / 255.0, + int(hex_color[2:4], 16) / 255.0, + int(hex_color[4:6], 16) / 255.0, + ) - with open("./decompiled/assets/no_connection.html", "w", encoding="utf-8") as file: - file.write(new_contents) + def apply(self, base: Dict[str, Any]) -> bool: + main_color = self.colors.primary + splash_color = self.colors.secondary - # Суффиксы лого - drawable_types = ["", "-night"] + # Обновление сообщения об отсутствии подключения + with open( + "./decompiled/assets/no_connection.html", "r", encoding="utf-8" + ) as file: + file_contents = file.read() - for drawable_type in drawable_types: - # Градиент лого приложения - file_path = f"./decompiled/res/drawable{drawable_type}/$logo__0.xml" + new_contents = file_contents.replace("#f04e4e", main_color) - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + with open( + "./decompiled/assets/no_connection.html", "w", encoding="utf-8" + ) as file: + file.write(new_contents) - # Замена атрибутов значениями из конфигурации - 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) + # Суффиксы лого + drawable_types = ["", "-night"] - # Сохранение - tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") + for drawable_type in drawable_types: + # Градиент лого приложения + file_path = f"./decompiled/res/drawable{drawable_type}/$logo__0.xml" - # Замена анимации лого - file_path = f"./decompiled/res/drawable{drawable_type}/$logo_splash_anim__0.xml" + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + # Замена атрибутов значениями из конфигурации + root.set( + f"{{{base['xml_ns']['android']}}}angle", str(self.logo.gradient.angle) + ) + root.set( + f"{{{base['xml_ns']['android']}}}startColor", + self.logo.gradient.start_color, + ) + root.set( + f"{{{base['xml_ns']['android']}}}endColor", self.logo.gradient.end_color + ) - 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"{{{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) + # Сохранение + tree.write( + file_path, pretty_print=True, xml_declaration=True, encoding="utf-8" + ) - # Сохранение - tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") + # Замена анимации лого + file_path = ( + f"./decompiled/res/drawable{drawable_type}/$logo_splash_anim__0.xml" + ) - for filename in ["$ic_launcher_foreground__0", "$ic_banner_foreground__0"]: - file_path = f"./decompiled/res/drawable-v24/{filename}.xml" + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + 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"{{{base['xml_ns']['android']}}}fillColor", + self.colors.secondary, + ) + elif name in ["path_1", "path_2"]: + el.set( + f"{{{base['xml_ns']['android']}}}fillColor", + self.logo.ears_color, + ) - # Замена атрибутов значениями из конфигурации - 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"{{{base['xml_ns']['android']}}}color", config.logo.gradient.start_color) - items[1].set(f"{{{base['xml_ns']['android']}}}color", config.logo.gradient.end_color) + # Сохранение + tree.write( + file_path, pretty_print=True, xml_declaration=True, encoding="utf-8" + ) - # Сохранение - tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") + for filename in ["$ic_launcher_foreground__0", "$ic_banner_foreground__0"]: + file_path = f"./decompiled/res/drawable-v24/{filename}.xml" - # Добаление новых цветов для темы - insert_after_public("carmine", "custom_color") - insert_after_public("carmine_alpha_10", "custom_color_alpha_10") - insert_after_color("carmine", "custom_color", main_color[0]+'ff'+main_color[1:]) - insert_after_color("carmine_alpha_10", "custom_color_alpha_10", main_color[0]+'1a'+main_color[1:]) + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - # Замена цветов - change_color("accent_alpha_10", main_color[0]+'1a'+main_color[1:]) - change_color("accent_alpha_20", main_color[0]+'33'+main_color[1:]) - change_color("accent_alpha_50", main_color[0]+'80'+main_color[1:]) - change_color("accent_alpha_70", main_color[0]+'b3'+main_color[1:]) + # Замена атрибутов значениями из конфигурации + root.set( + f"{{{base['xml_ns']['android']}}}angle", str(self.logo.gradient.angle) + ) + items = root.findall("item", namespaces=base["xml_ns"]) + assert len(items) == 2 + items[0].set( + f"{{{base['xml_ns']['android']}}}color", self.logo.gradient.start_color + ) + items[1].set( + f"{{{base['xml_ns']['android']}}}color", self.logo.gradient.end_color + ) - change_color("colorAccent", main_color[0]+'ff'+main_color[1:]) - change_color("link_color", main_color[0]+'ff'+main_color[1:]) - change_color("link_color_alpha_70", main_color[0]+'b3'+main_color[1:]) - change_color("refresh_progress", main_color[0]+'ff'+main_color[1:]) + # Сохранение + tree.write( + file_path, pretty_print=True, xml_declaration=True, encoding="utf-8" + ) - change_color("ic_launcher_background", "#ff000000") - change_color("bottom_nav_indicator_active", "#ffffffff") - change_color("bottom_nav_indicator_icon_checked", main_color[0]+'ff'+main_color[1:]) - change_color("bottom_nav_indicator_label_checked", main_color[0]+'ff'+main_color[1:]) + # Добаление новых цветов для темы + insert_after_public("carmine", "custom_color") + insert_after_public("carmine_alpha_10", "custom_color_alpha_10") + insert_after_color( + "carmine", "custom_color", main_color[0] + "ff" + main_color[1:] + ) + insert_after_color( + "carmine_alpha_10", + "custom_color_alpha_10", + main_color[0] + "1a" + main_color[1:], + ) - return True + # Замена цветов + change_color("accent_alpha_10", main_color[0] + "1a" + main_color[1:]) + change_color("accent_alpha_20", main_color[0] + "33" + main_color[1:]) + change_color("accent_alpha_50", main_color[0] + "80" + main_color[1:]) + change_color("accent_alpha_70", main_color[0] + "b3" + main_color[1:]) + + change_color("colorAccent", main_color[0] + "ff" + main_color[1:]) + change_color("link_color", main_color[0] + "ff" + main_color[1:]) + change_color("link_color_alpha_70", main_color[0] + "b3" + main_color[1:]) + change_color("refresh_progress", main_color[0] + "ff" + main_color[1:]) + + change_color("ic_launcher_background", "#ff000000") + change_color("bottom_nav_indicator_active", "#ffffffff") + change_color( + "bottom_nav_indicator_icon_checked", main_color[0] + "ff" + main_color[1:] + ) + change_color( + "bottom_nav_indicator_label_checked", main_color[0] + "ff" + main_color[1:] + ) + + return True diff --git a/patches/comment_vote.py b/patches/comment_vote.py index 1bc75a6..50d2c48 100644 --- a/patches/comment_vote.py +++ b/patches/comment_vote.py @@ -8,92 +8,100 @@ } """ -priority = 0 - -# imports +__author__ = "wowlikon " +__version__ = "1.0.0" import os import shutil -from tqdm import tqdm +from typing import Any, Dict + from lxml import etree from pydantic import Field -from typing import Dict, Any -from utils.config import PatchConfig +from tqdm import tqdm -#Config -class Config(PatchConfig): +from utils.config import PatchTemplate + + +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) replace: bool = Field(True, description="Менять местами лайк/дизлайк") custom_icons: bool = Field(True, description="Кастомные иконки") icon_size: str = Field("18.0dip", description="Размер иконки") -# Patch -def apply(config, base: Dict[str, Any]) -> bool: - file_path = "./decompiled/res/layout/item_comment.xml" - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + def apply(self, base: Dict[str, Any]) -> bool: + file_path = "./decompiled/res/layout/item_comment.xml" + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - tqdm.write("Меняем размер иконок лайка и дизлайка...") - for icon in root.xpath( - ".//*[@android:id='@id/votePlusInactive']//ImageView | " - ".//*[@android:id='@id/votePlusActive']//ImageView | " - ".//*[@android:id='@id/voteMinusInactive']//ImageView | " - ".//*[@android:id='@id/voteMinusActive']//ImageView", - namespaces=base['xml_ns'], - ): - icon.set(f"{{{base['xml_ns']['android']}}}layout_width", config.icon_size) - # icon.set(f"{{{base['xml_ns']['android']}}}layout_height", config.icon_size) - - if config.replace: - tqdm.write("Меняем местами лайк и дизлайк комментария...") - - containers = root.xpath( - ".//LinearLayout[.//*[@android:id='@id/voteMinus'] and .//*[@android:id='@id/votePlus']]", - namespaces=base["xml_ns"], - ) - - found = False - for container in containers: - children = list(container) - vote_plus = None - vote_minus = None - - for ch in children: - cid = ch.get(f'{{{base["xml_ns"]["android"]}}}id') - if cid == "@id/votePlus": - vote_plus = ch - elif cid == "@id/voteMinus": - vote_minus = ch - - if vote_plus is not None and vote_minus is not None: - found = True - i_plus = children.index(vote_plus) - i_minus = children.index(vote_minus) - children[i_plus], children[i_minus] = children[i_minus], children[i_plus] - container[:] = children - tqdm.write("Кнопки лайк и дизлайк поменялись местами.") - break - - if not found: - tqdm.write("Не удалось найти оба узла votePlus/voteMinus даже в общих LinearLayout.") - - if config.custom_icons: - tqdm.write("Заменяем иконки лайка и дизлайка на кастомные...") - for suffix in ["up", "up_40", "down", "down_40"]: - shutil.copy( - f"./resources/ic_chevron_{suffix}.xml", - f"./decompiled/res/drawable/ic_chevron_{suffix}.xml", - ) - - for inactive in root.xpath( - ".//*[@android:id='@id/votePlusInactive'] | .//*[@android:id='@id/voteMinusInactive']", + tqdm.write("Меняем размер иконок лайка и дизлайка...") + for icon in root.xpath( + ".//*[@android:id='@id/votePlusInactive']//ImageView | " + ".//*[@android:id='@id/votePlusActive']//ImageView | " + ".//*[@android:id='@id/voteMinusInactive']//ImageView | " + ".//*[@android:id='@id/voteMinusActive']//ImageView", namespaces=base["xml_ns"], ): - for img in inactive.xpath(".//ImageView[@android:src]", namespaces=base["xml_ns"]): - src = img.get(f'{{{base["xml_ns"]["android"]}}}src', "") - if src.startswith("@drawable/") and not src.endswith("_40"): - img.set(f'{{{base["xml_ns"]["android"]}}}src', src + "_40") + icon.set(f"{{{base['xml_ns']['android']}}}layout_width", self.icon_size) + # icon.set(f"{{{base['xml_ns']['android']}}}layout_height", self.icon_size) - # Сохраняем - tree.write(file_path, encoding="utf-8", xml_declaration=True, pretty_print=True) + if self.replace: + tqdm.write("Меняем местами лайк и дизлайк комментария...") - return True + containers = root.xpath( + ".//LinearLayout[.//*[@android:id='@id/voteMinus'] and .//*[@android:id='@id/votePlus']]", + namespaces=base["xml_ns"], + ) + + found = False + for container in containers: + children = list(container) + vote_plus = None + vote_minus = None + + for ch in children: + cid = ch.get(f'{{{base["xml_ns"]["android"]}}}id') + if cid == "@id/votePlus": + vote_plus = ch + elif cid == "@id/voteMinus": + vote_minus = ch + + if vote_plus is not None and vote_minus is not None: + found = True + i_plus = children.index(vote_plus) + i_minus = children.index(vote_minus) + children[i_plus], children[i_minus] = ( + children[i_minus], + children[i_plus], + ) + container[:] = children + tqdm.write("Кнопки лайк и дизлайк поменялись местами.") + break + + if not found: + tqdm.write( + "Не удалось найти оба узла votePlus/voteMinus даже в общих LinearLayout." + ) + + if self.custom_icons: + tqdm.write("Заменяем иконки лайка и дизлайка на кастомные...") + for suffix in ["up", "up_40", "down", "down_40"]: + shutil.copy( + f"./resources/ic_chevron_{suffix}.xml", + f"./decompiled/res/drawable/ic_chevron_{suffix}.xml", + ) + + for inactive in root.xpath( + ".//*[@android:id='@id/votePlusInactive'] | .//*[@android:id='@id/voteMinusInactive']", + namespaces=base["xml_ns"], + ): + for img in inactive.xpath( + ".//ImageView[@android:src]", namespaces=base["xml_ns"] + ): + src = img.get(f'{{{base["xml_ns"]["android"]}}}src', "") + if src.startswith("@drawable/") and not src.endswith("_40"): + img.set(f'{{{base["xml_ns"]["android"]}}}src', src + "_40") + + # Сохраняем + tree.write(file_path, encoding="utf-8", xml_declaration=True, pretty_print=True) + + return True diff --git a/patches/compress.py b/patches/compress.py index ae7d1b2..474b885 100644 --- a/patches/compress.py +++ b/patches/compress.py @@ -26,283 +26,308 @@ } """ -priority = -1 - -# imports +__author__ = "Kentai Radiquum " +__version__ = "1.0.0" import os import shutil import subprocess -from tqdm import tqdm -from pydantic import Field -from typing import Dict, List, Any +from typing import Any, Dict, List -from utils.config import PatchConfig +from pydantic import Field +from tqdm import tqdm + +from utils.config import PatchTemplate 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: 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 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: - shutil.rmtree(item_path) +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=-1) + 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" + ) + + def do_remove_unknown_files(self, 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 base.get("verbose", False): - tqdm.write(f"Удалёна директория: {item_path}") - return True - - -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) - if os.path.isfile(file_path) and filename.endswith(".smali"): - file_content = get_smali_lines(file_path) - new_content = [] - for line in file_content: - if line.find(".line") >= 0: - continue - new_content.append(line) - save_smali_lines(file_path, new_content) - if config.get("verbose", False): - tqdm.write(f"Удалены дебаг линии из: {file_path}") - return True - - -def compress_png(config: Dict[str, Any], png_path: str): - try: - assert subprocess.run( - [ - "pngquant", - "--force", - "--ext", ".png", - "--quality=65-90", - png_path, - ], - capture_output=True, - ).returncode in [0, 99] - if config.get("verbose", False): - tqdm.write(f"Сжат файл PNG: {png_path}") + tqdm.write(f"Удалён файл: {item_path}") + elif os.path.isdir(item_path): + if item not in self.remove_unknown_files_keep_dirs: + shutil.rmtree(item_path) + if base.get("verbose", False): + tqdm.write(f"Удалёна директория: {item_path}") return True - except subprocess.CalledProcessError as e: - tqdm.write(f"Ошибка при сжатии {png_path}: {e}") - return False + def do_remove_debug_lines(self, config: Dict[str, Any]): + for root, _, files in os.walk("./decompiled"): + for filename in files: + file_path = os.path.join(root, filename) + if os.path.isfile(file_path) and filename.endswith(".smali"): + file_content = get_smali_lines(file_path) + new_content = [] + for line in file_content: + if line.find(".line") >= 0: + continue + new_content.append(line) + save_smali_lines(file_path, new_content) + if config.get("verbose", False): + tqdm.write(f"Удалены дебаг линии из: {file_path}") + return True + + def compress_png(self, config: Dict[str, Any], png_path: str): + try: + assert subprocess.run( + [ + "pngquant", + "--force", + "--ext", + ".png", + "--quality=65-90", + png_path, + ], + capture_output=True, + ).returncode in [0, 99] + if config.get("verbose", False): + tqdm.write(f"Сжат файл PNG: {png_path}") + return True + except subprocess.CalledProcessError as e: + tqdm.write(f"Ошибка при сжатии {png_path}: {e}") + return False + + def do_compress_png_files(self, config: Dict[str, Any]): + compressed = [] + for root, _, files in os.walk("./decompiled"): + for file in files: + if file.lower().endswith(".png"): + self.compress_png(config, f"{root}/{file}") + compressed.append(f"{root}/{file}") + return len(compressed) > 0 and any(compressed) + + def do_remove_AI_voiceover(self, config: Dict[str, Any]): + blank = "./resources/blank.mp3" + path = "./decompiled/res/raw" + files = [ + "reputation_1.mp3", + "reputation_2.mp3", + "reputation_3.mp3", + "sound_beta_1.mp3", + "sound_create_blog_1.mp3", + "sound_create_blog_2.mp3", + "sound_create_blog_3.mp3", + "sound_create_blog_4.mp3", + "sound_create_blog_5.mp3", + "sound_create_blog_6.mp3", + "sound_create_blog_reputation_1.mp3", + "sound_create_blog_reputation_2.mp3", + "sound_create_blog_reputation_3.mp3", + "sound_create_blog_reputation_4.mp3", + "sound_create_blog_reputation_5.mp3", + "sound_create_blog_reputation_6.mp3", + ] -def compress_png_files(config: Dict[str, Any]): - compressed = [] - for root, _, files in os.walk("./decompiled"): for file in files: - if file.lower().endswith(".png"): - compress_png(config, f"{root}/{file}") - compressed.append(f"{root}/{file}") - return len(compressed) > 0 and any(compressed) + if os.path.exists(f"{path}/{file}"): + os.remove(f"{path}/{file}") + shutil.copyfile(blank, f"{path}/{file}") + if config.get("verbose", False): + tqdm.write(f"Файл mp3 был заменён на пустой: {path}/{file}") + return True -def remove_AI_voiceover(config: Dict[str, Any]): - blank = "./resources/blank.mp3" - path = "./decompiled/res/raw" - files = [ - "reputation_1.mp3", - "reputation_2.mp3", - "reputation_3.mp3", - "sound_beta_1.mp3", - "sound_create_blog_1.mp3", - "sound_create_blog_2.mp3", - "sound_create_blog_3.mp3", - "sound_create_blog_4.mp3", - "sound_create_blog_5.mp3", - "sound_create_blog_6.mp3", - "sound_create_blog_reputation_1.mp3", - "sound_create_blog_reputation_2.mp3", - "sound_create_blog_reputation_3.mp3", - "sound_create_blog_reputation_4.mp3", - "sound_create_blog_reputation_5.mp3", - "sound_create_blog_reputation_6.mp3", - ] + def do_remove_language_files(self, config: Dict[str, Any]): + path = "./decompiled/res" + folders = [ + "values-af", + "values-am", + "values-ar", + "values-as", + "values-az", + "values-b+es+419", + "values-b+sr+Latn", + "values-be", + "values-bg", + "values-bn", + "values-bs", + "values-ca", + "values-cs", + "values-da", + "values-de", + "values-el", + "values-en-rAU", + "values-en-rCA", + "values-en-rGB", + "values-en-rIN", + "values-en-rXC", + "values-es", + "values-es-rGT", + "values-es-rUS", + "values-et", + "values-eu", + "values-fa", + "values-fi", + "values-fr", + "values-fr-rCA", + "values-gl", + "values-gu", + "values-hi", + "values-hr", + "values-hu", + "values-hy", + "values-in", + "values-is", + "values-it", + "values-iw", + "values-ja", + "values-ka", + "values-kk", + "values-km", + "values-kn", + "values-ko", + "values-ky", + "values-lo", + "values-lt", + "values-lv", + "values-mk", + "values-ml", + "values-mn", + "values-mr", + "values-ms", + "values-my", + "values-nb", + "values-ne", + "values-nl", + "values-or", + "values-pa", + "values-pl", + "values-pt", + "values-pt-rBR", + "values-pt-rPT", + "values-ro", + "values-si", + "values-sk", + "values-sl", + "values-sq", + "values-sr", + "values-sv", + "values-sw", + "values-ta", + "values-te", + "values-th", + "values-tl", + "values-tr", + "values-uk", + "values-ur", + "values-uz", + "values-vi", + "values-zh", + "values-zh-rCN", + "values-zh-rHK", + "values-zh-rTW", + "values-zu", + "values-watch", + ] - for file in files: - if os.path.exists(f"{path}/{file}"): - os.remove(f"{path}/{file}") - shutil.copyfile(blank, f"{path}/{file}") - if config.get("verbose", False): - tqdm.write(f"Файл mp3 был заменён на пустой: {path}/{file}") + for folder in folders: + if os.path.exists(f"{path}/{folder}"): + shutil.rmtree(f"{path}/{folder}") + if config.get("verbose", False): + tqdm.write(f"Удалена директория: {path}/{folder}") + return True - return True + def do_remove_drawable_files(self, config: Dict[str, Any]): + path = "./decompiled/res" + folders = [ + "drawable-en-hdpi", + "drawable-en-ldpi", + "drawable-en-mdpi", + "drawable-en-xhdpi", + "drawable-en-xxhdpi", + "drawable-en-xxxhdpi", + "drawable-ldrtl-hdpi", + "drawable-ldrtl-mdpi", + "drawable-ldrtl-xhdpi", + "drawable-ldrtl-xxhdpi", + "drawable-ldrtl-xxxhdpi", + "drawable-tr-anydpi", + "drawable-tr-hdpi", + "drawable-tr-ldpi", + "drawable-tr-mdpi", + "drawable-tr-xhdpi", + "drawable-tr-xxhdpi", + "drawable-tr-xxxhdpi", + "drawable-watch", + "layout-watch", + ] + for folder in folders: + if os.path.exists(f"{path}/{folder}"): + shutil.rmtree(f"{path}/{folder}") + if config.get("verbose", False): + tqdm.write(f"Удалена директория: {path}/{folder}") + return True -def remove_language_files(config: Dict[str, Any]): - path = "./decompiled/res" - folders = [ - "values-af", - "values-am", - "values-ar", - "values-as", - "values-az", - "values-b+es+419", - "values-b+sr+Latn", - "values-be", - "values-bg", - "values-bn", - "values-bs", - "values-ca", - "values-cs", - "values-da", - "values-de", - "values-el", - "values-en-rAU", - "values-en-rCA", - "values-en-rGB", - "values-en-rIN", - "values-en-rXC", - "values-es", - "values-es-rGT", - "values-es-rUS", - "values-et", - "values-eu", - "values-fa", - "values-fi", - "values-fr", - "values-fr-rCA", - "values-gl", - "values-gu", - "values-hi", - "values-hr", - "values-hu", - "values-hy", - "values-in", - "values-is", - "values-it", - "values-iw", - "values-ja", - "values-ka", - "values-kk", - "values-km", - "values-kn", - "values-ko", - "values-ky", - "values-lo", - "values-lt", - "values-lv", - "values-mk", - "values-ml", - "values-mn", - "values-mr", - "values-ms", - "values-my", - "values-nb", - "values-ne", - "values-nl", - "values-or", - "values-pa", - "values-pl", - "values-pt", - "values-pt-rBR", - "values-pt-rPT", - "values-ro", - "values-si", - "values-sk", - "values-sl", - "values-sq", - "values-sr", - "values-sv", - "values-sw", - "values-ta", - "values-te", - "values-th", - "values-tl", - "values-tr", - "values-uk", - "values-ur", - "values-uz", - "values-vi", - "values-zh", - "values-zh-rCN", - "values-zh-rHK", - "values-zh-rTW", - "values-zu", - "values-watch", - ] + def apply(self, base: Dict[str, Any]) -> bool: + actions = [ + ( + self.remove_unknown_files, + "Удаление неизвестных файлов...", + self.do_remove_unknown_files, + ), + ( + self.remove_drawable_files, + "Удаление директорий drawable-xx...", + self.do_remove_drawable_files, + ), + ( + self.compress_png_files, + "Сжатие PNG файлов...", + self.do_compress_png_files, + ), + ( + self.remove_language_files, + "Удаление языков...", + self.do_remove_language_files, + ), + ( + self.remove_AI_voiceover, + "Удаление ИИ озвучки...", + self.do_remove_AI_voiceover, + ), + ( + self.remove_debug_lines, + "Удаление дебаг линий...", + self.do_remove_debug_lines, + ), + ] - for folder in folders: - if os.path.exists(f"{path}/{folder}"): - shutil.rmtree(f"{path}/{folder}") - if config.get("verbose", False): - tqdm.write(f"Удалена директория: {path}/{folder}") - return True + for enabled, message, action in actions: + if enabled: + tqdm.write(message) + action(base) - -def remove_drawable_files(config: Dict[str, Any]): - path = "./decompiled/res" - folders = [ - "drawable-en-hdpi", - "drawable-en-ldpi", - "drawable-en-mdpi", - "drawable-en-xhdpi", - "drawable-en-xxhdpi", - "drawable-en-xxxhdpi", - "drawable-ldrtl-hdpi", - "drawable-ldrtl-mdpi", - "drawable-ldrtl-xhdpi", - "drawable-ldrtl-xxhdpi", - "drawable-ldrtl-xxxhdpi", - "drawable-tr-anydpi", - "drawable-tr-hdpi", - "drawable-tr-ldpi", - "drawable-tr-mdpi", - "drawable-tr-xhdpi", - "drawable-tr-xxhdpi", - "drawable-tr-xxxhdpi", - "drawable-watch", - "layout-watch", - ] - - for folder in folders: - if os.path.exists(f"{path}/{folder}"): - shutil.rmtree(f"{path}/{folder}") - if config.get("verbose", False): - tqdm.write(f"Удалена директория: {path}/{folder}") - return True - - -def apply(config: Config, base: Dict[str, Any]) -> bool: - if config.remove_unknown_files: - tqdm.write(f"Удаление неизвестных файлов...") - remove_unknown_files(config, base) - - if config.remove_drawable_files: - tqdm.write(f"Удаление директорий drawable-xx...") - remove_drawable_files(base) - - if config.compress_png_files: - tqdm.write(f"Сжатие PNG файлов...") - compress_png_files(base) - - if config.remove_language_files: - tqdm.write(f"Удаление языков...") - remove_language_files(base) - - if config.remove_AI_voiceover: - tqdm.write(f"Удаление ИИ озвучки...") - remove_AI_voiceover(base) - - if config.remove_debug_lines: - tqdm.write(f"Удаление дебаг линий...") - remove_debug_lines(base) - - return True + return True diff --git a/patches/disable_ad.py b/patches/disable_ad.py index ffe7a90..8549607 100644 --- a/patches/disable_ad.py +++ b/patches/disable_ad.py @@ -5,44 +5,43 @@ } """ -priority = 0 - -# imports +__author__ = "Kentai Radiquum " +__version__ = "1.0.0" import textwrap -from tqdm import tqdm -from typing import Dict, Any +from typing import Any, Dict -from utils.config import PatchConfig -from utils.smali_parser import ( - find_smali_method_end, - find_smali_method_start, - get_smali_lines, - replace_smali_method_body, -) +from pydantic import Field + +from utils.config import PatchTemplate +from utils.smali_parser import (find_smali_method_end, find_smali_method_start, + get_smali_lines, replace_smali_method_body) -#Config -class Config(PatchConfig): ... +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) + def apply(self, base: Dict[str, Any]) -> bool: + path = "./decompiled/smali_classes2/com/swiftsoft/anixartd/Prefs.smali" + replacement = [ + f"\t{line}\n" + for line in textwrap.dedent( + """\ + .locals 0 + const/4 p0, 0x1 + return p0 + """ + ).splitlines() + ] -# Patch -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()] + lines = get_smali_lines(path) + for index, line in enumerate(lines): + if line.find("IS_SPONSOR") >= 0: + method_start = find_smali_method_start(lines, index) + method_end = find_smali_method_end(lines, index) + new_content = replace_smali_method_body( + lines, method_start, method_end, replacement + ) - path = "./decompiled/smali_classes2/com/swiftsoft/anixartd/Prefs.smali" - lines = get_smali_lines(path) - for index, line in enumerate(lines): - if line.find("IS_SPONSOR") >= 0: - method_start = find_smali_method_start(lines, index) - method_end = find_smali_method_end(lines, index) - new_content = replace_smali_method_body( - lines, method_start, method_end, replacement - ) - - with open(path, "w", encoding="utf-8") as file: - file.writelines(new_content) - return True + with open(path, "w", encoding="utf-8") as file: + file.writelines(new_content) + return True diff --git a/patches/disable_beta_banner.py b/patches/disable_beta_banner.py index 08fbc97..27f5f80 100644 --- a/patches/disable_beta_banner.py +++ b/patches/disable_beta_banner.py @@ -5,48 +5,52 @@ } """ -priority = 0 - -# imports +__author__ = "Kentai Radiquum " +__version__ = "1.0.0" import os -from tqdm import tqdm -from lxml import etree -from typing import Dict, Any +from typing import Any, Dict -from utils.config import PatchConfig +from lxml import etree +from pydantic import Field +from tqdm import tqdm + +from utils.config import PatchTemplate from utils.smali_parser import get_smali_lines, save_smali_lines -#Config -class Config(PatchConfig): ... -# Patch -def apply(config: Config, base: Dict[str, Any]) -> bool: - attributes = [ - "paddingTop", - "paddingBottom", - "paddingStart", - "paddingEnd", - "layout_width", - "layout_height", - "layout_marginTop", - "layout_marginBottom", - "layout_marginStart", - "layout_marginEnd", - ] +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) - beta_banner_xml = "./decompiled/res/layout/item_beta.xml" - if os.path.exists(beta_banner_xml): - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(beta_banner_xml, parser) - root = tree.getroot() + def apply(self, base: Dict[str, Any]) -> bool: + beta_banner_xml = "./decompiled/res/layout/item_beta.xml" + attributes = [ + "paddingTop", + "paddingBottom", + "paddingStart", + "paddingEnd", + "layout_width", + "layout_height", + "layout_marginTop", + "layout_marginBottom", + "layout_marginStart", + "layout_marginEnd", + ] - for attr in attributes: - if base.get("verbose", False): - tqdm.write(f"set {attr} = 0.0dip") - root.set(f"{{{base['xml_ns']['android']}}}{attr}", "0.0dip") + if os.path.exists(beta_banner_xml): + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(beta_banner_xml, parser) + root = tree.getroot() - tree.write( - beta_banner_xml, pretty_print=True, xml_declaration=True, encoding="utf-8" - ) + for attr in attributes: + if base.get("verbose", False): + tqdm.write(f"set {attr} = 0.0dip") + root.set(f"{{{base['xml_ns']['android']}}}{attr}", "0.0dip") - return True + tree.write( + beta_banner_xml, + pretty_print=True, + xml_declaration=True, + encoding="utf-8", + ) + + return True diff --git a/patches/package_name.py b/patches/package_name.py index ed160ff..4ea35b5 100644 --- a/patches/package_name.py +++ b/patches/package_name.py @@ -6,116 +6,114 @@ } """ -priority = -1 - -# imports +__author__ = "wowlikon " +__version__ = "1.0.0" import os -from tqdm import tqdm +from typing import Any, Dict + from lxml import etree -from typing import Dict, Any from pydantic import Field -from utils.config import PatchConfig +from utils.config import PatchTemplate -#Config -class Config(PatchConfig): + +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=-1) package_name: str = Field("com.wowlikon.anixart", description="Название пакета") -# Patch -def rename_dir(src, dst): - os.makedirs(os.path.dirname(dst), exist_ok=True) - os.rename(src, dst) + def rename_dir(self, src, dst): + os.makedirs(os.path.dirname(dst), exist_ok=True) + os.rename(src, dst) + def apply(self, base: Dict[str, Any]) -> bool: + for root, dirs, files in os.walk("./decompiled"): + for filename in files: + file_path = os.path.join(root, filename) -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) + if os.path.isfile(file_path): + try: # Изменяем имя пакета в файлах + with open(file_path, "r", encoding="utf-8") as file: + file_contents = file.read() - if os.path.isfile(file_path): - try: # Изменяем имя пакета в файлах - with open(file_path, "r", encoding="utf-8") as file: - file_contents = file.read() + new_contents = file_contents.replace( + "com.swiftsoft.anixartd", self.package_name + ) + new_contents = new_contents.replace( + "com/swiftsoft/anixartd", + self.package_name.replace(".", "/"), + ).replace( + "com/swiftsoft", + "/".join(self.package_name.split(".")[:2]), + ) + with open(file_path, "w", encoding="utf-8") as file: + file.write(new_contents) + except: + pass - new_contents = file_contents.replace( - "com.swiftsoft.anixartd", config.package_name - ) - new_contents = new_contents.replace( - "com/swiftsoft/anixartd", - config.package_name.replace(".", "/"), - ).replace( - "com/swiftsoft", - "/".join(config.package_name.split(".")[:2]), - ) - with open(file_path, "w", encoding="utf-8") as file: - file.write(new_contents) - except: - pass + # Изменяем названия папок + if os.path.exists("./decompiled/smali/com/swiftsoft/anixartd"): + self.rename_dir( + "./decompiled/smali/com/swiftsoft/anixartd", + os.path.join( + "./decompiled", "smali", self.package_name.replace(".", "/") + ), + ) + if os.path.exists("./decompiled/smali_classes2/com/swiftsoft/anixartd"): + self.rename_dir( + "./decompiled/smali_classes2/com/swiftsoft/anixartd", + os.path.join( + "./decompiled", + "smali_classes2", + self.package_name.replace(".", "/"), + ), + ) + if os.path.exists("./decompiled/smali_classes4/com/swiftsoft"): + self.rename_dir( + "./decompiled/smali_classes4/com/swiftsoft", + os.path.join( + "./decompiled", + "smali_classes4", + "/".join(self.package_name.split(".")[:2]), + ), + ) - # Изменяем названия папок - if os.path.exists("./decompiled/smali/com/swiftsoft/anixartd"): - rename_dir( - "./decompiled/smali/com/swiftsoft/anixartd", - os.path.join( - "./decompiled", "smali", config.package_name.replace(".", "/") - ), - ) - if os.path.exists("./decompiled/smali_classes2/com/swiftsoft/anixartd"): - rename_dir( - "./decompiled/smali_classes2/com/swiftsoft/anixartd", - os.path.join( - "./decompiled", - "smali_classes2", - config.package_name.replace(".", "/"), - ), - ) - if os.path.exists("./decompiled/smali_classes4/com/swiftsoft"): - rename_dir( - "./decompiled/smali_classes4/com/swiftsoft", - os.path.join( - "./decompiled", - "smali_classes4", - "/".join(config.package_name.split(".")[:2]), - ), - ) + # rename_dir( + # "./decompiled/smali_classes3/com/swiftsoft/anixartd", + # os.path.join( + # "./decompiled", + # "smali_classes3", + # config["new_package_name"].replace(".", "/"), + # ), + # ) - # rename_dir( - # "./decompiled/smali_classes3/com/swiftsoft/anixartd", - # os.path.join( - # "./decompiled", - # "smali_classes3", - # config["new_package_name"].replace(".", "/"), - # ), - # ) + # Замена названия пакета для smali_classes4 + for root, dirs, files in os.walk("./decompiled/smali_classes4/"): + for filename in files: + file_path = os.path.join(root, filename) - # Замена названия пакета для smali_classes4 - for root, dirs, files in os.walk("./decompiled/smali_classes4/"): - for filename in files: - file_path = os.path.join(root, filename) + if os.path.isfile(file_path): + try: + with open(file_path, "r", encoding="utf-8") as file: + file_contents = file.read() - if os.path.isfile(file_path): - try: - with open(file_path, "r", encoding="utf-8") as file: - file_contents = file.read() + new_contents = file_contents.replace( + "com/swiftsoft", + "/".join(self.package_name.split(".")[:-1]), + ) + with open(file_path, "w", encoding="utf-8") as file: + file.write(new_contents) + except: + pass - new_contents = file_contents.replace( - "com/swiftsoft", - "/".join(config.package_name.split(".")[:-1]), - ) - with open(file_path, "w", encoding="utf-8") as file: - file.write(new_contents) - except: - pass + # Скрытие входа по Google и VK (НЕ РАБОТАЮТ В МОДАХ) + file_path = "./decompiled/res/layout/fragment_sign_in.xml" + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - # Скрытие входа по Google и VK (НЕ РАБОТАЮТ В МОДАХ) - file_path = "./decompiled/res/layout/fragment_sign_in.xml" - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + last_linear = root.xpath("//LinearLayout/LinearLayout[4]")[0] + last_linear.set(f"{{{base['xml_ns']['android']}}}visibility", "gone") - last_linear = root.xpath("//LinearLayout/LinearLayout[4]")[0] - last_linear.set(f"{{{base['xml_ns']['android']}}}visibility", "gone") + tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") - tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") - - return True + return True diff --git a/patches/replace_navbar.py b/patches/replace_navbar.py index e273458..fca5b5f 100644 --- a/patches/replace_navbar.py +++ b/patches/replace_navbar.py @@ -7,56 +7,61 @@ } """ -priority = 0 +__author__ = "wowlikon " +__version__ = "1.0.0" +from typing import Any, Dict, List -# imports from lxml import etree -from tqdm import tqdm -from typing import Dict, List, Any from pydantic import Field +from tqdm import tqdm -from utils.config import PatchConfig +from utils.config import PatchTemplate -#Config -class Config(PatchConfig): - items: List[str] = Field(["home", "discover", "feed", "bookmarks", "profile"], description="Список элементов в панели навигации") -# Patch -def apply(config: Config, base: Dict[str, Any]) -> bool: - file_path = "./decompiled/res/menu/bottom.xml" +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) + items: List[str] = Field( + ["home", "discover", "feed", "bookmarks", "profile"], + description="Список элементов в панели навигации", + ) - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + def apply(self, base: Dict[str, Any]) -> bool: + file_path = "./decompiled/res/menu/bottom.xml" - # Получение элементов панели навигации - items = root.findall("item", namespaces=base['xml_ns']) + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - def get_id_suffix(item): - full_id = item.get(f"{{{base['xml_ns']['android']}}}id") - return full_id.split("tab_")[-1] if full_id else None + # Получение элементов панели навигации + items = root.findall("item", namespaces=base["xml_ns"]) - items_by_id = {get_id_suffix(item): item for item in items} - existing_order = [get_id_suffix(item) for item in items] + def get_id_suffix(item): + full_id = item.get(f"{{{base['xml_ns']['android']}}}id") + return full_id.split("tab_")[-1] if full_id else None - # Размещение в новом порядке - ordered_items = [] - for key in config.items: - if key in items_by_id: - ordered_items.append(items_by_id[key]) + items_by_id = {get_id_suffix(item): item for item in items} + existing_order = [get_id_suffix(item) for item in 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) + # Размещение в новом порядке + ordered_items = [] + for key in self.items: + if key in items_by_id: + ordered_items.append(items_by_id[key]) - for i in root.findall("item", namespaces=base['xml_ns']): - root.remove(i) + # Если есть не указанные в конфиге они помещаются в конец списка + extra = [i for i in items if get_id_suffix(i) not in self.items] + if extra: + tqdm.write( + "⚠Найдены лишние элементы: " + str([get_id_suffix(i) for i in extra]) + ) + ordered_items.extend(extra) - for item in ordered_items: - root.append(item) + for i in root.findall("item", namespaces=base["xml_ns"]): + root.remove(i) - tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") + for item in ordered_items: + root.append(item) - return True + tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") + + return True diff --git a/patches/selectable_text.py b/patches/selectable_text.py index 3ff4511..480ea3f 100644 --- a/patches/selectable_text.py +++ b/patches/selectable_text.py @@ -5,38 +5,41 @@ } """ -priority = 0 +__author__ = "wowlikon " +__version__ = "1.0.0" +from typing import Any, Dict -# imports -from tqdm import tqdm from lxml import etree -from typing import Dict, Any from pydantic import Field -from utils.config import PatchConfig +from utils.config import PatchTemplate -#Config -class Config(PatchConfig): ... -# Patch -def apply(config: Config, base: Dict[str, Any]) -> bool: +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) - file_path = "./decompiled/res/layout/release_info.xml" + def apply(self, base: Dict[str, Any]) -> bool: + file_path = "./decompiled/res/layout/release_info.xml" - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - # Список тегов, к которым нужно добавить атрибут - tags = ["TextView", "at.blogc.android.views.ExpandableTextView"] + # Список тегов, к которым нужно добавить атрибут + tags = ["TextView", "at.blogc.android.views.ExpandableTextView"] - for tag in tags: - for element in root.findall(f".//{tag}", namespaces=base["xml_ns"]): - # Проверяем, нет ли уже атрибута - if f"{{{base['xml_ns']['android']}}}textIsSelectable" not in element.attrib: - element.set(f"{{{base['xml_ns']['android']}}}textIsSelectable", "true") + for tag in tags: + for element in root.findall(f".//{tag}", namespaces=base["xml_ns"]): + # Проверяем, нет ли уже атрибута + if ( + f"{{{base['xml_ns']['android']}}}textIsSelectable" + not in element.attrib + ): + element.set( + f"{{{base['xml_ns']['android']}}}textIsSelectable", "true" + ) - # Сохраняем - tree.write(file_path, encoding="utf-8", xml_declaration=True, pretty_print=True) + # Сохраняем + tree.write(file_path, encoding="utf-8", xml_declaration=True, pretty_print=True) - return True + return True diff --git a/patches/settings_urls.py b/patches/settings_urls.py index 0ca43a0..90c23cb 100644 --- a/patches/settings_urls.py +++ b/patches/settings_urls.py @@ -20,119 +20,128 @@ } """ -priority = 0 - -# imports +__author__ = "wowlikon " +__version__ = "1.0.0" import shutil +from typing import Any, Dict, List + 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.config import PatchTemplate from utils.public import insert_after_public -#Config +# 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" - } - ] + "Мы в социальных сетях": [ + { + "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): + +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) version: str = Field(" by wowlikon", description="Суффикс версии") menu: Dict[str, List[Dict[str, str]]] = Field(DEFAULT_MENU, description="Меню") -# Patch -def make_category(ns, name, items): - cat = etree.Element("PreferenceCategory", nsmap=ns) - cat.set(f"{{{ns['android']}}}title", name) - cat.set(f"{{{ns['app']}}}iconSpaceReserved", "false") + def make_category(self, ns, name, items): + cat = etree.Element("PreferenceCategory", nsmap=ns) + cat.set(f"{{{ns['android']}}}title", name) + cat.set(f"{{{ns['app']}}}iconSpaceReserved", "false") - for item in items: - pref = etree.SubElement(cat, "Preference", nsmap=ns) - pref.set(f"{{{ns['android']}}}title", item["title"]) - pref.set(f"{{{ns['android']}}}summary", item["description"]) - pref.set(f"{{{ns['app']}}}icon", item["icon"]) - pref.set(f"{{{ns['app']}}}iconSpaceReserved", item["icon_space_reserved"]) + for item in items: + pref = etree.SubElement(cat, "Preference", nsmap=ns) + pref.set(f"{{{ns['android']}}}title", item["title"]) + pref.set(f"{{{ns['android']}}}summary", item["description"]) + pref.set(f"{{{ns['app']}}}icon", item["icon"]) + pref.set(f"{{{ns['app']}}}iconSpaceReserved", item["icon_space_reserved"]) - intent = etree.SubElement(pref, "intent", nsmap=ns) - intent.set(f"{{{ns['android']}}}action", "android.intent.action.VIEW") - intent.set(f"{{{ns['android']}}}data", item["url"]) - intent.set(f"{{{ns['app']}}}iconSpaceReserved", item["icon_space_reserved"]) + intent = etree.SubElement(pref, "intent", nsmap=ns) + intent.set(f"{{{ns['android']}}}action", "android.intent.action.VIEW") + intent.set(f"{{{ns['android']}}}data", item["url"]) + intent.set(f"{{{ns['app']}}}iconSpaceReserved", item["icon_space_reserved"]) - return cat + return cat -def apply(config: Config, base: Dict[str, Any]) -> bool: - # Добавление кастомных иконок - shutil.copy( - "./resources/ic_custom_crown.xml", - "./decompiled/res/drawable/ic_custom_crown.xml", - ) - insert_after_public("warning_error_counter_background", "ic_custom_crown") + def apply(self, base: Dict[str, Any]) -> bool: + # Добавление кастомных иконок + shutil.copy( + "./resources/ic_custom_crown.xml", + "./decompiled/res/drawable/ic_custom_crown.xml", + ) + insert_after_public("warning_error_counter_background", "ic_custom_crown") - shutil.copy( - "./resources/ic_custom_telegram.xml", - "./decompiled/res/drawable/ic_custom_telegram.xml", - ) - insert_after_public("warning_error_counter_background", "ic_custom_telegram") + shutil.copy( + "./resources/ic_custom_telegram.xml", + "./decompiled/res/drawable/ic_custom_telegram.xml", + ) + insert_after_public("warning_error_counter_background", "ic_custom_telegram") - file_path = "./decompiled/res/xml/preference_main.xml" - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + file_path = "./decompiled/res/xml/preference_main.xml" + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - # Вставка новых пунктов перед последним - pos = root.index(root[-1]) - for section, items in config.menu.items(): - root.insert(pos, make_category(base["xml_ns"], section, items)) - pos += 1 + # Вставка новых пунктов перед последним + pos = root.index(root[-1]) + for section, items in self.menu.items(): + root.insert(pos, self.make_category(base["xml_ns"], section, items)) + pos += 1 - # Сохранение - tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") + # Сохранение + tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") - # Добавление суффикса версии - filepaths = [ - "./decompiled/smali_classes2/com/swiftsoft/anixartd/ui/activity/UpdateActivity.smali", - "./decompiled/smali_classes2/com/swiftsoft/anixartd/ui/fragment/main/preference/MainPreferenceFragment.smali", - ] - for filepath in filepaths: - content = "" - 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('"'):] - else: - content += line - with open(filepath, "w", encoding="utf-8") as file: - file.write(content) - return True + # Добавление суффикса версии + filepaths = [ + "./decompiled/smali_classes2/com/swiftsoft/anixartd/ui/activity/UpdateActivity.smali", + "./decompiled/smali_classes2/com/swiftsoft/anixartd/ui/fragment/main/preference/MainPreferenceFragment.smali", + ] + for filepath in filepaths: + content = "" + 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('"')] + + self.version + + line[line.rindex('"') :] + ) + else: + content += line + with open(filepath, "w", encoding="utf-8") as file: + file.write(content) + return True diff --git a/patches/share_links.py b/patches/share_links.py index 5dbb1d3..d6ecfa9 100644 --- a/patches/share_links.py +++ b/patches/share_links.py @@ -11,43 +11,43 @@ } """ -priority = 0 +__author__ = "wowlikon " +__version__ = "1.0.0" +from typing import Any, Dict -# imports -from tqdm import tqdm from lxml import etree -from typing import Dict, Any from pydantic import Field -from utils.config import PatchConfig +from utils.config import PatchTemplate -#Config DEFAULT_FORMATS = { "share_channel_text": "Канал: «%1$s»\n%2$schannel/%3$d", "share_collection_text": "Коллекция: «%1$s»\n%2$scollection/%3$d", "share_profile_text": "Профиль пользователя «%1$s»\n%2$sprofile/%3$d", - "share_release_text": "Релиз: «%1$s»\n%2$srelease/%3$d" + "share_release_text": "Релиз: «%1$s»\n%2$srelease/%3$d", } -class Config(PatchConfig): - format: Dict[str, str] = Field(DEFAULT_FORMATS, description="Строки для замены в `strings.xml`") -# Patch -def apply(config: Config, base: Dict[str, Any]) -> bool: +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) + format: Dict[str, str] = Field( + DEFAULT_FORMATS, description="Строки для замены в `strings.xml`" + ) - file_path = "./decompiled/res/values/strings.xml" + def apply(self, base: Dict[str, Any]) -> bool: + file_path = "./decompiled/res/values/strings.xml" - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser) - root = tree.getroot() + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.parse(file_path, parser) + root = tree.getroot() - # Обновляем значения - for string in root.findall("string"): - name = string.get("name") - if name in config.format: - string.text = config.format[name] + # Обновляем значения + for string in root.findall("string"): + name = string.get("name") + if name in self.format: + string.text = self.format[name] - # Сохраняем обратно - tree.write(file_path, encoding="utf-8", xml_declaration=True, pretty_print=True) + # Сохраняем обратно + tree.write(file_path, encoding="utf-8", xml_declaration=True, pretty_print=True) - return True + return True diff --git a/patches/todo_custom_speed.py b/patches/todo_custom_speed.py index d7f6f59..668a5f6 100644 --- a/patches/todo_custom_speed.py +++ b/patches/todo_custom_speed.py @@ -6,33 +6,30 @@ } """ -priority = 0 +__author__ = "wowlikon " +__version__ = "1.0.0" +from typing import Any, Dict, List -# imports -from tqdm import tqdm -from typing import Dict, List, Any from pydantic import Field -from utils.config import PatchConfig +from utils.config import PatchTemplate +from utils.public import insert_after_id, insert_after_public 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: Config, base: Dict[str, Any]) -> bool: - assert float_to_hex(1.5) == "0x3fc00000" +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) + speeds: List[float] = Field( + [9.0], description="Список пользовательских скоростей воспроизведения" + ) - last = "speed75" - 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)}" + def apply(self, base: Dict[str, Any]) -> bool: + assert float_to_hex(1.5) == "0x3fc00000" - return False + last = "speed75" + for speed in self.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)}" + + return False diff --git a/patches/todo_example.py b/patches/todo_example.py index ece6039..39c830e 100644 --- a/patches/todo_example.py +++ b/patches/todo_example.py @@ -4,7 +4,7 @@ Каждый патч должен независеть от других патчей и проверять себя при применении. Он не должен вернуть True, если есть проблемы. На данный момент каждый патч должен иметь функцию `apply`, которая принимает на вход конфигурацию и возвращает True или False. -И модель `Config`, которая наследуется от `PatchConfig` (поле `enabled` добавлять не нужно). +И модель `Config`, которая наследуется от `PatchTemplate` (поле `enabled` добавлять не нужно). Это позволяет упростить конфигурирование и проверять типы данных, и делать значения по умолчанию. При успешном применении патча, функция apply должна вернуть True, иначе False. Ошибка будет интерпретирована как False. С выводом ошибки в консоль. @@ -25,24 +25,29 @@ python ./main.py build --verbose } """ -priority = 0 # Приоритет патча, чем выше, тем раньше он будет применен +__author__ = "wowlikon " +__version__ = "1.0.0" +from typing import Any, Dict, List -# imports -from tqdm import tqdm -from typing import Dict, List, Any from pydantic import Field +from tqdm import tqdm -from utils.config import PatchConfig +from utils.config import PatchTemplate -#Config -class Config(PatchConfig): + +class Patch(PatchTemplate): example: bool = Field(True, description="Пример кастомного параметра") - -# Patch -def apply(config: Config, base: Dict[str, Any]) -> bool: # Анотации типов для удобства, читаемости и поддержки IDE - tqdm.write("Вывод информации через tqdm, чтобы не мешать прогресс-бару") - tqdm.write("Пример включен" if config.example else "Пример отключен") - if base["verbose"]: - tqdm.write("Для вывода подробной и отладочной информации используйте флаг --verbose") - return True + def apply( + self, base: Dict[str, Any] + ) -> bool: # Анотации типов для удобства, читаемости и поддержки IDE + priority: int = Field( + frozen=True, exclude=True, default=0 + ) # Приоритет патча, чем выше, тем раньше он будет применен + tqdm.write("Вывод информации через tqdm, чтобы не мешать прогресс-бару") + tqdm.write("Пример включен" if self.example else "Пример отключен") + if base["verbose"]: + tqdm.write( + "Для вывода подробной и отладочной информации используйте флаг --verbose" + ) + return True diff --git a/patches/welcome.py b/patches/welcome.py index 508f9d4..ff9edf3 100644 --- a/patches/welcome.py +++ b/patches/welcome.py @@ -11,22 +11,20 @@ } """ -priority = 0 - -# imports -import os +__author__ = "wowlikon " +__version__ = "1.0.0" import shutil -from pydantic import Field -from typing import Dict, Any -from utils.config import PatchConfig -from utils.smali_parser import ( - find_and_replace_smali_line, - get_smali_lines, - save_smali_lines -) +from typing import Any, Dict -#Config -class Config(PatchConfig): +from pydantic import Field + +from utils.config import PatchTemplate +from utils.smali_parser import (find_and_replace_smali_line, get_smali_lines, + save_smali_lines) + + +class Patch(PatchTemplate): + priority: int = Field(frozen=True, exclude=True, default=0) title: str = Field("Anixarty", description="Заголовок") description: str = Field("Описание", description="Описание") link_text: str = Field("МЫ В TELEGRAM", description="Текст ссылки") @@ -34,23 +32,24 @@ class Config(PatchConfig): skip_text: str = Field("Пропустить", description="Текст кнопки пропуска") title_bg_color: str = Field("#FFFFFF", description="Цвет фона заголовка") + def apply(self, base: Dict[str, Any]) -> bool: + file_path = "./decompiled/smali_classes2/com/swiftsoft/anixartd/ui/activity/MainActivity.smali" + # Добавление ресурсов окна первого входа + shutil.copy("./resources/avatar.png", "./decompiled/assets/avatar.png") + shutil.copy( + "./resources/OpenSans-Regular.ttf", + "./decompiled/assets/OpenSans-Regular.ttf", + ) + shutil.copytree("./resources/smali_classes4/", "./decompiled/smali_classes4/") -# Patch -def apply(config: Config, base: Dict[str, Any]) -> bool: - # Добавление ресурсов окна первого входа - shutil.copy("./resources/avatar.png", "./decompiled/assets/avatar.png") - shutil.copy( - "./resources/OpenSans-Regular.ttf", - "./decompiled/assets/OpenSans-Regular.ttf", - ) - shutil.copytree( - "./resources/smali_classes4/", "./decompiled/smali_classes4/" - ) + method = "invoke-super {p0}, Lmoxy/MvpAppCompatActivity;->onResume()V" + lines = get_smali_lines(file_path) + lines = find_and_replace_smali_line( + lines, + method, + method + + "\ninvoke-static {p0}, Lcom/swiftsoft/about/$2;->oooooo(Landroid/content/Context;)Ljava/lang/Object;", + ) + save_smali_lines(file_path, lines) - file_path = "./decompiled/smali_classes2/com/swiftsoft/anixartd/ui/activity/MainActivity.smali" - method = "invoke-super {p0}, Lmoxy/MvpAppCompatActivity;->onResume()V" - lines = get_smali_lines(file_path) - lines = find_and_replace_smali_line(lines, method, method+"\ninvoke-static {p0}, Lcom/swiftsoft/about/$2;->oooooo(Landroid/content/Context;)Ljava/lang/Object;") - save_smali_lines(file_path, lines) - - return True + return True diff --git a/utils/apk.py b/utils/apk.py new file mode 100644 index 0000000..60025a4 --- /dev/null +++ b/utils/apk.py @@ -0,0 +1,228 @@ +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Optional + +import yaml +from pydantic import BaseModel, Field, computed_field +from rich.console import Console + +from utils.tools import DECOMPILED, MODIFIED, TOOLS, run + + +class APKMeta(BaseModel): + """Метаданные APK файла""" + + version_code: int = Field(default=0) + version_name: str = Field(default="unknown") + package: str = Field(default="unknown") + path: Path + + @computed_field + @property + def safe_version(self) -> str: + """Версия, безопасная для использования в именах файлов""" + return self.version_name.lower().replace(" ", "-").replace(".", "-") + + @computed_field + @property + def output_name(self) -> str: + """Имя выходного файла""" + return f"Anixarty-v{self.safe_version}.apk" + + @computed_field + @property + def aligned_name(self) -> str: + """Имя выровненного файла""" + return f"Anixarty-v{self.safe_version}-aligned.apk" + + @computed_field + @property + def signed_name(self) -> str: + """Имя подписанного файла""" + return f"Anixarty-v{self.safe_version}-mod.apk" + + +class SigningConfig(BaseModel): + """Конфигурация подписи APK""" + + keystore: Path = Field(default=Path("keystore.jks")) + keystore_pass_file: Path = Field(default=Path("keystore.pass")) + v1_signing: bool = Field(default=False) + v2_signing: bool = Field(default=True) + v3_signing: bool = Field(default=True) + + +class APKProcessor: + """Класс для работы с APK файлами""" + + def __init__(self, console: Console, tools_dir: Path = TOOLS): + self.console = console + self.tools_dir = tools_dir + self.apktool_jar = tools_dir / "apktool.jar" + + def decompile(self, apk: Path, output: Path = DECOMPILED) -> None: + """Декомпилирует APK файл""" + self.console.print("[yellow]Декомпиляция APK...") + run( + self.console, + [ + "java", + "-jar", + str(self.apktool_jar), + "d", + "-f", + "-o", + str(output), + str(apk), + ], + ) + self.console.print("[green]✔ Декомпиляция завершена") + + def compile(self, source: Path, output: Path) -> None: + """Компилирует APK из исходников""" + self.console.print("[yellow]Сборка APK...") + run( + self.console, + [ + "java", + "-jar", + str(self.apktool_jar), + "b", + str(source), + "-o", + str(output), + ], + ) + self.console.print("[green]✔ Сборка завершена") + + def align(self, input_apk: Path, output_apk: Path) -> None: + """Выравнивает APK с помощью zipalign""" + self.console.print("[yellow]Выравнивание APK...") + run( + self.console, ["zipalign", "-f", "-v", "4", str(input_apk), str(output_apk)] + ) + self.console.print("[green]✔ Выравнивание завершено") + + def sign( + self, + input_apk: Path, + output_apk: Path, + config: Optional[SigningConfig] = None, + ) -> None: + """Подписывает APK""" + if config is None: + config = SigningConfig() + + self.console.print("[yellow]Подпись APK...") + run( + self.console, + [ + "apksigner", + "sign", + "--v1-signing-enabled", + str(config.v1_signing).lower(), + "--v2-signing-enabled", + str(config.v2_signing).lower(), + "--v3-signing-enabled", + str(config.v3_signing).lower(), + "--ks", + str(config.keystore), + "--ks-pass", + f"file:{config.keystore_pass_file}", + "--out", + str(output_apk), + str(input_apk), + ], + ) + self.console.print("[green]✔ Подпись завершена") + + def _get_package_name_from_manifest(self, decompiled_path: Path) -> str: + """Читает имя пакета напрямую из AndroidManifest.xml""" + manifest_path = decompiled_path / "AndroidManifest.xml" + if not manifest_path.exists(): + return "unknown" + + try: + tree = ET.parse(manifest_path) + root = tree.getroot() + return root.get("package", "unknown") + except Exception: + return "unknown" + + def get_meta(self, decompiled: Path = DECOMPILED) -> APKMeta: + """Извлекает метаданные из декомпилированного APK""" + apktool_yml = decompiled / "apktool.yml" + + if not apktool_yml.exists(): + raise FileNotFoundError(f"Файл {apktool_yml} не найден") + + with open(apktool_yml, encoding="utf-8") as f: + meta = yaml.safe_load(f) + + version_info = meta.get("versionInfo", {}) + + package_name = self._get_package_name_from_manifest(decompiled) + + if package_name == "unknown": + package_info = meta_yaml.get("packageInfo", {}) + package_name = package_info.get("renameManifestPackage") or "unknown" + + return APKMeta( + version_code=version_info.get("versionCode", 0), + version_name=version_info.get("versionName", "unknown"), + package=package_name, + path=decompiled, + ) + + def _extract_package_from_manifest(self, decompiled: Path) -> str | None: + """Извлекает имя пакета из AndroidManifest.xml""" + manifest = decompiled / "AndroidManifest.xml" + + if not manifest.exists(): + return None + + try: + import re + + content = manifest.read_text(encoding="utf-8") + match = re.search(r'package="([^"]+)"', content) + if match: + return match.group(1) + except Exception: + pass + + return None + + def build_and_sign( + self, + source: Path = DECOMPILED, + output_dir: Path = MODIFIED, + signing_config: Optional[SigningConfig] = None, + cleanup: bool = True, + ) -> tuple[Path, APKMeta]: + """ + Полный цикл сборки: компиляция, выравнивание, подпись. + Возвращает путь к подписанному APK и метаданные. + """ + meta = self.get_meta(source) + + out_apk = output_dir / meta.output_name + aligned_apk = output_dir / meta.aligned_name + signed_apk = output_dir / meta.signed_name + + for f in [out_apk, aligned_apk, signed_apk]: + f.unlink(missing_ok=True) + + self.compile(source, out_apk) + self.align(out_apk, aligned_apk) + self.sign(aligned_apk, signed_apk, signing_config) + + if cleanup: + out_apk.unlink(missing_ok=True) + aligned_apk.unlink(missing_ok=True) + idsig = signed_apk.with_suffix(".apk.idsig") + idsig.unlink(missing_ok=True) + + self.console.print(f"[green]✔ APK готов: {signed_apk.name}") + + return signed_apk, meta diff --git a/utils/config.py b/utils/config.py index 2488f2c..28cd711 100644 --- a/utils/config.py +++ b/utils/config.py @@ -1,30 +1,137 @@ -from pydantic import BaseModel, Field, ValidationError -from rich.console import Console -from typing import Dict, Any +import json +import traceback +from abc import ABC, abstractmethod from pathlib import Path -import typer +from typing import Any, Dict, Literal + +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator +from rich.console import Console + +from utils.tools import CONFIGS class ToolsConfig(BaseModel): apktool_jar_url: str apktool_wrapper_url: str + @field_validator("apktool_jar_url", "apktool_wrapper_url") + @classmethod + def validate_url(cls, v: str) -> str: + if not v.startswith(("http://", "https://")): + raise ValueError("URL должен начинаться с http:// или https://") + return v + + +class SigningConfig(BaseModel): + keystore: Path = Field(default=Path("keystore.jks")) + keystore_pass_file: Path = Field(default=Path("keystore.pass")) + v1_signing: bool = False + v2_signing: bool = True + v3_signing: bool = True + + +class BuildConfig(BaseModel): + verbose: bool = False + force: bool = False + clean_after_build: bool = True + class Config(BaseModel): tools: ToolsConfig - base: Dict[str, Any] - - -class PatchConfig(BaseModel): - enabled: bool = Field(True, description="Включить или отключить патч") + signing: SigningConfig = Field(default_factory=SigningConfig) + build: BuildConfig = Field(default_factory=BuildConfig) + base: Dict[str, Any] = Field(default_factory=dict) def load_config(console: Console) -> Config: - try: - return Config.model_validate_json(Path("config.json").read_text()) - except FileNotFoundError: + """Загружает и валидирует конфигурацию""" + config_path = Path("config.json") + + if not config_path.exists(): console.print("[red]Файл config.json не найден") raise typer.Exit(1) + + try: + return Config.model_validate_json(config_path.read_text()) except ValidationError as e: - console.print("[red]Ошибка валидации config.json:", e) + console.print(f"[red]Ошибка валидации config.json:\n{e}") raise typer.Exit(1) + + +class PatchTemplate(BaseModel, ABC): + model_config = ConfigDict(arbitrary_types_allowed=True, validate_default=True) + + enabled: bool = Field(default=True, description="Включить или отключить патч") + priority: int = Field(default=0, description="Приоритет применения патча") + + _name: str = PrivateAttr() + _applied: bool = PrivateAttr(default=False) + _console: Console | None = PrivateAttr(default=None) + + def __init__(self, name: str, console: Console, **data): + loaded_data = self._load_config_static(name, console) + + merged_data = {**loaded_data, **data} + + valid_fields = set(self.model_fields.keys()) + filtered_data = {k: v for k, v in merged_data.items() if k in valid_fields} + + super().__init__(**filtered_data) + self._name = name + self._console = console + self._applied = False + + @staticmethod + def _load_config_static(name: str, console: Console | None) -> Dict[str, Any]: + """Загружает конфигурацию из файла (статический метод)""" + config_path = CONFIGS / f"{name}.json" + try: + if config_path.exists(): + return json.loads(config_path.read_text()) + except Exception as e: + if console: + console.print( + f"[red]Ошибка при загрузке конфигурации патча {name}: {e}" + ) + console.print(f"[yellow]Используются значения по умолчанию") + return {} + + def save_config(self) -> None: + """Сохраняет конфигурацию в файл""" + config_path = CONFIGS / f"{self._name}.json" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(self.model_dump_json(indent=2)) + + @property + def name(self) -> str: + return self._name + + @property + def applied(self) -> bool: + return self._applied + + @applied.setter + def applied(self, value: bool) -> None: + self._applied = value + + @property + def console(self) -> Console | None: + return self._console + + @abstractmethod + def apply(self, base: Dict[str, Any]) -> Any: + raise NotImplementedError( + "Попытка применения шаблона патча, а не его реализации" + ) + + def safe_apply(self, base: Dict[str, Any]) -> bool: + """Безопасно применяет патч с обработкой ошибок""" + try: + self._applied = self.apply(base) + return self._applied + except Exception as e: + if self._console: + self._console.print(f"[red]Ошибка в патче {self._name}: {e}") + if base.get("verbose"): + self._console.print_exception() + return False diff --git a/utils/info.py b/utils/info.py new file mode 100644 index 0000000..c357416 --- /dev/null +++ b/utils/info.py @@ -0,0 +1,159 @@ +from typing import Any, get_args, get_origin + +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from rich.console import Console +from rich.table import Table + + +def format_field_type(annotation: Any) -> str: + """Форматирует тип поля для отображения""" + if annotation is None: + return "None" + + origin = get_origin(annotation) + + if origin is not None: + args = get_args(annotation) + origin_name = getattr(origin, "__name__", str(origin)) + + if origin_name == "UnionType" or str(origin) == "typing.Union": + args_str = " | ".join(format_field_type(a) for a in args) + return args_str + + if args: + args_str = ", ".join(format_field_type(a) for a in args) + return f"{origin_name}[{args_str}]" + return origin_name + + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + return f"[magenta]{annotation.__name__}[/magenta]" + + return getattr(annotation, "__name__", str(annotation)) + + +def print_model_fields( + console: Console, + model_class: type[BaseModel], + indent: int = 0, + visited: set | None = None, +) -> None: + """Рекурсивно выводит поля модели с поддержкой вложенных моделей""" + if visited is None: + visited = set() + + if model_class in visited: + console.print( + f"{' ' * indent}[dim](циклическая ссылка на {model_class.__name__})[/dim]" + ) + return + visited.add(model_class) + + prefix = " " * indent + + for field_name, field_info in model_class.model_fields.items(): + annotation = field_info.annotation + field_type = format_field_type(annotation) + default = field_info.default + description = field_info.description or "" + + if default is None: + default_str = "[dim]None[/dim]" + elif default is ...: + default_str = "[red]required[/red]" + elif isinstance(default, bool): + default_str = "[green]true[/green]" if default else "[red]false[/red]" + else: + default_str = str(default) + + console.print( + f"{prefix}[yellow]{field_name}[/yellow]: {field_type} = {default_str}" + + (f" [dim]# {description}[/dim]" if description else "") + ) + + nested_model = None + + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + nested_model = annotation + else: + origin = get_origin(annotation) + if origin is not None: + for arg in get_args(annotation): + if isinstance(arg, type) and issubclass(arg, BaseModel): + nested_model = arg + break + + if nested_model is not None: + console.print(f"{prefix} [dim]└─ {nested_model.__name__}:[/dim]") + print_model_fields(console, nested_model, indent + 2, visited.copy()) + + +def print_model_table( + console: Console, + model_class: type[BaseModel], + prefix: str = "", + visited: set | None = None, +) -> Table: + """Выводит поля модели в виде таблицы с вложенными моделями""" + if visited is None: + visited = set() + + table = Table(show_header=True, box=None if prefix else None) + table.add_column("Поле", style="yellow") + table.add_column("Тип", style="cyan") + table.add_column("По умолчанию") + table.add_column("Описание", style="dim") + + _add_model_rows(table, model_class, prefix, visited) + + return table + + +def _add_model_rows( + table: Table, + model_class: type[BaseModel], + prefix: str = "", + visited: set | None = None, +) -> None: + """Добавляет строки модели в таблицу рекурсивно""" + if visited is None: + visited = set() + + if model_class in visited: + return + visited.add(model_class) + + for field_name, field_info in model_class.model_fields.items(): + annotation = field_info.annotation + field_type = format_field_type(annotation) + default = field_info.default + description = field_info.description or "" + + if default is None: + default_str = "-" + elif default is ...: + default_str = "[red]required[/red]" + elif isinstance(default, bool): + default_str = "true" if default else "false" + elif isinstance(default, BaseModel): + default_str = "{...}" + else: + default_str = str(default)[:20] + + full_name = f"{prefix}{field_name}" if prefix else field_name + table.add_row(full_name, field_type, default_str, description[:40]) + + nested_model = None + + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + nested_model = annotation + else: + origin = get_origin(annotation) + if origin is not None: + for arg in get_args(annotation): + if isinstance(arg, type) and issubclass(arg, BaseModel): + nested_model = arg + break + + if nested_model is not None and nested_model not in visited: + _add_model_rows(table, nested_model, f" {full_name}.", visited.copy()) diff --git a/utils/patch_manager.py b/utils/patch_manager.py new file mode 100644 index 0000000..169d51a --- /dev/null +++ b/utils/patch_manager.py @@ -0,0 +1,107 @@ +import importlib +from contextlib import contextmanager +from functools import wraps +from pathlib import Path +from typing import Dict, List, Type + +import typer +from rich.console import Console + +from utils.config import PatchTemplate +from utils.tools import PATCHES + + +class PatcherError(Exception): + """Базовое исключение патчера""" + + pass + + +class ConfigError(PatcherError): + """Ошибка конфигурации""" + + pass + + +class BuildError(PatcherError): + """Ошибка сборки""" + + pass + + +def handle_errors(func): + """Декоратор для обработки ошибок CLI""" + + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except PatcherError as e: + Console().print(f"[red]Ошибка: {e}") + raise typer.Exit(1) + except KeyboardInterrupt: + Console().print("\n[yellow]Прервано пользователем") + raise typer.Exit(130) + + return wrapper + + +class PatchManager: + """Менеджер для работы с патчами""" + + def __init__(self, console: Console, patches_dir: Path = PATCHES): + self.console = console + self.patches_dir = patches_dir + + def discover_patches(self, include_todo: bool = False) -> List[str]: + """Находит все доступные патчи""" + patches = [] + for f in self.patches_dir.glob("*.py"): + if f.name == "__init__.py": + continue + if f.name.startswith("todo_") and not include_todo: + continue + patches.append(f.stem) + return patches + + def discover_all(self) -> Dict[str, List[str]]: + """Находит все патчи, разделяя на готовые и в разработке""" + ready = [] + todo = [] + + for f in self.patches_dir.glob("*.py"): + if f.name == "__init__.py": + continue + if f.name.startswith("todo_"): + todo.append(f.stem) + else: + ready.append(f.stem) + + return {"ready": ready, "todo": todo} + + def load_patch_module(self, name: str) -> type: + """Загружает модуль патча""" + module = importlib.import_module(f"patches.{name}") + return module + + def load_patch_class(self, name: str) -> type: + """Загружает класс патча""" + module = importlib.import_module(f"patches.{name}") + return module.Patch + + def load_patch(self, name: str) -> PatchTemplate: + """Загружает экземпляр патча""" + module = importlib.import_module(f"patches.{name}") + return module.Patch(name=name, console=self.console) + + def load_enabled_patches(self) -> List[PatchTemplate]: + """Загружает все включённые патчи, отсортированные по приоритету""" + patches = [] + for name in self.discover_patches(): + patch = self.load_patch(name) + if patch.enabled: + patches.append(patch) + else: + self.console.print(f"[dim]≫ Пропускаем {name}[/dim]") + + return sorted(patches, key=lambda p: p.priority, reverse=True) diff --git a/utils/public.py b/utils/public.py index 1d663c2..1ca1301 100644 --- a/utils/public.py +++ b/utils/public.py @@ -1,6 +1,7 @@ -from typing_extensions import Optional from copy import deepcopy + from lxml import etree +from typing_extensions import Optional def insert_after_public(anchor_name: str, elem_name: str) -> Optional[int]: diff --git a/utils/tools.py b/utils/tools.py index 454751f..a70529d 100644 --- a/utils/tools.py +++ b/utils/tools.py @@ -1,11 +1,11 @@ -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 - +from plumbum import FG, ProcessExecutionError, local +from rich.console import Console +from rich.progress import Progress TOOLS = Path("tools") ORIGINAL = Path("original") @@ -23,7 +23,7 @@ def ensure_dirs(): 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] + prog() if hide_output else prog & FG except ProcessExecutionError as e: console.print(f"[red]Ошибка при выполнении команды: {' '.join(cmd)}") console.print(e.stderr) @@ -31,6 +31,7 @@ def run(console: Console, cmd: List[str], hide_output=True): def download(console: Console, url: str, dest: Path): + """Скачивание файла по URL""" console.print(f"[cyan]Скачивание {url} → {dest.name}") with httpx.Client(follow_redirects=True, timeout=60.0) as client: @@ -45,3 +46,23 @@ def download(console: Console, url: str, dest: Path): for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) progress.update(task, advance=len(chunk)) + + +def select_apk(console) -> Path: + """Выбор APK файла из папки original""" + apks = list(ORIGINAL.glob("*.apk")) + + if not apks: + raise BuildError("Нет apk-файлов в папке original") + + if len(apks) == 1: + console.print(f"[green]Выбран {apks[0].name}") + return apks[0] + + console.print("[cyan]Доступные APK файлы:") + options = {str(i): apk for i, apk in enumerate(apks, 1)} + for k, v in options.items(): + console.print(f" {k}. {v.name}") + + choice = Prompt.ask("Выберите номер", choices=list(options.keys())) + return options[choice]