36 Commits

Author SHA1 Message Date
70337ee3ec Улучшение cli и удобства создания патчей
Сборка мода / build (push) Successful in 2m16s
2025-12-28 17:47:56 +03:00
ec047cd3a5 Исправление создания релиза
Сборка мода / build (push) Successful in 4m59s
2025-10-11 19:08:31 +03:00
2fe61c1445 Перевод названий этапов в build.yml
Сборка мода / build (push) Successful in 7m31s
2025-10-11 18:53:51 +03:00
28c60aa7a3 Обновление вывода информации о патчах, добавление патча лайков/дизлайков 2025-10-11 18:40:44 +03:00
b646dbf6fe Добавление ссылко на аккаунты gitea 2025-10-02 17:41:34 +00:00
f46425b169 Патч для замены текста ссылок в "поделиться" 2025-10-02 17:13:11 +03:00
19e1ce2f45 Патч для выделения информации о релизе 2025-10-02 10:32:51 +03:00
fbc8b3e017 Обновление структуры проекта, использование pydantic для конфигураций, улучшение отчёта о патчах
Build mod / build (push) Successful in 6m39s
2025-10-01 23:07:37 +03:00
5ba590cc31 Обновить README.md 2025-09-28 08:40:25 +00:00
0a4aa544a2 Обновить README.md 2025-09-28 08:24:57 +00:00
40f9cf0307 Обновить README.md 2025-09-28 08:22:32 +00:00
8b8ca63bb1 Удаление ненужного 2025-09-22 09:39:19 +03:00
670c53ba69 Перенос resources и добавления assets в патч настроек 2025-09-22 09:35:51 +03:00
5ff882a8d5 Рефакторинг патчей, реализация Список патчей:
settings_urls: ✔ enabled
  disable_ad: ✔ enabled
  disable_beta_banner: ✔ enabled
  insert_new: ✔ enabled
  color_theme: ✔ enabled
  change_server: ✘ disabled
  package_name: ✔ enabled
  replace_navbar: ✔ enabled
  compress: ✔ enabled, обновление описаний
2025-09-20 23:00:00 +03:00
66336f3a5c Обновление ссылки 2001-01-01 00:00:00 +00:00
85aef3d997 Добавлеие вывода информации о включеных патчах
Build mod / build (push) Successful in 5m27s
2025-09-18 10:54:30 +03:00
41399eca2c Запуск сборки на создание тэга
Build mod / build (push) Successful in 5m1s
2025-09-15 23:34:24 +03:00
137c939e1d Загрузка pngquant 2025-09-15 23:31:19 +03:00
18ad769d33 Дополнение requirements.txt 2025-09-15 23:26:33 +03:00
5b39aec161 Исправление загрузки файлов с прогресс-баром 2025-09-15 23:22:48 +03:00
c1bb2f8845 Исправление загрузки tools 2025-09-15 16:48:00 +03:00
630ab0d094 Merge remote-tracking branch 'origin/main' 2025-09-15 16:33:39 +03:00
0f9f6f2932 Исправление патча compress обновление списка зависимостей 2025-09-15 16:33:26 +03:00
a09181fe5a Исправление requirements 2025-09-15 11:47:17 +00:00
77694ec4b7 Merge remote-tracking branch 'origin/main' 2025-09-15 00:27:11 +03:00
b8ab508dfb Доавление описания патчей в docstring 2025-09-15 00:26:45 +03:00
debf561cf9 Обновить README.md 2025-09-14 18:51:03 +00:00
f7e186d5db Обновить README.md 2025-09-14 18:47:46 +00:00
24a8a1d4d3 Патч панели навигации и подписи версии в настройках 2025-09-14 21:46:39 +03:00
550427338a Обновление документации репозитория 2025-09-14 17:46:33 +00:00
ac241e1189 Перенос добавления ресурсов в соответствующие патчи 2025-09-14 20:37:14 +03:00
c22ef507ba Слияние с обновлённым main 2025-09-14 20:26:24 +03:00
9da9e98547 Улучшение кода main.py и конфигурации 2025-09-14 20:12:28 +03:00
48953a857b Merge pull request 'Объединение патчей cleanup.py и compress_png.py в один compress.py. Добавление доп. функций для сжатия апк' (#9) from Radiquum/patcher:main into main
Reviewed-on: https://git.wowlikon.tech/anixart-mod/patcher/pulls/9
2025-09-14 17:05:08 +00:00
871ec11f7e Объединение патчей cleanup.py и compress_png.py в один compress.py. Добавление доп. функций для сжатия апк 2025-09-14 19:45:52 +05:00
e2614990df Обновление workflow 2001-01-01 00:00:00 +00:00
66 changed files with 2780 additions and 778 deletions
+36 -15
View File
@@ -1,10 +1,10 @@
name: Build mod
name: Сборка мода
on:
workflow_dispatch:
#schedule: # раз в 36 часов
# - cron: "0 0 */3 * *" # каждые 3 дня в 00:00
# - cron: "0 12 */3 * *" # каждые 3 дня в 12:00
push:
tags:
- 'v*'
jobs:
build:
@@ -13,18 +13,25 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Download APK
- name: Скачивание APK
run: |
curl -L -o app.apk "https://mirror-dl.anixart-app.com/anixart-beta.apk"
- name: Ensure aapt is installed
- name: Проверка наличия aapt
run: |
if ! command -v aapt &> /dev/null; then
echo "aapt не найден, устанавливаем..."
sudo apt-get update && sudo apt-get install -y --no-install-recommends android-sdk-build-tools
fi
- name: Export secrets
- name: Проверка наличия pngquant
run: |
if ! command -v pngquant &> /dev/null; then
echo "pngquant не найден, устанавливаем..."
sudo apt-get update && sudo apt-get install -y --no-install-recommends pngquant
fi
- name: Извлечение хранилища ключей
env:
KEYSTORE: ${{ secrets.KEYSTORE }}
KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
@@ -33,33 +40,47 @@ jobs:
echo "$KEYSTORE" | base64 -d > keystore.jks
echo "$KEYSTORE_PASS" > keystore.pass
- name: Build APK
- name: Подготовка к модифицированию APK
id: build
run: |
mkdir original
mv app.apk original/
python ./main.py -f
pip install -r ./requirements.txt --break-system-packages
python ./main.py init
- name: Read title from report.log
- name: Пересборка APK
id: build
run: |
python ./main.py build -f
- 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: Setup go
- name: Чтение body из report.md
id: get_body
run: |
BODY=$(tail -n +3 modified/report.md)
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Установка go
if: steps.build.outputs.BUILD_EXIT == '0'
uses: actions/setup-go@v4
with:
go-version: '>=1.20'
- name: Make release
- name: Создание релиза
if: steps.build.outputs.BUILD_EXIT == '0'
uses: https://gitea.com/actions/release-action@main
with:
title: ${{ steps.get_title.outputs.title }}
body_path: modified/report.log
body: ${{ steps.get_body.outputs.body }}
draft: true
api_key: '${{secrets.RELEASE_TOKEN}}'
files: |-
modified/**-mod.apk
modified/*-mod.apk
modified/report.log
+12 -21
View File
@@ -11,7 +11,7 @@
- `patches` Модули патчей
- `utils` Вспомогательные модули
- `tools` Инструменты для модификации
- `patches/resources` Ресурсы, используемые патчами
- `resources` Ресурсы, используемые патчами
- `todo_drafts` Заметки для новых патчей(можно в любом формате)
### Схема
@@ -32,7 +32,7 @@ flowchart TD
end
p f3@==> F[Сборка apk обратно]
F f4@==> G[Выравнивание zipaling]
F f4@==> G[Выравнивание zipalign]
G f5@==> H[Подпись V2+V3]
H f6@==> I([Модифицированый apk])
@@ -52,39 +52,30 @@ flowchart TD
git clone https://git.wowlikon.tech/anixart-mod/patcher.git
```
Требования:
- Python 3.6+
- Python 3.8+
- Java 8+
- zipalign
- apksigner
- pngquant
Все остальные инструменты и зависимости будут автоматически установлены при запуске `main.py`.
Все остальные инструменты и зависимости будут автоматически установлены при запуске `main.py init`.
2. Создайте keystore с помощью `keytool` (требуется только один раз):
```sh
keytool -genkey -v -keystore keystore.jks -alias [имя_пользователя] -keyalg RSA -keysize 2048 -validity 10000
```
Пароль от keystore нужно сохранить в `keystore.pass` для полностью автоматической сборки.
2. Измените настройки мода в файле `patches/config.json`. Если вы развернули свой [сервер](https://git.wowlikon.tech/anixart-mod/server), то измените `"server": "https://new.url"`
3. Поместите оригинальный apk файла anixart в папку `original`
4. Запустите `main.py` и выберите файл apk
## ПОКА ЕЩЁ В РАЗРАБОТКЕ И ПОЭТОМУ НЕ В СКРИПТЕ
1. Перейдите в папку `anixart/dist` и запустите `zipalign`:
```sh
zipalign -p 4 anixart.apk anixart-aligned.apk
```
2. Запустите `apksigner` для подписи apk файла:
```sh
apksigner sign --ks /путь/до/keystore.jks --out anixart-modded.apk anixart-aligned.apk
```
3. Установите приложение на ваше устройство.
3. Измените настройки мода в файле `patches/config.json`. Если вы развернули свой [сервер](https://git.wowlikon.tech/anixart-mod/server), то измените `"server": "https://new.url"`
4. Поместите оригинальный apk файла anixart в папку `original`
5. Запустите `main.py build` и выберите файл apk
6. Установите приложение на ваше устройство.
## Лицензия:
Этот проект лицензирован под лицензией MIT. См. [LICENSE](./LICENSE) для получения подробной информации.
### Вклад в проект:
- Seele - Все оригинальные патчи основаны на модификации приложения от Seele [[GitHub](https://github.com/seeleme) | [Telegram](https://t.me/seele_off)]
- Kentai Radiquum - Разработка неофициального сайта и помощь с изучением API [[GitHub](https://github.com/Radiquum) | [Telegram](https://t.me/radiquum)]
- ReCode Liner - Помощь в модификации приложения [[Telegram](https://t.me/recodius)]
- [Kentai Radiquum](https://git.0x174.su/Radiquum) - Значительный вклад в развитие патчера, разработка [anix](https://github.com/AniX-org/AniX) и помощь с API [[GitHub](https://github.com/adiquum) | [Telegram](https://t.me/radiquum)]
- [Seele](https://git.0x174.su/seele_archive) - Оригинальные патчи в начале разработки основаны на модификации от Seele
- [ReCode Liner](https://git.0x174.su/ReCodeLiner) - Помощь в изучении моддинга приложения [[Telegram](https://t.me/recodius)]
+3 -54
View File
@@ -3,61 +3,10 @@
"apktool_jar_url": "https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_2.12.0.jar",
"apktool_wrapper_url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool"
},
"new_package_name": "com.wowlikon.anixart2",
"server": "https://anixarty.wowlikon.tech/modding",
"theme": {
"colors": {
"primary": "#ccff00",
"secondary": "#ffffd700",
"background": "#ffffff",
"text": "#000000"
},
"gradient": {
"angle": "135.0",
"from": "#ffff6060",
"to": "#ffccff00"
}
},
"cleanup": {
"keep_dirs": ["META-INF", "kotlin"]
},
"settings_urls": {
"Мы в социальных сетях": [
{
"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://t.me/wowlikon",
"icon": "@drawable/ic_custom_crown",
"icon_space_reserved": "false"
}
]
},
"base": {
"xml_ns": {
"android": "http://schemas.android.com/apk/res/android",
"app": "http://schemas.android.com/apk/res-auto"
},
"speeds": [9.0]
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"enabled": false,
"server": "https://anixarty.0x174.su/patch"
}
+17
View File
@@ -0,0 +1,17 @@
{
"enabled": true,
"logo": {
"gradient": {
"angle": 0.0,
"start_color": "#ffccff00",
"end_color": "#ffcccc00"
},
"ears_color": "#ffd0d0d0"
},
"colors": {
"primary": "#ccff00",
"secondary": "#ffcccc00",
"background": "#ffffff",
"text": "#000000"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"enabled": true,
"replace": true,
"custom_icons": true,
"icon_size": "18.0dip"
}
+13
View File
@@ -0,0 +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
}
+3
View File
@@ -0,0 +1,3 @@
{
"enabled": true
}
+3
View File
@@ -0,0 +1,3 @@
{
"enabled": true
}
+4
View File
@@ -0,0 +1,4 @@
{
"enabled": true,
"package_name": "com.wowlikon.anixart"
}
+10
View File
@@ -0,0 +1,10 @@
{
"enabled": true,
"items": [
"home",
"discover",
"feed",
"bookmarks",
"profile"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"enabled": true
}
+38
View File
@@ -0,0 +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"
}
]
}
}
+9
View File
@@ -0,0 +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"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"enabled": true,
"title": "Anixarty",
"description": "Описание",
"link_text": "МЫ В TELEGRAM",
"link_url": "https://t.me/http_teapod",
"skip_text": "Пропустить",
"title_bg_color": "#FFFFFF"
}
+445 -201
View File
@@ -1,230 +1,474 @@
import os
import sys
import json
import yaml
import requests
import argparse
import colorama
import importlib
import traceback
import subprocess
from tqdm import tqdm
__version__ = "1.0.0"
import shutil
from functools import wraps
from pathlib import Path
from typing import Any, Dict, List
def init() -> dict:
for directory in ["original", "modified", "patches", "tools", "decompiled"]:
if not os.path.exists(directory):
os.makedirs(directory)
import typer
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
with open("./config.json", "r") as config_file:
conf = json.load(config_file)
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)
if not os.path.exists("./tools/apktool.jar"):
console = Console()
app = typer.Typer(
name="anixarty-patcher",
help="Инструмент для модификации Anixarty APK",
add_completion=False,
)
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:
print("Скачивание Apktool...")
jar_response = requests.get(conf["tools"]["apktool_jar_url"], stream=True)
jar_path = "tools/apktool.jar"
with open(jar_path, "wb") as f:
for chunk in jar_response.iter_content(chunk_size=8192):
f.write(chunk)
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
wrapper_response = requests.get(conf["tools"]["apktool_wrapper_url"])
wrapper_path = "tools/apktool"
with open(wrapper_path, "w") as f:
f.write(wrapper_response.text)
os.chmod(wrapper_path, 0o755)
lines = []
lines.append(f"Anixarty {meta.version_name} (build {meta.version_code})")
lines.append("")
except Exception as e:
print(f"Ошибка при скачивании Apktool: {e}")
exit(1)
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("")
try:
result = subprocess.run(
["java", "-version"], capture_output=True, text=True, check=True
lines.append("## 🔧 Применённые патчи")
lines.append("")
if applied_patches:
lines.append(
f"> ✅ Успешно применено: **{applied_count}** из **{len(patches)}**"
)
lines.append("")
lines.append("| Патч | Приоритет | Автор | Описание |")
lines.append("|------|:---------:|-------|----------|")
version_line = result.stderr.splitlines()[0]
if "1.8" in version_line or any(f"{i}." in version_line for i in range(9, 100)):
print("Java 8 или более поздняя версия установлена.")
for p in applied_patches:
info = get_patch_info(p)
lines.append(
f"| ✅ `{p.name}` | {p.priority} | {info['author']} | {info['doc']} |"
)
else:
print("Java 8 или более поздняя версия не установлена.")
sys.exit(1)
except subprocess.CalledProcessError:
print("Java не установлена. Установите Java 8 или более позднюю версию.")
exit(1)
lines.append("> ⚠️ Нет применённых патчей")
return conf
lines.append("")
def select_apk() -> str:
apks = []
for file in os.listdir("original"):
if file.endswith(".apk") and os.path.isfile(os.path.join("original", file)):
apks.append(file)
if failed_patches:
lines.append("## ❌ Ошибки")
lines.append("")
lines.append("| Патч | Приоритет | Автор | Описание |")
lines.append("|------|:---------:|-------|----------|")
if not apks:
print("Нет файлов .apk в текущей директории")
sys.exit(1)
for p in failed_patches:
info = get_patch_info(p)
lines.append(
f"| ❌ `{p.name}` | {p.priority} | {info['author']} | {info['doc']} |"
)
if len(apks) == 1:
apk = apks[0]
print(f"Выбран файл {apk}")
return apk
lines.append("")
while True:
print("Выберете файл для модификации")
for index, apk in enumerate(apks):
print(f"{index + 1}. {apk}")
print("0. Exit")
lines.append("---")
lines.append("")
lines.append(
"*Собрано с помощью [anixarty-patcher](https://git.0x174.su/anixart-mod/patcher)*"
)
report_path.write_text("\n".join(lines), encoding="utf-8")
console.print(f"[dim]Отчёт сохранён: {report_path}[/dim]")
# ========================= COMMANDS =========================
@app.command()
@handle_errors
def init():
"""Инициализация: создание директорий и скачивание инструментов"""
ensure_dirs()
conf = load_config(console)
# Проверка Java
console.print("[cyan]Проверка Java...")
try:
selected_index = int(input("\nВведите номер файла: "))
if selected_index == 0:
sys.exit(0)
elif selected_index > len(apks):
print("Неверный номер файла")
local["java"]["-version"].run(retcode=None)
console.print("[green]✔ Java найдена")
except ProcessExecutionError:
raise PatcherError("Java не установлена. Установите JDK 11+")
# Скачивание apktool
apktool_jar = TOOLS / "apktool.jar"
if not apktool_jar.exists():
download(console, conf.tools.apktool_jar_url, apktool_jar)
else:
apk = apks[selected_index - 1]
print(f"Выбран файл {apk}")
return apk
except ValueError:
print("Неверный формат ввода")
except KeyboardInterrupt:
print("Прервано пользователем")
sys.exit(0)
console.print(f"[dim]✔ {apktool_jar.name} уже существует[/dim]")
# Скачивание 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]")
def decompile_apk(apk: str):
print("Декомпилируем apk...")
# Проверка zipalign и apksigner
for tool in ["zipalign", "apksigner"]:
try:
result = subprocess.run(
"tools/apktool d -f -o decompiled " + os.path.join("original", apk),
shell=True,
check=True,
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
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]"
)
except subprocess.CalledProcessError as e:
print("Ошибка при выполнении команды:")
print(e.stderr)
sys.exit(1)
# Инициализация конфигов патчей
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]✔ Инициализация завершена")
def compile_apk(apk: str):
print("Компилируем apk...")
@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:
subprocess.run(
"tools/apktool b decompiled -o " + os.path.join("modified", apk),
shell=True,
check=True,
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
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)
)
subprocess.run(
"zipalign -v 4 " + os.path.join("modified", apk) + " " + os.path.join("modified", apk.replace(".apk", "-aligned.apk")),
shell=True,
check=True,
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
subprocess.run(
"apksigner sign " +
"--v1-signing-enabled false " +
"--v2-signing-enabled true " +
"--v3-signing-enabled true " +
"--ks keystore.jks " +
"--ks-pass file:keystore.pass " +
"--out " + os.path.join("modified", apk.replace(".apk", "-mod.apk")) +
" " + os.path.join("modified", apk.replace(".apk", "-aligned.apk")),
shell=True,
check=True,
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
title = "anixart mod "
with open('./decompiled/apktool.yml') as f:
package = yaml.safe_load(f)
title += ' '.join([f'{k}: {v}' for k, v in package['versionInfo'].items()])
with open("./modified/report.log", "w") as log_file:
log_file.write(title+'\n')
log_file.write("\n".join([f"{patch.name}: {'applied' if patch.applied else 'failed'}" for patch in patches]))
except subprocess.CalledProcessError as e:
print("Ошибка при выполнении команды:")
print(e.stderr)
sys.exit(1)
class Patch:
def __init__(self, name, pkg):
self.name = name
self.package = pkg
self.applied = False
try:
self.priority = pkg.priority
except AttributeError:
self.priority = 0
def apply(self, conf: dict) -> bool:
try:
self.applied = self.package.apply(conf)
return True
except Exception as e:
print(f"Ошибка при применении патча {self.name}: {e}")
print(type(e), e.args)
traceback.print_exc()
return False
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)
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)
@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__":
parser = argparse.ArgumentParser(
description="Автоматический патчер anixart"
)
parser.add_argument("-v", "--verbose",
action="store_true",
help="Выводить подробные сообщения")
parser.add_argument("-f", "--force",
action="store_true",
help="Принудительно собрать APK")
args = parser.parse_args()
conf = init()
apk = select_apk()
patch = decompile_apk(apk)
if args.verbose: conf["verbose"] = True
patches = []
for filename in os.listdir("patches/"):
if filename.endswith(".py") and filename != "__init__.py" and not filename.startswith("todo_"):
module_name = filename[:-3]
module = importlib.import_module(f"patches.{module_name}")
patches.append(Patch(module_name, module))
patches.sort(key=lambda x: x.package.priority, reverse=True)
for patch in tqdm(patches, colour="green", desc="Применение патчей"):
tqdm.write(f"Применение патча: {patch.name}")
patch.apply(conf)
statuses = {}
for patch in patches:
statuses[patch.name] = patch.applied
marker = colorama.Fore.GREEN + "" if patch.applied else colorama.Fore.RED + ""
print(f"{marker}{colorama.Style.RESET_ALL} {patch.name}")
if all(statuses.values()):
print(f"{colorama.Fore.GREEN}Все патчи успешно применены{colorama.Style.RESET_ALL}")
compile_apk(apk)
elif any(statuses.values()):
print(f"{colorama.Fore.YELLOW}{colorama.Style.RESET_ALL} Некоторые патчи не были успешно применены")
if args.force or input("Продолжить? (y/n): ").lower() == "y":
compile_apk(apk)
else:
print(colorama.Fore.RED + "Операция отменена" + colorama.Style.RESET_ALL)
else:
print(f"{colorama.Fore.RED}Ни один патч не был успешно применен{colorama.Style.RESET_ALL}")
sys.exit(1)
app()
View File
+63 -25
View File
@@ -1,40 +1,78 @@
"""Change api server"""
priority = 0
"""Заменяет сервер api
"change_server": {
"enabled": true,
"server": "https://anixarty.0x174.su/patch"
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
import json
from typing import Any, Dict
import requests
from pydantic import Field
from tqdm import tqdm
import json
import requests
from utils.config import PatchTemplate
def apply(config: dict) -> bool:
response = requests.get(config['server'])
assert response.status_code == 200, f"Failed to fetch data {response.status_code} {response.text}"
class Patch(PatchTemplate):
priority: int = Field(frozen=True, exclude=True, default=0)
server: str = Field("https://anixarty.0x174.su/patch", description="URL сервера")
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']:
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:
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']))
filepath = (
"./decompiled/smali_classes2/com/swiftsoft/anixartd/network/api/"
+ item["file"]
)
tqdm.write(f"Изменение Github ссылки")
filepath = './decompiled/smali_classes2/com/swiftsoft/anixartd/utils/anixnet/GithubPagesNetFetcher.smali'
with open(filepath, 'r') as f:
with open(filepath, "r") as f:
content = f.read()
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:
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:
f.write(
content.replace(
'const-string v1, "https://anixhelper.github.io/pages/urls.json"',
f'const-string v1, "{new_api["gh"]}"',
)
)
tqdm.write(
"Удаление динамического выбора сервера"
) # Отключение автовыбора сервера
filepath = "./decompiled/smali_classes2/com/swiftsoft/anixartd/DaggerApp_HiltComponents_SingletonC$SingletonCImpl$SwitchingProvider.smali"
content = ""
tqdm.write("Удаление динамического выбора сервера")
filepath = './decompiled/smali_classes2/com/swiftsoft/anixartd/DaggerApp_HiltComponents_SingletonC$SingletonCImpl$SwitchingProvider.smali'
with open(filepath, 'r') as f:
with open(filepath, "r") as f:
for line in f.readlines():
if "addInterceptor" in line: continue
if "addInterceptor" in line:
continue
content += line
with open(filepath, 'w') as f:
with open(filepath, "w") as f:
f.write(content)
return True
-22
View File
@@ -1,22 +0,0 @@
"""Remove unnecessary files"""
priority = 0
from tqdm import tqdm
import os
import shutil
def apply(config: dict) -> bool:
for item in os.listdir("./decompiled/unknown/"):
item_path = os.path.join("./decompiled/unknown/", item)
if os.path.isfile(item_path):
os.remove(item_path)
if config.get("verbose", False):
tqdm.write(f'Удалён файл: {item_path}')
elif os.path.isdir(item_path):
if item not in config["cleanup"]["keep_dirs"]:
shutil.rmtree(item_path)
if config.get("verbose", False):
tqdm.write(f'Удалена папка: {item_path}')
return True
+159 -63
View File
@@ -1,65 +1,151 @@
"""Change application theme"""
priority = 0
"""Изменяет цветовую тему приложения и иконку
"color_theme": {
"enabled": true,
"logo": {
"gradient": {
"angle": 0.0,
"start_color": "#ffccff00",
"end_color": "#ffcccc00"
},
"ears_color": "#ffffd0d0"
},
"colors": {
"primary": "#ccff00",
"secondary": "#ffcccc00",
"background": "#ffffff",
"text": "#000000"
}
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
from typing import Any, Dict
from lxml import etree
from pydantic import BaseModel, Field, model_validator
from utils.public import (
insert_after_public,
insert_after_color,
insert_after_id,
change_color,
)
from utils.config import PatchTemplate
from utils.public import change_color, insert_after_color, insert_after_public
def apply(config: dict) -> bool:
main_color = config["theme"]["colors"]["primary"]
splash_color = config["theme"]["colors"]["secondary"]
gradient_angle = config["theme"]["gradient"]["angle"]
gradient_from = config["theme"]["gradient"]["from"]
gradient_to = config["theme"]["gradient"]["to"]
# No connection alert coolor
with open("./decompiled/assets/no_connection.html", "r", encoding="utf-8") as file:
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(
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 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="Настройки цветов")
@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
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,
)
def apply(self, base: Dict[str, Any]) -> bool:
main_color = self.colors.primary
splash_color = self.colors.secondary
# Обновление сообщения об отсутствии подключения
with open(
"./decompiled/assets/no_connection.html", "r", encoding="utf-8"
) as file:
file_contents = file.read()
new_contents = file_contents.replace("#f04e4e", main_color)
with open("./decompiled/assets/no_connection.html", "w", encoding="utf-8") as file:
with open(
"./decompiled/assets/no_connection.html", "w", encoding="utf-8"
) as file:
file.write(new_contents)
# For logo
# Суффиксы лого
drawable_types = ["", "-night"]
for drawable_type in drawable_types:
# Application logo gradient colors
# Градиент лого приложения
file_path = f"./decompiled/res/drawable{drawable_type}/$logo__0.xml"
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser)
root = tree.getroot()
# Change attributes with namespace
root.set(f"{{{config['xml_ns']['android']}}}angle", gradient_angle)
root.set(f"{{{config['xml_ns']['android']}}}startColor", gradient_from)
root.set(f"{{{config['xml_ns']['android']}}}endColor", gradient_to)
# Замена атрибутов значениями из конфигурации
root.set(
f"{{{base['xml_ns']['android']}}}angle", str(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
)
# Save back
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"
)
# Application logo anim color
file_path = f"./decompiled/res/drawable{drawable_type}/$logo_splash_anim__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()
# Finding "path"
for el in root.findall("path", namespaces=config["xml_ns"]):
name = el.get(f"{{{config['xml_ns']['android']}}}name")
for el in root.findall("path", namespaces=base["xml_ns"]):
name = el.get(f"{{{base['xml_ns']['android']}}}name")
if name == "path":
el.set(f"{{{config['xml_ns']['android']}}}fillColor", splash_color)
el.set(
f"{{{base['xml_ns']['android']}}}fillColor",
self.colors.secondary,
)
elif name in ["path_1", "path_2"]:
el.set(
f"{{{base['xml_ns']['android']}}}fillColor",
self.logo.ears_color,
)
# Save back
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"
@@ -68,44 +154,54 @@ def apply(config: dict) -> bool:
tree = etree.parse(file_path, parser)
root = tree.getroot()
# Change attributes with namespace
root.set(f"{{{config['xml_ns']['android']}}}angle", gradient_angle)
items = root.findall("item", namespaces=config['xml_ns'])
# Замена атрибутов значениями из конфигурации
root.set(
f"{{{base['xml_ns']['android']}}}angle", str(self.logo.gradient.angle)
)
items = root.findall("item", namespaces=base["xml_ns"])
assert len(items) == 2
items[0].set(f"{{{config['xml_ns']['android']}}}color", gradient_from)
items[1].set(f"{{{config['xml_ns']['android']}}}color", gradient_to)
items[0].set(
f"{{{base['xml_ns']['android']}}}color", self.logo.gradient.start_color
)
items[1].set(
f"{{{base['xml_ns']['android']}}}color", self.logo.gradient.end_color
)
# Save back
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"
)
# Добаление новых цветов для темы
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:])
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:],
)
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("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:])
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("warning_error_counter_background", "ic_custom_telegram")
insert_after_public("warning_error_counter_background", "ic_custom_crown")
try:
last = "speed75"
for speed in config.get("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)}"
except Exception as e:
print(f"Error occurred while processing speeds: {e}")
return True
+107
View File
@@ -0,0 +1,107 @@
"""Меняет местами кнопки лайка и дизлайка у коментария и иконки
"comment_vote": {
"enabled": true,
"replace": true,
"custom_icons": true,
"icons_size": "14.0dip"
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
import os
import shutil
from typing import Any, Dict
from lxml import etree
from pydantic import Field
from tqdm import tqdm
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="Размер иконки")
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", self.icon_size)
# icon.set(f"{{{base['xml_ns']['android']}}}layout_height", self.icon_size)
if self.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 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
+333
View File
@@ -0,0 +1,333 @@
"""Удаляет ненужное и сжимает ресурсы что-бы уменьшить размер АПК
Эффективность на проверена на версии 9.0 Beta 7
разница = оригинальный размер апк - патченный размер апк, не учитывая другие патчи
| Настройка | Размер файла | Разница | % |
| :--------------: | :-------------------: | :-----------------: | :-: |
| Ничего | 17092 bytes - 17.1 MB | - | - |
| Сжатие PNG | 17072 bytes - 17.1 MB | 20 bytes - 0.0 MB | 0.11% |
| Удалить unknown | 17020 bytes - 17.0 MB | 72 bytes - 0.1 MB | 0.42% |
| Удалить draws | 16940 bytes - 16.9 MB | 152 bytes - 0.2 MB | 0.89% |
| Удалить lines | 16444 bytes - 16.4 MB | 648 bytes - 0.7 MB | 3.79% |
| Удалить ai voice | 15812 bytes - 15.8 MB | 1280 bytes - 1.3 MB | 7.49% |
| Удалить языки | 15764 bytes - 15.7 MB | 1328 bytes - 1.3 MB | 7.76% |
| Все включены | 13592 bytes - 13.6 MB | 3500 bytes - 4.8 MB | 20.5% |
"compress": {
"enabled": true,
"remove_language_files": true, // удаляет все языки кроме русского и английского
"remove_AI_voiceover": true, // заменяет ИИ озвучку маскота аниксы пустыми mp3 файлами
"remove_debug_lines": false, // удаляет строки `.line n` из smali файлов использованные для дебага
"remove_drawable_files": false, // удаляет неиспользованные drawable-* из директории decompiled/res
"remove_unknown_files": true, // удаляет файлы из директории decompiled/unknown
"remove_unknown_files_keep_dirs": ["META-INF", "kotlin"], // оставляет указанные директории в decompiled/unknown
"compress_png_files": true // сжимает PNG в директории decompiled/res
}
"""
__author__ = "Kentai Radiquum <radiquum@gmail.com>"
__version__ = "1.0.0"
import os
import shutil
import subprocess
from typing import Any, Dict, List
from pydantic import Field
from tqdm import tqdm
from utils.config import PatchTemplate
from utils.smali_parser import get_smali_lines, save_smali_lines
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}")
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
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",
]
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}")
return True
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 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 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 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 enabled, message, action in actions:
if enabled:
tqdm.write(message)
action(base)
return True
-36
View File
@@ -1,36 +0,0 @@
"""Compress PNGs"""
priority = -1
from tqdm import tqdm
import os
import subprocess
def compress_pngs(root_dir: str, verbose: bool = False):
compressed_files = []
for dirpath, _, filenames in os.walk(root_dir):
for filename in filenames:
if filename.lower().endswith(".png"):
filepath = os.path.join(dirpath, filename)
if verbose: tqdm.write(f"Сжимаю: {filepath}")
try:
assert subprocess.run(
[
"pngquant",
"--force",
"--ext",
".png",
"--quality=65-90",
filepath,
],
capture_output=True,
).returncode in [0, 99]
compressed_files.append(filepath)
except subprocess.CalledProcessError as e:
tqdm.write(f"Ошибка при сжатии {filepath}: {e}")
return compressed_files
def apply(config: dict) -> bool:
files = compress_pngs("./decompiled", config.get("verbose", False))
return len(files) > 0 and any(files)
+31 -17
View File
@@ -1,31 +1,45 @@
"""Disable ad banners"""
priority = 0
"""Удаляет баннеры рекламы
from utils.smali_parser import (
find_smali_method_end,
find_smali_method_start,
get_smali_lines,
replace_smali_method_body,
)
replace = """ .locals 0
const/4 p0, 0x1
return p0
"disable_ad": {
"enabled": true
}
"""
__author__ = "Kentai Radiquum <radiquum@gmail.com>"
__version__ = "1.0.0"
import textwrap
from typing import Any, Dict
def apply(config) -> bool:
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)
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()
]
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, replace
lines, method_start, method_end, replacement
)
with open(path, "w", encoding="utf-8") as file:
+28 -9
View File
@@ -1,12 +1,28 @@
"""Remove beta banner"""
priority = 0
"""Удаляет баннеры бета-версии
"disable_beta_banner": {
"enabled": true
}
"""
__author__ = "Kentai Radiquum <radiquum@gmail.com>"
__version__ = "1.0.0"
import os
from typing import Any, Dict
from lxml import etree
from pydantic import Field
from tqdm import tqdm
import os
from lxml import etree
from utils.config import PatchTemplate
from utils.smali_parser import get_smali_lines, save_smali_lines
def apply(config) -> bool:
class Patch(PatchTemplate):
priority: int = Field(frozen=True, exclude=True, default=0)
def apply(self, base: Dict[str, Any]) -> bool:
beta_banner_xml = "./decompiled/res/layout/item_beta.xml"
attributes = [
"paddingTop",
"paddingBottom",
@@ -20,18 +36,21 @@ def apply(config) -> bool:
"layout_marginEnd",
]
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()
for attr in attributes:
# tqdm.write(f"set {attr} = 0.0dip")
root.set(f"{{{config["xml_ns"]['android']}}}{attr}", "0.0dip")
if base.get("verbose", False):
tqdm.write(f"set {attr} = 0.0dip")
root.set(f"{{{base['xml_ns']['android']}}}{attr}", "0.0dip")
tree.write(
beta_banner_xml, pretty_print=True, xml_declaration=True, encoding="utf-8"
beta_banner_xml,
pretty_print=True,
xml_declaration=True,
encoding="utf-8",
)
return True
-44
View File
@@ -1,44 +0,0 @@
"""Insert new files"""
priority = 0
import shutil
import os
def apply(config: dict) -> bool:
# Mod first launch window
shutil.copytree(
"./patches/resources/smali_classes4/", "./decompiled/smali_classes4/"
)
# Mod assets
shutil.copy("./patches/resources/avatar.png", "./decompiled/assets/avatar.png")
shutil.copy(
"./patches/resources/OpenSans-Regular.ttf",
"./decompiled/assets/OpenSans-Regular.ttf",
)
shutil.copy(
"./patches/resources/ic_custom_crown.xml",
"./decompiled/res/drawable/ic_custom_crown.xml",
)
shutil.copy(
"./patches/resources/ic_custom_telegram.xml",
"./decompiled/res/drawable/ic_custom_telegram.xml",
)
shutil.copy(
"./patches/resources/ytsans_medium.ttf",
"./decompiled/res/font/ytsans_medium.ttf",
)
os.remove("./decompiled/res/font/ytsans_medium.otf")
# IDK
shutil.move(
"./decompiled/res/raw/bundled_cert.crt",
"./decompiled/res/raw/bundled_cert.cer",
)
shutil.move(
"./decompiled/res/raw/sdkinternalca.crt",
"./decompiled/res/raw/sdkinternalca.cer",
)
return True
+37 -21
View File
@@ -1,62 +1,79 @@
"""Change package name of apk"""
priority = -1
"""Изменяет имя пакета в apk, удаляет вход по google и vk
"package_name": {
"enabled": true,
"new_package_name": "com.wowlikon.anixart"
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
import os
from typing import Any, Dict
from lxml import etree
from pydantic import Field
from utils.config import PatchTemplate
def rename_dir(src, dst):
class Patch(PatchTemplate):
priority: int = Field(frozen=True, exclude=True, default=-1)
package_name: str = Field("com.wowlikon.anixart", description="Название пакета")
def rename_dir(self, src, dst):
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.rename(src, dst)
def apply(config: dict) -> bool:
assert config["new_package_name"] is not None, "new_package_name is not configured"
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)
if os.path.isfile(file_path):
try:
try: # Изменяем имя пакета в файлах
with open(file_path, "r", encoding="utf-8") as file:
file_contents = file.read()
new_contents = file_contents.replace(
"com.swiftsoft.anixartd", config["new_package_name"]
"com.swiftsoft.anixartd", self.package_name
)
new_contents = new_contents.replace(
"com/swiftsoft/anixartd",
config["new_package_name"].replace(".", "/"),
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
# Изменяем названия папок
if os.path.exists("./decompiled/smali/com/swiftsoft/anixartd"):
rename_dir(
self.rename_dir(
"./decompiled/smali/com/swiftsoft/anixartd",
os.path.join(
"./decompiled", "smali", config["new_package_name"].replace(".", "/")
"./decompiled", "smali", self.package_name.replace(".", "/")
),
)
if os.path.exists("./decompiled/smali_classes2/com/swiftsoft/anixartd"):
rename_dir(
self.rename_dir(
"./decompiled/smali_classes2/com/swiftsoft/anixartd",
os.path.join(
"./decompiled",
"smali_classes2",
config["new_package_name"].replace(".", "/"),
self.package_name.replace(".", "/"),
),
)
if os.path.exists("./decompiled/smali_classes4/com/swiftsoft"):
rename_dir(
self.rename_dir(
"./decompiled/smali_classes4/com/swiftsoft",
os.path.join(
"./decompiled",
"smali_classes4",
"/".join(config["new_package_name"].split(".")[:-1]),
"/".join(self.package_name.split(".")[:2]),
),
)
@@ -69,6 +86,7 @@ def apply(config: dict) -> bool:
# ),
# )
# Замена названия пакета для smali_classes4
for root, dirs, files in os.walk("./decompiled/smali_classes4/"):
for filename in files:
file_path = os.path.join(root, filename)
@@ -80,24 +98,22 @@ def apply(config: dict) -> bool:
new_contents = file_contents.replace(
"com/swiftsoft",
"/".join(config["new_package_name"].split(".")[:-1]),
"/".join(self.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()
last_linear = root.xpath("//LinearLayout/LinearLayout[4]")[0]
last_linear.set(f"{{{config['xml_ns']['android']}}}visibility", "gone")
last_linear.set(f"{{{base['xml_ns']['android']}}}visibility", "gone")
tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8")
return True
# smali_classes2/com/wowlikon/anixart/utils/DeviceInfoUtil.smali: const-string v3, "\u0411\u0430\u0433-\u0440\u0435\u043f\u043e\u0440\u0442 9.0 BETA 5 (25062213)"
+67
View File
@@ -0,0 +1,67 @@
"""
Меняет порядок вкладок в панели навигации
"replace_navbar": {
"enabled": true,
"items": ["home", "discover", "feed", "bookmarks", "profile"]
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
from typing import Any, Dict, List
from lxml import etree
from pydantic import Field
from tqdm import tqdm
from utils.config import PatchTemplate
class Patch(PatchTemplate):
priority: int = Field(frozen=True, exclude=True, default=0)
items: List[str] = Field(
["home", "discover", "feed", "bookmarks", "profile"],
description="Список элементов в панели навигации",
)
def apply(self, base: Dict[str, Any]) -> bool:
file_path = "./decompiled/res/menu/bottom.xml"
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser)
root = tree.getroot()
# Получение элементов панели навигации
items = root.findall("item", namespaces=base["xml_ns"])
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_by_id = {get_id_suffix(item): item for item in items}
existing_order = [get_id_suffix(item) for item in items]
# Размещение в новом порядке
ordered_items = []
for key in self.items:
if key in items_by_id:
ordered_items.append(items_by_id[key])
# Если есть не указанные в конфиге они помещаются в конец списка
extra = [i for i in items if get_id_suffix(i) not in self.items]
if extra:
tqdm.write(
"⚠Найдены лишние элементы: " + str([get_id_suffix(i) for i in extra])
)
ordered_items.extend(extra)
for i in root.findall("item", namespaces=base["xml_ns"]):
root.remove(i)
for item in ordered_items:
root.append(item)
tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8")
return True
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
"""Делает текст в описании аниме копируемым
"selectable_text": {
"enabled": true
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
from typing import Any, Dict
from lxml import etree
from pydantic import Field
from utils.config import PatchTemplate
class Patch(PatchTemplate):
priority: int = Field(frozen=True, exclude=True, default=0)
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()
# Список тегов, к которым нужно добавить атрибут
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"
)
# Сохраняем
tree.write(file_path, encoding="utf-8", xml_declaration=True, pretty_print=True)
return True
+116 -10
View File
@@ -1,10 +1,79 @@
"""Add new settings"""
priority = 0
"""Добавляет в настройки ссылки и добвляет текст к версии приложения
"settings_urls": {
"enabled": true,
"menu": {
"Раздел": [
{
"title": "Заголовок",
"description": "Описание",
"url": "ссылка",
"icon": "@drawable/ic_custom_telegram",
"icon_space_reserved": "false"
},
...
],
...
]
},
"version": " by wowlikon"
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
import shutil
from typing import Any, Dict, List
from lxml import etree
from pydantic import Field
from utils.config import PatchTemplate
from utils.public import insert_after_public
# Config
DEFAULT_MENU = {
"Мы в социальных сетях": [
{
"title": "wowlikon",
"description": "Разработчик",
"url": "https://t.me/wowlikon",
"icon": "@drawable/ic_custom_telegram",
"icon_space_reserved": "false",
},
{
"title": "Kentai Radiquum",
"description": "Разработчик",
"url": "https://t.me/radiquum",
"icon": "@drawable/ic_custom_telegram",
"icon_space_reserved": "false",
},
{
"title": "Мы в Telegram",
"description": "Подпишитесь на канал, чтобы быть в курсе последних новостей.",
"url": "https://t.me/http_teapod",
"icon": "@drawable/ic_custom_telegram",
"icon_space_reserved": "false",
},
],
"Прочее": [
{
"title": "Помочь проекту",
"description": "Вы можете помочь нам с идеями, написанием кода или тестированием.",
"url": "https://git.wowlikon.tech/anixart-mod",
"icon": "@drawable/ic_custom_crown",
"icon_space_reserved": "false",
}
],
}
def make_category(ns, name, items):
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="Меню")
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")
@@ -23,19 +92,56 @@ def make_category(ns, name, items):
return cat
def apply(config: dict) -> bool:
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")
file_path = "./decompiled/res/xml/preference_main.xml"
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser)
root = tree.getroot()
# Insert new PreferenceCategory before the last element
last = root[-1] # last element
pos = root.index(last)
for section, items in config["settings_urls"].items():
root.insert(pos, make_category(config["xml_ns"], section, items))
# Вставка новых пунктов перед последним
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
# Save back
# Сохранение
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('"')]
+ self.version
+ line[line.rindex('"') :]
)
else:
content += line
with open(filepath, "w", encoding="utf-8") as file:
file.write(content)
return True
+53
View File
@@ -0,0 +1,53 @@
"""Изменяет формат "поделиться"
"selectable_text": {
"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"
}
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
from typing import Any, Dict
from lxml import etree
from pydantic import Field
from utils.config import PatchTemplate
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",
}
class Patch(PatchTemplate):
priority: int = Field(frozen=True, exclude=True, default=0)
format: Dict[str, str] = Field(
DEFAULT_FORMATS, description="Строки для замены в `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()
# Обновляем значения
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)
return True
-6
View File
@@ -1,6 +0,0 @@
"""Change application icon"""
priority = 0
def apply(config: dict) -> bool:
return False
+29 -8
View File
@@ -1,14 +1,35 @@
"""Change application icon"""
priority = 0
"""Добавляет пользовательские скорости воспроизведения видео
import struct
"custom_speed": {
"enabled": true,
"speeds": [9.0]
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
from typing import Any, Dict, List
from pydantic import Field
from utils.config import PatchTemplate
from utils.public import insert_after_id, insert_after_public
from utils.smali_parser import float_to_hex
def float_to_hex(f):
b = struct.pack(">f", f)
return b.hex()
class Patch(PatchTemplate):
priority: int = Field(frozen=True, exclude=True, default=0)
speeds: List[float] = Field(
[9.0], description="Список пользовательских скоростей воспроизведения"
)
def apply(config: dict) -> bool:
def apply(self, base: Dict[str, Any]) -> bool:
assert float_to_hex(1.5) == "0x3fc00000"
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
+53
View File
@@ -0,0 +1,53 @@
"""Шаблон патча
Здесь вы можете добавить описание патча, его назначение и другие детали.
Каждый патч должен независеть от других патчей и проверять себя при применении. Он не должен вернуть True, если есть проблемы.
На данный момент каждый патч должен иметь функцию `apply`, которая принимает на вход конфигурацию и возвращает True или False.
И модель `Config`, которая наследуется от `PatchTemplate` (поле `enabled` добавлять не нужно).
Это позволяет упростить конфигурирование и проверять типы данных, и делать значения по умолчанию.
При успешном применении патча, функция apply должна вернуть True, иначе False.
Ошибка будет интерпретирована как False. С выводом ошибки в консоль.
Ещё патч должен иметь переменную `priority`, которая указывает приоритет патча, чем выше, тем раньше он будет применен.
Коротко о конфигурации. Она состоит из двух частей `config` (на основе модели `Config` и из файла `configs/название_патча.json`).
И постоянной не типизированной переменной `base` из `config.json` и флага `verbose`.
```
python ./main.py build --verbose
```
В конце docstring может быть дополнительное описание конфигурации патча (основное описание получается из модели `Config`).
Это может быть как короткий фрагмент из названия патча и одной опции "enabled", которая обрабатывается в коде патчера.
"todo_template": {
"enabled": true, // Пример описания тк этот текст просто пример
"example": true // Пример кастомного параметра
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
from typing import Any, Dict, List
from pydantic import Field
from tqdm import tqdm
from utils.config import PatchTemplate
class Patch(PatchTemplate):
example: bool = Field(True, description="Пример кастомного параметра")
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
+55
View File
@@ -0,0 +1,55 @@
"""Добавляет всплывающее окно при первом входе
"welcome": {
"enabled": true,
"title": "Anixarty",
"description": "Описание",
"link_text": "МЫ В TELEGRAM",
"link_url": "https://t.me/http_teapod",
"skip_text": "Пропустить",
"title_bg_color": "#FFFFFF"
}
"""
__author__ = "wowlikon <wowlikon@gmail.com>"
__version__ = "1.0.0"
import shutil
from typing import Any, Dict
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="Текст ссылки")
link_url: str = Field("https://t.me/http_teapod", description="Ссылка")
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/")
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
+9
View File
@@ -0,0 +1,9 @@
typer[all]>=0.16.0
rich>=14.1.0
httpx>=0.28.1
pydantic>=2.11.7
plumbum>=1.9.0
lxml>=6.0.1
PyYAML>=6.0.2
tqdm>=4.67.1
requests>=2.32.5

Before

Width:  |  Height:  |  Size: 26 MiB

After

Width:  |  Height:  |  Size: 26 MiB

Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.59 23.26l-0.01 0-0.07 0.03-0.02 0.01-0.01-0.01-0.07-0.03q-0.02-0.01-0.03 0l0 0.01-0.02 0.43 0.01 0.02 0.01 0.02 0.1 0.07 0.02 0 0.01 0 0.1-0.07 0.01-0.02 0.01-0.02-0.02-0.42q0-0.02-0.02-0.02m0.27-0.12l-0.01 0.01-0.19 0.09-0.01 0.01 0 0.01 0.02 0.43 0 0.01 0.01 0.01 0.2 0.09q0.02 0.01 0.03-0.01l0-0.01-0.03-0.61q-0.01-0.02-0.02-0.03m-0.72 0.01a0.02 0.02 0 0 0-0.02 0l-0.01 0.02-0.03 0.61q0 0.02 0.01 0.02l0.02 0 0.2-0.09 0.01-0.01 0-0.01 0.02-0.43 0-0.01-0.01-0.01z"/>
<path
android:fillColor="#FF000000"
android:pathData="M15 16h2.4a4 4 0 0 0 3.94-4.72l-0.91-5A4 4 0 0 0 16.5 3H8v12l1.82 5.79c0.3 0.69 1.06 1.32 2.02 1.13C13.37 21.63 15 20.43 15 18.5z m-9-1a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3z"/>
</vector>
+12
View File
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.59 23.26l-0.01 0-0.07 0.03-0.02 0.01-0.01-0.01-0.07-0.03q-0.02-0.01-0.03 0l0 0.01-0.02 0.43 0.01 0.02 0.01 0.02 0.1 0.07 0.02 0 0.01 0 0.1-0.07 0.01-0.02 0.01-0.02-0.02-0.42q0-0.02-0.02-0.02m0.27-0.12l-0.01 0.01-0.19 0.09-0.01 0.01 0 0.01 0.02 0.43 0 0.01 0.01 0.01 0.2 0.09q0.02 0.01 0.03-0.01l0-0.01-0.03-0.61q-0.01-0.02-0.02-0.03m-0.72 0.01a0.02 0.02 0 0 0-0.02 0l-0.01 0.02-0.03 0.61q0 0.02 0.01 0.02l0.02 0 0.2-0.09 0.01-0.01 0-0.01 0.02-0.43 0-0.01-0.01-0.01z"/>
<path
android:fillType="evenOdd"
android:fillColor="#FF000000"
android:pathData="M16.5 3a4 4 0 0 1 3.93 3.28l0.91 5a4 4 0 0 1-3.94 4.72H15v2.5c0 1.93-1.63 3.12-3.15 3.42-0.96 0.18-1.73-0.44-2.03-1.13l-2.48-5.79H6a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3z m0 2H9v8.59a1 1 0 0 0 0.08 0.39l2.54 5.94c0.88-0.22 1.38-0.83 1.38-1.42v-2.5a2 2 0 0 1 2-2h2.4a2 2 0 0 0 1.97-2.36l-0.91-5a2 2 0 0 0-1.96-1.64M7 5H6a1 1 0 0 0-0.99 0.88L5 6v6a1 1 0 0 0 0.88 0.99l0.12 0.01h1z"/>
</vector>
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.59 23.26l-0.01 0-0.07 0.03-0.02 0.01-0.01-0.01-0.07-0.03q-0.02-0.01-0.03 0l0 0.01-0.02 0.43 0.01 0.02 0.01 0.02 0.1 0.07 0.02 0 0.01 0 0.1-0.07 0.01-0.02 0.01-0.02-0.02-0.42q0-0.02-0.02-0.02m0.27-0.12l-0.01 0.01-0.19 0.09-0.01 0.01 0 0.01 0.02 0.43 0 0.01 0.01 0.01 0.2 0.09q0.02 0.01 0.03-0.01l0-0.01-0.03-0.61q-0.01-0.02-0.02-0.03m-0.72 0.01a0.02 0.02 0 0 0-0.02 0l-0.01 0.02-0.03 0.61q0 0.02 0.01 0.02l0.02 0 0.2-0.09 0.01-0.01 0-0.01 0.02-0.43 0-0.01-0.01-0.01z"/>
<path
android:fillColor="#FF000000"
android:pathData="M15 8h2.4a4 4 0 0 1 3.94 4.72l-0.91 5A4 4 0 0 1 16.5 21H8V9l1.82-5.79c0.3-0.69 1.06-1.32 2.02-1.13C13.37 2.38 15 3.57 15 5.5zM6 9a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3z"/>
</vector>
+12
View File
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12.59 23.26l-0.01 0-0.07 0.03-0.02 0.01-0.01-0.01-0.07-0.03q-0.02-0.01-0.03 0l0 0.01-0.02 0.43 0.01 0.02 0.01 0.02 0.1 0.07 0.02 0 0.01 0 0.1-0.07 0.01-0.02 0.01-0.02-0.02-0.42q0-0.02-0.02-0.02m0.27-0.12l-0.01 0.01-0.19 0.09-0.01 0.01 0 0.01 0.02 0.43 0 0.01 0.01 0.01 0.2 0.09q0.02 0.01 0.03-0.01l0-0.01-0.03-0.61q-0.01-0.02-0.02-0.03m-0.72 0.01a0.02 0.02 0 0 0-0.02 0l-0.01 0.02-0.03 0.61q0 0.02 0.01 0.02l0.02 0 0.2-0.09 0.01-0.01 0-0.01 0.02-0.43 0-0.01-0.01-0.01z"/>
<path
android:fillType="evenOdd"
android:fillColor="#FF000000"
android:pathData="M9.82 3.21c0.3-0.69 1.06-1.32 2.02-1.13 1.47 0.28 3.04 1.4 3.15 3.22L15 5.5V8h2.4a4 4 0 0 1 3.97 4.52l-0.03 0.2-0.91 5a4 4 0 0 1-3.74 3.28l-0.19 0H6a3 3 0 0 1-3-2.82L3 18v-6a3 3 0 0 1 2.82-3L6 9h1.34zM7 11H6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h1z m4.63-6.92l-2.55 5.94a1 1 0 0 0-0.07 0.26L9 10.41V19h7.5a2 2 0 0 0 1.93-1.49l0.03-0.15 0.91-5a2 2 0 0 0-1.82-2.35L17.41 10H15a2 2 0 0 1-2-1.85L13 8V5.5c0-0.55-0.43-1.12-1.21-1.37z"/>
</vector>
+27 -10
View File
@@ -1,10 +1,27 @@
/res/layout/monetization_ads_internal_rewarded_close_verification.xml
diff a.txt b.txt
4c4
< android:background="@drawable/monetization_ads_internal_rewarded_close_verification_button_close_background"
---
> android:background="@drawable/draw030e"
16c16
< android:background="@drawable/monetization_ads_internal_rewarded_close_verification_button_dismiss_background"
---
> android:background="@drawable/draw030f"
/res/layout/release_info.xml
<TextView android:textColor="@color/light_md_blue_500" android:id="@id/note" android:background="@drawable/bg_release_note" android:paddingTop="12.0dip" android:paddingBottom="12.0dip" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textIsSelectable="true" android:paddingStart="16.0dip" android:paddingEnd="16.0dip" style="?textAppearanceBodyMedium" />
</FrameLayout>
<LinearLayout android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent">
<LinearLayout android:gravity="center_vertical" android:orientation="horizontal" android:id="@id/countryLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent">
<androidx.appcompat.widget.AppCompatImageView android:id="@id/iconCountry" android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_flag_japan" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvCountry" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
<LinearLayout android:gravity="center_vertical" android:orientation="horizontal" android:id="@id/episodesLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_marginTop="2.0dip">
<androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_episodes" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvEpisodes" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
<LinearLayout android:gravity="center_vertical" android:orientation="horizontal" android:id="@id/scheduleLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_marginTop="2.0dip">
<androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_schedule" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvSchedule" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
<LinearLayout android:gravity="center_vertical" android:orientation="horizontal" android:id="@id/sourceLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_marginTop="2.0dip">
<androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_source" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvSource" android:layout_width="fill_parent" android:layout_height="wrap_content" android:lineSpacingExtra="4.0sp" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
<LinearLayout android:orientation="horizontal" android:id="@id/studioLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginTop="2.0dip">
<androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_studio" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvStudio" android:layout_width="fill_parent" android:layout_height="wrap_content" android:lineSpacingExtra="4.0sp" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
</LinearLayout>
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:linksClickable="true" android:id="@id/tvGenres" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginTop="16.0dip" android:layout_marginBottom="2.0dip" android:lineSpacingExtra="4.0sp" android:textIsSelectable="true" style="?textAppearanceBodyMedium" />
<at.blogc.android.views.ExpandableTextView android:textColor="?primaryTextColor" android:id="@id/description" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginTop="8.0dip" android:layout_marginBottom="8.0dip" android:maxLines="5" android:lineSpacingExtra="4.0sp" android:textIsSelectable="true" app:animation_duration="225" style="?textAppearanceBodyMedium" />
-27
View File
@@ -1,27 +0,0 @@
/res/layout/release_info.xml
<TextView android:textColor="@color/light_md_blue_500" android:id="@id/note" android:background="@drawable/bg_release_note" android:paddingTop="12.0dip" android:paddingBottom="12.0dip" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textIsSelectable="true" android:paddingStart="16.0dip" android:paddingEnd="16.0dip" style="?textAppearanceBodyMedium" />
</FrameLayout>
<LinearLayout android:orientation="vertical" android:layout_width="fill_parent" android:layout_height="fill_parent">
<LinearLayout android:gravity="center_vertical" android:orientation="horizontal" android:id="@id/countryLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent">
<androidx.appcompat.widget.AppCompatImageView android:id="@id/iconCountry" android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_flag_japan" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvCountry" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
<LinearLayout android:gravity="center_vertical" android:orientation="horizontal" android:id="@id/episodesLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_marginTop="2.0dip">
<androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_episodes" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvEpisodes" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
<LinearLayout android:gravity="center_vertical" android:orientation="horizontal" android:id="@id/scheduleLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_marginTop="2.0dip">
<androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_schedule" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvSchedule" android:layout_width="fill_parent" android:layout_height="wrap_content" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
<LinearLayout android:gravity="center_vertical" android:orientation="horizontal" android:id="@id/sourceLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_marginTop="2.0dip">
<androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_source" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvSource" android:layout_width="fill_parent" android:layout_height="wrap_content" android:lineSpacingExtra="4.0sp" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
<LinearLayout android:orientation="horizontal" android:id="@id/studioLayout" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginTop="2.0dip">
<androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" app:srcCompat="@drawable/ic_studio" />
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:gravity="center_vertical" android:linksClickable="true" android:id="@id/tvStudio" android:layout_width="fill_parent" android:layout_height="wrap_content" android:lineSpacingExtra="4.0sp" android:textIsSelectable="true" android:layout_marginStart="8.0dip" style="?textAppearanceBodyMedium" />
</LinearLayout>
</LinearLayout>
<TextView android:textColor="?primaryTextColor" android:textColorLink="?primaryTextColor" android:linksClickable="true" android:id="@id/tvGenres" android:visibility="gone" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginTop="16.0dip" android:layout_marginBottom="2.0dip" android:lineSpacingExtra="4.0sp" android:textIsSelectable="true" style="?textAppearanceBodyMedium" />
<at.blogc.android.views.ExpandableTextView android:textColor="?primaryTextColor" android:id="@id/description" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_marginTop="8.0dip" android:layout_marginBottom="8.0dip" android:maxLines="5" android:lineSpacingExtra="4.0sp" android:textIsSelectable="true" app:animation_duration="225" style="?textAppearanceBodyMedium" />
-2
View File
@@ -1,2 +0,0 @@
/res/menu/bottom.xml
replace lines
-1
View File
@@ -1 +0,0 @@
<color name="ic_launcher_background">#ff000000</color>
+228
View File
@@ -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
+137
View File
@@ -0,0 +1,137 @@
import json
import traceback
from abc import ABC, abstractmethod
from pathlib import Path
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
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:
"""Загружает и валидирует конфигурацию"""
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(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
-6
View File
@@ -1,6 +0,0 @@
import struct
def float_to_hex(f):
b = struct.pack(">f", f)
return b.hex()
+159
View File
@@ -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())
+107
View File
@@ -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)
+14 -5
View File
@@ -1,8 +1,10 @@
from lxml import etree
from copy import deepcopy
from lxml import etree
from typing_extensions import Optional
def insert_after_public(anchor_name: str, elem_name: str):
def insert_after_public(anchor_name: str, elem_name: str) -> Optional[int]:
file_path = "./decompiled/res/values/public.xml"
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser)
@@ -19,6 +21,8 @@ def insert_after_public(anchor_name: str, elem_name: str):
anchor = (elem, attrs)
types[attrs["type"]] = types.get(attrs["type"], []) + [int(attrs["id"], 16)]
assert anchor != None
free_ids = set()
group = types[anchor[1]["type"]]
for i in range(min(group), max(group) + 1):
@@ -47,7 +51,7 @@ def insert_after_public(anchor_name: str, elem_name: str):
return new_id
def insert_after_id(anchor_name: str, elem_name: str):
def insert_after_id(anchor_name: str, elem_name: str) -> None:
file_path = "./decompiled/res/values/ids.xml"
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser)
@@ -62,13 +66,15 @@ def insert_after_id(anchor_name: str, elem_name: str):
assert anchor == None
anchor = (elem, attrs)
assert anchor != None
new_elem = deepcopy(anchor[0])
new_elem.set("name", elem_name)
anchor[0].addnext(new_elem)
tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8")
def change_color(name: str, value: str):
def change_color(name: str, value: str) -> None:
file_path = "./decompiled/res/values/colors.xml"
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser)
@@ -86,7 +92,8 @@ def change_color(name: str, value: str):
assert replacements >= 1
tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8")
def insert_after_color(anchor_name: str, elem_name: str, elem_value: str):
def insert_after_color(anchor_name: str, elem_name: str, elem_value: str) -> None:
file_path = "./decompiled/res/values/colors.xml"
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser)
@@ -101,6 +108,8 @@ def insert_after_color(anchor_name: str, elem_name: str, elem_value: str):
assert anchor == None
anchor = (elem, attrs)
assert anchor != None
new_elem = deepcopy(anchor[0])
new_elem.set("name", elem_name)
anchor[0].addnext(new_elem)
+33 -14
View File
@@ -1,27 +1,41 @@
import struct
def get_smali_lines(file: str) -> list[str]:
lines = []
with open(file, "r", encoding="utf-8") as smali:
lines = smali.readlines()
return lines
def save_smali_lines(file: str, lines: list[str]) -> None:
with open(file, "w", encoding="utf-8") as f:
f.writelines(lines)
def find_smali_method_start(lines: list[str], index: int) -> int:
while True:
index -= 1
if lines[index].find(".method") >= 0:
return index
def find_smali_method_end(lines: list[str], index: int) -> int:
while True:
index += 1
if lines[index].find(".end method") >= 0:
return index
def debug_print_smali_method(lines: list[str], start: int, end: int) -> None:
while start != (end + 1):
print(start, lines[start])
start += 1
def replace_smali_method_body(lines: list[str], start: int, end: int, new_lines: list[str]) -> list[str]:
def replace_smali_method_body(
lines: list[str], start: int, end: int, new_lines: list[str]
) -> list[str]:
new_content = []
index = 0
skip = end - start - 1
@@ -38,21 +52,26 @@ def replace_smali_method_body(lines: list[str], start: int, end: int, new_lines:
new_content.append(lines[index])
index += 1
return new_content
# example i guess
# if __name__ == "__main__":
# lines = get_smali_lines("./decompiled/smali_classes2/com/radiquum/anixart/Prefs.smali")
# 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, c)
def find_and_replace_smali_line(
lines: list[str], search: str, replace: str
) -> list[str]:
for index, line in enumerate(lines):
if line.find(search) >= 0:
lines[index] = lines[index].replace(search, replace)
return lines
# with open("./help/Prefs_orig.smali", "w", encoding="utf-8") as file:
# file.writelines(lines)
# with open("./help/Prefs_modified.smali", "w", encoding="utf-8") as file:
# file.writelines(new_content)
def float_to_hex(f):
b = struct.pack(">f", f)
return b.hex()
def quick_replace(file: str) -> None:
content = ""
with open(file, "r", encoding="utf-8") as smali:
content = smali.read()
with open(file, "w", encoding="utf-8") as f:
f.writelines(content)
+68
View File
@@ -0,0 +1,68 @@
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")
MODIFIED = Path("modified")
DECOMPILED = Path("decompiled")
PATCHES = Path("patches")
CONFIGS = Path("configs")
def ensure_dirs():
for d in [TOOLS, ORIGINAL, MODIFIED, DECOMPILED, PATCHES, CONFIGS]:
d.mkdir(exist_ok=True)
def run(console: Console, cmd: List[str], hide_output=True):
prog = local[cmd[0]][cmd[1:]]
try:
prog() if hide_output else prog & FG
except ProcessExecutionError as e:
console.print(f"[red]Ошибка при выполнении команды: {' '.join(cmd)}")
console.print(e.stderr)
raise typer.Exit(1)
def download(console: Console, url: str, dest: Path):
"""Скачивание файла по URL"""
console.print(f"[cyan]Скачивание {url}{dest.name}")
with httpx.Client(follow_redirects=True, timeout=60.0) as client:
with client.stream("GET", url) as response:
response.raise_for_status()
total = int(response.headers.get("Content-Length", 0))
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as f, Progress(console=console) as progress:
task = progress.add_task("Загрузка", total=total if total else None)
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
progress.update(task, advance=len(chunk))
def 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]