From ce1d870d54ebbf845d28b11e14b940dc22c3396a Mon Sep 17 00:00:00 2001 From: cheb0chik Date: Thu, 22 Oct 2026 20:32:13 +0300 Subject: [PATCH] LihonT 2 Luna beta: source release Mod for TikTok 47.1.3 (arm64) in Material 3 style: bottom bar, profile menu, TikTok settings and comments; downloads without the watermark; switches for ads, live streams and the Shop tab; badges of other mods next to names and the niche mod badge (members service); app icons; mod settings screen. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 56 + LICENSE | 32 + README.md | 59 + create_by.md | 8 + docs/settings-model.md | 126 ++ lihont/__init__.py | 0 lihont/apkzip.py | 258 ++++ lihont/app_icons.py | 171 +++ lihont/arsc.py | 306 +++++ lihont/axml.py | 509 +++++++ lihont/badge_patch.py | 120 ++ lihont/bar_patch.py | 39 + lihont/clone.py | 185 +++ lihont/dexpatch.py | 500 +++++++ lihont/editions.py | 254 ++++ lihont/feed_patch.py | 234 ++++ lihont/java/EditionDex.java | 96 ++ lihont/manifest.py | 398 ++++++ lihont/media_patch.py | 102 ++ lihont/profile_features.py | 320 +++++ lihont/region.py | 210 +++ lihont/settings_patch.py | 196 +++ lihont/theme_patch.py | 153 +++ lihont/toolchain.py | 236 ++++ lihont/upgrade.py | 572 ++++++++ pyproject.toml | 8 + run_adb_controller.bat | 6 + scripts/deploy_site.py | 193 +++ scripts/make_lihont_promo.py | 449 +++++++ scripts/quick_runtime.py | 80 ++ scripts/stage_site.py | 47 + services/members/server.py | 268 ++++ src/EditionDex.java | 96 ++ src/MergeClasses.java | 28 + src/RefreshClasses.java | 42 + src/ReplaceClasses.java | 26 + src/brand/logo_note_flower.svg | 29 + src/brand/logo_note_flower_1024.png | Bin 0 -> 42981 bytes src/brand/logo_note_flower_512.png | Bin 0 -> 19307 bytes src/compat/README.md | 11 + src/icons/catalog.tsv | 5 + src/icons/elmacho.png | Bin 0 -> 31760 bytes src/icons/original.png | Bin 0 -> 12894 bytes src/icons/pushistik.png | Bin 0 -> 140790 bytes src/icons/tolstyj.png | Bin 0 -> 89450 bytes src/lihont/two/LihonTFlags.java | 116 ++ src/lihont/two/LihonTRegion.java | 262 ++++ src/lihont/two/badge/AccountNames.java | 101 ++ src/lihont/two/badge/BadgeArt.java | 65 + src/lihont/two/badge/BadgeSpans.java | 44 + src/lihont/two/badge/Badges.java | 322 +++++ src/lihont/two/badge/ExternalBadges.java | 361 +++++ src/lihont/two/badge/MemberClient.java | 78 ++ src/lihont/two/badge/NicheBadgeDrawable.java | 74 ++ src/lihont/two/badge/NicheClaim.java | 111 ++ src/lihont/two/bar/AvatarBadge.java | 87 ++ src/lihont/two/bar/AvatarCache.java | 195 +++ src/lihont/two/bar/BarStyle.java | 250 ++++ src/lihont/two/bar/BottomBar.java | 77 ++ src/lihont/two/bar/NavPill.java | 105 ++ src/lihont/two/bar/ProfileMenu.java | 175 +++ src/lihont/two/bar/UserAvatar.java | 46 + src/lihont/two/comments/CommentPanel.java | 406 ++++++ src/lihont/two/comments/CommentSkin.java | 107 ++ src/lihont/two/comments/Fill.java | 11 + src/lihont/two/comments/SheetBackground.java | 60 + src/lihont/two/feed/FeedFilter.java | 194 +++ src/lihont/two/feed/HomeTabs.java | 169 +++ src/lihont/two/media/Downloads.java | 58 + .../two/settings/AndroidSettingsStorage.java | 77 ++ src/lihont/two/settings/AppIcons.java | 96 ++ src/lihont/two/settings/CustomIconStore.java | 176 +++ src/lihont/two/settings/IconCrop.java | 32 + src/lihont/two/settings/IconShortcuts.java | 172 +++ .../two/settings/LihonTSettingsActivity.java | 1164 +++++++++++++++++ src/lihont/two/settings/Md3.java | 671 ++++++++++ src/lihont/two/settings/Md3Icon.java | 232 ++++ src/lihont/two/settings/Md3Switch.java | 176 +++ src/lihont/two/settings/ModSettings.java | 205 +++ src/lihont/two/settings/SettingsCell.java | 546 ++++++++ src/lihont/two/settings/SettingsCodec.java | 194 +++ .../two/settings/SettingsEntryHook.java | 13 + src/lihont/two/settings/SettingsHost.java | 108 ++ .../two/settings/SettingsRepository.java | 50 + src/lihont/two/settings/SvgPath.java | 267 ++++ src/lihont/two/theme/NativeTheme.java | 293 +++++ src/lihont/two/theme/ThemeCopy.java | 56 + .../lihont/two/settings/Md3BadgePainter.smali | 88 ++ src/web/assets/site/icons.svg | 52 + src/web/assets/site/md3.css | 1052 +++++++++++++++ src/web/assets/site/md3.js | 160 +++ src/web/index.html | 376 ++++++ src/web/lihont2-location.conf | 11 + src/web/lihont2.service | 14 + src/web/md3-theme.css | 859 ++++++++++++ src/web/members_index.html | 105 ++ src/web/pay_index.html | 81 ++ src/web/server.py | 649 +++++++++ src/web/test_portal.py | 69 + tests/__init__.py | 1 + tests/java/lihont/two/FlagsTest.java | 116 ++ .../lihont/two/settings/IconRenderTest.java | 183 +++ .../lihont/two/settings/IconStoreTest.java | 129 ++ .../two/settings/SettingsModelTest.java | 369 ++++++ tests/test_app_icons.py | 172 +++ tests/test_badge_patch.py | 80 ++ tests/test_bar_patch.py | 71 + tests/test_custom_icons.py | 67 + tests/test_feed_patch.py | 210 +++ tests/test_icons.py | 70 + tests/test_manifest_activity.py | 46 + tests/test_media_patch.py | 147 +++ tests/test_members_server.py | 73 ++ tests/test_portal.py | 201 +++ tests/test_profile_flags.py | 203 +++ tests/test_region.py | 98 ++ tests/test_runtime_compiles.py | 35 + tests/test_settings_model.py | 108 ++ tests/test_settings_patch.py | 152 +++ tests/test_theme_patch.py | 259 ++++ tools/adb_controller.py | 834 ++++++++++++ 121 files changed, 21570 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 create_by.md create mode 100644 docs/settings-model.md create mode 100644 lihont/__init__.py create mode 100644 lihont/apkzip.py create mode 100644 lihont/app_icons.py create mode 100644 lihont/arsc.py create mode 100644 lihont/axml.py create mode 100644 lihont/badge_patch.py create mode 100644 lihont/bar_patch.py create mode 100644 lihont/clone.py create mode 100644 lihont/dexpatch.py create mode 100644 lihont/editions.py create mode 100644 lihont/feed_patch.py create mode 100644 lihont/java/EditionDex.java create mode 100644 lihont/manifest.py create mode 100644 lihont/media_patch.py create mode 100644 lihont/profile_features.py create mode 100644 lihont/region.py create mode 100644 lihont/settings_patch.py create mode 100644 lihont/theme_patch.py create mode 100644 lihont/toolchain.py create mode 100644 lihont/upgrade.py create mode 100644 pyproject.toml create mode 100644 run_adb_controller.bat create mode 100644 scripts/deploy_site.py create mode 100644 scripts/make_lihont_promo.py create mode 100644 scripts/quick_runtime.py create mode 100644 scripts/stage_site.py create mode 100644 services/members/server.py create mode 100644 src/EditionDex.java create mode 100644 src/MergeClasses.java create mode 100644 src/RefreshClasses.java create mode 100644 src/ReplaceClasses.java create mode 100644 src/brand/logo_note_flower.svg create mode 100644 src/brand/logo_note_flower_1024.png create mode 100644 src/brand/logo_note_flower_512.png create mode 100644 src/compat/README.md create mode 100644 src/icons/catalog.tsv create mode 100644 src/icons/elmacho.png create mode 100644 src/icons/original.png create mode 100644 src/icons/pushistik.png create mode 100644 src/icons/tolstyj.png create mode 100644 src/lihont/two/LihonTFlags.java create mode 100644 src/lihont/two/LihonTRegion.java create mode 100644 src/lihont/two/badge/AccountNames.java create mode 100644 src/lihont/two/badge/BadgeArt.java create mode 100644 src/lihont/two/badge/BadgeSpans.java create mode 100644 src/lihont/two/badge/Badges.java create mode 100644 src/lihont/two/badge/ExternalBadges.java create mode 100644 src/lihont/two/badge/MemberClient.java create mode 100644 src/lihont/two/badge/NicheBadgeDrawable.java create mode 100644 src/lihont/two/badge/NicheClaim.java create mode 100644 src/lihont/two/bar/AvatarBadge.java create mode 100644 src/lihont/two/bar/AvatarCache.java create mode 100644 src/lihont/two/bar/BarStyle.java create mode 100644 src/lihont/two/bar/BottomBar.java create mode 100644 src/lihont/two/bar/NavPill.java create mode 100644 src/lihont/two/bar/ProfileMenu.java create mode 100644 src/lihont/two/bar/UserAvatar.java create mode 100644 src/lihont/two/comments/CommentPanel.java create mode 100644 src/lihont/two/comments/CommentSkin.java create mode 100644 src/lihont/two/comments/Fill.java create mode 100644 src/lihont/two/comments/SheetBackground.java create mode 100644 src/lihont/two/feed/FeedFilter.java create mode 100644 src/lihont/two/feed/HomeTabs.java create mode 100644 src/lihont/two/media/Downloads.java create mode 100644 src/lihont/two/settings/AndroidSettingsStorage.java create mode 100644 src/lihont/two/settings/AppIcons.java create mode 100644 src/lihont/two/settings/CustomIconStore.java create mode 100644 src/lihont/two/settings/IconCrop.java create mode 100644 src/lihont/two/settings/IconShortcuts.java create mode 100644 src/lihont/two/settings/LihonTSettingsActivity.java create mode 100644 src/lihont/two/settings/Md3.java create mode 100644 src/lihont/two/settings/Md3Icon.java create mode 100644 src/lihont/two/settings/Md3Switch.java create mode 100644 src/lihont/two/settings/ModSettings.java create mode 100644 src/lihont/two/settings/SettingsCell.java create mode 100644 src/lihont/two/settings/SettingsCodec.java create mode 100644 src/lihont/two/settings/SettingsEntryHook.java create mode 100644 src/lihont/two/settings/SettingsHost.java create mode 100644 src/lihont/two/settings/SettingsRepository.java create mode 100644 src/lihont/two/settings/SvgPath.java create mode 100644 src/lihont/two/theme/NativeTheme.java create mode 100644 src/lihont/two/theme/ThemeCopy.java create mode 100644 src/smali/lihont/two/settings/Md3BadgePainter.smali create mode 100644 src/web/assets/site/icons.svg create mode 100644 src/web/assets/site/md3.css create mode 100644 src/web/assets/site/md3.js create mode 100644 src/web/index.html create mode 100644 src/web/lihont2-location.conf create mode 100644 src/web/lihont2.service create mode 100644 src/web/md3-theme.css create mode 100644 src/web/members_index.html create mode 100644 src/web/pay_index.html create mode 100644 src/web/server.py create mode 100644 src/web/test_portal.py create mode 100644 tests/__init__.py create mode 100644 tests/java/lihont/two/FlagsTest.java create mode 100644 tests/java/lihont/two/settings/IconRenderTest.java create mode 100644 tests/java/lihont/two/settings/IconStoreTest.java create mode 100644 tests/java/lihont/two/settings/SettingsModelTest.java create mode 100644 tests/test_app_icons.py create mode 100644 tests/test_badge_patch.py create mode 100644 tests/test_bar_patch.py create mode 100644 tests/test_custom_icons.py create mode 100644 tests/test_feed_patch.py create mode 100644 tests/test_icons.py create mode 100644 tests/test_manifest_activity.py create mode 100644 tests/test_media_patch.py create mode 100644 tests/test_members_server.py create mode 100644 tests/test_portal.py create mode 100644 tests/test_profile_flags.py create mode 100644 tests/test_region.py create mode 100644 tests/test_runtime_compiles.py create mode 100644 tests/test_settings_model.py create mode 100644 tests/test_settings_patch.py create mode 100644 tests/test_theme_patch.py create mode 100644 tools/adb_controller.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f1f12a --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Generated and working files +/work/ +/build/ +/ComplectAPK/ +/OriginalAPKtiktok/ + +# Binaries: rebuild or fetch them, do not commit +*.apk +*.idsig +*.dex +*.jar +*.dll +*.exe +*.zip +*.mp4 +*.wav +*.mp3 + +# Per-app and per-plugin build output +/LihonB/build/ +/LihonB/gen/ +/sample_plugin/*/build/ + +# Built plugins at the repo root (sources live in sample_plugin/) +/*.lbp + +# Badge pictures of other mods (third-party artwork): src/compat/README.md says where they come from +/src/compat/*.png + +# Device screenshots and UI dumps from testing +/screen_*.png +/temp_*.png +/test_*.png +/clean_phone.png +/dump_ui.txt + +# JVM crash logs +hs_err_pid*.log + +# Secrets +.env +*.jks +*.keystore +*.pem + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ + +# OS and editor files +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a2f12af --- /dev/null +++ b/LICENSE @@ -0,0 +1,32 @@ +EVHNMKHU licence + +Copyright (c) 2026 intcatsite + +ПРАВИЛА ЛИЦЕНЗИИ + + • (по желанию) Люби маму, папу, бабушек и дедушек. + + • (обязательно) На деловых встречах и переговорах разговаривай так, + как будто тебя на клапан придавило. + + • (обязательно) Если это вайбкод, указывай, какой моделью ты + пользовался, в create_by.md. + +РАЗРЕШЕНО + + • Использовать как хочешь. + + • Можно не указывать автора. + +БЕЗ ГАРАНТИЙ + + Программа предоставляется «КАК ЕСТЬ» (AS IS), без каких-либо гарантий, + явных или подразумеваемых, включая гарантии пригодности для какой-либо + цели и отсутствия нарушений чьих-либо прав. Авторы не несут + ответственности за любой ущерб, возникший из-за использования программы. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR + OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE. + +(Первые два правила созданы на базе ООСД) diff --git a/README.md b/README.md new file mode 100644 index 0000000..63c6ff7 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# LihonT 2 · Luna (бета) + +Мод для TikTok 47.1.3 (arm64) в стиле Material 3. Telegram: [@lihont](https://t.me/lihont). +Готовую сборку берите на странице [релизов](https://git.lihont.space/lihont/LihonT/releases). + +Лицензия: EVHNMKHU, см. [LICENSE](LICENSE). Код написан с помощью ИИ, подробности в [create_by.md](create_by.md). + +## Что умеет + +- Интерфейс TikTok в стиле Material 3: нижняя панель, меню профиля, настройки TikTok, комментарии + (карточки, ручка шторки, поле ввода-пилюля). Один переключатель возвращает вид Serena 1.2. +- Скачивание видео без водяного знака. +- Убрать рекламу, прямые эфиры в ленте и вкладку «Магазин» (по отдельным переключателям). +- Значки у ников: значки TikTok You и MargyT рядом с ником и значок «Нишевый мод» (его выдаёт сервер, см. `services/members`). +- Баннеры профиля, репост комментариев, регион мода, сменные иконки приложения, своя иконка. +- Экран «Настройки мода» открывается из «Настройки и конфиденциальность». + +## Как собрать + +Нужны JDK 17+ и Python 3.10+. Чистый APK TikTok 47.1.3 (arm64-v8a, nodpi, minAPI 23) положите в +`OriginalAPKtiktok/`, затем из корня репозитория: + +``` +py -B -m lihont.upgrade "OriginalAPKtiktok/.apk" -o ComplectAPK/lihont-2.0-luna-beta.apk --work work/build +``` + +Полная сборка занимает 7–10 минут и около 3 ГБ свободного места. Если менялся только Java-код мода +(`src/lihont/two`), готовый APK можно пересобрать за минуту: + +``` +py -B scripts/quick_runtime.py ComplectAPK/lihont-2.0-luna-beta.apk -o ComplectAPK/lihont-2.0-luna-beta-next.apk +``` + +Помощники сборки (`smali.jar`, `r8.jar`, `android.jar`, подписчик APK) скачиваются в `tools/` при первой +сборке. `tools/baksmali.jar` (baksmali 3.x одним jar-файлом) пока кладётся туда вручную. + +Значки других модов: два рисунка (`supporter.png`, `developer.png`) принадлежат их авторам и в репозитории +не лежат, см. `src/compat/README.md`. Без них сборка работает, но этих двух значков не будет. + +## Устройство репозитория + +- `lihont/` — работа с APK, бинарным XML, таблицей ресурсов и dex; патчи классов TikTok (`*_patch.py`). +- `src/lihont/two/` — Java-код мода (экран настроек, MD3, панель, комментарии, значки, лента, загрузки). + Он собирается в dex и вшивается в `classes35.dex` TikTok. +- `src/icons/`, `src/brand/` — иконки приложения и логотип. `src/web/` — сайт проекта. +- `services/members/` — сервер значков (чистый Python и sqlite). +- `tests/` — тесты (`py -B -m unittest discover -s tests -t .`); часть из них компилирует и запускает Java. +- `scripts/` — выкладка сайта, быстрая пересборка, генерация промо-ролика. +- `tools/adb_controller.py` — управление телефоном по ADB при проверке. +- `docs/settings-model.md` — модель настроек. + +## Проверка + +``` +py -B -m unittest discover -s tests -t . +``` + +Нужны `java` и `javac` в `PATH`. Экраны на телефоне тестами не проверяются: после сборки APK ставится на +устройство и смотрится вживую. diff --git a/create_by.md b/create_by.md new file mode 100644 index 0000000..e550be0 --- /dev/null +++ b/create_by.md @@ -0,0 +1,8 @@ +# create_by + +LihonT 2.0 («Luna», бета) написан с помощью ИИ. По правилу лицензии EVHNMKHU вот модели. + +- Claude Sonnet 5.5 (Anthropic), через Claude Code: этот и ряд предыдущих сеансов. +- Более ранние сеансы шли в Claude Code на других моделях Claude; точный список не сохранился. + +Решения, проверки на телефоне и выпуск релизов на стороне автора. diff --git a/docs/settings-model.md b/docs/settings-model.md new file mode 100644 index 0000000..935b578 --- /dev/null +++ b/docs/settings-model.md @@ -0,0 +1,126 @@ +# Модель настроек LihonT 2 + +Первая схема настроек, 15 октября 2026. Код написан в `src/lihont/two/settings/` +без зависимости от классов или хранилищ других модов. + +## Значения + +| Поле | Тип и допустимые значения | По умолчанию | Применение после подключения | +|---|---|---|---| +| `region` | Строка, один из 249 ISO 3166-1 alpha-2 кодов, верхний регистр | `JP` | Следующий запуск процесса | +| `profileBanners` | Boolean | `true` | Следующий запуск процесса | +| `commentReposts` | Boolean | `true` | Следующий запуск процесса | +| `theme` | `system`, `light`, `dark` | `system` | Следующее создание экрана настроек | + +Перечень стран фиксирован в модели и не зависит от локали Android. Это список +стран и территорий ISO; названия для списка в интерфейсе будут локализованы +отдельно. `regionIso()` возвращает код в нижнем регистре для будущих хуков. +Ни телефонный префикс, ни MCC/MNC не вычисляются из позиции в этом списке. + +Для выбранного региона кроме `JP` метод `needsBannerWarning()` возвращает +`true`: интерфейс должен предупредить, что баннеры могут быть недоступны. Для +Японии предупреждения нет. Показ диалога будет реализован на этапе интерфейса. + +`ModSettings` неизменяемый: методы `with…` создают новый набор. По умолчанию +баннеры и репосты запрошены локально, но модель не сообщает, что сервер их выдал. +Тема относится к собственным экранам мода; сохранение темы пока не меняет TikTok. + +## Хранилище и жизненный цикл + +`SettingsRepository` получает `Storage`. На первом открытии создаёт файл со +значениями по умолчанию. На следующих открытиях полностью проверяет сохранённый +документ. Повреждённый файл, неизвестная версия или ошибка чтения приводят к +ошибке открытия и сохраняют исходные данные. Тихой замены на defaults нет. + +Нужен **один экземпляр репозитория на процесс приложения**. Это обязательство +будущего хоста; сейчас хост и инициализация из TikTok отсутствуют. Запись из +нескольких процессов не поддерживается. + +- `current()` — последний успешно сохранённый набор, для интерфейса. +- `active()` — набор, загруженный при создании репозитория, для будущих хуков. +- `restartRequired()` — различаются ли регион, баннеры или репосты между этими + наборами. Возврат к активным значениям снимает признак перезапуска. Тема в это + сравнение не входит. +- `save()` сначала записывает полный документ, затем публикует новое состояние. + Ошибка записи не меняет опубликованный набор. +- `reset()` сохраняет defaults. Если активные значения отличались от defaults, + необходимость перезапуска остаётся. + +`AndroidSettingsStorage` использует приватный `Context.getFilesDir()` и файл +`lihont-settings.json`. Разрешения на файлы и доступ к сети не требуются. Запись +идёт во временный файл в той же директории, выполняет `fsync`, затем атомарный +`android.system.Os.rename`. Старый файл не удаляется до замены. При ошибке до +замены старые данные сохраняются. Ошибки чтения не трактуются как первый запуск. +Доступен на minSDK 23; шифрование настроек не требуется, секретов в схеме нет. + +## Формат импорта и экспорта + +```json +{ + "format": "lihont.settings", + "schemaVersion": 1, + "values": { + "region": "JP", + "profileBanners": true, + "commentReposts": true, + "theme": "system" + } +} +``` + +`exportJson()` возвращает сохранённые значения. `importJson()` **заменяет весь +набор**, предварительно проверив структуру, формат, версию, точные типы и +допустимые значения. Частичного импорта и приведения строк к boolean нет. + +Лишние, отсутствующие и повторяющиеся ключи, неизвестные страны и темы, +повреждённый JSON, `null`, массивы, дробная версия, данные после документа и +документ длиннее 16 384 символов отклоняются. Файл Android читается как строгий +UTF-8 и ограничен 16 384 байтами; стандартный экспорт содержит только ASCII. +Ошибка проверки не вызывает запись. Ошибка записи не обновляет `current()`. + +Это **первая** схема: предыдущих форматов настроек у LihonT 2 нет. Поэтому версии +0 и выше 1 отклоняются с сохранением файла. При появлении схемы 2 потребуется +явная миграция 1 → 2 и её тесты до изменения номера. Настройки старой серии и +других модов автоматически не импортируются. + +Формат и API готовы для будущего интерфейса резервных копий. Кнопки экспорта и +импорта в APK на этом этапе не добавляются. + +## Граница текущего этапа + +Сейчас это модель, кодек и адаптер хранения. Они ещё не подключены к экрану +TikTok Settings, региональным вызовам, флагам баннеров или репостам. Существующий +`LihonTRegion` в прежнем тестовом APK по-прежнему возвращает фиксированный `by`. +Новые значения `JP` не выдаются за изменение аккаунта или серверного региона. + +Следующий этап — собственный хост настроек с инициализацией репозитория и +обработкой ошибок хранения. Затем подключаются функции по roadmap и проверяются +на устройстве. + +## Проверка + +Из корня проекта: + +```powershell +py -m unittest discover -s tests -v +py -c "from lihont.toolchain import Toolchain; print(Toolchain('tools').compile_dex('src/lihont/two', 'work/settings-model/runtime', 23))" +``` + +JVM-тесты компилируют и исполняют реальные `ModSettings`, `SettingsCodec` и +`SettingsRepository`. Android-адаптер компилируется против `android.jar`, затем +весь runtime проходит D8 с minAPI 23. JVM-тесты не подменяют проверку Android +файловой системы на устройстве; её включаем в интеграционный этап. + +## Подключение флагов TikTok + +`lihont.two.settings.SettingsHost` держит единственный `SettingsRepository` процесса. +Им пользуются экран настроек и `lihont.two.LihonTFlags`, поэтому `active()` — это то, +что видели хуки с первого чтения, а `restartRequired()` сравнивает экран именно с ним. + +- Переключатель включён: чтение флага возвращает значение LihonT (см. `profile_features.py`). +- Переключатель выключен: возвращается значение, которое прочитал сам TikTok. LihonT + ничего не подставляет и не утверждает, что функция доступна. +- Приложение ещё не создано или файл временно не читается: для этого вызова действуют + значения по умолчанию, результат не запоминается. +- Файл повреждён или записан более новой схемой: процесс до перезапуска работает со + значениями по умолчанию; экран настроек при этом показывает ошибку и файл не трогает. diff --git a/lihont/__init__.py b/lihont/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lihont/apkzip.py b/lihont/apkzip.py new file mode 100644 index 0000000..419053d --- /dev/null +++ b/lihont/apkzip.py @@ -0,0 +1,258 @@ +"""The apk as a zip, rewritten entry by entry. + +An apk this size is mostly bytes we have no business touching, so untouched +entries are copied across still compressed -- never inflated, never deflated +again. Only what the build actually replaces is new. + +Two rules from the platform are kept while writing: `resources.arsc` stays +stored, not deflated, and every stored entry's data begins on a four-byte +boundary. Both are what zipalign would otherwise have to fix afterwards. +""" + +from __future__ import annotations + +import os +import struct +import zlib +from typing import Dict, List, Optional + +STORED = 0 +DEFLATED = 8 + +LOCAL_SIG = 0x04034B50 +CENTRAL_SIG = 0x02014B50 +EOCD_SIG = 0x06054B50 + +ALIGNMENT = 4 + +# the old signature, which is about to stop being true +SIGNATURE_FILES = (".SF", ".RSA", ".DSA", ".EC") + + +class ZipError(Exception): + pass + + +class Entry: + def __init__(self, name: str, method: int, crc: int, csize: int, usize: int, dos_time: int, + dos_date: int, source_offset: Optional[int] = None, data: Optional[bytes] = None): + self.name = name + self.method = method + self.crc = crc + self.csize = csize + self.usize = usize + self.dos_time = dos_time + self.dos_date = dos_date + self.source_offset = source_offset # where the compressed bytes live in the input + self.data = data # set for entries this build produced + + +class Apk: + """An apk opened for reading, with edits held until `write`.""" + + def __init__(self, path: str): + self.path = path + self.file = open(path, "rb") + self.entries: List[Entry] = [] + self.index: Dict[str, Entry] = {} + self._read_central_directory() + + # ----------------------------------------------------------------- read + + def _read_central_directory(self) -> None: + size = os.path.getsize(self.path) + tail = min(size, 66000) + self.file.seek(size - tail) + blob = self.file.read(tail) + at = blob.rfind(struct.pack(" bytes: + """The entry's bytes exactly as stored, compression and all.""" + if entry.data is not None: + return _compress(entry.data, entry.method) + self.file.seek(entry.source_offset) + head = self.file.read(30) + sig, _ver, _flag, _method, _t, _d, _crc, _cs, _us, name_len, extra_len = struct.unpack( + " bytes: + entry = self.index.get(name) + if entry is None: + raise KeyError(name) + if entry.data is not None: + return entry.data + raw = self._raw(entry) + if entry.method == STORED: + return raw + return zlib.decompress(raw, -15) + + def names(self) -> List[str]: + return [e.name for e in self.entries] + + def has(self, name: str) -> bool: + return name in self.index + + # ---------------------------------------------------------------- write + + def replace(self, name: str, data: bytes, method: Optional[int] = None) -> None: + entry = self.index.get(name) + if entry is None: + raise KeyError(name) + entry.data = data + entry.usize = len(data) + entry.crc = zlib.crc32(data) & 0xFFFFFFFF + if method is not None: + entry.method = method + + def add(self, name: str, data: bytes, method: int = DEFLATED) -> None: + if name in self.index: + self.replace(name, data, method) + return + entry = Entry(name, method, zlib.crc32(data) & 0xFFFFFFFF, 0, len(data), 0, 0x21, None, data) + self.entries.append(entry) + self.index[name] = entry + + def remove(self, name: str) -> None: + entry = self.index.pop(name, None) + if entry is not None: + self.entries.remove(entry) + + def drop_signature(self) -> List[str]: + """Throw away the signature the apk arrived with.""" + gone = [] + for name in list(self.index): + upper = name.upper() + if upper == "META-INF/MANIFEST.MF" or ( + upper.startswith("META-INF/") and upper.endswith(SIGNATURE_FILES) + ): + self.remove(name) + gone.append(name) + return gone + + def drop_abi(self, abi: str) -> List[str]: + """Drop all native libraries for the given ABI (e.g. 'armeabi-v7a').""" + prefix = "lib/%s/" % abi + gone = [] + for name in list(self.index): + if name.startswith(prefix): + self.remove(name) + gone.append(name) + return gone + + + def write(self, out_path: str) -> None: + with open(out_path, "wb") as out: + offsets = [] + for entry in self.entries: + raw = self._raw(entry) + if entry.data is not None: + entry.csize = len(raw) + name = entry.name.encode("utf-8") + flag = 0 if name.isascii() else 0x800 + + pad = 0 + if entry.method == STORED: + at = out.tell() + 30 + len(name) + pad = (-at) % ALIGNMENT + + offsets.append(out.tell()) + out.write( + struct.pack( + " None: + self.file.close() + + +def _compress(data: bytes, method: int) -> bytes: + if method == STORED: + return data + compressor = zlib.compressobj(9, zlib.DEFLATED, -15) + return compressor.compress(data) + compressor.flush() diff --git a/lihont/app_icons.py b/lihont/app_icons.py new file mode 100644 index 0000000..caf903b --- /dev/null +++ b/lihont/app_icons.py @@ -0,0 +1,171 @@ +"""Launcher icons the user can pick between, shipped inside the apk. + +The icon an app wears is a property of the component the home screen opens, so a choice of +icons is one `activity-alias` per icon, all pointing at TikTok's own entry activity; the mod +turns one on and the rest off. TikTok starts its own entry, SplashActivity, by name, so that +component stays enabled and is only taken off the home screen; the stock icon becomes one more +alias, on by default. An alias needs a resource id for its icon, and adding entries +to resources.arsc is exactly what this build avoids (see arsc.py). So each icon takes over a +dead PNG drawable of the original table: the file's path string in the table is swapped for one +of the same length, and the icon is added to the zip under that path. Nothing moves, no id +changes. + +A slot is only dead if nothing reads it. The pinned slots below were checked against 47.1.3: +no dex, binary XML or table bag mentions their ids, and their path string belongs to no other +resource. `verify_slot` re-checks the part the table can answer on every build. +""" + +from __future__ import annotations + +import re +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import List, Sequence + +from . import manifest as manifest_module +from .apkzip import Apk, STORED +from .arsc import Arsc, TYPE_STRING +from .axml import Axml + +ALIAS_PREFIX = "lihont.two.icons." +# TikTok's own launcher entry in 47.1.3: the one that stays the default +ORIGINAL_ENTRY = "com.ss.android.ugc.aweme.splash.SplashActivity" + +# (resource id, path in the original apk): dead drawables of TikTok 47.1.3 +SLOTS = ( + (0x7F0400E9, "res/c/a0g.png"), # t7, an empty file + (0x7F040203, "res/c/a2q.png"), # a0t, an empty file + (0x7F040E36, "res/a/as6.png"), # icon_top_shop_dark_it_summer_sale + (0x7F040E3B, "res/a/asa.png"), # icon_top_shop_light_it_summer_sale + (0x7F040E35, "res/a/as5.png"), # icon_top_shop_dark_fr_summer_sale + (0x7F040E3A, "res/a/as_.png"), # icon_top_shop_light_fr_summer_sale + (0x7F040DB1, "res/a/ar7.png"), # icon_bottom_shop_dark_unselected_uk_summer_sale + (0x7F040DB3, "res/a/ar9.png"), # icon_bottom_shop_light_unselected_uk_summer_sale +) + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +# no density qualifier on these files: the system reads them as 160 dpi and scales them up by the +# screen density, so a big picture turns into a huge bitmap in the launcher +MIN_SIDE, MAX_SIDE = 192, 512 +MAX_PNG_BYTES = 300 * 1024 +LAUNCHER_LABEL = "TikTok" +# the icon TikTok ships, as one more choice (a catalog icon must not take this slug) +STOCK_SLUG = "stock" +STOCK_TITLE = "TikTok" + + +class IconError(ValueError): + pass + + +@dataclass(frozen=True) +class Icon: + slug: str + title: str + png: bytes + + +def png_side(data: bytes) -> int: + """The side of a square PNG, or an IconError saying what is wrong with it.""" + if len(data) < 33 or data[:8] != PNG_SIGNATURE or data[12:16] != b"IHDR": + raise IconError("not a PNG") + width, height = struct.unpack(">II", data[16:24]) + if width != height: + raise IconError("not square: %dx%d" % (width, height)) + if not MIN_SIDE <= width <= MAX_SIDE: + raise IconError("side %d is outside %d..%d" % (width, MIN_SIDE, MAX_SIDE)) + return width + + +def load_catalog(directory: Path) -> List[Icon]: + """`slug title file` lines; `#` starts a comment.""" + icons: List[Icon] = [] + seen = set() + for number, line in enumerate((directory / "catalog.tsv").read_text(encoding="utf-8").splitlines(), 1): + if not line.strip() or line.lstrip().startswith("#"): + continue + parts = line.split("\t") + if len(parts) != 3: + raise IconError("catalog line %d: expected slug, title, file" % number) + slug, title, file = (part.strip() for part in parts) + if not re.fullmatch(r"[a-z][a-z0-9]{1,23}", slug) or slug in seen or slug == STOCK_SLUG: + raise IconError("catalog line %d: bad or repeated slug %r" % (number, slug)) + if not title or len(title) > 32 or "\n" in title: + raise IconError("catalog line %d: title must be 1..32 characters" % number) + data = (directory / file).read_bytes() + if len(data) > MAX_PNG_BYTES: + raise IconError("%s is %d bytes, more than %d" % (file, len(data), MAX_PNG_BYTES)) + try: + png_side(data) + except IconError as error: + raise IconError("%s: %s" % (file, error)) + seen.add(slug) + icons.append(Icon(slug, title, data)) + if len(icons) > len(SLOTS): + raise IconError("%d icons but only %d dead slots" % (len(icons), len(SLOTS))) + return icons + + +def slot_path(old_path: str, index: int) -> str: + """A path in the same folder, with a file name of exactly the same length.""" + folder, _, name = old_path.rpartition("/") + stem, dot, extension = name.rpartition(".") + fresh = ("li%x" % index).ljust(len(stem), "x") + if len(fresh) != len(stem): + raise IconError("slot path %r leaves no room for a new name" % old_path) + return "%s/%s%s%s" % (folder, fresh, dot, extension) + + +def alias_name(slug: str) -> str: + return ALIAS_PREFIX + slug + + +def verify_slot(arsc: Arsc, res_id: int, path: str) -> int: + """The pool index of the slot's path string; refuses a table that is not the one pinned.""" + values = arsc.values(res_id) + if len(values) != 1 or values[0].kind != TYPE_STRING: + raise IconError("slot 0x%08x is not a single file reference" % res_id) + if arsc.file_path(values[0]) != path: + raise IconError("slot 0x%08x points at %r, expected %r" % (res_id, arsc.file_path(values[0]), path)) + return values[0].data + + +def apply(apk: Apk, xml: Axml, icons: Sequence[Icon]) -> dict: + """Move the home-screen entry to our aliases: stock TikTok icon on, the chosen others off. + + The original entry stays an enabled component (TikTok starts it by name) but stops being an + icon; the stock icon becomes an alias of ours that is on by default, so nothing changes for + the user until they pick another. + """ + if not icons: + return {"icons": 0} + entry, target = manifest_module.launcher_entry(xml) + if entry != ORIGINAL_ENTRY: + raise IconError("the launcher entry is %r, not %r" % (entry, ORIGINAL_ENTRY)) + stock_icon = manifest_module.entry_icon(xml, entry) + if manifest_module.hide_from_launcher(xml, entry) != 1: + raise IconError("expected exactly one launcher category on %s" % entry) + manifest_module.add_icon_alias( + xml, alias_name(STOCK_SLUG), target, stock_icon, LAUNCHER_LABEL, True, title=STOCK_TITLE) + arsc = Arsc(apk.read("resources.arsc")) + report = { + "icons": len(icons), "original_entry": entry, "target": target, + "stock": {"alias": alias_name(STOCK_SLUG), "resource": "0x%08x" % stock_icon}, + "aliases": {}, + } + for index, icon in enumerate(icons): + res_id, old_path = SLOTS[index] + pool_index = verify_slot(arsc, res_id, old_path) + new_path = slot_path(old_path, index) + if apk.has(new_path): + raise IconError("%s already exists in the apk" % new_path) + arsc.replace_string(pool_index, new_path) + apk.remove(old_path) + apk.add(new_path, icon.png, STORED) + name = alias_name(icon.slug) + manifest_module.add_icon_alias(xml, name, target, res_id, LAUNCHER_LABEL, False, title=icon.title) + report["aliases"][name] = {"resource": "0x%08x" % res_id, "file": new_path, "title": icon.title} + apk.replace("resources.arsc", arsc.build()) + apk.replace("AndroidManifest.xml", xml.build()) + return report diff --git a/lihont/arsc.py b/lihont/arsc.py new file mode 100644 index 0000000..220e715 --- /dev/null +++ b/lihont/arsc.py @@ -0,0 +1,306 @@ +"""resources.arsc, read and patched in place. + +The table is never rebuilt. It is parsed to find out where a resource lives, +and the only writes are ones that cannot move a byte: a string swapped for +another of exactly the same encoded length, or a typed value overwritten with +another of the same eight bytes. Everything an id refers to -- the id itself, +every offset, every chunk size -- is left where aapt2 put it. + +That is the whole trick behind this build. Rewriting a 25 MB resource table is +what needs aapt2, and aapt2 rebuilding a table it did not write is what breaks +an apk this size. +""" + +from __future__ import annotations + +import struct +from typing import Dict, List, Optional, Tuple + +RES_STRING_POOL = 0x0001 +RES_TABLE = 0x0002 +RES_TABLE_PACKAGE = 0x0200 +RES_TABLE_TYPE = 0x0201 +RES_TABLE_TYPE_SPEC = 0x0202 + +TYPE_STRING = 0x03 + +FLAG_SPARSE = 0x01 +FLAG_OFFSET16 = 0x02 + +ENTRY_FLAG_COMPLEX = 0x0001 +ENTRY_FLAG_COMPACT = 0x0008 + +NO_ENTRY32 = 0xFFFFFFFF +NO_ENTRY16 = 0xFFFF + + +class ArscError(Exception): + pass + + +class Value: + """One configuration's value for one resource id.""" + + def __init__(self, type_name: str, config: bytes, kind: int, data: int, offset: int): + self.type_name = type_name + self.config = config + self.kind = kind + self.data = data + self.offset = offset # where the 8-byte Res_value sits in the file + + @property + def density(self) -> int: + """The config's density in dpi, 0 when it does not name one.""" + if len(self.config) < 16: + return 0 + return struct.unpack_from(" int: + if len(self.config) < 26: + return 0 + return struct.unpack_from(" str: + return "Value(%s, density=%d, kind=0x%02x, data=0x%08x)" % ( + self.type_name, + self.density, + self.kind, + self.data, + ) + + +class StringPool: + """The table's global string pool, with the offset of every string kept. + + The offsets are what makes an in-place swap possible: a string can be + rewritten where it lies as long as the replacement encodes to the same + number of bytes. + """ + + UTF8 = 1 << 8 + + def __init__(self, data: bytearray, start: int): + # the table's own buffer, not a copy: a string patched in place has to + # be what the next read of it sees + kind, header_size, size = struct.unpack_from(" int: + return self.count + + def get(self, index: int) -> Optional[str]: + if index < 0 or index >= self.count: + return None + if index in self._cache: + return self._cache[index] + value = _read_string(self._data, self.base + self.offsets[index], self.utf8) + self._cache[index] = value + return value + + def encoded_span(self, index: int) -> Tuple[int, int]: + """Where the string's bytes live: (offset of the first byte, length).""" + pos = self.base + self.offsets[index] + if self.utf8: + _chars, pos = _read_len(self._data, pos, True) + nbytes, pos = _read_len(self._data, pos, True) + return pos, nbytes + nchars, pos = _read_len(self._data, pos, False) + return pos, nchars * 2 + + +def _read_len(data: bytes, pos: int, utf8: bool) -> Tuple[int, int]: + if utf8: + first = data[pos] + if first & 0x80: + return ((first & 0x7F) << 8) | data[pos + 1], pos + 2 + return first, pos + 1 + first = struct.unpack_from(" str: + if utf8: + _chars, pos = _read_len(data, pos, True) + nbytes, pos = _read_len(data, pos, True) + return data[pos : pos + nbytes].decode("utf-8", "replace") + nchars, pos = _read_len(data, pos, False) + return data[pos : pos + nchars * 2].decode("utf-16-le", "replace") + + +class Package: + def __init__(self, pid: int, name: str): + self.id = pid + self.name = name + self.type_names: Optional[StringPool] = None + # type id -> list of RES_TABLE_TYPE chunk starts + self.types: Dict[int, List[int]] = {} + + +class Arsc: + def __init__(self, data: bytes): + self.data = bytearray(data) + self.dirty = False + self.packages: List[Package] = [] + self._parse() + + # ------------------------------------------------------------- parsing + + def _parse(self) -> None: + data = self.data + kind, header_size, _size = struct.unpack_from(" Package: + data = self.data + pid = struct.unpack_from(" Optional[Package]: + for p in self.packages: + if p.id == pid: + return p + return None + + def values(self, res_id: int) -> List[Value]: + """Every configuration's value for `res_id`, in table order.""" + pid = (res_id >> 24) & 0xFF + type_id = (res_id >> 16) & 0xFF + entry_id = res_id & 0xFFFF + package = self.package(pid) + if package is None: + return [] + type_name = "?" + if package.type_names is not None: + type_name = package.type_names.get(type_id - 1) or "?" + + out = [] + for chunk in package.types.get(type_id, []): + value = self._entry_value(chunk, entry_id, type_name) + if value is not None: + out.append(value) + return out + + def _entry_value(self, chunk: int, entry_id: int, type_name: str) -> Optional[Value]: + data = self.data + _ctype, header_size, _csize = struct.unpack_from("= entry_count: + return None + off = struct.unpack_from("= entry_count: + return None + off = struct.unpack_from("> 8 + value = struct.unpack_from(" Optional[str]: + if value.kind != TYPE_STRING: + return None + return self.strings.get(value.data) + + # ------------------------------------------------------------- patching + + def replace_string(self, index: int, new: str) -> None: + """Overwrite a pooled string with one of the same encoded length.""" + pos, length = self.strings.encoded_span(index) + raw = new.encode("utf-8" if self.strings.utf8 else "utf-16-le") + if len(raw) != length: + raise ArscError( + "%r does not encode to the %d bytes %r does" + % (new, length, self.strings.get(index)) + ) + self.data[pos : pos + length] = raw + self.strings._cache.pop(index, None) + self.dirty = True + + def set_value(self, value: Value, kind: int, data: int) -> None: + """Overwrite a typed value where it lies.""" + struct.pack_into(" bytes: + return bytes(self.data) diff --git a/lihont/axml.py b/lihont/axml.py new file mode 100644 index 0000000..bad8d79 --- /dev/null +++ b/lihont/axml.py @@ -0,0 +1,509 @@ +"""Binary XML (AXML) read and write. + +Android's compiled XML is a handful of chunks: a string pool, a map from +attribute name to framework resource id, and a flat stream of start/end nodes. +This module parses that into objects, lets you edit them, and writes it back. + +Nothing here rebuilds resources.arsc, so every id an attribute refers to has to +be an id the apk already has. Strings are different: the pool is ours to append +to, and appended strings never disturb the indices already in use -- which is +what keeps the resource map (aligned with the first N pool entries) valid. +""" + +from __future__ import annotations + +import struct +from typing import List, Optional + +# chunk types +RES_STRING_POOL = 0x0001 +RES_XML = 0x0003 +RES_XML_START_NAMESPACE = 0x0100 +RES_XML_END_NAMESPACE = 0x0101 +RES_XML_START_ELEMENT = 0x0102 +RES_XML_END_ELEMENT = 0x0103 +RES_XML_CDATA = 0x0104 +RES_XML_RESOURCE_MAP = 0x0180 + +# Res_value types +TYPE_NULL = 0x00 +TYPE_REFERENCE = 0x01 +TYPE_STRING = 0x03 +TYPE_INT_BOOLEAN = 0x12 + +ANDROID_NS = "http://schemas.android.com/apk/res/android" + +# The framework attributes we need to be able to write. These never change -- +# they are public ids, fixed the day each one shipped -- and the build checks +# every one of them against android.jar rather than trusting this table. +ATTR_IDS = { + "theme": 0x01010000, + "label": 0x01010001, + "icon": 0x01010002, + "name": 0x01010003, + "exported": 0x01010010, + "enabled": 0x0101000E, + "configChanges": 0x0101001F, + "launchMode": 0x0101001D, + "roundIcon": 0x0101052C, + "authorities": 0x01010018, + # 16842779, read out of android.jar rather than written down: the build + # checks every one of these against it and stops on a disagreement + "grantUriPermissions": 0x0101001B, + "targetActivity": 0x01010202, + "taskAffinity": 0x01010012, + "minSdkVersion": 0x0101020C, + "drawable": 0x01010199, + "value": 0x01010024, + "scheme": 0x01010027, + "host": 0x01010028, +} + +NO_ENTRY = 0xFFFFFFFF + + +class AxmlError(Exception): + pass + + +# --------------------------------------------------------------- string pool + + +class StringPool: + """The pool of every string the file mentions, in its original order. + + Strings are appended, never reordered or removed: every node and every + entry of the resource map refers to a string by index. + """ + + SORTED = 1 << 0 + UTF8 = 1 << 8 + + def __init__(self, strings: List[str], flags: int, styles: bytes, style_offsets: List[int]): + self.strings = strings + # a sorted pool stops being sorted the moment we append to it, and + # nothing in a manifest relies on the flag + self.flags = flags & ~self.SORTED + self.styles = styles + self.style_offsets = style_offsets + + @property + def utf8(self) -> bool: + return bool(self.flags & self.UTF8) + + def index(self, value: str) -> int: + """The index of `value`, appending it to the pool if it is new.""" + try: + return self.strings.index(value) + except ValueError: + self.strings.append(value) + return len(self.strings) - 1 + + def get(self, index: int) -> Optional[str]: + if index == NO_ENTRY or index < 0 or index >= len(self.strings): + return None + return self.strings[index] + + # -- parsing + + @classmethod + def parse(cls, data: bytes, start: int) -> "StringPool": + kind, header_size, size = struct.unpack_from(" bytes: + offsets = [] + blob = bytearray() + seen = {} + for s in self.strings: + if s in seen: + offsets.append(seen[s]) + continue + seen[s] = len(blob) + offsets.append(len(blob)) + blob += _write_string(s, self.utf8) + while len(blob) % 4: + blob.append(0) + + header_size = 28 + strings_start = header_size + 4 * len(offsets) + 4 * len(self.style_offsets) + styles_start = (strings_start + len(blob)) if self.styles else 0 + size = strings_start + len(blob) + len(self.styles) + + out = bytearray() + out += struct.pack(" str: + if utf8: + _chars, pos = _read_len(data, pos, True) # length in utf-16 units, unused + nbytes, pos = _read_len(data, pos, True) + return data[pos : pos + nbytes].decode("utf-8", "replace") + nchars, pos = _read_len(data, pos, False) + return data[pos : pos + nchars * 2].decode("utf-16-le", "replace") + + +def _write_len(value: int, utf8: bool) -> bytes: + if utf8: + if value > 0x7F: + return bytes([0x80 | ((value >> 8) & 0x7F), value & 0xFF]) + return bytes([value]) + if value > 0x7FFF: + return struct.pack("> 16) & 0x7FFF), value & 0xFFFF) + return struct.pack(" bytes: + if utf8: + raw = value.encode("utf-8") + chars = len(value.encode("utf-16-le")) // 2 + return _write_len(chars, True) + _write_len(len(raw), True) + raw + b"\x00" + raw = value.encode("utf-16-le") + return _write_len(len(raw) // 2, False) + raw + b"\x00\x00" + + +# --------------------------------------------------------------------- nodes + + +class Attribute: + def __init__(self, ns: int, name: int, raw: int, size: int, res0: int, kind: int, data: int): + self.ns = ns + self.name = name + self.raw = raw + self.size = size + self.res0 = res0 + self.kind = kind + self.data = data + + def pack(self) -> bytes: + return struct.pack( + " bytes: + body = bytearray() + if self.kind in (RES_XML_START_NAMESPACE, RES_XML_END_NAMESPACE): + body += struct.pack(" "Axml": + kind, header_size, _size = struct.unpack_from(" bytes: + body = bytearray() + body += self.pool.build() + if self.resource_map: + size = 8 + 4 * len(self.resource_map) + body += struct.pack(" Optional[str]: + return self.pool.get(index) + + def elements(self, name: str) -> List[Node]: + return [ + n + for n in self.nodes + if n.kind == RES_XML_START_ELEMENT and self.pool.get(n.name) == name + ] + + def attr(self, node: Node, name: str) -> Optional[Attribute]: + for a in node.attributes: + if self.pool.get(a.name) == name and self.pool.get(a.ns) in (ANDROID_NS, None): + return a + return None + + def attr_string(self, node: Node, name: str) -> Optional[str]: + a = self.attr(node, name) + if a is None: + return None + if a.kind == TYPE_STRING: + return self.pool.get(a.data) + return self.pool.get(a.raw) + + # -- editing + + def attribute_name_index(self, name: str) -> int: + """The pool index for an `android:` attribute name. + + The resource map runs parallel to the first entries of the pool, so an + attribute name has to sit at the index whose map entry is its id. An + attribute the file already uses is simply found there. One it does not + is inserted at the end of the map -- in the middle of the pool -- and + every index above it is shifted up to match. + """ + if name not in ATTR_IDS: + raise AxmlError("unknown framework attribute: %s" % name) + wanted = ATTR_IDS[name] + for i, rid in enumerate(self.resource_map): + if rid == wanted: + if self.pool.get(i) != name: + raise AxmlError( + "resource map says %d is %s, the pool says %r" + % (i, name, self.pool.get(i)) + ) + return i + + at = len(self.resource_map) + self.pool.strings.insert(at, name) + self.resource_map.append(wanted) + self._shift_indices(at) + return at + + def _shift_indices(self, at: int) -> None: + """Move every string reference at or above `at` up by one.""" + + def bump(index: int) -> int: + return index + 1 if index != NO_ENTRY and index >= at else index + + seen = set() + for node in self.nodes + self.pending: + if id(node) in seen: + continue + seen.add(id(node)) + if node.kind in (RES_XML_START_NAMESPACE, RES_XML_END_NAMESPACE): + node.prefix = bump(node.prefix) + node.uri = bump(node.uri) + continue + node.ns = bump(node.ns) + node.name = bump(node.name) + for a in node.attributes: + a.ns = bump(a.ns) + a.name = bump(a.name) + a.raw = bump(a.raw) + if a.kind == TYPE_STRING: + a.data = bump(a.data) + if node.kind == RES_XML_CDATA and len(node.tail) == 8: + size, res0, kind, data = struct.unpack(" None: + """Set an `android:` attribute, replacing it if the element has it.""" + name_index = self.attribute_name_index(name) + ns_index = self.pool.index(ANDROID_NS) + existing = self.attr(node, name) + if existing is not None: + existing.ns = ns_index + existing.name = name_index + existing.raw = raw + existing.size = 8 + existing.res0 = 0 + existing.kind = kind + existing.data = data + return + attr = Attribute(ns_index, name_index, raw, 8, 0, kind, data) + # attributes are expected in ascending resource-id order + ids = [ATTR_IDS.get(self.pool.get(a.name) or "", 0) for a in node.attributes] + where = len(node.attributes) + for i, rid in enumerate(ids): + if rid and rid > ATTR_IDS[name]: + where = i + break + node.attributes.insert(where, attr) + + def set_attr_string(self, node: Node, name: str, value: str) -> None: + # the attribute's own name goes in first: adding one renumbers the pool + # from the end of the resource map up, and a value index looked up + # before that would be pointing one string to the left afterwards + self.attribute_name_index(name) + index = self.pool.index(value) + self.set_attr(node, name, TYPE_STRING, index, raw=index) + + def set_attr_bool(self, node: Node, name: str, value: bool) -> None: + self.set_attr(node, name, TYPE_INT_BOOLEAN, 0xFFFFFFFF if value else 0) + + def set_attr_ref(self, node: Node, name: str, res_id: int) -> None: + self.set_attr(node, name, TYPE_REFERENCE, res_id) + + def make_element(self, name: str) -> Node: + node = Node(RES_XML_START_ELEMENT, 0, NO_ENTRY) + node.ns = NO_ENTRY + node.name = self.pool.index(name) + self.pending.append(node) + return node + + def close_element(self, start: Node) -> Node: + node = Node(RES_XML_END_ELEMENT, 0, NO_ENTRY) + node.ns = start.ns + node.name = start.name + self.pending.append(node) + return node + + def insert_into(self, parent: Node, subtree: List[Node]) -> None: + """Put a subtree in as the last child of `parent`.""" + depth = 0 + for i in range(self.nodes.index(parent) + 1, len(self.nodes)): + node = self.nodes[i] + if node.kind == RES_XML_START_ELEMENT: + depth += 1 + elif node.kind == RES_XML_END_ELEMENT: + if depth == 0: + self.nodes[i:i] = subtree + return + depth -= 1 + raise AxmlError("element has no end tag") + + +def _parse_node(data: bytes, pos: int, ctype: int, chdr: int, csize: int) -> Node: + line, comment = struct.unpack_from("remember(Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String;" +PREPARE = HELPER + "->prepare(Landroid/widget/TextView;Ljava/lang/CharSequence;)Ljava/lang/CharSequence;" +COMMENT = HELPER + "->bindComment(Ljava/lang/Object;Landroid/widget/TextView;)V" +PROFILE = HELPER + "->bindProfile(Ljava/lang/Object;Landroid/widget/TextView;)V" + +USER = "Lcom/ss/android/ugc/aweme/profile/model/User;" +TUX = "Lcom/bytedance/tux/input/TuxTextView;" +CELL = "Lcom/ss/android/ugc/aweme/commentv2/commentlist/powercell/BaseCommentCell;" +NICKNAME = "Lcom/ss/android/ugc/profile/business/ur/nickname/ProfileInfoNicknameProtocol;" + +DEXES = {USER: "classes40.dex", TUX: "classes32.dex", CELL: "classes25.dex", NICKNAME: "classes26.dex"} +MARKER = "# lihont badges" + +_METHOD = re.compile(r"^\.method ([^\n]*)\n(.*?)^\.end method", re.M | re.S) +GETTERS = ("getNickname", "getUniqueId", "getUserDisplayName") + + +def runtime_targets(): + return [REMEMBER, PREPARE, COMMENT, PROFILE] + + +def _rewrite(text: str, owner: str, change) -> tuple[str, dict]: + if not re.search(r"^\.class [^\n]*" + re.escape(owner) + r"$", text, re.M): + raise ValueError("Not the class " + owner) + hits: dict = {} + + def method(match): + header, body = match.group(1), match.group(2) + if MARKER in body: + raise ValueError("Badge hook already present in " + owner) + changed = change(header, body, hits) + return match.group(0) if changed is None else ".method %s\n%s.end method" % (header, changed) + + return _METHOD.sub(method, text), hits + + +def rewrite_user(text: str): + def change(header, body, hits): + name = next((g for g in GETTERS if (" %s()Ljava/lang/String;" % g) in " " + header), None) + if name is None: + return None + if not re.search(r"^ \.registers 2$", body, re.M) or body.count("return-object v0") != 1: + raise ValueError("Unexpected account-name getter: " + header) + hits[name] = 1 + return body.replace( + " return-object v0", + " %s\n invoke-static {p0, v0}, %s\n\n move-result-object v0\n\n return-object v0" % (MARKER, REMEMBER)) + + result, hits = _rewrite(text, USER, change) + if len(hits) != len(GETTERS): + raise ValueError("User is missing name getters: %s" % sorted(hits)) + return result, hits + + +def rewrite_text_view(text: str): + def change(header, body, hits): + if not header.endswith(" setText(Ljava/lang/CharSequence;Landroid/widget/TextView$BufferType;)V"): + return None + hits["set_text"] = 1 + # p0 and p1 are adjacent, so a range call works whatever the register layout + return re.sub(r"^( \.registers \d+)$", lambda m: m.group(1) + "\n\n %s\n invoke-static/range {p0 .. p1}, %s\n\n" + " move-result-object p1\n" % (MARKER, PREPARE), body, count=1, flags=re.M) + + result, hits = _rewrite(text, TUX, change) + if hits != {"set_text": 1}: + raise ValueError("TuxTextView.setText not found") + return result, hits + + +def rewrite_comment(text: str): + def change(header, body, hits): + if not header.endswith(" U7(Ljava/lang/CharSequence;)V"): + return None + field = re.search(r"iget-object v3, p0, " + re.escape(CELL) + r"->(\w+):Lcom/bytedance/tux/input/TuxTextView;", body) + if field is None or body.count(" return-void") != 2: + raise ValueError("Comment author setter changed") + # the author view is v3 from the first read until the end; bind after the last statement + head, tail = body.rsplit(" return-void", 1) + hits["comment"] = 1 + return head + " %s\n invoke-static {p0, v3}, %s\n\n return-void" % (MARKER, COMMENT) + tail + + result, hits = _rewrite(text, CELL, change) + if hits != {"comment": 1}: + raise ValueError("BaseCommentCell.U7 not found") + return result, hits + + +def rewrite_profile(text: str): + def change(header, body, hits): + if not header.endswith(" LLI(Landroid/text/SpannableStringBuilder;Lcom/bytedance/tux/input/TuxTextView;)V"): + return None + if body.count(" return-void") != 1 or "->LJL()" not in body: + raise ValueError("Profile nickname binder changed") + hits["profile"] = 1 + head, tail = body.rsplit(" return-void", 1) + return head + " %s\n invoke-static {p0, p2}, %s\n\n return-void" % (MARKER, PROFILE) + tail + + result, hits = _rewrite(text, NICKNAME, change) + if hits != {"profile": 1}: + raise ValueError("ProfileInfoNicknameProtocol.LLI not found") + return result, hits + + +REWRITES = {USER: rewrite_user, TUX: rewrite_text_view, CELL: rewrite_comment, NICKNAME: rewrite_profile} diff --git a/lihont/bar_patch.py b/lihont/bar_patch.py new file mode 100644 index 0000000..0f026b9 --- /dev/null +++ b/lihont/bar_patch.py @@ -0,0 +1,39 @@ +"""Tell lihont.two.bar.BottomBar when TikTok's main screen is on screen. + +The bottom bar (home, friends, create, inbox, profile) is built by obfuscated view classes, so the +mod finds its views at run time instead of patching them one by one. All it needs from TikTok is a +signal that the main activity came to the front: MainActivity.onResume calls +BottomBar.onResumed(activity) right after its super call. +""" + +from __future__ import annotations + +import re + +OWNER = "Lcom/ss/android/ugc/aweme/main/MainActivity;" +DEX = "classes25.dex" +HOOK = "Llihont/two/bar/BottomBar;->onResumed(Landroid/app/Activity;)V" + +_ON_RESUME = re.compile(r"^\.method public onResume\(\)V\n.*?^\.end method", re.M | re.S) +_SUPER_CALL = re.compile(r"^ invoke-super \{p0\}, [^\n]*->onResume\(\)V\n", re.M) + + +def runtime_targets(): + """Runtime methods the rewrite calls, as they appear in a method table.""" + return [HOOK] + + +def rewrite_main_activity(text: str) -> str: + """Call the hook right after MainActivity.onResume's super call.""" + match = _ON_RESUME.search(text) + if match is None: + raise ValueError("MainActivity.onResume not found") + method = match.group() + if "BottomBar" in method: + raise ValueError("Bottom bar hook already present in MainActivity.onResume") + # `this` is a parameter register, so the call adds no register and cannot clash with a local + calls = _SUPER_CALL.findall(method) + if len(calls) != 1: + raise ValueError("MainActivity.onResume must hold exactly one super call, found %d" % len(calls)) + patched = _SUPER_CALL.sub(lambda m: m.group() + "\n invoke-static {p0}, %s\n" % HOOK, method, count=1) + return text[:match.start()] + patched + text[match.end():] diff --git a/lihont/clone.py b/lihont/clone.py new file mode 100644 index 0000000..12dd5a8 --- /dev/null +++ b/lihont/clone.py @@ -0,0 +1,185 @@ +"""Repackage the built LihonT 2.0 APK under an independent application ID. + +The output installs alongside TikTok and any LihonT 1.x build instead of +replacing them. Class names, method definitions and resource IDs stay fixed; +only application identity changes: the manifest package attribute, provider +authorities, custom permissions, resource package names in resources.arsc and +the matching string literals inside DEX files. The APK is then re-signed. + +python -m lihont.clone ComplectAPK/lihont-2.0-test.apk \ + -o ComplectAPK/lihont-2.0-test.burmalda.apk --package test.test.burmalda +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +from pathlib import Path + +from .apkzip import Apk +from .axml import Axml +from . import dexpatch, editions, manifest +from .toolchain import Toolchain + +SOURCE_VERSION = "47.1.3" + + +def edition_dex_helper(tools: Toolchain, room: Path, clone: str, api: int) -> Path: + """Compile a clone-specific EditionDex: the Java helper hardcodes the id.""" + source = Path(__file__).parent / "java" / "EditionDex.java" + if not source.exists(): + source = Path("src/EditionDex.java").resolve() + text = source.read_text(encoding="utf-8") + if editions.CLONE not in text: + raise ValueError("EditionDex source does not carry the expected clone id") + variant = room / "src" / "EditionDex.java" + variant.parent.mkdir(parents=True, exist_ok=True) + variant.write_text(text.replace(editions.CLONE, clone), encoding="utf-8") + compiler = room / "compiler" + compiler.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["javac", "--release", "17", "-classpath", tools.smali, + "-d", str(compiler), str(variant)], check=True + ) + return compiler + + +def rewrite_dex(apk: Apk, mapping: dict, tools: Toolchain, room: Path, clone: str, api: int) -> dict: + compiler = edition_dex_helper(tools, room, clone, api) + smali_jar = tools.smali + report: dict = {} + cache_root = room / "dex-cache" + for name in sorted(n for n in apk.names() if n.endswith(".dex")): + data = apk.read(name) + if not any(key.encode("ascii") in data for key in mapping): + continue + key = hashlib.sha256( + data + json.dumps(mapping, sort_keys=True).encode() + + clone_fingerprint(mapping).encode() + ).hexdigest() + cache = cache_root / key + cache.mkdir(parents=True, exist_ok=True) + patched = cache / "patched.dex" + info_path = cache / "report.json" + if patched.exists() and info_path.exists(): + info = json.loads(info_path.read_text()) + if hashlib.sha256(patched.read_bytes()).hexdigest() != info["sha256"]: + raise ValueError("Invalid cached DEX in " + name) + print("Cached " + name, flush=True) + else: + original = cache / "original.dex" + original.write_bytes(data) + properties = cache / "identities.properties" + properties.write_text( + "\n".join(k + "=" + v for k, v in sorted(mapping.items())), + encoding="utf-8", + ) + print("Rewrite DEX identities: " + name, flush=True) + result = subprocess.run( + ["java", "-Xmx1g", "-cp", os.pathsep.join([str(compiler), smali_jar]), + "EditionDex", str(original), str(patched), str(properties), str(api)], + check=True, capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + changed = json.loads(result.stdout.strip()) + info = {"sha256": hashlib.sha256(patched.read_bytes()).hexdigest(), **changed} + info_path.write_text(json.dumps(info, indent=2), encoding="utf-8") + if dexpatch.defined_methods(data) != dexpatch.defined_methods(patched.read_bytes()) or \ + dexpatch.dex_format(data) != dexpatch.dex_format(patched.read_bytes()): + raise ValueError("Clone changed class definitions or DEX format in " + name) + if info["literals"]: + apk.replace(name, patched.read_bytes()) + report[name] = {"literals": info["literals"], "classes": info["classes"]} + return report + + +def clone_fingerprint(mapping: dict) -> str: + return hashlib.sha256( + "|".join(k + "->" + v for k, v in sorted(mapping.items())).encode() + ).hexdigest()[:16] + + +def build(source: str, output: str, clone: str, workspace: str = "work/clone") -> dict: + source_path, output_path, room = ( + Path(source).resolve(), Path(output).resolve(), Path(workspace).resolve() + ) + if source_path == output_path: + raise ValueError("Keep the input APK unchanged") + if not clone or "." not in clone: + raise ValueError("Clone application id looks invalid: " + clone) + room.mkdir(parents=True, exist_ok=True) + tools = Toolchain("tools") + apk = Apk(str(source_path)) + report = { + "clone_package": clone, "source": source_path.name, + "source_sha256": hashlib.sha256(source_path.read_bytes()).hexdigest(), + } + try: + axml = Axml.parse(apk.read("AndroidManifest.xml")) + if manifest.package_name(axml) != editions.ORIGINAL or \ + manifest.version_name(axml) != SOURCE_VERSION: + raise ValueError("Expected the LihonT 2.0 TikTok " + SOURCE_VERSION + " baseline") + api = manifest.min_sdk(axml) + + # editions.* helpers carry the previous clone id; retarget them all. + old_clone = editions.CLONE + editions.CLONE = clone + try: + mapping = editions.authority_map(axml) + # SHARED was precomputed at import time with the old clone id. + mapping = { + key: (clone + value[len(old_clone):] if value.startswith(old_clone) else value) + for key, value in mapping.items() + } + editions.clone_manifest(axml, mapping) + resources, changed_packages = editions.clone_resource_packages(apk.read("resources.arsc")) + apk.replace("resources.arsc", resources) + report["resource_packages"] = changed_packages + finally: + editions.CLONE = old_clone + + report["changed_dex"] = rewrite_dex(apk, mapping, tools, room, clone, api) + apk.replace("AndroidManifest.xml", axml.build()) + apk.drop_signature() + output_path.parent.mkdir(parents=True, exist_ok=True) + apk.write(str(output_path)) + finally: + apk.close() + tools.sign(str(output_path)) + + # Verify the signed artifact actually carries the requested identity. + check = Apk(str(output_path)) + try: + identity = manifest.package_name(Axml.parse(check.read("AndroidManifest.xml"))) + finally: + check.close() + if identity != clone: + raise ValueError("Clone identity mismatch: " + identity) + report["verified_package"] = identity + report.update({ + "file": output_path.name, "bytes": output_path.stat().st_size, + "sha256": hashlib.sha256(output_path.read_bytes()).hexdigest(), + }) + Path(str(output_path) + ".json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return report + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("apk") + parser.add_argument("-o", "--out", required=True) + parser.add_argument("--package", default="test.test.burmalda") + parser.add_argument("--work", default="work/clone") + args = parser.parse_args() + print(json.dumps( + build(args.apk, args.out, args.package, args.work), + ensure_ascii=False, indent=2 + )) + + +if __name__ == "__main__": + main() diff --git a/lihont/dexpatch.py b/lihont/dexpatch.py new file mode 100644 index 0000000..db9a591 --- /dev/null +++ b/lihont/dexpatch.py @@ -0,0 +1,500 @@ +"""LihonT bytecode patcher: No-Watermark and Region Spoofing. + +Redirects Dalvik/ART calls in the TikTok APK to pure LihonT static methods. +""" + +from __future__ import annotations + +import os +import re +import shutil +import struct +import subprocess +from typing import Dict, List, Optional, Tuple + +from . import profile_features + +TELEPHONY = "Landroid/telephony/TelephonyManager;" +SUBSCRIPTIONS = "Landroid/telephony/SubscriptionManager;" +CANVAS = "Landroid/graphics/Canvas;" + +# LihonT Injected Classes +DOWNLOAD = "Llihont/Download;" +WATERMARK = "Llihont/Watermark;" +REGION = "Llihont/Region;" + +# TikTok models +VIDEO = "Lcom/ss/android/ugc/aweme/feed/model/Video;" +ACL = "Lcom/ss/android/ugc/aweme/feed/model/ACLCommonShare;" +AWEME = "Lcom/ss/android/ugc/aweme/feed/model/Aweme;" +USER = "Lcom/ss/android/ugc/aweme/profile/model/User;" +VIDEO_CONTROL = "Lcom/ss/android/ugc/aweme/feed/model/VideoControl;" +PHOTO_IMAGE = "Lcom/ss/android/ugc/aweme/feed/model/PhotoModeImageUrlModel;" + +STRING = "Ljava/lang/String;" +URL_MODEL = "Lcom/ss/android/ugc/aweme/base/model/UrlModel;" +BOXED_BOOLEAN = "Ljava/lang/Boolean;" + +# Watermark draw call anchored inside the class containing '[tiktok_logo]' +ANCHORED_SOURCES: List[Tuple[str, str, str, str, str, str]] = [ + ("[tiktok_logo]", CANVAS, "drawBitmap", + "(Landroid/graphics/Bitmap;FFLandroid/graphics/Paint;)V", + "(%sLandroid/graphics/Bitmap;FFLandroid/graphics/Paint;)V" % CANVAS, WATERMARK), +] + +# Model method rewrites +MODEL_SOURCES: List[Tuple[str, str, str, str, str]] = [ + # Download clean video without watermark + (VIDEO, "getDownloadAddr", "()%s" % URL_MODEL, "(%s)%s" % (VIDEO, URL_MODEL), DOWNLOAD), + # Bypass download restrictions + (ACL, "getCode", "()I", "(%s)I" % ACL, DOWNLOAD), + (ACL, "getShowType", "()I", "(%s)I" % ACL, DOWNLOAD), + (ACL, "getTranscode", "()I", "(%s)I" % ACL, DOWNLOAD), + (AWEME, "isPreventDownload", "()Z", "(%s)Z" % AWEME, DOWNLOAD), + (USER, "isPreventDownload", "()Z", "(%s)Z" % USER, DOWNLOAD), + # Region & SIM spoofing for CIS feed availability + (TELEPHONY, "getPhoneCount", "()I", "(%s)I" % TELEPHONY, REGION), + (TELEPHONY, "getActiveModemCount", "()I", "(%s)I" % TELEPHONY, REGION), + (TELEPHONY, "getPhoneType", "()I", "(%s)I" % TELEPHONY, REGION), + (SUBSCRIPTIONS, "getActiveSubscriptionInfoCount", "()I", "(%s)I" % SUBSCRIPTIONS, REGION), +] + +MODEL_STATICS: List[Tuple[str, str, str, str, str]] = [ + (SUBSCRIPTIONS, "getDefaultDataSubscriptionId", "()I", "()I", REGION), + (SUBSCRIPTIONS, "getDefaultVoiceSubscriptionId", "()I", "()I", REGION), + (SUBSCRIPTIONS, "getDefaultSmsSubscriptionId", "()I", "()I", REGION), + (SUBSCRIPTIONS, "getActiveDataSubscriptionId", "()I", "()I", REGION), +] + +FIELD_SOURCES: List[Tuple[str, str, str, str, str]] = [ + (VIDEO_CONTROL, "allowDownload", BOXED_BOOLEAN, "allowDownload", DOWNLOAD), + (PHOTO_IMAGE, "ownerWatermarkImage", URL_MODEL, "ownerWatermarkImage", DOWNLOAD), + (PHOTO_IMAGE, "userWatermarkImage", URL_MODEL, "userWatermarkImage", DOWNLOAD), +] + +TARGETS: List[Tuple[str, str, str]] = [ + ("getSimCountryIso", "()Ljava/lang/String;", "(%s)Ljava/lang/String;" % TELEPHONY), + ("getNetworkCountryIso", "()Ljava/lang/String;", "(%s)Ljava/lang/String;" % TELEPHONY), + ("getSimOperator", "()Ljava/lang/String;", "(%s)Ljava/lang/String;" % TELEPHONY), + ("getNetworkOperator", "()Ljava/lang/String;", "(%s)Ljava/lang/String;" % TELEPHONY), + ("getSimOperatorName", "()Ljava/lang/String;", "(%s)Ljava/lang/String;" % TELEPHONY), + ("getNetworkOperatorName", "()Ljava/lang/String;", "(%s)Ljava/lang/String;" % TELEPHONY), +] + +FORCED_FALSE: List[Tuple[str, str]] = [ + ("com/bytedance/lobby/google/GoogleAuth", "isAvailable()Z"), +] + + +def rules() -> List[Tuple[str, "re.Pattern[str]", str]]: + out = [] + for name, original, replacement in TARGETS: + pattern = re.compile( + r"invoke-virtual(/range)? (\{[^}]*\}), %s->%s%s" + % (re.escape(TELEPHONY), name, re.escape(original)) + ) + target = r"invoke-static\1 \2, %s->%s%s" % (REGION, name, replacement) + out.append((name + original, pattern, target)) + return out + + +def model_rules() -> List[Tuple[str, "re.Pattern[str]", str]]: + out = [] + for owner, name, original, replacement, target in MODEL_SOURCES: + theirs, ours = name if isinstance(name, tuple) else (name, name) + out.append(( + "%s->%s" % (owner.rsplit("/", 1)[-1][:-1], theirs), + re.compile(r"invoke-(?:virtual|interface)(/range)? (\{[^}]*\}), %s->%s%s" + % (re.escape(owner), theirs, re.escape(original))), + r"invoke-static\1 \2, %s->%s%s" % (target, ours, replacement), + )) + for owner, name, original, replacement, target in MODEL_STATICS: + theirs, ours = name if isinstance(name, tuple) else (name, name) + out.append(( + "%s->%s" % (owner.rsplit("/", 1)[-1][:-1], theirs), + re.compile(r"invoke-static(/range)? (\{[^}]*\}), %s->%s%s" + % (re.escape(owner), theirs, re.escape(original))), + r"invoke-static\1 \2, %s->%s%s" % (target, ours, replacement), + )) + for owner, field, kind, name, target in FIELD_SOURCES: + if kind in ("J", "D"): + read, took = "iget-wide", "move-result-wide" + elif kind in ("Z", "B", "S", "C", "I", "F"): + read, took = "iget", "move-result" + else: + read, took = "iget-object", "move-result-object" + out.append(( + "%s.%s" % (owner.rsplit("/", 1)[-1][:-1], field), + re.compile(r"^(\s*)%s ([vp]\d+), ([vp]\d+), %s->%s:%s$" + % (read, re.escape(owner), re.escape(field), re.escape(kind)), + re.MULTILINE), + r"\1invoke-static {\3}, %s->%s(%s)%s\n\n\1%s \2" + % (target, name, owner, kind, took), + )) + return out + + +def anchored_rules() -> List[Tuple[str, str, "re.Pattern[str]", str]]: + out = [] + for anchor, owner, name, original, replacement, target in ANCHORED_SOURCES: + out.append(( + anchor, + "%s->%s (in class marked %s)" % (owner.rsplit("/", 1)[-1][:-1], name, anchor), + re.compile(r"invoke-virtual(/range)? (\{[^}]*\}), %s->%s%s" + % (re.escape(owner), name, re.escape(original))), + r"invoke-static\1 \2, %s->%s%s" % (target, name, replacement), + )) + return out + + +def carries_an_anchor(dex: bytes) -> bool: + return any(anchor.encode() in dex for anchor, *_rest in ANCHORED_SOURCES) + + +def method_references(dex: bytes) -> set: + if len(dex) < 112 or dex[:4] != b"dex\n": + return set() + string_ids_off = struct.unpack_from(" str: + cached = str_cache.get(index) + if cached is not None: + return cached + at = struct.unpack_from(" str: + cached = type_cache.get(index) + if cached is not None: + return cached + s = string(struct.unpack_from(" bool: + for owner, field, _kind, _name, _target in FIELD_SOURCES: + if owner.encode() in dex and field.encode() in dex: + return True + targets = [] + for owner, name, _original, _replacement, _target in MODEL_SOURCES + MODEL_STATICS: + theirs = name[0] if isinstance(name, tuple) else name + if owner.encode() in dex and theirs.encode() in dex: + targets.append((owner, theirs)) + for name, _original, _replacement in TARGETS: + if TELEPHONY.encode() in dex and name.encode() in dex: + targets.append((TELEPHONY, name)) + if not targets: + return False + refs = method_references(dex) + return any((owner, name) in refs for owner, name in targets) + + +def force_false(root: str) -> Dict[str, int]: + counts: Dict[str, int] = {} + for class_name, signature in FORCED_FALSE: + path = os.path.join(root, *class_name.split("/")) + ".smali" + if not os.path.exists(path): + continue + with open(path, encoding="utf-8") as handle: + text = handle.read() + pattern = re.compile( + r"^\.method ([^\n]*%s)\n.*?^\.end method$" % re.escape(signature), + re.MULTILINE | re.DOTALL, + ) + match = pattern.search(text) + if match is None: + continue + stub = ".method %s\n .registers 1\n\n const/4 v0, 0x0\n\n return v0\n.end method" % ( + match.group(1), + ) + text = text[: match.start()] + stub + text[match.end():] + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + counts["%s->%s" % (class_name.rsplit("/", 1)[-1], signature)] = 1 + return counts + + +def interesting(dex: bytes, literals: Optional[Dict[str, str]] = None, + feature_flags: Optional[Dict[str, int]] = None) -> bool: + if profile_features.interesting(dex, feature_flags or {}): + return True + for old in (literals or {}): + if old.encode() in dex: + return True + if carries_an_anchor(dex): + return True + for class_name, _signature in FORCED_FALSE: + if ("L%s;" % class_name).encode() in dex: + return True + return touches_a_model(dex) + + + +def _spill_classes(src_dir: str, dst_dir: str, count: int = 50) -> int: + os.makedirs(dst_dir, exist_ok=True) + moved = 0 + for root, dirs, files in os.walk(src_dir): + for f in files: + if not f.endswith(".smali"): + continue + src_path = os.path.join(root, f) + with open(src_path, "r", encoding="utf-8", errors="ignore") as handle: + content = handle.read() + if "lihont" in content: + continue + rel_path = os.path.relpath(src_path, src_dir) + dst_path = os.path.join(dst_dir, rel_path) + os.makedirs(os.path.dirname(dst_path), exist_ok=True) + shutil.move(src_path, dst_path) + moved += 1 + if moved >= count: + return moved + return moved + + +def rewrite_all(root: str, literals: Optional[Dict[str, str]] = None) -> Dict[str, int]: + counts: Dict[str, int] = {} + smali_rules = rules() + model_r = model_rules() + owners = tuple(set([owner for owner, _n, _o, _r, _t in MODEL_SOURCES] + + [owner for owner, _n, _o, _r, _t in MODEL_STATICS] + + [owner for owner, _f, _k, _n, _t in FIELD_SOURCES])) + anchored_r = anchored_rules() + lits = literals or {} + + for dirpath, _dirs, files in os.walk(root): + for name in files: + if not name.endswith(".smali"): + continue + path = os.path.join(dirpath, name) + with open(path, encoding="utf-8") as handle: + text = handle.read() + before = text + + if TELEPHONY in text: + for label, pattern, target in smali_rules: + text, hits = pattern.subn(target, text) + if hits: + counts[label] = counts.get(label, 0) + hits + + if lits: + for old, new in lits.items(): + needle = '"%s"' % old + hits = text.count(needle) + if hits: + text = text.replace(needle, '"%s"' % new) + counts[old] = counts.get(old, 0) + hits + + if any(owner in text for owner in owners): + for label, pattern, target in model_r: + text, hits = pattern.subn(target, text) + if hits: + counts[label] = counts.get(label, 0) + hits + + for anchor, label, pattern, target in anchored_r: + if anchor in text: + text, hits = pattern.subn(target, text) + if hits: + counts[label] = counts.get(label, 0) + hits + + if text != before: + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + return counts + + +class Smali: + def __init__(self, jar: str, api: int, jobs: int = 0, heap: str = "4g"): + self.jar = jar + self.api = api + self.jobs = jobs or (os.cpu_count() or 2) + self.heap = heap + + def _run(self, main: str, args: List[str]) -> None: + baksmali_jar = os.path.join(os.path.dirname(self.jar), "baksmali.jar") + cp = os.pathsep.join([self.jar, baksmali_jar]) if os.path.exists(baksmali_jar) else self.jar + command = ["java", "-Xmx" + self.heap, "-cp", cp, main] + args + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + "%s failed:\n%s\n%s" % (main.split(".")[-2], result.stdout, result.stderr) + ) + + def disassemble(self, dex_path: str, out_dir: str) -> None: + self._run( + "com.android.tools.smali.baksmali.Main", + ["d", "-a", str(self.api), "-j", str(self.jobs), "-o", out_dir, dex_path], + ) + + def assemble(self, smali_dir: str, dex_path: str) -> None: + self._run( + "com.android.tools.smali.smali.Main", + ["a", "-a", str(self.api), "-j", str(self.jobs), "-o", dex_path, smali_dir], + ) + + +def dex_format(dex: bytes) -> str: + return dex[4:7].decode("ascii", "replace") + + +def rewrite_targets() -> List[str]: + out = [] + for name, _original, replacement in TARGETS: + out.append("%s->%s%s" % (REGION, name, replacement)) + for _owner, name, _original, replacement, target in MODEL_SOURCES + MODEL_STATICS: + _theirs, ours = name if isinstance(name, tuple) else (name, name) + out.append("%s->%s%s" % (target, ours, replacement)) + for owner, _field, kind, name, target in FIELD_SOURCES: + out.append("%s->%s(%s)%s" % (target, name, owner, kind)) + for _anchor, _owner, name, _original, replacement, target in ANCHORED_SOURCES: + out.append("%s->%s%s" % (target, name, replacement)) + return out + + +def _uleb(data: bytes, at: int) -> Tuple[int, int]: + value = shift = 0 + while True: + byte = data[at] + at += 1 + value |= (byte & 0x7F) << shift + shift += 7 + if not byte & 0x80: + return value, at + + +def defined_methods(dex: bytes) -> set: + string_ids_off = struct.unpack_from(" str: + at = struct.unpack_from(" str: + return string(struct.unpack_from(" str: + _shorty, return_type, parameters = struct.unpack_from( + "<3I", dex, proto_ids_off + 12 * index) + arguments = "" + if parameters: + count = struct.unpack_from(" str: + owner, proto, name = struct.unpack_from("%s%s" % (type_name(owner), string(name), descriptor(proto)) + + out = set() + for i in range(class_defs_size): + data_off = struct.unpack_from(" List[str]: + defined = defined_methods(dex) + return [target for target in rewrite_targets() if target not in defined] + + +def patch(dex: bytes, name: str, smali: Smali, workspace: str, + literals: Optional[Dict[str, str]] = None, + feature_flags: Optional[Dict[str, int]] = None) -> Tuple[bytes, Dict[str, int]]: + room = os.path.join(workspace, name.replace(".dex", "")) + shutil.rmtree(room, ignore_errors=True) + os.makedirs(room, exist_ok=True) + + dex_in = os.path.join(room, "in.dex") + dex_out = os.path.join(room, "out.dex") + with open(dex_in, "wb") as handle: + handle.write(dex) + + smali.disassemble(dex_in, os.path.join(room, "smali")) + counts = rewrite_all(os.path.join(room, "smali"), literals) + counts.update(force_false(os.path.join(room, "smali"))) + flags = feature_flags or {} + counts.update(profile_features.rewrite_directory( + os.path.join(room, "smali"), flags, profile_features.literal_owners(dex, flags) + )) + if not counts: + shutil.rmtree(room, ignore_errors=True) + return dex, counts + + while True: + try: + smali.assemble(os.path.join(room, "smali"), dex_out) + break + except RuntimeError as exc: + err_str = str(exc) + if "out of range" in err_str or "Unsigned short" in err_str: + spill_dir = os.path.join(workspace, "spillover") + moved = _spill_classes(os.path.join(room, "smali"), spill_dir, count=50) + if moved == 0: + raise + else: + raise + + with open(dex_out, "rb") as handle: + patched = handle.read() + shutil.rmtree(room, ignore_errors=True) + + if dex_format(patched) != dex_format(dex): + raise RuntimeError( + "%s came back as dex %s, it went in as dex %s" + % (name, dex_format(patched), dex_format(dex)) + ) + return patched, counts + + +def next_dex_name(names: List[str]) -> str: + used = set() + for name in names: + match = re.fullmatch(r"classes(\d*)\.dex", name) + if match: + used.add(int(match.group(1) or "1")) + number = 2 + while number in used: + number += 1 + return "classes%d.dex" % number diff --git a/lihont/editions.py b/lihont/editions.py new file mode 100644 index 0000000..1ddf03a --- /dev/null +++ b/lihont/editions.py @@ -0,0 +1,254 @@ +"""Rebrand existing LihonT APKs and make the optional independent package. + +Class names, method definitions and resource IDs stay fixed. Only application +identity, provider/permission identity and exact corresponding string literals +are changed. Already patched APKs are never sent through the full mod builder. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import struct +import subprocess +from pathlib import Path + +from .apkzip import Apk +from .axml import Axml, RES_XML_START_ELEMENT, TYPE_STRING +from .arsc import RES_TABLE_PACKAGE +from . import dexpatch, manifest +from .toolchain import Toolchain, _javap + +ORIGINAL = "com.zhiliaoapp.musically" +CLONE = "online.lihon.tiktok" +SHARED = { + "com.ss.android.common.multiprocess.SHARE_PROVIDER_AUTHORITY1233": CLONE + ".shared1233", + "com.facebook.app.FacebookContentProvider597615686992125": CLONE + ".facebook597615686992125", +} +LITERAL = re.compile(r'(?m)^(\s*const-string(?:/jumbo)?\s+[vp]\d+,\s*)"([^"\r\n]*)"(\s*(?:#.*)?)$') +STATIC = re.compile(r'(?m)^(\s*\.field[^\r\n]*:Ljava/lang/String;\s*=\s*)"([^"\r\n]*)"(\s*)$') + + +def authority_map(axml): + mapping = dict(SHARED) + mapping[ORIGINAL] = CLONE + for node in axml.elements("provider"): + for value in (axml.attr_string(node, "authorities") or "").split(";"): + if value.startswith(ORIGINAL + "."): + mapping[value] = CLONE + value[len(ORIGINAL):] + for node in axml.elements("permission") + axml.elements("uses-permission") + axml.elements("action") + axml.elements("category"): + value = axml.attr_string(node, "name") or "" + if value.startswith(ORIGINAL + "."): + mapping[value] = CLONE + value[len(ORIGINAL):] + return mapping + + +def translate_literal(value, mapping): + if value in mapping: + return mapping[value] + # Provider URIs and explicit private storage paths can be embedded strings. + for before, after in mapping.items(): + if before == ORIGINAL: + continue + if value.startswith("content://" + before): + end = len("content://" + before) + if len(value) == end or value[end] in "/?#": + return "content://" + after + value[end:] + for prefix in ("/data/data/", "/data/user/0/", "/data/user_de/0/"): + if value.startswith(prefix + ORIGINAL + "/"): + return prefix + CLONE + value[len(prefix + ORIGINAL):] + return value + + +def rewrite_literals(text, mapping): + count = 0 + def sub(match): + nonlocal count + before = match.group(2) + after = translate_literal(before, mapping) + if after == before: + return match.group(0) + count += 1 + return match.group(1) + '"' + after + '"' + match.group(3) + return STATIC.sub(sub, LITERAL.sub(sub, text)), count + + +def replace_string_attribute(axml, node, name, value): + attribute = axml.attr(node, name) + if attribute is None: + raise ValueError("Missing existing attribute: " + name) + index = axml.pool.index(value) + attribute.kind = TYPE_STRING + attribute.raw = index + attribute.data = index + + +def clone_manifest(axml, mapping): + original = manifest.package_name(axml) + if original != ORIGINAL: + raise ValueError("Expected the original application ID") + replace_string_attribute(axml, axml.elements("manifest")[0], "package", CLONE) + class_attributes = {"name", "targetActivity", "backupAgent", "appComponentFactory"} + class_tags = {"application", "activity", "activity-alias", "provider", "service", "receiver", "instrumentation"} + for node in axml.nodes: + if node.kind != RES_XML_START_ELEMENT: + continue + tag = axml.pool.get(node.name) + # Relative component names must still point to the existing DEX class. + if tag in class_tags: + for attr in class_attributes: + value = axml.attr_string(node, attr) + if value and value.startswith("."): + replace_string_attribute(axml, node, attr, ORIGINAL + value) + if tag == "provider": + value = axml.attr_string(node, "authorities") + if value: + replace_string_attribute(axml, node, "authorities", ";".join(mapping.get(x, x) for x in value.split(";"))) + if tag in {"permission", "uses-permission", "uses-permission-sdk-23", "action", "category"}: + value = axml.attr_string(node, "name") + if value in mapping: + replace_string_attribute(axml, node, "name", mapping[value]) + for attr in ("permission", "readPermission", "writePermission", "taskAffinity", "process"): + value = axml.attr_string(node, attr) + if value in mapping: + replace_string_attribute(axml, node, attr, mapping[value]) + elif value and value.startswith(ORIGINAL + ".") and attr in {"taskAffinity", "process"}: + replace_string_attribute(axml, node, attr, CLONE + value[len(ORIGINAL):]) + return axml + + +def clone_resource_packages(data): + result = bytearray(data) + pos = struct.unpack_from(" len(result): + raise ValueError("Invalid resource table chunk") + if kind == RES_TABLE_PACKAGE: + name_bytes = result[pos + 12:pos + 268] + name = name_bytes.decode("utf-16-le").split("\0", 1)[0] + if name == ORIGINAL or name.startswith(ORIGINAL + "."): + renamed = CLONE + name[len(ORIGINAL):] + encoded = renamed.encode("utf-16-le") + if len(encoded) > 254: + raise ValueError("Resource package name is too long") + result[pos + 12:pos + 268] = encoded + bytes(256 - len(encoded)) + changes.append([name, renamed]) + pos += size + if not changes: + raise ValueError("Expected original resource package") + return bytes(result), changes + + +def dex_helper(tools, room): + source = Path(__file__).parent / "java" / "EditionDex.java" + compiler = room / "compiler" + compiler.mkdir(parents=True, exist_ok=True) + marker = compiler / "source.sha256" + digest = hashlib.sha256(source.read_bytes()).hexdigest() + if not marker.exists() or marker.read_text() != digest or not (compiler / "EditionDex.class").exists(): + candidate = Path(_javap()) + javac = candidate.with_name("javac" + candidate.suffix) + command = [str(javac) if javac.exists() else (shutil.which("javac") or "javac"), + "--release", "17", "-classpath", tools.smali, "-d", str(compiler), str(source)] + subprocess.run(command, check=True) + marker.write_text(digest) + return compiler, digest + + +def build(source, output, edition, package=ORIGINAL, workspace="work/edition-split"): + source, output, room = Path(source).resolve(), Path(output).resolve(), Path(workspace).resolve() + if source == output or edition not in {"serena", "luna"} or package not in {ORIGINAL, CLONE}: + raise ValueError("Invalid edition, package or output path") + room.mkdir(parents=True, exist_ok=True) + tools = Toolchain("tools") + apk = Apk(str(source)) + report = {"edition": edition, "package": package, "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), "changed_dex": {}} + try: + axml = Axml.parse(apk.read("AndroidManifest.xml")) + if manifest.package_name(axml) != ORIGINAL or manifest.version_name(axml) != "46.9.3": + raise ValueError("Expected the validated LihonT 46.9.3 baseline") + has_members = manifest.has_activity(axml, "lihont.members.MemberSettings") + if edition == "luna" and not has_members: + raise ValueError("Luna requires the membership/settings runtime") + manifest.set_label(axml, "LihonT " + edition.capitalize()) + if package == CLONE: + # Use a shared superset so common DEX files have the same cache key. + baseline = Apk("build/lihont-members-test.apk") + try: + mapping = authority_map(Axml.parse(baseline.read("AndroidManifest.xml"))) + finally: + baseline.close() + clone_manifest(axml, mapping) + resources, changed = clone_resource_packages(apk.read("resources.arsc")) + apk.replace("resources.arsc", resources) + report["resource_packages"] = changed + compiler, helper_digest = dex_helper(tools, room) + for name in sorted(n for n in apk.names() if n.endswith(".dex")): + data = apk.read(name) + if not any(key.encode("ascii") in data for key in mapping): + continue + key = hashlib.sha256(data + json.dumps(mapping, sort_keys=True).encode() + helper_digest.encode()).hexdigest() + cache = room / "dex-cache" / key + cache.mkdir(parents=True, exist_ok=True) + patched = cache / "patched.dex" + cached_report = cache / "report.json" + if patched.exists() and cached_report.exists(): + info = json.loads(cached_report.read_text()) + if hashlib.sha256(patched.read_bytes()).hexdigest() != info["sha256"]: + raise ValueError("Invalid cached DEX") + print("Cached " + name, flush=True) + else: + original = cache / "original.dex" + original.write_bytes(data) + properties = cache / "identities.properties" + properties.write_text("\n".join(k + "=" + v for k, v in sorted(mapping.items())), encoding="utf-8") + print("Rewrite DEX identities: " + name, flush=True) + result = subprocess.run(["java", "-Xmx1g", "-cp", os.pathsep.join([str(compiler), tools.smali]), + "EditionDex", str(original), str(patched), str(properties), str(manifest.min_sdk(axml))], + check=True, capture_output=True, text=True, encoding="utf-8", errors="replace") + changed_strings = json.loads(result.stdout.strip()) + hits, touched = changed_strings["literals"], changed_strings["classes"] + print("Clone " + name + ": " + str(hits) + " literals / " + str(touched) + " classes", flush=True) + patch = patched.read_bytes() + if dexpatch.defined_methods(data) != dexpatch.defined_methods(patch) or dexpatch.dex_format(data) != dexpatch.dex_format(patch): + raise ValueError("Clone changed class definitions or DEX format") + info = {"sha256": hashlib.sha256(patch).hexdigest(), "literals": hits, "classes": touched} + cached_report.write_text(json.dumps(info, indent=2), encoding="utf-8") + if info["literals"]: + apk.replace(name, patched.read_bytes()) + report["changed_dex"][name] = info + apk.replace("AndroidManifest.xml", axml.build()) + apk.drop_signature() + output.parent.mkdir(parents=True, exist_ok=True) + apk.write(str(output)) + finally: + apk.close() + print("Sign " + str(output), flush=True) + tools.sign(str(output)) + report.update({"file": output.name, "bytes": output.stat().st_size, "sha256": hashlib.sha256(output.read_bytes()).hexdigest()}) + Path(str(output) + ".json").write_text(json.dumps(report, indent=2), encoding="utf-8") + return report + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--all", action="store_true") + parser.add_argument("--luna-source", default="build/lihont-luna-settings-test.apk") + args = parser.parse_args() + if not args.all: + parser.error("Use --all to create the four validated edition variants") + results = [] + for edition, source in [("serena", "build/lihont-profile-test.apk"), ("luna", args.luna_source)]: + for flavor, package in [("original", ORIGINAL), ("clone", CLONE)]: + results.append(build(source, "build/lihont-" + edition + "-" + flavor + ".apk", edition, package)) + Path("work/edition-split/releases.json").write_text(json.dumps(results, indent=2), encoding="utf-8") + print("FOUR EDITION VARIANTS BUILT", flush=True) + + +if __name__ == "__main__": + main() diff --git a/lihont/feed_patch.py b/lihont/feed_patch.py new file mode 100644 index 0000000..0c56784 --- /dev/null +++ b/lihont/feed_patch.py @@ -0,0 +1,234 @@ +"""Take advertisements and live streams out of TikTok 47.1.3's feed. + +A page of the feed is a FeedItemList; its items are Aweme objects, and an advertisement or a live +room is just an Aweme with a flag or a room attached. Every read of the page goes through a few +getters, so each of them first lets lihont.two.feed.FeedFilter clean the page: + + FeedItemList.getItems() advertisements and live rooms are removed from the list + FeedItemList.setItems(List) the same for a list that is put in + FeedItemList.getPreloadAds() the preloaded advertisements are dropped + FeedItemList.isHasAd() the page no longer claims to hold one + +Three other places fetch or hold advertisements outside the pages and are switched off while the +setting is on: the mid-feed ad response, the candidate ad request and the app-open "top view" +store. Each gets one early return, taken only when FeedFilter.ads() says so. +""" + +from __future__ import annotations + +import re + +HELPER = "Llihont/two/feed/FeedFilter;" +ADS = HELPER + "->ads()Z" +CLEAN = HELPER + "->cleanPage(Ljava/lang/Object;)V" +FILTER = HELPER + "->filter(Ljava/util/List;)Ljava/util/List;" + +FEED_PAGE = "Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;" +MID_AD = "Lcom/ss/android/ugc/aweme/commercialize/feed/assem/midad/MidAdResponse;" +CANDIDATES = "Lcom/ss/android/ugc/aweme/commercialize/feed/candidate/CandidateRequestImpl;" +TOP_VIEW = "Lcom/bytedance/ies/ugc/aweme/commercialize/splash/topview/TopViewJsonManager;" + +# owner -> (dex that holds it in TikTok 47.1.3) +DEXES = { + FEED_PAGE: "classes36.dex", + MID_AD: "classes35.dex", + CANDIDATES: "classes10.dex", + TOP_VIEW: "classes17.dex", +} + +MARKER = "# lihont: feed filter" + +_METHOD = re.compile(r"^\.method ([^\n]*)\n(.*?)^\.end method", re.M | re.S) +_REGISTERS = re.compile(r"^ \.registers (\d+)$", re.M) + + +def runtime_targets(): + """Runtime methods the rewrites call, as they appear in a method table.""" + return [ADS, CLEAN, FILTER] + + +def _parameter_registers(header: str) -> int: + """Registers taken by `this` and the arguments of a method header like `public final x(IJ)V`.""" + signature = re.search(r"\((.*?)\)", header).group(1) + count = 0 if " static " in " " + header + " " else 1 + index = 0 + while index < len(signature): + char = signature[index] + if char in "JD": + count += 2 + index += 1 + elif char in "ZBSCIF": + count += 1 + index += 1 + else: + while signature[index] == "[": + index += 1 + if signature[index] == "L": + index = signature.index(";", index) + 1 + else: + index += 1 + count += 1 + return count + + +def _first_instruction(body: str) -> int: + """Offset in `body` of the first line that is code: past .registers, annotations and debug lines.""" + offset = 0 + in_annotation = False + for line in body.splitlines(keepends=True): + stripped = line.strip() + if in_annotation: + if stripped.startswith(".end annotation"): + in_annotation = False + elif stripped.startswith(".annotation"): + in_annotation = True + elif stripped and not stripped.startswith( + (".registers", ".locals", ".prologue", ".line", ".param", ".end param", "#")): + return offset + offset += len(line) + raise ValueError("Method has no code") + + +def _with_local(header: str, body: str) -> str: + """Make sure `v0` is a local register of the method, so a hook can use it before any real code.""" + match = _REGISTERS.search(body) + if match is None: + raise ValueError("Method has no .registers line") + total = int(match.group(1)) + if total - _parameter_registers(header) >= 1: + return body + return body[:match.start()] + " .registers %d" % (total + 1) + body[match.end():] + + +def _rewrite_methods(text: str, owner: str, rewrite) -> tuple[str, dict[str, int]]: + """Apply rewrite(header, body, hits) to every method of the class and return the new text.""" + if not re.search(r"^\.class [^\n]*" + re.escape(owner) + r"$", text, re.M): + raise ValueError("Not the class " + owner) + hits: dict[str, int] = {} + + def method(match: re.Match) -> str: + header, body = match.group(1), match.group(2) + if MARKER in body: + raise ValueError("Feed filter already present in " + owner) + changed = rewrite(header, body, hits) + if changed is None: + return match.group(0) + return ".method %s\n%s.end method" % (header, changed) + + return _METHOD.sub(method, text), hits + + +def _early_return(header: str, body: str, code: str) -> str: + """Put `if (FeedFilter.ads()) { code }` in front of the method's own code.""" + body = _with_local(header, body) + at = _first_instruction(body) + guard = ( + " %s\n invoke-static {}, %s\n\n move-result v0\n\n if-eqz v0, :lihont_keep\n\n%s\n" + " :lihont_keep\n" % (MARKER, ADS, code) + ) + return body[:at] + guard + body[at:] + + +def _entry_call(body: str, call: str) -> str: + at = _first_instruction(body) + return body[:at] + " %s\n%s\n" % (MARKER, call) + body[at:] + + +def rewrite_feed_page(text: str) -> tuple[str, dict[str, int]]: + """FeedItemList: clean the page when it is read or written.""" + clean = " invoke-static {p0}, %s\n" % CLEAN + filtering = (" invoke-static {p1}, %s\n\n move-result-object p1\n\n" + " invoke-static {p0}, %s\n" % (FILTER, CLEAN)) + + def rewrite(header: str, body: str, hits: dict) -> str | None: + if header.endswith(" getItems()Ljava/util/List;"): + if "->items:Ljava/util/List;" not in body: + raise ValueError("FeedItemList.getItems() changed") + hits["items_read"] = 1 + return _entry_call(body, clean) + if header.endswith(" setItems(Ljava/util/List;)V"): + if "->items:Ljava/util/List;" not in body: + raise ValueError("FeedItemList.setItems() changed") + hits["items_write"] = 1 + return _entry_call(body, filtering) + if header.endswith(" getPreloadAds()Ljava/util/List;"): + if "->preloadAds:Ljava/util/List;" not in body: + raise ValueError("FeedItemList.getPreloadAds() changed") + hits["preload_read"] = 1 + return _entry_call(body, clean) + if header.endswith(" isHasAd()Z"): + if "->hasAd:Z" not in body: + raise ValueError("FeedItemList.isHasAd() changed") + hits["ad_flag"] = 1 + return _entry_call(body, clean) + return None + + result, hits = _rewrite_methods(text, FEED_PAGE, rewrite) + if len(hits) != 4: + raise ValueError("FeedItemList is missing some of the methods this rewrite expects: %s" % sorted(hits)) + return result, hits + + +def rewrite_mid_ad(text: str) -> tuple[str, dict[str, int]]: + """MidAdResponse.getAweme(): no mid-feed advertisement while ads are off.""" + def rewrite(header: str, body: str, hits: dict) -> str | None: + if not header.endswith(" getAweme()Lcom/ss/android/ugc/aweme/feed/model/Aweme;"): + return None + if "->aweme:Lcom/ss/android/ugc/aweme/feed/model/Aweme;" not in body: + raise ValueError("Mid-feed advertisement response changed") + hits["mid_ad"] = 1 + # the one caller handles a missing advertisement + return _early_return(header, body, " const/4 v0, 0x0\n\n return-object v0\n") + + result, hits = _rewrite_methods(text, MID_AD, rewrite) + if len(hits) != 1: + raise ValueError("MidAdResponse.getAweme() not found") + return result, hits + + +def rewrite_candidates(text: str) -> tuple[str, dict[str, int]]: + """CandidateRequestImpl.LIZ(String, String, List): do not ask for candidate advertisements.""" + def rewrite(header: str, body: str, hits: dict) -> str | None: + if not header.endswith(" LIZ(Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V"): + return None + if "CandidateAdRequestApi;->request(" not in body: + raise ValueError("Candidate advertisement request changed") + hits["candidate_request"] = 1 + return _early_return(header, body, " return-void\n") + + result, hits = _rewrite_methods(text, CANDIDATES, rewrite) + if len(hits) != 1: + raise ValueError("CandidateRequestImpl.LIZ(...) not found") + return result, hits + + +def rewrite_top_view(text: str) -> tuple[str, dict[str, int]]: + """TopViewJsonManager: no app-open advertisement and an empty store of them while ads are off.""" + def rewrite(header: str, body: str, hits: dict) -> str | None: + if header.endswith(" LIZIZ(Ljava/lang/String;)Lcom/ss/android/ugc/aweme/feed/model/Aweme;"): + if "->LIZLLL(Ljava/lang/String;)Ljava/util/Map;" not in body: + raise ValueError("Top view advertisement getter changed") + hits["top_view"] = 1 + return _early_return(header, body, " const/4 v0, 0x0\n\n return-object v0\n") + if header.endswith(" LJ()Ljava/util/Map;"): + if "->LJ:Ljava/util/concurrent/ConcurrentHashMap;" not in body: + raise ValueError("Top view advertisement store changed") + hits["top_view_store"] = 1 + return _early_return( + header, body, + " new-instance v0, Ljava/util/HashMap;\n\n" + " invoke-direct {v0}, Ljava/util/HashMap;->()V\n\n return-object v0\n") + return None + + result, hits = _rewrite_methods(text, TOP_VIEW, rewrite) + if len(hits) != 2: + raise ValueError("TopViewJsonManager is missing some of the methods this rewrite expects: %s" % sorted(hits)) + return result, hits + + +REWRITES = { + FEED_PAGE: rewrite_feed_page, + MID_AD: rewrite_mid_ad, + CANDIDATES: rewrite_candidates, + TOP_VIEW: rewrite_top_view, +} diff --git a/lihont/java/EditionDex.java b/lihont/java/EditionDex.java new file mode 100644 index 0000000..d720984 --- /dev/null +++ b/lihont/java/EditionDex.java @@ -0,0 +1,96 @@ +import com.android.tools.smali.dexlib2.DexFileFactory; +import com.android.tools.smali.dexlib2.Opcodes; +import com.android.tools.smali.dexlib2.Opcode; +import com.android.tools.smali.dexlib2.iface.*; +import com.android.tools.smali.dexlib2.iface.instruction.*; +import com.android.tools.smali.dexlib2.iface.reference.StringReference; +import com.android.tools.smali.dexlib2.iface.value.*; +import com.android.tools.smali.dexlib2.immutable.instruction.*; +import com.android.tools.smali.dexlib2.immutable.reference.ImmutableStringReference; +import com.android.tools.smali.dexlib2.immutable.value.ImmutableStringEncodedValue; +import com.android.tools.smali.dexlib2.rewriter.*; +import com.android.tools.smali.dexlib2.writer.pool.DexPool; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** Build helper only; never shipped inside TikTok. */ +public final class EditionDex { + static final String ORIGINAL = "com.zhiliaoapp.musically"; + static final String CLONE = "online.lihon.tiktok"; + static final Map mapping = new LinkedHashMap<>(); + static String translate(String value) { + String replacement = mapping.get(value); + if (replacement != null) return replacement; + for (Map.Entry e : mapping.entrySet()) { + if (e.getKey().equals(ORIGINAL)) continue; + String prefix = "content://" + e.getKey(); + if (value.startsWith(prefix) && (value.length()==prefix.length() || + "/?#".indexOf(value.charAt(prefix.length()))>=0)) + return "content://" + e.getValue() + value.substring(prefix.length()); + } + for (String prefix : new String[]{"/data/data/","/data/user/0/","/data/user_de/0/"}) + if(value.startsWith(prefix+ORIGINAL+"/")) + return prefix+CLONE+value.substring((prefix+ORIGINAL).length()); + return value; + } + static boolean changed(String value) { return !value.equals(translate(value)); } + public static void main(String[] args) throws Exception { + Properties p = new Properties(); + try (java.io.Reader reader=Files.newBufferedReader(Path.of(args[2]), StandardCharsets.UTF_8)) { p.load(reader); } + for (String name:p.stringPropertyNames()) mapping.put(name,p.getProperty(name)); + DexFile dex = DexFileFactory.loadDexFile(args[0], Opcodes.forApi(Integer.parseInt(args[3]))); + int hits=0, classes=0; + for(ClassDef c:dex.getClasses()) { + boolean touched=false; + for(Field field:c.getFields()) { + EncodedValue value=field.getInitialValue(); + if(value instanceof StringEncodedValue && changed(((StringEncodedValue)value).getValue())) {hits++;touched=true;} + } + for(Method method:c.getMethods()) { + MethodImplementation impl=method.getImplementation(); + if(impl==null)continue; + for(Instruction instruction:impl.getInstructions()) + if((instruction.getOpcode()==Opcode.CONST_STRING || instruction.getOpcode()==Opcode.CONST_STRING_JUMBO) + && changed(((StringReference)((ReferenceInstruction)instruction).getReference()).getString())) {hits++;touched=true;} + } + if(touched)classes++; + } + if(hits==0) { Files.copy(Path.of(args[0]),Path.of(args[1]),StandardCopyOption.REPLACE_EXISTING); } + else { + DexRewriter rewriter=new DexRewriter(new RewriterModule() { + @Override public Rewriter getInstructionRewriter(Rewriters rewriters) { + return new InstructionRewriter(rewriters) { + @Override public Instruction rewrite(Instruction instruction) { + Opcode op=instruction.getOpcode(); + if(op==Opcode.CONST_STRING || op==Opcode.CONST_STRING_JUMBO) { + String before=((StringReference)((ReferenceInstruction)instruction).getReference()).getString(); + String after=translate(before); + if(!before.equals(after)) { + int register=((OneRegisterInstruction)instruction).getRegisterA(); + ImmutableStringReference ref=new ImmutableStringReference(after); + return op==Opcode.CONST_STRING ? new ImmutableInstruction21c(op,register,ref) + : new ImmutableInstruction31c(op,register,ref); + } + } + return super.rewrite(instruction); + } + }; + } + @Override public Rewriter getEncodedValueRewriter(Rewriters rewriters) { + return new EncodedValueRewriter(rewriters) { + @Override public EncodedValue rewrite(EncodedValue value) { + if(value instanceof StringEncodedValue) { + String before=((StringEncodedValue)value).getValue(),after=translate(before); + if(!before.equals(after)) return new ImmutableStringEncodedValue(after); + } + return super.rewrite(value); + } + }; + } + }); + DexPool.writeTo(args[1],rewriter.getDexFileRewriter().rewrite(dex)); + } + System.out.println("{\"literals\":"+hits+",\"classes\":"+classes+"}"); + } +} diff --git a/lihont/manifest.py b/lihont/manifest.py new file mode 100644 index 0000000..c2d6953 --- /dev/null +++ b/lihont/manifest.py @@ -0,0 +1,398 @@ +"""The edits MargyT makes to AndroidManifest.xml. + +Three of them, and the package name is not one: the apk keeps the package it +was built with. Renaming it is what makes a repackaged TikTok interesting to +debug -- provider authorities collide, the OAuth redirect scheme is claimed +twice, and every `com.zhiliaoapp.musically.something` string in fifty-two dex +files is suddenly half true. +""" + +from __future__ import annotations + +from typing import List, Optional + +from .axml import ( + Axml, + Node, + RES_XML_END_ELEMENT, + RES_XML_START_ELEMENT, + TYPE_REFERENCE, + TYPE_STRING, +) + +MAIN_ACTION = "android.intent.action.MAIN" +LAUNCHER_CATEGORY = "android.intent.category.LAUNCHER" +# meta-data on an icon alias: what the picker calls it (read back by the runtime) +ICON_TITLE_KEY = "lihont.title" +# what the launcher category of a retired home-screen entry is turned into: matches no launcher +HIDDEN_CATEGORY = "lihont.two.category.NOT_A_LAUNCHER_ENTRY" + + +class ManifestError(Exception): + pass + + +def application(axml: Axml) -> Node: + elements = axml.elements("application") + if not elements: + raise ManifestError("the manifest has no ") + return elements[0] + + +def package_name(axml: Axml) -> Optional[str]: + return axml.attr_string(axml.elements("manifest")[0], "package") + + +def application_class(axml: Axml) -> Optional[str]: + return axml.attr_string(application(axml), "name") + + +def min_sdk(axml: Axml, fallback: int = 21) -> int: + """The oldest Android the apk is built for. + + Everything the build produces has to be readable that far back: a dex + assembled for a newer api is stamped with a newer format, and an Android + that does not know the format refuses the whole app rather than the file. + """ + for node in axml.elements("uses-sdk"): + attr = axml.attr(node, "minSdkVersion") + if attr is not None and attr.kind != TYPE_STRING: + return attr.data + return fallback + + +def icon_ids(axml: Axml) -> List[int]: + """The resource ids the launcher will actually draw, without duplicates.""" + app = application(axml) + out = [] + for name in ("icon", "roundIcon"): + attr = axml.attr(app, name) + if attr is not None and attr.kind == TYPE_REFERENCE and attr.data not in out: + out.append(attr.data) + return out + + +def launcher_elements(axml: Axml) -> List[Node]: + """Every activity or alias that puts an entry on the launcher.""" + stack: List[Node] = [] + found: dict = {} + for node in axml.nodes: + if node.kind == RES_XML_START_ELEMENT: + stack.append(node) + name = axml.pool.get(node.name) + if name in ("action", "category"): + value = axml.attr_string(node, "name") + if value in (MAIN_ACTION, LAUNCHER_CATEGORY): + for parent in reversed(stack): + if axml.pool.get(parent.name) in ("activity", "activity-alias"): + found.setdefault(id(parent), [parent, set()])[1].add(value) + break + elif node.kind == RES_XML_END_ELEMENT: + if stack: + stack.pop() + return [node for node, kinds in found.values() if len(kinds) == 2] + + +def shared_authorities(axml: Axml, package: str) -> List[str]: + """Provider authorities that are not spelled with the package name. + + Android will not install two apps that claim the same provider authority, + and most of TikTok's authorities are safe because they are built out of the + package name -- rename the package and they follow. These do not: they are + written out in full, identical in every apk built from this one. Keeping + TikTok's package name means keeping them too, and then any other mod of the + same app on the phone is enough to have the installer refuse this one. + """ + out = [] + for node in axml.elements("provider"): + value = axml.attr_string(node, "authorities") + if not value: + continue + for authority in value.split(";"): + if authority and package not in authority and authority not in out: + out.append(authority) + return out + + +def rename_authorities(axml: Axml, renames: dict) -> List[str]: + """Rewrite provider authorities according to `renames`.""" + touched = [] + for node in axml.elements("provider"): + value = axml.attr_string(node, "authorities") + if not value: + continue + parts = [renames.get(part, part) for part in value.split(";")] + new_value = ";".join(parts) + if new_value != value: + axml.set_attr_string(node, "authorities", new_value) + touched.append(new_value) + return touched + + +def set_label(axml: Axml, label: str) -> List[str]: + """Put `label` under the icon, wherever the launcher would read it from.""" + touched = [] + app = application(axml) + axml.set_attr_string(app, "label", label) + touched.append("application") + for node in launcher_elements(axml): + axml.set_attr_string(node, "label", label) + touched.append(axml.pool.get(node.name) + " " + str(axml.attr_string(node, "name"))) + return touched + + +def has_activity(axml: Axml, class_name: str) -> bool: + return any( + axml.attr_string(node, "name") == class_name + for node in axml.elements("activity") + axml.elements("activity-alias") + ) + + +def has_permission(axml: Axml, name: str) -> bool: + return any(axml.attr_string(node, "name") == name + for node in axml.elements("uses-permission")) + + +def add_package_query(axml: Axml, package: str) -> bool: + """Allow Android 11+ to resolve one external app's launcher.""" + stack = [] + for node in axml.nodes: + if node.kind == RES_XML_START_ELEMENT: + tag = axml.pool.get(node.name) + if tag == "package" and "queries" in stack and axml.attr_string(node, "name") == package: + return False + stack.append(tag) + elif node.kind == RES_XML_END_ELEMENT and stack: + stack.pop() + queries = axml.elements("queries") + if queries: + parent = queries[0] + else: + parent = axml.make_element("queries") + axml.insert_into(axml.elements("manifest")[0], [parent, axml.close_element(parent)]) + child = axml.make_element("package") + axml.set_attr_string(child, "name", package) + axml.insert_into(parent, [child, axml.close_element(child)]) + return True + + +def add_permission(axml: Axml, name: str) -> bool: + """Ask for one more permission, as a child of . + + Returns whether anything was added: an apk that already asks for it is + left exactly as it was. + """ + if has_permission(axml, name): + return False + node = axml.make_element("uses-permission") + axml.set_attr_string(node, "name", name) + axml.insert_into(axml.elements("manifest")[0], [node, axml.close_element(node)]) + return True + + +def version_name(axml: Axml) -> str: + """What the apk calls its own version, for the mod to write down.""" + for node in axml.elements("manifest"): + found = axml.attr_string(node, "versionName") + if found: + return found + return "?" + + +def add_provider(axml: Axml, class_name: str, authority: str) -> None: + """Declare a provider, which is how the mod gets to run at start-up. + + Android builds every declared provider before the application's own + onCreate, so one that answers nothing is still a hook -- and one that needs + no patch to TikTok's own Application class to exist. + """ + provider = axml.make_element("provider") + axml.set_attr_string(provider, "name", class_name) + axml.set_attr_bool(provider, "exported", False) + axml.set_attr_string(provider, "authorities", authority) + # the same provider hands the installer the apk of an update: a content uri + # the mod grants read on for that one intent, which is what a FileProvider + # would have done had adding its xml been possible here + axml.set_attr_bool(provider, "grantUriPermissions", True) + axml.insert_into(application(axml), [provider, axml.close_element(provider)]) + + +LAUNCH_SINGLE_TASK = 2 + + +def launcher_entry(axml: Axml): + """What the home screen opens, as (the component, the activity behind it). + + TikTok's own entry is not an activity at all -- it is an `activity-alias` + called SplashActivity standing in front of MainActivity. Which is the same + trick the icon switcher uses, so the two halves of the answer are both + wanted: the component to turn off when another icon is chosen, and the + activity every new alias should point at. + + Read by walking the node stream rather than by asking for children: the + parsed manifest is a flat run of opens and closes. + """ + from .axml import RES_XML_START_ELEMENT, RES_XML_END_ELEMENT + + name = target = None + inside = None + saw_main = saw_launcher = False + for node in axml.nodes: + if node.kind == RES_XML_START_ELEMENT: + tag = axml.pool.get(node.name) + if tag in ("activity", "activity-alias"): + inside = tag + name = axml.attr_string(node, "name") + target = axml.attr_string(node, "targetActivity") or name + saw_main = saw_launcher = False + elif inside and tag in ("action", "category"): + value = axml.attr_string(node, "name") + saw_main |= value == MAIN_ACTION + saw_launcher |= value == LAUNCHER_CATEGORY + elif node.kind == RES_XML_END_ELEMENT: + if axml.pool.get(node.name) == inside: + if saw_main and saw_launcher and name and target: + return name, target + inside = None + raise ManifestError("this apk has no launcher entry to stand in for") + + +def entry_icon(axml: Axml, entry: str) -> int: + """The icon the home screen draws for the alias `entry`: its own, else the application's.""" + for node in axml.elements("activity-alias"): + if axml.attr_string(node, "name") == entry: + own = axml.attr(node, "icon") + if own is not None and own.kind == TYPE_REFERENCE: + return own.data + ids = icon_ids(axml) + if not ids: + raise ManifestError("%s has no icon to carry over" % entry) + return ids[0] + + +def hide_from_launcher(axml: Axml, entry: str) -> int: + """Keep the alias `entry` as a component but take it off the home screen. + + TikTok starts its own entry by name (`setClassName(..., "...splash.SplashActivity")`), so + turning that component off would break those starts. Changing its launcher category to one + nothing asks for leaves it enabled and callable, and no longer an icon. Returns how many + categories were changed. + """ + inside, depth, changed = False, 0, 0 + for node in axml.nodes: + if node.kind == RES_XML_START_ELEMENT: + tag = axml.pool.get(node.name) + if inside: + depth += 1 + elif tag == "activity-alias" and axml.attr_string(node, "name") == entry: + inside, depth = True, 0 + if inside and tag == "category" and axml.attr_string(node, "name") == LAUNCHER_CATEGORY: + axml.set_attr_string(node, "name", HIDDEN_CATEGORY) + changed += 1 + elif node.kind == RES_XML_END_ELEMENT and inside: + if depth == 0: + inside = False + else: + depth -= 1 + return changed + + +def add_icon_alias(axml: Axml, name: str, target: str, icon: int, + label: str, enabled: bool, title: str = None) -> None: + """An `activity-alias` that is the same app under a different icon. + + Which icon an app wears is a property of the component the home screen + opens, and there is no way to change one at runtime -- so an app that + offers a choice ships one component per icon and turns on the one that was + chosen. They all point at the same activity, so whichever is on, tapping it + opens the app exactly as before. + + `title` is what the picker calls the icon, carried as meta-data so the + runtime can list the choices without a table of its own; `label` is what + the home screen writes under it. + """ + alias = axml.make_element("activity-alias") + axml.set_attr(alias, "icon", TYPE_REFERENCE, icon) + axml.set_attr_string(alias, "label", label) + axml.set_attr_string(alias, "name", name) + axml.set_attr_bool(alias, "enabled", enabled) + axml.set_attr_bool(alias, "exported", True) + axml.set_attr_string(alias, "targetActivity", target) + + meta = [] + if title: + data = axml.make_element("meta-data") + axml.set_attr_string(data, "name", ICON_TITLE_KEY) + axml.set_attr_string(data, "value", title) + meta = [data, axml.close_element(data)] + + intent_filter = axml.make_element("intent-filter") + action = axml.make_element("action") + axml.set_attr_string(action, "name", MAIN_ACTION) + category = axml.make_element("category") + axml.set_attr_string(category, "name", LAUNCHER_CATEGORY) + + axml.insert_into(application(axml), [ + alias, + *meta, + intent_filter, + action, + axml.close_element(action), + category, + axml.close_element(category), + axml.close_element(intent_filter), + axml.close_element(alias), + ]) + + +def add_activity(axml: Axml, class_name: str, label: str, theme: int, + affinity: str = None, launcher: bool = False, + exported: bool = False) -> None: + """Declare an activity, last child of ````. + + With `launcher` it also gets an entry on the home screen -- and then it + needs a task affinity of its own, because without one it shares the app's, + and tapping its icon while TikTok is running does what tapping a launcher + entry does: brings that task back to the front, TikTok and all, instead of + opening this screen. + + Without one none of that applies and none of it is written. The screen is + opened from inside the app, in the app's own task, so the back button goes + back to where it was opened from. + """ + if launcher and not exported: + raise ManifestError("launcher activity must be exported") + for existing in axml.elements("activity"): + if axml.attr_string(existing, "name") == class_name: + raise ManifestError("%s is already declared" % class_name) + + activity = axml.make_element("activity") + axml.set_attr(activity, "theme", TYPE_REFERENCE, theme) + axml.set_attr_string(activity, "label", label) + axml.set_attr_string(activity, "name", class_name) + axml.set_attr_bool(activity, "exported", exported) + + if not launcher: + axml.insert_into(application(axml), [activity, axml.close_element(activity)]) + return + + axml.set_attr_string(activity, "taskAffinity", affinity) + axml.set_attr(activity, "launchMode", 0x10, LAUNCH_SINGLE_TASK) + + intent_filter = axml.make_element("intent-filter") + action = axml.make_element("action") + axml.set_attr_string(action, "name", MAIN_ACTION) + category = axml.make_element("category") + axml.set_attr_string(category, "name", LAUNCHER_CATEGORY) + + subtree = [ + activity, + intent_filter, + action, + axml.close_element(action), + category, + axml.close_element(category), + axml.close_element(intent_filter), + axml.close_element(activity), + ] + axml.insert_into(application(axml), subtree) diff --git a/lihont/media_patch.py b/lihont/media_patch.py new file mode 100644 index 0000000..98b333e --- /dev/null +++ b/lihont/media_patch.py @@ -0,0 +1,102 @@ +"""Make a saved video come out of TikTok's download pipeline without its logo. + +Saving a video from the feed runs through TikTok's download chain (X.0qwE is the context for one +saved feed video, X.0qvH picks its source, X.1Dj0 builds the chain). The logo reaches the file in two +ways, and each gets one small rewrite: + + * Some videos are fetched from an address the server has already stamped. X.0qvH.LIZIZ(Aweme) picks + that address from the share-platform table; with the switch on it hands over to X.0qvH.LIZ(Aweme, + false) instead, which picks the address without the stamp (the no-logo address, or the plain + playback stream). + * Others are fetched clean and TikTok stamps them itself in a later step. That step only runs when + X.0qwE.LJIIIZ() says the logo is wanted, so the method is routed through Downloads.logo(Z)Z. The + flags for AI-generated content are separate methods and are left as TikTok has them. + +lihont.two.media.Downloads supplies the switch and logs which rewrite took effect. +""" + +from __future__ import annotations + +import re + +DEX = "classes24.dex" +SOURCE_OWNER = "LX/0qvH;" +CONTEXT_OWNER = "LX/0qwE;" +SWITCH = "Llihont/two/media/Downloads;->pick()Z" +LOGO = "Llihont/two/media/Downloads;->logo(Z)Z" +ROUTE = "Llihont/two/media/Downloads;->route(I)V" + +AWEME = "Lcom/ss/android/ugc/aweme/feed/model/Aweme;" + +_PICK = re.compile( + r"^\.method public final LIZIZ\(" + re.escape(AWEME) + r"\)V\n( \.registers \d+\n)", re.M) +_PICK_TARGET = ".method public final LIZ(" + AWEME + "Z)V" + +_LOGO_METHOD = re.compile(r"^\.method public final LJIIIZ\(\)Z\n.*?^\.end method", re.M | re.S) +_FIELD_READ = re.compile(r"^ iget-boolean v0, p0, (LX/0qwE;->\w+:Z)$", re.M) + +PICK_HOOK = """\ + invoke-static {}, %(switch)s + + move-result v0 + + if-eqz v0, :lihont_normal + + const/4 v0, 0x0 + + invoke-virtual {p0, p1, v0}, LX/0qvH;->LIZ(%(aweme)sZ)V + + const/4 v0, 0x1 + + invoke-static {v0}, %(route)s + + return-void + + :lihont_normal +""" % {"switch": SWITCH, "aweme": AWEME, "route": ROUTE} + +LOGO_BODY = """\ +.method public final LJIIIZ()Z + .registers 2 + + iget-boolean v0, p0, %(field)s + + invoke-static {v0}, %(logo)s + + move-result v0 + + return v0 +.end method""" + + +def runtime_targets(): + """Runtime methods the rewrites call, as they appear in a method table.""" + return [SWITCH, LOGO, ROUTE] + + +def rewrite_source(text: str) -> str: + """Let X.0qvH.LIZIZ(Aweme) pick the clean address when the switch is on.""" + match = _PICK.search(text) + if match is None: + raise ValueError("X.0qvH.LIZIZ(Aweme) not found") + method_end = text.index("\n.end method", match.start()) + if "lihont" in text[match.start():method_end]: + raise ValueError("Download hook already present in X.0qvH.LIZIZ") + if _PICK_TARGET not in text: + raise ValueError("X.0qvH.LIZ(Aweme, boolean) not found") + return text[:match.end()] + "\n" + PICK_HOOK + text[match.end():] + + +def rewrite_context(text: str) -> str: + """Route X.0qwE.LJIIIZ() (is the logo wanted) through the switch.""" + match = _LOGO_METHOD.search(text) + if match is None: + raise ValueError("X.0qwE.LJIIIZ() not found") + method = match.group() + if "lihont" in method: + raise ValueError("Download hook already present in X.0qwE.LJIIIZ") + field = _FIELD_READ.search(method) + if field is None or "invoke" in method: + raise ValueError("X.0qwE.LJIIIZ() is not the plain field getter this rewrite expects") + body = LOGO_BODY % {"field": field.group(1), "logo": LOGO} + return text[:match.start()] + body + text[match.end():] diff --git a/lihont/profile_features.py b/lihont/profile_features.py new file mode 100644 index 0000000..52c13a4 --- /dev/null +++ b/lihont/profile_features.py @@ -0,0 +1,320 @@ +"""Route native profile-background and repost flag reads through LihonT's switches. + +Only direct primitive configuration reads are changed. The original call stays +in place; its result is passed to lihont.two.LihonTFlags, which returns the +forced value when the matching setting is on and TikTok's own value when it is +off. No registers are allocated, and no code, hooks, network endpoints, or +assets are copied from another mod. +""" + +from __future__ import annotations + +import os +import re +import shutil +import struct +import subprocess +from typing import Dict, Tuple + +# Flag key -> value LihonT forces while its switch is on. LihonTFlags.java holds +# the same values; tests/test_profile_flags.py keeps the two in step. +PROFILE_FLAGS = { + "profile_bg_in_allow_list": 1, + "profile_bg_enable_consumption_group": 3, + "profile_left_align": 1, + "profile_left_align_ab_group": 11, +} +REPOST_FLAGS = { + "tt_comment_repost_interaction": 1, + "repost_support_reply": 1, + "repost_support_digg": 1, +} + +FLAGS_CLASS = "Llihont/two/LihonTFlags;" + + +def runtime_targets(): + """Every LihonTFlags method a rewrite may call: one per key and read type.""" + return ["%s->%s%s" % (FLAGS_CLASS, key, descriptor) + for key in sorted({**PROFILE_FLAGS, **REPOST_FLAGS}) + for descriptor in ("(I)I", "(Z)Z")] + +# Observed primitive AB/settings reader shapes. Obfuscated method names are +# intentionally absent. A key sent to an API, map, or logger is not a read. +READ_SIGNATURES = { + "(Ljava/lang/String;I)I", + "(ILjava/lang/String;)I", + "(IILjava/lang/String;Z)I", + "(IIILjava/lang/String;Z)I", + "(Ljava/lang/String;Z)Z", + "(ILjava/lang/String;ZZ)Z", + "(IILjava/lang/String;ZZ)Z", +} +SETTINGS = "Lcom/bytedance/ies/abmock/SettingsManager;" +METHOD = re.compile(r"^\.method[^\n]*\n.*?^\.end method", re.M | re.S) +INVOKE = re.compile( + r"invoke-(static|virtual)(/range)? \{([^}]*)\}, " + r"(L[^;]+;)->[^\s(]+(\([^)]*\)[IZ])$" +) +TYPES = re.compile(r"\[*L[^;]+;|\[*[ZBSCIJFD]") +CONST_STRING = re.compile(r'const-string(?:/jumbo)? ([vp]\d+), "([^"\\]*)"$') +MOVE_OBJECT = re.compile(r"move-object(?:/from16|/16)? ([vp]\d+), ([vp]\d+)$") +MOVE_RESULT = re.compile(r"move-result ([vp]\d+)$") + + +def flags_for(features) -> Dict[str, int]: + flags = {} + if features.profile_background: + flags.update(PROFILE_FLAGS) + if features.repost_comments: + flags.update(REPOST_FLAGS) + return flags + + +def interesting(dex: bytes, flags: Dict[str, int]) -> bool: + return any(key.encode("utf-8") in dex for key in flags) + + +def literal_owners(dex: bytes, flags: Dict[str, int]): + """Find classes with flag const-string instructions in their method bodies. + + Annotation-only mentions do not qualify. A byte match inside an instruction + payload may add a candidate; the smali rewriter still verifies the actual + instructions, parameter types, and register flow before changing anything. + """ + from .dexpatch import _uleb + + if not interesting(dex, flags): + return set() + strings_count, strings_offset = struct.unpack_from(" len(code): + continue + string_index = int.from_bytes(code[offset + 2:offset + 2 + width], "little") + if string_index in indices: + found = True + break + if found: + type_index = struct.unpack_from(" Tuple[str, Dict[str, int]]: + """Override reads whose String argument is a known literal in this block. + + Register aliases are tracked inside each basic block and discarded at + branches/labels. Values are inserted immediately after move-result, so + boxing, lazy caching, and the caller's subsequent comparisons use the new + value. Unsupported readers are left untouched. + """ + counts: Dict[str, int] = {} + if not flags or not any('"' + key + '"' in text for key in flags): + return text, counts + + def method(match): + original = match.group() + lines = original.splitlines(keepends=True) + signature = lines[0].split("(", 1)[1].split(")", 1)[0] + param_words = sum(2 if t in ("J", "D") else 1 for t in TYPES.findall(signature)) + if " static " not in " " + lines[0] + " ": + param_words += 1 + locals_count = None + for line in lines: + info = re.fullmatch(r"\s*\.(locals|registers) (\d+)\s*", line) + if info: + n = int(info[2]) + locals_count = n if info[1] == "locals" else n - param_words + break + if locals_count is None or locals_count < 0: + return original + + def absolute(register): + return int(register[1:]) + (locals_count if register[0] == "p" else 0) + + def args(registers, is_range): + if is_range: + ends = registers.split(" .. ") + if len(ends) != 2: + return [] + return list(range(absolute(ends[0]), absolute(ends[1]) + 1)) + return [absolute(r.strip()) for r in registers.split(",") if r.strip()] + + known = {} + pending = None + result = [] + index = 0 + while index < len(lines): + line = lines[index] + instruction = line.strip() + result.append(line) + index += 1 + if not instruction or instruction.startswith((".", "#")): + continue + if instruction.startswith(":"): + known.clear() + pending = None + continue + literal = CONST_STRING.fullmatch(instruction) + if literal: + known.pop(absolute(literal[1]), None) + if literal[2] in flags: + known[absolute(literal[1])] = literal[2] + pending = None + continue + move = MOVE_OBJECT.fullmatch(instruction) + if move: + value = known.get(absolute(move[2])) + known.pop(absolute(move[1]), None) + if value is not None: + known[absolute(move[1])] = value + pending = None + continue + invoke = INVOKE.fullmatch(instruction) + if invoke: + pending = None + owner, descriptor = invoke[4], invoke[5] + if (owner == SETTINGS or owner.startswith("LX/")) and descriptor in READ_SIGNATURES: + registers = args(invoke[3], invoke[2]) + word = 0 if invoke[1] == "static" else 1 + for kind in TYPES.findall(descriptor.split(")", 1)[0][1:]): + if kind == "Ljava/lang/String;" and word < len(registers): + key = known.get(registers[word]) + if key is not None: + pending = (key, descriptor[-1]) + word += 2 if kind in ("J", "D") else 1 + continue + moved = MOVE_RESULT.fullmatch(instruction) + if moved and pending: + key, kind = pending + register = moved[1] + marker = " # lihont feature flag: %s\n" % key + block = [ + marker, + " invoke-static/range {%s .. %s}, %s->%s(%s)%s\n" + % (register, register, FLAGS_CLASS, key, kind, kind), + " move-result %s\n" % register, + ] + # Reapplying to an already patched dex must not stack hooks. An + # identical block is left alone; a block from an earlier scheme + # (const/16 override, other read type) is replaced. + following = lines[index:index + 3] + consumed, changed = 0, True + if following[:1] == [marker]: + if following == block: + consumed, changed = 3, False + elif len(following) > 2 and FLAGS_CLASS in following[1]: + consumed = 3 + elif len(following) > 1 and following[1].lstrip().startswith("const/16 "): + consumed = 2 + result.extend(block) + index += consumed + if changed: + counts[key] = counts.get(key, 0) + 1 + known.pop(absolute(register), None) + pending = None + continue + + pending = None + opcode = instruction.split(" ", 1)[0] + if opcode.startswith(("if-", "goto", "return", "throw", "switch")) or opcode.endswith("-switch"): + known.clear() + # Other Dalvik instructions which assign their first register. + elif opcode.startswith(("move", "const", "new-", "iget", "sget", "aget", + "array-length", "instance-of", "cmp", "neg-", "not-")) or ( + "-to-" in opcode or opcode.startswith(("add-", "sub-", "mul-", "div-", "rem-", + "and-", "or-", "xor-", "shl-", "shr-", "ushr-")) + ): + destination = re.match(r"\S+ ([vp]\d+)", instruction) + if destination: + at = absolute(destination[1]) + known.pop(at, None) + if "wide" in opcode or opcode.endswith(("-long", "-double")): + known.pop(at + 1, None) + return "".join(result) + + return METHOD.sub(method, text), counts + + +def candidate_files(root: str, flags: Dict[str, int], owners=None): + if not flags: + return [] + if owners is not None: + from pathlib import Path + paths = set() + for owner in owners: + base = Path(root, *owner[1:-1].split("/")) + # Baksmali appends .1, .2, ... for case-colliding class names on + # Windows. Match the class declaration rather than its filename. + found = False + for path in base.parent.glob(base.name + "*.smali"): + with open(path, encoding="utf-8") as handle: + declaration = handle.readline().strip().split() + if declaration and declaration[-1] == owner: + paths.add(str(path)) + found = True + break + if not found: + raise RuntimeError("Disassembly is missing candidate class %s" % owner) + return sorted(paths) + rg = shutil.which("rg") + if rg: + command = [rg, "--null", "--files-with-matches", "--fixed-strings", "-g", "*.smali"] + for key in flags: + command.extend(("-e", key)) + result = subprocess.run(command + [root], capture_output=True) + if result.returncode not in (0, 1): + raise RuntimeError(result.stderr.decode("utf-8", "replace")) + return [os.fsdecode(path) for path in result.stdout.split(b"\0") if path] + return [os.path.join(directory, name) + for directory, _dirs, files in os.walk(root) + for name in files if name.endswith(".smali")] + + +def rewrite_directory(root: str, flags: Dict[str, int], owners=None) -> Dict[str, int]: + counts: Dict[str, int] = {} + for path in candidate_files(root, flags, owners): + with open(path, encoding="utf-8") as handle: + text = handle.read() + rewritten, hits = rewrite_smali(text, flags) + if hits: + with open(path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(rewritten) + for key, count in hits.items(): + counts[key] = counts.get(key, 0) + count + return counts diff --git a/lihont/region.py b/lihont/region.py new file mode 100644 index 0000000..801e941 --- /dev/null +++ b/lihont/region.py @@ -0,0 +1,210 @@ +"""LihonT 2.0 region patch for TikTok 47.1.3 (arm64). + +Every TelephonyManager country/operator read in this build goes through a +small number of audited choke points: + +- classes35.dex X/1DHU BPEA wrapper: LIZIZ/LIZLLL/LJFF/LJII/LJIIIZ/LJIIJJI + plus the six delegating no-cert helpers; each wrapper tags the call with a + literal like "TelephonyManager_getSimCountryIso". +- classes35.dex X/1DHB monitor fallback methods LLII/LLIIII/LLIIIL/LLIIIZ/ + LLIIJI; each one carries a const/16 v4, 0x28xx marker and a literal of the + getter name. +- classes21.dex X/0kmK, X/0kmL Function1 lambdas calling getNetworkOperator + and getSimOperator directly. +- classes35.dex com/ss/android/common/util/NetworkUtils getNetworkOperatorCode + delegates to X/1DHU;LJ, so patching 1DHU covers it. + +Each audited method body is replaced with a constant return from +lihont.two.LihonTRegion. Version markers inside the bodies are verified +before replacing; any drift raises and the build stops. +""" +from __future__ import annotations + +import re +from collections import defaultdict + +MARKER = "# lihont2 region: " +METHOD = re.compile(r"^\.method[^\n]*\n.*?^\.end method", re.M | re.S) + +REGION = "Llihont/two/LihonTRegion;" + +# BPEA wrapper: method -> (getter tag literal that must appear in the body) +BPEA = { + "LIZIZ": "TelephonyManager_getNetworkCountryIso", + "LIZLLL": "TelephonyManager_getNetworkOperator", + "LJFF": "TelephonyManager_getNetworkOperatorName", + "LJII": "TelephonyManager_getSimCountryIso", + "LJIIIZ": "TelephonyManager_getSimOperator", + "LJIIJJI": "TelephonyManager_getSimOperatorName", +} + +# Delegating no-cert helpers: body must call exactly these wrappers. +BPEA_DELEGATES = { + "LIZJ": "LIZIZ", + "LJ": "LIZLLL", + "LJI": "LJFF", + "LJIIJ": "LJIIIZ", + "LJIIL": "LJIIJJI", +} + +# Monitor fallback: method -> (const marker, getter name literal) +MONITOR = { + "LLII": ("0x2861", "getNetworkOperator"), + "LLIIII": ("0x2862", "getNetworkOperatorName"), + "LLIIIL": ("0x2866", "getSimCountryIso"), + "LLIIIZ": ("0x2867", "getSimOperator"), + "LLIIJI": ("0x2868", "getSimOperatorName"), +} + +# Direct lambdas in classes21: class -> (getter, value helper) +LAMBDAS = { + "LX/0kmK;": ("getNetworkOperator", "operator"), + "LX/0kmL;": ("getSimOperator", "operator"), +} + + +def _method(match: re.Match) -> str: + return match[0] + + +def _replace_body(header: str, helper: str, returns_int: bool = False) -> str: + """A constant-returning method body. + + Register sizing: two-arg methods need at least 2 registers so the p0/p1 + parameters stay addressable; the result is written to p0 on return. + """ + if returns_int: + # int is unused by the region patch; kept for completeness. + raise ValueError("No int-returning region helper is patched") + return ( + header + + "\n .registers 2\n " + + MARKER + + "constant region\n" + + " invoke-static {}, " + + REGION + + "->" + + helper + + "()Ljava/lang/String;\n" + + " move-result-object p0\n" + + " return-object p0\n" + + ".end method" + ) + + +def rewrite(text: str, owner: str) -> tuple[str, dict]: + crlf = "\r\n" in text + text = text.replace("\r\n", "\n") + hits: dict = {} + + def method(match: re.Match) -> str: + body = match[0] + if MARKER in body: + return body + header = body.splitlines()[0] + name = re.search(r"^\.method[^(]* (\S+)\(", header) + if name is None: + return body + method_name = name.group(1) + + if owner == "LX/1DHU;": + if method_name in BPEA: + tag, helper = BPEA[method_name], _helper_for(method_name) + if ("const-string v1, \"" + tag + "\"") not in body: + raise ValueError("BPEA wrapper drift: " + method_name) + hits["bpea_" + method_name] = 1 + return _replace_body(header, helper) + if method_name in BPEA_DELEGATES: + delegate = BPEA_DELEGATES[method_name] + if ("LX/1DHU;->" + delegate + "(") not in body: + raise ValueError("BPEA delegate drift: " + method_name) + hits["bpea_delegate_" + method_name] = 1 + return _replace_body(header, _helper_for(delegate)) + return body + + result = METHOD.sub(method, text) + return (result.replace("\n", "\r\n") if crlf else result), hits + + +def _helper_for(wrapper: str) -> str: + tag = BPEA.get(wrapper) + if tag is None: + raise ValueError("No helper for wrapper " + wrapper) + if "CountryIso" in tag: + return "iso" + if "OperatorName" in tag: + return "carrier" + return "operator" + + +def rewrite_monitor(text: str) -> tuple[str, dict]: + """X/1DHB fallback bodies; identified by const markers, not names alone.""" + crlf = "\r\n" in text + text = text.replace("\r\n", "\n") + hits: dict = {} + + def method(match: re.Match) -> str: + body = match[0] + if MARKER in body: + return body + header = body.splitlines()[0] + name = re.search(r"^\.method[^(]* (\S+)\(Landroid/telephony/TelephonyManager;", header) + if name is None or name.group(1) not in MONITOR: + return body + const, getter = MONITOR[name.group(1)] + if ("const/16 v4, " + const) not in body or ('"' + getter + '"') not in body: + return body + hits["monitor_" + name.group(1)] = 1 + helper = "carrier" if getter.endswith("Name") else ("iso" if "CountryIso" in getter else "operator") + return _replace_body(header, helper) + + result = METHOD.sub(method, text) + return (result.replace("\n", "\r\n") if crlf else result), hits + + +def rewrite_lambda(text: str, owner: str) -> tuple[str, dict]: + crlf = "\r\n" in text + text = text.replace("\r\n", "\n") + getter, helper = LAMBDAS[owner] + hits: dict = {} + + def method(match: re.Match) -> str: + body = match[0] + if MARKER in body: + return body + header = body.splitlines()[0] + if " invoke(Ljava/lang/Object;)Ljava/lang/Object;" not in header: + return body + if ("TelephonyManager;->" + getter + "()") not in body: + raise ValueError("Lambda drift in " + owner) + hits["lambda_" + owner] = 1 + return ( + header + + "\n .registers 2\n " + + MARKER + + "constant region\n" + + " invoke-static {}, " + + REGION + + "->" + + helper + + "()Ljava/lang/String;\n" + + " move-result-object v0\n" + + " return-object v0\n" + + ".end method" + ) + + result = METHOD.sub(method, text) + return (result.replace("\n", "\r\n") if crlf else result), hits + + +def expected_hits() -> dict: + counts: dict = defaultdict(int) + for name in BPEA: + counts["bpea_" + name] += 1 + for name in BPEA_DELEGATES: + counts["bpea_delegate_" + name] += 1 + for name in MONITOR: + counts["monitor_" + name] += 1 + for owner in LAMBDAS: + counts["lambda_" + owner] += 1 + return dict(counts) diff --git a/lihont/settings_patch.py b/lihont/settings_patch.py new file mode 100644 index 0000000..10f1a21 --- /dev/null +++ b/lihont/settings_patch.py @@ -0,0 +1,196 @@ +"""Patch TikTok 47.1.3 settings composables to inject native mod settings row and MD3 icons.""" +from __future__ import annotations + +import re + +RENDERER_CLASS = "LX/1DvK;" +GROUP_CLASS = "LX/1DuR;" +DRAW_HOOK = "Llihont/two/settings/SettingsCell;->draw(ZLjava/lang/Object;)Z" +CELL_CLASS = "LX/0LDm;" +PAINTER_CLASS = "Llihont/two/settings/Md3BadgePainter;" + + +def rewrite_1dvk(text: str) -> str: + """Inject lihont(...) composable into LX/1DvK that renders 'Настройки мода' cell.""" + if "lihont(" in text: + raise ValueError("lihont method already present in LX/1DvK") + + m_match = re.search( + r"(?ms)^\.method public static final LIZ\(LX/0CWi;ZZLX/0LYA;I\)V\n(.*?)\n\.end method", + text, + ) + if not m_match: + raise ValueError("LX/1DvK;->LIZ method not found") + + body = m_match.group(1) + + old_recomp = ( + " new-instance v0, Lkotlin/jvm/internal/AwS2S0121000_35;\n\n" + " .line 83886157\n" + " .line 83886158\n" + " const/16 v11, 0x23\n\n" + " .line 83886159\n" + " .line 83886160\n" + " move-object v6, v0\n\n" + " .line 83886161\n" + " move-object v7, v5\n\n" + " .line 83886162\n" + " move v8, v4\n\n" + " .line 83886163\n" + " move v9, v3\n\n" + " .line 83886164\n" + " move v10, v2\n\n" + " .line 83886165\n" + " invoke-direct/range {v6 .. v11}, Lkotlin/jvm/internal/AwS2S0121000_35;->(LX/0CWi;ZZII)V" + ) + new_recomp = ( + " invoke-static {v5, v4, v3, v2}, Llihont/two/settings/SettingsCell;->again(Ljava/lang/Object;ZZI)Ljava/lang/Object;\n\n" + " move-result-object v0\n\n" + " check-cast v0, Lkotlin/jvm/functions/Function2;" + ) + if old_recomp not in body: + raise ValueError("LX/1DvK;->LIZ recomposition callback pattern not found") + body = body.replace(old_recomp, new_recomp, 1) + + old_title_icon = ( + " iget-object v0, v5, LX/0CWi;->LLJJIJIIJIL:Ljava/lang/Integer;\n\n" + " .line 83886180\n" + " .line 83886181\n" + " const/4 v12, 0x0\n\n" + " .line 83886182\n" + " if-eqz v0, :cond_e5\n\n" + " .line 83886183\n" + " .line 83886184\n" + " invoke-virtual {v0}, Ljava/lang/Integer;->intValue()I\n\n" + " .line 83886185\n" + " .line 83886186\n" + " .line 83886187\n" + " move-result v0\n\n" + " .line 83886188\n" + " :goto_6c\n" + " invoke-static {v0, v6}, LX/0KwU;->LIZIZ(ILX/0LYA;)Ljava/lang/String;\n\n" + " .line 83886189\n" + " .line 83886190\n" + " .line 83886191\n" + " move-result-object v11\n\n" + " .line 83886192\n" + " const/4 v10, 0x0\n\n" + " .line 83886193\n" + " iget-object v0, v5, LX/0CWi;->LLJJIJI:LX/0LGU;\n\n" + " .line 83886194\n" + " .line 83886195\n" + " if-nez v0, :cond_77\n\n" + " .line 83886196\n" + " .line 83886197\n" + " sget-object v0, LX/0L1b;->LIZ:LX/0LGU;\n\n" + " .line 83886198\n" + " .line 83886199\n" + " :cond_77\n" + " invoke-static {v0, v6}, LX/0Lis;->LJFF(LX/0LGU;LX/0LYA;)LX/0MIo;" + ) + new_title_icon = ( + " invoke-static {}, Llihont/two/settings/SettingsCell;->title()Ljava/lang/String;\n\n" + " move-result-object v11\n\n" + " const/4 v12, 0x0\n\n" + " const/4 v10, 0x0\n\n" + " sget-object v0, LX/0LB8;->LLLLIIL:LX/0LGU;\n\n" + " invoke-static {v0, v6}, LX/0Lis;->LJFF(LX/0LGU;LX/0LYA;)LX/0MIo;" + ) + if old_title_icon not in body: + raise ValueError("LX/1DvK;->LIZ title/icon pattern not found") + body = body.replace(old_title_icon, new_title_icon, 1) + + cond_e5_pattern = re.compile( + r"(?m)^\s*:cond_e5\s*\n\s*const/4 v0, 0x0\s*\n(?:\s*\.line [0-9]+\s*\n)?\s*goto :goto_6c\s*\n?" + ) + if not cond_e5_pattern.search(body): + raise ValueError("LX/1DvK;->LIZ cond_e5 block not found") + body = cond_e5_pattern.sub("", body) + + click_pattern = re.compile( + r"(?ms)new-instance v9, Lkotlin/jvm/internal/AwS499S0200000_4;.*?" + r"invoke-direct \{v9, v5, v1, v0\}, Lkotlin/jvm/internal/AwS499S0200000_4;->\(LX/0CWi;Landroid/content/Context;I\)V" + ) + if click_pattern.search(body): + body = click_pattern.sub( + "invoke-static {v1}, Llihont/two/settings/SettingsCell;->onClick(Ljava/lang/Object;)Ljava/lang/Object;\n\n move-result-object v9", + body, + ) + + old_ll = " iget-object v7, v5, LX/0CWi;->LL:Lkotlin/jvm/functions/Function1;" + if old_ll in body: + body = body.replace(old_ll, " const/4 v7, 0x0", 1) + + lihont_method = ( + "\n.method public static final lihont(LX/0CWi;ZZLX/0LYA;I)V\n" + + body + + "\n.end method\n" + ) + return text + lihont_method + + +def rewrite_1dur(text: str) -> str: + """Inject SettingsCell.draw before ManagePostsVM in LX/1DuR;->LJIIIZ.""" + if DRAW_HOOK in text: + raise ValueError("SettingsCell.draw already present in LX/1DuR") + + anchor = ( + " const/4 v14, 0x4\n\n" + " .line 83886338\n" + " move-object v12, v6\n\n" + " .line 83886339\n" + " invoke-static/range {v9 .. v14}, LX/1Dvf;->LIZIZ(ZZLcom/ss/android/ugc/aweme/setting/ui/rvmpcompose/group/top/cell/ManagePostsVM;LX/0LYA;II)V" + ) + if anchor not in text: + raise ValueError("LX/1DuR ManagePostsVM anchor not found") + + injection = ( + " const/4 v14, 0x4\n\n" + " .line 83886338\n" + " move-object v12, v6\n\n" + " # LihonT 2.0 native settings cell injection: 47.1.3\n" + f" invoke-static {{v9, v6}}, {DRAW_HOOK}\n\n" + " move-result v9\n\n" + " .line 83886339\n" + " invoke-static/range {v9 .. v14}, LX/1Dvf;->LIZIZ(ZZLcom/ss/android/ugc/aweme/setting/ui/rvmpcompose/group/top/cell/ManagePostsVM;LX/0LYA;II)V" + ) + return text.replace(anchor, injection, 1) + + +def rewrite_0ldm(text: str) -> str: + """Inject Material 3 icon transformation into LX/0LDm;->LIZ and upgrade card corner radius.""" + if "SettingsCell;->transformIcon" in text: + raise ValueError("transformIcon hook already present in LX/0LDm") + + # Upgrade settings card corner radius from 12dp to Material 3 standard 24dp (0x18) + clinit_corner = re.search( + r"const/16 v0, 0xc\s*\n(?:\s*\.line [0-9]+\s*\n)*\s*int-to-float v0, v0\s*\n(?:\s*\.line [0-9]+\s*\n)*\s*sput v0, LX/0LDm;->LIZLLL:F", + text, + ) + if clinit_corner: + text = text.replace( + clinit_corner.group(0), + clinit_corner.group(0).replace("const/16 v0, 0xc", "const/16 v0, 0x18"), + 1, + ) + + pattern = re.compile( + r"(\.method public static final LIZ\(LX/0M1d;[^\n]+\n.*?\.prologue\n(?:\s*\.line [0-9]+\n)?)(\s*move-object/from16 p20, p20)", + re.DOTALL + ) + m = pattern.search(text) + if not m: + raise ValueError("LX/0LDm;->LIZ prologue anchor not found") + + injection = ( + " # LihonT 2.0 Material 3 settings icon hook\n" + " move-object/from16 v0, p8\n" + " move-object/from16 v1, p1\n" + " move-object/from16 v2, p25\n" + " invoke-static {v0, v1, v2}, Llihont/two/settings/SettingsCell;->transformIcon(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object;\n" + " move-result-object v0\n" + " check-cast v0, LX/0LRh;\n" + " move-object/from16 p8, v0\n\n" + ) + text = pattern.sub(r"\1" + injection + r"\2", text, count=1) + return text diff --git a/lihont/theme_patch.py b/lihont/theme_patch.py new file mode 100644 index 0000000..3795a0b --- /dev/null +++ b/lihont/theme_patch.py @@ -0,0 +1,153 @@ +"""Route TikTok's Compose colour-token reads through LihonT's native-screen theme. + +TikTok 47.1.3 reads its colour tokens (LX/1MiX, one public long per token) with +LX/0LAr;->LIZIZ(composer). The rewrite passes the result and the composer to +lihont.two.theme.NativeTheme, which returns the same object for every screen +except TikTok's own settings, where it returns a recoloured copy. +""" + +from __future__ import annotations + +import re + +ACCESSOR_CLASS = "LX/0LAr;" +HOOK = "Llihont/two/theme/NativeTheme;->colors(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;" + +_METHOD = re.compile( + r"^\.method public static LIZIZ\(LX/0LYA;\)LX/1MiX;\n.*?^\.end method", re.M | re.S) +_CAST = re.compile(r"^ check-cast v0, LX/1MiX;\n", re.M) + + +def runtime_targets(): + """Runtime methods the rewrite calls, as they appear in a method table.""" + return [HOOK] + + +def rewrite_colors_accessor(text: str) -> str: + """Insert the NativeTheme call between the local read and its cast.""" + match = _METHOD.search(text) + if match is None: + raise ValueError("LX/0LAr;->LIZIZ(LX/0LYA;)LX/1MiX; not found") + method = match.group() + if "NativeTheme" in method: + raise ValueError("Colour hook already present in LX/0LAr;->LIZIZ") + # Two registers means v0 is free and p0 is v1, so no register is added. + if ".registers 2" not in method or "invoke-interface {p0, v0}" not in method: + raise ValueError("LX/0LAr;->LIZIZ has an unexpected register layout") + casts = _CAST.findall(method) + if len(casts) != 1: + raise ValueError("LX/0LAr;->LIZIZ must hold exactly one cast to LX/1MiX;") + patched = _CAST.sub( + " invoke-static {v0, p0}, %s\n\n move-result-object v0\n\n" + " check-cast v0, LX/1MiX;\n" % HOOK, method, count=1) + return text[:match.start()] + patched + text[match.end():] + + +SECTION_HOOK = "Llihont/two/theme/NativeTheme;->sectionLabel(Ljava/lang/Object;)J" + +SECTION_CLASS = "LX/0Lis;" + +_LABEL_METHOD = re.compile( + r"^\.method public static final LIZJ\(Ljava/lang/String;ZZZLkotlin/jvm/functions/Function0;LX/0LYA;II\)V\n" + r".*?^\.end method", re.M | re.S) +_LABEL_COLOUR = re.compile(r"^ iget-wide ([vp]\d+), ([vp]\d+), LX/1MiX;->d4:J\n", re.M) + + +def section_targets(): + return [SECTION_HOOK] + + +def rewrite_section_label(text: str) -> str: + """Make the group title composable read its colour through NativeTheme.sectionLabel. + + LX/0Lis;->LIZJ draws the title above each settings group (it is called once per group with + the group's string). Its text colour is the secondary-text token (d4), read in two branches. + Each call takes the colour-token object that was in the register and writes the same wide + pair, so no register is added. Outside TikTok settings it returns d4, the original behaviour. + """ + match = _LABEL_METHOD.search(text) + if match is None: + raise ValueError("LX/0Lis;->LIZJ(Ljava/lang/String;ZZZLkotlin/jvm/functions/Function0;" + "LX/0LYA;II)V not found") + method = match.group() + if "sectionLabel" in method: + raise ValueError("Section label hook already present in LX/0Lis;->LIZJ") + if len(_LABEL_COLOUR.findall(method)) != 2: + raise ValueError("LX/0Lis;->LIZJ must read the d4 token exactly twice") + patched = _LABEL_COLOUR.sub( + lambda read: " invoke-static/range {%s .. %s}, %s\n\n move-result-wide %s\n" + % (read.group(2), read.group(2), SECTION_HOOK, read.group(1)), + method) + return text[:match.start()] + patched + text[match.end():] + + +ROW_CLASS = "LX/0LDf;" +ROW_MODIFIER_HOOK = ("Llihont/two/theme/NativeTheme;->rowModifier" + "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;") +ROW_SHAPE_HOOK = "Llihont/two/theme/NativeTheme;->rowShape(FFFFLjava/lang/Object;)Ljava/lang/Object;" + +# Baksmali puts blank lines and `.line N` directives between instructions. +_BETWEEN = r"(?:[ \t]*(?:\.line \d+)?\n)*" +_ROW_BASE = re.compile( + r"( move-object/from16 v9, v17\n)(" + _BETWEEN + + r" invoke-static \{v9, v1, v13\}, LX/0M8m;->LIZIZ\(LX/0M1d;ZLkotlin/jvm/functions/Function1;\)LX/0M1d;\n)") +_ROW_SHAPE = re.compile( + r" invoke-static \{v8, v8, v7, v7\}, LX/0Kzn;->LIZJ\(FFFF\)LX/0LQf;\n(" + + _BETWEEN + r" move-result-object v7\n)") + + +def row_targets(): + return [ROW_MODIFIER_HOOK, ROW_SHAPE_HOOK] + + +def rewrite_row_lambda(text: str) -> str: + """Let NativeTheme restyle one settings row: its outer padding and its background shape. + + TikTok draws every settings row with its own background whose corners are rounded only when + the row is first or last in its group, which makes a group look like one card. The shape call + and the modifier the row starts from are routed through NativeTheme with the composer, so + TikTok's settings can use fully rounded, separated rows while other screens are unchanged. + """ + if "NativeTheme" in text: + raise ValueError("Row hooks already present in LX/0LDf") + if len(_ROW_BASE.findall(text)) != 1: + raise ValueError("LX/0LDf must start its row modifier from v17 exactly once") + if len(_ROW_SHAPE.findall(text)) != 1: + raise ValueError("LX/0LDf must build its background shape exactly once") + text = _ROW_BASE.sub( + lambda m: m.group(1) + " invoke-static {v9, v0}, %s\n\n move-result-object v9\n\n" + " check-cast v9, LX/0M1d;\n" % ROW_MODIFIER_HOOK + m.group(2), text, count=1) + text = _ROW_SHAPE.sub( + lambda m: " invoke-static {v8, v8, v7, v7, v0}, %s\n" % ROW_SHAPE_HOOK + m.group(1) + + "\n check-cast v7, LX/0LQf;\n", text, count=1) + return text + + +LANGUAGE_CLASS = "LX/0Lk9;" +CARD_HOOK = ("Llihont/two/theme/NativeTheme;->cardBackground" + "(Ljava/lang/Object;JLjava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;") + +_CARD = re.compile( + r" invoke-static \{v11, v0, v1, v15\}, LX/0L5k;->LIZIZ\(LX/0M1d;JLX/0LQx;\)LX/0M1d;\n(" + + _BETWEEN + r" move-result-object v0\n)") + + +def card_targets(): + return [CARD_HOOK] + + +def rewrite_language_card(text: str) -> str: + """Route the language row's own card background through NativeTheme.cardBackground. + + Unlike every other settings row, the language row wraps itself in a card: the card colour + (N2, or an animated highlight) with 8 dp corners, drawn outside the row. Inside TikTok + settings that wrapper would show as a wide rectangle between the separated rounded rows, so + the hook may return the modifier unchanged. Elsewhere it makes TikTok's own call. + """ + if "NativeTheme" in text: + raise ValueError("Card hook already present in LX/0Lk9") + if len(_CARD.findall(text)) != 1: + raise ValueError("LX/0Lk9 must draw its card background exactly once") + return _CARD.sub( + lambda m: " invoke-static {v11, v0, v1, v15, v3}, %s\n" % CARD_HOOK + m.group(1) + + "\n check-cast v0, LX/0M1d;\n", text, count=1) diff --git a/lihont/toolchain.py b/lihont/toolchain.py new file mode 100644 index 0000000..ef021a9 --- /dev/null +++ b/lihont/toolchain.py @@ -0,0 +1,236 @@ +"""The four tools the build needs, fetched once and kept in `tools/`. + +Every URL here is pinned to a version and points at Google's Maven, Maven +Central or a release asset -- no "latest" lookups through an API that rate +limits, and no surprise when a tool changes under the build. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import urllib.request +import zipfile +from typing import Optional + +# apktool's cli jar is used for one thing only: it carries smali and baksmali, +# assembler and disassembler, in a single jar. The apktool part -- decoding and +# rebuilding resources -- is exactly what this build does not do. +SMALI_JAR = ( + "https://repo1.maven.org/maven2/org/apktool/apktool-cli/3.0.3/apktool-cli-3.0.3.jar" +) +R8_JAR = "https://dl.google.com/dl/android/maven2/com/android/tools/r8/8.7.18/r8-8.7.18.jar" +PLATFORM_ZIP = "https://dl.google.com/android/repository/platform-35_r02.zip" +PLATFORM_MEMBER = "android-35/android.jar" +SIGNER_JAR = ( + "https://github.com/patrickfav/uber-apk-signer/releases/download/v1.3.0/" + "uber-apk-signer-1.3.0.jar" +) + + +def _javap() -> str: + found = shutil.which("javap") + if found: + return found + for fallback in [ + r"C:\Program Files\Java\jdk-17\bin\javap.exe", + r"C:\Program Files\Java\jdk-26.0.1\bin\javap.exe", + ]: + if os.path.exists(fallback): + return fallback + return "javap" + + +class Toolchain: + def __init__(self, directory: str, quiet: bool = False): + self.directory = directory + self.quiet = quiet + os.makedirs(directory, exist_ok=True) + + def _say(self, message: str) -> None: + if not self.quiet: + print(message) + + def _fetch(self, url: str, name: str) -> str: + path = os.path.join(self.directory, name) + if os.path.exists(path): + return path + self._say(" fetching %s" % name) + temporary = path + ".part" + with urllib.request.urlopen(url) as response, open(temporary, "wb") as out: + shutil.copyfileobj(response, out) + os.replace(temporary, path) + return path + + @property + def smali(self) -> str: + return self._fetch(SMALI_JAR, "smali.jar") + + @property + def r8(self) -> str: + return self._fetch(R8_JAR, "r8.jar") + + @property + def signer(self) -> str: + return self._fetch(SIGNER_JAR, "uber-apk-signer.jar") + + @property + def android_jar(self) -> str: + path = os.path.join(self.directory, "android.jar") + if os.path.exists(path): + return path + archive = self._fetch(PLATFORM_ZIP, "platform.zip") + self._say(" unpacking android.jar") + with zipfile.ZipFile(archive) as zf, open(path, "wb") as out: + shutil.copyfileobj(zf.open(PLATFORM_MEMBER), out) + os.remove(archive) + return path + + # ------------------------------------------------------------ using them + + def framework_constant(self, field: str) -> int: + """Read a constant out of android.jar rather than writing it down. + + Framework resource ids never change, but writing one into the source + makes it a number nobody can check. javap can just read it. + """ + result = subprocess.run( + [_javap(), "-cp", self.android_jar, "-constants", "android.R$style"], + capture_output=True, + text=True, + ) + for line in result.stdout.splitlines(): + if (" " + field + " = ") in line: + return int(line.rsplit("=", 1)[1].strip().rstrip(";")) + raise RuntimeError("android.jar has no android.R.style.%s" % field) + + def check_attribute_ids(self, ids: dict) -> None: + """Check the framework attribute ids against android.jar. + + Every id the manifest is written with is a public one that has not + changed since the day it shipped -- but a table of bare numbers is a + table nobody can check, and one wrong digit writes an attribute that + means something else entirely. + """ + result = subprocess.run( + [_javap(), "-cp", self.android_jar, "-constants", "android.R$attr"], + capture_output=True, + text=True, + ) + known = {} + for line in result.stdout.splitlines(): + if " = " not in line or not line.strip().endswith(";"): + continue + name = line.split()[-3] + known[name] = int(line.rsplit("=", 1)[1].strip().rstrip(";")) + for name, value in ids.items(): + if name in known and known[name] != value: + raise RuntimeError( + "android:%s is 0x%08x in android.jar, not 0x%08x" + % (name, known[name], value) + ) + + def compile_dex(self, sources_dir: str, out_dir: str, min_api: int, + stubs_dir: Optional[str] = None) -> str: + """javac then d8: the mod's own classes, as one dex. + + `stubs_dir` holds TikTok's own classes in outline, so the mod can be + written against the ones it is rewritten into. They are compiled to + build against and then handed to d8 as classpath rather than input: + the real ones are in the apk, and a second copy under the same name + would be a different class as far as the runtime is concerned. + """ + classes = os.path.join(out_dir, "classes") + stubs = os.path.join(out_dir, "stubs") + dex = os.path.join(out_dir, "dex") + for path in (classes, stubs, dex): + shutil.rmtree(path, ignore_errors=True) + os.makedirs(path) + + classpath = self.android_jar + if stubs_dir: + stub_sources = _java_under(stubs_dir) + if stub_sources: + _run(["javac", "-nowarn", "-Xlint:-options", "-encoding", "UTF-8", + "-cp", self.android_jar, "--release", "17", "-d", stubs] + + stub_sources) + classpath = self.android_jar + os.pathsep + stubs + + sources = _java_under(sources_dir) + if not sources: + raise RuntimeError("no java sources under %s" % sources_dir) + + _run( + ["javac", "-nowarn", "-Xlint:-options", "-encoding", "UTF-8", + "-cp", classpath, "--release", "17", "-d", classes] + sources + ) + class_files = _classes_under(classes) + command = ["java", "-cp", self.r8, "com.android.tools.r8.D8", "--release", + "--min-api", str(min_api), "--lib", self.android_jar] + if stubs_dir and _classes_under(stubs): + command += ["--classpath", stubs] + _run(command + ["--output", dex] + class_files) + return os.path.join(dex, "classes.dex") + + def _zipalign_path(self) -> Optional[str]: + found = shutil.which("zipalign") + if found: + return found + local_app_data = os.environ.get("LOCALAPPDATA", "") + if local_app_data: + bt = os.path.join(local_app_data, "Android", "Sdk", "build-tools") + if os.path.isdir(bt): + # Prefer stable releases (34, 33, 30) over unstable previews (37, 36) + preferred = ["34.0.0", "33.0.1", "30.0.3"] + for ver in preferred: + cand = os.path.join(bt, ver, "zipalign.exe" if os.name == "nt" else "zipalign") + if os.path.isfile(cand): + return cand + for ver in sorted(os.listdir(bt), reverse=True): + cand = os.path.join(bt, ver, "zipalign.exe" if os.name == "nt" else "zipalign") + if os.path.isfile(cand): + return cand + return None + + + def sign(self, apk_path: str, key: Optional[str] = None) -> None: + """Sign in place. Without a key, a debug one is made up on the spot.""" + # its zipalign pass runs before signing, which is where alignment has + # to be settled: the signature block goes in after every entry is placed + command = ["java", "-Xmx512m", "-jar", self.signer, "-a", apk_path, "--allowResign", "--overwrite"] + za = self._zipalign_path() + if za: + command += ["--zipAlignPath", za] + za_dir = os.path.dirname(za) + os.environ["PATH"] = za_dir + os.pathsep + os.environ.get("PATH", "") + if key: + command += ["--ks", key] + _run(command) + + + + +def _java_under(root: str) -> list: + out = [] + for dirpath, _dirs, files in os.walk(root): + out += [os.path.join(dirpath, f) for f in files if f.endswith(".java")] + return sorted(out) + + +def _classes_under(root: str) -> list: + out = [] + for dirpath, _dirs, files in os.walk(root): + out += [os.path.join(dirpath, f) for f in files if f.endswith(".class")] + return sorted(out) + + +def _run(command: list) -> None: + environment = dict(os.environ) + # the wrapper's own JVM options print a banner into every tool's output + environment.pop("JAVA_TOOL_OPTIONS", None) + result = subprocess.run(command, capture_output=True, text=True, env=environment) + if result.returncode != 0: + sys.stderr.write(result.stdout + result.stderr) + raise RuntimeError("%s failed" % os.path.basename(command[0])) diff --git a/lihont/upgrade.py b/lihont/upgrade.py new file mode 100644 index 0000000..54036de --- /dev/null +++ b/lihont/upgrade.py @@ -0,0 +1,572 @@ +"""Build LihonT 2.0 test build: TikTok 47.1.3 arm64, BY region helper, and +native profile banners / comment reposts behind the settings switches. + +python -m lihont.upgrade OriginalAPKtiktok/ -o ComplectAPK/lihont-2.0-test.apk +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +from collections import defaultdict +from pathlib import Path + +from . import badge_patch, bar_patch, dexpatch, feed_patch, media_patch, region, profile_features, settings_patch, theme_patch +from .apkzip import Apk +from .axml import Axml +from .toolchain import Toolchain + +SOURCE_VERSION = "47.1.3" +SETTINGS_ACTIVITY = "lihont.two.settings.LihonTSettingsActivity" +# Existing app theme keeps system bars/insets compatible with TikTok; the +# activity draws its own Nothing-inspired surface. +SETTINGS_THEME = 0x7F120364 + +# dex -> list of class descriptors patched for the region feature +REGION_TARGETS = { + "classes35.dex": ["LX/1DHU;", "LX/1DHB;"], + "classes21.dex": ["LX/0kmK;", "LX/0kmL;"], +} + +# TikTok 47.1.3's native settings Compose composables. +# SettingsCell.draw injects the row into the Activity section card above ManagePostsVM. +SETTINGS_TARGET_DEX = "classes36.dex" +SETTINGS_TARGET_CLASSES = [settings_patch.RENDERER_CLASS, settings_patch.GROUP_CLASS] + + + +def patch_classes(apk: Apk, targets: dict, tools: Toolchain, room: Path, api: int) -> dict: + compiler = room / "compiler" + compiler.mkdir(parents=True, exist_ok=True) + grouped: dict = defaultdict(list) + for name, owners in targets.items(): + for owner in owners: + grouped[name].append(owner) + reports: dict = {} + smali = dexpatch.Smali(tools.smali, api, jobs=2, heap="1g") + subprocess.run( + ["javac", "--release", "17", "-cp", tools.smali, "-d", str(compiler), + "src/ReplaceClasses.java", "src/MergeClasses.java"], check=True + ) + for name, owners in grouped.items(): + folder = room / name[:-4] + folder.mkdir(parents=True, exist_ok=True) + before = folder / "original.dex" + before.write_bytes(apk.read(name)) + decoded = folder / "smali" + for owner in owners: + subprocess.run( + ["java", "-Xmx1g", "-cp", + os.pathsep.join([tools.smali, "tools/baksmali.jar"]), + "com.android.tools.smali.baksmali.Main", "disassemble", + str(before), "--classes", owner, "-o", str(decoded)], check=True + ) + hits: dict = {} + for owner in owners: + path = decoded / (owner[1:-1] + ".smali") + text = path.read_text(encoding="utf-8") + class_desc = re.search(r"^\.class[^\n]* (L[^;]+;)$", text, re.M) + if class_desc is None or class_desc.group(1) != owner: + raise ValueError("Unexpected smali class in " + owner) + if owner == "LX/1DHB;": + rewritten, counts = region.rewrite_monitor(text) + elif owner in region.LAMBDAS: + rewritten, counts = region.rewrite_lambda(text, owner) + else: + rewritten, counts = region.rewrite(text, owner) + if not counts: + raise ValueError("Region hook missing in " + owner) + path.write_text(rewritten, encoding="utf-8", newline="\n") + hits[owner] = counts + replacement = folder / "replacement.dex" + smali.assemble(str(decoded), str(replacement)) + after = folder / "patched.dex" + subprocess.run( + ["java", "-Xmx1g", "-cp", os.pathsep.join([str(compiler), tools.smali]), + "ReplaceClasses", str(before), str(replacement), str(after), str(api)], + check=True + ) + old, new = before.read_bytes(), after.read_bytes() + if dexpatch.defined_methods(old) != dexpatch.defined_methods(new) or \ + dexpatch.dex_format(old) != dexpatch.dex_format(new): + raise ValueError("Class definitions or DEX version changed in " + name) + apk.replace(name, new) + reports[name] = {"sha256": hashlib.sha256(new).hexdigest(), "hooks": hits} + return reports + + +def patch_settings_entry(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Inject the native Jetpack Compose settings row into TikTok's settings card.""" + name = SETTINGS_TARGET_DEX + before = apk.read(name) + folder = room / "settings-entry" + folder.mkdir(parents=True, exist_ok=True) + original = folder / "original.dex" + original.write_bytes(before) + decoded = folder / "smali" + for owner in SETTINGS_TARGET_CLASSES: + subprocess.run( + ["java", "-Xmx1g", "-cp", + os.pathsep.join([tools.smali, "tools/baksmali.jar"]), + "com.android.tools.smali.baksmali.Main", "disassemble", + str(original), "--classes", owner, "-o", str(decoded)], + check=True, + ) + + dvk_path = decoded / (settings_patch.RENDERER_CLASS[1:-1] + ".smali") + dur_path = decoded / (settings_patch.GROUP_CLASS[1:-1] + ".smali") + if not dvk_path.exists() or not dur_path.exists(): + raise ValueError("Settings Compose classes missing from " + name) + + dvk_text = dvk_path.read_text(encoding="utf-8").replace("\r\n", "\n") + dur_text = dur_path.read_text(encoding="utf-8").replace("\r\n", "\n") + + patched_dvk = settings_patch.rewrite_1dvk(dvk_text) + patched_dur = settings_patch.rewrite_1dur(dur_text) + + dvk_path.write_text(patched_dvk, encoding="utf-8", newline="\n") + dur_path.write_text(patched_dur, encoding="utf-8", newline="\n") + + replacement = folder / "replacement.dex" + dexpatch.Smali(tools.smali, api, jobs=2, heap="1g").assemble(str(decoded), str(replacement)) + + compiler = room / "compiler" + compiler.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["javac", "--release", "17", "-cp", tools.smali, "-d", str(compiler), + "src/ReplaceClasses.java", "src/MergeClasses.java"], + check=True, + ) + patched_path = folder / "patched.dex" + subprocess.run( + ["java", "-Xmx1g", "-cp", os.pathsep.join([str(compiler), tools.smali]), + "ReplaceClasses", str(original), str(replacement), str(patched_path), str(api)], + check=True, + ) + patched = patched_path.read_bytes() + before_methods = dexpatch.defined_methods(before) + patched_methods = dexpatch.defined_methods(patched) + if not before_methods.issubset(patched_methods): + raise ValueError("Settings entry hook lost existing methods") + if not any("LX/1DvK;->lihont(" in m for m in patched_methods): + raise ValueError("LX/1DvK;->lihont missing after patch") + if b"Llihont/two/settings/SettingsCell;" not in patched: + raise ValueError("SettingsCell hook missing in patched dex") + if dexpatch.dex_format(before) != dexpatch.dex_format(patched): + raise ValueError("Settings entry hook changed DEX format") + apk.replace(name, patched) + return { + "dex": name, + "classes": SETTINGS_TARGET_CLASSES, + "hook": settings_patch.DRAW_HOOK, + "sha256": hashlib.sha256(patched).hexdigest(), + } + + +def patch_md3_settings_icons(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Inject Material 3 flower badge transformer into TikTok's settings cell renderer in classes9.dex.""" + name = "classes9.dex" + before = apk.read(name) + folder = room / "settings-md3-icons" + folder.mkdir(parents=True, exist_ok=True) + original = folder / "original.dex" + original.write_bytes(before) + decoded = folder / "smali" + subprocess.run( + ["java", "-Xmx1g", "-cp", + os.pathsep.join([tools.smali, "tools/baksmali.jar"]), + "com.android.tools.smali.baksmali.Main", "disassemble", + str(original), "--classes", settings_patch.CELL_CLASS, "-o", str(decoded)], + check=True, + ) + + ldm_path = decoded / (settings_patch.CELL_CLASS[1:-1] + ".smali") + if not ldm_path.exists(): + raise ValueError("Settings cell class missing from " + name) + + ldm_text = ldm_path.read_text(encoding="utf-8").replace("\r\n", "\n") + patched_ldm = settings_patch.rewrite_0ldm(ldm_text) + ldm_path.write_text(patched_ldm, encoding="utf-8", newline="\n") + + painter_src = Path("src/smali/lihont/two/settings/Md3BadgePainter.smali") + if not painter_src.exists(): + raise ValueError(f"{painter_src} missing") + painter_dst = decoded / "lihont" / "two" / "settings" / "Md3BadgePainter.smali" + painter_dst.parent.mkdir(parents=True, exist_ok=True) + painter_dst.write_text(painter_src.read_text(encoding="utf-8"), encoding="utf-8", newline="\n") + + replacement = folder / "replacement.dex" + dexpatch.Smali(tools.smali, api, jobs=2, heap="1g").assemble(str(decoded), str(replacement)) + + compiler = room / "compiler" + compiler.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["javac", "--release", "17", "-cp", tools.smali, "-d", str(compiler), + "src/ReplaceClasses.java", "src/MergeClasses.java"], + check=True, + ) + patched_path = folder / "patched.dex" + subprocess.run( + ["java", "-Xmx1g", "-cp", os.pathsep.join([str(compiler), tools.smali]), + "ReplaceClasses", str(original), str(replacement), str(patched_path), str(api)], + check=True, + ) + patched = patched_path.read_bytes() + before_methods = dexpatch.defined_methods(before) + patched_methods = dexpatch.defined_methods(patched) + if not before_methods.issubset(patched_methods): + raise ValueError("MD3 settings icon hook lost existing methods") + if not any("Llihont/two/settings/Md3BadgePainter;->LJIIIZ(" in m for m in patched_methods): + raise ValueError("Md3BadgePainter missing after patch") + if b"Llihont/two/settings/SettingsCell;" not in patched or b"transformIcon" not in patched: + raise ValueError("transformIcon hook missing in patched dex") + if dexpatch.dex_format(before) != dexpatch.dex_format(patched): + raise ValueError("MD3 settings icon hook changed DEX format") + apk.replace(name, patched) + return { + "dex": name, + "classes": [settings_patch.CELL_CLASS, settings_patch.PAINTER_CLASS], + "hook": "Llihont/two/settings/SettingsCell;->transformIcon(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object;", + "sha256": hashlib.sha256(patched).hexdigest(), + } + + +def patch_class(apk: Apk, tools: Toolchain, room: Path, api: int, dex_name: str, owner: str, + label: str, rewrite, marker: bytes) -> dict: + """Disassemble one class, rewrite its smali, and put the reassembled class back in its dex.""" + before = apk.read(dex_name) + folder = room / label + folder.mkdir(parents=True, exist_ok=True) + original = folder / "original.dex" + original.write_bytes(before) + decoded = folder / "smali" + subprocess.run( + ["java", "-Xmx1g", "-cp", + os.pathsep.join([tools.smali, "tools/baksmali.jar"]), + "com.android.tools.smali.baksmali.Main", "disassemble", + str(original), "--classes", owner, "-o", str(decoded)], + check=True, + ) + source = decoded / (owner[1:-1] + ".smali") + if not source.exists(): + raise ValueError("%s missing from %s" % (owner, dex_name)) + text = source.read_text(encoding="utf-8").replace("\r\n", "\n") + source.write_text(rewrite(text), encoding="utf-8", newline="\n") + + replacement = folder / "replacement.dex" + dexpatch.Smali(tools.smali, api, jobs=2, heap="1g").assemble(str(decoded), str(replacement)) + + compiler = room / "compiler" + compiler.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["javac", "--release", "17", "-cp", tools.smali, "-d", str(compiler), + "src/ReplaceClasses.java", "src/MergeClasses.java"], + check=True, + ) + patched_path = folder / "patched.dex" + subprocess.run( + ["java", "-Xmx1g", "-cp", os.pathsep.join([str(compiler), tools.smali]), + "ReplaceClasses", str(original), str(replacement), str(patched_path), str(api)], + check=True, + ) + patched = patched_path.read_bytes() + if not dexpatch.defined_methods(before).issubset(dexpatch.defined_methods(patched)): + raise ValueError("%s rewrite lost existing methods" % owner) + if marker not in patched: + raise ValueError("%s rewrite did not reach the dex" % owner) + if dexpatch.dex_format(before) != dexpatch.dex_format(patched): + raise ValueError("%s rewrite changed the DEX format" % owner) + apk.replace(dex_name, patched) + return {"dex": dex_name, "class": owner, "sha256": hashlib.sha256(patched).hexdigest()} + + +def patch_native_theme(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Pass TikTok's Compose colour tokens through lihont.two.theme.NativeTheme.""" + report = patch_class(apk, tools, room, api, "classes9.dex", theme_patch.ACCESSOR_CLASS, + "native-theme", theme_patch.rewrite_colors_accessor, + b"Llihont/two/theme/NativeTheme;") + report["hook"] = theme_patch.HOOK + return report + + +def patch_settings_rows(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Let NativeTheme give TikTok's settings rows their own rounded shape and spacing.""" + report = patch_class(apk, tools, room, api, "classes9.dex", theme_patch.ROW_CLASS, + "native-rows", theme_patch.rewrite_row_lambda, + b"Llihont/two/theme/NativeTheme;") + report["hooks"] = theme_patch.row_targets() + return report + + +def patch_section_labels(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Let NativeTheme colour the title above each settings group.""" + report = patch_class(apk, tools, room, api, "classes9.dex", theme_patch.SECTION_CLASS, + "section-labels", theme_patch.rewrite_section_label, + b"Llihont/two/theme/NativeTheme;") + report["hook"] = theme_patch.SECTION_HOOK + return report + + +def patch_language_card(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Let NativeTheme drop the language row's own card inside TikTok settings.""" + report = patch_class(apk, tools, room, api, "classes9.dex", theme_patch.LANGUAGE_CLASS, + "language-card", theme_patch.rewrite_language_card, + b"Llihont/two/theme/NativeTheme;") + report["hook"] = theme_patch.CARD_HOOK + return report + + +def patch_main_activity(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Tell the bottom-bar code when TikTok's main screen comes to the front.""" + report = patch_class(apk, tools, room, api, bar_patch.DEX, bar_patch.OWNER, "main-activity", + bar_patch.rewrite_main_activity, b"Llihont/two/bar/BottomBar;") + report["hook"] = bar_patch.HOOK + return report + + +def patch_downloads(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Let saved videos come out of the download chain without the watermark.""" + marker = b"Llihont/two/media/Downloads;" + report = { + "source": patch_class(apk, tools, room, api, media_patch.DEX, media_patch.SOURCE_OWNER, + "download-source", media_patch.rewrite_source, marker), + "context": patch_class(apk, tools, room, api, media_patch.DEX, media_patch.CONTEXT_OWNER, + "download-context", media_patch.rewrite_context, marker), + } + report["hooks"] = media_patch.runtime_targets() + return report + + +def patch_feed(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Let lihont.two.feed.FeedFilter take advertisements and live streams out of the feed.""" + marker = b"Llihont/two/feed/FeedFilter;" + report = {} + for owner, rewrite in feed_patch.REWRITES.items(): + label = "feed-" + owner.rsplit("/", 1)[1].rstrip(";").lower() + report[owner] = patch_class(apk, tools, room, api, feed_patch.DEXES[owner], owner, label, + lambda text, rewrite=rewrite: rewrite(text)[0], marker) + report["hooks"] = feed_patch.runtime_targets() + return report + + +def patch_badges(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Let lihont.two.badge.Badges put badges next to names, and ship the two pictures TikTok You builds in.""" + marker = b"Llihont/two/badge/Badges;" + report = {} + for owner, rewrite in badge_patch.REWRITES.items(): + label = "badge-" + owner.rsplit("/", 1)[1].rstrip(";").lower() + report[owner] = patch_class(apk, tools, room, api, badge_patch.DEXES[owner], owner, label, + lambda text, rewrite=rewrite: rewrite(text)[0], marker) + shipped = [] + for name in ("supporter.png", "developer.png"): + source = Path("src/compat", name) + if not source.is_file(): + continue # third-party artwork that the repository does not carry: see src/compat/README.md + entry = "assets/lihont/compat/" + name + data = source.read_bytes() + if apk.has(entry): + apk.replace(entry, data) + else: + apk.add(entry, data) + shipped.append(name) + report["compat_pictures"] = shipped + report["hooks"] = badge_patch.runtime_targets() + return report + + +def merge_runtime(host_dex: bytes, runtime_dex: bytes, tools: Toolchain, room: Path, api: int) -> bytes: + """Add the runtime classes to an existing dex with dexlib2 class merging.""" + folder = room / "runtime-merge" + folder.mkdir(parents=True, exist_ok=True) + host = folder / "host.dex" + extra = folder / "extra.dex" + merged = folder / "merged.dex" + host.write_bytes(host_dex) + extra.write_bytes(runtime_dex) + subprocess.run( + ["java", "-Xmx1g", "-cp", + os.pathsep.join([str(room / "compiler"), tools.smali]), + "MergeClasses", str(host), str(extra), str(merged), str(api)], + check=True, + ) + result = merged.read_bytes() + if dexpatch.dex_format(host_dex) != dexpatch.dex_format(result): + raise ValueError("Runtime merge changed the DEX format") + host_classes = dexpatch.defined_methods(host_dex) + merged_classes = dexpatch.defined_methods(result) + if not host_classes.issubset(merged_classes): + raise ValueError("Runtime merge lost existing classes") + if not any(m.startswith("Llihont/two/LihonTRegion;->") for m in merged_classes): + raise ValueError("Runtime merge did not add LihonTRegion") + return result + + +def patch_feature_flags(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Route native profile banner and comment repost flag reads through LihonTFlags. + + Only direct SettingsManager-style reads whose String argument is a known + flag literal are rewritten; the original call stays in place and its result + goes to the LihonT switch for that flag. Verified per-dex against the + method-definition table. + """ + flags = {**profile_features.PROFILE_FLAGS, **profile_features.REPOST_FLAGS} + counts: dict = {} + changed: dict = {} + smali = dexpatch.Smali(tools.smali, api, jobs=2, heap="768m") + for name in sorted(apk.names()): + if not name.endswith(".dex"): + continue + data = apk.read(name) + if not profile_features.interesting(data, flags): + continue + owners = profile_features.literal_owners(data, flags) + if not owners: + continue + dex_room = room / ("flags-" + name[:-4]) + dex_room.mkdir(parents=True, exist_ok=True) + dex_input = dex_room / "in.dex" + dex_output = dex_room / "out.dex" + dex_input.write_bytes(data) + decoded = dex_room / "smali" + smali.disassemble(str(dex_input), str(decoded)) + originals = {} + hits = {} + try: + for path in profile_features.candidate_files(str(decoded), flags, owners): + raw = Path(path).read_bytes() + rewritten, file_hits = profile_features.rewrite_smali( + raw.decode("utf-8"), flags + ) + if not file_hits: + continue + originals[path] = raw + Path(path).write_bytes(rewritten.encode("utf-8")) + for key, count in file_hits.items(): + hits[key] = hits.get(key, 0) + count + if not hits: + print("%s: no direct flag reads" % name, flush=True) + continue + print("%s: %s" % (name, hits), flush=True) + smali.assemble(str(decoded), str(dex_output)) + patched = dex_output.read_bytes() + if dexpatch.dex_format(patched) != dexpatch.dex_format(data): + raise ValueError("DEX format changed in " + name) + if dexpatch.defined_methods(patched) != dexpatch.defined_methods(data): + raise ValueError("Class method definitions changed in " + name) + apk.replace(name, patched) + changed[name] = {"sha256": hashlib.sha256(patched).hexdigest(), "counts": hits} + for key, count in hits.items(): + counts[key] = counts.get(key, 0) + count + finally: + for path, raw in originals.items(): + Path(path).write_bytes(raw) + missing = sorted(flags.keys() - counts.keys()) + if missing: + raise ValueError("Could not verify native flag reads: " + ", ".join(missing)) + return {"counts": counts, "changed_dex": changed} + + +def build(source: str, output: str, workspace: str = "work/build") -> dict: + source_path, output_path, room = ( + Path(source).resolve(), Path(output).resolve(), Path(workspace).resolve() + ) + if source_path == output_path: + raise ValueError("Keep the source APK unchanged") + digest = hashlib.sha256(source_path.read_bytes()).hexdigest() + room.mkdir(parents=True, exist_ok=True) + tools = Toolchain("tools") + apk = Apk(str(source_path)) + report = { + "edition": "lihont", "mod_version": "2.0-test", + "source": source_path.name, "source_sha256": digest, + } + try: + xml = Axml.parse(apk.read("AndroidManifest.xml")) + from . import manifest as manifest_module + package = manifest_module.package_name(xml) + version = manifest_module.version_name(xml) + if package != "com.zhiliaoapp.musically" or version != SOURCE_VERSION: + raise ValueError("Expected TikTok " + SOURCE_VERSION + ", got " + version) + api = manifest_module.min_sdk(xml) + report["source_version"] = version + # Register the private settings screen in the host manifest. It is + # intentionally non-exported: only the in-app settings hook should + # be able to launch it. No intent-filter is added, so it cannot appear + # as a launcher entry or be reached through an external deep link. + from . import manifest as manifest_module + if not manifest_module.has_activity(xml, SETTINGS_ACTIVITY): + manifest_module.add_activity( + xml, SETTINGS_ACTIVITY, "LihonT", SETTINGS_THEME, exported=False + ) + apk.replace("AndroidManifest.xml", xml.build()) + report["manifest"] = {"settings_activity": SETTINGS_ACTIVITY, "exported": False} + else: + report["manifest"] = {"settings_activity": SETTINGS_ACTIVITY, "exported": False, "already_present": True} + # The launcher icons the settings screen offers: one disabled alias per catalog icon. + from . import app_icons + report["app_icons"] = app_icons.apply(apk, xml, app_icons.load_catalog(Path("src/icons"))) + report["changed_dex"] = patch_classes(apk, REGION_TARGETS, tools, room, api) + report["settings_entry"] = patch_settings_entry(apk, tools, room, api) + report["settings_icons"] = patch_md3_settings_icons(apk, tools, room, api) + report["native_theme"] = patch_native_theme(apk, tools, room, api) + report["native_rows"] = patch_settings_rows(apk, tools, room, api) + report["section_labels"] = patch_section_labels(apk, tools, room, api) + report["language_card"] = patch_language_card(apk, tools, room, api) + report["main_activity"] = patch_main_activity(apk, tools, room, api) + report["downloads"] = patch_downloads(apk, tools, room, api) + report["feed"] = patch_feed(apk, tools, room, api) + report["badges"] = patch_badges(apk, tools, room, api) + report["feature_flags"] = patch_feature_flags(apk, tools, room, api) + runtime_path = tools.compile_dex("src/lihont/two", str(room / "runtime"), api) + runtime = Path(runtime_path).read_bytes() + if b"Llihont/two/LihonTRegion;" not in runtime: + raise ValueError("Runtime class missing") + # classes.dex is a live TikTok dex here, never overwrite it: the small + # runtime rides along inside the patched classes35.dex instead. + apk.replace("classes35.dex", merge_runtime(apk.read("classes35.dex"), runtime, tools, room, api)) + report["runtime"] = {"dex": "classes35.dex", "sha256": hashlib.sha256(runtime).hexdigest()} + merged = dexpatch.defined_methods(apk.read("classes35.dex")) + if not any(m.startswith("Llihont/two/LihonTRegion;->") for m in merged): + raise ValueError("LihonTRegion missing after merge") + absent = [t for t in profile_features.runtime_targets() + theme_patch.runtime_targets() + + theme_patch.section_targets() + theme_patch.row_targets() + + theme_patch.card_targets() + bar_patch.runtime_targets() + + media_patch.runtime_targets() + feed_patch.runtime_targets() + + badge_patch.runtime_targets() if t not in merged] + if absent: + raise ValueError("Hooks have no runtime target: " + ", ".join(absent)) + report["feature_flags"]["runtime"] = profile_features.FLAGS_CLASS + report["region"] = { + "country": "by", "operator": "25701", "carrier": "A1 BY", + "expected_hooks": region.expected_hits(), + } + apk.drop_signature() + output_path.parent.mkdir(parents=True, exist_ok=True) + apk.write(str(output_path)) + finally: + apk.close() + tools.sign(str(output_path)) + report.update({ + "file": output_path.name, "bytes": output_path.stat().st_size, + "sha256": hashlib.sha256(output_path.read_bytes()).hexdigest(), + }) + Path(str(output_path) + ".json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return report + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("apk") + parser.add_argument("-o", "--out", required=True) + parser.add_argument("--work", default="work/build") + args = parser.parse_args() + print(json.dumps(build(args.apk, args.out, args.work), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..54f219f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "lihont-2-workspace" +version = "2.0.0" +requires-python = ">=3.10" +description = "LihonT 2.0 Android APK build workspace" + +[tool.ruff] +line-length = 100 diff --git a/run_adb_controller.bat b/run_adb_controller.bat new file mode 100644 index 0000000..51b5998 --- /dev/null +++ b/run_adb_controller.bat @@ -0,0 +1,6 @@ +@echo off +chcp 65001 >nul +title LihonT ADB Phone Controller +cd /d "%~dp0" +python tools\adb_controller.py +if errorlevel 1 pause diff --git a/scripts/deploy_site.py b/scripts/deploy_site.py new file mode 100644 index 0000000..fd169b3 --- /dev/null +++ b/scripts/deploy_site.py @@ -0,0 +1,193 @@ +"""Deploy the redesigned site to the Helsinki server (ssh host "hels") and roll it back. + + py scripts/deploy_site.py --dry-run show the plan, change nothing + py scripts/deploy_site.py back up the live files, upload, verify + py scripts/deploy_site.py --rollback restore the newest backup (or --stamp for another) + py scripts/deploy_site.py --portal swap the /lihont2 portal's server.py, restart it, roll back on failure + +Only the files listed in stage_site.PAYLOAD are touched. Before overwriting, each live file is +copied to /var/backups/lihont-site// (outside the web root, so it is never served). +Files are uploaded under a temporary name and renamed into place, so a visitor never sees a +half-written page. Downloads, logos and the old md3-theme.css are left alone. +""" + +from __future__ import annotations + +import argparse +import hashlib +import shlex +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import stage_site # noqa: E402 + +HOST = "hels" +WEB_ROOT = "/var/www/lihont" +BACKUPS = "/var/backups/lihont-site" +SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", HOST] + + +def remote(command: str, data: bytes | None = None) -> subprocess.CompletedProcess: + return subprocess.run(SSH + [command], input=data, capture_output=True, timeout=180) + + +def text(result: subprocess.CompletedProcess) -> str: + # ssh prints a post-quantum key exchange warning on stderr; it is not an error. + lines = [line for line in result.stderr.decode("utf-8", "replace").splitlines() + if line and not line.startswith("**")] + return "\n".join(lines) + + +def deploy(dry_run: bool) -> int: + stage_site.main() + files = [(stage_site.STAGE / target, target) for _source, target in stage_site.PAYLOAD] + stamp = time.strftime("%Y%m%d-%H%M%S") + backup = "%s/%s" % (BACKUPS, stamp) + print("Backup folder on the server: %s" % backup) + for local, target in files: + print(" %-26s %7d bytes" % (target, local.stat().st_size)) + if dry_run: + print("Dry run: nothing was changed.") + return 0 + + # 1. Back up every live file that exists. + script = "set -e; mkdir -p %s; " % shlex.quote(backup) + for _local, target in files: + live = "%s/%s" % (WEB_ROOT, target) + script += "if [ -f %s ]; then mkdir -p %s; cp -p %s %s; fi; " % ( + shlex.quote(live), shlex.quote("%s/%s" % (backup, Path(target).parent.as_posix())), + shlex.quote(live), shlex.quote("%s/%s" % (backup, target))) + result = remote(script) + if result.returncode: + print("Backup failed, nothing was uploaded:\n" + text(result)) + return 1 + + # 2. Upload under a temporary name, then rename into place. + for local, target in files: + live = "%s/%s" % (WEB_ROOT, target) + command = ("set -e; mkdir -p %s; cat > %s.new && chown root:root %s.new && chmod 644 %s.new " + "&& mv -f %s.new %s") % tuple( + [shlex.quote(str(Path(live).parent.as_posix()))] + [shlex.quote(live)] * 5) + result = remote(command, local.read_bytes()) + if result.returncode: + print("Upload of %s failed:\n%s\nRoll back with: py scripts/deploy_site.py --rollback" % (target, text(result))) + return 1 + print("uploaded %s" % target) + + # 3. Verify that the server holds exactly what was staged. + failed = 0 + for local, target in files: + digest = hashlib.sha256(local.read_bytes()).hexdigest() + result = remote("sha256sum %s" % shlex.quote("%s/%s" % (WEB_ROOT, target))) + live = result.stdout.decode().split()[0] if result.returncode == 0 and result.stdout else "?" + status = "ok" if live == digest else "MISMATCH" + failed += status != "ok" + print(" %-26s %s" % (target, status)) + if failed: + print("Some files differ. Roll back with: py scripts/deploy_site.py --rollback") + return 1 + print("Deployed. Backup: %s" % backup) + return 0 + + +PORTAL_SOURCE = Path(__file__).resolve().parents[1] / "src" / "web" / "server.py" +PORTAL_PATH = "/opt/lihont2/server.py" +PORTAL_BACKUPS = "/var/backups/lihont-portal" +PORTAL_SERVICE = "lihont2" +PORTAL_URL = "http://127.0.0.1:18750/lihont2/" + + +def portal_healthy() -> bool: + check = "sleep 2; systemctl is-active %s && curl -s -o /dev/null -w '%%{http_code}' %s" % (PORTAL_SERVICE, PORTAL_URL) + result = remote(check) + return result.returncode == 0 and result.stdout.decode().split()[-1:] == ["200"] + + +def restore_portal(stamp: str) -> int: + backup = "%s/%s/server.py" % (PORTAL_BACKUPS, stamp) + command = "set -e; cp -p %s %s.new && mv -f %s.new %s && systemctl restart %s" % ( + shlex.quote(backup), PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_SERVICE) + result = remote(command) + print(text(result) or "restored %s" % backup) + return result.returncode or (0 if portal_healthy() else 1) + + +def deploy_portal(dry_run: bool) -> int: + """Swap /opt/lihont2/server.py and restart the portal; sessions live in sqlite, so logins survive.""" + data = PORTAL_SOURCE.read_bytes() + stamp = time.strftime("%Y%m%d-%H%M%S") + print("server.py: %d bytes, backup %s/%s (root only)" % (len(data), PORTAL_BACKUPS, stamp)) + if dry_run: + print("Dry run: nothing was changed.") + return 0 + + # Upload next to the target and parse it with the server's own Python before anything is replaced. + result = remote("cat > %s.new && python3 -c \"import ast; ast.parse(open('%s.new').read())\"" % (PORTAL_PATH, PORTAL_PATH), data) + if result.returncode: + print("The new server.py does not parse on the server; nothing was replaced:\n" + text(result)) + remote("rm -f %s.new" % PORTAL_PATH) + return 1 + + backup = "%s/%s" % (PORTAL_BACKUPS, stamp) + command = ("set -e; umask 077; mkdir -p %s; cp -p %s %s/server.py; chown root:root %s.new; chmod 644 %s.new; " + "mv -f %s.new %s; systemctl restart %s") % ( + shlex.quote(backup), PORTAL_PATH, shlex.quote(backup), PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_SERVICE) + result = remote(command) + if result.returncode: + print("Swap failed:\n" + text(result)) + return 1 + if not portal_healthy(): + print("The portal is not answering with the new server.py; restoring the old one.") + return restore_portal(stamp) or 1 + print("Portal restarted and answering. Backup: %s" % backup) + return 0 + + +def rollback_portal(stamp: str | None) -> int: + result = remote("ls -1 %s | sort" % shlex.quote(PORTAL_BACKUPS)) + stamps = result.stdout.decode().split() + if result.returncode or not stamps: + print("No portal backup found on the server.") + return 1 + print("Portal backups: %s" % ", ".join(stamps)) + return restore_portal(stamp or stamps[-1]) + + +def rollback(stamp: str | None) -> int: + result = remote("ls -1 %s | sort" % shlex.quote(BACKUPS)) + stamps = result.stdout.decode().split() + if result.returncode or not stamps: + print("No backup found on the server.") + return 1 + print("Backups: %s" % ", ".join(stamps)) + stamp = stamp or stamps[-1] + if stamp not in stamps: + print("No such backup: %s" % stamp) + return 1 + backup = "%s/%s" % (BACKUPS, stamp) + print("Restoring %s" % backup) + command = ("set -e; cd %s; find . -type f | while read f; do " + "mkdir -p \"$(dirname %s/$f)\"; cp -p \"$f\" \"%s/$f.new\" && mv -f \"%s/$f.new\" \"%s/$f\"; done; echo restored") + command = command % (shlex.quote(backup), WEB_ROOT, WEB_ROOT, WEB_ROOT, WEB_ROOT) + result = remote(command) + print(result.stdout.decode().strip() or text(result)) + return result.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--rollback", action="store_true") + parser.add_argument("--stamp", help="backup to restore with --rollback (default: the newest)") + parser.add_argument("--portal", action="store_true", help="act on the /lihont2 portal (server.py) instead of the static site") + args = parser.parse_args() + if args.portal: + return rollback_portal(args.stamp) if args.rollback else deploy_portal(args.dry_run) + return rollback(args.stamp) if args.rollback else deploy(args.dry_run) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/make_lihont_promo.py b/scripts/make_lihont_promo.py new file mode 100644 index 0000000..d6b3738 --- /dev/null +++ b/scripts/make_lihont_promo.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LihonT 2 «Luna beta» promo video: 1080x1920, 30 fps, about 28 seconds, with a synthesised soundtrack. + +The screens are real screenshots of the mod on a phone, taken to work/promo/shots/ (the status bar and the +gesture strip are cut off). Frames are drawn with Pillow in worker processes and piped to ffmpeg. + +usage (from the repository root): + py -B scripts/make_lihont_promo.py # renders work/promo/lihont_promo.mp4 + py -B scripts/make_lihont_promo.py --still 9.5 # one frame at 9.5 s as work/promo/still_9.50.png +""" + +from __future__ import annotations + +import argparse +import math +import random +import shutil +import struct +import subprocess +import wave +from multiprocessing import Pool +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +W, H, FPS = 1080, 1920, 30 +ROOT = Path(__file__).resolve().parents[1] +SHOTS = ROOT / "work" / "promo" / "shots" +OUT = ROOT / "work" / "promo" / "lihont_promo.mp4" +AUDIO = ROOT / "work" / "promo" / "promo_audio.wav" +LOGO = ROOT / "src" / "brand" / "logo_note_flower_1024.png" +ICONS = [("tolstyj", "Эй толстый"), ("elmacho", "Эль мачо"), ("pushistik", "Пушистик"), ("original", "Оригинал")] + +# Scenes: name, start, end (seconds). Neighbouring scenes cross-fade over 0.4 s. +SCENES = [ + ("intro", 0.0, 3.6), + ("system", 3.6, 8.0), + ("comments", 8.0, 12.4), + ("badges", 12.4, 16.4), + ("settings", 16.4, 21.2), + ("icons", 21.2, 24.4), + ("outro", 24.4, 28.4), +] +DURATION = SCENES[-1][2] +FRAMES = int(round(DURATION * FPS)) +FADE = 0.2 + +BG = (20, 18, 24) +PRIMARY = (208, 188, 255) +ON = (230, 224, 233) +MUTED = (202, 196, 208) +CHIP = (74, 68, 88) +PINK = (239, 184, 200) +CORAL = (226, 108, 108) + +STATUS_CUT = 118 # status bar with the clock +NAV_CUT = 66 # gesture strip + +# Faces of people other than the author are blurred in the comment shots: (left, top, right, bottom) in shot pixels. +BLUR = { + "comments_beta5.png": [ + (121, 998, 261, 1138), (290, 1002, 605, 1047), + (238, 1366, 311, 1439), (341, 1360, 669, 1401), + (137, 1667, 248, 1776), (290, 1658, 620, 1700), + ], +} + +FONT_BOLD = "C:/Windows/Fonts/segoeuib.ttf" +FONT_REG = "C:/Windows/Fonts/segoeui.ttf" +_fonts: dict = {} + + +def font(size: int, bold: bool = True) -> ImageFont.FreeTypeFont: + key = (int(size), bold) + if key not in _fonts: + _fonts[key] = ImageFont.truetype(FONT_BOLD if bold else FONT_REG, int(size)) + return _fonts[key] + + +# ---------------------------------------------------------------- easing + +def clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float: + return max(lo, min(hi, x)) + + +def ease_out_cubic(x: float) -> float: + x = clamp(x) + return 1 - (1 - x) ** 3 + + +def ease_out_back(x: float) -> float: + x = clamp(x) + c1 = 1.70158 + c3 = c1 + 1 + return 1 + c3 * ((x - 1) ** 3) + c1 * ((x - 1) ** 2) + + +def ease_in_out(x: float) -> float: + x = clamp(x) + return -(math.cos(math.pi * x) - 1) / 2 + + +def pop(lt: float, delay: float, duration: float = 0.55) -> float: + return ease_out_back((lt - delay) / duration) + + +# ---------------------------------------------------------------- audio + +def generate_audio(path: Path, duration: float) -> None: + rate = 44100 + n = int(rate * duration) + bpm = 120 + beat = 60.0 / bpm + random.seed(7) + arp = [220.0, 261.63, 293.66, 329.63, 392.0, 440.0, 523.25, 587.33] + risers = [s[1] - 0.8 for s in SCENES[1:]] + out = [] + for i in range(n): + t = i / rate + idx = int(t / beat) + pos = (t % beat) / beat + kick = 0.0 + if pos < 0.3: + freq = 160.0 * math.exp(-pos * 20) + 45.0 + kick = math.sin(2 * math.pi * freq * pos) * math.exp(-pos * 12) * 0.85 + clap = 0.0 + if idx % 2 == 1 and pos < 0.22: + clap = ((random.random() * 2 - 1) * 0.7 + math.sin(2 * math.pi * 210 * pos) * 0.3) * math.exp(-pos * 16) * 0.55 + hat = 0.0 + sp = ((t * 4) % beat) / beat + if sp < 0.06: + hat = (random.random() * 2 - 1) * math.exp(-sp * 48) * (0.30 if int(t * 4 / beat) % 2 else 0.15) + root = [55.0, 55.0, 65.4, 73.4][(idx // 4) % 4] + side = min(1.0, pos * 3.5) if pos < 0.3 else 1.0 + bass = (math.sin(2 * math.pi * root * t) + 0.3 * math.sin(4 * math.pi * root * t)) * side * 0.38 + note = arp[int(t * 8) % len(arp)] + pluck = math.sin(2 * math.pi * note * t) * math.exp(-(t % 0.125) * 22) * 0.2 + rise = 0.0 + for cut in risers: + if 0 <= cut + 0.8 - t < 0.8 and t >= cut: + p = (t - cut) / 0.8 + rise += (math.sin(2 * math.pi * (250 + p * p * 3000) * t) + random.random() * 0.3) * p * 0.28 + fade = 1.0 + if t < 0.4: + fade = t / 0.4 + if t > duration - 1.4: + fade = max(0.0, (duration - t) / 1.4) + mix = max(-0.95, min(0.95, (kick + clap + hat + bass + pluck + rise) * fade)) + out.append(int(mix * 32767)) + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(rate) + wf.writeframes(struct.pack("<%dh" % len(out), *out)) + + +# ---------------------------------------------------------------- assets + +ASSETS: dict = {} + + +def rounded(img: Image.Image, radius: int) -> Image.Image: + img = img.convert("RGBA") + mask = Image.new("L", img.size, 0) + ImageDraw.Draw(mask).rounded_rectangle([0, 0, img.width - 1, img.height - 1], radius=radius, fill=255) + img.putalpha(mask) + return img + + +def load_shot(name: str) -> Image.Image: + img = Image.open(SHOTS / name).convert("RGB") + for box in BLUR.get(name, []): + region = img.crop(box).filter(ImageFilter.GaussianBlur(22)) + img.paste(region, box[:2]) + return img.crop((0, STATUS_CUT, img.width, img.height - NAV_CUT)) + + +BEZEL, PAD = 14, 40 + + +def phone_shell(screen_w: int, screen_h: int) -> Image.Image: + body_w, body_h = screen_w + 2 * BEZEL, screen_h + 2 * BEZEL + canvas = Image.new("RGBA", (body_w + 2 * PAD, body_h + 2 * PAD), (0, 0, 0, 0)) + shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + ImageDraw.Draw(shadow).rounded_rectangle([PAD, PAD + 18, PAD + body_w, PAD + 18 + body_h], radius=56, fill=(0, 0, 0, 190)) + canvas.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(20))) + ImageDraw.Draw(canvas).rounded_rectangle([PAD, PAD, PAD + body_w, PAD + body_h], radius=56, + fill=(30, 29, 34, 255), outline=(82, 78, 90, 255), width=3) + return canvas + + +def mount(shell: Image.Image, screen: Image.Image) -> Image.Image: + phone = shell.copy() + phone.alpha_composite(rounded(screen, 40), (PAD + BEZEL, PAD + BEZEL)) + return phone + + +def screen_for(shot: Image.Image, screen_w: int = 560) -> Image.Image: + return shot.resize((screen_w, int(screen_w * shot.height / shot.width)), Image.Resampling.LANCZOS) + + +def build_phone(shot: Image.Image, screen_w: int = 560) -> Image.Image: + screen = screen_for(shot, screen_w) + return mount(phone_shell(screen.width, screen.height), screen) + + +def blob(radius: int, color: tuple) -> Image.Image: + layer = Image.new("RGBA", (radius * 4, radius * 4), (0, 0, 0, 0)) + ImageDraw.Draw(layer).ellipse([radius, radius, radius * 3, radius * 3], fill=color) + return layer.filter(ImageFilter.GaussianBlur(radius * 0.55)) + + +def flower(size: int, color: tuple) -> Image.Image: + layer = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(layer) + c, r = size / 2, size * 0.30 + pr = size * 0.17 + d.ellipse([c - r, c - r, c + r, c + r], fill=color) + for k in range(8): + a = k * math.pi / 4 + px, py = c + math.cos(a) * size * 0.31, c + math.sin(a) * size * 0.31 + d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color) + return layer + + +def init_assets() -> None: + if ASSETS: + return + ASSETS["bg"] = Image.new("RGBA", (W, H), BG + (255,)) + ASSETS["blobs"] = [blob(360, (208, 188, 255, 62)), blob(300, (239, 184, 200, 52)), blob(260, (120, 200, 190, 36))] + ASSETS["flower"] = flower(70, (255, 255, 255, 26)) + rnd = random.Random(3) + ASSETS["motes"] = [(rnd.random() * W, rnd.random() * H, 0.4 + rnd.random() * 0.9, rnd.random() * 6.28) for _ in range(16)] + ASSETS["logo"] = Image.open(LOGO).convert("RGBA") + shots = { + "comments": "comments_beta5.png", "menu": "menu.png", "native": "settings_native.png", + "mod1": "mod_settings1.png", "mod2": "mod_settings2.png", "profile": "profile.png", + } + cache = {key: load_shot(name) for key, name in shots.items()} + ASSETS["shots"] = cache + ASSETS["phones"] = {key: build_phone(cache[key]) for key in ("comments", "menu", "native")} + ASSETS["mod_screens"] = [screen_for(cache["mod1"]), screen_for(cache["mod2"])] + ASSETS["mod_shell"] = phone_shell(*ASSETS["mod_screens"][0].size) + ASSETS["icons"] = {slug: rounded(Image.open(ROOT / "src" / "icons" / (slug + ".png")).convert("RGB").resize((320, 320), Image.Resampling.LANCZOS), 72) + for slug, _ in ICONS} + + +# ---------------------------------------------------------------- drawing + +def draw_text(layer: Image.Image, xy, s: str, size: int, fill, bold: bool = True, anchor: str = "mm") -> None: + ImageDraw.Draw(layer, "RGBA").text(xy, s, font=font(size, bold), fill=fill, anchor=anchor) + + +def chip(layer: Image.Image, cx: float, cy: float, s: str, k: float = 1.0, size: int = 42, + fill=CHIP, fg=ON) -> None: + if k <= 0.02: + return + f = font(max(8, size * k)) + d = ImageDraw.Draw(layer, "RGBA") + box = d.textbbox((0, 0), s, font=f, anchor="lt") + w, h = box[2] + 70 * k, box[3] + 44 * k + cx = max(w / 2 + 24, min(W - w / 2 - 24, cx)) + d.rounded_rectangle([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], radius=h / 2, fill=fill + (255,)) + d.text((cx, cy + 1), s, font=f, fill=fg + (255,), anchor="mm") + + +def headline(layer: Image.Image, lt: float, title: str, sub: str, y: int = 200) -> None: + a = ease_out_cubic(lt / 0.5) + b = ease_out_cubic((lt - 0.15) / 0.5) + draw_text(layer, (W / 2, y + (1 - a) * 50), title, 88, ON + (int(255 * a),)) + draw_text(layer, (W / 2, y + 100 + (1 - b) * 40), sub, 44, PRIMARY + (int(255 * b),), bold=False) + + +def place(layer: Image.Image, img: Image.Image, cx: float, cy: float, scale: float = 1.0, angle: float = 0.0) -> None: + if scale != 1.0: + img = img.resize((max(1, int(img.width * scale)), max(1, int(img.height * scale))), Image.Resampling.BILINEAR) + if angle: + img = img.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC) + layer.alpha_composite(img, (int(cx - img.width / 2), int(cy - img.height / 2))) + + +# ---------------------------------------------------------------- scenes + +def scene_intro(layer: Image.Image, lt: float) -> None: + k = ease_out_back(lt / 0.9) + pulse = 1 + 0.025 * math.sin(lt * 4) + logo = ASSETS["logo"] + glow = ASSETS["blobs"][1] + place(layer, glow, W / 2, 760, 1.5 * k) + place(layer, logo, W / 2, 760, 0.52 * k * pulse, math.sin(lt * 2) * 3) + a = ease_out_cubic((lt - 0.7) / 0.5) + draw_text(layer, (W / 2, 1180 + (1 - a) * 50), "LihonT 2", 150, ON + (int(255 * a),)) + b = ease_out_cubic((lt - 1.0) / 0.5) + draw_text(layer, (W / 2, 1320 + (1 - b) * 40), "Luna · бета", 64, PRIMARY + (int(255 * b),)) + c = ease_out_cubic((lt - 1.4) / 0.5) + draw_text(layer, (W / 2, 1430 + (1 - c) * 30), "Мод для TikTok в стиле Material 3", 44, MUTED + (int(255 * c),), bold=False) + + +def scene_system(layer: Image.Image, lt: float) -> None: + headline(layer, lt, "Всё в стиле Material 3", "Меню, настройки, нижняя панель") + left = ease_out_cubic((lt - 0.2) / 0.8) + right = ease_out_cubic((lt - 0.45) / 0.8) + bob = math.sin(lt * 2.2) * 8 + place(layer, ASSETS["phones"]["menu"], 305, 1260 + (1 - left) * 900 + bob, 0.8, 6) + place(layer, ASSETS["phones"]["native"], 785, 1330 + (1 - right) * 900 - bob, 0.8, -5) + chip(layer, 250, 640, "Меню профиля", pop(lt, 1.2)) + chip(layer, 830, 640, "Настройки TikTok", pop(lt, 1.5)) + + +def scene_comments(layer: Image.Image, lt: float) -> None: + headline(layer, lt, "Новые комментарии", "Карточки, ручка шторки, поле-пилюля") + rise = ease_out_cubic((lt - 0.15) / 0.9) + tilt = (1 - ease_out_cubic(lt / 1.2)) * -9 + place(layer, ASSETS["phones"]["comments"], W / 2, 1200 + (1 - rise) * 900 + math.sin(lt * 2) * 6, 1.0, tilt) + chip(layer, 175, 700, "Карточки", pop(lt, 0.9)) + chip(layer, 905, 590, "Ручка шторки", pop(lt, 1.3)) + chip(layer, 850, 1780, "Поле-пилюля", pop(lt, 1.7)) + + +def scene_badges(layer: Image.Image, lt: float) -> None: + headline(layer, lt, "Значки у ников", "TikTok You, MargyT и нишевый мод") + profile = ASSETS["shots"]["profile"] + header = profile.crop((0, 0, 1080, 900)) + card = rounded(header.resize((940, 783), Image.Resampling.LANCZOS), 56) + a = ease_out_cubic((lt - 0.2) / 0.8) + place(layer, card, W / 2, 880 + (1 - a) * 700, 1.0) + # the badges beside the nickname, enlarged + zoom = rounded(profile.crop((20, 166, 700, 270)).resize((880, 135), Image.Resampling.LANCZOS), 40) + z = pop(lt, 1.1, 0.6) + ring = Image.new("RGBA", (880 + 16, 135 + 16), PRIMARY + (255,)) + ring = rounded(ring, 48) + place(layer, ring, W / 2, 1500, z) + place(layer, zoom, W / 2, 1500, z) + chip(layer, W / 2, 1700, "Значок рядом с ником", pop(lt, 1.6), size=40, fill=PRIMARY, fg=BG) + + +def scene_settings(layer: Image.Image, lt: float) -> None: + headline(layer, lt, "Настройки мода", "Включай только нужное") + push = ease_in_out((lt - 1.9) / 0.7) + first, second = ASSETS["mod_screens"] + screen = Image.new("RGB", first.size) + shift = int(push * first.width) + screen.paste(first, (-shift, 0)) + screen.paste(second, (first.width - shift, 0)) + phone = mount(ASSETS["mod_shell"], screen) + rise = ease_out_cubic((lt - 0.15) / 0.9) + place(layer, phone, W / 2, 1200 + (1 - rise) * 900, 1.0) + gone = 1.0 - clamp((lt - 1.9) / 0.4) + chip(layer, 170, 760, "Без водяного знака", pop(lt, 0.9) * gone, size=36) + chip(layer, 905, 960, "Убрать рекламу", pop(lt, 1.2) * gone, size=36) + chip(layer, 185, 1260, "Убрать эфиры", pop(lt, 1.5) * gone, size=36) + chip(layer, 890, 1500, "Скрыть «Магазин»", pop(lt, 2.4), size=36) + + +def scene_icons(layer: Image.Image, lt: float) -> None: + headline(layer, lt, "Иконки приложения", "Выбери свою или добавь свою") + spots = [(350, 800), (730, 800), (350, 1220), (730, 1220)] + for n, ((slug, label), (x, y)) in enumerate(zip(ICONS, spots)): + k = pop(lt, 0.3 + n * 0.22) + wob = math.sin(lt * 2.5 + n) * 3 + place(layer, ASSETS["icons"][slug], x, y + wob, k) + draw_text(layer, (x, y + 210), label, 40, ON + (int(255 * clamp(k)),)) + + +def scene_outro(layer: Image.Image, lt: float) -> None: + k = ease_out_back(lt / 0.8) + pulse = 1 + 0.03 * math.sin(lt * 4) + place(layer, ASSETS["blobs"][0], W / 2, 640, 1.3 * k) + place(layer, ASSETS["logo"], W / 2, 640, 0.40 * k * pulse, math.sin(lt * 2) * 3) + a = ease_out_cubic((lt - 0.4) / 0.5) + draw_text(layer, (W / 2, 960 + (1 - a) * 40), "Скачай бету", 110, ON + (int(255 * a),)) + b = ease_out_cubic((lt - 0.7) / 0.5) + draw_text(layer, (W / 2, 1075 + (1 - b) * 30), "LihonT 2 · Luna", 56, PRIMARY + (int(255 * b),)) + chip(layer, W / 2, 1290, "git.lihont.space", pop(lt, 1.0), size=54, fill=PRIMARY, fg=BG) + chip(layer, W / 2, 1430, "Telegram · @lihont", pop(lt, 1.3), size=54) + c = ease_out_cubic((lt - 1.8) / 0.5) + draw_text(layer, (W / 2, 1700), "Открытый код · лицензия EVHNMKHU", 38, MUTED + (int(255 * c),), bold=False) + + +SCENE_DRAW = { + "intro": scene_intro, "system": scene_system, "comments": scene_comments, "badges": scene_badges, + "settings": scene_settings, "icons": scene_icons, "outro": scene_outro, +} + + +def render_frame(index: int) -> bytes: + return frame_at(index / FPS).convert("RGB").tobytes() + + +def frame_at(t: float) -> Image.Image: + init_assets() + img = ASSETS["bg"].copy() + for n, blob_img in enumerate(ASSETS["blobs"]): + x = W * (0.2 + 0.6 * (0.5 + 0.5 * math.sin(t * (0.5 + n * 0.2) + n * 2))) + y = H * (0.25 + 0.5 * (0.5 + 0.5 * math.cos(t * (0.4 + n * 0.15) + n))) + img.alpha_composite(blob_img, (int(x - blob_img.width / 2), int(y - blob_img.height / 2))) + for x, y, speed, phase in ASSETS["motes"]: + yy = (y - t * 40 * speed) % (H + 100) - 50 + img.alpha_composite(ASSETS["flower"], (int(x + math.sin(t * speed + phase) * 30), int(yy))) + for name, start, end in SCENES: + alpha = clamp((t - (start - FADE)) / (2 * FADE)) * clamp(((end + FADE) - t) / (2 * FADE)) + if name == "intro": + alpha = clamp(((end + FADE) - t) / (2 * FADE)) + if name == "outro": + alpha = clamp((t - (start - FADE)) / (2 * FADE)) + if alpha <= 0.0: + continue + layer = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + SCENE_DRAW[name](layer, max(0.0, t - start)) + if alpha < 1.0: + layer.putalpha(layer.getchannel("A").point(lambda v, a=alpha: int(v * a))) + img.alpha_composite(layer) + return img + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--still", type=float, help="render one frame at this time (seconds) and stop") + parser.add_argument("--out", default=str(OUT)) + parser.add_argument("--jobs", type=int, default=6) + args = parser.parse_args() + OUT.parent.mkdir(parents=True, exist_ok=True) + + if args.still is not None: + path = OUT.parent / ("still_%.2f.png" % args.still) + frame_at(args.still).convert("RGB").save(path) + print(path) + return + + generate_audio(AUDIO, DURATION) + ffmpeg = shutil.which("ffmpeg") or "ffmpeg" + command = [ffmpeg, "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", "%dx%d" % (W, H), + "-r", str(FPS), "-i", "-", "-i", str(AUDIO), "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k", "-shortest", args.out] + proc = subprocess.Popen(command, stdin=subprocess.PIPE) + with Pool(args.jobs, initializer=init_assets) as pool: + for n, data in enumerate(pool.imap(render_frame, range(FRAMES), chunksize=4)): + proc.stdin.write(data) + if n % 60 == 0: + print("frame %d/%d" % (n, FRAMES), flush=True) + proc.stdin.close() + proc.wait() + print("done:", args.out) + + +if __name__ == "__main__": + main() diff --git a/scripts/quick_runtime.py b/scripts/quick_runtime.py new file mode 100644 index 0000000..0f6ea68 --- /dev/null +++ b/scripts/quick_runtime.py @@ -0,0 +1,80 @@ +"""Quick rebuild after a change in the runtime Java only (src/lihont/two). + +Takes an APK that py -m lihont.upgrade built earlier, swaps the mod's runtime classes inside its +classes35.dex for a fresh compile of src/lihont/two, and signs the result. The smali patches of TikTok's +own classes are not touched, so this is only right while lihont/*_patch.py and lihont/upgrade.py are +unchanged since that APK was built; after any such change run the full build. + +usage (from the repository root): + py -B scripts/quick_runtime.py ComplectAPK/lihont-2.0-luna-beta5.apk -o ComplectAPK/lihont-2.0-luna-beta6.apk +""" + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +os.chdir(ROOT) + +from lihont import dexpatch, manifest as manifest_module # noqa: E402 +from lihont.apkzip import Apk # noqa: E402 +from lihont.axml import Axml # noqa: E402 +from lihont.toolchain import Toolchain # noqa: E402 + +RUNTIME = "Llihont/two/" +DEX = "classes35.dex" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("built", help="an APK built by lihont.upgrade") + parser.add_argument("-o", "--out", required=True) + parser.add_argument("--work", default="work/quick") + args = parser.parse_args() + + source, output, room = Path(args.built).resolve(), Path(args.out).resolve(), Path(args.work).resolve() + if source == output: + raise SystemExit("Write to a new file: the built APK stays as it is") + room.mkdir(parents=True, exist_ok=True) + tools = Toolchain("tools") + apk = Apk(str(source)) + try: + api = manifest_module.min_sdk(Axml.parse(apk.read("AndroidManifest.xml"))) + runtime = Path(tools.compile_dex("src/lihont/two", str(room / "runtime"), api)).read_bytes() + + compiler = room / "compiler" + compiler.mkdir(parents=True, exist_ok=True) + subprocess.run(["javac", "--release", "17", "-cp", tools.smali, "-d", str(compiler), + "src/RefreshClasses.java"], check=True) + host_dex = apk.read(DEX) + host, fresh, merged = room / "host.dex", room / "fresh.dex", room / "merged.dex" + host.write_bytes(host_dex) + fresh.write_bytes(runtime) + subprocess.run(["java", "-Xmx1g", "-cp", os.pathsep.join([str(compiler), tools.smali]), + "RefreshClasses", str(host), str(fresh), str(merged), str(api)], check=True) + result = merged.read_bytes() + + if dexpatch.dex_format(host_dex) != dexpatch.dex_format(result): + raise SystemExit("The refresh changed the DEX format") + before = {m for m in dexpatch.defined_methods(host_dex) if not m.startswith(RUNTIME)} + after = dexpatch.defined_methods(result) + if not before.issubset(after): + raise SystemExit("The refresh lost classes that are not part of the runtime") + if not any(m.startswith("Llihont/two/LihonTRegion;->") for m in after): + raise SystemExit("LihonTRegion is missing after the refresh") + + apk.replace(DEX, result) + apk.drop_signature() + output.parent.mkdir(parents=True, exist_ok=True) + apk.write(str(output)) + finally: + apk.close() + tools.sign(str(output)) + print("%s %d bytes" % (output.name, output.stat().st_size)) + + +if __name__ == "__main__": + main() diff --git a/scripts/stage_site.py b/scripts/stage_site.py new file mode 100644 index 0000000..aefe266 --- /dev/null +++ b/scripts/stage_site.py @@ -0,0 +1,47 @@ +"""Assemble the website payload in work/site-stage, laid out as on the server. + + py scripts/stage_site.py + +The server serves /var/www/lihont. Pages that live in folders (/lihonb, /members, /pay) are +index.html files there. Only files that belong to the site redesign are staged: the pages and +src/web/assets/site/{md3.css,md3.js,icons.svg}. Existing downloads, logos and the old +md3-theme.css (used by the member portal) are not touched. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +STAGE = ROOT / "work" / "site-stage" + +# (source in the repository, path on the server relative to the web root) +PAYLOAD = [ + ("src/web/index.html", "index.html"), + ("src/web/lihonb.html", "lihonb.html"), + ("src/web/lihonb.html", "lihonb/index.html"), + ("src/web/members_index.html", "members/index.html"), + ("src/web/pay_index.html", "pay/index.html"), + ("src/web/assets/site/md3.css", "assets/site/md3.css"), + ("src/web/assets/site/md3.js", "assets/site/md3.js"), + ("src/web/assets/site/icons.svg", "assets/site/icons.svg"), +] + + +def main() -> int: + missing = [source for source, _ in PAYLOAD if not (ROOT / source).is_file()] + if missing: + print("Missing sources:\n " + "\n ".join(missing)) + return 1 + for source, target in PAYLOAD: + destination = STAGE / target + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / source, destination) + print("Staged %d files in %s" % (len(PAYLOAD), STAGE)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/members/server.py b/services/members/server.py new file mode 100644 index 0000000..1a5e7d6 --- /dev/null +++ b/services/members/server.py @@ -0,0 +1,268 @@ +"""Self-reported LihonT presence registry. Standard library only; bind to localhost. + +This is a community marker, not proof of TikTok account ownership or official +verification. A modified client can claim another UID. No TikTok credentials +are accepted. Installation secrets permit withdrawing only that installation's +contribution. Public lookup responses expose only requested membership bits. +""" +from __future__ import annotations +import argparse +import base64 +import hmac +import secrets +import urllib.request +import hashlib +import json +import re +import sqlite3 +import threading +import time +from collections import defaultdict, deque +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +UID = re.compile(r"[1-9][0-9]{4,24}\Z") +INSTALL = re.compile(r"[a-f0-9]{64}\Z") +LIFETIME = 30 * 86400 +UNIQUE = re.compile(r"[A-Za-z0-9_.]{2,24}\Z") +# The niche-mod badge can be claimed until this moment (Moscow time). +CLAIM_DEADLINE = 1794735240 # 2026-11-15 12:34:00 +03:00 + + +def fetch_profile(unique_id): + """Public TikTok profile page of @unique_id as (account id, bio), or None. Anonymous GET only.""" + request = urllib.request.Request( + "https://www.tiktok.com/@" + unique_id, + headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"}) + with urllib.request.urlopen(request, timeout=20) as response: + page = response.read(3_000_000).decode("utf-8", "replace") + match = re.search(r'', page, re.S) + if not match: + return None + user = (json.loads(match.group(1)).get("__DEFAULT_SCOPE__", {}) + .get("webapp.user-detail", {}).get("userInfo", {}).get("user", {})) + if not user.get("id"): + return None + return str(user["id"]), str(user.get("signature") or "") + + +def valid_uid(value): + return isinstance(value, str) and UID.fullmatch(value) is not None + + +class Registry: + def __init__(self, path, clock=time.time, fetcher=fetch_profile): + self.path, self.clock, self.fetcher = str(path), clock, fetcher + with self.connect() as db: + db.execute("PRAGMA journal_mode=WAL") + db.execute("CREATE TABLE IF NOT EXISTS presence (installation TEXT NOT NULL, uid TEXT NOT NULL, last_seen INTEGER NOT NULL, frame_color TEXT NOT NULL DEFAULT 'green', PRIMARY KEY(installation, uid))") + columns = {row[1] for row in db.execute("PRAGMA table_info(presence)")} + if "frame_color" not in columns: + db.execute("ALTER TABLE presence ADD COLUMN frame_color TEXT NOT NULL DEFAULT 'green'") + db.execute("CREATE INDEX IF NOT EXISTS presence_uid ON presence(uid, last_seen)") + db.execute("CREATE TABLE IF NOT EXISTS blocked (uid TEXT PRIMARY KEY)") + db.execute("CREATE TABLE IF NOT EXISTS supporters (uid TEXT PRIMARY KEY, granted_at INTEGER NOT NULL)") + db.execute("CREATE TABLE IF NOT EXISTS niche (uid TEXT PRIMARY KEY, unique_id TEXT NOT NULL, granted_at INTEGER NOT NULL)") + db.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + db.execute("INSERT OR IGNORE INTO meta(key,value) VALUES('code_secret',?)", (secrets.token_hex(32),)) + + @contextmanager + def connect(self): + db = sqlite3.connect(self.path, timeout=10) + try: + with db: + yield db + finally: + db.close() + + def presence(self, uid, installation, withdraw=False, frame_color="green"): + if not valid_uid(uid) or not isinstance(installation, str) or not INSTALL.fullmatch(installation): + raise ValueError("Invalid account ID or installation code") + digest = hashlib.sha256(installation.encode("ascii")).hexdigest() + if frame_color not in ("none", "green", "red", "yellow"): + frame_color = "green" + now = int(self.clock()) + with self.connect() as db: + db.execute("DELETE FROM presence WHERE last_seen < ?", (now - LIFETIME,)) + if withdraw: + db.execute("DELETE FROM presence WHERE installation=? AND uid=?", (digest, uid)) + else: + if db.execute("SELECT 1 FROM blocked WHERE uid=?", (uid,)).fetchone(): + raise ValueError("Account excluded from the registry") + existing = db.execute("SELECT 1 FROM presence WHERE installation=? AND uid=?", (digest, uid)).fetchone() + if not existing and db.execute("SELECT COUNT(*) FROM presence WHERE installation=?", (digest,)).fetchone()[0] >= 20: + raise ValueError("Too many accounts for one installation") + db.execute("INSERT INTO presence(installation,uid,last_seen,frame_color) VALUES(?,?,?,?) ON CONFLICT(installation,uid) DO UPDATE SET last_seen=excluded.last_seen,frame_color=excluded.frame_color", (digest, uid, now, frame_color)) + return {"ok": True} + + def code(self, uid): + """The proof string for an account: it only ever goes into that account's own bio.""" + if not valid_uid(uid): + raise ValueError("Invalid account ID") + with self.connect() as db: + secret = db.execute("SELECT value FROM meta WHERE key='code_secret'").fetchone()[0] + digest = hmac.new(bytes.fromhex(secret), uid.encode("ascii"), "sha256").digest() + return "LT-" + base64.b32encode(digest)[:8].decode("ascii") + + def claim(self, uid, unique_id): + """Gives the niche-mod badge to `uid` when its public bio holds the account's code.""" + if not valid_uid(uid) or not isinstance(unique_id, str) or not UNIQUE.fullmatch(unique_id): + raise ValueError("Invalid account") + with self.connect() as db: + if db.execute("SELECT 1 FROM niche WHERE uid=?", (uid,)).fetchone(): + return {"ok": True, "granted": True} + if db.execute("SELECT 1 FROM blocked WHERE uid=?", (uid,)).fetchone(): + raise ValueError("Account excluded") + if self.clock() > CLAIM_DEADLINE: + raise ValueError("The badge can no longer be claimed") + try: + profile = self.fetcher(unique_id) + except Exception: + raise ValueError("Could not read the TikTok profile, try again later") + if profile is None: + raise ValueError("Profile not found") + found_uid, bio = profile + if found_uid != uid: + raise ValueError("That profile belongs to another account") + if self.code(uid) not in bio: + return {"ok": True, "granted": False, "reason": "code_missing"} + with self.connect() as db: + db.execute("INSERT OR IGNORE INTO niche(uid,unique_id,granted_at) VALUES(?,?,?)", + (uid, unique_id, int(self.clock()))) + return {"ok": True, "granted": True} + + def lookup(self, uids): + if not isinstance(uids, list) or len(uids) > 100 or any(not valid_uid(uid) for uid in uids): + raise ValueError("Request at most 100 account IDs") + uids = list(dict.fromkeys(uids)) + if not uids: + return {"members": {}} + placeholders = ",".join("?" for _ in uids) + with self.connect() as db: + rows = db.execute("SELECT uid,frame_color FROM presence WHERE uid IN (" + placeholders + ") AND last_seen >= ? AND uid NOT IN (SELECT uid FROM blocked)", uids + [int(self.clock()) - LIFETIME]).fetchall() + niche = {row[0] for row in db.execute( + "SELECT uid FROM niche WHERE uid IN (" + placeholders + ") AND uid NOT IN (SELECT uid FROM blocked)", uids + ).fetchall()} + supporters = {row[0] for row in db.execute( + "SELECT uid FROM supporters WHERE uid IN (" + placeholders + ")", uids + ).fetchall()} + colors = {} + for uid, color in rows: + colors.setdefault(uid, color if color in ("none", "green", "red", "yellow") else "green") + return {"members": {uid: uid in colors for uid in uids}, "frames": colors, + "supporters": {uid: uid in supporters for uid in uids}, + "niche": {uid: uid in niche for uid in uids}, "ttl": 300} + + def set_supporter(self, uid, enabled=True): + """Admin-only when invoked from the server CLI; never exposed over HTTP.""" + if not valid_uid(uid): + raise ValueError("Invalid account ID") + with self.connect() as db: + if enabled: + db.execute("INSERT OR IGNORE INTO supporters(uid,granted_at) VALUES(?,?)", + (uid, int(self.clock()))) + else: + db.execute("DELETE FROM supporters WHERE uid=?", (uid,)) + return {"uid": uid, "supporter": bool(enabled)} + + +class Limits: + def __init__(self): + self.events = defaultdict(deque) + self.lock = threading.Lock() + + def allow(self, ip, operation): + key = ip, operation + window, count = (60, 120) if operation == "lookup" else (3600, 10) if operation == "claim" else (3600, 40) + now = time.monotonic() + with self.lock: + if len(self.events) > 10000: + for old in list(self.events): + if not self.events[old] or self.events[old][-1] < now - 3600: + del self.events[old] + if len(self.events) > 10000 and key not in self.events: + return False + queue = self.events[key] + while queue and queue[0] < now - window: + queue.popleft() + if len(queue) >= count: + return False + queue.append(now) + return True + + +class Handler(BaseHTTPRequestHandler): + server_version = "LihonT-Members/1" + + def log_message(self, fmt, *args): + # Do not log payloads, installation codes or account IDs. + pass + + def send_json(self, status, payload): + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + self.send_json(200 if self.path == "/members/v1/health" else 404, + {"ok": True} if self.path == "/members/v1/health" else {"error": "Not found"}) + + def do_POST(self): + operation = self.path.removeprefix("/members/v1/") + if self.path != "/members/v1/" + operation or operation not in ("presence", "lookup", "withdraw", "code", "claim"): + return self.send_json(404, {"error": "Not found"}) + # This server binds to localhost; nginx overwrites this header. + ip = self.headers.get("X-Real-IP", self.client_address[0]) + if not self.server.limits.allow(ip, operation if operation in ("lookup", "claim") else "presence"): + return self.send_json(429, {"error": "Try again later"}) + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= 8192: + return self.send_json(413, {"error": "Invalid payload size"}) + self.connection.settimeout(10) + payload = json.loads(self.rfile.read(length)) + if not isinstance(payload, dict): + raise ValueError("Expected an object") + if operation == "lookup": + result = self.server.registry.lookup(payload.get("uids")) + elif operation == "code": + result = {"code": self.server.registry.code(payload.get("uid")), + "deadline": CLAIM_DEADLINE, "open": time.time() <= CLAIM_DEADLINE} + elif operation == "claim": + result = self.server.registry.claim(payload.get("uid"), payload.get("unique_id")) + else: + result = self.server.registry.presence(payload.get("uid"), payload.get("installation"), operation == "withdraw", payload.get("frame_color", "green")) + self.send_json(200, result) + except (ValueError, TypeError, UnicodeError) as exc: + self.send_json(400, {"error": str(exc)}) + except Exception: + self.send_json(503, {"error": "Service temporarily unavailable"}) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--database", required=True) + parser.add_argument("--port", type=int, default=18746) + parser.add_argument("--grant-supporter", metavar="UID", help="Grant the LihonT supporter badge locally") + parser.add_argument("--revoke-supporter", metavar="UID", help="Revoke the LihonT supporter badge locally") + args = parser.parse_args() + Path(args.database).resolve().parent.mkdir(parents=True, exist_ok=True) + if args.grant_supporter or args.revoke_supporter: + registry = Registry(args.database) + print(json.dumps(registry.set_supporter(args.grant_supporter or args.revoke_supporter, + enabled=bool(args.grant_supporter)))) + return + server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + server.registry = Registry(args.database) + server.limits = Limits() + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/src/EditionDex.java b/src/EditionDex.java new file mode 100644 index 0000000..d720984 --- /dev/null +++ b/src/EditionDex.java @@ -0,0 +1,96 @@ +import com.android.tools.smali.dexlib2.DexFileFactory; +import com.android.tools.smali.dexlib2.Opcodes; +import com.android.tools.smali.dexlib2.Opcode; +import com.android.tools.smali.dexlib2.iface.*; +import com.android.tools.smali.dexlib2.iface.instruction.*; +import com.android.tools.smali.dexlib2.iface.reference.StringReference; +import com.android.tools.smali.dexlib2.iface.value.*; +import com.android.tools.smali.dexlib2.immutable.instruction.*; +import com.android.tools.smali.dexlib2.immutable.reference.ImmutableStringReference; +import com.android.tools.smali.dexlib2.immutable.value.ImmutableStringEncodedValue; +import com.android.tools.smali.dexlib2.rewriter.*; +import com.android.tools.smali.dexlib2.writer.pool.DexPool; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** Build helper only; never shipped inside TikTok. */ +public final class EditionDex { + static final String ORIGINAL = "com.zhiliaoapp.musically"; + static final String CLONE = "online.lihon.tiktok"; + static final Map mapping = new LinkedHashMap<>(); + static String translate(String value) { + String replacement = mapping.get(value); + if (replacement != null) return replacement; + for (Map.Entry e : mapping.entrySet()) { + if (e.getKey().equals(ORIGINAL)) continue; + String prefix = "content://" + e.getKey(); + if (value.startsWith(prefix) && (value.length()==prefix.length() || + "/?#".indexOf(value.charAt(prefix.length()))>=0)) + return "content://" + e.getValue() + value.substring(prefix.length()); + } + for (String prefix : new String[]{"/data/data/","/data/user/0/","/data/user_de/0/"}) + if(value.startsWith(prefix+ORIGINAL+"/")) + return prefix+CLONE+value.substring((prefix+ORIGINAL).length()); + return value; + } + static boolean changed(String value) { return !value.equals(translate(value)); } + public static void main(String[] args) throws Exception { + Properties p = new Properties(); + try (java.io.Reader reader=Files.newBufferedReader(Path.of(args[2]), StandardCharsets.UTF_8)) { p.load(reader); } + for (String name:p.stringPropertyNames()) mapping.put(name,p.getProperty(name)); + DexFile dex = DexFileFactory.loadDexFile(args[0], Opcodes.forApi(Integer.parseInt(args[3]))); + int hits=0, classes=0; + for(ClassDef c:dex.getClasses()) { + boolean touched=false; + for(Field field:c.getFields()) { + EncodedValue value=field.getInitialValue(); + if(value instanceof StringEncodedValue && changed(((StringEncodedValue)value).getValue())) {hits++;touched=true;} + } + for(Method method:c.getMethods()) { + MethodImplementation impl=method.getImplementation(); + if(impl==null)continue; + for(Instruction instruction:impl.getInstructions()) + if((instruction.getOpcode()==Opcode.CONST_STRING || instruction.getOpcode()==Opcode.CONST_STRING_JUMBO) + && changed(((StringReference)((ReferenceInstruction)instruction).getReference()).getString())) {hits++;touched=true;} + } + if(touched)classes++; + } + if(hits==0) { Files.copy(Path.of(args[0]),Path.of(args[1]),StandardCopyOption.REPLACE_EXISTING); } + else { + DexRewriter rewriter=new DexRewriter(new RewriterModule() { + @Override public Rewriter getInstructionRewriter(Rewriters rewriters) { + return new InstructionRewriter(rewriters) { + @Override public Instruction rewrite(Instruction instruction) { + Opcode op=instruction.getOpcode(); + if(op==Opcode.CONST_STRING || op==Opcode.CONST_STRING_JUMBO) { + String before=((StringReference)((ReferenceInstruction)instruction).getReference()).getString(); + String after=translate(before); + if(!before.equals(after)) { + int register=((OneRegisterInstruction)instruction).getRegisterA(); + ImmutableStringReference ref=new ImmutableStringReference(after); + return op==Opcode.CONST_STRING ? new ImmutableInstruction21c(op,register,ref) + : new ImmutableInstruction31c(op,register,ref); + } + } + return super.rewrite(instruction); + } + }; + } + @Override public Rewriter getEncodedValueRewriter(Rewriters rewriters) { + return new EncodedValueRewriter(rewriters) { + @Override public EncodedValue rewrite(EncodedValue value) { + if(value instanceof StringEncodedValue) { + String before=((StringEncodedValue)value).getValue(),after=translate(before); + if(!before.equals(after)) return new ImmutableStringEncodedValue(after); + } + return super.rewrite(value); + } + }; + } + }); + DexPool.writeTo(args[1],rewriter.getDexFileRewriter().rewrite(dex)); + } + System.out.println("{\"literals\":"+hits+",\"classes\":"+classes+"}"); + } +} diff --git a/src/MergeClasses.java b/src/MergeClasses.java new file mode 100644 index 0000000..11508b3 --- /dev/null +++ b/src/MergeClasses.java @@ -0,0 +1,28 @@ +import com.android.tools.smali.dexlib2.*; +import com.android.tools.smali.dexlib2.iface.*; +import com.android.tools.smali.dexlib2.writer.pool.DexPool; +import java.util.*; + +/** Merges extra classes into an existing dex; existing definitions win. */ +public final class MergeClasses { + public static void main(String[] args) throws Exception { + Opcodes api = Opcodes.forApi(Integer.parseInt(args[3])); + DexFile host = DexFileFactory.loadDexFile(args[0], api); + DexFile extra = DexFileFactory.loadDexFile(args[1], api); + Map classes = new LinkedHashMap<>(); + for (ClassDef c : host.getClasses()) classes.put(c.getType(), c); + int added = 0; + for (ClassDef c : extra.getClasses()) { + if (classes.containsKey(c.getType())) continue; + classes.put(c.getType(), c); + added++; + } + if (added == 0) throw new IllegalArgumentException("No new classes to merge"); + DexFile result = new DexFile() { + @Override public Set getClasses() { return new LinkedHashSet<>(classes.values()); } + @Override public Opcodes getOpcodes() { return api; } + }; + DexPool.writeTo(args[2], result); + System.out.println("Merged " + added + " classes"); + } +} diff --git a/src/RefreshClasses.java b/src/RefreshClasses.java new file mode 100644 index 0000000..488f70b --- /dev/null +++ b/src/RefreshClasses.java @@ -0,0 +1,42 @@ +import com.android.tools.smali.dexlib2.*; +import com.android.tools.smali.dexlib2.iface.*; +import com.android.tools.smali.dexlib2.writer.pool.DexPool; +import java.util.*; + +/** + * Swaps the mod's runtime classes (Llihont/two/) in a dex that already carries them for a fresh build + * of the same classes. Every other class of the dex is kept exactly as it is. + */ +public final class RefreshClasses { + private static final String RUNTIME = "Llihont/two/"; + + public static void main(String[] args) throws Exception { + Opcodes api = Opcodes.forApi(Integer.parseInt(args[3])); + DexFile host = DexFileFactory.loadDexFile(args[0], api); + DexFile fresh = DexFileFactory.loadDexFile(args[1], api); + Map classes = new LinkedHashMap<>(); + int dropped = 0; + for (ClassDef c : host.getClasses()) { + if (c.getType().startsWith(RUNTIME)) { + dropped++; + continue; + } + classes.put(c.getType(), c); + } + int added = 0; + for (ClassDef c : fresh.getClasses()) { + if (!c.getType().startsWith(RUNTIME)) { + throw new IllegalArgumentException("Runtime build holds a foreign class: " + c.getType()); + } + classes.put(c.getType(), c); + added++; + } + if (added == 0) throw new IllegalArgumentException("No runtime classes to put in"); + DexFile result = new DexFile() { + @Override public Set getClasses() { return new LinkedHashSet<>(classes.values()); } + @Override public Opcodes getOpcodes() { return api; } + }; + DexPool.writeTo(args[2], result); + System.out.println("Replaced " + dropped + " runtime classes with " + added); + } +} diff --git a/src/ReplaceClasses.java b/src/ReplaceClasses.java new file mode 100644 index 0000000..2fbf4df --- /dev/null +++ b/src/ReplaceClasses.java @@ -0,0 +1,26 @@ +import com.android.tools.smali.dexlib2.*; +import com.android.tools.smali.dexlib2.iface.*; +import com.android.tools.smali.dexlib2.writer.pool.DexPool; +import java.util.*; + +/** Build-time targeted class replacement, preserving every other class/method definition. */ +public final class ReplaceClasses { + public static void main(String[] args) throws Exception { + Opcodes api = Opcodes.forApi(Integer.parseInt(args[3])); + DexFile original = DexFileFactory.loadDexFile(args[0], api); + DexFile replacement = DexFileFactory.loadDexFile(args[1], api); + Map classes = new LinkedHashMap<>(); + for (ClassDef c : original.getClasses()) classes.put(c.getType(), c); + int count = 0; + for (ClassDef c : replacement.getClasses()) { + classes.put(c.getType(), c); + count++; + } + DexFile result = new DexFile() { + @Override public Set getClasses() { return new LinkedHashSet<>(classes.values()); } + @Override public Opcodes getOpcodes() { return api; } + }; + DexPool.writeTo(args[2], result); + System.out.println("Replaced " + count + " classes"); + } +} diff --git a/src/brand/logo_note_flower.svg b/src/brand/logo_note_flower.svg new file mode 100644 index 0000000..c4c040e --- /dev/null +++ b/src/brand/logo_note_flower.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/brand/logo_note_flower_1024.png b/src/brand/logo_note_flower_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..dad6bfa1c7eddb989ca3bad199604f92cbddd70d GIT binary patch literal 42981 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 literal 0 HcmV?d00001 diff --git a/src/brand/logo_note_flower_512.png b/src/brand/logo_note_flower_512.png new file mode 100644 index 0000000000000000000000000000000000000000..13ebcf707fe25638b4e45c9acea28566acb508a4 GIT binary patch literal 19307 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 D6#ETp literal 0 HcmV?d00001 diff --git a/src/icons/original.png b/src/icons/original.png new file mode 100644 index 0000000000000000000000000000000000000000..459c6429b79b5b51c3b62c0a2a31a09dee0f55dd GIT binary patch literal 12894 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

uI@uYo>d(WPQUN| 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 z z<^FpU!%g*5eKgNS_rp3SyirQvPu?SlKT3lqgt|O^!~eoJq-glbe-c5WUTQ~`VWA!A zvzvOcaMOR5%1Tkhz(I+;N7PS7NJWDN`g&z!NFfJg`Ws}_g~#3gj*Y423FJhh_1nr7 z4B{16WM-93gEddMC%$sIcGg3kh2rn*)|MBv(k(sb55Fc)+l}WSInA_ER%6H$0Yb%x z;h&6%*z5_=DvL^5O&U4YFAbBDL&YQV^D~mR$jLa_DKD{mp?$;rAzzs;b>0-;qE`sD z)GDT`s`4jqoJZ5fMX*GXn>Y?ImL+zJ!p9ps2C({~mWAI}TuD*$XB5&0`oNcgb!MCA z>fTj($j-fxJs@X%nfa0{*=0pTsud@rM}s?B<_CgJ<9oRZ61x(Y7puOizQaLxqP+fW z5mo$m9oj9q6F>Q@h5idax=Ys5(c0d@5m`f2;EH}PBQtP~QGk%P_folXas!C{zEnBB zxP$`ihl40D@@hdrsrQ6>n}a1u?kIpnu)aRB((7dClyjSC-Ton_hE-822w~G6JS%%s zDm|^W(Zw+}L-4ipOJ=wE?F8Ck%A>S%VRLg9?IHDLAl>n;vhR0eWF%}k+rja}0#~KE zAtk0{!GNtzL5<~OM<)JG{LQ?4jrk$lE6J;WT0QjbS|+)xGdkuxUmxE7cX8wGd@ZF6 z#Xrk0(0+o^$x%qA%4L$d$g_|fUDM>RB$({YoZAla*0VoG+3+sNrh>LHKS4V~Gnh(=%~B z+ODRc{vLPywr`ZjM8jstzUui#s|y`Y>O+o{l2#f*ON_u1w9|PxdR14qB@83B`))@5 zEk%y|)E6qKUh2#@)GceSh>Nu+GM#vER7d9d-BxEm>K__aAxexslIxqQX9D%P3~IJa zaYeSryykIW!T4fl8b?+)D{}Hkwc`_$vbg@GpGwYl!ZQc$*o5w7$0rjxG90P5T~Oo8`=<|TVq<` zPd;EwH{7EwrvnPyF>u3<*W-?|qr~!fexncE!fdXcFM@xibRN*%zk_s90?4i_za9@2u z+LMXJ*F5Swxd*E!Btqx;ibTJc13~!h9Y);&sfYvD^o;#D+jH{hmP#eMexb%Hn~wd2 zhl?JawyVuROwETV1tqRmr;-6gm;sC7;r{2Y3E9o*K(VZTkXur9p%vWhhmicH)j;!M zPBSYDM?$w3`|phgEnCo82gNcb6)U}`EaBVwu;AgAxt~RJv-_zFrr&<50-(TD&i}FL z_cfCwykKqc$sF#cwJ6Z6s=@f=2ePwrG0f%ClQk6Qt zls;u0a}dn`_lLG)yUUkvAurY^L?CdOffxa-hwPX8EdM$`BVOBpu2@bQcpjSOs(XTr{zPG^sNdatC^>@7( z`;h2ZG%v*RRjIV1N~1hqxu4qZd>QbN1RS4~Of{C2JPjftyziGl$HxwhN1_-Vr#wdU zRD(`8=Ffqq)Kee4Pk!*}+U#zUiO?dxRSAsEw$gm|;I3_FpwAy3V7}l!cD!&lF{9XI zSv9$lek1RFlWV-fefY0h-U1Z86ff8xwwQu)GV(1?FDr5x5HuQ_lKmX>ZtN#0TFO8t zE@w@W&LG}^8X`fGB{;2}+8{7-L$&eU)x&zpzkD6>Xuyx1ML1a>$&DkSz*e5WjpR3S zyPr`jxFEd+bsCpv`sjy{br*jVLu%Jr@w0z@RnF3)LDesEgImR#d^=tYLfsuv!(|_! z%Y0(4!11|#6pXUr?C;0lp;YE#i0F%-t5SV^s4S*q5NW@^Ipx*d`6oLX=%J>hyD4%w zs7Sz2d<9Q~rT|io2DR<^YEkLUe)|w*xWS+jv0Hq{X8Xp&24S3aQsq=@)QVwMB_p(ol}-w8g|Q=;!wOk zc|qlNH{DlLT2O?%{GW8NUtU;Jve||YbueeKOWtYlTUf+3b~q5XMVs_z=!wkkmj}Hftj(N4U&Iob_HAMyZ(q-y^6SF*qHMb1L|(Hv7jjuV8c52hJ!6u!3pJQNHUYzzHHHOxy} z6#a*S#kEUB4h5oNYU7u*6A&ns_$HyktA%cIM4SwD*1EKe=E)vGmlp46T^vHG*oidX zUNx=|gERA0dpl&nQgWcrhilh+lh<{cjcWov%+-`>6q4!;_UY@-b3-nw633v1W79}? zEY6STTS{G-ir?Y>;-+%XV*?Ox&ky-o*Y@4HiG#OnWgv>EdwYD$zoYK)K0qR_J(d$8 z!5tq65E#h=GUFJ3A?u9CWJmcTXgQ_oz~p?}zDmR(v5OJ6*4bFBZjt|*@~Bj#llU0m zN!X|}!VSAwW=vMi6q8LPoB+EtH(#8yZ~s(uHn6Iv1~r6O>(%~O?? z{h|B|hmfHE1SjKIuZfXoi^jb6PF)_FvXS4IjJoLfz9JFq^FNBt`k|@6kKzLn0Z|6h zpoE}Kq;rTgd|?RaD48_UCEblMIviaRBOOSL4v~@?k`s_nVj%74eD3oP?1$ZZKk+{2 zyw1_VKcFq?a~YC#)krM8!9h?!Kv#SfH9?A0HqnDaLaYm4wms7r$jsFl^UVXODMFxe zks<-S~AVK-QPD9Y9) zqOCEARvWaN!ql$|X(b4!rvUqh>TplGw!WP@@Z-!RX2CN0ef)Igv-3g6TQflT4>&e- zMh7mp5B1{aoN1d)R39afXP0Ud;VnU{GIGIZDCXdUB93;SD}@28spCd(|JdIbMKtf2 zVQDN7PGS4Bah zkmnsGifD6@-=Mpf9Ea@QXI6Z_5Z}G;!#)Yfg0$we&GXwzNT<2D?g9t4cMtlypr*Xp zn;rApG|H%0F|Hk&^PJVSGwW94d$shL=_({Zru09KKrt=%*Y>T`w|0M)m%rQBB$F3? zr^xX{Fr|*pf_mW`Z25UeL|8#ZB#aePR%}m9sW2`_szXASGVa77|6Wy}iN| z49WJW3v0slD?9$UO{64rC+X-<(Uv^wd(5Ogoei9`eJ4C>?0)V`C6wxt z=RO>DM`D2lpUbSoBbbN8@F8_1aTXVwx>AN&q>i213ARDfo z{u;SOfEf0|e}8LgO3%zIPws3<3FJ8Eax*VpxT%_@5yX0~ za+}nPoq7PP3B2eF3qVNv7IW4k5Pa^KD6@vLSadl{Wn>w8t&-VH4@pS?5L}+!U(&gG z2~Q&fPygK}_)Z-H=Pr@myRDBR8=NHPwq4ng8OakVX}-%9C)g@T!Mr;1otb%Dnt2J!bwijIUp(K7%k>i{09KrQ(iTc&`3?r}f(h~-ZIKX1AJq<_DdZ%lo@ zq;yGKKQ1|y`ebK|;MHYNsbsCSS0#HfFBwOcVE;@1!+aLYM+e2C+%QAPO%hnzxtKV= zN*l4V!_;#=@JXt)mohRTmpH5(kusQqhSygNZb!(_tmIa^-sDF7<1T#pCI)0iZKCYu z{TyP|zTg%3&^}dEJkfFvixUQE5_MtFIp?Xk7lQwnyP2g4ZfPB+nFC_lHIxhIBNge~ zYE3hrCFev7LtcehG9v2cPRJkjFnd>Pj+BPWLRnWQZVRj z0)U~;oX%_?I@tod7Hz3rR*4<^fBhH#rZ!WEIpej@2y?MZmws3n8p-nfKL~*8nBJUn z&2b+Pf4?Ne4p6_0{n;bCf4|}RZ#UPH6FAAH#Cmf{&?c9ve80gxJYf_Qv6TIj?sXs= z7dc0mjjE!-#?~VmN_Dm2l|B0JpJ2-N9yDLm;cm%=aCLWbl>1Qv4UyA=OUl~oC6sCO zC2s6AQ}g51%I?w`W62}U#5j|HjB z@8p-=<05M#(E9JrHRyy>y|w7?|@=bFnCPI2L>OPYma@O!O$%tCS4lX|NNF(@jct| zmR0DP^7}w>S;3h=C1OP5H)L2mhWzf zl`!WmvK1rS9HRIjkl5Nn*4%woXxX$oMW00dJ&s+d`Q!D^AG)D&d{#9LAE%xpVvv(t z<*@qlBxY;YQWa&3g9>{PH_A7S${nhLvMj9oJfJ2%sQ(is89(T z?7}?>QeIE9(#m(A>ruL<>QvO{Px%36Oxeh~?HQ?r8oNQeQGW4;4zC5*);%y{=N!{amGJ)9N5%H^=qK^|_1R?ibf?R{?nK%J&Y- z#GY_qg}mkW(%q!mc)}5%?JEtifNZn{X}b3vTb;7kb2aP+0B+h#^11Dnr*~=hn&cXc zpQAG=HC~HeR$lMn6Xt>X&|3r>8x0tIu)>s}kkC&;dzw=j|L5;o1)v+4n|*U-_dYkq zd!bS0>~4rOEB$0&^#y;9Fyex*L02@+*P^PTi|Sz|SFU05kI&4s6)db$YhUv+GG?BS zDi6DG$><9P*Tc1ZTN!FxtiS6jw-I#9|HG{sRfGg-`3^LrD7v2b1sujWJIm~MPj>#p zk1a%g3A&ucuXL$fn)UT)=btc*Zqo%6)i7bX^Ina?r}?T*wf|ZULl+Y zX-RUtd#ZY;g&k-?Z%R~@vr2MHXg?p_KwJAveQ?FiJO@g{Dn6_ObL93p-`zEy0!OgQ(H2BumHhRe1kH`cYNIX%^??w~z%)yf>m4tTfD0WL>Pz@c0Fg@`W3SDRGhvnw|%4XRCmZ>+=TDxlqDWa46F zW@4^&&Xx7N>cyOM&|xo=25xie@0MerzxTuP9GAuzHDzGZR7)evKeWAlpEjCGv@Wq` zBYF+UJm)_;fGXE# zr^+eLS7^P)PB2RP@jQ>v>+8Zj(FPQ@^?ifCd3Gb$Y2$hR=Dg|T`@0j3*=GJd0d;~k z3M14OA&0Pb(UL4OI@vR3xl6VC;Z$GSFS_i7UopAS&wWqm`o!38hEztt1~ZW_M?f!`VQ5m2Z_rwUJfM-A5baoMxu`mD)k1w z>6X|%=-cnDvw`871B-iyGaga7kG4Z{tA z`RFAVLD!HOQW|`15IiG6i!|x*J-s-+CI3>{X^sQ{W;eCB4x#Ifhj6!;EGHs z?q!`KM^1bFc4Z@mXx!9Kr*~fR>kgpCXy&j&)@Z5_hVU%&fWgQ5DxzN06&es?PLfb0 zC)|SiQ5NiO7sD8e5#+w*hRdT=1X;^G_vQ$Cb#)dcrL_bC%`eM=H+qr>3IsOSVE1xKSZAy>u05R8qYi9M* zt+UL?J??-BCVc_62XZn7E4WvLa-oWwa2=JoK`Pb0N8Z=ZE6?Dy)?V)-J*CQ?YR=Ks zX0blu5+D{@>j~t$E$k)&(OnSjDDhifphr~qPvxB!FLrH1Vx^}XJNfJPGr+yCTPS7u zhgdc+>wd_|KJikm=pCrdBoe3uBfYo)w96v73zIuV|9|R4rp}YG#NdPKt9jSXQHpkA z#cYk-8g&HQ;xKP19BG?xtwxc6pR22XR?+LEQ>BGKf(07NE&)r|AB*;0mn zV7}&JTTre>R@6ppx*%|A(iwEZ-{i>e^vTnJ0MMoUNMWBuByh(8dpF|E>gRS%JM!c% zPnN+K+w;IRibR<}N|(4S6>m8q;h8zImBcQ5!}UdLWqI$s1CVj97A7tIwFX<_Rjnu{ z<)!X|?o!mCp2=emd%+d{l;H2pOM&;gr8r{LJh;*)vBtE*de(y~B%BfNIg~Y#;w;7< zX>Oi(hAfa~A-1@$U{i4IPGZv#XY; zqM}I?2g%@6iVA_*nsO5Ey6Uip9He*<`kt-Ewnut);h%xIgo?*=kWsaN=;t2O%K<7wgJ1u$< zrHZX8+)^d+2H94Z102EZ0C7EyNQ|WtI_jcXeyxYEd@vkzx@r2^z~P1_G}j5o0Hz1O zH(Zk$l+u)~-}*Ua^-*D-=tJNri+(>|5*ky&%likc$lLJH0@wReX>a(@hMJh7R}h_t z)YZN7U8`KjnyN42Q$@2H1kPlFYn4`g{3T`T)C``MGnRCp5dtkbQ~c)DI??^TtGjK| zu%t4#xBbD{_P0$F@k=C~0n3W;U+auX0j?4q>Uzsl!I>saV zBAg96dd}o(!}2~N|M1MNk8VAdWtx8B39iE-AUI^!XQyO;!GUbeKc9_y5?@t|+x;!j*gj{D}6UsmbQ#^jT~t9(q^ zf4<)fRp6v!7_yDy2q3tN#&Z3dUjP(6E0eo;$?J*W^J}UEoA8h}c|&M$dz@$O^J0&W zYYVp?-Tj`PA2^-w^0n9rkbOzkXV$3iMD8bzk00}Ml>+4QQ8c)ypaew`df%XY^cF{e zkIVx%t#U*|hK72l@2Sdr0J4@t-1*Yk+$vh<*-0MKV5c8gZ4Iw~R`(Q`;S_?i8|>H! z7aL$+N5$@!diIIP;b(AgQaiyvsH6gxOa|#wGiqK}CNoQOHyI}l31l+}+|7O1w-nA2 z!&*_xN`y3X`p720m9=g%$k&i=@U-!WiB{rOUJs~vW^L`V$+mJ`)J3YnMVt;v=7_}I9Ia;xe%UPV)Y;f7`ZV{i1dWl=@8 z+kA2~497BTF(LGvAr&zvK1QueRrlCU+14ITc0-B@20{Kfu56JsNQJR(pXFs%I_*v| znX*TjV-Z-Vw@iJHX2mpdzL{$!lyf6qS*p861HJzJTReaXJ=p$}yTGzk?>ey%=NcEE zHk_d60Zd=)vh{*<2(dxiF)IJYT;W{BmogtZj-8&fCj!e{GO&r#L`rSb0E!7WeP;2e z8HsxQ(3gK>qK{K8){yj}WDS6d0yIQEd?Jww7-{ozoL~b`g5F}u){iZzxQba%+Vrh3 zj=mZc8TmpDi%9bl7lGZvV{0wP)iBK$g8sFz0OXSf`(2x6?gP5*<;ou!^cLe{eLN#e zI!iQBh`Y8>htEDm!&R1ypmGOXk*Ee#V2N5#6l}I+2@I-DeVi9}(mW>7dE{arqxxOo z{e*Jq7Pz;uVh@W*-|L;y?u2yoArV0$!-=1jJ+aFac;QpYu$GkY~9cH#4~Gt+soPGX`u z+}2__z@D#6+-K2`sQ-D+?EN(L=DcN}0 z`$&Qo%$x3-WqP%?dKMu1Yn$zd-bBX?2jfQuN%fcvtB*?o@;yT%rPl|y07)!`Yp zG)-!z_7kSztf+% z^IrNv!ehFWsNWvatzbsNGhr60GgFvgC2jVTt2+)I&Q(iudjaMJxWoMXv`lX8zpo~)56)X$lD#Lw*fL{1I=qs$*}Y$_HI<4)gz;HfW3R!qqVELY>P~vm;^czw*j_kr%j9b@H0`#ONDo3)IEvxfBdTw+Ewf5D zM7sUfcCo63D*9DJwE{!S6I|%F&JKxLlu-mqxR<;Tfmh)Rmv3G6uO(B6;u*%`2ZayC!M z5BhiD@y5MYk?Z}+9}QQdwDn`WVDJgy`rS{*bFJgt%az_k(Oo7T7}N^17550DPcSht zZTCA2+u1+y_;t6PlRx5QraI`VTQF#^;(8LJu}HJvuN((Sh3DPvAC##k`kJ71akq-@ z0DPVs%^4!+Mutx(OD-SMJ5h78TDDYKlBsi-=-O{b6*QA(7&`FY6jl=5LFs~5KhgkQKa}vtymk&@YI8``sfo`QWzrp zqCk^BU&oClTjUffA4gK0baT+ZN*M;}N4Y6;78HXOy-O&2_|oA|-}BNrQF2F^k)u;N zUt2)1)XT`Q5C8~jKc;#c0;__aBbr@DqYCaR_JOmuYG%eF=pm)kzG_?~X0oH3BBK*+ zb52n~LVXQRh`S6;B{}W_5C#khrBa;Y59>Nt=XpoT{%+S+7E7vSse{1X9Ph4C;^%rT zD0WSo`vW`QSKNWAc2s3$rCpwq`zAq=V)61f%@t6mxp`@w({8Fks~QbYn7Q$LOKF9> zf1Bxg9q^dn3Kn`(gl${cwa2J#ny<&}AA281EpPP3W?N-?Up}B zI>w)09(NMkm%_)Td&tCX(QBRyrpDsb`rpTznJv?1kCscO8>%YQ$~;GrC3afim-riI zakO9{s*g5jm#+w74t0P<^RYR(381YVS@Q>ZzSv<}J2uY~B9zzQPc2Beiw=JrqfuZu zM8(K$TLEV8DWf!Zf``|FE!g`rMj`zO4UBl&v?(bmSbe6p3YpYAOZ(9w_xP=14(=lyM$tU1{f+tfC{L#>M8BfdlO=V*c2ffA0NKE+fyag+<;znhC z7K78wbZ|aR#Ys3uJWM$p+&B{jFVXwO6@iF(HK?Y?;s$dzY}PO0rR*KX{f8-*kAbAU z9R4MtFAB46=^Q}8#n-l2@2hSA-iRHO$Df2mq}F-<6n8P!ED<`z69 zA+b>2<+2g@pcJV^JggD|)0!3%@+J;5NRoT3&gPE3y<+(?!oz383XJ0+P(2VA@)a#s zS5-iBz___Qw@eckA1h(vQgv7x6QP z1H>uc!;DUe$1*GJEjVBR(T<~kcshgmA>cm>*vocZHlET?*PVU;%pfQ*IA5_08xVYa zd3|AUy|M9o;O^z=mHHd^%YPrlA4MYjM2M-|O6u*x4smGF>Y%+#)2lxQawlCJLLt*I z7RX2T$ejs}>rY^5UZjJx7PbvM{mZdI?hJQq5_C2*a_DJ;rSm$h+O5|gJpFMz-zL-E z>b-cq<89i}X3Qix@)3KqS#3JEAba0Nf!SgZ>e-+H@eJ%A?k7U~eyLlw%J#3co952# zR`1aCo3ncx;G>pZJt65SIvdjNfpwaYv>cT1;5z)N#Hx!GMpNfEn`Jg{&FVe2qEwX6 zZz9uFl5}c8;C&azOm=>eoNnjPAZOlksPc|*+VUCl(YZ!2lwqb zi*mD8hB^Lw#@}>V;q7T@b?RZPs`sL2nLA!oVRg+Og({QL!L=5k=&UzXp|FoVt2y?N z+ErmrbqMSGr|EDFMtJm4mQZ8JW`C-peI(@wwB+LdqV-hRAvt=m#p8~FjpQKs_ziP}+;iBU6A^H+cEJC*v(*Jqs{GU)L| zB}L}Nzm}9V(AJKcIAvG-Wx5S37w+rWU0j%-sd}ETo!=2qGvmC_?!W%oP``8jAaF4% z13pM|ti5=-W3w#P)*hUJ*PGdCYuXJY%r+;%Z}wDezQxziIA-JGz)p+(IqG0i-%fkW zx#_x9fAwR6igvKw8zTetHsHWmX<_YO`x=-~`i ziEl^`oKo++&}m@F=(%Q zH-A)Tz{+qMMf^KGA|x7_u3Qz!^d$)!fw8&7lN4a|;LAENS|oDG9!ob#LpqU+m^{QQtG_W>v` z59Sq@#RqB4dj(0Y7~P7rmOjr+8}fyn1~pv`sLTV@m@%He(%;7i7`+Dh1p2?w)VUpB zZ4~jTx7E2ZFd*<4z@3=p1zk>Uc3!EZ!YRcQinv(dz$ZladJP75ju`C9pRe8YD~m&x z^C*;k07X)>w|CC$Q2pyXrwKj`K3Tk~t{>}Pwri3KGCK^q^E>8A1ZQy)V&25~ddr~m zXl&7Fn=kyf`NT2QW-V)U=&}~jL(uwJc+V*mY+d%hd1P(Xrm!nknaI{otUFsT?NL;wHh8UUkUe}F`Aa=iX z*kEcGLWwlqu~j)GjQP_VLhrT+8M+vmiGHF1)wba>TfapY0aC4eq`H~Z3hucK_c^~S zIJclGRxbafGgL7+Li0NJ8K)4V^xF++6?4sR)sWDhbDQ66v}9EFv0oT_bzo-X5M{Pf zF_hPk50kQ@xc4w){5Ob^1-L&kB;aXg8oA5fgS9JQ zLG8@HI!iZRta;5fB`(dU+w~G;L`8c)_oFi1hI1}9>BRu_G>z(qI<0%ETJB`0?1QVJ z>wkhdv4YiA5=sPpjsPKqWUoy4L$1YzMLu4Wj({|^`Cq_dC2Gzy1&25;G?uC>Al$$%-+B6*Liu+yFEYgnh(O$eA~)|D@2$)q_K6( zm&t-X^0&-LDN*JiS9rGh2kBlhN?234q+>H<^G^Z$_mh>DuqhK7sTX-1ue$3#UkhBM#VKnTMe6iB{wI zhl+%)TUpeRS)!AQq*%wIgtcy8{kJAs*Jvub;8UC6{hG7RD=k)b$}g$`XOw|gEC=_Q zN0djEt~J6rQBRveiuVM?EC5T~0A1GchlnJ&8ZR2YecP_3^AW*uzXy=3MJc(^pRs8O zO&2FT?Q0EQ#fTgoijL`u;_*HTuB;7dV|dAgSgtzKeEkD`MByPhW?$tSJm%QMF4bYi zS;E=4`pAISrcq#g-TQjKwDW@L`i$xNkExb0Pbk+aVu<}4oh1n)|39*y{mMMxPm*iT z3M`++Iq6p0 z{(K*davS@o1GOV?r>^XC@j!5zW}<%1831(XtcE?YuYzrEJ8MJ`e5r&fi*d6V@VM9Z z@n0Ffvd;afrBtWzs|qXF-Ev0;IGN8XB2o9;lNAd;9ksi_xOn~`3X+TR4791AB_V(G zC%N0%r{CsGE)#hV0X@m5f!kp8MF>Cj!ASWC7+i3rY-QU2lHnqrltfAxJOy182D|4(-NrR)EDona~X1&=Yzv2TK7MLa_?F@~2$`(hq*=BAK0x z)F;43sMXZAe-wiqFp`au6z}3x$Q&?fgZ`$Vpv&vi#%tpB>h=B}zO&7Jg5799FX>IV zsihZm8u1w%QD}xiJI5g`x6Zljq!;EPkWv)MSD0M{YkdNmbZpDG+*3@V@8s`exfVZH zKBRNiF%KBjMsxYYmnr#;x)t_Os3iW}cb{0xT12a^t`g>k;fk1>tGt~e$E#?s^cBeJ zqEe<|b~DF;Ra5@gdnaVbM!dfzP*yTIPDNdW1%LB09$ekucWs>keHyRT=TvY?b#`LX zc~q!+J_aMCxR=9WMVfZI&SYqTu23X%z!NO9)9lZzW-4xZFi)06dT~HvY+zozNti?S zyC#gMJwKevftpi-RO>dtXDL)wgZS^3+5v7Bdkmb?>^r*NBT6A=^nB+-ab?Q6CvE2y zTq?>*`r7rⅈd5Sm%j}%l`BHm$`gqh@hl2KNDmn@LnlU< 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 VL@nq&jRXJy002ovPDHLkV1gW!H);R? literal 0 HcmV?d00001 diff --git a/src/lihont/two/LihonTFlags.java b/src/lihont/two/LihonTFlags.java new file mode 100644 index 0000000..a2b39a3 --- /dev/null +++ b/src/lihont/two/LihonTFlags.java @@ -0,0 +1,116 @@ +package lihont.two; + +import android.util.Log; +import java.util.HashSet; +import java.util.Set; +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; + +/** + * Local switches for TikTok's own feature flags. + * + * lihont.profile_features rewrites each direct read of a known flag so its result passes through + * the method named after that flag key, in the overload matching the type TikTok read. With the + * switch on, LihonT's value replaces TikTok's. With it off, TikTok's own value is returned + * untouched, so what the account is offered stays the server's decision. Nothing here claims that + * a feature is available. + * + * The first read of each flag in a process is logged once under the LIHONT tag as + * "flag key: original -> result", which shows on a device what TikTok read and what came back. + * + * The forced values and the group of each key are checked against profile_features.py by + * tests/test_profile_flags.py. Method names are the flag keys on purpose. + */ +public final class LihonTFlags { + private static final String TAG = "LIHONT"; + private static final Set LOGGED = new HashSet(); + + private LihonTFlags() {} + + private static boolean banners() { + ModSettings active = SettingsHost.active(); + return (active != null ? active : ModSettings.defaults()).profileBanners(); + } + + private static boolean reposts() { + ModSettings active = SettingsHost.active(); + return (active != null ? active : ModSettings.defaults()).commentReposts(); + } + + private static int pick(String key, boolean on, int original, int forced) { + int result = on ? forced : original; + note(key, "int", on, original, result); + return result; + } + + private static boolean pick(String key, boolean on, boolean original) { + boolean result = on || original; + note(key, "bool", on, original, result); + return result; + } + + private static void note(String key, String kind, boolean on, Object original, Object result) { + synchronized (LOGGED) { + if (!LOGGED.add(key + ":" + kind)) return; + } + try { + Log.i(TAG, "flag " + key + " (" + kind + "): " + original + " -> " + result + + (on ? " [switch on]" : " [switch off]")); + } catch (Throwable noLogger) { + // No Android logger, as on a desktop JVM: logging is only a diagnostic. + } + } + + // ---- profile banners --------------------------------------------------- + + public static int profile_bg_in_allow_list(int original) { + return pick("profile_bg_in_allow_list", banners(), original, 1); + } + public static boolean profile_bg_in_allow_list(boolean original) { + return pick("profile_bg_in_allow_list", banners(), original); + } + + public static int profile_bg_enable_consumption_group(int original) { + return pick("profile_bg_enable_consumption_group", banners(), original, 3); + } + public static boolean profile_bg_enable_consumption_group(boolean original) { + return pick("profile_bg_enable_consumption_group", banners(), original); + } + + public static int profile_left_align(int original) { + return pick("profile_left_align", banners(), original, 1); + } + public static boolean profile_left_align(boolean original) { + return pick("profile_left_align", banners(), original); + } + + public static int profile_left_align_ab_group(int original) { + return pick("profile_left_align_ab_group", banners(), original, 11); + } + public static boolean profile_left_align_ab_group(boolean original) { + return pick("profile_left_align_ab_group", banners(), original); + } + + // ---- comment reposts --------------------------------------------------- + + public static int tt_comment_repost_interaction(int original) { + return pick("tt_comment_repost_interaction", reposts(), original, 1); + } + public static boolean tt_comment_repost_interaction(boolean original) { + return pick("tt_comment_repost_interaction", reposts(), original); + } + + public static int repost_support_reply(int original) { + return pick("repost_support_reply", reposts(), original, 1); + } + public static boolean repost_support_reply(boolean original) { + return pick("repost_support_reply", reposts(), original); + } + + public static int repost_support_digg(int original) { + return pick("repost_support_digg", reposts(), original, 1); + } + public static boolean repost_support_digg(boolean original) { + return pick("repost_support_digg", reposts(), original); + } +} diff --git a/src/lihont/two/LihonTRegion.java b/src/lihont/two/LihonTRegion.java new file mode 100644 index 0000000..e2a662b --- /dev/null +++ b/src/lihont/two/LihonTRegion.java @@ -0,0 +1,262 @@ +package lihont.two; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Dynamic LihonT 2.0 region provider. + * Reads the user's selected country from private app settings storage + * (lihont-settings.json) and returns genuine ISO country code, MCC/MNC operator code, + * and carrier name so TikTok feeds, logins, and region-dependent features reflect + * the selected country dynamically without being hardcoded to any single region. + */ +public final class LihonTRegion { + public static final String COUNTRY = "by"; + public static final String OPERATOR = "25701"; + public static final String CARRIER = "A1 BY"; + + private static volatile String activeCode = "JP"; + private static volatile String cachedIso = "jp"; + private static volatile String cachedOperator = "44010"; + private static volatile String cachedCarrier = "DOCOMO"; + private static volatile long lastFileCheck = 0; + private static volatile long lastModified = -1; + + private static final Map MCC = new HashMap(); + private static final Map CARRIERS = new HashMap(); + + static { + initMccTable(); + initCarriers(); + reload(); + } + + private LihonTRegion() {} + + /** TelephonyManager.getSimCountryIso / getNetworkCountryIso */ + public static String iso() { + checkReload(); + return cachedIso; + } + + /** TelephonyManager.getSimOperator / getNetworkOperator */ + public static String operator() { + checkReload(); + return cachedOperator; + } + + /** TelephonyManager.getSimOperatorName / getNetworkOperatorName */ + public static String carrier() { + checkReload(); + return cachedCarrier; + } + + /** Updates active region in memory immediately when chosen from UI. */ + public static synchronized void setRegion(String code) { + if (code != null && code.length() == 2) { + applyRegion(code.toUpperCase(Locale.ROOT)); + File f = findSettingsFile(); + if (f != null && f.exists()) { + lastModified = f.lastModified(); + } + } + } + + public static synchronized String currentCode() { + checkReload(); + return activeCode; + } + + private static void checkReload() { + long now = System.currentTimeMillis(); + if (now - lastFileCheck > 1500) { + lastFileCheck = now; + reload(); + } + } + + public static synchronized void reload() { + File file = findSettingsFile(); + if (file == null || !file.exists()) { + return; + } + long mod = file.lastModified(); + if (mod == lastModified) { + return; + } + lastModified = mod; + try { + FileInputStream fis = new FileInputStream(file); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[1024]; + int n; + while ((n = fis.read(buf)) != -1) { + baos.write(buf, 0, n); + } + fis.close(); + String json = new String(baos.toByteArray(), "UTF-8"); + int idx = json.indexOf("\"region\""); + if (idx != -1) { + int colon = json.indexOf(':', idx); + int q1 = json.indexOf('"', colon); + int q2 = json.indexOf('"', q1 + 1); + if (q1 != -1 && q2 != -1) { + String code = json.substring(q1 + 1, q2).trim().toUpperCase(Locale.ROOT); + if (code.length() == 2) { + applyRegion(code); + } + } + } + } catch (Throwable ignored) {} + } + + private static void applyRegion(String code) { + activeCode = code; + cachedIso = code.toLowerCase(Locale.ROOT); + + if ("JP".equals(code)) { + cachedOperator = "44010"; + cachedCarrier = "DOCOMO"; + return; + } else if ("US".equals(code)) { + cachedOperator = "310260"; + cachedCarrier = "T-Mobile"; + return; + } else if ("BY".equals(code)) { + cachedOperator = "25701"; + cachedCarrier = "A1 BY"; + return; + } else if ("RU".equals(code)) { + cachedOperator = "25001"; + cachedCarrier = "MTS RU"; + return; + } else if ("KZ".equals(code)) { + cachedOperator = "40101"; + cachedCarrier = "Beeline KZ"; + return; + } else if ("UA".equals(code)) { + cachedOperator = "25501"; + cachedCarrier = "Kyivstar UA"; + return; + } else if ("BJ".equals(code)) { + cachedOperator = "61601"; + cachedCarrier = "MTN BJ"; + return; + } + + String mcc = MCC.get(code); + if (mcc == null) mcc = "440"; + cachedOperator = mcc + "01"; + + String carrier = CARRIERS.get(code); + if (carrier != null) { + cachedCarrier = carrier; + } else { + cachedCarrier = code + " Telecom"; + } + } + + private static File findSettingsFile() { + try { + Class at = Class.forName("android.app.ActivityThread"); + Object app = at.getMethod("currentApplication").invoke(null); + if (app instanceof android.content.Context) { + File dir = ((android.content.Context) app).getFilesDir(); + if (dir != null) { + File target = new File(dir, "lihont-settings.json"); + if (target.exists()) return target; + } + } + } catch (Throwable ignored) {} + + File f1 = new File("/data/data/com.zhiliaoapp.musically/files/lihont-settings.json"); + if (f1.exists()) return f1; + File f2 = new File("/data/user/0/com.zhiliaoapp.musically/files/lihont-settings.json"); + if (f2.exists()) return f2; + return f1; + } + + private static void initCarriers() { + CARRIERS.put("BY", "A1 BY"); + CARRIERS.put("RU", "MTS RU"); + CARRIERS.put("JP", "DOCOMO"); + CARRIERS.put("US", "T-Mobile"); + CARRIERS.put("UA", "Kyivstar"); + CARRIERS.put("KZ", "Beeline KZ"); + CARRIERS.put("UZ", "Ucell"); + CARRIERS.put("DE", "Telekom"); + CARRIERS.put("FR", "Orange"); + CARRIERS.put("GB", "O2 UK"); + CARRIERS.put("TR", "Turkcell"); + CARRIERS.put("BJ", "MTN BJ"); + CARRIERS.put("PL", "Orange PL"); + CARRIERS.put("IT", "TIM"); + CARRIERS.put("ES", "Movistar"); + CARRIERS.put("CN", "China Mobile"); + CARRIERS.put("KR", "SK Telecom"); + CARRIERS.put("IN", "Jio"); + CARRIERS.put("BR", "Vivo"); + CARRIERS.put("CA", "Rogers"); + CARRIERS.put("AU", "Telstra"); + CARRIERS.put("NL", "KPN"); + CARRIERS.put("SE", "Telia"); + CARRIERS.put("NO", "Telenor"); + CARRIERS.put("FI", "Elisa"); + CARRIERS.put("AT", "A1 AT"); + CARRIERS.put("CH", "Swisscom"); + CARRIERS.put("BE", "Proximus"); + CARRIERS.put("CZ", "T-Mobile CZ"); + CARRIERS.put("SK", "Orange SK"); + CARRIERS.put("RO", "Orange RO"); + CARRIERS.put("BG", "A1 BG"); + CARRIERS.put("GR", "Cosmote"); + CARRIERS.put("PT", "MEO"); + CARRIERS.put("IL", "Cellcom"); + CARRIERS.put("AE", "Etisalat"); + CARRIERS.put("SA", "STC"); + CARRIERS.put("EG", "Vodafone EG"); + CARRIERS.put("ZA", "Vodacom"); + CARRIERS.put("MX", "Telcel"); + CARRIERS.put("AR", "Claro AR"); + CARRIERS.put("CL", "Entel"); + CARRIERS.put("CO", "Claro CO"); + } + + private static void initMccTable() { + String data = "AD:213 AE:424 AF:412 AG:344 AI:365 AL:276 AM:283 AO:631 AQ:672 AR:722 " + + "AS:544 AT:232 AU:505 AW:363 AX:244 AZ:400 BA:218 BB:342 BD:470 BE:206 " + + "BF:613 BG:284 BH:426 BI:642 BJ:616 BL:340 BM:350 BN:528 BO:736 BQ:362 " + + "BR:724 BS:364 BT:402 BV:242 BW:652 BY:257 BZ:702 CA:302 CC:505 CD:630 " + + "CF:623 CG:629 CH:228 CI:612 CK:548 CL:730 CM:624 CN:460 CO:732 CR:712 " + + "CU:368 CV:625 CW:362 CX:505 CY:280 CZ:230 DE:262 DJ:638 DK:238 DM:366 " + + "DO:370 DZ:603 EC:740 EE:248 EG:602 EH:604 ER:657 ES:214 ET:636 FI:244 " + + "FJ:542 FK:750 FM:550 FO:288 FR:208 GA:628 GB:234 GD:352 GE:282 GF:742 " + + "GG:234 GH:620 GI:266 GL:290 GM:607 GN:611 GP:340 GQ:627 GR:202 GS:234 " + + "GT:704 GU:535 GW:632 GY:738 HK:454 HM:505 HN:708 HR:219 HT:372 HU:216 " + + "ID:510 IE:272 IL:425 IM:234 IN:404 IO:234 IQ:418 IR:432 IS:274 IT:222 " + + "JE:234 JM:338 JO:416 JP:440 KE:639 KG:437 KH:456 KI:545 KM:654 KN:356 " + + "KP:467 KR:450 KW:419 KY:346 KZ:401 LA:457 LB:415 LC:358 LI:295 LK:413 " + + "LR:618 LS:651 LT:246 LU:270 LV:247 LY:606 MA:604 MC:212 MD:259 ME:297 " + + "MF:340 MG:646 MH:551 MK:294 ML:610 MM:414 MN:428 MO:455 MP:534 MQ:340 " + + "MR:609 MS:354 MT:278 MU:617 MV:472 MW:650 MX:334 MY:502 MZ:643 NA:649 " + + "NC:546 NE:614 NF:505 NG:621 NI:710 NL:204 NO:242 NP:429 NR:536 NU:555 " + + "NZ:530 OM:422 PA:714 PE:716 PF:547 PG:537 PH:515 PK:410 PL:260 PM:308 " + + "PN:530 PR:330 PS:425 PT:268 PW:552 PY:744 QA:427 RE:647 RO:226 RS:220 " + + "RU:250 RW:635 SA:420 SB:540 SC:633 SD:634 SE:240 SG:525 SH:658 SI:293 " + + "SJ:242 SK:231 SL:619 SM:292 SN:608 SO:637 SR:746 SS:659 ST:626 SV:706 " + + "SX:362 SY:417 SZ:653 TC:376 TD:622 TG:615 TH:520 TJ:436 TK:554 TL:514 " + + "TM:438 TN:605 TO:539 TR:286 TT:374 TV:553 TW:466 TZ:640 UA:255 UG:641 " + + "UM:310 US:310 UY:748 UZ:434 VA:222 VC:360 VE:734 VG:348 VI:332 VN:452 " + + "VU:541 WF:543 WS:544 YE:421 YT:647 ZA:655 ZM:645 ZW:648"; + for (String pair : data.split(" ")) { + int colon = pair.indexOf(':'); + if (colon != -1) { + MCC.put(pair.substring(0, colon), pair.substring(colon + 1)); + } + } + } +} diff --git a/src/lihont/two/badge/AccountNames.java b/src/lihont/two/badge/AccountNames.java new file mode 100644 index 0000000..a3d2b6c --- /dev/null +++ b/src/lihont/two/badge/AccountNames.java @@ -0,0 +1,101 @@ +package lihont.two.badge; + +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; + +import java.util.ArrayDeque; +import java.util.IdentityHashMap; + +/** + * Tracks the account behind a displayed name by object identity, never by spelling. + * + * The getters of TikTok's User model hand out a fresh copy of the name and remember which account it + * came from; a name that reaches a text view as that very object belongs to that account. Two people + * with the same nickname therefore never share a badge, and a name that was edited on the way is left + * alone unless a screen binds it explicitly. + */ +final class AccountNames { + private static final IdentityHashMap NAMES = new IdentityHashMap(); + private static final ArrayDeque ORDER = new ArrayDeque(); + private static final int LIMIT = 8192; + /** Name by value, for texts that were copied on the way; a name two accounts share is left out. */ + private static final java.util.LinkedHashMap BY_VALUE = new java.util.LinkedHashMap(); + + /** Marks the part of a text that is a name of the account `uid`. */ + static final class AccountSpan { + final String uid; + + AccountSpan(String uid) { + this.uid = uid; + } + } + + private AccountNames() {} + + /** Called by the User getters: records that `name` (a copy is returned) belongs to `user`. */ + static String remember(Object user, String name) { + if (name == null || name.length() == 0 || user == null) return name; + try { + String uid = (String) user.getClass().getMethod("getUid").invoke(user); + if (!validUid(uid)) return name; + String copy = new String(name); + synchronized (NAMES) { + NAMES.put(copy, uid); + ORDER.addLast(copy); + while (ORDER.size() > LIMIT) NAMES.remove(ORDER.removeFirst()); + String seen = BY_VALUE.get(name); + if (seen == null) { + BY_VALUE.put(name, uid); + if (BY_VALUE.size() > 4096) BY_VALUE.remove(BY_VALUE.keySet().iterator().next()); + } else if (!seen.equals(uid)) { + BY_VALUE.put(name, ""); + } + } + return copy; + } catch (Exception ignored) { + return name; + } + } + + static boolean validUid(String uid) { + if (uid == null || uid.length() < 5 || uid.length() > 25 || uid.charAt(0) == '0') return false; + for (int i = 0; i < uid.length(); i++) { + char c = uid.charAt(i); + if (c < '0' || c > '9') return false; + } + return true; + } + + /** The account a text object was recorded for, or null. */ + static String uid(CharSequence text) { + if (text == null) return null; + synchronized (NAMES) { + return NAMES.get(text); + } + } + + /** The account whose recorded name is spelled exactly like `text`, when only one account has it. */ + static String uidByValue(CharSequence text) { + if (text == null || text.length() == 0 || text.length() > 60) return null; + synchronized (NAMES) { + String uid = BY_VALUE.get(text.toString()); + return uid == null || uid.length() == 0 ? null : uid; + } + } + + /** Puts an AccountSpan over the whole of `target` when `source` is a recorded name. */ + static void tag(Spannable target, CharSequence source) { + String uid = uid(source); + if (uid != null && source.length() <= target.length()) { + target.setSpan(new AccountSpan(uid), 0, source.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + static SpannableStringBuilder withAccount(CharSequence text, String uid) { + SpannableStringBuilder result = new SpannableStringBuilder(text); + for (AccountSpan old : result.getSpans(0, result.length(), AccountSpan.class)) result.removeSpan(old); + result.setSpan(new AccountSpan(uid), 0, result.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return result; + } +} diff --git a/src/lihont/two/badge/BadgeArt.java b/src/lihont/two/badge/BadgeArt.java new file mode 100644 index 0000000..c7975b1 --- /dev/null +++ b/src/lihont/two/badge/BadgeArt.java @@ -0,0 +1,65 @@ +package lihont.two.badge; + +import android.graphics.Bitmap; +import android.graphics.PorterDuff; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.widget.TextView; + +/** + * How a badge picture is prepared for the line it sits in. + * + * The pictures of the other mods have transparent margins, up to a fifth of the canvas on each side. + * Drawn as they are, the visible mark comes out a third smaller than the nickname beside it, so the + * margins are cut off first (anything below 8 of 255 alpha counts as empty) and the badge is then as + * tall as the text is, with its width following the picture's own proportions. + */ +final class BadgeArt { + private static final int EMPTY_ALPHA = 8; + + private BadgeArt() {} + + /** The picture without its fully transparent margins; the same bitmap when there is nothing to cut. */ + static Bitmap crop(Bitmap source) { + int width = source.getWidth(); + int height = source.getHeight(); + int[] pixels = new int[width * height]; + source.getPixels(pixels, 0, width, 0, 0, width, height); + int left = width; + int right = -1; + int top = height; + int bottom = -1; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if ((pixels[y * width + x] >>> 24) < EMPTY_ALPHA) continue; + if (x < left) left = x; + if (x > right) right = x; + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + if (right < left || bottom < top) return source; + if (left == 0 && top == 0 && right == width - 1 && bottom == height - 1) return source; + return Bitmap.createBitmap(source, left, top, right - left + 1, bottom - top + 1); + } + + /** Height of a badge for a text view: the size of its text, or 14 dp when that is not known yet. */ + static int height(TextView view) { + int size = Math.round(view.getTextSize()); + if (size > 0) return size; + return Math.round(view.getResources().getDisplayMetrics().density * 14f); + } + + /** A drawable of `bitmap` as tall as `height`, tinted when `tint` is set. */ + static Drawable drawable(Bitmap bitmap, int height, Integer tint) { + BitmapDrawable drawable = new BitmapDrawable(bitmap); + if (tint != null) drawable.setColorFilter(tint.intValue(), PorterDuff.Mode.SRC_IN); + drawable.setBounds(0, 0, width(bitmap.getWidth(), bitmap.getHeight(), height), height); + return drawable; + } + + static int width(int pictureWidth, int pictureHeight, int height) { + if (pictureWidth <= 0 || pictureHeight <= 0) return height; + return Math.max(1, Math.round(height * (float) pictureWidth / pictureHeight)); + } +} diff --git a/src/lihont/two/badge/BadgeSpans.java b/src/lihont/two/badge/BadgeSpans.java new file mode 100644 index 0000000..1e3bdb9 --- /dev/null +++ b/src/lihont/two/badge/BadgeSpans.java @@ -0,0 +1,44 @@ +package lihont.two.badge; + +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.text.style.ImageSpan; +import android.view.View; +import android.widget.Toast; + +import android.graphics.drawable.Drawable; + +/** The two spans a badge is made of: its picture, and what happens when it is tapped. */ +final class BadgeSpans { + private BadgeSpans() {} + + /** Every span that belongs to a badge; the renderer removes them before it draws a text again. */ + interface Mark {} + + /** The picture. ALIGN_BOTTOM, as the other mods do it: the bottom edge sits on the line's bottom. */ + static final class Picture extends ImageSpan implements Mark { + Picture(Drawable drawable) { + super(drawable, ImageSpan.ALIGN_BOTTOM); + } + } + + /** Shows what the badge means when it is tapped. */ + static final class Tap extends ClickableSpan implements Mark { + private final String text; + + Tap(String text) { + this.text = text; + } + + @Override public void onClick(View widget) { + try { + Toast.makeText(widget.getContext().getApplicationContext(), text, Toast.LENGTH_LONG).show(); + } catch (RuntimeException ignored) { + // A message is a nicety. + } + } + + /** A badge is a picture: it keeps the colour and underline of the name around it. */ + @Override public void updateDrawState(TextPaint paint) {} + } +} diff --git a/src/lihont/two/badge/Badges.java b/src/lihont/two/badge/Badges.java new file mode 100644 index 0000000..53d03f4 --- /dev/null +++ b/src/lihont/two/badge/Badges.java @@ -0,0 +1,322 @@ +package lihont.two.badge; + +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.widget.TextView; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import lihont.two.settings.SettingsHost; + +/** + * Puts badges next to names (see lihont/badge_patch.py for how TikTok calls in). + * + * `prepare` is called for every text a TikTok text view is given. Only a text that is the very name + * object of a known account (or has been bound to one by a screen) is touched: it is rebuilt with a + * picture after the name for the LihonT badge and for each badge a catalog of another mod lists for + * that account. The rebuilt text is remembered per view so that it can be redrawn when a catalog or a + * picture arrives later, and a view that gets another text loses its account at once. + */ +public final class Badges { + static final String NICHE_TEXT = "Взгляните у него нишевый мод"; + + private static final long HOLDER_TTL_MS = 30 * 60000L; + private static final long MISS_TTL_MS = 5 * 60000L; + private static final long FAILURE_TTL_MS = 60000L; + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static final ExecutorService NETWORK = Executors.newSingleThreadExecutor(); + private static final Map HOLDERS = new HashMap(); + private static final Map CHECKED = new HashMap(); + private static final WeakHashMap BINDINGS = + new WeakHashMap(); + private static final LinkedHashSet PENDING = new LinkedHashSet(); + private static final ThreadLocal REFRESHING = new ThreadLocal(); + + private static boolean scheduled; + private static int logged; + + private static void log(String message) { + if (logged++ < 150) android.util.Log.i("LIHONT", "badge: " + message); + } + private static Method commentGetter; + + private Badges() {} + + private static long now() { + return SystemClock.elapsedRealtime(); + } + + // ---- the hook the text views call ---------------------------------------- + + /** TuxTextView.setText: returns the text to show, with badges when it names an account. */ + public static CharSequence prepare(TextView view, CharSequence text) { + if (view == null || text == null || text.length() == 0) return text; + if (Boolean.TRUE.equals(REFRESHING.get())) return text; + try { + String known = AccountNames.uid(text); + if (known == null && !(text instanceof Spanned && carries((Spanned) text)) + && view.getClass().getSimpleName().equals("TuxTextView")) { + known = AccountNames.uidByValue(text); + } + boolean touched = text instanceof Spanned && carries((Spanned) text); + if (known == null && !touched) { + synchronized (BINDINGS) { + BINDINGS.remove(view); // a recycled row must lose its previous account at once + } + return text; + } + ExternalBadges.start(view.getContext()); + SpannableStringBuilder base = new SpannableStringBuilder(text); + AccountNames.tag(base, text); + if (known != null && base.getSpans(0, base.length(), AccountNames.AccountSpan.class).length == 0) { + base.setSpan(new AccountNames.AccountSpan(known), 0, base.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + strip(base); + AccountNames.AccountSpan[] accounts = base.getSpans(0, base.length(), AccountNames.AccountSpan.class); + if (accounts.length == 0) { + synchronized (BINDINGS) { + BINDINGS.remove(view); + } + return text; + } + synchronized (BINDINGS) { + BINDINGS.put(view, base); + } + for (AccountNames.AccountSpan account : accounts) queue(account.uid); + log("name of " + accounts[0].uid + " in " + view.getClass().getSimpleName() + ", external " + ExternalBadges.badges(accounts[0].uid).size()); + return render(view, base); + } catch (RuntimeException ignored) { + return text; + } + } + + private static boolean carries(Spanned text) { + return text.getSpans(0, text.length(), AccountNames.AccountSpan.class).length > 0 + || text.getSpans(0, text.length(), BadgeSpans.Mark.class).length > 0; + } + + /** Removes the badges an earlier pass put in, together with the characters that carry them. */ + private static void strip(SpannableStringBuilder text) { + List old = new ArrayList(Arrays.asList(text.getSpans(0, text.length(), BadgeSpans.Mark.class))); + final SpannableStringBuilder target = text; + Collections.sort(old, new java.util.Comparator() { + @Override public int compare(Object a, Object b) { + return Integer.compare(target.getSpanStart(b), target.getSpanStart(a)); + } + }); + for (Object span : old) { + int start = text.getSpanStart(span); + int end = text.getSpanEnd(span); + text.removeSpan(span); + if (start >= 0 && end > start && end <= text.length() && text.subSequence(start, end).toString().equals("")) { + text.delete(start, end); + } + } + } + + private static CharSequence render(TextView view, SpannableStringBuilder base) { + SpannableStringBuilder result = new SpannableStringBuilder(base); + AccountNames.AccountSpan[] accounts = result.getSpans(0, result.length(), AccountNames.AccountSpan.class); + // Right to left, so that the positions still to be handled do not move. + final SpannableStringBuilder spans = result; + Arrays.sort(accounts, new java.util.Comparator() { + @Override public int compare(AccountNames.AccountSpan a, AccountNames.AccountSpan b) { + return Integer.compare(spans.getSpanEnd(b), spans.getSpanEnd(a)); + } + }); + int height = BadgeArt.height(view); + int gap = Math.max(3, height / 5); + boolean clickable = false; + Set done = new LinkedHashSet(); + for (AccountNames.AccountSpan account : accounts) { + int end = result.getSpanEnd(account); + if (end <= 0 || !done.add(Integer.valueOf(end))) continue; + int position = end; + if (isHolder(account.uid)) { + Drawable art = new InsetDrawable(new NicheBadgeDrawable(), gap, 0, 0, 0); + art.setBounds(0, 0, height + gap, height); + position = insert(result, position, art, NICHE_TEXT); + clickable = true; + } + for (ExternalBadges.Ready badge : ExternalBadges.badges(account.uid)) { + Drawable art = new InsetDrawable(BadgeArt.drawable(badge.bitmap, height, badge.tint), gap, 0, 0, 0); + art.setBounds(0, 0, BadgeArt.width(badge.bitmap.getWidth(), badge.bitmap.getHeight(), height) + gap, + height); + position = insert(result, position, art, badge.tap); + clickable |= badge.tap != null; + } + } + if (clickable) { + view.setMovementMethod(LinkMovementMethod.getInstance()); + view.setHighlightColor(0x00000000); + } + return result; + } + + /** One badge: the object-replacement character, the picture over it and, with text, a tap. */ + private static int insert(SpannableStringBuilder text, int at, Drawable art, String tap) { + text.insert(at, ""); + text.setSpan(new BadgeSpans.Picture(art), at, at + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (tap != null) text.setSpan(new BadgeSpans.Tap(tap), at, at + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return at + 1; + } + + // ---- who holds the niche mod badge ---------------------------------------- + + private static boolean isHolder(String uid) { + Long until = HOLDERS.get(uid); + return until != null && until.longValue() > now(); + } + + private static void queue(String uid) { + Long checked = CHECKED.get(uid); + if (checked != null && checked.longValue() > now()) return; + if (!AccountNames.validUid(uid) || PENDING.size() >= 200) return; + PENDING.add(uid); + if (!scheduled) { + scheduled = true; + MAIN.postDelayed(new Runnable() { + @Override public void run() { + flush(); + } + }, 250L); + } + } + + private static void flush() { + scheduled = false; + if (PENDING.isEmpty()) return; + final ArrayList ids = new ArrayList(); + for (String uid : PENDING) { + ids.add(uid); + if (ids.size() == 100) break; + } + PENDING.removeAll(ids); + NETWORK.execute(new Runnable() { + @Override public void run() { + Set holders = null; + try { + holders = MemberClient.holders(ids); + } catch (Exception ignored) { + // A failed lookup never gives anyone a badge. + } + final Set found = holders; + MAIN.post(new Runnable() { + @Override public void run() { + if (HOLDERS.size() > 4096) { + HOLDERS.clear(); + CHECKED.clear(); + } + for (String uid : ids) { + if (found == null) { + CHECKED.put(uid, Long.valueOf(now() + FAILURE_TTL_MS)); + } else if (found.contains(uid)) { + HOLDERS.put(uid, Long.valueOf(now() + HOLDER_TTL_MS)); + CHECKED.put(uid, Long.valueOf(now() + HOLDER_TTL_MS)); + } else { + CHECKED.put(uid, Long.valueOf(now() + MISS_TTL_MS)); + } + } + if (found != null) refreshViews(); + if (!PENDING.isEmpty()) { + scheduled = true; + MAIN.postDelayed(new Runnable() { + @Override public void run() { + flush(); + } + }, 500L); + } + } + }); + } + }); + } + + /** Redraws every bound text with what is known now (a catalog or a picture has arrived). */ + static void refreshViews() { + List> bound; + synchronized (BINDINGS) { + bound = new ArrayList>(BINDINGS.entrySet()); + } + for (Map.Entry item : bound) { + TextView view = item.getKey(); + if (view == null || !view.isAttachedToWindow()) continue; + REFRESHING.set(Boolean.TRUE); + try { + view.setText(render(view, item.getValue())); + } finally { + REFRESHING.remove(); + } + } + } + + // ---- screens that know the account without a recorded name ----------------- + + /** A comment cell: the author name view and the comment the cell shows. */ + public static void bindComment(Object cell, TextView view) { + if (cell == null || view == null) return; + try { + if (commentGetter == null || !commentGetter.getDeclaringClass().isInstance(cell)) { + for (Method method : cell.getClass().getMethods()) { + if (method.getParameterTypes().length == 0 + && method.getReturnType().getName().endsWith(".comment.model.Comment")) { + commentGetter = method; + break; + } + } + } + Object comment = commentGetter.invoke(cell); + Object user = comment.getClass().getMethod("getUser").invoke(comment); + bind(view, (String) user.getClass().getMethod("getUid").invoke(user)); + log("comment bound"); + } catch (Exception error) { + log("comment failed " + error); + } + } + + /** The profile header: the protocol holds the profile data, the view shows the nickname. */ + public static void bindProfile(Object protocol, TextView view) { + if (protocol == null || view == null) return; + try { + Object common = protocol.getClass().getMethod("LJLI").invoke(protocol); + Object info = common.getClass().getMethod("getUserProfileInfo").invoke(common); + bind(view, (String) info.getClass().getMethod("getUid").invoke(info)); + } catch (Exception ignored) { + // The name stays as TikTok drew it. + } + } + + private static void bind(TextView view, String uid) { + if (!AccountNames.validUid(uid)) return; + CharSequence current = view.getText(); + if (current == null || current.length() == 0) return; + view.setText(AccountNames.withAccount(current, uid)); + } + + /** Called by the User getters. */ + public static String remember(Object user, String name) { + return AccountNames.remember(user, name); + } + + /** Whether the other mods' badges are wanted: used by the hook that must not run when they are off. */ + public static boolean enabled() { + return SettingsHost.foreignBadges(); + } +} diff --git a/src/lihont/two/badge/ExternalBadges.java b/src/lihont/two/badge/ExternalBadges.java new file mode 100644 index 0000000..f62d7c2 --- /dev/null +++ b/src/lihont/two/badge/ExternalBadges.java @@ -0,0 +1,361 @@ +package lihont.two.badge; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.util.LruCache; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import lihont.two.settings.SettingsHost; + +/** + * The badges of the other mods, read from their public catalogs and nothing else. + * + * Two catalogs are known: TikTok You (a list of accounts, most of them with its built-in supporter + * picture) and MargyT (badge definitions, each with the accounts that hold it). Both are anonymous + * GET requests; nothing about the user or the account is sent, and what a catalog says is only ever + * data: a picture address on one of the projects' own hosts, a colour, a line of text for the tap. + * The last good copy of each catalog is kept on disk for a day, pictures for a week. + */ +final class ExternalBadges { + private static final String YOU = "https://tiktokyou.yzewe.ru/api/v1/badges"; + private static final String MARGY = "https://margy.narez.xyz/api/badges"; + private static final String MARGY_ICONS = "https://margy.narez.xyz/api/icon/"; + /** TikTok You paints its white badge masks in this colour, whatever text or theme they sit in. */ + private static final int YOU_ACCENT = 0xFFFD2C56; + private static final long REFRESH_MS = 15 * 60000L; + private static final long CATALOG_KEEP_MS = 86400000L; + private static final long PICTURE_KEEP_MS = 7 * 86400000L; + private static final int CATALOG_LIMIT = 2 * 1024 * 1024; + private static final int PICTURE_LIMIT = 512 * 1024; + private static final String[] SOURCES = {"you", "margy"}; + + /** One badge of one account, as a catalog describes it. */ + static final class Item { + final String picture; + final String source; + final Integer tint; + final String tap; + + Item(String picture, String source, Integer tint, String tap) { + this.picture = picture; + this.source = source; + this.tint = tint; + this.tap = tap; + } + } + + /** An item together with its picture, ready to be drawn. */ + static final class Ready { + final Bitmap bitmap; + final Integer tint; + final String tap; + + Ready(Bitmap bitmap, Integer tint, String tap) { + this.bitmap = bitmap; + this.tint = tint; + this.tap = tap; + } + } + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static final ExecutorService IO = Executors.newFixedThreadPool(2); + private static final Map>> CATALOGS = new HashMap>>(); + private static final Map UPDATED = new HashMap(); + private static final Set LOADING = new HashSet(); + private static final Map RETRY = new HashMap(); + private static final LruCache PICTURES = new LruCache(4 * 1024 * 1024) { + @Override protected int sizeOf(String key, Bitmap bitmap) { + return bitmap.getAllocationByteCount(); + } + }; + + private static Context context; + private static boolean fetching; + private static long nextFetch; + + private ExternalBadges() {} + + static void start(Context any) { + if (context == null) context = any.getApplicationContext(); + } + + /** Refreshes the catalogs when they are due; cheap to call on every draw. */ + static void update() { + if (context == null || !SettingsHost.foreignBadges() || fetching || SystemClock.elapsedRealtime() < nextFetch) { + return; + } + fetching = true; + nextFetch = SystemClock.elapsedRealtime() + REFRESH_MS; + IO.execute(new Runnable() { + @Override public void run() { + for (String source : SOURCES) refresh(source); + MAIN.post(new Runnable() { + @Override public void run() { + fetching = false; + } + }); + } + }); + } + + private static void refresh(final String source) { + File file = new File(context.getCacheDir(), "lihont-badges-" + source + ".json"); + try { + if (file.isFile() && System.currentTimeMillis() - file.lastModified() < CATALOG_KEEP_MS) { + deliver(source, parse(source, new String(read(new FileInputStream(file), CATALOG_LIMIT), + StandardCharsets.UTF_8)), file.lastModified()); + } + } catch (Exception ignored) { + // A damaged copy only means waiting for the network. + } + try { + byte[] data = get(source.equals("you") ? YOU : MARGY, CATALOG_LIMIT); + // Parsed before it replaces a copy that still works. + Map> parsed = parse(source, new String(data, StandardCharsets.UTF_8)); + write(file, data); + deliver(source, parsed, System.currentTimeMillis()); + } catch (Exception ignored) { + // Offline or the project's server is down: the older copy stays. + } + } + + private static void deliver(final String source, final Map> parsed, final long time) { + MAIN.post(new Runnable() { + @Override public void run() { + CATALOGS.put(source, parsed); + android.util.Log.i("LIHONT", "badge: catalog " + source + " " + parsed.size() + " accounts"); + UPDATED.put(source, Long.valueOf(time)); + Badges.refreshViews(); + } + }); + } + + static Map> parse(String source, String json) throws Exception { + Map> result = new HashMap>(); + if (source.equals("you")) { + JSONArray rows = new JSONArray(json); + if (rows.length() > 50000) throw new IOException("Catalog too large"); + for (int i = 0; i < rows.length(); i++) { + JSONObject row = rows.optJSONObject(i); + if (row == null) continue; + String picture = row.optString("badgeUrl", ""); + boolean developer = row.optBoolean("isDeveloper", false); + if (picture.length() == 0 || picture.equals("null")) { + picture = developer ? "asset:developer.png" : "asset:supporter.png"; + } + if (picture.equals("developer")) picture = "asset:developer.png"; + if (picture.equals("supporter")) picture = "asset:supporter.png"; + if (!safePicture(picture)) continue; + boolean tinted = row.has("tinted") && !row.isNull("tinted") + ? row.optBoolean("tinted") : picture.startsWith("asset:"); + String tap = row.optString("text", ""); + add(result, row.optString("uid", ""), new Item(picture, source, + tinted ? Integer.valueOf(YOU_ACCENT) : null, tap.length() == 0 ? null : tap)); + } + } else if (source.equals("margy")) { + JSONArray rows = new JSONObject(json).getJSONArray("badges"); + if (rows.length() > 200) throw new IOException("Catalog too large"); + for (int i = 0; i < rows.length(); i++) { + JSONObject row = rows.optJSONObject(i); + if (row == null) continue; + String file = row.optString("image", ""); + if (!file.matches("[A-Za-z0-9_.-]+(?:\\?[A-Za-z0-9_=&.-]+)?")) continue; + String colour = row.optString("colour", ""); + Integer tint = colour.matches("[0-9A-Fa-f]{6}") + ? Integer.valueOf((int) (0xFF000000L | Long.parseLong(colour, 16))) : null; + JSONArray users = row.optJSONArray("users"); + if (users == null || users.length() > 50000) continue; + Item item = new Item(MARGY_ICONS + file, source, tint, margyTap(row)); + for (int u = 0; u < users.length(); u++) add(result, users.optString(u, ""), item); + } + } else { + throw new IOException("Unknown catalog"); + } + return result; + } + + /** The title and text MargyT shows for a badge, in the language of the phone when it has one. */ + private static String margyTap(JSONObject row) { + String language = Locale.getDefault().getLanguage(); + String title = row.optString("title", ""); + String text = row.optString("text", ""); + if (language.equals("ru") || language.equals("uk")) { + String localTitle = row.optString("title_" + language, ""); + String localText = row.optString("text_" + language, ""); + if (localTitle.length() > 0) title = localTitle; + if (localText.length() > 0) text = localText; + } + if (title.length() == 0) return text.length() == 0 ? null : text; + return text.length() == 0 ? title : title + ": " + text; + } + + private static void add(Map> result, String uid, Item item) { + if (!AccountNames.validUid(uid)) return; + List items = result.get(uid); + if (items == null) { + items = new ArrayList(); + result.put(uid, items); + } + for (Item existing : items) { + if (existing.picture.equals(item.picture)) return; + } + if (items.size() < 8) items.add(item); + } + + /** The badges of `uid` whose pictures are loaded; the others are being fetched and arrive on a refresh. */ + static List badges(String uid) { + List result = new ArrayList(); + if (!SettingsHost.foreignBadges()) return result; + update(); + for (String source : SOURCES) { + Map> catalog = CATALOGS.get(source); + Long time = UPDATED.get(source); + if (catalog == null || time == null || System.currentTimeMillis() - time.longValue() > CATALOG_KEEP_MS) { + continue; + } + List items = catalog.get(uid); + if (items == null) continue; + for (Item item : items) { + Bitmap picture = PICTURES.get(item.picture); + if (picture == null) load(item.picture); + else result.add(new Ready(picture, item.tint, item.tap)); + } + } + return result; + } + + static boolean safePicture(String value) { + if (value.equals("asset:developer.png") || value.equals("asset:supporter.png")) return true; + try { + URL url = new URL(value); + if (!url.getProtocol().equals("https") || url.getUserInfo() != null + || (url.getPort() != -1 && url.getPort() != 443)) { + return false; + } + return (url.getHost().equals("cdn.jsdelivr.net") && url.getPath().startsWith("/gh/yzewe/tiktokyou-static@")) + || (url.getHost().equals("margy.narez.xyz") && url.getPath().startsWith("/api/icon/")); + } catch (Exception ignored) { + return false; + } + } + + private static void load(final String url) { + if (context == null || LOADING.contains(url) || LOADING.size() >= 32 || !safePicture(url)) return; + Long later = RETRY.get(url); + if (later != null && SystemClock.elapsedRealtime() < later.longValue()) return; + LOADING.add(url); + IO.execute(new Runnable() { + @Override public void run() { + Bitmap bitmap = null; + try { + bitmap = BadgeArt.crop(decode(pictureBytes(url))); + } catch (Exception ignored) { + // Retried later. + } + final Bitmap loaded = bitmap; + MAIN.post(new Runnable() { + @Override public void run() { + LOADING.remove(url); + if (loaded != null) { + PICTURES.put(url, loaded); + Badges.refreshViews(); + } else { + RETRY.put(url, Long.valueOf(SystemClock.elapsedRealtime() + 5 * 60000L)); + } + } + }); + } + }); + } + + private static byte[] pictureBytes(String url) throws Exception { + if (url.startsWith("asset:")) { + return read(context.getAssets().open("lihont/compat/" + url.substring(6)), PICTURE_LIMIT); + } + StringBuilder key = new StringBuilder(); + for (byte b : MessageDigest.getInstance("SHA-256").digest(url.getBytes(StandardCharsets.UTF_8))) { + key.append(String.format(Locale.ROOT, "%02x", b & 255)); + } + File file = new File(context.getCacheDir(), "lihont-badge-" + key + ".png"); + if (file.isFile() && System.currentTimeMillis() - file.lastModified() < PICTURE_KEEP_MS) { + return read(new FileInputStream(file), PICTURE_LIMIT); + } + byte[] data = get(url, PICTURE_LIMIT); + write(file, data); + return data; + } + + private static Bitmap decode(byte[] data) throws IOException { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(data, 0, data.length, options); + if (options.outWidth < 1 || options.outHeight < 1 || options.outWidth > 2048 || options.outHeight > 2048) { + throw new IOException("Invalid picture"); + } + options.inSampleSize = Math.max(1, Math.max(options.outWidth, options.outHeight) / 128); + options.inJustDecodeBounds = false; + Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, options); + if (bitmap == null) throw new IOException("Invalid picture"); + return bitmap; + } + + private static byte[] get(String address, int limit) throws Exception { + HttpURLConnection connection = (HttpURLConnection) new URL(address).openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(6000); + connection.setReadTimeout(6000); + connection.setRequestProperty("User-Agent", "LihonT-BadgeCompatibility/2"); + try { + if (connection.getResponseCode() != 200) throw new IOException("Catalog unavailable"); + return read(connection.getInputStream(), limit); + } finally { + connection.disconnect(); + } + } + + private static byte[] read(InputStream stream, int limit) throws Exception { + try (InputStream in = stream; ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[4096]; + int count; + while ((count = in.read(buffer)) != -1) { + out.write(buffer, 0, count); + if (out.size() > limit) throw new IOException("Reply too large"); + } + return out.toByteArray(); + } + } + + private static void write(File file, byte[] data) throws IOException { + File part = new File(file.getPath() + ".part"); + try (FileOutputStream out = new FileOutputStream(part)) { + out.write(data); + } + if (!part.renameTo(file)) throw new IOException("Cache write failed"); + } +} diff --git a/src/lihont/two/badge/MemberClient.java b/src/lihont/two/badge/MemberClient.java new file mode 100644 index 0000000..f3a9334 --- /dev/null +++ b/src/lihont/two/badge/MemberClient.java @@ -0,0 +1,78 @@ +package lihont.two.badge; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Talks to the LihonT badge service: who holds the "niche mod" badge. + * + * Only public numeric account ids go over the wire, at most a hundred per request, and nothing else + * about the user. A failed request never gives anyone a badge. + */ +final class MemberClient { + static final String API = "https://lihont.space/members/v1/"; + + private MemberClient() {} + + /** The ids out of `uids` that hold the niche mod badge. */ + static Set holders(List uids) throws Exception { + JSONObject reply = post("lookup", new JSONObject().put("uids", new JSONArray(uids))); + Set result = new HashSet(); + JSONObject niche = reply.optJSONObject("niche"); + if (niche == null) return result; + for (String uid : uids) { + if (niche.optBoolean(uid, false)) result.add(uid); + } + return result; + } + + static JSONObject post(String operation, JSONObject payload) throws Exception { + HttpURLConnection connection = (HttpURLConnection) new URL(API + operation).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(8000); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + connection.setRequestProperty("User-Agent", "LihonT-Members/2"); + byte[] body = payload.toString().getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(body.length); + try { + try (OutputStream out = connection.getOutputStream()) { + out.write(body); + } + int status = connection.getResponseCode(); + InputStream stream = status == 200 ? connection.getInputStream() : connection.getErrorStream(); + JSONObject reply = stream == null ? new JSONObject() : new JSONObject(readLimited(stream)); + if (status != 200) { + throw new IOException(reply.optString("error", "Membership request failed (" + status + ")")); + } + return reply; + } finally { + connection.disconnect(); + } + } + + private static String readLimited(InputStream stream) throws IOException { + try (InputStream in = stream; ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[2048]; + int count; + while ((count = in.read(buffer)) >= 0) { + out.write(buffer, 0, count); + if (out.size() > 32768) throw new IOException("Membership reply too large"); + } + return out.toString("UTF-8"); + } + } +} diff --git a/src/lihont/two/badge/NicheBadgeDrawable.java b/src/lihont/two/badge/NicheBadgeDrawable.java new file mode 100644 index 0000000..825e3cc --- /dev/null +++ b/src/lihont/two/badge/NicheBadgeDrawable.java @@ -0,0 +1,74 @@ +package lihont.two.badge; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; + +/** + * The badge of LihonT: a single eighth note on a Material 3 flower (a central disk and eight petals). + * + * Drawn from the same geometry as src/brand/logo_note_flower.svg on a 512 unit square, so it stays + * sharp at any size and needs no picture file. + */ +final class NicheBadgeDrawable extends Drawable { + private static final float FLOWER_RADIUS = 240f; + private static final int FLOWER = 0xFFE26D6D; + private static final int NOTE = 0xFF4A121E; + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path flag = new Path(); + private final RectF stem = new RectF(250f, 115f, 278f, 345f); + private final RectF head = new RectF(140f, 297f, 270f, 393f); + + NicheBadgeDrawable() { + flag.moveTo(264f, 115f); + flag.cubicTo(300f, 115f, 365f, 140f, 375f, 205f); + flag.cubicTo(380f, 238f, 358f, 265f, 338f, 270f); + flag.cubicTo(320f, 274f, 308f, 258f, 316f, 242f); + flag.cubicTo(328f, 220f, 338f, 195f, 330f, 175f); + flag.cubicTo(320f, 152f, 288f, 145f, 264f, 145f); + flag.close(); + } + + @Override public void draw(Canvas canvas) { + Rect bounds = getBounds(); + float side = Math.min(bounds.width(), bounds.height()); + if (side <= 0f) return; + canvas.save(); + canvas.translate(bounds.left + (bounds.width() - side) / 2f, bounds.top + (bounds.height() - side) / 2f); + canvas.scale(side / 512f, side / 512f); + + paint.setStyle(Paint.Style.FILL); + paint.setColor(FLOWER); + canvas.drawCircle(256f, 256f, FLOWER_RADIUS * 0.70f, paint); + for (int i = 0; i < 8; i++) { + double angle = Math.toRadians(i * 45 + 22.5); + canvas.drawCircle(256f + (float) Math.cos(angle) * FLOWER_RADIUS * 0.62f, + 256f + (float) Math.sin(angle) * FLOWER_RADIUS * 0.62f, FLOWER_RADIUS * 0.38f, paint); + } + + // the note, scaled to 95% about the middle of its own box, as in the SVG + canvas.translate(256f, 256f); + canvas.scale(0.95f, 0.95f); + canvas.translate(-260f, -254f); + paint.setColor(NOTE); + canvas.save(); + canvas.rotate(-20f, 205f, 345f); + canvas.drawOval(new RectF(205f - 65f, 345f - 48f, 205f + 65f, 345f + 48f), paint); + canvas.restore(); + canvas.drawRoundRect(stem, 14f, 14f, paint); + canvas.drawPath(flag, paint); + canvas.restore(); + } + + @Override public void setAlpha(int alpha) { paint.setAlpha(alpha); } + + @Override public void setColorFilter(ColorFilter filter) { paint.setColorFilter(filter); } + + @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } +} diff --git a/src/lihont/two/badge/NicheClaim.java b/src/lihont/two/badge/NicheClaim.java new file mode 100644 index 0000000..e12418c --- /dev/null +++ b/src/lihont/two/badge/NicheClaim.java @@ -0,0 +1,111 @@ +package lihont.two.badge; + +import android.os.Handler; +import android.os.Looper; + +import org.json.JSONObject; + +import java.lang.reflect.Method; + +/** + * Getting the niche mod badge: the account's code goes into its TikTok bio, then the service reads the + * public profile and, if the code is there, adds the account to the holders. + * + * Only the numeric account id and the public username are sent. Nothing is read from TikTok's session. + */ +public final class NicheClaim { + public interface Callback { + void done(boolean ok, String message, String code); + } + + /** The signed-in account: id and public username, or null when nobody is signed in. */ + public static final class Account { + public final String uid; + public final String uniqueId; + + Account(String uid, String uniqueId) { + this.uid = uid; + this.uniqueId = uniqueId; + } + } + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + + private NicheClaim() {} + + public static Account account() { + try { + Class manager = Class.forName("com.ss.android.ugc.aweme.framework.services.ServiceManager"); + Object instance = manager.getMethod("get").invoke(null); + Class api = Class.forName("com.ss.android.ugc.aweme.IAccountUserService"); + Object service = manager.getMethod("getService", Class.class).invoke(instance, api); + if (service == null || !Boolean.TRUE.equals(api.getMethod("isLogin").invoke(service))) return null; + String uid = (String) api.getMethod("getCurUserId").invoke(service); + Object user = api.getMethod("getCurUser").invoke(service); + Method unique = user.getClass().getMethod("getUniqueId"); + String name = (String) unique.invoke(user); + if (!AccountNames.validUid(uid) || name == null || name.length() == 0) return null; + return new Account(uid, name); + } catch (Exception unavailable) { + return null; + } + } + + /** Asks the service for the code to put into the bio. */ + public static void fetchCode(final Account account, final Callback callback) { + run(new Task() { + @Override public void run(Result result) throws Exception { + JSONObject reply = MemberClient.post("code", new JSONObject().put("uid", account.uid)); + if (!reply.optBoolean("open", true)) { + result.ok = false; + result.message = "Выдача значка закончилась"; + return; + } + result.ok = true; + result.code = reply.getString("code"); + } + }, callback); + } + + /** Asks the service to look for the code in the bio and issue the badge. */ + public static void claim(final Account account, final Callback callback) { + run(new Task() { + @Override public void run(Result result) throws Exception { + JSONObject reply = MemberClient.post("claim", + new JSONObject().put("uid", account.uid).put("unique_id", account.uniqueId)); + result.ok = reply.optBoolean("granted", false); + result.message = result.ok ? "Значок выдан. Он появится рядом с ником через несколько минут." + : "Кода в описании профиля не нашли. Вставьте его и подождите минуту."; + } + }, callback); + } + + private static final class Result { + boolean ok; + String message; + String code; + } + + private interface Task { + void run(Result result) throws Exception; + } + + private static void run(final Task task, final Callback callback) { + new Thread(new Runnable() { + @Override public void run() { + final Result result = new Result(); + try { + task.run(result); + } catch (Exception error) { + result.ok = false; + result.message = error.getMessage() == null ? "Нет связи с сервером" : error.getMessage(); + } + MAIN.post(new Runnable() { + @Override public void run() { + callback.done(result.ok, result.message, result.code); + } + }); + } + }, "lihont-claim").start(); + } +} diff --git a/src/lihont/two/bar/AvatarBadge.java b/src/lihont/two/bar/AvatarBadge.java new file mode 100644 index 0000000..303b60a --- /dev/null +++ b/src/lihont/two/bar/AvatarBadge.java @@ -0,0 +1,87 @@ +package lihont.two.bar; + +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Shader; +import android.graphics.drawable.Drawable; +import android.view.View; + +/** + * The user's avatar in a circle, drawn over the profile tab's icon, with a ring while the tab is selected. + * + * It lives in the tab's view overlay: above the icon, outside the layout, never taking a touch. + */ +final class AvatarBadge extends Drawable { + private static final float DIAMETER_DP = 26f; + private static final float RING_GAP_DP = 2.5f; + private static final float RING_WIDTH_DP = 2f; + + private final View item; + private final View icon; + private final float density; + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + private final Paint edge = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint ring = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Matrix matrix = new Matrix(); + private Bitmap bitmap; + private BitmapShader shader; + + AvatarBadge(View item, View icon, float density, int ringColor) { + this.item = item; + this.icon = icon; + this.density = density; + ring.setStyle(Paint.Style.STROKE); + ring.setStrokeWidth(RING_WIDTH_DP * density); + ring.setColor(ringColor); + edge.setStyle(Paint.Style.STROKE); + edge.setStrokeWidth(Math.max(1f, density)); + edge.setColor((ringColor & 0x00ffffff) | 0x33000000); + } + + boolean hasBitmap() { + return bitmap != null; + } + + void setBitmap(Bitmap value) { + bitmap = value; + shader = value == null ? null : new BitmapShader(value, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP); + fill.setShader(shader); + invalidateSelf(); + } + + void setRingColor(int color) { + ring.setColor(color); + edge.setColor((color & 0x00ffffff) | 0x33000000); + invalidateSelf(); + } + + @Override public void draw(Canvas canvas) { + if (bitmap == null || shader == null) return; + float cx = (icon.getLeft() + icon.getRight()) / 2f; + float cy = (icon.getTop() + icon.getBottom()) / 2f; + float radius = DIAMETER_DP * density / 2f; + matrix.setScale(2f * radius / bitmap.getWidth(), 2f * radius / bitmap.getHeight()); + matrix.postTranslate(cx - radius, cy - radius); + shader.setLocalMatrix(matrix); + canvas.drawCircle(cx, cy, radius, fill); + if (item.isSelected()) { + canvas.drawCircle(cx, cy, radius + (RING_GAP_DP + RING_WIDTH_DP / 2f) * density, ring); + } else { + canvas.drawCircle(cx, cy, radius - edge.getStrokeWidth() / 2f, edge); + } + } + + /** The badge is always opaque; the thin edge keeps its own translucency. */ + @Override public void setAlpha(int alpha) { } + + @Override public void setColorFilter(ColorFilter filter) { + fill.setColorFilter(filter); + } + + @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } +} diff --git a/src/lihont/two/bar/AvatarCache.java b/src/lihont/two/bar/AvatarCache.java new file mode 100644 index 0000000..b1d3393 --- /dev/null +++ b/src/lihont/two/bar/AvatarCache.java @@ -0,0 +1,195 @@ +package lihont.two.bar; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; + +/** + * The current user's avatar as a small square bitmap, kept in memory and in one private file. + * + * The address TikTok gives is a signed CDN link whose query part changes, so the picture is keyed + * by host and path. A known picture is shown at once from disk; the network is only asked when the + * key changes. Only https is fetched, with short timeouts and a size cap. + */ +final class AvatarCache { + interface Listener { + void onAvatar(Bitmap avatar); + } + + private static final int SIDE = 192; + private static final int MAX_BYTES = 2 * 1024 * 1024; + private static final int TIMEOUT_MS = 8000; + private static final String FILE_PREFIX = "avatar-"; + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static Bitmap bitmap; + private static String key; + private static boolean loading; + + private AvatarCache() {} + + /** What is known right now, possibly nothing. */ + static synchronized Bitmap current() { + return bitmap; + } + + /** + * Brings the avatar up to date and reports the result on the main thread. A logged-out user + * (no address) reports null so the tab can go back to TikTok's own icon. + */ + static void refresh(final Context context, final Listener listener) { + final Context app = context.getApplicationContext() != null ? context.getApplicationContext() : context; + final List urls = UserAvatar.thumbnailUrls(); + synchronized (AvatarCache.class) { + if (urls.isEmpty()) { + bitmap = null; + key = null; + post(listener, null); + return; + } + String wanted = keyOf(urls.get(0)); + if (bitmap != null && wanted.equals(key)) { + post(listener, bitmap); + return; + } + if (loading) return; + loading = true; + } + final String wanted = keyOf(urls.get(0)); + new Thread(new Runnable() { + @Override public void run() { + Bitmap result = null; + try { + File file = fileFor(app, wanted); + result = file.isFile() ? BitmapFactory.decodeFile(file.getAbsolutePath()) : null; + if (result == null) { + for (String url : urls) { + byte[] data = download(url); + if (data == null) continue; + result = decode(data); + if (result != null) { + store(app, wanted, result); + break; + } + } + } + } catch (Throwable ignored) { + // No avatar this time; the next resume asks again. + } + synchronized (AvatarCache.class) { + loading = false; + if (result != null) { + bitmap = result; + key = wanted; + } + } + post(listener, result); + } + }, "lihont-avatar").start(); + } + + private static void post(final Listener listener, final Bitmap value) { + MAIN.post(new Runnable() { + @Override public void run() { + try { + listener.onAvatar(value); + } catch (Throwable ignored) { + // Decoration only. + } + } + }); + } + + /** Host and path: the part of the signed link that identifies the picture. */ + static String keyOf(String url) { + Uri uri = Uri.parse(url); + String raw = uri.getHost() + uri.getPath(); + return Integer.toHexString(raw.hashCode()) + Integer.toHexString(raw.length()); + } + + private static File directory(Context context) { + return new File(new File(context.getFilesDir(), "lihont"), "avatar"); + } + + private static File fileFor(Context context, String name) { + return new File(directory(context), FILE_PREFIX + name + ".png"); + } + + private static void store(Context context, String name, Bitmap value) throws IOException { + File directory = directory(context); + if (!directory.isDirectory() && !directory.mkdirs()) return; + File[] old = directory.listFiles(); + if (old != null) { + for (File file : old) { + if (file.getName().startsWith(FILE_PREFIX)) file.delete(); + } + } + FileOutputStream out = new FileOutputStream(fileFor(context, name)); + try { + value.compress(Bitmap.CompressFormat.PNG, 100, out); + } finally { + out.close(); + } + } + + private static byte[] download(String address) { + if (!address.startsWith("https://")) return null; + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(address).openConnection(); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(TIMEOUT_MS); + connection.setInstanceFollowRedirects(false); + if (connection.getResponseCode() != 200) return null; + if (connection.getContentLength() > MAX_BYTES) return null; + InputStream in = connection.getInputStream(); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) > 0) { + out.write(buffer, 0, count); + if (out.size() > MAX_BYTES) return null; + } + return out.toByteArray(); + } finally { + in.close(); + } + } catch (IOException failed) { + return null; + } finally { + if (connection != null) connection.disconnect(); + } + } + + private static Bitmap decode(byte[] data) { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(data, 0, data.length, bounds); + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null; + int shorter = Math.min(bounds.outWidth, bounds.outHeight); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = Math.max(1, shorter / (SIDE * 2)); + Bitmap decoded = BitmapFactory.decodeByteArray(data, 0, data.length, options); + if (decoded == null) return null; + int side = Math.min(decoded.getWidth(), decoded.getHeight()); + Bitmap square = Bitmap.createBitmap(decoded, (decoded.getWidth() - side) / 2, + (decoded.getHeight() - side) / 2, side, side); + Bitmap scaled = Bitmap.createScaledBitmap(square, SIDE, SIDE, true); + if (scaled != square) square.recycle(); + if (square != decoded && !decoded.isRecycled()) decoded.recycle(); + return scaled; + } +} diff --git a/src/lihont/two/bar/BarStyle.java b/src/lihont/two/bar/BarStyle.java new file mode 100644 index 0000000..173cd80 --- /dev/null +++ b/src/lihont/two/bar/BarStyle.java @@ -0,0 +1,250 @@ +package lihont.two.bar; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.ImageView; +import android.widget.TextView; + +import lihont.two.settings.Md3; +import lihont.two.settings.ModSettings; + +/** + * Material 3 styling for TikTok's five-tab bottom bar, laid over the views TikTok built. + * + * Nothing in the layout changes: the bar gets a tonal background, each tab gets a pill as its + * background, and the profile tab gets the user's avatar in its view overlay while TikTok's own icon + * is kept transparent. The strip below the bar (window background, system scrim and TikTok's own + * filler view) is painted in the same tone. Every frame a light check keeps all of it true when + * TikTok redraws or restyles the tabs, for instance by recolouring the bar's background in place. + */ +final class BarStyle implements ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener, + AvatarCache.Listener { + static final int TABS = 5; + static final int CREATE = 2; + static final int PROFILE = 4; + /** TikTok's own filler under the bar: transparent on the home tab, opaque black on the others. */ + private static final String STRIP_ID = "gsa"; + + private static BarStyle current; + + private final Activity activity; + private final ViewGroup bar; + private final ModSettings.Theme theme; + private final float density; + private final NavPill[] pills = new NavPill[TABS]; + private ColorDrawable tint; + private AvatarBadge badge; + private View profileItem; + private View profileIcon; + private Boolean dark; + private int tintColor; + private ColorDrawable windowTint; + private View strip; + private int replaced; + + private BarStyle(Activity activity, ViewGroup bar, ModSettings.Theme theme) { + this.activity = activity; + this.bar = bar; + this.theme = theme; + this.density = activity.getResources().getDisplayMetrics().density; + } + + /** Styles `bar` once; a second call for the same bar only refreshes the avatar. */ + static void attach(Activity activity, ViewGroup bar, ModSettings.Theme theme) { + if (current != null && current.bar == bar) { + current.refreshAvatar(); + return; + } + BarStyle style = new BarStyle(activity, bar, theme); + current = style; + style.install(); + } + + private void install() { + dark = isDark(); + Md3.Palette palette = palette(dark); + // The bar itself becomes a tonal surface, like the profile menu. TikTok paints it black or + // white to suit its own icons, so the shade is chosen from the colour of its labels. + tintColor = palette.surfaceContainer; + tint = new ColorDrawable(tintColor); + bar.setBackground(tint); + paintNavigationBar(); + int styled = 0; + for (int i = 0; i < TABS; i++) { + View item = bar.getChildAt(i); + View icon = firstImage(item); + if (icon == null) continue; + if (i == PROFILE) { + profileItem = item; + profileIcon = icon; + } + if (i == CREATE) continue; + Drawable existing = item.getBackground(); + if (existing != null && !(existing instanceof NavPill)) continue; + NavPill pill = new NavPill(icon, density, palette.secondaryContainer); + item.setBackground(pill); + pill.snapTo(item.isSelected()); + pills[i] = pill; + styled++; + } + bar.getViewTreeObserver().addOnPreDrawListener(this); + bar.addOnAttachStateChangeListener(this); + android.util.Log.i("LihonT", "bar-style pills=" + styled + " profile=" + (profileIcon != null) + + " dark=" + dark); + refreshAvatar(); + } + + /** + * The strip under the bar is not part of TikTok's layout: it is the window's own background with + * the system's dark scrim over it. The window takes the bar's colour and the scrim is switched + * off, so the bar seems to reach the bottom edge. + */ + private void paintNavigationBar() { + android.view.Window window = activity.getWindow(); + View decor = window.getDecorView(); + if (windowTint == null) windowTint = new ColorDrawable(tintColor); + if (decor.getBackground() != windowTint) decor.setBackground(windowTint); + if (windowTint.getColor() != tintColor) windowTint.setColor(tintColor); + if (android.os.Build.VERSION.SDK_INT >= 29 && window.isNavigationBarContrastEnforced()) { + window.setNavigationBarContrastEnforced(false); + } + if (window.getNavigationBarColor() != tintColor) window.setNavigationBarColor(tintColor); + paintStrip(decor); + } + + /** TikTok fills that strip with black itself on every tab but the home one; it takes the bar's colour too. */ + private void paintStrip(View decor) { + if (strip == null || !strip.isAttachedToWindow()) { + int id = activity.getResources().getIdentifier(STRIP_ID, "id", activity.getPackageName()); + strip = id == 0 ? null : decor.findViewById(id); + } + if (strip == null || strip.getBackground() == null) return; + Drawable background = strip.getBackground().mutate(); + if (!(background instanceof ColorDrawable)) return; + ColorDrawable fill = (ColorDrawable) background; + if (Color.alpha(fill.getColor()) != 0 && fill.getColor() != tintColor) fill.setColor(tintColor); + } + + private void refreshAvatar() { + if (profileItem != null) AvatarCache.refresh(activity, this); + } + + @Override public void onAvatar(Bitmap avatar) { + if (profileItem == null || profileIcon == null) return; + if (avatar == null) { + if (badge != null) { + profileItem.getOverlay().remove(badge); + badge = null; + } + profileIcon.setAlpha(1f); + return; + } + if (badge == null) { + badge = new AvatarBadge(profileItem, profileIcon, density, palette(isDark()).onSurface); + profileItem.getOverlay().add(badge); + } + badge.setBitmap(avatar); + profileIcon.setAlpha(0f); + android.util.Log.i("LihonT", "bar-avatar shown"); + } + + @Override public boolean onPreDraw() { + try { + if (badge != null && badge.hasBitmap() && profileIcon.getAlpha() != 0f) profileIcon.setAlpha(0f); + if (badge != null) badge.setBounds(0, 0, profileItem.getWidth(), profileItem.getHeight()); + if (bar.getBackground() != tint) { + if (replaced++ < 6) { + android.util.Log.i("LihonT", "bar-style background replaced by " + bar.getBackground()); + } + bar.setBackground(tint); + } + boolean now = isDark(); + if (dark == null || dark != now) { + dark = now; + Md3.Palette palette = palette(now); + tintColor = palette.surfaceContainer; + for (NavPill pill : pills) { + if (pill != null) pill.setColor(palette.secondaryContainer); + } + if (badge != null) badge.setRingColor(palette.onSurface); + } + // TikTok recolours the bar by View.setBackgroundColor, which rewrites an existing + // ColorDrawable in place, so the drawable can stay ours while its colour is not. + if (tint.getColor() != tintColor || tint.getAlpha() != 255) { + if (replaced++ < 6) { + android.util.Log.i("LihonT", "bar-style tint was #" + Integer.toHexString(tint.getColor())); + } + tint.setAlpha(255); + tint.setColor(tintColor); + } + paintNavigationBar(); + } catch (Throwable failure) { + // Decoration must not break a frame. + if (replaced++ < 6) android.util.Log.i("LihonT", "bar-style frame check failed: " + failure); + } + return true; + } + + @Override public void onViewAttachedToWindow(View v) { + bar.getViewTreeObserver().removeOnPreDrawListener(this); + bar.getViewTreeObserver().addOnPreDrawListener(this); + } + + @Override public void onViewDetachedFromWindow(View v) { + bar.getViewTreeObserver().removeOnPreDrawListener(this); + android.util.Log.i("LihonT", "bar-style detached"); + if (current == this) current = null; + } + + private Md3.Palette palette(boolean night) { + return Md3.palette(activity, night ? ModSettings.Theme.DARK : ModSettings.Theme.LIGHT); + } + + /** + * Whether TikTok wants a dark bar right now: its selected label is light on a dark bar and dark + * on a light one. Without a readable label the mod's own theme decides. + */ + private boolean isDark() { + TextView label = null; + for (int i = 0; i < bar.getChildCount(); i++) { + View tab = bar.getChildAt(i); + TextView text = firstText(tab); + if (text == null) continue; + if (tab.isSelected()) { + label = text; + break; + } + if (label == null) label = text; + } + if (label != null) { + int color = label.getCurrentTextColor(); + return 0.299f * Color.red(color) + 0.587f * Color.green(color) + 0.114f * Color.blue(color) > 128f; + } + return Md3.palette(activity, theme).dark; + } + + private static TextView firstText(View item) { + if (!(item instanceof ViewGroup)) return null; + ViewGroup group = (ViewGroup) item; + for (int i = 0; i < group.getChildCount(); i++) { + if (group.getChildAt(i) instanceof TextView) return (TextView) group.getChildAt(i); + } + return null; + } + + private static View firstImage(View item) { + if (!(item instanceof ViewGroup)) return null; + ViewGroup group = (ViewGroup) item; + for (int i = 0; i < group.getChildCount(); i++) { + View child = group.getChildAt(i); + if (child.getClass() == ImageView.class) return child; + } + return null; + } +} diff --git a/src/lihont/two/bar/BottomBar.java b/src/lihont/two/bar/BottomBar.java new file mode 100644 index 0000000..91fbae2 --- /dev/null +++ b/src/lihont/two/bar/BottomBar.java @@ -0,0 +1,77 @@ +package lihont.two.bar; + +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; + +import lihont.two.comments.CommentSkin; +import lihont.two.feed.HomeTabs; +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; + +/** + * Called by TikTok's main screen whenever it comes to the front (see lihont/bar_patch.py). + * + * Finds the bottom tab row and hands it to BarStyle. The row is an obfuscated view, found by the + * resource id name it has in TikTok 47.1.3 and checked by shape: five children, each a clickable + * tab with a description. Anything that does not fit is left exactly as TikTok drew it. + */ +public final class BottomBar { + private static final String ROW_ID = "opq"; + private static final int ATTEMPTS = 10; + private static final long RETRY_MS = 500L; + + private BottomBar() {} + + public static void onResumed(final Activity activity) { + try { + HomeTabs.watch(activity); + } catch (Throwable ignored) { + // The feed tabs are decoration too. + } + try { + ModSettings settings = SettingsHost.active(); + if (settings != null && !settings.md3()) return; + ModSettings.Theme theme = settings != null ? settings.theme() : ModSettings.defaults().theme(); + ProfileMenu.watch(activity, theme); + CommentSkin.watch(activity, theme); + locate(activity, theme, 0); + } catch (Throwable ignored) { + // The bar is decoration: never let it take the main screen down. + } + } + + private static void locate(final Activity activity, final ModSettings.Theme theme, final int attempt) { + try { + View decor = activity.getWindow().getDecorView(); + ViewGroup row = find(activity, decor); + if (row != null) { + BarStyle.attach(activity, row, theme); + return; + } + if (attempt < ATTEMPTS && !activity.isFinishing()) { + decor.postDelayed(new Runnable() { + @Override public void run() { + locate(activity, theme, attempt + 1); + } + }, RETRY_MS); + } + } catch (Throwable ignored) { + // Same: best effort. + } + } + + private static ViewGroup find(Activity activity, View decor) { + int id = activity.getResources().getIdentifier(ROW_ID, "id", activity.getPackageName()); + if (id == 0) return null; + View view = decor.findViewById(id); + if (!(view instanceof ViewGroup)) return null; + ViewGroup row = (ViewGroup) view; + if (row.getChildCount() != BarStyle.TABS) return null; + for (int i = 0; i < row.getChildCount(); i++) { + View tab = row.getChildAt(i); + if (!tab.isClickable() || tab.getContentDescription() == null) return null; + } + return row; + } +} diff --git a/src/lihont/two/bar/NavPill.java b/src/lihont/two/bar/NavPill.java new file mode 100644 index 0000000..bc456a6 --- /dev/null +++ b/src/lihont/two/bar/NavPill.java @@ -0,0 +1,105 @@ +package lihont.two.bar; + +import android.animation.ValueAnimator; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.view.View; + +/** + * The Material 3 navigation bar's active indicator: a pill behind the icon of the selected tab. + * + * Used as the tab's background, so it sits under the icon and changes nothing in the layout. The + * tab's own selected state, which arrives here as a drawable state, grows and fades it in and out. + */ +final class NavPill extends Drawable { + private static final float WIDTH_DP = 64f; + private static final float HEIGHT_DP = 32f; + private static final long DURATION_MS = 200L; + + private final View icon; + private final float density; + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF rect = new RectF(); + private float progress; + private boolean selected; + private ValueAnimator animator; + + NavPill(View icon, float density, int color) { + this.icon = icon; + this.density = density; + paint.setColor(color); + } + + void setColor(int color) { + if (paint.getColor() != color) { + paint.setColor(color); + invalidateSelf(); + } + } + + /** Takes the current state without animating, for the moment the pill is installed. */ + void snapTo(boolean on) { + stop(); + selected = on; + progress = on ? 1f : 0f; + invalidateSelf(); + } + + @Override public boolean isStateful() { + return true; + } + + @Override protected boolean onStateChange(int[] state) { + boolean now = false; + for (int item : state) { + if (item == android.R.attr.state_selected) now = true; + } + if (now == selected) return false; + selected = now; + animateTo(now ? 1f : 0f); + return true; + } + + private void animateTo(float target) { + stop(); + animator = ValueAnimator.ofFloat(progress, target); + animator.setDuration(DURATION_MS); + animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override public void onAnimationUpdate(ValueAnimator animation) { + progress = (Float) animation.getAnimatedValue(); + invalidateSelf(); + } + }); + animator.start(); + } + + private void stop() { + if (animator != null) { + animator.cancel(); + animator = null; + } + } + + @Override public void draw(Canvas canvas) { + if (progress <= 0.001f) return; + float width = WIDTH_DP * density * (0.35f + 0.65f * progress); + float height = HEIGHT_DP * density; + float cx = (icon.getLeft() + icon.getRight()) / 2f; + float cy = (icon.getTop() + icon.getBottom()) / 2f; + rect.set(cx - width / 2f, cy - height / 2f, cx + width / 2f, cy + height / 2f); + int alpha = paint.getAlpha(); + paint.setAlpha(Math.round(alpha * progress)); + canvas.drawRoundRect(rect, height / 2f, height / 2f, paint); + paint.setAlpha(alpha); + } + + @Override public void setAlpha(int alpha) { paint.setAlpha(alpha); } + + @Override public void setColorFilter(ColorFilter filter) { paint.setColorFilter(filter); } + + @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } +} diff --git a/src/lihont/two/bar/ProfileMenu.java b/src/lihont/two/bar/ProfileMenu.java new file mode 100644 index 0000000..81f3d35 --- /dev/null +++ b/src/lihont/two/bar/ProfileMenu.java @@ -0,0 +1,175 @@ +package lihont.two.bar; + +import android.app.Activity; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.InsetDrawable; +import android.os.SystemClock; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; +import java.util.WeakHashMap; + +import lihont.two.settings.Md3; +import lihont.two.settings.ModSettings; + +/** + * Material 3 look for the menu that slides in from the profile's burger button (balance, + * activity centre, offline videos, QR code, Studio, settings). + * + * The menu is plain views, inflated when it is first opened, so the main screen's layout is + * watched (at most every 350 ms) until the list shows up, and again whenever its rows are rebuilt. + * The panel gets a rounded edge and a tonal surface, each row becomes a pill like the mod's own + * settings, section titles take the primary colour, and icons are tinted. No size or position + * changes: everything is a background, a colour or a filter. + */ +final class ProfileMenu { + private static final String PANEL = "v29"; + private static final String LIST = "t91"; + private static final String CELL = "dui"; + private static final String ICON = "klg"; + private static final String ACCESSORY = "tux_textCell_accessory"; + private static final String TITLE = "zkj"; + private static final long THROTTLE_MS = 350L; + + private static final WeakHashMap WATCHED = new WeakHashMap(); + private static final WeakHashMap STYLED = new WeakHashMap(); + // the panel's first colour, read before it is replaced: later passes must not take our own for TikTok's + private static final WeakHashMap DARK = new WeakHashMap(); + + private ProfileMenu() {} + + /** Starts watching `activity`'s window for the menu; once per window. */ + static void watch(final Activity activity, final ModSettings.Theme theme) { + final View decor = activity.getWindow().getDecorView(); + if (WATCHED.containsKey(decor)) return; + final int list = id(activity, LIST); + if (list == 0) return; + WATCHED.put(decor, Boolean.TRUE); + decor.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + private long last; + + @Override public void onGlobalLayout() { + long now = SystemClock.uptimeMillis(); + if (now - last < THROTTLE_MS) return; + last = now; + try { + View found = decor.findViewById(list); + if (found instanceof ViewGroup) style(activity, decor, (ViewGroup) found, theme); + } catch (Throwable ignored) { + // Decoration must not break a layout pass. + } + } + }); + } + + private static void style(Activity activity, View decor, ViewGroup list, ModSettings.Theme theme) { + int signature = list.getChildCount() * 31 + System.identityHashCode(list.getChildAt(list.getChildCount() - 1)); + Integer done = STYLED.get(list); + if (done != null && done == signature) return; + View panel = decor.findViewById(id(activity, PANEL)); + Boolean remembered = panel != null ? DARK.get(panel) : null; + boolean dark = remembered != null ? remembered : darkOf(panel, activity, theme); + if (panel != null) DARK.put(panel, dark); + Md3.Palette palette = Md3.palette(activity, dark ? ModSettings.Theme.DARK : ModSettings.Theme.LIGHT); + float density = activity.getResources().getDisplayMetrics().density; + if (panel != null) panel.setBackground(panelShape(density, palette.surfaceContainerLow)); + int cell = id(activity, CELL); + int icon = id(activity, ICON); + int accessory = id(activity, ACCESSORY); + int title = id(activity, TITLE); + + List all = new ArrayList(); + collect(list, all); + for (View view : all) { + if (view.getId() == cell && cell != 0 && view.getParent() instanceof View) { + styleRow((View) view.getParent(), view, palette, density, icon, accessory, title); + } else if (view.getClass() == View.class && view.getHeight() <= Math.round(4 * density)) { + view.setBackgroundColor(Color.TRANSPARENT); + } else if (view instanceof TextView && view.getId() == View.NO_ID && !insideCell(view, cell)) { + ((TextView) view).setTextColor(palette.primary); + } + } + STYLED.put(list, signature); + android.util.Log.i("LihonT", "menu-style rows=" + all.size() + " dark=" + dark); + } + + private static void styleRow(View row, View cell, Md3.Palette palette, float density, int icon, + int accessory, int title) { + int side = Math.round(12 * density); + int gap = Math.round(1 * density); + GradientDrawable tile = new GradientDrawable(); + tile.setColor(palette.surfaceContainerHigh); + tile.setCornerRadius(24 * density); + row.setBackground(new InsetDrawable(tile, side, gap, side, gap)); + + View name = cell.findViewById(title); + if (name instanceof TextView) ((TextView) name).setTextColor(palette.onSurface); + View chevronBox = cell.findViewById(accessory); + List inner = new ArrayList(); + collect(cell, inner); + for (View view : inner) { + if (!(view instanceof ImageView) || view.getId() != icon) continue; + boolean chevron = chevronBox instanceof ViewGroup && isInside(view, (ViewGroup) chevronBox); + ((ImageView) view).setColorFilter(chevron ? palette.onSurfaceVariant : palette.onSurface, + PorterDuff.Mode.SRC_IN); + } + } + + private static Drawable panelShape(float density, int color) { + GradientDrawable shape = new GradientDrawable(); + shape.setColor(color); + float r = 28 * density; + shape.setCornerRadii(new float[] { r, r, 0, 0, 0, 0, r, r }); + return shape; + } + + /** TikTok's own colour for the panel when it is a plain one, else the mod's theme. */ + private static boolean darkOf(View panel, Activity activity, ModSettings.Theme theme) { + if (panel != null && panel.getBackground() instanceof ColorDrawable) { + int color = ((ColorDrawable) panel.getBackground()).getColor(); + if (Color.alpha(color) > 200) { + return 0.299f * Color.red(color) + 0.587f * Color.green(color) + 0.114f * Color.blue(color) < 128f; + } + } + return Md3.palette(activity, theme).dark; + } + + private static boolean insideCell(View view, int cell) { + for (android.view.ViewParent parent = view.getParent(); parent instanceof View; + parent = parent.getParent()) { + if (((View) parent).getId() == cell && cell != 0) return true; + } + return false; + } + + private static boolean isInside(View view, ViewGroup box) { + for (android.view.ViewParent parent = view.getParent(); parent != null; parent = parent.getParent()) { + if (parent == box) return true; + } + return false; + } + + private static void collect(View view, List into) { + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + View child = group.getChildAt(i); + into.add(child); + collect(child, into); + } + } + } + + private static int id(Activity activity, String name) { + return activity.getResources().getIdentifier(name, "id", activity.getPackageName()); + } +} diff --git a/src/lihont/two/bar/UserAvatar.java b/src/lihont/two/bar/UserAvatar.java new file mode 100644 index 0000000..8eaca2c --- /dev/null +++ b/src/lihont/two/bar/UserAvatar.java @@ -0,0 +1,46 @@ +package lihont.two.bar; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * The logged-in user's avatar address, read from TikTok through its public service interfaces. + * + * Reflection on names TikTok itself keeps (the service manager, IAccountUserService, getCurUser, + * getAvatarThumb, getUrlList): the mod's classes are merged into TikTok's own dex, but compile + * against android.jar only. Anything missing or renamed ends as an empty list, never an exception. + */ +final class UserAvatar { + private static final String SERVICE_MANAGER = "com.ss.android.ugc.aweme.framework.services.ServiceManager"; + private static final String ACCOUNT_SERVICE = "com.ss.android.ugc.aweme.IAccountUserService"; + + private UserAvatar() {} + + /** Thumbnail URLs of the current user, best first; empty when logged out or unreadable. */ + static List thumbnailUrls() { + try { + Class managerClass = Class.forName(SERVICE_MANAGER); + Object manager = managerClass.getMethod("get").invoke(null); + Class serviceClass = Class.forName(ACCOUNT_SERVICE); + Object service = managerClass.getMethod("getService", Class.class).invoke(manager, serviceClass); + if (service == null) return Collections.emptyList(); + Object user = serviceClass.getMethod("getCurUser").invoke(service); + if (user == null) return Collections.emptyList(); + Object thumb = user.getClass().getMethod("getAvatarThumb").invoke(user); + if (thumb == null) return Collections.emptyList(); + Method listMethod = thumb.getClass().getMethod("getUrlList"); + Object raw = listMethod.invoke(thumb); + List urls = new ArrayList(); + if (raw instanceof List) { + for (Object item : (List) raw) { + if (item instanceof String && ((String) item).startsWith("http")) urls.add((String) item); + } + } + return urls; + } catch (Throwable unavailable) { + return Collections.emptyList(); + } + } +} diff --git a/src/lihont/two/comments/CommentPanel.java b/src/lihont/two/comments/CommentPanel.java new file mode 100644 index 0000000..783ee23 --- /dev/null +++ b/src/lihont/two/comments/CommentPanel.java @@ -0,0 +1,406 @@ +package lihont.two.comments; + +import android.app.Activity; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.TextView; + +import java.util.HashMap; +import java.util.WeakHashMap; + +import lihont.two.settings.Md3; +import lihont.two.settings.ModSettings; + +/** + * Material 3 styling for TikTok's comment sheet, laid over the views TikTok built. + * + * The sheet becomes one tonal surface rounded at the top with a drag handle, every comment becomes a + * rounded card, the input becomes a filled pill, and texts and icons take the scheme's colours. + * Nothing is replaced: the views are the ones TikTok inflated, found by the resource id names they + * have in 47.1.3 and checked by shape, and a frame check keeps the styling true when TikTok binds a + * recycled row or repaints a background. Anything that does not fit is left as TikTok drew it. + */ +final class CommentPanel { + private static final String TAG = "LIHONT"; + + // Resource id names in TikTok 47.1.3 (R8 output); the first two decide whether this is the panel. + private static final String PANEL = "pyg"; + private static final String LIST = "u8p"; + private static final String INPUT = "eko"; + private static final String COUNT = "wnz"; + private static final String SORT_ICON = "l7j"; + private static final String CLOSE_ICON = "bsd"; + private static final String CHIP_LABEL = "k2b"; + private static final String CHIP_QUERY = "mu6"; + private static final String CHIP_ICON = "kgv"; + private static final String PILL = "n2b"; + private static final String ICON_ROW = "kl3"; + private static final String CARD = "f36"; + private static final String AUTHOR = "title"; + private static final String BODY = "f67"; + private static final String DATE = "ep_"; + private static final String REPLY = "eo1"; + private static final String LIKES = "tv_like_count"; + private static final String MORE_TITLE = "tv_reply_comment_title"; + private static final String MORE_LINE = "view_more_comment_divider"; + /** Rows drawn above the sheet's header, outside the sheet's own views. */ + private static final String[] OVERLAYS = {"l8_", "ejn"}; + + private static final float CARD_RADIUS_DP = 20f; + private static final float CARD_SIDE_DP = 8f; + private static final float CARD_GAP_DP = 3f; + private static final float PILL_RADIUS_DP = 28f; + private static final int ICONS_IDLE = 3; + + private static int logged; + private static View unplaced; + + final ViewGroup root; + private final Activity activity; + private final View decor; + private final ViewGroup sheet; + private final Resources resources; + private final float density; + private final Md3.Palette palette; + private final SheetBackground sheetBackground; + private final Fill pill; + private final PorterDuffColorFilter mutedIcon; + private final PorterDuffColorFilter strongIcon; + private final PorterDuffColorFilter accentIcon; + private final HashMap ids = new HashMap(); + private final HashMap cache = new HashMap(); + private final WeakHashMap cards = new WeakHashMap(); + + private static final class CardState { + final Fill fill; + int left = Integer.MIN_VALUE; + int right = Integer.MIN_VALUE; + int top = Integer.MIN_VALUE; + int bottom = Integer.MIN_VALUE; + + CardState(Fill fill) { + this.fill = fill; + } + } + + private CommentPanel(Activity activity, View decor, ViewGroup root, ViewGroup sheet, boolean dark) { + this.activity = activity; + this.decor = decor; + this.root = root; + this.sheet = sheet; + this.resources = activity.getResources(); + this.density = resources.getDisplayMetrics().density; + this.palette = Md3.palette(activity, dark ? ModSettings.Theme.DARK : ModSettings.Theme.LIGHT); + this.sheetBackground = new SheetBackground(density); + this.sheetBackground.setColors(palette.surfaceContainerLow, palette.onSurfaceVariant); + this.pill = new Fill(palette.surfaceContainerHigh, PILL_RADIUS_DP * density); + this.mutedIcon = new PorterDuffColorFilter(palette.onSurfaceVariant, PorterDuff.Mode.SRC_IN); + this.strongIcon = new PorterDuffColorFilter(palette.onSurface, PorterDuff.Mode.SRC_IN); + this.accentIcon = new PorterDuffColorFilter(palette.primary, PorterDuff.Mode.SRC_IN); + } + + /** + * The root of the comment panel, or null while none is open. The ids are shared with other + * screens and a hidden copy of the panel may be in the tree, so the open panel is found by its + * visible list: the nearest ancestor of the list that carries the panel's id. + */ + static View locate(Activity activity, View decor) { + Resources resources = activity.getResources(); + String pkg = activity.getPackageName(); + int listId = resources.getIdentifier(LIST, "id", pkg); + int panelId = resources.getIdentifier(PANEL, "id", pkg); + if (listId == 0 || panelId == 0) return null; + View list = findShown(decor, listId); + if (list == null) return null; + for (android.view.ViewParent parent = list.getParent(); parent instanceof View; parent = parent.getParent()) { + if (((View) parent).getId() == panelId) return (View) parent; + } + if (list != unplaced) { + unplaced = list; + StringBuilder chain = new StringBuilder(); + int depth = 0; + for (android.view.ViewParent parent = list.getParent(); parent instanceof View && depth++ < 12; + parent = parent.getParent()) { + chain.append(' ').append(describe((View) parent)); + } + note("list is open but no panel above it:" + chain); + } + return null; + } + + private static View findShown(View view, int id) { + if (view.getId() == id && view.isShown()) return view; + if (!(view instanceof ViewGroup)) return null; + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + View found = findShown(group.getChildAt(i), id); + if (found != null) return found; + } + return null; + } + + /** The panel behind `root`, or null when it does not have the shape of the comment sheet. */ + static CommentPanel create(Activity activity, View decor, View root, ModSettings.Theme theme) { + if (!(root instanceof ViewGroup)) return null; + ViewGroup group = (ViewGroup) root; + Resources resources = activity.getResources(); + String pkg = activity.getPackageName(); + View list = findShown(group, resources.getIdentifier(LIST, "id", pkg)); + View input = findShown(group, resources.getIdentifier(INPUT, "id", pkg)); + if (list == null || input == null) { + note("panel rejected: list=" + describe(list) + " input=" + describe(input)); + return null; + } + ViewGroup sheet = null; + for (android.view.ViewParent parent = list.getParent(); parent instanceof ViewGroup; parent = parent.getParent()) { + if (parent == group) break; + if (((ViewGroup) parent).getParent() == group) sheet = (ViewGroup) parent; + } + if (sheet == null) { + note("panel rejected: no sheet between " + describe(group) + " and the list"); + return null; + } + View count = findShown(sheet, resources.getIdentifier(COUNT, "id", pkg)); + boolean dark; + if (count instanceof TextView) { + int text = ((TextView) count).getCurrentTextColor(); + dark = 0.299f * Color.red(text) + 0.587f * Color.green(text) + 0.114f * Color.blue(text) > 128f; + } else { + dark = Md3.palette(activity, theme).dark; + } + StringBuilder children = new StringBuilder(); + for (int i = 0; i < group.getChildCount(); i++) children.append(' ').append(describe(group.getChildAt(i))); + note("panel found, " + (dark ? "dark" : "light") + ", root children:" + children + "; sheet " + + describe(sheet) + " bg=" + sheet.getBackground() + "; list bg=" + list.getBackground() + + "; count=" + describe(count)); + return new CommentPanel(activity, decor, group, sheet, dark); + } + + /** + * Brings every styled view back to the skin. Returns true when a layout parameter changed, so the + * caller can hold the frame back until the views have been laid out again. + */ + boolean enforce() { + if (!root.isShown()) return false; + if (sheet.getBackground() != sheetBackground) sheet.setBackground(sheetBackground); + int wide = (int) (sheet.getWidth() * 0.9f); + clear(sheet, true, wide); + for (String name : OVERLAYS) { + View overlay = overlay(name); + if (overlay != null) clear(overlay, false, wide); + } + header(); + input(); + return cards(); + } + + // ---- sheet ---------------------------------------------------------------- + + /** + * TikTok paints the header and the input bar with their own backgrounds: whatever wide view in + * the sheet carries one is made transparent, so the sheet's surface shows through. A hairline + * keeps its place and takes the scheme's divider colour. The list's rows are left to cards(). + */ + private void clear(View view, boolean top, int wide) { + if (!top) { + Drawable background = view.getBackground(); + if (background != null && !(background instanceof Fill) && !(background instanceof SheetBackground) + && view.getWidth() >= wide && view.getWidth() > 0) { + if (view.getHeight() <= 3 * density) { + solid(view, background, palette.outlineVariant); + } else { + view.setBackground(null); + note("cleared " + describe(view) + " " + background); + } + } + } + if (view instanceof ViewGroup && !isList(view)) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) clear(group.getChildAt(i), false, wide); + } + } + + private void solid(View view, Drawable current, int color) { + if (current instanceof ColorDrawable && ((ColorDrawable) current).getColor() == color) return; + view.setBackground(new ColorDrawable(color)); + } + + private void header() { + ink(sheetView(COUNT), palette.onSurface); + tint(sheetView(SORT_ICON), strongIcon); + tint(overlay(CLOSE_ICON), mutedIcon); + ink(sheetView(CHIP_LABEL), palette.onSurfaceVariant); + ink(sheetView(CHIP_QUERY), palette.primary); + tint(sheetView(CHIP_ICON), accentIcon); + } + + private void input() { + View field = sheetView(PILL); + if (field != null && field.getBackground() != pill) field.setBackground(pill); + View edit = sheetView(INPUT); + if (edit instanceof EditText) { + EditText text = (EditText) edit; + if (text.getCurrentTextColor() != palette.onSurface) text.setTextColor(palette.onSurface); + if (text.getCurrentHintTextColor() != palette.onSurfaceVariant) { + text.setHintTextColor(palette.onSurfaceVariant); + } + } + View row = sheetView(ICON_ROW); + if (row instanceof ViewGroup && ((ViewGroup) row).getChildCount() == ICONS_IDLE) { + ViewGroup icons = (ViewGroup) row; + for (int i = 0; i < icons.getChildCount(); i++) { + View item = icons.getChildAt(i); + tint(item instanceof ImageView ? item : firstImage(item), mutedIcon); + } + } + } + + // ---- comments --------------------------------------------------------------- + + private boolean cards() { + View list = sheetView(LIST); + if (!(list instanceof ViewGroup)) return false; + ViewGroup rows = (ViewGroup) list; + int cardId = id(CARD); + boolean relayout = false; + for (int i = 0; i < rows.getChildCount(); i++) { + View row = rows.getChildAt(i); + View card = cardId == 0 ? null : row.findViewById(cardId); + if (card != null) { + relayout |= card(card); + } else { + ink(row.findViewById(id(MORE_TITLE)), palette.onSurfaceVariant); + View line = row.findViewById(id(MORE_LINE)); + if (line != null && line.getBackground() != null) solid(line, line.getBackground(), palette.outlineVariant); + } + } + return relayout; + } + + /** One comment as a rounded card; returns true when its margins changed. */ + private boolean card(View card) { + CardState state = cards.get(card); + if (state == null) { + state = new CardState(new Fill(palette.surfaceContainer, CARD_RADIUS_DP * density)); + cards.put(card, state); + note("card " + describe(card) + " " + card.getClass().getSuperclass().getSimpleName() + + " bg=" + card.getBackground() + " parent=" + describe((View) card.getParent()) + + " padding=" + card.getPaddingLeft() + "," + card.getPaddingTop()); + } + // A background TikTok put there itself (the highlight of a linked comment) is left alone. + if (card.getBackground() == null) card.setBackground(state.fill); + + boolean relayout = false; + ViewGroup.LayoutParams raw = card.getLayoutParams(); + if (raw instanceof ViewGroup.MarginLayoutParams) { + ViewGroup.MarginLayoutParams margins = (ViewGroup.MarginLayoutParams) raw; + if (margins.leftMargin != state.left || margins.rightMargin != state.right + || margins.topMargin != state.top || margins.bottomMargin != state.bottom) { + // New row, or TikTok set its own margins for what the row now shows: add ours on top. + int side = (int) (CARD_SIDE_DP * density + .5f); + int gap = (int) (CARD_GAP_DP * density + .5f); + margins.leftMargin += side; + margins.rightMargin += side; + margins.topMargin += gap; + margins.bottomMargin += gap; + state.left = margins.leftMargin; + state.right = margins.rightMargin; + state.top = margins.topMargin; + state.bottom = margins.bottomMargin; + card.setLayoutParams(margins); + relayout = true; + } + } + ink(card.findViewById(id(AUTHOR)), palette.onSurfaceVariant); + ink(card.findViewById(id(BODY)), palette.onSurface); + ink(card.findViewById(id(DATE)), palette.onSurfaceVariant); + ink(card.findViewById(id(REPLY)), palette.primary); + ink(card.findViewById(id(LIKES)), palette.onSurfaceVariant); + return relayout; + } + + // ---- helpers ---------------------------------------------------------------- + + private int id(String name) { + Integer known = ids.get(name); + if (known == null) { + known = resources.getIdentifier(name, "id", activity.getPackageName()); + ids.put(name, known); + } + return known; + } + + /** A view of the sheet by id name, found again when TikTok has recreated it. */ + private View sheetView(String name) { + View cached = cache.get(name); + if (cached != null && cached.isAttachedToWindow()) return cached; + int id = id(name); + View found = id == 0 ? null : sheet.findViewById(id); + cache.put(name, found); + return found; + } + + /** A view drawn over the sheet that lives outside it. */ + private View overlay(String name) { + String key = "overlay:" + name; + View cached = cache.get(key); + if (cached != null && cached.isAttachedToWindow()) return cached; + int id = id(name); + View found = id == 0 ? null : decor.findViewById(id); + cache.put(key, found); + return found; + } + + private boolean isList(View view) { + if (view.getId() == id(LIST)) return true; + for (Class type = view.getClass(); type != null; type = type.getSuperclass()) { + if (type.getName().equals("androidx.recyclerview.widget.RecyclerView")) return true; + } + return false; + } + + private static void ink(View view, int color) { + if (view instanceof TextView && ((TextView) view).getCurrentTextColor() != color) { + ((TextView) view).setTextColor(color); + } + } + + private static void tint(View view, PorterDuffColorFilter filter) { + if (view instanceof ImageView && ((ImageView) view).getColorFilter() != filter) { + ((ImageView) view).setColorFilter(filter); + } + } + + private static View firstImage(View item) { + if (!(item instanceof ViewGroup)) return null; + ViewGroup group = (ViewGroup) item; + for (int i = 0; i < group.getChildCount(); i++) { + if (group.getChildAt(i) instanceof ImageView) return group.getChildAt(i); + } + return null; + } + + private static String describe(View view) { + if (view == null) return "null"; + String name = ""; + try { + if (view.getId() != View.NO_ID) name = view.getResources().getResourceEntryName(view.getId()); + } catch (Resources.NotFoundException ignored) { + // A view without a named id. + } + return view.getClass().getSimpleName() + "#" + name + "[" + view.getWidth() + "x" + view.getHeight() + "]"; + } + + static void note(String message) { + if (logged++ < 80) Log.i(TAG, "comments: " + message); + } +} diff --git a/src/lihont/two/comments/CommentSkin.java b/src/lihont/two/comments/CommentSkin.java new file mode 100644 index 0000000..9a60ba7 --- /dev/null +++ b/src/lihont/two/comments/CommentSkin.java @@ -0,0 +1,107 @@ +package lihont.two.comments; + +import android.app.Activity; +import android.util.Log; +import android.view.View; +import android.view.ViewTreeObserver; + +import lihont.two.settings.ModSettings; + +/** + * Called by TikTok's main screen whenever it comes to the front (through BottomBar.onResumed). + * + * Watches the window for the comment sheet and, while it is open, keeps it in the Material 3 skin of + * CommentPanel. The sheet is built by obfuscated views and opens and closes without any call into the + * mod, so a light check runs before every frame: one lookup by id while no sheet is open. + */ +public final class CommentSkin { + private static final String TAG = "LIHONT"; + /** Frames in a row a styling change may hold back, so a layout that never settles cannot freeze the screen. */ + private static final int MAX_HELD = 3; + + private static Watcher current; + + private CommentSkin() {} + + public static void watch(Activity activity, ModSettings.Theme theme) { + try { + View decor = activity.getWindow().getDecorView(); + if (current != null && current.decor == decor) return; + if (current != null) current.stop(); + current = new Watcher(activity, decor, theme); + current.start(); + Log.i(TAG, "comments: watching " + activity.getClass().getName()); + } catch (Throwable ignored) { + // The comment skin is decoration: never let it take the main screen down. + } + } + + private static final class Watcher implements ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener { + final Activity activity; + final View decor; + private final ModSettings.Theme theme; + private View panel; + private CommentPanel skin; + private int held; + private int failures; + + Watcher(Activity activity, View decor, ModSettings.Theme theme) { + this.activity = activity; + this.decor = decor; + this.theme = theme; + } + + void start() { + decor.getViewTreeObserver().addOnPreDrawListener(this); + decor.addOnAttachStateChangeListener(this); + } + + void stop() { + decor.getViewTreeObserver().removeOnPreDrawListener(this); + decor.removeOnAttachStateChangeListener(this); + if (current == this) current = null; + } + + @Override public boolean onPreDraw() { + try { + return frame(); + } catch (Throwable failure) { + if (failures++ < 5) Log.i(TAG, "comments: frame check failed: " + failure); + return true; + } + } + + private boolean frame() { + View found = panel; + if (found == null || !found.isAttachedToWindow() || !found.isShown()) { + found = CommentPanel.locate(activity, decor); + if (found != panel) skin = null; + panel = found; + } + if (found == null) return true; + if (skin == null) { + skin = CommentPanel.create(activity, decor, found, theme); + if (skin == null) { + panel = null; // look again next frame: the sheet may still be inflating + return true; + } + } + boolean relayout = skin.enforce(); + if (relayout && held < MAX_HELD) { + held++; + return false; + } + held = 0; + return true; + } + + @Override public void onViewAttachedToWindow(View v) { + decor.getViewTreeObserver().removeOnPreDrawListener(this); + decor.getViewTreeObserver().addOnPreDrawListener(this); + } + + @Override public void onViewDetachedFromWindow(View v) { + decor.getViewTreeObserver().removeOnPreDrawListener(this); + } + } +} diff --git a/src/lihont/two/comments/Fill.java b/src/lihont/two/comments/Fill.java new file mode 100644 index 0000000..9f835b6 --- /dev/null +++ b/src/lihont/two/comments/Fill.java @@ -0,0 +1,11 @@ +package lihont.two.comments; + +import android.graphics.drawable.GradientDrawable; + +/** A rounded tonal fill of the comment skin; the type tells the skin which backgrounds are its own. */ +final class Fill extends GradientDrawable { + Fill(int color, float radiusPx) { + setColor(color); + setCornerRadius(radiusPx); + } +} diff --git a/src/lihont/two/comments/SheetBackground.java b/src/lihont/two/comments/SheetBackground.java new file mode 100644 index 0000000..91e2059 --- /dev/null +++ b/src/lihont/two/comments/SheetBackground.java @@ -0,0 +1,60 @@ +package lihont.two.comments; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; + +/** The comment sheet's surface: a tonal fill, rounded at the top, with the Material 3 drag handle. */ +final class SheetBackground extends Drawable { + private static final int HANDLE_ALPHA = 102; + + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint handle = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path shape = new Path(); + private final RectF rect = new RectF(); + private final float radius; + private final float handleWidth; + private final float handleHeight; + private final float handleTop; + + SheetBackground(float density) { + radius = 28f * density; + handleWidth = 32f * density; + handleHeight = 4f * density; + handleTop = 6f * density; + } + + void setColors(int surface, int handleColor) { + fill.setColor(surface); + handle.setColor(handleColor); + handle.setAlpha(HANDLE_ALPHA); + invalidateSelf(); + } + + @Override protected void onBoundsChange(Rect bounds) { + rect.set(bounds); + shape.reset(); + shape.addRoundRect(rect, new float[] {radius, radius, radius, radius, 0f, 0f, 0f, 0f}, Path.Direction.CW); + } + + @Override public void draw(Canvas canvas) { + canvas.drawPath(shape, fill); + float left = rect.centerX() - handleWidth / 2f; + float top = rect.top + handleTop; + canvas.drawRoundRect(left, top, left + handleWidth, top + handleHeight, + handleHeight / 2f, handleHeight / 2f, handle); + } + + @Override public void setAlpha(int alpha) {} + + @Override public void setColorFilter(ColorFilter filter) {} + + @Override public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/src/lihont/two/feed/FeedFilter.java b/src/lihont/two/feed/FeedFilter.java new file mode 100644 index 0000000..f5c0dd0 --- /dev/null +++ b/src/lihont/two/feed/FeedFilter.java @@ -0,0 +1,194 @@ +package lihont.two.feed; + +import android.util.Log; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import lihont.two.settings.SettingsHost; + +/** + * Takes advertisements and live streams out of the pages of TikTok's feed (see lihont/feed_patch.py). + * + * A feed page is a FeedItemList whose items are Aweme objects. An advertisement is an Aweme with an + * ad flag or an ad payload, a live stream one with a room attached. Fields are read by reflection: + * the names are TikTok's own (the model classes are not renamed) and a missing one only means that + * kind of item is not recognised. + */ +public final class FeedFilter { + private static final String TAG = "LIHONT"; + private static final String AWEME = "com.ss.android.ugc.aweme.feed.model.Aweme"; + private static final int REPORTED_PAGES = 12; + + private static volatile Class awemeClass; + private static volatile Field adFlag; + private static volatile Field softAdFlag; + private static volatile Field adPayload; + private static volatile Field room; + private static volatile Field newRoom; + private static volatile Field roomCell; + private static volatile Field awemeType; + + private static volatile Class pageClass; + private static volatile Field pageItems; + private static volatile Field pagePreload; + private static volatile Field pageHasAd; + + private static int reported; + + private FeedFilter() {} + + /** Whether advertisements are to be removed; the early returns of the ad hooks ask this. */ + public static boolean ads() { + return SettingsHost.hideAds(); + } + + private static Field field(Class owner, String name) { + try { + Field result = owner.getField(name); + result.setAccessible(true); + return result; + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + private static synchronized void awemeFields(Class owner) { + if (awemeClass == owner) return; + adFlag = field(owner, "_isAd"); + softAdFlag = field(owner, "_isSoftAd"); + adPayload = field(owner, "awemeRawAd"); + room = field(owner, "room"); + newRoom = field(owner, "newLiveRoomData"); + roomCell = field(owner, "mRoomFeedCellStruct"); + awemeType = field(owner, "awemeType"); + awemeClass = owner; + } + + private static boolean has(Field field, Object item) throws IllegalAccessException { + return field != null && field.get(item) != null; + } + + private static boolean flag(Field field, Object item) throws IllegalAccessException { + return field != null && field.getBoolean(item); + } + + /** An item TikTok marks as an advertisement, by its own flags rather than the getter that also wants a payload. */ + static boolean isAdvertisement(Object item) { + try { + return flag(adFlag, item) || flag(softAdFlag, item) || has(adPayload, item); + } catch (IllegalAccessException | RuntimeException unreadable) { + return false; + } + } + + /** An item that carries a live room. */ + static boolean isLive(Object item) { + try { + return has(room, item) || has(newRoom, item) || has(roomCell, item); + } catch (IllegalAccessException | RuntimeException unreadable) { + return false; + } + } + + /** + * The list without the items the switches remove. The same list comes back when nothing is + * removed; a list from the server or a cache is never changed in place. + */ + public static List filter(List input) { + if (input == null || input.isEmpty()) return input; + boolean ads = SettingsHost.hideAds(); + boolean live = SettingsHost.hideLive(); + if (!ads && !live) return input; + ArrayList kept = null; + int removedAds = 0; + int removedLive = 0; + Map types = new TreeMap(); + for (int i = 0, size = input.size(); i < size; i++) { + Object item = input.get(i); + boolean drop = false; + if (item != null && AWEME.equals(item.getClass().getName())) { + if (awemeClass != item.getClass()) awemeFields(item.getClass()); + if (ads && isAdvertisement(item)) { + drop = true; + removedAds++; + } else if (live && isLive(item)) { + drop = true; + removedLive++; + } + if (!drop) count(types, item); + } + if (drop) { + if (kept == null) { + kept = new ArrayList(size); + kept.addAll(input.subList(0, i)); + } + } else if (kept != null) { + kept.add(item); + } + } + report(input.size(), removedAds, removedLive, types); + return kept == null ? input : kept; + } + + private static void count(Map types, Object item) { + try { + if (awemeType == null) return; + Integer type = Integer.valueOf(awemeType.getInt(item)); + Integer seen = types.get(type); + types.put(type, Integer.valueOf(seen == null ? 1 : seen.intValue() + 1)); + } catch (IllegalAccessException | RuntimeException ignored) { + // Only for the log. + } + } + + private static void report(int total, int removedAds, int removedLive, Map types) { + synchronized (FeedFilter.class) { + if (reported >= REPORTED_PAGES || (removedAds == 0 && removedLive == 0 && reported >= 3)) return; + reported++; + } + try { + Log.i(TAG, "feed: " + total + " items, ads removed " + removedAds + ", live removed " + + removedLive + ", kept by type " + types); + } catch (Throwable noLogger) { + // Diagnostic only. + } + } + + private static synchronized void pageFields(Class owner) { + if (pageClass == owner) return; + pageItems = field(owner, "items"); + pagePreload = field(owner, "preloadAds"); + pageHasAd = field(owner, "hasAd"); + pageClass = owner; + } + + /** Cleans a FeedItemList in place: its items, the advertisements preloaded for it and its ad flag. */ + public static void cleanPage(Object page) { + if (page == null) return; + try { + if (pageClass != page.getClass()) pageFields(page.getClass()); + if (pageItems != null) { + Object input = pageItems.get(page); + if (input instanceof List) { + List output = filter((List) input); + if (output != input) pageItems.set(page, output); + } + } + if (SettingsHost.hideAds()) { + if (pagePreload != null) { + Object preload = pagePreload.get(page); + if (!(preload instanceof List) || !((List) preload).isEmpty()) { + pagePreload.set(page, new ArrayList()); + } + } + if (pageHasAd != null) pageHasAd.setBoolean(page, false); + } + } catch (IllegalAccessException | RuntimeException ignored) { + // The page is left as TikTok built it. + } + } +} diff --git a/src/lihont/two/feed/HomeTabs.java b/src/lihont/two/feed/HomeTabs.java new file mode 100644 index 0000000..1117e65 --- /dev/null +++ b/src/lihont/two/feed/HomeTabs.java @@ -0,0 +1,169 @@ +package lihont.two.feed; + +import android.app.Activity; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.ViewTreeObserver; +import android.widget.TextView; + +import lihont.two.settings.SettingsHost; + +/** + * The tabs above TikTok's feed (Following, Shop, For You): removes the Shop tab when the switch says so. + * + * TikTok builds the strip from obfuscated views, so the Shop tab is found by its label and by its + * place next to the For You label, and anything that does not fit is left as it was. Called from + * BottomBar.onResumed, which MainActivity.onResume reaches. + */ +public final class HomeTabs { + private static final String TAG = "LIHONT"; + private static final String[] SHOP = {"Магазин", "Shop"}; + private static final String[] FOR_YOU = {"Рекомендации", "For You"}; + private static final int ATTEMPTS = 10; + private static final long RETRY_MS = 500L; + + private static Hider current; + + private HomeTabs() {} + + public static void watch(final Activity activity) { + if (!SettingsHost.hideShop()) return; + locate(activity, 0); + } + + private static void locate(final Activity activity, final int attempt) { + try { + View decor = activity.getWindow().getDecorView(); + View item = shopTab(decor); + if (item != null) { + if (current == null || current.item != item) { + current = new Hider(item); + current.install(); + } + return; + } + if (attempt < ATTEMPTS && !activity.isFinishing()) { + decor.postDelayed(new Runnable() { + @Override public void run() { + locate(activity, attempt + 1); + } + }, RETRY_MS); + } + } catch (Throwable ignored) { + // Decoration of the feed must never take the main screen down. + } + } + + /** The tab view of the Shop label: the topmost ancestor of the label that does not also hold the For You label. */ + private static View shopTab(View root) { + TextView shop = label(root, SHOP); + TextView forYou = label(root, FOR_YOU); + if (shop == null || forYou == null) return null; + View item = shop; + while (true) { + ViewParent parent = item.getParent(); + if (!(parent instanceof ViewGroup)) return null; + ViewGroup group = (ViewGroup) parent; + if (contains(group, forYou)) return item; + item = group; + } + } + + private static boolean contains(ViewGroup group, View view) { + for (ViewParent p = view.getParent(); p != null; p = p.getParent()) { + if (p == group) return true; + } + return false; + } + + private static TextView label(View view, String[] texts) { + if (view instanceof TextView && view.getVisibility() == View.VISIBLE) { + CharSequence value = ((TextView) view).getText(); + for (String text : texts) { + if (value != null && text.contentEquals(value)) return (TextView) view; + } + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + TextView found = label(group.getChildAt(i), texts); + if (found != null) return found; + } + } + return null; + } + + /** Keeps one tab view gone for as long as it is on screen. */ + private static final class Hider implements ViewTreeObserver.OnPreDrawListener, + View.OnAttachStateChangeListener { + final View item; + + Hider(View item) { + this.item = item; + } + + void install() { + describe(); + probePagers(item.getRootView(), new int[] {0}); + item.setVisibility(View.GONE); + item.getViewTreeObserver().addOnPreDrawListener(this); + item.addOnAttachStateChangeListener(this); + Log.i(TAG, "shop tab hidden"); + } + + private void describe() { + StringBuilder chain = new StringBuilder(); + View view = item; + for (int depth = 0; view != null && depth < 4; depth++) { + chain.append(depth == 0 ? "" : " < ").append(view.getClass().getName()) + .append('[').append(view.getLeft()).append(',').append(view.getTop()).append('-') + .append(view.getRight()).append(',').append(view.getBottom()).append(']'); + ViewParent parent = view.getParent(); + view = parent instanceof View ? (View) parent : null; + } + Log.i(TAG, "shop tab found: " + chain); + } + + /** Temporary: logs every pager-like view (it has getCurrentItem and getAdapter) with its page count. */ + private void probePagers(View view, int[] found) { + if (found[0] >= 6) return; + try { + java.lang.reflect.Method current = view.getClass().getMethod("getCurrentItem"); + java.lang.reflect.Method adapter = view.getClass().getMethod("getAdapter"); + Object value = adapter.invoke(view); + Object count = value == null ? null : value.getClass().getMethod("getCount").invoke(value); + found[0]++; + Log.i(TAG, "pager: " + view.getClass().getName() + " item=" + current.invoke(view) + + " count=" + count + " adapter=" + (value == null ? null : value.getClass().getName()) + + " bounds=" + view.getLeft() + "," + view.getTop() + "-" + view.getRight() + "," + view.getBottom()); + } catch (Throwable notAPager) { + // Most views are not. + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) probePagers(group.getChildAt(i), found); + } + } + + @Override public boolean onPreDraw() { + try { + if (item.getVisibility() != View.GONE) item.setVisibility(View.GONE); + } catch (Throwable ignored) { + // Best effort. + } + return true; + } + + @Override public void onViewAttachedToWindow(View v) { + item.getViewTreeObserver().removeOnPreDrawListener(this); + item.getViewTreeObserver().addOnPreDrawListener(this); + } + + @Override public void onViewDetachedFromWindow(View v) { + item.getViewTreeObserver().removeOnPreDrawListener(this); + if (current != null && current.item == item) current = null; + } + } +} diff --git a/src/lihont/two/media/Downloads.java b/src/lihont/two/media/Downloads.java new file mode 100644 index 0000000..dd21598 --- /dev/null +++ b/src/lihont/two/media/Downloads.java @@ -0,0 +1,58 @@ +package lihont.two.media; + +import android.util.Log; + +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; + +/** + * What TikTok's download chain asks the mod (see lihont/media_patch.py). + * + * With the switch on, a saved video is taken from an address without TikTok's logo and the step that + * stamps the logo itself is skipped. The switch is read from the settings active since the process + * started, like the other features. + */ +public final class Downloads { + private static final String TAG = "LIHONT"; + private static int reported; + + private Downloads() {} + + /** Whether saved videos should come out without the watermark. */ + public static boolean noWatermark() { + ModSettings active = SettingsHost.active(); + return (active != null ? active : ModSettings.defaults()).noWatermark(); + } + + /** X.0qvH.LIZIZ(Aweme) is about to pick a source: whether it should take the clean address instead. */ + public static boolean pick() { + boolean clean = noWatermark(); + say("source requested, clean=" + clean); + return clean; + } + + /** What X.0qwE.LJIIIZ() answers: whether TikTok should stamp its own logo on a saved video. */ + public static boolean logo(boolean wanted) { + boolean skip = wanted && noWatermark(); + say("logo wanted=" + wanted + (skip ? ", skipped" : "")); + return wanted && !skip; + } + + /** Which rewrite took effect for a download: 1 the source was taken from the clean address. */ + public static void route(int which) { + say(which == 1 ? "clean source chosen" : "route " + which); + } + + /** Logged for the first few downloads of a process. */ + private static void say(String message) { + synchronized (Downloads.class) { + if (reported >= 12) return; + reported++; + } + try { + Log.i(TAG, "download: " + message); + } catch (Throwable noLogger) { + // Diagnostic only. + } + } +} diff --git a/src/lihont/two/settings/AndroidSettingsStorage.java b/src/lihont/two/settings/AndroidSettingsStorage.java new file mode 100644 index 0000000..b147e13 --- /dev/null +++ b/src/lihont/two/settings/AndroidSettingsStorage.java @@ -0,0 +1,77 @@ +package lihont.two.settings; + +import android.content.Context; +import android.system.ErrnoException; +import android.system.Os; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +/** Private app storage, no Android permission or network access. Minimum API 23. */ +public final class AndroidSettingsStorage implements SettingsRepository.Storage { + public static final String FILE_NAME = "lihont-settings.json"; + private final File target; + + public AndroidSettingsStorage(Context context) { + if (context == null) throw new IllegalArgumentException("Context is required"); + target = new File(context.getFilesDir(), FILE_NAME); + } + + @Override public synchronized String read() throws IOException { + // Attempt opening first: a permissions/I/O error is not a first launch. + FileInputStream input; + try { input = new FileInputStream(target); } + catch (FileNotFoundException e) { + if (!target.exists() && target.getParentFile().isDirectory()) return null; + throw e; + } + try (FileInputStream stream = input; + ByteArrayOutputStream bytes = new ByteArrayOutputStream()) { + byte[] block = new byte[1024]; + int count; + while ((count = stream.read(block)) != -1) { + if (bytes.size() + count > SettingsCodec.MAX_DOCUMENT_LENGTH) { + throw new IOException("Settings file is too large"); + } + bytes.write(block, 0, count); + } + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes.toByteArray())).toString(); + } catch (CharacterCodingException e) { + throw new IOException("Settings file is not UTF-8", e); + } + } + } + + @Override public synchronized void write(String document) throws IOException { + // Keep even direct adapter callers from persisting malformed settings. + SettingsCodec.decode(document); + byte[] bytes = document.getBytes(StandardCharsets.UTF_8); + if (bytes.length > SettingsCodec.MAX_DOCUMENT_LENGTH) { + throw new IOException("Settings file is too large"); + } + File pending = File.createTempFile("lihont-settings-", ".tmp", target.getParentFile()); + try { + try (FileOutputStream output = new FileOutputStream(pending)) { + output.write(bytes); + output.getFD().sync(); + } + // Same-directory POSIX rename is atomic and replaces an existing file on Android. + // Do not delete the old document before rename or throw after successful rename. + try { Os.rename(pending.getAbsolutePath(), target.getAbsolutePath()); } + catch (ErrnoException e) { throw new IOException("Cannot replace settings file", e); } + } finally { + pending.delete(); + } + } +} diff --git a/src/lihont/two/settings/AppIcons.java b/src/lihont/two/settings/AppIcons.java new file mode 100644 index 0000000..9c42855 --- /dev/null +++ b/src/lihont/two/settings/AppIcons.java @@ -0,0 +1,96 @@ +package lihont.two.settings; + +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.drawable.Drawable; + +import java.util.ArrayList; +import java.util.List; + +/** + * The launcher icons this apk ships and the switch between them. + * + * Each icon is a disabled {@code activity-alias} named {@code lihont.two.icons.*} (the stock TikTok + * icon is one of them, on by default); the picker's title rides along as meta-data. Exactly one + * alias is enabled at any time, so the home screen shows one icon. TikTok's own entry component + * stays enabled but is no longer a launcher icon, because TikTok starts it by name. + */ +final class AppIcons { + static final String ALIAS_PREFIX = "lihont.two.icons."; + static final String TITLE_KEY = "lihont.title"; + + /** PackageManager.MATCH_DISABLED_COMPONENTS, spelled GET_DISABLED_COMPONENTS before API 24. */ + private static final int WITH_DISABLED = 0x00000200; + + private AppIcons() {} + + static final class Choice { + final String component; + final String title; + final Drawable icon; + final boolean selected; + + Choice(String component, String title, Drawable icon, boolean selected) { + this.component = component; + this.title = title; + this.icon = icon; + this.selected = selected; + } + } + + /** Every icon in manifest order, the stock one first; empty when this build ships none. */ + static List list(Context context) { + List choices = new ArrayList(); + PackageManager manager = context.getPackageManager(); + PackageInfo info; + try { + info = manager.getPackageInfo(context.getPackageName(), + PackageManager.GET_ACTIVITIES | PackageManager.GET_META_DATA | WITH_DISABLED); + } catch (PackageManager.NameNotFoundException | RuntimeException unavailable) { + return choices; + } + if (info.activities == null) return choices; + for (ActivityInfo activity : info.activities) { + if (activity.name == null || !activity.name.startsWith(ALIAS_PREFIX)) continue; + String title = activity.metaData == null ? null : activity.metaData.getString(TITLE_KEY); + if (title == null || title.length() == 0) title = activity.name.substring(ALIAS_PREFIX.length()); + choices.add(new Choice(activity.name, title, activity.loadIcon(manager), + isEnabled(manager, context.getPackageName(), activity))); + } + return choices; + } + + private static boolean isEnabled(PackageManager manager, String packageName, ActivityInfo activity) { + int state = manager.getComponentEnabledSetting(new ComponentName(packageName, activity.name)); + if (state == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) return true; + return state == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT && activity.enabled; + } + + /** + * Turns {@code component} on and every other icon off, in that order, so there is never a moment + * without a launcher entry. False when nothing changed or the system refused. + */ + static boolean select(Context context, String component) { + List all = list(context); + boolean known = false; + for (Choice choice : all) known |= choice.component.equals(component); + if (!known) return false; + PackageManager manager = context.getPackageManager(); + String packageName = context.getPackageName(); + try { + manager.setComponentEnabledSetting(new ComponentName(packageName, component), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP); + for (Choice choice : all) { + if (choice.component.equals(component)) continue; + manager.setComponentEnabledSetting(new ComponentName(packageName, choice.component), + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP); + } + return true; + } catch (RuntimeException refused) { + return false; + } + } +} diff --git a/src/lihont/two/settings/CustomIconStore.java b/src/lihont/two/settings/CustomIconStore.java new file mode 100644 index 0000000..3f5ab69 --- /dev/null +++ b/src/lihont/two/settings/CustomIconStore.java @@ -0,0 +1,176 @@ +package lihont.two.settings; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * The user's own launcher icons: one PNG per icon plus an index of names, in one private folder. + * + * Plain java.io on purpose, so the store runs under a desktop JVM test. The index is rewritten whole + * through a temporary file; entries whose picture is missing or whose id is not ours are skipped + * rather than trusted. + */ +final class CustomIconStore { + static final int MAX_ICONS = 12; + static final int MAX_NAME = 24; + static final String DEFAULT_NAME = "TikTok"; + static final int MAX_PNG_BYTES = 4 * 1024 * 1024; + + private static final String INDEX = "index.txt"; + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + static final class Entry { + final String id; + final String name; + final File image; + + Entry(String id, String name, File image) { + this.id = id; + this.name = name; + this.image = image; + } + } + + private final File directory; + + CustomIconStore(File directory) { + this.directory = directory; + } + + /** One line, no control characters, at most {@link #MAX_NAME} characters; never empty. */ + static String cleanName(String raw) { + if (raw == null) return DEFAULT_NAME; + StringBuilder out = new StringBuilder(); + boolean space = false; + for (int i = 0; i < raw.length() && out.length() < MAX_NAME; i++) { + char c = raw.charAt(i); + if (Character.isWhitespace(c) || Character.isISOControl(c)) { + space = out.length() > 0; + } else { + if (space) out.append(' '); + space = false; + out.append(c); + } + } + String name = out.toString(); + if (name.length() > MAX_NAME) name = name.substring(0, MAX_NAME); + return name.length() == 0 ? DEFAULT_NAME : name; + } + + static boolean validId(String id) { + if (id == null || id.length() != 8) return false; + for (int i = 0; i < id.length(); i++) { + char c = id.charAt(i); + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) return false; + } + return true; + } + + synchronized List list() { + List entries = new ArrayList(); + File index = new File(directory, INDEX); + if (!index.isFile()) return entries; + String text; + try { + text = new String(read(index), UTF_8); + } catch (IOException unreadable) { + return entries; + } + for (String line : text.split("\n")) { + int tab = line.indexOf('\t'); + if (tab < 0) continue; + String id = line.substring(0, tab); + if (!validId(id)) continue; + File image = new File(directory, id + ".png"); + if (!image.isFile()) continue; + entries.add(new Entry(id, cleanName(line.substring(tab + 1)), image)); + } + return entries; + } + + /** Stores a new icon; fails when the folder is full, the picture is not a PNG, or the disk refuses. */ + synchronized Entry add(byte[] png, String name) throws IOException { + if (png == null || png.length < 8 || png.length > MAX_PNG_BYTES || !isPng(png)) { + throw new IOException("Not a usable PNG"); + } + List entries = list(); + if (entries.size() >= MAX_ICONS) throw new IOException("Too many icons"); + if (!directory.isDirectory() && !directory.mkdirs()) throw new IOException("Cannot create " + directory); + String id; + do { + id = UUID.randomUUID().toString().substring(0, 8); + } while (new File(directory, id + ".png").exists()); + File image = new File(directory, id + ".png"); + write(image, png); + Entry entry = new Entry(id, cleanName(name), image); + entries.add(entry); + try { + writeIndex(entries); + } catch (IOException failed) { + image.delete(); + throw failed; + } + return entry; + } + + synchronized boolean remove(String id) { + if (!validId(id)) return false; + List entries = list(); + List kept = new ArrayList(); + for (Entry entry : entries) { + if (!entry.id.equals(id)) kept.add(entry); + } + if (kept.size() == entries.size()) return false; + try { + writeIndex(kept); + } catch (IOException failed) { + return false; + } + new File(directory, id + ".png").delete(); + return true; + } + + private void writeIndex(List entries) throws IOException { + StringBuilder text = new StringBuilder(); + for (Entry entry : entries) text.append(entry.id).append('\t').append(entry.name).append('\n'); + File temporary = new File(directory, INDEX + ".tmp"); + write(temporary, text.toString().getBytes(UTF_8)); + File index = new File(directory, INDEX); + if (!temporary.renameTo(index)) { + if (index.exists() && !index.delete()) throw new IOException("Cannot replace " + index); + if (!temporary.renameTo(index)) throw new IOException("Cannot write " + index); + } + } + + private static boolean isPng(byte[] data) { + return (data[0] & 0xff) == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G'; + } + + private static byte[] read(File file) throws IOException { + java.io.FileInputStream in = new java.io.FileInputStream(file); + try { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int count; + while ((count = in.read(buffer)) > 0) out.write(buffer, 0, count); + return out.toByteArray(); + } finally { + in.close(); + } + } + + private static void write(File file, byte[] data) throws IOException { + FileOutputStream out = new FileOutputStream(file); + try { + out.write(data); + out.getFD().sync(); + } finally { + out.close(); + } + } +} diff --git a/src/lihont/two/settings/IconCrop.java b/src/lihont/two/settings/IconCrop.java new file mode 100644 index 0000000..32a019e --- /dev/null +++ b/src/lihont/two/settings/IconCrop.java @@ -0,0 +1,32 @@ +package lihont.two.settings; + +/** Geometry of turning any photo into the square an adaptive launcher icon is drawn from. */ +final class IconCrop { + /** Side of the bitmap handed to the launcher: the 108dp adaptive canvas at 4x density. */ + static final int ICON_PIXELS = 432; + + /** Side of the part of that canvas every launcher mask shows (72 of 108 dp). */ + static final int VISIBLE_PIXELS = ICON_PIXELS * 72 / 108; + + private IconCrop() {} + + /** Largest power-of-two down-sampling that still leaves the shorter side at least {@code target}. */ + static int sampleSize(int width, int height, int target) { + requireSize(width, height); + int shorter = Math.min(width, height); + int sample = 1; + while (shorter / (sample * 2) >= target) sample *= 2; + return sample; + } + + /** {left, top, side} of the centred square inside a width x height picture. */ + static int[] centerSquare(int width, int height) { + requireSize(width, height); + int side = Math.min(width, height); + return new int[] { (width - side) / 2, (height - side) / 2, side }; + } + + private static void requireSize(int width, int height) { + if (width <= 0 || height <= 0) throw new IllegalArgumentException("Empty picture " + width + "x" + height); + } +} diff --git a/src/lihont/two/settings/IconShortcuts.java b/src/lihont/two/settings/IconShortcuts.java new file mode 100644 index 0000000..c9779a5 --- /dev/null +++ b/src/lihont/two/settings/IconShortcuts.java @@ -0,0 +1,172 @@ +package lihont.two.settings; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.Build; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; + +/** + * Turns a photo into a launcher icon the only way Android allows at run time: a pinned shortcut. + * + * The icon of the application itself is read from the manifest when the package is installed and + * cannot be replaced from inside, so the photo becomes an extra home-screen icon that opens TikTok. + */ +final class IconShortcuts { + static final int REQUEST_PICK = 4101; + private static final String SHORTCUT_PREFIX = "lihont_icon_"; + + private IconShortcuts() {} + + static Intent pickIntent() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("image/*"); + intent.addCategory(Intent.CATEGORY_OPENABLE); + return intent; + } + + static File directory(Context context) { + return new File(new File(context.getFilesDir(), "lihont"), "icons"); + } + + /** The picture centred and cut to a square at the launcher's canvas size, rotated as the camera meant. */ + static Bitmap decodeSquare(ContentResolver resolver, Uri uri) throws IOException { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + InputStream measure = resolver.openInputStream(uri); + if (measure == null) throw new IOException("Cannot open the picture"); + try { + BitmapFactory.decodeStream(measure, null, bounds); + } finally { + measure.close(); + } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) throw new IOException("Not a picture"); + + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = IconCrop.sampleSize(bounds.outWidth, bounds.outHeight, IconCrop.ICON_PIXELS); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + Bitmap decoded; + InputStream in = resolver.openInputStream(uri); + if (in == null) throw new IOException("Cannot open the picture"); + try { + decoded = BitmapFactory.decodeStream(in, null, options); + } finally { + in.close(); + } + if (decoded == null) throw new IOException("Not a picture"); + + Matrix turn = orientation(resolver, uri); + int[] square = IconCrop.centerSquare(decoded.getWidth(), decoded.getHeight()); + // The centred square is the same before and after a quarter turn, so cut first and turn the small piece. + Bitmap cut = Bitmap.createBitmap(decoded, square[0], square[1], square[2], square[2], turn, true); + if (cut != decoded) decoded.recycle(); + Bitmap scaled = Bitmap.createScaledBitmap(cut, IconCrop.ICON_PIXELS, IconCrop.ICON_PIXELS, true); + if (scaled != cut) cut.recycle(); + return scaled; + } + + private static Matrix orientation(ContentResolver resolver, Uri uri) { + Matrix matrix = new Matrix(); + if (Build.VERSION.SDK_INT < 24) return matrix; + InputStream in = null; + try { + in = resolver.openInputStream(uri); + if (in == null) return matrix; + int exif = new android.media.ExifInterface(in).getAttributeInt( + android.media.ExifInterface.TAG_ORIENTATION, android.media.ExifInterface.ORIENTATION_NORMAL); + switch (exif) { + case android.media.ExifInterface.ORIENTATION_ROTATE_90: matrix.postRotate(90); break; + case android.media.ExifInterface.ORIENTATION_ROTATE_180: matrix.postRotate(180); break; + case android.media.ExifInterface.ORIENTATION_ROTATE_270: matrix.postRotate(270); break; + case android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL: matrix.postScale(-1, 1); break; + case android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL: matrix.postScale(1, -1); break; + default: break; + } + } catch (IOException | RuntimeException unreadable) { + // No orientation tag is the usual case; the picture is then used as decoded. + } finally { + if (in != null) { + try { in.close(); } catch (IOException ignored) { } + } + } + return matrix; + } + + static byte[] toPng(Bitmap bitmap) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) throw new IOException("Cannot encode the picture"); + return out.toByteArray(); + } + + /** What the launcher will show: the centre window of the canvas, as a circle, for the preview. */ + static Bitmap roundedPreview(Bitmap square, int sizePixels) { + Bitmap out = Bitmap.createBitmap(sizePixels, sizePixels, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(out); + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + int margin = (square.getWidth() - IconCrop.VISIBLE_PIXELS) / 2; + android.graphics.Rect source = new android.graphics.Rect( + margin, margin, square.getWidth() - margin, square.getHeight() - margin); + android.graphics.RectF target = new android.graphics.RectF(0, 0, sizePixels, sizePixels); + canvas.drawOval(target, paint); + paint.setXfermode(new android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(square, source, target, paint); + return out; + } + + static boolean canPin(Context context) { + if (Build.VERSION.SDK_INT < 26) return false; + ShortcutManager manager = context.getSystemService(ShortcutManager.class); + return manager != null && manager.isRequestPinShortcutSupported(); + } + + /** Asks the launcher to pin the icon; the system shows its own confirmation. False when it cannot. */ + static boolean pin(Context context, CustomIconStore.Entry entry) { + if (!canPin(context)) return false; + try { + // Not a component: whichever launcher alias is switched on when the shortcut is tapped + // answers, so choosing another app icon later does not break pinned copies. + Intent launch = new Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_LAUNCHER) + .setPackage(context.getPackageName()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + Bitmap bitmap = BitmapFactory.decodeFile(entry.image.getAbsolutePath()); + if (bitmap == null) return false; + ShortcutInfo info = new ShortcutInfo.Builder(context, SHORTCUT_PREFIX + entry.id) + .setShortLabel(entry.name) + .setLongLabel(entry.name) + .setIcon(Icon.createWithAdaptiveBitmap(bitmap)) + .setIntent(launch) + .build(); + return context.getSystemService(ShortcutManager.class).requestPinShortcut(info, null); + } catch (RuntimeException refused) { + return false; + } + } + + /** Greys out the pinned copy of an icon that was deleted, so it does not open a dead entry. */ + static void unpin(Context context, String id) { + if (Build.VERSION.SDK_INT < 25) return; + try { + ShortcutManager manager = context.getSystemService(ShortcutManager.class); + if (manager != null) { + manager.disableShortcuts(Collections.singletonList(SHORTCUT_PREFIX + id), "Значок удалён из LihonT"); + } + } catch (RuntimeException ignored) { + // Not pinned or already gone: nothing left to disable. + } + } +} diff --git a/src/lihont/two/settings/LihonTSettingsActivity.java b/src/lihont/two/settings/LihonTSettingsActivity.java new file mode 100644 index 0000000..affaff5 --- /dev/null +++ b/src/lihont/two/settings/LihonTSettingsActivity.java @@ -0,0 +1,1164 @@ +package lihont.two.settings; + +import android.app.Activity; +import android.app.Dialog; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.Outline; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.InputFilter; +import android.text.TextWatcher; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewOutlineProvider; +import android.view.Window; +import android.view.WindowManager; +import android.widget.BaseAdapter; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.ScrollView; +import android.widget.TextView; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +/** Material 3 settings screen for LihonT 2. */ +public final class LihonTSettingsActivity extends Activity { + private static final String VERSION_LINE = "LihonT 2.0 · Luna (бета)"; + private static final String BASE_LINE = "База TikTok 47.1.3"; + private static final String CHANNEL_URL = "https://t.me/lihont"; + private static final String DISCLAIMER = + "Бесплатная модификация TikTok для Android. Проект не связан с ByteDance и TikTok Pte. Ltd."; + + private SettingsRepository repository; + private ModSettings settings; + private Md3.Palette palette; + private LinearLayout content; + private TextView status; + private FrameLayout host; + private View snackbar; + private int bottomInset; + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable hideSnackbar = this::dismissRestartNotice; + + // The "Свой значок" screen: a full-screen dialog that lives on top of this activity. + private CustomIconStore iconStore; + private Dialog iconsDialog; + private LinearLayout iconsList; + private FrameLayout iconsHost; + private View iconsNotice; + private int iconCountShown; + private final Runnable hideIconsNotice = this::dismissIconsNotice; + + /** Entry point for the TikTok Settings hook; the activity stays non-exported. */ + public static Intent intent(Context context) { + return new Intent(context, LihonTSettingsActivity.class); + } + + @Override public void onCreate(Bundle state) { + super.onCreate(state); + try { + repository = SettingsHost.repository(this); + settings = repository.current(); + lihont.two.LihonTRegion.setRegion(settings.region()); + } catch (IOException | RuntimeException error) { + showStorageError(); + return; + } + iconStore = new CustomIconStore(IconShortcuts.directory(this)); + palette = Md3.palette(this, settings.theme(), settings.md3()); + Window window = getWindow(); + window.setBackgroundDrawable(new ColorDrawable(palette.surface)); + window.setStatusBarColor(palette.surface); + window.setNavigationBarColor(palette.surface); + if (Build.VERSION.SDK_INT >= 29) { + window.setNavigationBarContrastEnforced(false); + window.setStatusBarContrastEnforced(false); + } + updateSystemBars(window, palette.dark); + buildScreen(); + } + + // ---- screen ------------------------------------------------------------ + + private void buildScreen() { + handler.removeCallbacks(hideSnackbar); + snackbar = null; + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(palette.surface); + + root.addView(Md3.appBar(this, palette, "Настройки мода", Md3Icon.BACK, "Назад", v -> finish())); + + ScrollView scroll = new ScrollView(this); + scroll.setFillViewport(true); + scroll.setClipToPadding(false); + scroll.setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS); + scroll.setFocusable(true); + scroll.setFocusableInTouchMode(true); + content = new LinearLayout(this); + content.setOrientation(LinearLayout.VERTICAL); + content.setPadding(0, 0, 0, Md3.dp(this, 32)); + scroll.addView(content, new ScrollView.LayoutParams(-1, -2)); + root.addView(scroll, new LinearLayout.LayoutParams(-1, 0, 1f)); + applyInsets(root, scroll); + host = new FrameLayout(this); + host.addView(root, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + setContentView(host); + + content.addView(Md3.subheader(this, palette, "Функции")); + LinearLayout cardFunc = Md3.card(this, palette); + cardFunc.addView(switchRow("Баннеры профиля", "Карточки и фон в шапке профиля", + Md3.flowerIconAvatar(this, palette.primaryContainer, Md3Icon.BANNER, palette.onPrimaryContainer, 8), + settings.profileBanners(), TOGGLE_BANNERS)); + cardFunc.addView(Md3.cardDivider(this, palette)); + cardFunc.addView(switchRow("Репост комментариев", "Действие репоста у комментария", + Md3.flowerIconAvatar(this, palette.tertiaryContainer, Md3Icon.REPOST, palette.onTertiaryContainer, 8), + settings.commentReposts(), TOGGLE_REPOSTS)); + cardFunc.addView(Md3.cardDivider(this, palette)); + cardFunc.addView(switchRow("Интерфейс в стиле MD3", + "Нижняя панель, меню профиля и настройки TikTok. Выключено: вид Serena 1.2", + Md3.flowerIconAvatar(this, palette.secondaryContainer, Md3Icon.BOTTOM_BAR, palette.onSecondaryContainer, 8), + settings.md3(), TOGGLE_MD3)); + cardFunc.addView(Md3.cardDivider(this, palette)); + cardFunc.addView(switchRow("Скачивание без водяного знака", "Сохранённые видео без логотипа TikTok", + Md3.flowerIconAvatar(this, palette.primaryContainer, Md3Icon.DOWNLOAD, palette.onPrimaryContainer, 8), + settings.noWatermark(), TOGGLE_NO_WATERMARK)); + content.addView(cardFunc); + + content.addView(Md3.subheader(this, palette, "Лента")); + LinearLayout cardFeed = Md3.card(this, palette); + cardFeed.addView(switchRow("Убрать рекламу", "Рекламные ролики и вставки между видео", + Md3.flowerIconAvatar(this, palette.errorContainer, Md3Icon.AD_OFF, palette.onErrorContainer, 8), + settings.hideAds(), TOGGLE_ADS)); + cardFeed.addView(Md3.cardDivider(this, palette)); + cardFeed.addView(switchRow("Убрать эфиры из ленты", "Прямые трансляции не появляются между видео", + Md3.flowerIconAvatar(this, palette.tertiaryContainer, Md3Icon.LIVE, palette.onTertiaryContainer, 8), + settings.hideLive(), TOGGLE_LIVE)); + cardFeed.addView(Md3.cardDivider(this, palette)); + cardFeed.addView(switchRow("Убрать раздел «Магазин»", "Вкладка над лентой. В бете может сбиться полоса под вкладками", + Md3.flowerIconAvatar(this, palette.secondaryContainer, Md3Icon.SHOP, palette.onSecondaryContainer, 8), + settings.hideShop(), TOGGLE_SHOP)); + content.addView(cardFeed); + + content.addView(Md3.subheader(this, palette, "Значки в именах")); + LinearLayout cardBadges = Md3.card(this, palette); + cardBadges.addView(switchRow("Значки других модов", "TikTok You и MargyT рядом с ником", + Md3.flowerIconAvatar(this, palette.primaryContainer, Md3Icon.NOTE, palette.onPrimaryContainer, 8), + settings.foreignBadges(), TOGGLE_BADGES)); + cardBadges.addView(Md3.cardDivider(this, palette)); + cardBadges.addView(nicheRow()); + content.addView(cardBadges); + + content.addView(Md3.subheader(this, palette, "Параметры")); + LinearLayout cardParams = Md3.card(this, palette); + cardParams.addView(regionRow()); + cardParams.addView(Md3.cardDivider(this, palette)); + cardParams.addView(themeRow()); + content.addView(cardParams); + + content.addView(Md3.subheader(this, palette, "Значок")); + LinearLayout cardIcon = Md3.card(this, palette); + View appIcon = appIconRow(); + if (appIcon != null) { + cardIcon.addView(appIcon); + cardIcon.addView(Md3.cardDivider(this, palette)); + } + cardIcon.addView(iconsRow()); + content.addView(cardIcon); + + content.addView(Md3.subheader(this, palette, "О моде")); + LinearLayout cardAbout = Md3.card(this, palette); + cardAbout.addView(Md3.listItem(this, palette, + Md3.flowerIconAvatar(this, palette.primaryContainer, Md3Icon.INFO, palette.onPrimaryContainer, 8), + "Версия", VERSION_LINE, null, false)); + cardAbout.addView(Md3.cardDivider(this, palette)); + cardAbout.addView(Md3.listItem(this, palette, + Md3.flowerIconAvatar(this, palette.tertiaryContainer, Md3Icon.TIKTOK, palette.onTertiaryContainer, 8), + "База", BASE_LINE, null, false)); + cardAbout.addView(Md3.cardDivider(this, palette)); + cardAbout.addView(telegramRow()); + content.addView(cardAbout); + + status = Md3.text(this, "", Md3.BODY_SMALL, false, palette.onSurfaceVariant); + status.setGravity(Gravity.CENTER_HORIZONTAL); + status.setPadding(Md3.dp(this, 24), Md3.dp(this, 8), Md3.dp(this, 24), Md3.dp(this, 4)); + content.addView(status); + + TextView disclaimer = Md3.text(this, DISCLAIMER, Md3.BODY_SMALL, false, palette.onSurfaceVariant); + disclaimer.setGravity(Gravity.CENTER_HORIZONTAL); + disclaimer.setPadding(Md3.dp(this, 24), Md3.dp(this, 4), Md3.dp(this, 24), Md3.dp(this, 12)); + content.addView(disclaimer); + + LinearLayout actions = new LinearLayout(this); + actions.setOrientation(LinearLayout.VERTICAL); + actions.setGravity(Gravity.CENTER_HORIZONTAL); + actions.setPadding(Md3.dp(this, 16), Md3.dp(this, 8), Md3.dp(this, 16), Md3.dp(this, 16)); + if (repository.restartRequired()) { + TextView restartBtn = Md3.filledButton(this, palette, "Перезагрузить и очистить кэш"); + restartBtn.setOnClickListener(v -> showRestartDialog()); + LinearLayout.LayoutParams restartParams = new LinearLayout.LayoutParams(-2, -2); + restartParams.bottomMargin = Md3.dp(this, 12); + actions.addView(restartBtn, restartParams); + } + TextView reset = Md3.filledTonalButton(this, palette, "Сбросить настройки"); + reset.setOnClickListener(v -> confirmReset()); + actions.addView(reset); + content.addView(actions); + + updateStatus(); + } + + private static final int TOGGLE_BANNERS = 0; + private static final int TOGGLE_REPOSTS = 1; + private static final int TOGGLE_MD3 = 2; + private static final int TOGGLE_NO_WATERMARK = 3; + private static final int TOGGLE_ADS = 4; + private static final int TOGGLE_LIVE = 5; + private static final int TOGGLE_SHOP = 6; + private static final int TOGGLE_BADGES = 7; + + private ModSettings withToggle(int which, boolean value) { + if (which == TOGGLE_BANNERS) return settings.withProfileBanners(value); + if (which == TOGGLE_REPOSTS) return settings.withCommentReposts(value); + if (which == TOGGLE_NO_WATERMARK) return settings.withNoWatermark(value); + if (which == TOGGLE_ADS) return settings.withHideAds(value); + if (which == TOGGLE_LIVE) return settings.withHideLive(value); + if (which == TOGGLE_SHOP) return settings.withHideShop(value); + if (which == TOGGLE_BADGES) return settings.withForeignBadges(value); + return settings.withMd3(value); + } + + private boolean toggleValue(int which) { + if (which == TOGGLE_BANNERS) return settings.profileBanners(); + if (which == TOGGLE_REPOSTS) return settings.commentReposts(); + if (which == TOGGLE_NO_WATERMARK) return settings.noWatermark(); + if (which == TOGGLE_ADS) return settings.hideAds(); + if (which == TOGGLE_LIVE) return settings.hideLive(); + if (which == TOGGLE_SHOP) return settings.hideShop(); + if (which == TOGGLE_BADGES) return settings.foreignBadges(); + return settings.md3(); + } + + private View switchRow(String title, String hint, View leading, boolean checked, final int which) { + Md3Switch toggle = new Md3Switch(this, palette); + toggle.setContentDescription(title); + toggle.setChecked(checked, false); + toggle.setOnCheckedChangeListener((view, value) -> { + if (!persist(withToggle(which, value))) { + view.setChecked(toggleValue(which), true); + } else if (repository.restartRequired()) { + showRestartNotice(); + } + }); + LinearLayout row = Md3.listItem(this, palette, leading, title, hint, toggle, true); + row.setOnClickListener(v -> toggle.toggle()); + return row; + } + + private View regionRow() { + View trailing = Md3.chevron(this, palette); + trailing.setTag("region"); + String label = countryLabel(settings.region()); + String subtitle = settings.needsBannerWarning() ? label : (label + " · по умолчанию"); + LinearLayout row = Md3.listItem(this, palette, + Md3.flowerAvatar(this, palette.tertiaryContainer, palette.onTertiaryContainer, settings.region(), 8), + "Регион мода", subtitle, trailing, true); + row.setOnClickListener(v -> openRegionPicker()); + return row; + } + + private View themeRow() { + View trailing = Md3.chevron(this, palette); + int themeIcon = palette.dark ? Md3Icon.MOON : Md3Icon.SUN; + LinearLayout row = Md3.listItem(this, palette, + Md3.flowerIconAvatar(this, palette.secondaryContainer, themeIcon, palette.onSecondaryContainer, 8), + "Тема оформления", themeLabel(settings.theme()), trailing, true); + row.setOnClickListener(v -> openThemePicker()); + return row; + } + + private View telegramRow() { + LinearLayout row = Md3.listItem(this, palette, + Md3.flowerIconAvatar(this, palette.secondaryContainer, Md3Icon.SEND, palette.onSecondaryContainer, 8), + "Telegram-канал", CHANNEL_URL, null, true); + row.setOnClickListener(v -> openUrl(CHANNEL_URL)); + return row; + } + + // ---- niche mod badge --------------------------------------------------- + + private View nicheRow() { + LinearLayout row = Md3.listItem(this, palette, + Md3.flowerIconAvatar(this, palette.tertiaryContainer, Md3Icon.NOTE, palette.onTertiaryContainer, 8), + "Значок «Нишевый мод»", "Получить можно до 15 ноября 12:34 (МСК)", Md3.chevron(this, palette), true); + row.setOnClickListener(v -> openNicheDialog()); + return row; + } + + private void openNicheDialog() { + final lihont.two.badge.NicheClaim.Account account = lihont.two.badge.NicheClaim.account(); + final Dialog dialog = new Dialog(this); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout box = new LinearLayout(this); + box.setOrientation(LinearLayout.VERTICAL); + box.setPadding(Md3.dp(this, 24), Md3.dp(this, 24), Md3.dp(this, 24), Md3.dp(this, 16)); + box.setBackground(Md3.shape(palette.surfaceContainerHigh, Md3.dialogRadius(palette), this)); + box.addView(Md3.text(this, "Значок «Нишевый мод»", Md3.HEADLINE_SMALL, false, palette.onSurface)); + final TextView info = Md3.text(this, account == null + ? "Войдите в TikTok, чтобы получить значок." + : "1. Скопируйте код.\n2. Вставьте его в описание профиля TikTok.\n3. Нажмите «Проверить»: " + + "сервер найдёт код и выдаст значок. Он будет виден рядом с вашим ником у всех с модом.", + Md3.BODY_MEDIUM, false, palette.onSurfaceVariant); + info.setPadding(0, Md3.dp(this, 12), 0, Md3.dp(this, 12)); + box.addView(info); + final TextView code = Md3.text(this, account == null ? "" : "…", Md3.TITLE_LARGE, true, palette.primary); + box.addView(code); + final TextView result = Md3.text(this, "", Md3.BODY_MEDIUM, false, palette.onSurface); + result.setPadding(0, Md3.dp(this, 12), 0, 0); + box.addView(result); + LinearLayout buttons = new LinearLayout(this); + buttons.setGravity(Gravity.END); + buttons.setPadding(0, Md3.dp(this, 16), 0, 0); + TextView copy = Md3.textButton(this, palette, "Скопировать"); + TextView check = Md3.filledButton(this, palette, "Проверить"); + TextView close = Md3.textButton(this, palette, "Закрыть"); + buttons.addView(close); + buttons.addView(copy); + buttons.addView(check); + box.addView(buttons); + close.setOnClickListener(v -> dialog.dismiss()); + if (account != null) { + lihont.two.badge.NicheClaim.fetchCode(account, (ok, message, value) -> { + code.setText(ok ? value : ""); + if (!ok) result.setText(message); + }); + copy.setOnClickListener(v -> { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("LihonT", code.getText())); + result.setText("Код скопирован"); + }); + check.setOnClickListener(v -> { + result.setText("Проверяем…"); + lihont.two.badge.NicheClaim.claim(account, (ok, message, value) -> result.setText(message)); + }); + } + dialog.setContentView(box); + styleDialog(dialog); + dialog.show(); + sizeDialog(dialog, 0.92f); + } + + // ---- app icon ---------------------------------------------------------- + + /** The row that opens the choice of built-in app icons; null when this build ships none. */ + private View appIconRow() { + List choices = AppIcons.list(this); + if (choices.size() < 2) return null; + AppIcons.Choice current = choices.get(0); + for (AppIcons.Choice choice : choices) { + if (choice.selected) current = choice; + } + LinearLayout row = Md3.listItem(this, palette, iconPreview(current.icon), "Значок приложения", + current.title, Md3.chevron(this, palette), true); + row.setOnClickListener(v -> openAppIconPicker()); + return row; + } + + /** An app icon as a 40dp rounded square, the way the launcher masks it. */ + private ImageView iconPreview(Drawable icon) { + final int size = Md3.dp(this, 40); + ImageView view = new ImageView(this); + view.setImageDrawable(icon); + view.setScaleType(ImageView.ScaleType.CENTER_CROP); + view.setOutlineProvider(new ViewOutlineProvider() { + @Override public void getOutline(View v, Outline outline) { + outline.setRoundRect(0, 0, v.getWidth(), v.getHeight(), size * 0.24f); + } + }); + view.setClipToOutline(true); + view.setLayoutParams(new LinearLayout.LayoutParams(size, size)); + return view; + } + + private void openAppIconPicker() { + final Dialog dialog = new Dialog(this); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout list = new LinearLayout(this); + list.setOrientation(LinearLayout.VERTICAL); + list.setPadding(0, Md3.dp(this, 8), 0, Md3.dp(this, 8)); + TextView title = Md3.text(this, "Значок приложения", Md3.HEADLINE_SMALL, false, palette.onSurface); + title.setPadding(Md3.dp(this, 24), Md3.dp(this, 16), Md3.dp(this, 24), Md3.dp(this, 8)); + list.addView(title); + for (final AppIcons.Choice choice : AppIcons.list(this)) { + View trailing = null; + if (choice.selected) { + ImageView check = new ImageView(this); + check.setImageDrawable(Md3Icon.of(Md3Icon.CHECK, palette.primary, this)); + check.setScaleType(ImageView.ScaleType.CENTER); + check.setLayoutParams(new LinearLayout.LayoutParams(Md3.dp(this, 24), Md3.dp(this, 24))); + trailing = check; + } + LinearLayout row = Md3.listItem(this, palette, iconPreview(choice.icon), choice.title, null, trailing, true); + row.setOnClickListener(v -> { + dialog.dismiss(); + chooseAppIcon(choice); + }); + list.addView(row); + } + ScrollView scroll = new ScrollView(this); + scroll.addView(list, new ScrollView.LayoutParams(-1, -2)); + scroll.setBackground(Md3.shape(palette.surfaceContainerHigh, Md3.dialogRadius(palette), this)); + dialog.setContentView(scroll); + styleDialog(dialog); + dialog.show(); + sizeDialog(dialog, 0.9f); + } + + private void chooseAppIcon(AppIcons.Choice choice) { + if (choice.selected) return; + boolean done = AppIcons.select(this, choice.component); + buildScreen(); + showNotice(done + ? "Значок сменится через несколько секунд. Если не изменился, перезапустите лончер." + : "Не удалось сменить значок"); + } + + // ---- custom icon ------------------------------------------------------- + + private View iconsRow() { + int count = iconStore.list().size(); + iconCountShown = count; + String hint = count == 0 ? "Своё фото как значок на главном экране" : "Сохранено значков: " + count; + LinearLayout row = Md3.listItem(this, palette, + Md3.flowerIconAvatar(this, palette.primaryContainer, Md3Icon.ADD_PHOTO, palette.onPrimaryContainer, 8), + "Свой значок", hint, Md3.chevron(this, palette), true); + row.setOnClickListener(v -> openIconsScreen()); + return row; + } + + private void openIconsScreen() { + final Dialog dialog = new Dialog(this); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(palette.surface); + applyInsets(root); + root.addView(Md3.appBar(this, palette, "Свой значок", Md3Icon.CLOSE, "Закрыть", v -> dialog.dismiss())); + ScrollView scroll = new ScrollView(this); + scroll.setFillViewport(true); + iconsList = new LinearLayout(this); + iconsList.setOrientation(LinearLayout.VERTICAL); + scroll.addView(iconsList, new ScrollView.LayoutParams(-1, -2)); + root.addView(scroll, new LinearLayout.LayoutParams(-1, 0, 1f)); + iconsHost = new FrameLayout(this); + iconsHost.addView(root, new FrameLayout.LayoutParams(-1, -1)); + iconsDialog = dialog; + dialog.setOnDismissListener(d -> { + handler.removeCallbacks(hideIconsNotice); + iconsDialog = null; + iconsList = null; + iconsHost = null; + iconsNotice = null; + if (iconStore.list().size() != iconCountShown) buildScreen(); + }); + renderIcons(); + dialog.setContentView(iconsHost); + styleDialog(dialog); + dialog.show(); + if (dialog.getWindow() != null) { + dialog.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + } + + private void renderIcons() { + if (iconsList == null) return; + iconsList.removeAllViews(); + TextView intro = Md3.text(this, + "Выберите фото: оно станет значком TikTok на главном экране. Значок самого приложения в меню остаётся прежним.", + Md3.BODY_MEDIUM, false, palette.onSurfaceVariant); + intro.setPadding(Md3.dp(this, 24), Md3.dp(this, 8), Md3.dp(this, 24), Md3.dp(this, 16)); + iconsList.addView(intro); + + TextView add = Md3.filledTonalButton(this, palette, "Добавить фото"); + add.setOnClickListener(v -> pickIcon()); + LinearLayout.LayoutParams addParams = new LinearLayout.LayoutParams(-2, -2); + addParams.leftMargin = Md3.dp(this, 16); + addParams.bottomMargin = Md3.dp(this, 8); + iconsList.addView(add, addParams); + + if (!IconShortcuts.canPin(this)) { + TextView unsupported = Md3.text(this, + "Этот лончер не умеет закреплять ярлыки: значки можно сохранить, но вынести на экран не получится.", + Md3.BODY_SMALL, false, palette.error); + unsupported.setPadding(Md3.dp(this, 24), Md3.dp(this, 8), Md3.dp(this, 24), Md3.dp(this, 8)); + iconsList.addView(unsupported); + } + + final List entries = iconStore.list(); + if (entries.isEmpty()) { + TextView empty = Md3.text(this, "Пока нет значков", Md3.BODY_MEDIUM, false, palette.onSurfaceVariant); + empty.setPadding(Md3.dp(this, 24), Md3.dp(this, 16), Md3.dp(this, 24), Md3.dp(this, 8)); + iconsList.addView(empty); + return; + } + iconsList.addView(Md3.subheader(this, palette, "Мои значки")); + LinearLayout card = Md3.card(this, palette); + for (int i = 0; i < entries.size(); i++) { + if (i > 0) card.addView(Md3.cardDivider(this, palette)); + card.addView(iconRow(entries.get(i))); + } + iconsList.addView(card); + } + + private View iconRow(final CustomIconStore.Entry entry) { + int size = Md3.dp(this, 40); + ImageView preview = new ImageView(this); + Bitmap stored = BitmapFactory.decodeFile(entry.image.getAbsolutePath()); + if (stored != null) { + preview.setImageBitmap(IconShortcuts.roundedPreview(stored, size)); + stored.recycle(); + } + preview.setLayoutParams(new LinearLayout.LayoutParams(size, size)); + + ImageView delete = new ImageView(this); + delete.setImageDrawable(Md3Icon.of(Md3Icon.DELETE, palette.onSurfaceVariant, this)); + delete.setScaleType(ImageView.ScaleType.CENTER); + delete.setBackground(Md3.stateLayer(this, Md3.SHAPE_FULL, Md3.stateLayerColor(palette.onSurface))); + delete.setContentDescription("Удалить значок " + entry.name); + delete.setClickable(true); + delete.setFocusable(true); + delete.setLayoutParams(new LinearLayout.LayoutParams(size, size)); + delete.setOnClickListener(v -> confirmDeleteIcon(entry)); + + LinearLayout row = Md3.listItem(this, palette, preview, entry.name, + "Нажмите, чтобы добавить на главный экран", delete, true); + row.setOnClickListener(v -> pinIcon(entry)); + return row; + } + + private void pickIcon() { + try { + startActivityForResult(IconShortcuts.pickIntent(), IconShortcuts.REQUEST_PICK); + } catch (ActivityNotFoundException | SecurityException none) { + showIconsNotice("Не нашлось приложения для выбора фото"); + } + } + + @Override protected void onActivityResult(int request, int result, Intent data) { + super.onActivityResult(request, result, data); + if (request != IconShortcuts.REQUEST_PICK || result != RESULT_OK || data == null || iconStore == null) return; + final Uri uri = data.getData(); + if (uri == null) return; + new Thread(() -> { + Bitmap square = null; + try { + square = IconShortcuts.decodeSquare(getContentResolver(), uri); + } catch (IOException | RuntimeException | OutOfMemoryError unreadable) { + // Reported on the screen below. + } + final Bitmap picked = square; + handler.post(() -> { + if (isFinishing()) return; + if (iconsDialog == null) openIconsScreen(); + if (picked == null) showIconsNotice("Не удалось открыть это фото"); + else showIconNameDialog(picked); + }); + }, "lihont-icon-decode").start(); + } + + private void showIconNameDialog(final Bitmap square) { + final Dialog dialog = new Dialog(this); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackground(Md3.shape(palette.surfaceContainerHigh, Md3.dialogRadius(palette), this)); + root.setPadding(Md3.dp(this, 24), Md3.dp(this, 24), Md3.dp(this, 24), Md3.dp(this, 24)); + root.addView(Md3.text(this, "Новый значок", Md3.HEADLINE_SMALL, false, palette.onSurface)); + + int previewSize = Md3.dp(this, 96); + ImageView preview = new ImageView(this); + preview.setImageBitmap(IconShortcuts.roundedPreview(square, previewSize)); + LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(previewSize, previewSize); + previewParams.gravity = Gravity.CENTER_HORIZONTAL; + previewParams.topMargin = Md3.dp(this, 16); + root.addView(preview, previewParams); + + TextView caption = Md3.text(this, "Так значок выглядит на главном экране", Md3.BODY_SMALL, false, + palette.onSurfaceVariant); + caption.setGravity(Gravity.CENTER_HORIZONTAL); + LinearLayout.LayoutParams captionParams = new LinearLayout.LayoutParams(-1, -2); + captionParams.topMargin = Md3.dp(this, 8); + captionParams.bottomMargin = Md3.dp(this, 16); + root.addView(caption, captionParams); + + final EditText input = new EditText(this); + input.setText(CustomIconStore.DEFAULT_NAME); + input.setSelectAllOnFocus(true); + input.setHint("Название под значком"); + input.setHintTextColor(palette.onSurfaceVariant); + input.setTextColor(palette.onSurface); + input.setTextSize(Md3.BODY_LARGE); + input.setSingleLine(true); + input.setFilters(new InputFilter[] { new InputFilter.LengthFilter(CustomIconStore.MAX_NAME) }); + input.setBackground(Md3.outlined(Color.TRANSPARENT, palette.outline, 1f, Md3.SHAPE_EXTRA_SMALL, this)); + input.setPadding(Md3.dp(this, 16), Md3.dp(this, 14), Md3.dp(this, 16), Md3.dp(this, 14)); + root.addView(input, new LinearLayout.LayoutParams(-1, -2)); + + LinearLayout actions = new LinearLayout(this); + actions.setGravity(Gravity.END); + LinearLayout.LayoutParams actionsParams = new LinearLayout.LayoutParams(-1, -2); + actionsParams.topMargin = Md3.dp(this, 24); + root.addView(actions, actionsParams); + TextView cancel = Md3.textButton(this, palette, "Отмена"); + cancel.setOnClickListener(v -> dialog.dismiss()); + actions.addView(cancel); + TextView save = Md3.textButton(this, palette, "Добавить"); + save.setOnClickListener(v -> { + dialog.dismiss(); + try { + iconStore.add(IconShortcuts.toPng(square), input.getText().toString()); + renderIcons(); + showIconsNotice("Значок добавлен. Нажмите на него, чтобы вынести на главный экран"); + } catch (IOException | RuntimeException error) { + showIconsNotice(iconStore.list().size() >= CustomIconStore.MAX_ICONS + ? "Достигнут предел в " + CustomIconStore.MAX_ICONS + " значков. Удалите ненужный." + : "Не удалось сохранить значок"); + } + }); + LinearLayout.LayoutParams saveParams = new LinearLayout.LayoutParams(-2, -2); + saveParams.leftMargin = Md3.dp(this, 8); + actions.addView(save, saveParams); + + dialog.setContentView(root); + styleDialog(dialog); + dialog.show(); + sizeDialog(dialog, 0.9f); + } + + private void pinIcon(CustomIconStore.Entry entry) { + if (IconShortcuts.pin(this, entry)) { + showIconsNotice("Подтвердите добавление значка в окне лончера"); + } else if (!IconShortcuts.canPin(this)) { + showIconsNotice("Этот лончер не умеет закреплять ярлыки"); + } else { + showIconsNotice("Не удалось добавить значок"); + } + } + + private void confirmDeleteIcon(final CustomIconStore.Entry entry) { + basicDialog("Удалить значок «" + entry.name + "»?", + "Копия на главном экране перестанет открываться.", + "Отмена", "Удалить", () -> { + if (iconStore.remove(entry.id)) { + IconShortcuts.unpin(this, entry.id); + renderIcons(); + } else { + showIconsNotice("Не удалось удалить значок"); + } + }); + } + + /** A message at the bottom of the icons screen; the settings screen's own snackbar is under it. */ + private void showIconsNotice(String message) { + if (iconsHost == null) return; + handler.removeCallbacks(hideIconsNotice); + if (iconsNotice != null) iconsHost.removeView(iconsNotice); + iconsNotice = Md3.snackbar(this, palette, message, "OK", v -> dismissIconsNotice()); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM); + params.leftMargin = Md3.dp(this, 16); + params.rightMargin = Md3.dp(this, 16); + params.bottomMargin = bottomInset + Md3.dp(this, 16); + iconsHost.addView(iconsNotice, params); + handler.postDelayed(hideIconsNotice, 6000); + } + + private void dismissIconsNotice() { + handler.removeCallbacks(hideIconsNotice); + if (iconsNotice != null && iconsHost != null) iconsHost.removeView(iconsNotice); + iconsNotice = null; + } + + // ---- pickers ----------------------------------------------------------- + + private void openRegionPicker() { + final Dialog dialog = new Dialog(this); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(palette.surface); + applyInsets(root); + root.addView(Md3.appBar(this, palette, "Регион мода", Md3Icon.CLOSE, "Закрыть", v -> dialog.dismiss())); + + LinearLayout searchBar = new LinearLayout(this); + searchBar.setOrientation(LinearLayout.HORIZONTAL); + searchBar.setGravity(Gravity.CENTER_VERTICAL); + searchBar.setBackground(Md3.shape(palette.surfaceContainerHigh, Md3.SHAPE_FULL, this)); + searchBar.setPadding(Md3.dp(this, 16), 0, Md3.dp(this, 16), 0); + ImageView searchIcon = new ImageView(this); + searchIcon.setImageDrawable(Md3Icon.of(Md3Icon.SEARCH, palette.onSurfaceVariant, this)); + searchBar.addView(searchIcon, new LinearLayout.LayoutParams(Md3.dp(this, 24), Md3.dp(this, 24))); + EditText input = new EditText(this); + input.setHint("Поиск страны или ISO-кода"); + input.setHintTextColor(palette.onSurfaceVariant); + input.setTextColor(palette.onSurface); + input.setTextSize(Md3.BODY_LARGE); + input.setSingleLine(true); + input.setBackground(null); + LinearLayout.LayoutParams inputParams = new LinearLayout.LayoutParams(0, -1, 1f); + inputParams.leftMargin = Md3.dp(this, 12); + searchBar.addView(input, inputParams); + LinearLayout.LayoutParams barParams = new LinearLayout.LayoutParams(-1, Md3.dp(this, 56)); + barParams.leftMargin = Md3.dp(this, 16); + barParams.rightMargin = Md3.dp(this, 16); + barParams.topMargin = Md3.dp(this, 4); + barParams.bottomMargin = Md3.dp(this, 8); + root.addView(searchBar, barParams); + + final List entries = countryEntries(); + final List visible = new ArrayList(entries); + final BaseAdapter adapter = new BaseAdapter() { + @Override public int getCount() { return visible.size(); } + @Override public Object getItem(int position) { return visible.get(position); } + @Override public long getItemId(int position) { return position; } + @Override public View getView(int position, View recycled, ViewGroup parent) { + String[] entry = visible.get(position); + boolean selected = entry[0].equals(settings.region()); + View trailing = null; + if (selected) { + ImageView check = new ImageView(LihonTSettingsActivity.this); + check.setImageDrawable(Md3Icon.of(Md3Icon.CHECK, palette.primary, LihonTSettingsActivity.this)); + check.setScaleType(ImageView.ScaleType.CENTER); + check.setLayoutParams(new LinearLayout.LayoutParams(Md3.dp(LihonTSettingsActivity.this, 24), + Md3.dp(LihonTSettingsActivity.this, 24))); + trailing = check; + } + LinearLayout row = Md3.listItem(LihonTSettingsActivity.this, palette, + Md3.flowerAvatar(LihonTSettingsActivity.this, palette.tertiaryContainer, palette.onTertiaryContainer, entry[0], 8), + entry[1], entry[0], trailing, false); + row.setOnClickListener(v -> chooseRegion(dialog, entry[0])); + return row; + } + }; + ListView list = new ListView(this); + list.setDivider(null); + list.setSelector(new ColorDrawable(Color.TRANSPARENT)); + list.setAdapter(adapter); + list.setOnItemClickListener((parent, view, position, id) -> chooseRegion(dialog, visible.get(position)[0])); + root.addView(list, new LinearLayout.LayoutParams(-1, 0, 1f)); + + input.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) { } + @Override public void onTextChanged(CharSequence s, int a, int b, int c) { } + @Override public void afterTextChanged(Editable s) { + String query = s.toString().trim().toLowerCase(Locale.ROOT); + visible.clear(); + for (String[] entry : entries) { + if (query.length() == 0 || entry[1].toLowerCase(Locale.ROOT).contains(query) + || entry[0].toLowerCase(Locale.ROOT).contains(query)) { + visible.add(entry); + } + } + adapter.notifyDataSetChanged(); + } + }); + + dialog.setContentView(root); + styleDialog(dialog); + dialog.show(); + if (dialog.getWindow() != null) { + dialog.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + } + + private void chooseRegion(final Dialog dialog, final String code) { + persist(settings.withRegion(code)); + dialog.dismiss(); + showRestartDialog(); + } + + private void showRestartDialog() { + basicDialog("Для изменения настроек перезагрузите приложение", + "Кэш приложения будет очищен, чтобы новые настройки применились корректно.", + "Отмена", "Перезагрузить", () -> { + restartApp(); + }); + } + + /** A bottom message for switches: the change is saved, but only the next TikTok start uses it. */ + private void showRestartNotice() { + showSnack("Изменения применятся после перезапуска TikTok", "Перезапуск", () -> { + dismissRestartNotice(); + showRestartDialog(); + }); + } + + private void showNotice(String message) { + showSnack(message, "OK", this::dismissRestartNotice); + } + + private void showSnack(String message, String action, final Runnable onAction) { + if (host == null) return; + handler.removeCallbacks(hideSnackbar); + if (snackbar != null) host.removeView(snackbar); + snackbar = Md3.snackbar(this, palette, message, action, v -> onAction.run()); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM); + params.leftMargin = Md3.dp(this, 16); + params.rightMargin = Md3.dp(this, 16); + params.bottomMargin = bottomInset + Md3.dp(this, 16); + snackbar.setAlpha(0f); + snackbar.setTranslationY(Md3.dp(this, 24)); + host.addView(snackbar, params); + snackbar.animate().alpha(1f).translationY(0f).setDuration(200).start(); + handler.postDelayed(hideSnackbar, 8000); + } + + private void dismissRestartNotice() { + handler.removeCallbacks(hideSnackbar); + final View bar = snackbar; + if (bar == null || host == null) return; + snackbar = null; + bar.animate().alpha(0f).translationY(Md3.dp(this, 24)).setDuration(150) + .withEndAction(() -> { if (host != null) host.removeView(bar); }).start(); + } + + @Override protected void onDestroy() { + handler.removeCallbacks(hideSnackbar); + handler.removeCallbacks(hideIconsNotice); + if (iconsDialog != null) { + iconsDialog.setOnDismissListener(null); + iconsDialog.dismiss(); + } + super.onDestroy(); + } + + private void clearCache() { + try { + deleteRecursively(getCacheDir()); + if (Build.VERSION.SDK_INT >= 21) { + deleteRecursively(getCodeCacheDir()); + } + File ext = getExternalCacheDir(); + if (ext != null) { + deleteRecursively(ext); + } + } catch (Throwable ignored) {} + } + + private static boolean deleteRecursively(File file) { + if (file == null || !file.exists()) return true; + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + return file.delete(); + } + + private void restartApp() { + clearCache(); + try { + Intent intent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + } + } catch (Throwable ignored) {} + System.exit(0); + } + + private void openThemePicker() { + final Dialog dialog = new Dialog(this); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackground(Md3.shape(palette.surfaceContainerHigh, Md3.dialogRadius(palette), this)); + root.setPadding(0, Md3.dp(this, 8), 0, Md3.dp(this, 8)); + for (final ModSettings.Theme option : ModSettings.Theme.values()) { + boolean selected = option == settings.theme(); + View trailing = null; + if (selected) { + ImageView check = new ImageView(this); + check.setImageDrawable(Md3Icon.of(Md3Icon.CHECK, palette.primary, this)); + check.setScaleType(ImageView.ScaleType.CENTER); + check.setLayoutParams(new LinearLayout.LayoutParams(Md3.dp(this, 24), Md3.dp(this, 24))); + trailing = check; + } + LinearLayout row = Md3.listItem(this, palette, null, themeLabel(option), null, trailing, true); + row.setOnClickListener(v -> { + persist(settings.withTheme(option)); + dialog.dismiss(); + }); + root.addView(row); + } + dialog.setContentView(root); + styleDialog(dialog); + dialog.show(); + sizeDialog(dialog, 0.85f); + } + + private void confirmReset() { + basicDialog("Сбросить настройки?", "Значения вернутся к состоянию по умолчанию: Япония, баннеры и репосты включены.", + "Отмена", "Сбросить", () -> { + try { + repository.reset(); + settings = repository.current(); + palette = Md3.palette(this, settings.theme(), settings.md3()); + Window window = getWindow(); + window.setBackgroundDrawable(new ColorDrawable(palette.surface)); + window.setStatusBarColor(palette.surface); + window.setNavigationBarColor(palette.surface); + updateSystemBars(window, palette.dark); + buildScreen(); + } catch (IOException | RuntimeException error) { + updateStatus(); + } + }); + } + + private Dialog basicDialog(CharSequence title, CharSequence message, CharSequence negative, + CharSequence positive, final Runnable onPositive) { + final Dialog dialog = new Dialog(this); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackground(Md3.shape(palette.surfaceContainerHigh, Md3.dialogRadius(palette), this)); + root.setPadding(Md3.dp(this, 24), Md3.dp(this, 24), Md3.dp(this, 24), Md3.dp(this, 24)); + root.addView(Md3.text(this, title, Md3.HEADLINE_SMALL, false, palette.onSurface)); + if (message != null) { + TextView body = Md3.text(this, message, Md3.BODY_MEDIUM, false, palette.onSurfaceVariant); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(-1, -2); + params.topMargin = Md3.dp(this, 16); + root.addView(body, params); + } + LinearLayout actions = new LinearLayout(this); + actions.setGravity(Gravity.END); + LinearLayout.LayoutParams actionsParams = new LinearLayout.LayoutParams(-1, -2); + actionsParams.topMargin = Md3.dp(this, 24); + root.addView(actions, actionsParams); + if (negative != null) { + TextView cancel = Md3.textButton(this, palette, negative); + cancel.setOnClickListener(v -> dialog.dismiss()); + actions.addView(cancel); + } + if (positive != null) { + TextView confirm = Md3.textButton(this, palette, positive); + confirm.setOnClickListener(v -> { + dialog.dismiss(); + if (onPositive != null) onPositive.run(); + }); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(-2, -2); + params.leftMargin = Md3.dp(this, 8); + actions.addView(confirm, params); + } + dialog.setContentView(root); + styleDialog(dialog); + dialog.show(); + sizeDialog(dialog, 0.9f); + return dialog; + } + + private void showStorageError() { + palette = Md3.dark(); + Window window = getWindow(); + window.setBackgroundDrawable(new ColorDrawable(palette.surface)); + window.setStatusBarColor(palette.surface); + window.setNavigationBarColor(palette.surface); + if (Build.VERSION.SDK_INT >= 29) { + window.setNavigationBarContrastEnforced(false); + window.setStatusBarContrastEnforced(false); + } + updateSystemBars(window, palette.dark); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setBackgroundColor(palette.surface); + applyInsets(root, null); + root.addView(Md3.appBar(this, palette, "Настройки мода", Md3Icon.CLOSE, "Закрыть", v -> finish())); + TextView message = Md3.text(this, + "Не удалось прочитать локальные настройки. Данные не были перезаписаны.", + Md3.BODY_MEDIUM, false, palette.onSurfaceVariant); + message.setPadding(Md3.dp(this, 16), Md3.dp(this, 8), Md3.dp(this, 16), 0); + root.addView(message); + setContentView(root); + } + + // ---- state ------------------------------------------------------------- + + private boolean persist(ModSettings next) { + try { + repository.save(next); + lihont.two.LihonTRegion.setRegion(next.region()); + settings = next; + palette = Md3.palette(this, settings.theme(), settings.md3()); + Window window = getWindow(); + window.setBackgroundDrawable(new ColorDrawable(palette.surface)); + window.setStatusBarColor(palette.surface); + window.setNavigationBarColor(palette.surface); + updateSystemBars(window, palette.dark); + buildScreen(); + return true; + } catch (IOException | RuntimeException error) { + if (status != null) { + status.setText("Не удалось сохранить настройки."); + status.setTextColor(palette.error); + } + return false; + } + } + + private void updateStatus() { + if (status == null) return; + StringBuilder message = new StringBuilder("Значения хранятся локально и не выдаются за серверные."); + if (repository.restartRequired()) message.append(" Изменения применятся после перезапуска TikTok."); + if (settings.needsBannerWarning()) message.append(" В выбранном регионе часть баннеров может быть недоступна."); + status.setText(message); + status.setTextColor(settings.needsBannerWarning() ? palette.error : palette.onSurfaceVariant); + } + + // ---- helpers ----------------------------------------------------------- + + private List countryEntries() { + List entries = new ArrayList(); + for (String code : ModSettings.COUNTRY_CODES) { + String name = new Locale("", code).getDisplayCountry(new Locale("ru")); + if (name == null || name.length() == 0) name = code; + entries.add(new String[] { code, name }); + } + Collections.sort(entries, new Comparator() { + @Override public int compare(String[] left, String[] right) { + return left[1].compareToIgnoreCase(right[1]); + } + }); + return entries; + } + + private String countryLabel(String code) { + String name = new Locale("", code).getDisplayCountry(new Locale("ru")); + return (name == null || name.length() == 0) ? code : name; + } + + private String themeLabel(ModSettings.Theme theme) { + if (theme == ModSettings.Theme.LIGHT) return "Светлая"; + if (theme == ModSettings.Theme.DARK) return "Тёмная"; + return "Системная"; + } + + private void openUrl(String url) { + try { + startActivity(new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))); + } catch (RuntimeException ignored) { + // No browser or blocked link: the row simply stays informational. + } + } + + private void applyInsets(final View root) { + applyInsets(root, null); + } + + private void applyInsets(final View root, final View scroll) { + if (Build.VERSION.SDK_INT >= 20) { + root.setOnApplyWindowInsetsListener((v, insets) -> { + int top = 0; + int bottom = 0; + if (Build.VERSION.SDK_INT >= 30) { + android.graphics.Insets bars = insets.getInsets(android.view.WindowInsets.Type.systemBars()); + top = bars.top; + bottom = bars.bottom; + } else { + top = insets.getSystemWindowInsetTop(); + bottom = insets.getSystemWindowInsetBottom(); + } + v.setPadding(0, top, 0, scroll == null ? bottom : 0); + if (scroll != null) { + bottomInset = bottom; + if (snackbar != null && snackbar.getLayoutParams() instanceof FrameLayout.LayoutParams) { + FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) snackbar.getLayoutParams(); + lp.bottomMargin = bottom + Md3.dp(LihonTSettingsActivity.this, 16); + snackbar.setLayoutParams(lp); + } + scroll.setPadding(0, 0, 0, bottom + Md3.dp(LihonTSettingsActivity.this, 24)); + } + return insets; + }); + } + } + + private void updateSystemBars(Window window, boolean isDark) { + if (Build.VERSION.SDK_INT >= 30) { + android.view.WindowInsetsController controller = window.getInsetsController(); + if (controller != null) { + int appearance = 0; + if (!isDark) { + appearance |= android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS; + appearance |= android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS; + } + controller.setSystemBarsAppearance(appearance, + android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS | + android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS); + } + } else if (Build.VERSION.SDK_INT >= 26) { + View decor = window.getDecorView(); + int flags = decor.getSystemUiVisibility(); + if (!isDark) { + flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } else { + flags &= ~View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; + flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + decor.setSystemUiVisibility(flags); + } + } + + private void styleDialog(Dialog dialog) { + Window window = dialog.getWindow(); + if (window == null) return; + window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + window.setStatusBarColor(palette.surface); + window.setNavigationBarColor(palette.surface); + if (Build.VERSION.SDK_INT >= 29) { + window.setNavigationBarContrastEnforced(false); + window.setStatusBarContrastEnforced(false); + } + updateSystemBars(window, palette.dark); + window.setDimAmount(0.5f); + window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + } + + private void sizeDialog(Dialog dialog, float widthFraction) { + Window window = dialog.getWindow(); + if (window == null) return; + int width = (int) (getResources().getDisplayMetrics().widthPixels * widthFraction); + window.setLayout(width, ViewGroup.LayoutParams.WRAP_CONTENT); + } +} + + diff --git a/src/lihont/two/settings/Md3.java b/src/lihont/two/settings/Md3.java new file mode 100644 index 0000000..d1dc514 --- /dev/null +++ b/src/lihont/two/settings/Md3.java @@ -0,0 +1,671 @@ +package lihont.two.settings; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Configuration; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.RippleDrawable; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** + * Material 3 (Material You) tokens and primitives for LihonT screens. + * + * Values are the baseline M3 color roles, the M3 type scale and the M3 shape + * scale, so the mod's own screens follow Material 3 without the Material + * Components library: TikTok's APK ships no Theme.Material3 resources, so the + * components are built from framework views on top of these tokens. + */ +public final class Md3 { + // ---- type scale (baseline M3, sp) ------------------------------------- + public static final float DISPLAY_SMALL = 36f; + public static final float HEADLINE_MEDIUM = 28f; + public static final float HEADLINE_SMALL = 24f; + public static final float TITLE_LARGE = 22f; + public static final float TITLE_MEDIUM = 16f; + public static final float TITLE_SMALL = 14f; + public static final float BODY_LARGE = 16f; + public static final float BODY_MEDIUM = 14f; + public static final float BODY_SMALL = 12f; + public static final float LABEL_LARGE = 14f; + public static final float LABEL_MEDIUM = 12f; + public static final float LABEL_SMALL = 11f; + + // ---- shape scale (dp) -------------------------------------------------- + public static final float SHAPE_EXTRA_SMALL = 4f; + public static final float SHAPE_SMALL = 8f; + public static final float SHAPE_MEDIUM = 12f; + public static final float SHAPE_LARGE = 16f; + public static final float SHAPE_EXTRA_LARGE = 28f; + /** Used for pills and circles: larger than any real corner. */ + public static final float SHAPE_FULL = 512f; + + private static final Typeface REGULAR = Typeface.create("sans-serif", Typeface.NORMAL); + private static final Typeface MEDIUM = Typeface.create("sans-serif-medium", Typeface.NORMAL); + + private Md3() {} + + /** One resolved M3 color scheme: dynamic Monet or the baseline light or dark palette. */ + public static final class Palette { + public final boolean dark; + /** The flat TikTok-style skin of Serena 1.2 instead of Material 3 (see classic()). */ + public final boolean classic; + public final int primary; + public final int onPrimary; + public final int primaryContainer; + public final int onPrimaryContainer; + public final int secondaryContainer; + public final int onSecondaryContainer; + public final int tertiary; + public final int onTertiary; + public final int tertiaryContainer; + public final int onTertiaryContainer; + public final int surface; + public final int surfaceContainerLowest; + public final int surfaceContainerLow; + public final int surfaceContainer; + public final int surfaceContainerHigh; + public final int surfaceContainerHighest; + public final int onSurface; + public final int onSurfaceVariant; + public final int outline; + public final int outlineVariant; + public final int error; + public final int onError; + public final int errorContainer; + public final int onErrorContainer; + + private Palette(boolean dark, boolean classic, int primary, int onPrimary, int primaryContainer, + int onPrimaryContainer, int secondaryContainer, int onSecondaryContainer, + int tertiary, int onTertiary, int tertiaryContainer, int onTertiaryContainer, + int surface, int surfaceContainerLowest, int surfaceContainerLow, + int surfaceContainer, int surfaceContainerHigh, int surfaceContainerHighest, + int onSurface, int onSurfaceVariant, int outline, int outlineVariant, + int error, int onError, int errorContainer, int onErrorContainer) { + this.dark = dark; + this.classic = classic; + this.primary = primary; + this.onPrimary = onPrimary; + this.primaryContainer = primaryContainer; + this.onPrimaryContainer = onPrimaryContainer; + this.secondaryContainer = secondaryContainer; + this.onSecondaryContainer = onSecondaryContainer; + this.tertiary = tertiary; + this.onTertiary = onTertiary; + this.tertiaryContainer = tertiaryContainer; + this.onTertiaryContainer = onTertiaryContainer; + this.surface = surface; + this.surfaceContainerLowest = surfaceContainerLowest; + this.surfaceContainerLow = surfaceContainerLow; + this.surfaceContainer = surfaceContainer; + this.surfaceContainerHigh = surfaceContainerHigh; + this.surfaceContainerHighest = surfaceContainerHighest; + this.onSurface = onSurface; + this.onSurfaceVariant = onSurfaceVariant; + this.outline = outline; + this.outlineVariant = outlineVariant; + this.error = error; + this.onError = onError; + this.errorContainer = errorContainer; + this.onErrorContainer = onErrorContainer; + } + } + + /** M3 dark baseline: primary80, neutral6/10/12/17/22, neutral_variant30/60/80. */ + private static final Palette DARK = new Palette(true, false, + Color.rgb(208, 188, 255), Color.rgb(56, 30, 114), + Color.rgb(79, 55, 139), Color.rgb(234, 221, 255), + Color.rgb(74, 68, 88), Color.rgb(232, 222, 248), + Color.rgb(239, 184, 200), Color.rgb(73, 37, 50), + Color.rgb(99, 59, 72), Color.rgb(255, 216, 228), + Color.rgb(20, 18, 24), Color.rgb(15, 13, 19), Color.rgb(29, 27, 32), + Color.rgb(33, 31, 38), Color.rgb(43, 41, 48), Color.rgb(54, 52, 59), + Color.rgb(230, 224, 233), Color.rgb(202, 196, 208), + Color.rgb(147, 143, 153), Color.rgb(73, 69, 79), + Color.rgb(242, 184, 181), Color.rgb(96, 20, 16), + Color.rgb(140, 29, 24), Color.rgb(249, 222, 220)); + + /** M3 light baseline: primary40, neutral98/96/94/92/90, neutral_variant30/50/80. */ + private static final Palette LIGHT = new Palette(false, false, + Color.rgb(103, 80, 164), Color.rgb(255, 255, 255), + Color.rgb(234, 221, 255), Color.rgb(33, 0, 93), + Color.rgb(232, 222, 248), Color.rgb(29, 25, 43), + Color.rgb(125, 82, 96), Color.rgb(255, 255, 255), + Color.rgb(255, 216, 228), Color.rgb(49, 17, 29), + Color.rgb(254, 247, 255), Color.rgb(255, 255, 255), Color.rgb(247, 242, 250), + Color.rgb(243, 237, 247), Color.rgb(236, 230, 240), Color.rgb(230, 224, 233), + Color.rgb(29, 27, 32), Color.rgb(73, 69, 79), + Color.rgb(121, 116, 126), Color.rgb(202, 196, 208), + Color.rgb(179, 38, 30), Color.rgb(255, 255, 255), + Color.rgb(249, 222, 220), Color.rgb(65, 14, 11)); + + /** + * The look of Serena 1.2, the last version before Material 3: flat near-black (or light grey) + * page, plain cards with 8 dp corners, TikTok's cyan for switches and buttons. + */ + private static final Palette CLASSIC_DARK = new Palette(true, true, + Color.rgb(32, 213, 236), Color.rgb(18, 18, 18), + Color.rgb(37, 37, 37), Color.rgb(255, 255, 255), + Color.rgb(58, 58, 58), Color.rgb(255, 255, 255), + Color.rgb(32, 213, 236), Color.rgb(18, 18, 18), + Color.rgb(58, 58, 58), Color.rgb(255, 255, 255), + Color.rgb(18, 18, 18), Color.rgb(10, 10, 10), Color.rgb(24, 24, 24), + Color.rgb(37, 37, 37), Color.rgb(47, 47, 47), Color.rgb(58, 58, 58), + Color.rgb(255, 255, 255), Color.rgb(138, 139, 145), + Color.rgb(92, 93, 98), Color.rgb(52, 52, 52), + Color.rgb(254, 44, 85), Color.rgb(255, 255, 255), + Color.rgb(80, 20, 30), Color.rgb(255, 200, 208)); + + private static final Palette CLASSIC_LIGHT = new Palette(false, true, + Color.rgb(32, 213, 236), Color.rgb(22, 24, 35), + Color.rgb(255, 255, 255), Color.rgb(22, 24, 35), + Color.rgb(233, 233, 234), Color.rgb(22, 24, 35), + Color.rgb(32, 213, 236), Color.rgb(22, 24, 35), + Color.rgb(233, 233, 234), Color.rgb(22, 24, 35), + Color.rgb(241, 241, 242), Color.rgb(255, 255, 255), Color.rgb(248, 248, 248), + Color.rgb(255, 255, 255), Color.rgb(255, 255, 255), Color.rgb(233, 233, 234), + Color.rgb(22, 24, 35), Color.rgb(117, 118, 122), + Color.rgb(170, 171, 175), Color.rgb(227, 227, 228), + Color.rgb(254, 44, 85), Color.rgb(255, 255, 255), + Color.rgb(255, 225, 230), Color.rgb(120, 10, 30)); + + public static Palette dark() { return DARK; } + + public static Palette light() { return LIGHT; } + + /** The Serena 1.2 palette for the saved theme; its night mode is resolved like the M3 one. */ + public static Palette classic(Context context, ModSettings.Theme theme) { + return night(context, theme) ? CLASSIC_DARK : CLASSIC_LIGHT; + } + + private static int systemColor(Context context, String name, int fallback) { + if (android.os.Build.VERSION.SDK_INT >= 31 && context != null) { + try { + int id = context.getResources().getIdentifier(name, "color", "android"); + if (id != 0) { + return context.getColor(id); + } + } catch (Throwable ignored) { + } + } + return fallback; + } + + private static Palette resolveDynamic(Context context, boolean night) { + if (android.os.Build.VERSION.SDK_INT < 31 || context == null) { + return night ? DARK : LIGHT; + } + int testAccent = systemColor(context, "system_accent1_200", 0); + if (testAccent == 0) { + return night ? DARK : LIGHT; + } + if (night) { + return new Palette(true, false, + systemColor(context, "system_accent1_200", DARK.primary), + systemColor(context, "system_accent1_800", DARK.onPrimary), + systemColor(context, "system_accent1_700", DARK.primaryContainer), + systemColor(context, "system_accent1_100", DARK.onPrimaryContainer), + systemColor(context, "system_accent2_700", DARK.secondaryContainer), + systemColor(context, "system_accent2_100", DARK.onSecondaryContainer), + systemColor(context, "system_accent3_200", DARK.tertiary), + systemColor(context, "system_accent3_800", DARK.onTertiary), + systemColor(context, "system_accent3_700", DARK.tertiaryContainer), + systemColor(context, "system_accent3_100", DARK.onTertiaryContainer), + systemColor(context, "system_neutral1_900", DARK.surface), + systemColor(context, "system_neutral1_1000", DARK.surfaceContainerLowest), + systemColor(context, "system_neutral1_800", DARK.surfaceContainerLow), + systemColor(context, "system_neutral1_700", DARK.surfaceContainer), + systemColor(context, "system_neutral1_600", DARK.surfaceContainerHigh), + systemColor(context, "system_neutral1_500", DARK.surfaceContainerHighest), + systemColor(context, "system_neutral1_100", DARK.onSurface), + systemColor(context, "system_neutral2_200", DARK.onSurfaceVariant), + systemColor(context, "system_neutral2_400", DARK.outline), + systemColor(context, "system_neutral2_700", DARK.outlineVariant), + DARK.error, DARK.onError, DARK.errorContainer, DARK.onErrorContainer); + } else { + return new Palette(false, false, + systemColor(context, "system_accent1_600", LIGHT.primary), + Color.WHITE, + systemColor(context, "system_accent1_100", LIGHT.primaryContainer), + systemColor(context, "system_accent1_900", LIGHT.onPrimaryContainer), + systemColor(context, "system_accent2_100", LIGHT.secondaryContainer), + systemColor(context, "system_accent2_900", LIGHT.onSecondaryContainer), + systemColor(context, "system_accent3_600", LIGHT.tertiary), + Color.WHITE, + systemColor(context, "system_accent3_100", LIGHT.tertiaryContainer), + systemColor(context, "system_accent3_900", LIGHT.onTertiaryContainer), + systemColor(context, "system_neutral1_10", LIGHT.surface), + Color.WHITE, + systemColor(context, "system_neutral1_50", LIGHT.surfaceContainerLow), + systemColor(context, "system_neutral1_100", LIGHT.surfaceContainer), + systemColor(context, "system_neutral1_200", LIGHT.surfaceContainerHigh), + systemColor(context, "system_neutral1_300", LIGHT.surfaceContainerHighest), + systemColor(context, "system_neutral1_900", LIGHT.onSurface), + systemColor(context, "system_neutral2_700", LIGHT.onSurfaceVariant), + systemColor(context, "system_neutral2_500", LIGHT.outline), + systemColor(context, "system_neutral2_200", LIGHT.outlineVariant), + LIGHT.error, LIGHT.onError, LIGHT.errorContainer, LIGHT.onErrorContainer); + } + } + + /** Resolves the scheme from the saved theme, falling back to the system night mode and dynamic Monet colors. */ + public static Palette palette(Context context, ModSettings.Theme theme) { + return resolveDynamic(context, night(context, theme)); + } + + /** The scheme of the settings screen: Material 3, or the Serena 1.2 skin when the MD3 switch is off. */ + public static Palette palette(Context context, ModSettings.Theme theme, boolean md3) { + return md3 ? palette(context, theme) : classic(context, theme); + } + + private static boolean night(Context context, ModSettings.Theme theme) { + if (theme == ModSettings.Theme.DARK) return true; + if (theme == ModSettings.Theme.LIGHT) return false; + return context != null && (context.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + /** Corner radius of a dialog or bottom sheet: Material 3 rounds them far more than the old skin. */ + public static float dialogRadius(Palette palette) { + return palette.classic ? 12f : SHAPE_EXTRA_LARGE; + } + + // ---- primitives -------------------------------------------------------- + + public static int dp(Context context, float value) { + return (int) (value * context.getResources().getDisplayMetrics().density + .5f); + } + + public static int stateLayerColor(int contentColor) { + return Color.argb(31, Color.red(contentColor), Color.green(contentColor), Color.blue(contentColor)); + } + + public static TextView text(Context context, CharSequence value, float sizeSp, boolean medium, int color) { + TextView view = new TextView(context); + view.setText(value); + view.setTextSize(sizeSp); + view.setTextColor(color); + view.setTypeface(medium ? MEDIUM : REGULAR); + view.setIncludeFontPadding(false); + return view; + } + + /** A filled M3 shape: solid color plus corner radius. */ + public static GradientDrawable shape(int color, float radiusDp, Context context) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(color); + drawable.setCornerRadius(dp(context, radiusDp)); + return drawable; + } + + /** An outlined M3 shape, used for the unchecked switch track and cards. */ + public static GradientDrawable outlined(int color, int stroke, float strokeDp, float radiusDp, Context context) { + GradientDrawable drawable = shape(color, radiusDp, context); + drawable.setStroke(Math.max(1, dp(context, strokeDp)), stroke); + return drawable; + } + + /** An M3 state layer: transparent content plus a 12% on-surface ripple. */ + public static Drawable stateLayer(Context context, float radiusDp, int rippleColor) { + Drawable mask = radiusDp > 0 ? shape(Color.WHITE, radiusDp, context) : null; + return new RippleDrawable(ColorStateList.valueOf(rippleColor), + shape(Color.TRANSPARENT, radiusDp, context), mask); + } + + // ---- components -------------------------------------------------------- + + /** Small top app bar: 64dp, navigation icon plus a title. */ + public static LinearLayout appBar(Context context, Palette palette, CharSequence title, + Integer navigationIcon, String navigationDescription, View.OnClickListener onNavigation) { + LinearLayout bar = new LinearLayout(context); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.setGravity(Gravity.CENTER_VERTICAL); + bar.setMinimumHeight(dp(context, palette.classic ? 56 : 64)); + if (palette.classic) { + bar.setBackground(new UnderlinedDrawable(palette.surface, palette.outlineVariant, + Math.max(1, dp(context, 1)))); + } else { + bar.setBackgroundColor(palette.surface); + } + if (navigationIcon != null) { + ImageView button = new ImageView(context); + button.setImageDrawable(Md3Icon.of(navigationIcon.intValue(), palette.onSurface, context)); + button.setScaleType(ImageView.ScaleType.CENTER); + button.setContentDescription(navigationDescription); + button.setBackground(stateLayer(context, SHAPE_FULL, stateLayerColor(palette.onSurface))); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(context, 48), dp(context, 48)); + params.leftMargin = dp(context, 4); + bar.addView(button, params); + if (onNavigation != null) button.setOnClickListener(onNavigation); + } + if (palette.classic) { + // The old bar centres its title between the back button and an empty slot of the same width. + TextView titleView = text(context, title, 18f, true, palette.onSurface); + titleView.setGravity(Gravity.CENTER); + titleView.setMaxLines(2); + bar.addView(titleView, new LinearLayout.LayoutParams(0, -2, 1f)); + bar.addView(new View(context), new LinearLayout.LayoutParams(dp(context, 48), dp(context, 48))); + return bar; + } + TextView titleView = text(context, title, TITLE_LARGE, false, palette.onSurface); + LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams(0, -2, 1f); + titleParams.leftMargin = dp(context, 8); + titleParams.rightMargin = dp(context, 16); + bar.addView(titleView, titleParams); + return bar; + } + + /** A flat fill with a one-pixel rule along the bottom edge: the old app bar's underline. */ + private static final class UnderlinedDrawable extends Drawable { + private final Paint fill = new Paint(); + private final Paint rule = new Paint(); + private final int thickness; + + UnderlinedDrawable(int fillColor, int ruleColor, int thickness) { + fill.setColor(fillColor); + rule.setColor(ruleColor); + this.thickness = thickness; + } + + @Override public void draw(Canvas canvas) { + Rect b = getBounds(); + canvas.drawRect(b, fill); + canvas.drawRect(b.left, b.bottom - thickness, b.right, b.bottom, rule); + } + + @Override public void setAlpha(int alpha) {} + @Override public void setColorFilter(ColorFilter filter) {} + @Override public int getOpacity() { return PixelFormat.OPAQUE; } + } + + /** Section subheader above card: labelLarge in primary color with 24dp start margin. */ + public static TextView subheader(Context context, Palette palette, CharSequence value) { + if (palette.classic) { + TextView plain = text(context, value, 14f, true, palette.onSurfaceVariant); + plain.setPadding(dp(context, 20), dp(context, 20), dp(context, 20), dp(context, 10)); + return plain; + } + TextView view = text(context, value, LABEL_LARGE, true, palette.primary); + view.setPadding(dp(context, 24), dp(context, 12), dp(context, 24), dp(context, 4)); + return view; + } + + public static View divider(Context context, Palette palette) { + View line = new View(context); + line.setBackgroundColor(palette.outlineVariant); + LinearLayout.LayoutParams params = + new LinearLayout.LayoutParams(-1, Math.max(1, dp(context, 1))); + params.leftMargin = dp(context, 16); + params.rightMargin = dp(context, 16); + line.setLayoutParams(params); + return line; + } + + /** Inset divider for inside Material 3 cards, aligned past the leading avatar. */ + public static View cardDivider(Context context, Palette palette) { + View line = new View(context); + line.setBackgroundColor(palette.outlineVariant); + LinearLayout.LayoutParams params = + new LinearLayout.LayoutParams(-1, Math.max(1, dp(context, 1))); + params.leftMargin = dp(context, palette.classic ? 16 : 56); + params.rightMargin = dp(context, 16); + line.setLayoutParams(params); + return line; + } + + /** Material 3 Card Container: rounded elevated container with 16dp horizontal margins. */ + public static LinearLayout card(Context context, Palette palette) { + LinearLayout card = new LinearLayout(context); + card.setOrientation(LinearLayout.VERTICAL); + card.setBackground(shape(palette.surfaceContainer, palette.classic ? SHAPE_SMALL : SHAPE_EXTRA_LARGE, context)); + if (android.os.Build.VERSION.SDK_INT >= 21) { + card.setClipToOutline(true); + } + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(-1, -2); + params.leftMargin = dp(context, 16); + params.rightMargin = dp(context, 16); + params.topMargin = dp(context, palette.classic ? 0 : 2); + params.bottomMargin = dp(context, palette.classic ? 4 : 12); + card.setLayoutParams(params); + return card; + } + + /** Marks a leading view as decoration: the old skin leaves such views out of its rows. */ + private static final String DECOR = "decor"; + + /** Material You scalloped flower shape drawable for avatar badges. */ + public static final class FlowerDrawable extends Drawable { + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final int petals; + + public FlowerDrawable(int color, int petals) { + this.petals = petals; + paint.setColor(color); + paint.setStyle(Paint.Style.FILL); + } + + @Override + public void draw(Canvas canvas) { + Rect b = getBounds(); + float cx = b.exactCenterX(); + float cy = b.exactCenterY(); + float r = Math.min(b.width(), b.height()) / 2f; + if (r <= 0f) return; + + // Draw central disk + canvas.drawCircle(cx, cy, r * 0.70f, paint); + + // Draw scalloped petals + float petalR = (petals == 4) ? (r * 0.46f) : (r * 0.38f); + float dist = (petals == 4) ? (r * 0.54f) : (r * 0.62f); + double step = (2.0 * Math.PI) / petals; + double offset = Math.PI / petals; + for (int i = 0; i < petals; i++) { + double angle = i * step + offset; + float px = (float) (cx + Math.cos(angle) * dist); + float py = (float) (cy + Math.sin(angle) * dist); + canvas.drawCircle(px, py, petalR, paint); + } + } + + @Override public void setAlpha(int alpha) { paint.setAlpha(alpha); } + @Override public void setColorFilter(ColorFilter filter) { paint.setColorFilter(filter); } + @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } + } + + /** A 40dp circular leading avatar, M3 list-item style. */ + public static TextView avatar(Context context, Palette palette, CharSequence value) { + TextView view = text(context, value, LABEL_LARGE, true, palette.onSurfaceVariant); + view.setGravity(Gravity.CENTER); + view.setBackground(shape(palette.surfaceContainerHighest, SHAPE_FULL, context)); + int size = dp(context, 40); + view.setLayoutParams(new LinearLayout.LayoutParams(size, size)); + return view; + } + + /** Material You flower/scalloped avatar badge with text. */ + public static TextView flowerAvatar(Context context, int bgColor, int fgColor, CharSequence value, int petals) { + TextView view = text(context, value, LABEL_LARGE, true, fgColor); + view.setGravity(Gravity.CENTER); + view.setBackground(new FlowerDrawable(bgColor, petals)); + view.setTag(DECOR); + int size = dp(context, 40); + view.setLayoutParams(new LinearLayout.LayoutParams(size, size)); + return view; + } + + /** Material You flower/scalloped avatar badge with icon. */ + public static ImageView flowerIconAvatar(Context context, int bgColor, int iconKind, int iconColor, int petals) { + ImageView view = new ImageView(context); + view.setImageDrawable(Md3Icon.of(iconKind, iconColor, context)); + view.setScaleType(ImageView.ScaleType.CENTER); + view.setBackground(new FlowerDrawable(bgColor, petals)); + view.setTag(DECOR); + int size = dp(context, 40); + view.setLayoutParams(new LinearLayout.LayoutParams(size, size)); + return view; + } + + /** + * An M3 list item. One-line items are 56dp, two-line items 72dp; the row is + * a state layer over the surface, with an optional leading avatar and + * trailing control. + */ + public static LinearLayout listItem(Context context, Palette palette, View leading, + CharSequence title, CharSequence supporting, View trailing, boolean clickable) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + if (palette.classic) { + row.setMinimumHeight(dp(context, supporting == null ? 60 : 64)); + row.setPadding(dp(context, 16), dp(context, 12), dp(context, 12), dp(context, 12)); + row.setBackground(stateLayer(context, SHAPE_SMALL, stateLayerColor(palette.onSurface))); + } else { + row.setMinimumHeight(dp(context, supporting == null ? 56 : 72)); + row.setPadding(dp(context, 16), dp(context, 8), dp(context, 16), dp(context, 8)); + row.setBackground(stateLayer(context, SHAPE_MEDIUM, stateLayerColor(palette.onSurface))); + } + if (clickable) { + row.setClickable(true); + row.setFocusable(true); + } + if (leading != null && !(palette.classic && DECOR.equals(leading.getTag()))) { + ViewGroup.LayoutParams existing = leading.getLayoutParams(); + LinearLayout.LayoutParams params = existing instanceof LinearLayout.LayoutParams + ? (LinearLayout.LayoutParams) existing + : new LinearLayout.LayoutParams(-2, -2); + params.rightMargin = dp(context, 14); + row.addView(leading, params); + } + LinearLayout labels = new LinearLayout(context); + labels.setOrientation(LinearLayout.VERTICAL); + labels.addView(text(context, title, BODY_LARGE, palette.classic, palette.onSurface)); + if (supporting != null) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(-2, -2); + params.topMargin = dp(context, palette.classic ? 5 : 2); + labels.addView(text(context, supporting, palette.classic ? 13f : BODY_MEDIUM, false, + palette.onSurfaceVariant), params); + } + row.addView(labels, new LinearLayout.LayoutParams(0, -2, 1f)); + if (trailing != null) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(-2, -2); + params.leftMargin = dp(context, 8); + row.addView(trailing, params); + } + return row; + } + + /** Trailing chevron alone for a clean navigational list item. */ + public static ImageView chevron(Context context, Palette palette) { + ImageView chevron = new ImageView(context); + chevron.setImageDrawable(Md3Icon.of(Md3Icon.CHEVRON_RIGHT, palette.onSurfaceVariant, context)); + chevron.setScaleType(ImageView.ScaleType.CENTER); + chevron.setLayoutParams(new LinearLayout.LayoutParams(dp(context, 24), dp(context, 24))); + return chevron; + } + + /** Trailing text plus chevron for a navigational list item. */ + public static LinearLayout trailingValue(Context context, Palette palette, CharSequence value) { + LinearLayout group = new LinearLayout(context); + group.setOrientation(LinearLayout.HORIZONTAL); + group.setGravity(Gravity.CENTER_VERTICAL); + group.addView(text(context, value, LABEL_LARGE, false, palette.onSurfaceVariant)); + ImageView chevron = new ImageView(context); + chevron.setImageDrawable(Md3Icon.of(Md3Icon.CHEVRON_RIGHT, palette.onSurfaceVariant, context)); + chevron.setScaleType(ImageView.ScaleType.CENTER); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dp(context, 24), dp(context, 24)); + params.leftMargin = dp(context, 4); + group.addView(chevron, params); + return group; + } + + /** M3 filled button: primary container, 40dp, full radius. */ + public static TextView filledButton(Context context, Palette palette, CharSequence label) { + TextView button = text(context, label, LABEL_LARGE, true, palette.onPrimary); + button.setGravity(Gravity.CENTER); + button.setPadding(dp(context, 24), 0, dp(context, 24), 0); + button.setMinimumHeight(dp(context, buttonHeight(palette))); + button.setClickable(true); + button.setFocusable(true); + button.setBackground(new RippleDrawable( + ColorStateList.valueOf(stateLayerColor(palette.onPrimary)), + shape(palette.primary, buttonRadius(palette), context), + shape(Color.WHITE, buttonRadius(palette), context))); + return button; + } + + /** M3 filled tonal button: secondary container, 40dp, full radius. */ + public static TextView filledTonalButton(Context context, Palette palette, CharSequence label) { + TextView button = text(context, label, LABEL_LARGE, true, palette.onSecondaryContainer); + button.setGravity(Gravity.CENTER); + button.setPadding(dp(context, 24), 0, dp(context, 24), 0); + button.setMinimumHeight(dp(context, buttonHeight(palette))); + button.setClickable(true); + button.setFocusable(true); + button.setBackground(new RippleDrawable( + ColorStateList.valueOf(stateLayerColor(palette.onSecondaryContainer)), + shape(palette.secondaryContainer, buttonRadius(palette), context), + shape(Color.WHITE, buttonRadius(palette), context))); + return button; + } + + /** Material 3 buttons are pills; the old skin's are 44 dp tall with 6 dp corners. */ + private static float buttonRadius(Palette palette) { + return palette.classic ? 6f : SHAPE_FULL; + } + + private static int buttonHeight(Palette palette) { + return palette.classic ? 44 : 40; + } + + /** M3 text button, primary content color. */ + public static TextView textButton(Context context, Palette palette, CharSequence label) { + TextView button = text(context, label, LABEL_LARGE, true, palette.primary); + button.setGravity(Gravity.CENTER); + button.setPadding(dp(context, 12), 0, dp(context, 12), 0); + button.setMinimumHeight(dp(context, 40)); + button.setClickable(true); + button.setFocusable(true); + button.setBackground(stateLayer(context, SHAPE_FULL, stateLayerColor(palette.primary))); + return button; + } + + /** M3 snackbar: inverse surface, one line of text and one text action. */ + public static LinearLayout snackbar(Context context, Palette palette, CharSequence message, + CharSequence action, View.OnClickListener onAction) { + LinearLayout bar = new LinearLayout(context); + bar.setOrientation(LinearLayout.HORIZONTAL); + bar.setGravity(Gravity.CENTER_VERTICAL); + bar.setBackground(shape(palette.onSurface, palette.classic ? SHAPE_SMALL : 16f, context)); + bar.setPadding(dp(context, 16), dp(context, 6), dp(context, 8), dp(context, 6)); + bar.setMinimumHeight(dp(context, 52)); + bar.setElevation(dp(context, 6)); + bar.setClickable(true); + bar.addView(text(context, message, BODY_MEDIUM, false, palette.surface), + new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + TextView button = textButton(context, palette, action); + button.setTextColor(palette.primaryContainer); + button.setBackground(stateLayer(context, SHAPE_FULL, stateLayerColor(palette.primaryContainer))); + button.setOnClickListener(onAction); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.leftMargin = dp(context, 8); + bar.addView(button, params); + return bar; + } +} + + + diff --git a/src/lihont/two/settings/Md3Icon.java b/src/lihont/two/settings/Md3Icon.java new file mode 100644 index 0000000..4752783 --- /dev/null +++ b/src/lihont/two/settings/Md3Icon.java @@ -0,0 +1,232 @@ +package lihont.two.settings; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PixelFormat; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; + +/** + * Rounded, filled icons drawn from SVG path data, so no icon font or image resources are needed. + * + * All glyphs are Material Symbols Rounded, weight 400, fill 1 (npm @material-symbols/svg-400 + * 0.28.2, Apache License 2.0), except TIKTOK, the TikTok mark from Simple Icons 13.21.0 + * (CC0 1.0; TikTok is a trademark of its owner and is shown only to name the app). + */ +public final class Md3Icon extends Drawable { + public static final int BACK = 0; + public static final int CLOSE = 1; + public static final int CHECK = 2; + public static final int CHEVRON_RIGHT = 3; + public static final int SEARCH = 4; + public static final int BANNER = 5; + public static final int REPOST = 6; + public static final int SUN = 7; + public static final int MOON = 8; + public static final int INFO = 9; + public static final int NOTE = 10; + public static final int SEND = 11; + public static final int TIKTOK = 12; + public static final int ADD_PHOTO = 13; + public static final int DELETE = 14; + public static final int BOTTOM_BAR = 15; + public static final int DOWNLOAD = 16; + public static final int AD_OFF = 17; + public static final int LIVE = 18; + public static final int SHOP = 19; + + /** Path data in the order of the constants above. */ + static final String[] PATHS = { + // arrow_back + "m274-450 227 227q9 9 9 21t-9 21q-9 9-21 9t-21-9L181-459q-5-5-7-10t-2-11q0-6 2-11t7-10l278-278q9-9 21" + + "-9t21 9q9 9 9 21t-9 21L274-510h496q13 0 21.5 8.5T800-480q0 13-8.5 21.5T770-450H274Z", + // close + "M480-438 270-228q-9 9-21 9t-21-9q-9-9-9-21t9-21l210-210-210-210q-9-9-9-21t9-21q9-9 21-9t21 9l210 210" + + " 210-210q9-9 21-9t21 9q9 9 9 21t-9 21L522-480l210 210q9 9 9 21t-9 21q-9 9-21 9t-21-9L480-438Z", + // check + "m378-332 363-363q9.27-9 21.64-9 12.36 0 21.36 9.05 9 9.06 9 21.5 0 12.45-9 21.45L399-267q-9 9-21 9t-" + + "21-9L175-449q-9-9.07-8.5-21.53.5-12.47 9.55-21.47 9.06-9 21.5-9 12.45 0 21.45 9l159 160Z", + // chevron_right + "M530-481 353-658q-9-9-8.5-21t9.5-21q9-9 21.5-9t21.5 9l198 198q5 5 7 10t2 11q0 6-2 11t-7 10L396-261q-" + + "9 9-21 8.5t-21-9.5q-9-9-9-21.5t9-21.5l176-176Z", + // search + "M378-329q-108.16 0-183.08-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542" + + " 618-502q-14 40-42 75l242 240q9 8.56 9 21.78T818-143q-9 9-22.22 9-13.22 0-21.78-9L533-384q-30 26-69." + + "96 40.5Q423.08-329 378-329Zm-1-60q81.25 0 138.13-57.5Q572-504 572-585t-56.87-138.5Q458.25-781 377-78" + + "1q-82.08 0-139.54 57.5Q180-666 180-585t57.46 138.5Q294.92-389 377-389Z", + // wallpaper + "M180-120q-24 0-42-18t-18-42v-240q0-12.75 8.68-21.38 8.67-8.62 21.5-8.62 12.82 0 21.32 8.62 8.5 8.63 " + + "8.5 21.38v240h240q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5H180Zm" + + "600 0H540q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h240v-240q0-1" + + "2.75 8.68-21.38 8.67-8.62 21.5-8.62 12.82 0 21.32 8.62 8.5 8.63 8.5 21.38v240q0 24-18 42t-42 18ZM448" + + "-293l120-155q5-6 12-6t12 6l117 156q5 8 .75 16T697-268H268q-9 0-13.5-8t1.5-16l87-112q5-6 12-6t12 6l81" + + " 111ZM120-780q0-24 18-42t42-18h240q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8." + + "5-21.38 8.5H180v240q0 12.75-8.68 21.37-8.67 8.63-21.5 8.63-12.82 0-21.32-8.63-8.5-8.62-8.5-21.37v-24" + + "0Zm720 0v240q0 12.75-8.68 21.37-8.67 8.63-21.5 8.63-12.82 0-21.32-8.63-8.5-8.62-8.5-21.37v-240H540q-" + + "12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h240q24 0 42 18t18 42ZM6" + + "24-570q-23.4 0-38.7-15.3Q570-600.6 570-624q0-23.4 15.3-38.7Q600.6-678 624-678q23.4 0 38.7 15.3Q678-6" + + "47.4 678-624q0 23.4-15.3 38.7Q647.4-570 624-570Z", + // repeat + "m236-210 65 65q9 9 8.5 21t-8.75 21.12q-9 9.12-21.37 9.5Q267-93 258-102L141-219q-5-5-7-10.13-2-5.14-2" + + "-11 0-5.87 2-10.87 2-5 7-10l117-117q9.07-9 21.53-8.5Q292-386 301-377q8.25 9 8.63 21 .37 12-8.63 21l-" + + "65 65h464v-130q0-12.75 8.68-21.38 8.67-8.62 21.5-8.62 12.82 0 21.32 8.62 8.5 8.63 8.5 21.38v130q0 24" + + ".75-17.62 42.37Q724.75-210 700-210H236Zm488-480H260v130q0 12.75-8.68 21.37-8.67 8.63-21.5 8.63-12.82" + + " 0-21.32-8.63-8.5-8.62-8.5-21.37v-130q0-24.75 17.63-42.38Q235.25-750 260-750h464l-65-65q-9-9-8.5-21t" + + "8.75-21.12q9-9.12 21.38-9.5Q693-867 702-858l117 117q5 5 7 10.13 2 5.14 2 11 0 5.87-2 10.87-2 5-7 10L" + + "702-582q-9.07 9-21.53 8.5Q668-574 659-583q-8.25-9-8.62-21-.38-12 8.62-21l65-65Z", + // light_mode + "M480-280q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T48" + + "0-280ZM70-450q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32Q57.25-510 70-510h100q12.75 " + + "0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5H70Zm720 0q-12.75 0-21.37-8.68" + + "-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.62-8.5 21.37-8.5h100q12.75 0 21.38 8.68 8.62 8.67 8.62 21." + + "5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5H790ZM479.82-760q-12.82 0-21.32-8.63-8.5-8.62-8.5-21.37v-100q" + + "0-12.75 8.68-21.38 8.67-8.62 21.5-8.62 12.82 0 21.32 8.62 8.5 8.63 8.5 21.38v100q0 12.75-8.68 21.37-" + + "8.67 8.63-21.5 8.63Zm0 720Q467-40 458.5-48.63 450-57.25 450-70v-100q0-12.75 8.68-21.38 8.67-8.62 21." + + "5-8.62 12.82 0 21.32 8.62 8.5 8.63 8.5 21.38v100q0 12.75-8.68 21.37-8.67 8.63-21.5 8.63ZM240-678l-57" + + "-56q-9-9-8.63-21.6.37-12.61 8.53-21.5 8.89-8.9 21.5-8.9 12.6 0 21.6 9l56 57q8 9 8 21t-8 20.5q-8 8.5-" + + "20.5 8.5t-21.5-8Zm494 495-56-57q-8-9-8-21.38 0-12.37 8.5-20.62 8.5-9 20.5-9t21 9l57 56q9 9 8.63 21.6" + + "-.37 12.61-8.53 21.5-8.89 8.9-21.5 8.9-12.6 0-21.6-9Zm-56-495q-9-9-9-21t9-21l56-57q9-9 21.6-8.63 12." + + "61.37 21.5 8.53 8.9 8.89 8.9 21.5 0 12.6-9 21.6l-57 56q-8 8-20.36 8-12.37 0-21.64-8ZM182.9-182.9q-8." + + "9-8.89-8.9-21.5 0-12.6 9-21.6l57-56q8.8-9 20.9-9 12.1 0 20.71 9 9.39 9 9.39 21t-9 21l-56 57q-9 9-21." + + "6 8.63-12.61-.37-21.5-8.53Z", + // dark_mode + "M480-120q-150 0-255-105T120-480q0-135 79.5-229T408-830q20-5 34-1t22 15q8 10 7.5 25t-8.5 35q-9 23-14 " + + "47t-5 49q0 90 63 153t153 63q25 0 48.5-4.5T754-461q22-8 38-7t26 9q10 8 13 23t-2 36q-27 121-121 200.5T" + + "480-120Z", + // info + "M483.18-280q12.82 0 21.32-8.63 8.5-8.62 8.5-21.37v-180q0-12.75-8.68-21.38-8.67-8.62-21.5-8.62-12.82 " + + "0-21.32 8.62-8.5 8.63-8.5 21.38v180q0 12.75 8.68 21.37 8.67 8.63 21.5 8.63Zm-3.2-314q14.02 0 23.52-9" + + ".2T513-626q0-14.45-9.48-24.22-9.48-9.78-23.5-9.78t-23.52 9.78Q447-640.45 447-626q0 13.6 9.48 22.8 9." + + "48 9.2 23.5 9.2Zm.29 514q-82.74 0-155.5-31.5Q252-143 197.5-197.5t-86-127.34Q80-397.68 80-480.5t31.5-" + + "155.66Q143-709 197.5-763t127.34-85.5Q397.68-880 480.5-880t155.66 31.5Q709-817 763-763t85.5 127Q880-5" + + "63 880-480.27q0 82.74-31.5 155.5Q817-252 763-197.68q-54 54.31-127 86Q563-80 480.27-80Z", + // music_note + "M393-120q-63 0-106.5-43.5T243-270q0-63 43.5-106.5T393-420q28 0 50.5 8t39.5 22v-420q0-13 8.5-21.5T513" + + "-840h174q13 0 21.5 8.5T717-810v75q0 13-8.5 21.5T687-705H543v435q0 63-43.5 106.5T393-120Z", + // send + "M162-178q-15 6-28.5-2.5T120-205v-200l302-75-302-77v-198q0-16 13.5-24.5T162-782l652 274q18 8 18 28t-1" + + "8 28L162-178Z", + // tiktok + "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1" + + ".44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3." + + "94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03" + + "-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-." + + "32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1" + + ".12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z", + // add_photo_alternate + "M180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h357q15.61 0 23.8 14.5 8.2 14.5.2 29.5-8 17-12 35.2" + + "9-4 18.28-4 37.71 0 73.87 52.07 125.93Q649.13-545 723-545q19.43 0 37.71-4Q779-553 796-561q16-7 30 .5" + + "t14 23.5v357q0 24-18 42t-42 18H180Zm60-162h480L576-474 449-307l-94-124-115 149Zm482.82-323q-12.82 0-" + + "21.32-8.63-8.5-8.62-8.5-21.37v-57h-58q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-12.82 8.63-21.32 8.6" + + "2-8.5 21.37-8.5h58v-58q0-12.75 8.68-21.38 8.67-8.62 21.5-8.62 12.82 0 21.32 8.62 8.5 8.63 8.5 21.38v" + + "58h57q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-57v57q0 12.75-8." + + "68 21.37-8.67 8.63-21.5 8.63Z", + // delete + "M261-120q-24.75 0-42.37-17.63Q201-155.25 201-180v-570h-11q-12.75 0-21.37-8.68-8.63-8.67-8.63-21.5 0-" + + "12.82 8.63-21.32 8.62-8.5 21.37-8.5h158q0-13 8.63-21.5 8.62-8.5 21.37-8.5h204q12.75 0 21.38 8.62Q612" + + "-822.75 612-810h158q12.75 0 21.38 8.68 8.62 8.67 8.62 21.5 0 12.82-8.62 21.32-8.63 8.5-21.38 8.5h-11" + + "v570q0 24.75-17.62 42.37Q723.75-120 699-120H261Zm136.18-146q12.82 0 21.32-8.63 8.5-8.62 8.5-21.37v-3" + + "39q0-12.75-8.68-21.38-8.67-8.62-21.5-8.62-12.82 0-21.32 8.62-8.5 8.63-8.5 21.38v339q0 12.75 8.68 21." + + "37 8.67 8.63 21.5 8.63Zm166 0q12.82 0 21.32-8.63 8.5-8.62 8.5-21.37v-339q0-12.75-8.68-21.38-8.67-8.6" + + "2-21.5-8.62-12.82 0-21.32 8.62-8.5 8.63-8.5 21.38v339q0 12.75 8.68 21.37 8.67 8.63 21.5 8.63Z", + // dock_to_bottom + "M180-120q-24.75 0-42.37-17.63Q120-155.25 120-180v-600q0-24.75 17.63-42.38Q155.25-840 180-840h600q24." + + "75 0 42.38 17.62Q840-804.75 840-780v600q0 24.75-17.62 42.37Q804.75-120 780-120H180Zm0-267h600v-393H1" + + "80v393Z", + // download + "M479.87-325q-5.87 0-10.87-2-5-2-10-7L308-485q-9-9.27-8.5-21.64.5-12.36 9.11-21.36 9.39-9 21.89-9t21." + + "5 9l98 99v-341q0-12.75 8.68-21.38 8.67-8.62 21.5-8.62 12.82 0 21.32 8.62 8.5 8.63 8.5 21.38v341l99-9" + + "9q8.8-9 20.9-8.5 12.1.5 21.49 9.5 8.61 9 8.61 21.5t-9 21.5L501-334q-5 5-10.13 7-5.14 2-11 2ZM220-160" + + "q-24 0-42-18t-18-42v-113q0-12.75 8.68-21.38 8.67-8.62 21.5-8.62 12.82 0 21.32 8.62 8.5 8.63 8.5 21.3" + + "8v113h520v-113q0-12.75 8.68-21.38 8.67-8.62 21.5-8.62 12.82 0 21.32 8.62 8.5 8.63 8.5 21.38v113q0 24" + + "-18 42t-42 18H220Z", + // block + "M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q8" + + "3 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 " + + "104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0" + + "-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448Z", + // sensors + "M400,-480 A80 80 0 1 1 560,-480 A80 80 0 1 1 400,-480 Z M607.3,-607.3 A180 180 0 0 1 607.3,-352.7 L6" + + "63.8,-296.2 A260 260 0 0 0 663.8,-663.8 Z M352.7,-352.7 A180 180 0 0 1 352.7,-607.3 L296.2,-663.8 A2" + + "60 260 0 0 0 296.2,-296.2 Z M713.3,-713.3 A330 330 0 0 1 713.3,-246.7 L769.9,-190.1 A410 410 0 0 0 7" + + "69.9,-769.9 Z M246.7,-246.7 A330 330 0 0 1 246.7,-713.3 L190.1,-769.9 A410 410 0 0 0 190.1,-190.1 Z", + // shopping bag + "M240,-640 H720 A40 40 0 0 1 760,-600 V-200 A40 40 0 0 1 720,-160 H240 A40 40 0 0 1 200,-200 V-600 A4" + + "0 40 0 0 1 240,-640 Z M320,-640 A160 160 0 0 1 640,-640 L560,-640 A80 80 0 0 0 400,-640 Z", + }; + + /** Side of the square grid each path is drawn on: Material Symbols use 960, Simple Icons 24. */ + static final float[] GRID = {960f, 960f, 960f, 960f, 960f, 960f, 960f, 960f, 960f, 960f, 960f, 960f, 24f, 960f, 960f, 960f, 960f, 960f, 960f, 960f}; + + /** Top of the grid on the y axis: Material Symbols start at -960, Simple Icons at 0. */ + static final float[] ORIGIN_Y = {-960f, -960f, -960f, -960f, -960f, -960f, -960f, -960f, -960f, -960f, -960f, -960f, 0f, -960f, -960f, -960f, -960f, -960f, -960f, -960f}; + + private final float density; + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + + private Md3Icon(int kind, int color, float density) { + if (kind < 0 || kind >= PATHS.length) throw new IllegalArgumentException("Unknown icon " + kind); + this.density = density; + paint.setColor(color); + paint.setStyle(Paint.Style.FILL); + build(kind, new SvgPath.Sink() { + @Override public void moveTo(float x, float y) { path.moveTo(x, y); } + + @Override public void lineTo(float x, float y) { path.lineTo(x, y); } + + @Override public void cubicTo(float x1, float y1, float x2, float y2, float x, float y) { + path.cubicTo(x1, y1, x2, y2, x, y); + } + + @Override public void close() { path.close(); } + }); + } + + /** Feeds one icon to a sink, mapped from its own grid onto the 24 unit square the drawable uses. */ + static void build(int kind, final SvgPath.Sink sink) { + final float scale = 24f / GRID[kind]; + final float top = ORIGIN_Y[kind]; + SvgPath.parse(PATHS[kind], new SvgPath.Sink() { + @Override public void moveTo(float x, float y) { sink.moveTo(x * scale, (y - top) * scale); } + + @Override public void lineTo(float x, float y) { sink.lineTo(x * scale, (y - top) * scale); } + + @Override public void cubicTo(float x1, float y1, float x2, float y2, float x, float y) { + sink.cubicTo(x1 * scale, (y1 - top) * scale, x2 * scale, (y2 - top) * scale, + x * scale, (y - top) * scale); + } + + @Override public void close() { sink.close(); } + }); + } + + public static Drawable of(int kind, int color, Context context) { + return new Md3Icon(kind, color, context.getResources().getDisplayMetrics().density); + } + + @Override public void draw(Canvas canvas) { + RectF bounds = new RectF(getBounds()); + float scale = Math.min(bounds.width(), bounds.height()) / 24f; + if (scale <= 0f) return; + canvas.save(); + canvas.translate(bounds.left + (bounds.width() - 24f * scale) / 2f, + bounds.top + (bounds.height() - 24f * scale) / 2f); + canvas.scale(scale, scale); + canvas.drawPath(path, paint); + canvas.restore(); + } + + @Override public void setAlpha(int alpha) { paint.setAlpha(alpha); } + + @Override public void setColorFilter(ColorFilter filter) { paint.setColorFilter(filter); } + + @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } + + @Override public int getIntrinsicWidth() { return (int) (24f * density + .5f); } + + @Override public int getIntrinsicHeight() { return (int) (24f * density + .5f); } +} diff --git a/src/lihont/two/settings/Md3Switch.java b/src/lihont/two/settings/Md3Switch.java new file mode 100644 index 0000000..749198c --- /dev/null +++ b/src/lihont/two/settings/Md3Switch.java @@ -0,0 +1,176 @@ +package lihont.two.settings; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.widget.Checkable; + +/** + * The M3 switch: a 52x32dp track with a 16dp handle off and a 24dp handle on. + * It is drawn from tokens rather than themed, because the framework Switch + * cannot follow the Material 3 spec. With the classic palette it is the plain switch of + * Serena 1.2 instead: a 44x26dp track and a white handle. + */ +public final class Md3Switch extends View implements Checkable { + public interface OnCheckedChangeListener { + void onCheckedChanged(Md3Switch view, boolean checked); + } + + private static final float STATE_LAYER_DP = 40f; + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF track = new RectF(); + private final int trackOff; + private final int trackOn; + private final int handleOff; + private final int handleOn; + private final int outline; + private final int stateLayer; + private final boolean classic; + private final float trackWidthDp; + private final float trackHeightDp; + private final float handleOffDp; + private final float handleOnDp; + + private boolean checked; + private boolean pressed; + private float progress; + private ValueAnimator animator; + private OnCheckedChangeListener listener; + + public Md3Switch(Context context, Md3.Palette palette) { + super(context); + classic = palette.classic; + if (classic) { + // Serena 1.2: a 44x26dp track with a plain white 20dp handle that only changes sides + trackWidthDp = 44f; + trackHeightDp = 26f; + handleOffDp = 20f; + handleOnDp = 20f; + trackOff = palette.outline; + trackOn = palette.primary; + handleOff = android.graphics.Color.WHITE; + handleOn = android.graphics.Color.WHITE; + } else { + trackWidthDp = 52f; + trackHeightDp = 32f; + handleOffDp = 16f; + handleOnDp = 24f; + trackOff = palette.surfaceContainerHighest; + trackOn = palette.primary; + handleOff = palette.outline; + handleOn = palette.onPrimary; + } + outline = palette.outline; + stateLayer = Md3.stateLayerColor(palette.onSurface); + setClickable(true); + setFocusable(true); + } + + public void setOnCheckedChangeListener(OnCheckedChangeListener value) { + listener = value; + } + + @Override public boolean isChecked() { return checked; } + + @Override public void setChecked(boolean value) { setChecked(value, true); } + + public void setChecked(boolean value, boolean animate) { + if (checked == value) return; + checked = value; + if (animator != null) animator.cancel(); + if (animate) { + animator = ValueAnimator.ofFloat(progress, value ? 1f : 0f); + animator.setDuration(200); + animator.setInterpolator(new DecelerateInterpolator()); + animator.addUpdateListener(animation -> { + progress = (Float) animation.getAnimatedValue(); + invalidate(); + }); + animator.start(); + } else { + progress = value ? 1f : 0f; + invalidate(); + } + if (listener != null) listener.onCheckedChanged(this, checked); + } + + @Override public void toggle() { setChecked(!checked, true); } + + @Override public boolean performClick() { + super.performClick(); + toggle(); + return true; + } + + @Override public void setPressed(boolean value) { + super.setPressed(value); + pressed = value; + invalidate(); + } + + @Override protected void onMeasure(int widthSpec, int heightSpec) { + setMeasuredDimension(dp(trackWidthDp) + getPaddingLeft() + getPaddingRight(), + dp(trackHeightDp) + getPaddingTop() + getPaddingBottom()); + } + + @Override protected void onDraw(Canvas canvas) { + float height = getHeight() - getPaddingTop() - getPaddingBottom(); + float width = Math.min(getWidth() - getPaddingLeft() - getPaddingRight(), dp(trackWidthDp)); + float left = getPaddingLeft() + (getWidth() - getPaddingLeft() - getPaddingRight() - width) / 2f; + float top = getPaddingTop() + (getHeight() - getPaddingTop() - getPaddingBottom() - height) / 2f; + track.set(left, top, left + width, top + height); + float radius = height / 2f; + + paint.setStyle(Paint.Style.FILL); + paint.setColor(blend(trackOff, trackOn, progress)); + canvas.drawRoundRect(track, radius, radius, paint); + + if (!classic && progress < 1f) { + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(Math.max(1f, dp(1f))); + paint.setColor(withAlpha(outline, (int) (255 * (1f - progress)))); + canvas.drawRoundRect(track, radius, radius, paint); + } + + float handleRadius = (dp(handleOffDp) / 2f) + (dp(handleOnDp) - dp(handleOffDp)) / 4f * progress; + float handleCenter = track.left + radius + (track.right - radius - (track.left + radius)) * progress; + if (pressed && !classic) { + paint.setStyle(Paint.Style.FILL); + paint.setColor(stateLayer); + canvas.drawCircle(handleCenter, track.centerY(), dp(STATE_LAYER_DP) / 2f, paint); + } + paint.setStyle(Paint.Style.FILL); + paint.setColor(blend(handleOff, handleOn, progress)); + canvas.drawCircle(handleCenter, track.centerY(), handleRadius, paint); + } + + @Override protected void onDetachedFromWindow() { + if (animator != null) animator.cancel(); + super.onDetachedFromWindow(); + } + + private int dp(float value) { return Md3.dp(getContext(), value); } + + private static int blend(int from, int to, float amount) { + int red = (int) (android.graphics.Color.red(from) + + (android.graphics.Color.red(to) - android.graphics.Color.red(from)) * amount); + int green = (int) (android.graphics.Color.green(from) + + (android.graphics.Color.green(to) - android.graphics.Color.green(from)) * amount); + int blue = (int) (android.graphics.Color.blue(from) + + (android.graphics.Color.blue(to) - android.graphics.Color.blue(from)) * amount); + return android.graphics.Color.rgb(red, green, blue); + } + + private static int withAlpha(int color, int alpha) { + return android.graphics.Color.argb(alpha, + android.graphics.Color.red(color), + android.graphics.Color.green(color), + android.graphics.Color.blue(color)); + } +} + diff --git a/src/lihont/two/settings/ModSettings.java b/src/lihont/two/settings/ModSettings.java new file mode 100644 index 0000000..470356d --- /dev/null +++ b/src/lihont/two/settings/ModSettings.java @@ -0,0 +1,205 @@ +package lihont.two.settings; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; + +/** Immutable local preferences. A preference does not imply server availability. */ +public final class ModSettings { + public static final int SCHEMA_VERSION = 1; + public static final Set COUNTRY_CODES = Collections.unmodifiableSet( + new LinkedHashSet(Arrays.asList(( + "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI " + + "BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN " + + "CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK " + + "FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM " + + "HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN " + + "KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK " + + "ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP " + + "NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW " + + "SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF " + + "TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI " + + "VN VU WF WS YE YT ZA ZM ZW").split(" ")))); + + public enum Theme { + SYSTEM("system"), LIGHT("light"), DARK("dark"); + + private final String value; + + Theme(String value) { this.value = value; } + + public String value() { return value; } + + public static Theme fromValue(String value) { + for (Theme theme : values()) { + if (theme.value.equals(value)) return theme; + } + throw new IllegalArgumentException("Unknown theme"); + } + } + + private final String region; + private final boolean profileBanners; + private final boolean commentReposts; + /** Master switch: every Material 3 restyle of TikTok and of the mod screen; off gives the Serena 1.2 look. */ + private final boolean md3; + private final boolean noWatermark; + /** Feed cleaning: advertisements, live streams and the Shop tab (off by default: it is a beta). */ + private final boolean hideAds; + private final boolean hideLive; + private final boolean hideShop; + /** Badges of the other mods (TikTok You, MargyT) next to names. */ + private final boolean foreignBadges; + private final Theme theme; + + /** Settings written before the MD3 switch existed have it on. */ + public ModSettings(String region, boolean profileBanners, boolean commentReposts, Theme theme) { + this(region, profileBanners, commentReposts, true, theme); + } + + /** Settings written before the download switch existed have it on. */ + public ModSettings(String region, boolean profileBanners, boolean commentReposts, boolean md3, + Theme theme) { + this(region, profileBanners, commentReposts, md3, true, theme); + } + + /** Settings written before the feed switches existed have ads and live off the feed; the Shop tab stays. */ + public ModSettings(String region, boolean profileBanners, boolean commentReposts, boolean md3, + boolean noWatermark, Theme theme) { + this(region, profileBanners, commentReposts, md3, noWatermark, true, true, false, theme); + } + + /** Settings written before the badge switch existed show the other mods' badges. */ + public ModSettings(String region, boolean profileBanners, boolean commentReposts, boolean md3, + boolean noWatermark, boolean hideAds, boolean hideLive, boolean hideShop, Theme theme) { + this(region, profileBanners, commentReposts, md3, noWatermark, hideAds, hideLive, hideShop, true, theme); + } + + public ModSettings(String region, boolean profileBanners, boolean commentReposts, boolean md3, + boolean noWatermark, boolean hideAds, boolean hideLive, boolean hideShop, + boolean foreignBadges, Theme theme) { + if (region == null || !COUNTRY_CODES.contains(region)) { + throw new IllegalArgumentException("Expected uppercase ISO 3166-1 alpha-2 region"); + } + if (theme == null) throw new IllegalArgumentException("Theme is required"); + this.region = region; + this.profileBanners = profileBanners; + this.commentReposts = commentReposts; + this.md3 = md3; + this.noWatermark = noWatermark; + this.hideAds = hideAds; + this.hideLive = hideLive; + this.hideShop = hideShop; + this.foreignBadges = foreignBadges; + this.theme = theme; + } + + public static ModSettings defaults() { + return new ModSettings("JP", true, true, true, true, true, true, false, true, Theme.SYSTEM); + } + + public String region() { return region; } + public String regionIso() { return region.toLowerCase(Locale.ROOT); } + public boolean profileBanners() { return profileBanners; } + public boolean commentReposts() { return commentReposts; } + public boolean md3() { return md3; } + public boolean noWatermark() { return noWatermark; } + public boolean hideAds() { return hideAds; } + public boolean hideLive() { return hideLive; } + public boolean hideShop() { return hideShop; } + public boolean foreignBadges() { return foreignBadges; } + public Theme theme() { return theme; } + public boolean needsBannerWarning() { return !"JP".equals(region); } + + private ModSettings with(String region, boolean profileBanners, boolean commentReposts, boolean md3, + boolean noWatermark, boolean hideAds, boolean hideLive, boolean hideShop, boolean foreignBadges, + Theme theme) { + return new ModSettings(region, profileBanners, commentReposts, md3, noWatermark, hideAds, hideLive, + hideShop, foreignBadges, theme); + } + + public ModSettings withRegion(String value) { + return with(value, profileBanners, commentReposts, md3, noWatermark, hideAds, hideLive, hideShop, + foreignBadges, theme); + } + + public ModSettings withProfileBanners(boolean value) { + return with(region, value, commentReposts, md3, noWatermark, hideAds, hideLive, hideShop, + foreignBadges, theme); + } + + public ModSettings withCommentReposts(boolean value) { + return with(region, profileBanners, value, md3, noWatermark, hideAds, hideLive, hideShop, + foreignBadges, theme); + } + + public ModSettings withMd3(boolean value) { + return with(region, profileBanners, commentReposts, value, noWatermark, hideAds, hideLive, hideShop, + foreignBadges, theme); + } + + public ModSettings withNoWatermark(boolean value) { + return with(region, profileBanners, commentReposts, md3, value, hideAds, hideLive, hideShop, + foreignBadges, theme); + } + + public ModSettings withHideAds(boolean value) { + return with(region, profileBanners, commentReposts, md3, noWatermark, value, hideLive, hideShop, + foreignBadges, theme); + } + + public ModSettings withHideLive(boolean value) { + return with(region, profileBanners, commentReposts, md3, noWatermark, hideAds, value, hideShop, + foreignBadges, theme); + } + + public ModSettings withHideShop(boolean value) { + return with(region, profileBanners, commentReposts, md3, noWatermark, hideAds, hideLive, value, + foreignBadges, theme); + } + + public ModSettings withForeignBadges(boolean value) { + return with(region, profileBanners, commentReposts, md3, noWatermark, hideAds, hideLive, hideShop, + value, theme); + } + + public ModSettings withTheme(Theme value) { + return with(region, profileBanners, commentReposts, md3, noWatermark, hideAds, hideLive, hideShop, + foreignBadges, value); + } + + /** Features are to be read from a process-start snapshot when hooks are connected. */ + public boolean requiresRestartComparedTo(ModSettings active) { + if (active == null) throw new IllegalArgumentException("Active settings are required"); + return !region.equals(active.region) || profileBanners != active.profileBanners + || commentReposts != active.commentReposts || md3 != active.md3 + || noWatermark != active.noWatermark || hideAds != active.hideAds + || hideLive != active.hideLive || hideShop != active.hideShop + || foreignBadges != active.foreignBadges; + } + + @Override public boolean equals(Object other) { + if (!(other instanceof ModSettings)) return false; + ModSettings that = (ModSettings) other; + return region.equals(that.region) && profileBanners == that.profileBanners + && commentReposts == that.commentReposts && md3 == that.md3 + && noWatermark == that.noWatermark && hideAds == that.hideAds + && hideLive == that.hideLive && hideShop == that.hideShop + && foreignBadges == that.foreignBadges && theme == that.theme; + } + + @Override public int hashCode() { + int result = region.hashCode(); + result = 31 * result + (profileBanners ? 1 : 0); + result = 31 * result + (commentReposts ? 1 : 0); + result = 31 * result + (md3 ? 1 : 0); + result = 31 * result + (noWatermark ? 1 : 0); + result = 31 * result + (hideAds ? 1 : 0); + result = 31 * result + (hideLive ? 1 : 0); + result = 31 * result + (hideShop ? 1 : 0); + result = 31 * result + (foreignBadges ? 1 : 0); + return 31 * result + theme.hashCode(); + } +} diff --git a/src/lihont/two/settings/SettingsCell.java b/src/lihont/two/settings/SettingsCell.java new file mode 100644 index 0000000..f7abdcf --- /dev/null +++ b/src/lihont/two/settings/SettingsCell.java @@ -0,0 +1,546 @@ +package lihont.two.settings; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.util.Log; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; + +/** + * Native Jetpack Compose settings row and Material 3 icon transformer for TikTok 47.1.3. + * + * Injects "Настройки мода" at the top of the Activity section card, and intercepts + * LX/0LDm;->LIZ to transform all settings rows into Material 3 Expressive items with + * 8-petal scalloped flower badges and dynamic category-adaptive Monet colors. + */ +public final class SettingsCell { + private static final String TAG = "LIHONT"; + private static final String CELLS = "X.1DvK"; + private static final String METHOD = "lihont"; + + private static volatile boolean broken = false; + private static volatile boolean drawn = false; + private static volatile Method cellMethod = null; + private static volatile Object unitInstance = null; + private static volatile Context cachedContext = null; + + private static final Paint BADGE_PAINT = new Paint(Paint.ANTI_ALIAS_FLAG); + + private SettingsCell() {} + + /** + * Entry point called from LX/1DuR;->LJIIIZ right before ManagePostsVM. + * @param isFirst whether ManagePostsVM was marked as the first item in the card. + * @param composer the Compose Composer (LX/0LYA). + * @return false if our row drew successfully (so ManagePostsVM is NOT first, rounded top corners go to us); + * or isFirst if an error occurred (so ManagePostsVM continues normally without crashing). + */ + public static boolean draw(boolean isFirst, Object composer) { + if (broken || composer == null) { + return isFirst; + } + try { + Log.i(TAG, "SettingsCell.draw invoked: isFirst=" + isFirst); + findContext(composer); + Method m = cell(); + m.invoke(null, null, true, false, composer, 0); + drawn = true; + Log.i(TAG, "SettingsCell.draw success"); + return false; + } catch (InvocationTargetException ite) { + Throwable cause = ite.getCause(); + Log.e(TAG, "SettingsCell.draw InvocationTargetException", cause); + broken = true; + return isFirst; + } catch (Throwable t) { + Log.e(TAG, "SettingsCell.draw failed", t); + broken = true; + return isFirst; + } + } + + /** Display title for the row. */ + public static String title() { + return "Настройки мода"; + } + + /** + * Intercepts row icons in LX/0LDm;->LIZ and wraps them in Material 3 flower badges. With the MD3 + * switch off TikTok's own icons stay, and the mod's own row gets its note icon without a badge. + */ + public static Object transformIcon(Object iconObj, String title, Object composerObj) { + try { + if (title == null) title = ""; + boolean md3 = SettingsHost.md3(); + if (!md3 && !(title.contains("Настройки мода") || title.contains("мода"))) return iconObj; + Context ctx = findContext(composerObj); + boolean dark = true; + if (ctx != null) { + dark = (ctx.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + int badgeColor = pickBadgeColor(title, dark); + int iconColor = dark ? Color.WHITE : Color.rgb(29, 25, 43); + int petals = 8; + int iconKind = -1; + + if (title.contains("Настройки мода") || title.contains("мода")) { + iconKind = Md3Icon.NOTE; // TikTok music note + petals = 8; + badgeColor = dark ? Color.rgb(128, 88, 218) : Color.rgb(234, 221, 255); + iconColor = dark ? Color.WHITE : Color.rgb(33, 0, 93); + if (!md3) { + petals = 0; // no badge: the icon alone, in the colour of TikTok's own icons + iconColor = dark ? Color.WHITE : Color.rgb(22, 24, 35); + } + } + + if (iconObj == null && iconKind < 0) { + return null; + } + + Class painterClass = Class.forName("lihont.two.settings.Md3BadgePainter"); + Constructor ctor = painterClass.getConstructors()[0]; + return ctor.newInstance(iconObj, title, badgeColor, iconColor, petals, iconKind); + } catch (Throwable t) { + Log.e(TAG, "transformIcon failed for '" + title + "', fallback to original icon", t); + return iconObj; + } + } + + /** + * Called from Md3BadgePainter.LJIIIZ to paint the flower badge and centered icon onto Compose canvas. + */ + public static void drawBadge(Object painterObj, Object drawScopeObj) { + if (painterObj == null || drawScopeObj == null) return; + try { + Class pCls = painterObj.getClass(); + Field fWrapped = pCls.getField("wrapped"); + Field fTitle = pCls.getField("title"); + Field fBadgeColor = pCls.getField("badgeColor"); + Field fIconColor = pCls.getField("iconColor"); + Field fPetals = pCls.getField("petals"); + Field fIconKind = pCls.getField("iconKind"); + + Object wrapped = fWrapped.get(painterObj); + String title = (String) fTitle.get(painterObj); + int badgeColor = fBadgeColor.getInt(painterObj); + int iconColor = fIconColor.getInt(painterObj); + int petals = fPetals.getInt(painterObj); + int iconKind = fIconKind.getInt(painterObj); + + Canvas canvas = getNativeCanvas(drawScopeObj); + + long sizeLong = 0L; + float w = 0f, h = 0f; + try { + for (Method m : drawScopeObj.getClass().getMethods()) { + if ("LIZIZ".equals(m.getName()) && m.getReturnType() == long.class) { + sizeLong = ((Number) m.invoke(drawScopeObj)).longValue(); + w = Float.intBitsToFloat((int)(sizeLong >> 32)); + h = Float.intBitsToFloat((int)(sizeLong & 0xffffffffL)); + break; + } + } + } catch (Throwable ignored) {} + + Context ctx = getFallbackContext(); + float density = (ctx != null) ? ctx.getResources().getDisplayMetrics().density : 3.0f; + float defaultSize = 24f * density; + if (w <= 0f || Float.isNaN(w)) w = defaultSize; + if (h <= 0f || Float.isNaN(h)) h = defaultSize; + if (sizeLong == 0L) { + sizeLong = (((long) Float.floatToRawIntBits(w)) << 32) | ((long) Float.floatToRawIntBits(h) & 0xffffffffL); + } + + float shiftLeft = 0f; + float cx = w / 2f; + float cy = h / 2f; + + // 15.5dp radius = 31dp diameter (clean MD3 badge matching Mod Settings) + float badgeRadius = 15.5f * density; + + if (canvas != null && petals > 0) { + Paint paint = BADGE_PAINT; + paint.setColor(badgeColor); + paint.setAlpha(255); + paint.setStyle(Paint.Style.FILL); + + // Central disk + canvas.drawCircle(cx, cy, badgeRadius * 0.70f, paint); + + // Scalloped flower petals + float petalR = (petals == 4) ? (badgeRadius * 0.46f) : (badgeRadius * 0.38f); + float dist = (petals == 4) ? (badgeRadius * 0.54f) : (badgeRadius * 0.62f); + double step = (2.0 * Math.PI) / petals; + double offset = Math.PI / petals; + for (int i = 0; i < petals; i++) { + double angle = i * step + offset; + float px = (float) (cx + Math.cos(angle) * dist); + float py = (float) (cy + Math.sin(angle) * dist); + canvas.drawCircle(px, py, petalR, paint); + } + } + + // Draw icon ON TOP of flower badge with crisp high-contrast iconColor + if (iconKind >= 0 && canvas != null) { + if (ctx != null) { + Drawable icon = Md3Icon.of(iconKind, iconColor, ctx); + int sz = (int) (badgeRadius * 1.15f); + int left = (int) (cx - sz / 2f); + int top = (int) (cy - sz / 2f); + icon.setBounds(left, top, left + sz, top + sz); + icon.draw(canvas); + } + } else if (wrapped != null) { + // Create high-contrast ColorFilter so the icon is crisp, solid white on top + Object ourFilter = null; + try { + ClassLoader cl = wrapped.getClass().getClassLoader(); + if (cl == null) cl = SettingsCell.class.getClassLoader(); + Class miqCls = cl.loadClass("X.0MIq"); + Constructor ctor = miqCls.getConstructor(android.graphics.ColorFilter.class); + ourFilter = ctor.newInstance(new android.graphics.PorterDuffColorFilter( + iconColor, android.graphics.PorterDuff.Mode.SRC_IN)); + } catch (Throwable t) { + try { + Field ff = painterObj.getClass().getField("LLJJIJIIJIL"); + ourFilter = ff.get(painterObj); + } catch (Throwable ignored) {} + } + + if (ourFilter != null) { + try { + Method m = wrapped.getClass().getMethod("LIZIZ", ourFilter.getClass()); + m.invoke(wrapped, ourFilter); + } catch (Throwable ignored) {} + try { + Field f = wrapped.getClass().getField("LLJJIJIIJIL"); + f.set(wrapped, ourFilter); + } catch (Throwable ignored) {} + } + + float alpha = 1.0f; + Method drawMethod = null; + for (Method m : wrapped.getClass().getMethods()) { + if ("LJI".equals(m.getName()) && m.getParameterTypes().length == 4) { + drawMethod = m; + break; + } + } + + if (canvas != null && shiftLeft != 0f) { + canvas.save(); + canvas.translate(-shiftLeft, 0f); + } + try { + if (drawMethod != null) { + drawMethod.invoke(wrapped, drawScopeObj, sizeLong, alpha, ourFilter); + } else { + for (Method m : wrapped.getClass().getMethods()) { + if ("LJIIIZ".equals(m.getName()) && m.getParameterTypes().length == 1) { + m.invoke(wrapped, drawScopeObj); + break; + } + } + } + } finally { + if (canvas != null && shiftLeft != 0f) { + canvas.restore(); + } + } + } + } catch (Throwable t) { + Log.e(TAG, "drawBadge failed", t); + } + } + + public static int pickBadgeColor(String title, boolean dark) { + if (title == null) title = ""; + String t = title.toLowerCase(); + + if (t.contains("мод") || t.contains("lihont")) { + return dark ? Color.rgb(128, 88, 218) : Color.rgb(234, 221, 255); // M3 Purple + } + if (t.contains("публикац") || t.contains("видео") || t.contains("контент") || t.contains("post")) { + return dark ? Color.rgb(55, 125, 215) : Color.rgb(212, 227, 255); // M3 Sky Blue + } + if (t.contains("эфир") || t.contains("live") || t.contains("трансляц")) { + return dark ? Color.rgb(215, 65, 100) : Color.rgb(255, 216, 228); // M3 Soft Rose + } + if (t.contains("уведомлен") || t.contains("оповещ") || t.contains("notif")) { + return dark ? Color.rgb(215, 135, 35) : Color.rgb(255, 222, 168); // M3 Warm Amber + } + if (t.contains("время") || t.contains("благополуч") || t.contains("экран") || t.contains("time")) { + return dark ? Color.rgb(50, 160, 95) : Color.rgb(195, 238, 209); // M3 Sage Green + } + if (t.contains("семейн") || t.contains("родител") || t.contains("family")) { + return dark ? Color.rgb(155, 80, 195) : Color.rgb(242, 218, 255); // M3 Soft Plum + } + if (t.contains("аккаунт") || t.contains("учетн") || t.contains("профил") || t.contains("account")) { + return dark ? Color.rgb(75, 110, 210) : Color.rgb(220, 226, 249); // M3 Slate Indigo + } + if (t.contains("безопасн") || t.contains("разрешен") || t.contains("парол") || t.contains("secur")) { + return dark ? Color.rgb(30, 155, 150) : Color.rgb(204, 232, 231); // M3 Deep Teal + } + if (t.contains("поделиться") || t.contains("ссылк") || t.contains("share")) { + return dark ? Color.rgb(225, 110, 65) : Color.rgb(255, 219, 200); // M3 Soft Peach + } + if (t.contains("язык") || t.contains("перевод") || t.contains("lang")) { + return dark ? Color.rgb(40, 160, 135) : Color.rgb(209, 232, 226); // M3 Eucalyptus + } + if (t.contains("кэш") || t.contains("памят") || t.contains("мест") || t.contains("cache") || t.contains("space") || t.contains("освобождение")) { + return dark ? Color.rgb(105, 155, 60) : Color.rgb(217, 231, 203); // M3 Soft Olive + } + if (t.contains("трафик") || t.contains("сеть") || t.contains("данн") || t.contains("data") || t.contains("экономия")) { + return dark ? Color.rgb(45, 130, 190) : Color.rgb(213, 228, 240); // M3 Cobalt + } + if (t.contains("поддержк") || t.contains("справк") || t.contains("проблем") || t.contains("help") || t.contains("report")) { + return dark ? Color.rgb(200, 75, 105) : Color.rgb(255, 217, 226); // M3 Soft Coral + } + if (t.contains("центр") || t.contains("safety")) { + return dark ? Color.rgb(40, 140, 180) : Color.rgb(207, 230, 241); // M3 Marine + } + if (t.contains("условия") || t.contains("политик") || t.contains("terms") || t.contains("policy")) { + return dark ? Color.rgb(125, 90, 185) : Color.rgb(230, 220, 250); // M3 Lavender + } + if (t.contains("обои") || t.contains("wall")) { + return dark ? Color.rgb(180, 70, 160) : Color.rgb(245, 215, 240); // M3 Magenta + } + if (t.contains("выход") || t.contains("выйти") || t.contains("сменить") || t.contains("logout")) { + return dark ? Color.rgb(195, 70, 70) : Color.rgb(255, 218, 214); // M3 Crimson / Error + } + + int[] darkPalette = new int[] { + Color.rgb(55, 125, 215), Color.rgb(128, 88, 218), Color.rgb(50, 160, 95), + Color.rgb(215, 135, 35), Color.rgb(30, 155, 150), Color.rgb(155, 80, 195), + Color.rgb(75, 110, 210), Color.rgb(215, 65, 100), Color.rgb(40, 160, 135), + Color.rgb(105, 155, 60), Color.rgb(45, 130, 190), Color.rgb(200, 75, 105) + }; + int[] lightPalette = new int[] { + Color.rgb(212, 227, 255), Color.rgb(234, 221, 255), Color.rgb(195, 238, 209), + Color.rgb(255, 222, 168), Color.rgb(204, 232, 231), Color.rgb(242, 218, 255), + Color.rgb(220, 226, 249), Color.rgb(255, 216, 228), Color.rgb(209, 232, 226), + Color.rgb(217, 231, 203), Color.rgb(213, 228, 240), Color.rgb(255, 217, 226) + }; + int idx = (title.hashCode() & 0x7fffffff) % darkPalette.length; + return dark ? darkPalette[idx] : lightPalette[idx]; + } + + private static Canvas getNativeCanvas(Object drawScope) { + if (drawScope == null) return null; + try { + Method getContext = drawScope.getClass().getMethod("LJLJI"); + Object drawContext = getContext.invoke(drawScope); + if (drawContext == null) return null; + Method getCanvas = drawContext.getClass().getMethod("LIZ"); + Object composeCanvas = getCanvas.invoke(drawContext); + if (composeCanvas == null) return null; + + try { + Field f = composeCanvas.getClass().getField("LIZ"); + Object c = f.get(composeCanvas); + if (c instanceof Canvas) return (Canvas) c; + } catch (Throwable ignored) {} + + for (Field f : composeCanvas.getClass().getDeclaredFields()) { + if (Canvas.class.isAssignableFrom(f.getType())) { + f.setAccessible(true); + Object c = f.get(composeCanvas); + if (c instanceof Canvas) return (Canvas) c; + } + } + } catch (Throwable t) { + Log.e(TAG, "getNativeCanvas failed", t); + } + return null; + } + + private static Context findContext(Object composerObj) { + if (cachedContext != null) return cachedContext; + if (composerObj != null) { + try { + Class locals = Class.forName("androidx.compose.ui.platform.AndroidCompositionLocals_androidKt"); + Field f = locals.getField("LIZIZ"); + Object localContextKey = f.get(null); + Method mConsume = composerObj.getClass().getMethod("LJJIZ", Class.forName("X.0MNv")); + Object res = mConsume.invoke(composerObj, localContextKey); + if (res instanceof Context) { + cachedContext = (Context) res; + return cachedContext; + } + } catch (Throwable ignored) {} + } + cachedContext = getFallbackContext(); + return cachedContext; + } + + /** + * Creates a Function0 click listener proxy that launches LihonTSettingsActivity. + */ + public static Object onClick(final Object contextObj) { + try { + Class fn0 = Class.forName("kotlin.jvm.functions.Function0"); + InvocationHandler handler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if ("invoke".equals(method.getName())) { + open(contextObj); + return unit(); + } + return plain(proxy, method.getName(), args); + } + }; + return Proxy.newProxyInstance(fn0.getClassLoader(), new Class[] { fn0 }, handler); + } catch (Throwable t) { + Log.e(TAG, "onClick proxy creation failed", t); + return null; + } + } + + /** + * Compose recomposition callback for our row. + * Invoked when the cell needs recomposition (e.g. scroll or theme change). + */ + public static Object again(final Object state, final boolean isFirst, final boolean isLast, final int flags) { + try { + Class fn2 = Class.forName("kotlin.jvm.functions.Function2"); + InvocationHandler handler = new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if ("invoke".equals(method.getName()) && args != null && args.length == 2) { + try { + Method m = cell(); + Object composer = args[0]; + int changed = flags | 1; + m.invoke(null, state, isFirst, isLast, composer, changed); + return unit(); + } catch (InvocationTargetException ite) { + throw ite.getCause(); + } + } + return plain(proxy, method.getName(), args); + } + }; + return Proxy.newProxyInstance(fn2.getClassLoader(), new Class[] { fn2 }, handler); + } catch (Throwable t) { + Log.e(TAG, "again proxy creation failed", t); + return null; + } + } + + private static Method cell() throws Exception { + if (cellMethod != null) { + return cellMethod; + } + Class clazz = null; + try { + clazz = Class.forName("X.1DvK"); + } catch (ClassNotFoundException e) { + try { + clazz = Class.forName("LX.1DvK"); + } catch (ClassNotFoundException e2) { + Log.e(TAG, "Neither X.1DvK nor LX.1DvK found in ClassLoader", e); + throw e; + } + } + Log.i(TAG, "Found renderer class: " + clazz.getName()); + for (Method m : clazz.getDeclaredMethods()) { + if (METHOD.equals(m.getName()) && Modifier.isStatic(m.getModifiers())) { + m.setAccessible(true); + cellMethod = m; + Log.i(TAG, "Found static method " + METHOD + ": " + m); + return m; + } + } + throw new NoSuchMethodException(clazz.getName() + " has no static method " + METHOD); + } + + private static Object unit() throws Exception { + if (unitInstance != null) { + return unitInstance; + } + Class clazz = Class.forName("kotlin.Unit"); + for (Field f : clazz.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers()) && f.getType() == clazz) { + f.setAccessible(true); + unitInstance = f.get(null); + if (unitInstance != null) { + return unitInstance; + } + } + } + throw new NoSuchFieldException("kotlin.Unit instance not found"); + } + + private static void open(Object contextObj) { + try { + Context context = null; + if (contextObj instanceof Context) { + context = (Context) contextObj; + } + if (context == null) { + context = getFallbackContext(); + } + if (context == null) { + Log.e(TAG, "Cannot launch settings: context is null"); + return; + } + Intent intent = LihonTSettingsActivity.intent(context); + if (!(context instanceof Activity)) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + Log.i(TAG, "Starting LihonTSettingsActivity with context=" + context); + context.startActivity(intent); + } catch (Throwable t) { + Log.e(TAG, "Failed to launch LihonTSettingsActivity", t); + } + } + + private static Context getFallbackContext() { + if (cachedContext != null) return cachedContext; + try { + Class at = Class.forName("android.app.ActivityThread"); + Method m = at.getMethod("currentApplication"); + cachedContext = (Context) m.invoke(null); + return cachedContext; + } catch (Throwable ignored) { + return null; + } + } + + private static Object plain(Object proxy, String name, Object[] args) { + if ("hashCode".equals(name)) { + return System.identityHashCode(proxy); + } + if ("equals".equals(name)) { + return Boolean.valueOf(args != null && args.length == 1 && proxy == args[0]); + } + if ("toString".equals(name)) { + return "lihont-settings-row"; + } + return null; + } + + public static boolean drawn() { + return drawn; + } + + public static void reset() { + drawn = false; + broken = false; + } +} diff --git a/src/lihont/two/settings/SettingsCodec.java b/src/lihont/two/settings/SettingsCodec.java new file mode 100644 index 0000000..52aa3e9 --- /dev/null +++ b/src/lihont/two/settings/SettingsCodec.java @@ -0,0 +1,194 @@ +package lihont.two.settings; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Strict, bounded JSON shared by the on-device file and portable exports. */ +public final class SettingsCodec { + public static final String FORMAT = "lihont.settings"; + public static final int MAX_DOCUMENT_LENGTH = 16384; + + private SettingsCodec() {} + + public static String encode(ModSettings settings) { + if (settings == null) throw invalid("Settings are required"); + return "{\n \"format\": \"" + FORMAT + "\",\n \"schemaVersion\": " + + ModSettings.SCHEMA_VERSION + ",\n" + + " \"values\": {\n \"region\": \"" + settings.region() + "\",\n" + + " \"profileBanners\": " + settings.profileBanners() + ",\n" + + " \"commentReposts\": " + settings.commentReposts() + ",\n" + + " \"md3\": " + settings.md3() + ",\n" + + " \"noWatermark\": " + settings.noWatermark() + ",\n" + + " \"hideAds\": " + settings.hideAds() + ",\n" + + " \"hideLive\": " + settings.hideLive() + ",\n" + + " \"hideShop\": " + settings.hideShop() + ",\n" + + " \"foreignBadges\": " + settings.foreignBadges() + ",\n" + + " \"theme\": \"" + settings.theme().value() + "\"\n }\n}\n"; + } + + public static ModSettings decode(String document) { + Map root = object(new Parser(document).document()); + keys(root, "format", "schemaVersion", "values"); + if (!FORMAT.equals(root.get("format"))) throw invalid("Not a LihonT settings file"); + if (!Integer.valueOf(ModSettings.SCHEMA_VERSION).equals(root.get("schemaVersion"))) { + // No legacy schema exists yet. Never guess or silently downgrade a future schema. + throw invalid("Unsupported settings schema version"); + } + Map values = object(root.get("values")); + // md3, noWatermark and the feed switches arrived after the first files were written: absent + // means on. md3 was + // called bottomBar until it became the switch for every MD3 restyle; a file has one or the other. + keys(values, new String[] {"region", "profileBanners", "commentReposts", "theme"}, + "md3", "bottomBar", "noWatermark", "hideAds", "hideLive", "hideShop", "foreignBadges"); + if (values.containsKey("md3") && values.containsKey("bottomBar")) { + throw invalid("Missing or unknown settings keys"); + } + String md3Key = values.containsKey("bottomBar") ? "bottomBar" : "md3"; + return new ModSettings(string(values.get("region")), + bool(values.get("profileBanners")), bool(values.get("commentReposts")), + values.containsKey(md3Key) ? bool(values.get(md3Key)) : true, + values.containsKey("noWatermark") ? bool(values.get("noWatermark")) : true, + values.containsKey("hideAds") ? bool(values.get("hideAds")) : true, + values.containsKey("hideLive") ? bool(values.get("hideLive")) : true, + values.containsKey("hideShop") ? bool(values.get("hideShop")) : false, + values.containsKey("foreignBadges") ? bool(values.get("foreignBadges")) : true, + ModSettings.Theme.fromValue(string(values.get("theme")))); + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + if (!(value instanceof Map)) throw invalid("Expected object"); + return (Map) value; + } + + private static void keys(Map value, String... expected) { + keys(value, expected, new String[0]); + } + + /** Every required key, and nothing outside required and optional. */ + private static void keys(Map value, String[] required, String... optional) { + if (!value.keySet().containsAll(Arrays.asList(required))) throw invalid("Missing or unknown settings keys"); + int allowed = required.length; + for (String key : optional) { + if (value.containsKey(key)) allowed++; + } + if (value.size() != allowed) throw invalid("Missing or unknown settings keys"); + } + + private static String string(Object value) { + if (!(value instanceof String)) throw invalid("Expected string"); + return (String) value; + } + + private static boolean bool(Object value) { + if (!(value instanceof Boolean)) throw invalid("Expected boolean"); + return ((Boolean) value).booleanValue(); + } + + private static IllegalArgumentException invalid(String message) { + return new IllegalArgumentException(message); + } + + /** Only JSON objects, strings, booleans and unsigned integer versions belong to this format. */ + private static final class Parser { + private final String text; + private int at; + + Parser(String text) { + if (text == null || text.length() > MAX_DOCUMENT_LENGTH) { + throw invalid("Missing or oversized settings document"); + } + this.text = text; + } + + Object document() { + Object result = value(0); + space(); + if (at != text.length()) throw invalid("Trailing data"); + return result; + } + + private Object value(int depth) { + space(); + if (at == text.length() || depth > 3) throw invalid("Incomplete or nested document"); + char ch = text.charAt(at); + if (ch == '{') { + at++; + Map result = new LinkedHashMap(); + if (take('}')) return result; + do { + String key = quoted(); + if (result.containsKey(key)) throw invalid("Duplicate settings key"); + if (!take(':')) throw invalid("Expected colon"); + result.put(key, value(depth + 1)); + if (take('}')) return result; + } while (take(',')); + throw invalid("Expected comma or closing brace"); + } + if (ch == '"') return quoted(); + if (text.startsWith("true", at)) { at += 4; return Boolean.TRUE; } + if (text.startsWith("false", at)) { at += 5; return Boolean.FALSE; } + if (ch >= '0' && ch <= '9') { + int start = at++; + while (at < text.length() && text.charAt(at) >= '0' && text.charAt(at) <= '9') at++; + if (at - start > 1 && ch == '0') throw invalid("Leading zero in integer"); + try { return Integer.valueOf(text.substring(start, at)); } + catch (NumberFormatException e) { throw invalid("Version integer overflow"); } + } + throw invalid("Unexpected JSON value"); + } + + private String quoted() { + if (!take('"')) throw invalid("Expected quoted string"); + StringBuilder result = new StringBuilder(); + while (at < text.length()) { + char ch = text.charAt(at++); + if (ch == '"') return result.toString(); + if (ch < 32) throw invalid("Control character in string"); + if (ch == '\\') { + if (at == text.length()) throw invalid("Incomplete escape"); + ch = text.charAt(at++); + switch (ch) { + case '"': case '\\': case '/': break; + case 'b': ch = '\b'; break; + case 'f': ch = '\f'; break; + case 'n': ch = '\n'; break; + case 'r': ch = '\r'; break; + case 't': ch = '\t'; break; + case 'u': + if (at + 4 > text.length()) throw invalid("Incomplete unicode escape"); + int code = 0; + for (int i = 0; i < 4; i++) { + char hex = text.charAt(at++); + int digit = hex >= '0' && hex <= '9' ? hex - '0' + : hex >= 'a' && hex <= 'f' ? hex - 'a' + 10 + : hex >= 'A' && hex <= 'F' ? hex - 'A' + 10 : -1; + if (digit < 0) throw invalid("Invalid unicode escape"); + code = (code << 4) | digit; + } + ch = (char) code; + break; + default: throw invalid("Invalid string escape"); + } + } + result.append(ch); + } + throw invalid("Unterminated string"); + } + + private boolean take(char ch) { + space(); + if (at < text.length() && text.charAt(at) == ch) { at++; return true; } + return false; + } + + private void space() { + while (at < text.length()) { + char ch = text.charAt(at); + if (ch != ' ' && ch != '\n' && ch != '\r' && ch != '\t') break; + at++; + } + } + } +} diff --git a/src/lihont/two/settings/SettingsEntryHook.java b/src/lihont/two/settings/SettingsEntryHook.java new file mode 100644 index 0000000..eea8b07 --- /dev/null +++ b/src/lihont/two/settings/SettingsEntryHook.java @@ -0,0 +1,13 @@ +package lihont.two.settings; + +import android.app.Activity; + +/** + * Legacy entry hook preserved as no-op. + * Settings entry is now natively integrated into Jetpack Compose via SettingsCell. + */ +public final class SettingsEntryHook { + public static void attach(Activity activity) { + // No-op: Compose native row handled by SettingsCell + } +} diff --git a/src/lihont/two/settings/SettingsHost.java b/src/lihont/two/settings/SettingsHost.java new file mode 100644 index 0000000..fe85df7 --- /dev/null +++ b/src/lihont/two/settings/SettingsHost.java @@ -0,0 +1,108 @@ +package lihont.two.settings; + +import android.content.Context; +import java.io.IOException; + +/** + * The one SettingsRepository of this application process. + * + * The settings screen and the feature hooks share it, so {@code active()} is the snapshot every + * hook has seen since the first read and the screen's restart notice compares against exactly it. + */ +public final class SettingsHost { + private static SettingsRepository repository; + private static ModSettings fallback; + private static volatile ModSettings known; + + private SettingsHost() {} + + /** For the settings screen: storage problems are reported to the caller, not hidden. */ + public static synchronized SettingsRepository repository(Context context) throws IOException { + if (repository == null) { + Context application = context.getApplicationContext(); + repository = new SettingsRepository( + new AndroidSettingsStorage(application != null ? application : context)); + } + return repository; + } + + /** + * For hooks: the settings active in this process, or null while the application does not exist + * yet or storage cannot be read right now (callers use defaults for that call and ask again). + * A corrupt or newer-schema file is not retried: the process keeps the defaults, which is what + * TikTok-side hooks did before settings existed. Never throws. + */ + public static synchronized ModSettings active() { + if (repository != null) return repository.active(); + if (fallback != null) return fallback; + Context application = application(); + if (application == null) return null; + try { + return repository(application).active(); + } catch (IOException e) { + return null; + } catch (RuntimeException e) { + fallback = ModSettings.defaults(); + return fallback; + } + } + + /** + * The process settings, kept once they are known: hooks ask on every frame or every feed read, + * and the snapshot never changes within a process. Null until the application exists. + */ + private static ModSettings known() { + ModSettings value = known; + if (value != null) return value; + value = active(); + if (value != null) known = value; + return value; + } + + /** Whether the Material 3 restyles are on. Until the settings are known they stay on, as before the switch. */ + public static boolean md3() { + ModSettings value = known(); + return value == null || value.md3(); + } + + /** Whether advertisements are taken out of the feed (on until the settings are known). */ + public static boolean hideAds() { + ModSettings value = known(); + return value == null || value.hideAds(); + } + + /** Whether the badges of the other mods are shown next to names (on until the settings are known). */ + public static boolean foreignBadges() { + ModSettings value = known(); + return value == null || value.foreignBadges(); + } + + /** Whether live streams are taken out of the feed. */ + public static boolean hideLive() { + ModSettings value = known(); + return value == null || value.hideLive(); + } + + /** Whether the Shop tab above the feed is removed (off until the settings are known). */ + public static boolean hideShop() { + ModSettings value = known(); + return value != null && value.hideShop(); + } + + /** Replaces the process repository; null forgets it. For tests only. */ + public static synchronized void install(SettingsRepository value) { + repository = value; + fallback = null; + known = null; + } + + private static Context application() { + try { + Object app = Class.forName("android.app.ActivityThread") + .getMethod("currentApplication").invoke(null); + return app instanceof Context ? (Context) app : null; + } catch (Throwable unavailable) { + return null; + } + } +} diff --git a/src/lihont/two/settings/SettingsRepository.java b/src/lihont/two/settings/SettingsRepository.java new file mode 100644 index 0000000..a74b9e6 --- /dev/null +++ b/src/lihont/two/settings/SettingsRepository.java @@ -0,0 +1,50 @@ +package lihont.two.settings; + +import java.io.IOException; + +/** One instance per application process, shared by UI and future runtime hooks. */ +public final class SettingsRepository { + public interface Storage { + /** Returns null only when no settings file exists. Read errors must be reported. */ + String read() throws IOException; + + /** Atomically replaces the full document; on failure the previous value must survive. */ + void write(String document) throws IOException; + } + + private final Storage storage; + private final ModSettings active; + private ModSettings current; + + public SettingsRepository(Storage storage) throws IOException { + if (storage == null) throw new IllegalArgumentException("Storage is required"); + this.storage = storage; + String document = storage.read(); + ModSettings initial = document == null ? ModSettings.defaults() : SettingsCodec.decode(document); + if (document == null) storage.write(SettingsCodec.encode(initial)); + current = initial; + active = initial; + } + + public synchronized ModSettings current() { return current; } + public ModSettings active() { return active; } + + public synchronized boolean restartRequired() { + return current.requiresRestartComparedTo(active); + } + + public synchronized void save(ModSettings value) throws IOException { + if (value == null) throw new IllegalArgumentException("Settings are required"); + if (value.equals(current)) return; + storage.write(SettingsCodec.encode(value)); + current = value; + } + + public synchronized void reset() throws IOException { save(ModSettings.defaults()); } + public synchronized String exportJson() { return SettingsCodec.encode(current); } + + /** Validation is complete before any write; rejected imports leave storage and memory intact. */ + public synchronized void importJson(String document) throws IOException { + save(SettingsCodec.decode(document)); + } +} diff --git a/src/lihont/two/settings/SvgPath.java b/src/lihont/two/settings/SvgPath.java new file mode 100644 index 0000000..7e4fea0 --- /dev/null +++ b/src/lihont/two/settings/SvgPath.java @@ -0,0 +1,267 @@ +package lihont.two.settings; + +/** + * Parser for SVG path data (the "d" attribute) that reports plain move, line, cubic and close + * commands. It uses no Android classes, so the same code runs on a desktop JVM in tests. + * + * Supported commands: M m L l H h V v C c S s Q q T t A a Z z, with the shortened forms SVG + * allows (repeated coordinates, no separator before a minus sign or a second dot, and arc flags + * written without separators). Quadratic curves and arcs are converted to cubic curves. + */ +final class SvgPath { + interface Sink { + void moveTo(float x, float y); + + void lineTo(float x, float y); + + void cubicTo(float x1, float y1, float x2, float y2, float x, float y); + + void close(); + } + + private final String data; + private final Sink sink; + private int at; + private float x, y, startX, startY; + private float lastControlX, lastControlY; + private char previous; + + private SvgPath(String data, Sink sink) { + this.data = data; + this.sink = sink; + } + + static void parse(String data, Sink sink) { + new SvgPath(data, sink).run(); + } + + private void run() { + char command = 0; + while (true) { + skipSeparators(); + if (at >= data.length()) return; + char next = data.charAt(at); + if (Character.isLetter(next)) { + command = next; + at++; + if (command == 'Z' || command == 'z') { + sink.close(); + x = startX; + y = startY; + previous = 'Z'; + continue; + } + } else if (command == 0 || command == 'Z' || command == 'z') { + throw new IllegalArgumentException("Path data has numbers without a command at " + at); + } + execute(command); + // After the first pair of a moveto, further pairs are implicit linetos. + if (command == 'M') command = 'L'; + else if (command == 'm') command = 'l'; + } + } + + private void execute(char command) { + boolean relative = Character.isLowerCase(command); + float ox = relative ? x : 0f; + float oy = relative ? y : 0f; + switch (Character.toUpperCase(command)) { + case 'M': { + float nx = ox + number(), ny = oy + number(); + sink.moveTo(nx, ny); + x = startX = nx; + y = startY = ny; + previous = 'M'; + break; + } + case 'L': { + float nx = ox + number(), ny = oy + number(); + sink.lineTo(nx, ny); + x = nx; + y = ny; + previous = 'L'; + break; + } + case 'H': { + float nx = ox + number(); + sink.lineTo(nx, y); + x = nx; + previous = 'L'; + break; + } + case 'V': { + float ny = oy + number(); + sink.lineTo(x, ny); + y = ny; + previous = 'L'; + break; + } + case 'C': { + float x1 = ox + number(), y1 = oy + number(); + float x2 = ox + number(), y2 = oy + number(); + float nx = ox + number(), ny = oy + number(); + cubic(x1, y1, x2, y2, nx, ny); + break; + } + case 'S': { + float x1 = x, y1 = y; + if (previous == 'C') { + x1 = 2f * x - lastControlX; + y1 = 2f * y - lastControlY; + } + float x2 = ox + number(), y2 = oy + number(); + float nx = ox + number(), ny = oy + number(); + cubic(x1, y1, x2, y2, nx, ny); + break; + } + case 'Q': { + float qx = ox + number(), qy = oy + number(); + float nx = ox + number(), ny = oy + number(); + quadratic(qx, qy, nx, ny); + break; + } + case 'T': { + float qx = x, qy = y; + if (previous == 'Q') { + qx = 2f * x - lastControlX; + qy = 2f * y - lastControlY; + } + float nx = ox + number(), ny = oy + number(); + quadratic(qx, qy, nx, ny); + break; + } + case 'A': { + float rx = number(), ry = number(), rotation = number(); + boolean large = flag(), sweep = flag(); + float nx = ox + number(), ny = oy + number(); + arc(rx, ry, rotation, large, sweep, nx, ny); + x = nx; + y = ny; + previous = 'A'; + break; + } + default: + throw new IllegalArgumentException("Unsupported path command " + command); + } + } + + private void cubic(float x1, float y1, float x2, float y2, float nx, float ny) { + sink.cubicTo(x1, y1, x2, y2, nx, ny); + lastControlX = x2; + lastControlY = y2; + x = nx; + y = ny; + previous = 'C'; + } + + private void quadratic(float qx, float qy, float nx, float ny) { + // Raise the quadratic curve to a cubic one with the same shape. + sink.cubicTo(x + 2f / 3f * (qx - x), y + 2f / 3f * (qy - y), + nx + 2f / 3f * (qx - nx), ny + 2f / 3f * (qy - ny), nx, ny); + lastControlX = qx; + lastControlY = qy; + x = nx; + y = ny; + previous = 'Q'; + } + + /** SVG implementation notes F.6.5: endpoint arc to centre arc, then cubic pieces of at most 90 degrees. */ + private void arc(float rx, float ry, float rotation, boolean large, boolean sweep, float nx, float ny) { + if (x == nx && y == ny) return; + rx = Math.abs(rx); + ry = Math.abs(ry); + if (rx == 0f || ry == 0f) { + sink.lineTo(nx, ny); + return; + } + double phi = Math.toRadians(rotation % 360f); + double cos = Math.cos(phi), sin = Math.sin(phi); + double dx = (x - nx) / 2.0, dy = (y - ny) / 2.0; + double x1 = cos * dx + sin * dy; + double y1 = -sin * dx + cos * dy; + double lambda = x1 * x1 / ((double) rx * rx) + y1 * y1 / ((double) ry * ry); + if (lambda > 1.0) { + double scale = Math.sqrt(lambda); + rx = (float) (rx * scale); + ry = (float) (ry * scale); + } + double rx2 = (double) rx * rx, ry2 = (double) ry * ry; + double numerator = rx2 * ry2 - rx2 * y1 * y1 - ry2 * x1 * x1; + double denominator = rx2 * y1 * y1 + ry2 * x1 * x1; + double factor = denominator == 0.0 ? 0.0 : Math.sqrt(Math.max(0.0, numerator / denominator)); + if (large == sweep) factor = -factor; + double cx1 = factor * rx * y1 / ry; + double cy1 = -factor * ry * x1 / rx; + double cx = cos * cx1 - sin * cy1 + (x + nx) / 2.0; + double cy = sin * cx1 + cos * cy1 + (y + ny) / 2.0; + double theta = Math.atan2((y1 - cy1) / ry, (x1 - cx1) / rx); + double end = Math.atan2((-y1 - cy1) / ry, (-x1 - cx1) / rx); + double delta = end - theta; + if (sweep && delta < 0) delta += 2 * Math.PI; + else if (!sweep && delta > 0) delta -= 2 * Math.PI; + + int pieces = (int) Math.ceil(Math.abs(delta) / (Math.PI / 2) - 1e-9); + double step = delta / pieces; + double t = 4.0 / 3.0 * Math.tan(step / 4.0); + double angle = theta; + double px = x, py = y; + for (int i = 0; i < pieces; i++) { + double a1 = angle, a2 = angle + step; + double c1x = Math.cos(a1) - t * Math.sin(a1), c1y = Math.sin(a1) + t * Math.cos(a1); + double c2x = Math.cos(a2) + t * Math.sin(a2), c2y = Math.sin(a2) - t * Math.cos(a2); + double ex = Math.cos(a2), ey = Math.sin(a2); + float[] p1 = transform(c1x, c1y, rx, ry, cos, sin, cx, cy); + float[] p2 = transform(c2x, c2y, rx, ry, cos, sin, cx, cy); + float[] pe = i == pieces - 1 ? new float[] {nx, ny} : transform(ex, ey, rx, ry, cos, sin, cx, cy); + sink.cubicTo(p1[0], p1[1], p2[0], p2[1], pe[0], pe[1]); + angle = a2; + } + } + + private static float[] transform(double ux, double uy, double rx, double ry, double cos, double sin, + double cx, double cy) { + double sx = ux * rx, sy = uy * ry; + return new float[] {(float) (cos * sx - sin * sy + cx), (float) (sin * sx + cos * sy + cy)}; + } + + // ---- tokens ------------------------------------------------------------ + + private void skipSeparators() { + while (at < data.length()) { + char c = data.charAt(at); + if (c == ' ' || c == ',' || c == '\t' || c == '\n' || c == '\r') at++; + else break; + } + } + + private boolean flag() { + skipSeparators(); + if (at >= data.length()) throw new IllegalArgumentException("Path data ends inside an arc"); + char c = data.charAt(at++); + if (c != '0' && c != '1') throw new IllegalArgumentException("Bad arc flag at " + (at - 1)); + return c == '1'; + } + + private float number() { + skipSeparators(); + int start = at; + if (at < data.length() && (data.charAt(at) == '-' || data.charAt(at) == '+')) at++; + boolean dot = false; + while (at < data.length()) { + char c = data.charAt(at); + if (c >= '0' && c <= '9') { + at++; + } else if (c == '.' && !dot) { + dot = true; + at++; + } else if ((c == 'e' || c == 'E') && at > start) { + at++; + if (at < data.length() && (data.charAt(at) == '-' || data.charAt(at) == '+')) at++; + } else { + break; + } + } + if (at == start) throw new IllegalArgumentException("Expected a number at " + start); + return Float.parseFloat(data.substring(start, at)); + } +} diff --git a/src/lihont/two/theme/NativeTheme.java b/src/lihont/two/theme/NativeTheme.java new file mode 100644 index 0000000..4b50e4e --- /dev/null +++ b/src/lihont/two/theme/NativeTheme.java @@ -0,0 +1,293 @@ +package lihont.two.theme; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.graphics.drawable.ColorDrawable; +import android.os.Build; +import android.util.Log; +import android.view.Window; +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; +import lihont.two.settings.Md3; +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; + +/** + * Re-skins TikTok's own settings screens with the palette of the LihonT settings screen. + * + * LX/0LAr;->LIZIZ hands every Compose reader TikTok's colour-token object (LX/1MiX). The patch + * from lihont/theme_patch.py passes that object here together with the composer. Screens that are + * not TikTok settings get the original back untouched; settings screens get a recoloured copy. + * + * Token meaning, read off a colour-coded screenshot of TikTok 47.1.3 (names are R8 output): + * M2 page and app bar background, N2 card fill, a4 primary text and icons, d4 secondary text + * (section labels and trailing values), i4 chevrons, P2 destructive red. + */ +public final class NativeTheme { + private static final String TAG = "LIHONT"; + private static final String SETTINGS_PACKAGE = "com.ss.android.ugc.aweme.setting."; + + private static final Object LOCK = new Object(); + private static final Map THEMED = new IdentityHashMap(); + private static final Map PALETTES = new IdentityHashMap(); + private static final Set SEEN_ACTIVITIES = new HashSet(); + + private static Object lastComposer; + private static WeakReference lastActivity = new WeakReference(null); + private static WeakReference chromeActivity = new WeakReference(null); + + private static Field localContextKey; + private static Method consume; + private static Field secondaryText; + + private NativeTheme() {} + + /** Called from the patched LX/0LAr;->LIZIZ. Never throws. */ + public static Object colors(Object original, Object composer) { + if (original == null || composer == null) return original; + try { + Activity activity = activityOf(composer); + if (activity == null) return original; + note(activity); + if (!isSettings(activity)) return original; + Object copy = themed(original, activity); + applyChrome(activity, copy); + return copy; + } catch (Throwable error) { + // Theming must never break TikTok; fall back to its own colours. + return original; + } + } + + /** + * Called in place of reading the secondary-text token for a section label. Settings screens + * draw it in the primary colour, as the LihonT screen does; elsewhere it stays the token. + */ + public static long sectionLabel(Object colors) { + try { + Md3.Palette palette; + synchronized (THEMED) { + palette = PALETTES.get(colors); + } + if (palette != null) return ThemeCopy.compose(palette.primary | 0xFF000000); + if (secondaryText == null) { + Field field = colors.getClass().getDeclaredField("d4"); + field.setAccessible(true); + secondaryText = field; + } + return secondaryText.getLong(colors); + } catch (Throwable error) { + return 0x10L; // Compose's unspecified colour: the text takes the surrounding content colour + } + } + + // ---- row shape and spacing --------------------------------------------- + + /** Extra side padding per row: TikTok's 8 dp page margin plus this is the LihonT screen's 16 dp. */ + private static final float ROW_SIDE_DP = 8f; + /** Half the gap between two rows, above and below each row. */ + private static final float ROW_GAP_DP = 2f; + /** Corner radius of a row; Compose caps it at half the row height, which makes a pill. */ + private static final float ROW_RADIUS_DP = 28f; + + private static Method padding; + private static Method roundedAll; + private static Method roundedCorners; + + /** + * Called with the modifier a settings row starts from. In TikTok settings the row gets side + * margins and a gap above and below, outside its click area; elsewhere it is returned as is. + */ + public static Object rowModifier(Object modifier, Object composer) { + try { + Activity activity = activityOf(composer); + if (activity == null || !isSettings(activity)) return modifier; + if (padding == null) { + padding = Class.forName("X.0LS4").getMethod("LJIIJJI", + Class.forName("X.0M1d"), float.class, float.class, float.class, float.class); + } + return padding.invoke(null, modifier, ROW_SIDE_DP, ROW_GAP_DP, ROW_SIDE_DP, ROW_GAP_DP); + } catch (Throwable error) { + return modifier; + } + } + + /** + * Called instead of TikTok's four-corner rounded shape for a row background. TikTok rounds only + * the top of the first row and the bottom of the last row of a group; in settings every row + * is rounded on all sides, so each one reads as its own button. + */ + public static Object rowShape(float topStart, float topEnd, float bottomEnd, float bottomStart, + Object composer) { + try { + Activity activity = activityOf(composer); + if (activity != null && isSettings(activity)) { + if (roundedAll == null) { + roundedAll = Class.forName("X.0Kzn").getMethod("LIZIZ", float.class); + } + return roundedAll.invoke(null, ROW_RADIUS_DP); + } + } catch (Throwable error) { + // Fall through to TikTok's own shape. + } + try { + if (roundedCorners == null) { + roundedCorners = Class.forName("X.0Kzn").getMethod("LIZJ", + float.class, float.class, float.class, float.class); + } + return roundedCorners.invoke(null, topStart, topEnd, bottomEnd, bottomStart); + } catch (Throwable error) { + throw new IllegalStateException("TikTok's rounded shape factory is unavailable", error); + } + } + + private static Method originalBackground; + + /** + * Called in place of the language row's own card background. In TikTok settings an opaque card + * (the normal case) is dropped, because the row already draws its own rounded background and + * the card would show as a wide rectangle between the separated rows; a translucent colour, + * the highlight animation, and every other screen get TikTok's own background call. + */ + public static Object cardBackground(Object modifier, long color, Object shape, Object composer) { + try { + Activity activity = activityOf(composer); + if (activity != null && isSettings(activity) && (ThemeCopy.argb(color) >>> 24) == 0xFF) { + return modifier; + } + } catch (Throwable error) { + // Fall through to TikTok's own background. + } + try { + if (originalBackground == null) { + originalBackground = Class.forName("X.0L5k").getMethod("LIZIZ", + Class.forName("X.0M1d"), long.class, Class.forName("X.0LQx")); + } + return originalBackground.invoke(null, modifier, color, shape); + } catch (Throwable error) { + throw new IllegalStateException("TikTok's background modifier is unavailable", error); + } + } + + // ---- the recoloured copy ----------------------------------------------- + + private static Object themed(Object original, Activity activity) throws Exception { + synchronized (THEMED) { + Object copy = THEMED.get(original); + if (copy == null) { + final Md3.Palette palette = Md3.palette(activity, + isDark(original) ? ModSettings.Theme.DARK : ModSettings.Theme.LIGHT); + copy = ThemeCopy.copy(original, new ThemeCopy.Mapper() { + @Override public long map(String field, long value) { + return recolor(palette, field, value); + } + }); + THEMED.put(original, copy); + PALETTES.put(copy, palette); + Log.i(TAG, "theme: recoloured settings tokens, " + (palette.dark ? "dark" : "light")); + } + return copy; + } + } + + private static long recolor(Md3.Palette palette, String field, long value) { + switch (field) { + case "M2": return opaque(palette.surface); + case "N2": return opaque(palette.surfaceContainer); + case "a4": return opaque(palette.onSurface); + case "d4": + case "i4": return opaque(palette.onSurfaceVariant); + case "P2": return opaque(palette.error); + default: return value; + } + } + + private static long opaque(int argb) { + return ThemeCopy.compose(argb | 0xFF000000); + } + + /** The page background token is black in TikTok's dark scheme and white in the light one. */ + private static boolean isDark(Object original) throws Exception { + Field page = original.getClass().getDeclaredField("M2"); + page.setAccessible(true); + int argb = ThemeCopy.argb(page.getLong(original)); + int luminance = (((argb >> 16) & 0xFF) * 299 + ((argb >> 8) & 0xFF) * 587 + (argb & 0xFF) * 114) / 1000; + return luminance < 128; + } + + /** Status and navigation bars follow the page colour, once per settings screen. */ + private static void applyChrome(Activity activity, Object copy) { + if (chromeActivity.get() == activity) return; + Md3.Palette palette; + synchronized (THEMED) { + palette = PALETTES.get(copy); + } + if (palette == null) return; + chromeActivity = new WeakReference(activity); + try { + // Under edge-to-edge the system bars are transparent, so what shows behind them is + // the window background; the bar colours cover older, non-edge-to-edge setups. + Window window = activity.getWindow(); + window.setBackgroundDrawable(new ColorDrawable(palette.surface | 0xFF000000)); + window.setStatusBarColor(palette.surface); + window.setNavigationBarColor(palette.surface); + if (Build.VERSION.SDK_INT >= 29) { + // Without this the system darkens the bottom bar with its own scrim. + window.setStatusBarContrastEnforced(false); + window.setNavigationBarContrastEnforced(false); + } + } catch (Throwable ignored) { + // The bars simply keep TikTok's colour. + } + } + + // ---- which screen is composing ----------------------------------------- + + private static Activity activityOf(Object composer) throws Exception { + synchronized (LOCK) { + if (composer == lastComposer) return lastActivity.get(); + } + Context context = contextOf(composer); + while (context instanceof ContextWrapper && !(context instanceof Activity)) { + context = ((ContextWrapper) context).getBaseContext(); + } + Activity activity = context instanceof Activity ? (Activity) context : null; + synchronized (LOCK) { + lastComposer = composer; + lastActivity = new WeakReference(activity); + } + return activity; + } + + private static Context contextOf(Object composer) throws Exception { + if (localContextKey == null) { + Class locals = Class.forName("androidx.compose.ui.platform.AndroidCompositionLocals_androidKt"); + localContextKey = locals.getField("LIZIZ"); + } + if (consume == null || !consume.getDeclaringClass().isInstance(composer)) { + consume = composer.getClass().getMethod("LJJIZ", Class.forName("X.0MNv")); + } + Object value = consume.invoke(composer, localContextKey.get(null)); + return value instanceof Context ? (Context) value : null; + } + + /** TikTok's settings screens, while the MD3 switch is on: with it off every hook returns TikTok's own. */ + private static boolean isSettings(Activity activity) { + return SettingsHost.md3() && activity.getClass().getName().startsWith(SETTINGS_PACKAGE); + } + + /** Logs each activity class that reads colour tokens, once, so the scope of a theme is known. */ + private static void note(Activity activity) { + String name = activity.getClass().getName(); + synchronized (SEEN_ACTIVITIES) { + if (!SEEN_ACTIVITIES.add(name)) return; + } + Log.i(TAG, "theme: colour tokens read from " + name); + } +} diff --git a/src/lihont/two/theme/ThemeCopy.java b/src/lihont/two/theme/ThemeCopy.java new file mode 100644 index 0000000..5975769 --- /dev/null +++ b/src/lihont/two/theme/ThemeCopy.java @@ -0,0 +1,56 @@ +package lihont.two.theme; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +/** Makes a copy of TikTok's colour-token object with every colour passed through a mapper. */ +final class ThemeCopy { + /** Maps one token, given its field name and Compose colour value, to the colour to use. */ + interface Mapper { + long map(String field, long value); + } + + private static Object unsafe; + private static Method allocate; + + private ThemeCopy() {} + + static Object copy(Object original, Mapper mapper) throws Exception { + Object copy = allocate(original.getClass()); + for (Class type = original.getClass(); type != null && type != Object.class; + type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + field.setAccessible(true); + if (field.getType() == long.class) { + field.setLong(copy, mapper.map(field.getName(), field.getLong(original))); + } else { + field.set(copy, field.get(original)); + } + } + } + return copy; + } + + /** A Compose Color is its ARGB value in the high half and a colour space id (0 is sRGB) below. */ + static long compose(int argb) { + return (((long) argb) & 0xFFFFFFFFL) << 32; + } + + /** The ARGB of an sRGB Compose colour, or 0 for another colour space. */ + static int argb(long composeColor) { + return (composeColor & 0x3FL) == 0 ? (int) (composeColor >>> 32) : 0; + } + + private static synchronized Object allocate(Class type) throws Exception { + if (allocate == null) { + Class holder = Class.forName("sun.misc.Unsafe"); + Field instance = holder.getDeclaredField("theUnsafe"); + instance.setAccessible(true); + unsafe = instance.get(null); + allocate = holder.getMethod("allocateInstance", Class.class); + } + return allocate.invoke(unsafe, type); + } +} diff --git a/src/smali/lihont/two/settings/Md3BadgePainter.smali b/src/smali/lihont/two/settings/Md3BadgePainter.smali new file mode 100644 index 0000000..561c85b --- /dev/null +++ b/src/smali/lihont/two/settings/Md3BadgePainter.smali @@ -0,0 +1,88 @@ +.class public final Llihont/two/settings/Md3BadgePainter; +.super LX/0LRh; +.source "Md3BadgePainter.smali" + + +# instance fields +.field public final wrapped:LX/0LRh; + +.field public final title:Ljava/lang/String; + +.field public final badgeColor:I + +.field public final iconColor:I + +.field public final petals:I + +.field public final iconKind:I + + +# direct methods +.method public constructor (LX/0LRh;Ljava/lang/String;IIII)V + .registers 7 + + .prologue + invoke-direct {p0}, LX/0LRh;->()V + + iput-object p1, p0, Llihont/two/settings/Md3BadgePainter;->wrapped:LX/0LRh; + + iput-object p2, p0, Llihont/two/settings/Md3BadgePainter;->title:Ljava/lang/String; + + iput p3, p0, Llihont/two/settings/Md3BadgePainter;->badgeColor:I + + iput p4, p0, Llihont/two/settings/Md3BadgePainter;->iconColor:I + + iput p5, p0, Llihont/two/settings/Md3BadgePainter;->petals:I + + iput p6, p0, Llihont/two/settings/Md3BadgePainter;->iconKind:I + + return-void +.end method + + +# virtual methods +.method public LIZ(F)Z + .registers 3 + + const/4 v0, 0x1 + + return v0 +.end method + +.method public LIZIZ(LX/0MIq;)Z + .registers 3 + + const/4 v0, 0x1 + + return v0 +.end method + +.method public LJIIIIZZ()J + .registers 3 + + .prologue + iget-object v0, p0, Llihont/two/settings/Md3BadgePainter;->wrapped:LX/0LRh; + + if-eqz v0, :cond_9 + + invoke-virtual {v0}, LX/0LRh;->LJIIIIZZ()J + + move-result-wide v0 + + return-wide v0 + + :cond_9 + const-wide v0, 0x7fc000007fc00000L + + return-wide v0 +.end method + +.method public LJIIIZ(LX/0LR4;)V + .registers 2 + + .prologue + invoke-static {p0, p1}, Llihont/two/settings/SettingsCell;->drawBadge(Ljava/lang/Object;Ljava/lang/Object;)V + + return-void +.end method + diff --git a/src/web/assets/site/icons.svg b/src/web/assets/site/icons.svg new file mode 100644 index 0000000..d2d19f6 --- /dev/null +++ b/src/web/assets/site/icons.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/web/assets/site/md3.css b/src/web/assets/site/md3.css new file mode 100644 index 0000000..48b0391 --- /dev/null +++ b/src/web/assets/site/md3.css @@ -0,0 +1,1052 @@ +/* + * LihonT site: Material Design 3. + * + * Colour roles, type scale, shape and elevation follow the Material 3 specification. + * Light and dark schemes follow the system and can be forced with data-theme on . + * An accent swaps the colour roles of one page: data-accent="rose" (LihonB), "gold" (support). + * No third-party code, fonts or images are loaded. + */ + +/* ---------- colour roles: purple scheme (default) ---------- */ + +:root { + color-scheme: light dark; + + --md-sys-color-primary: #6750a4; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #eaddff; + --md-sys-color-on-primary-container: #21005d; + --md-sys-color-secondary: #625b71; + --md-sys-color-on-secondary: #ffffff; + --md-sys-color-secondary-container: #e8def8; + --md-sys-color-on-secondary-container: #1d192b; + --md-sys-color-tertiary: #7d5260; + --md-sys-color-on-tertiary: #ffffff; + --md-sys-color-tertiary-container: #ffd8e4; + --md-sys-color-on-tertiary-container: #31111d; + --md-sys-color-error: #b3261e; + --md-sys-color-on-error: #ffffff; + --md-sys-color-error-container: #f9dedc; + --md-sys-color-on-error-container: #410e0b; + --md-sys-color-surface: #fef7ff; + --md-sys-color-on-surface: #1d1b20; + --md-sys-color-on-surface-variant: #49454f; + --md-sys-color-outline: #79747e; + --md-sys-color-outline-variant: #cac4d0; + --md-sys-color-surface-container-lowest: #ffffff; + --md-sys-color-surface-container-low: #f7f2fa; + --md-sys-color-surface-container: #f3edf7; + --md-sys-color-surface-container-high: #ece6f0; + --md-sys-color-surface-container-highest: #e6e0e9; + --md-sys-color-inverse-surface: #322f35; + --md-sys-color-inverse-on-surface: #f5eff7; + --md-sys-color-inverse-primary: #d0bcff; + --md-sys-color-success: #1b6d3c; + --md-sys-color-success-container: #b5f2c5; + --md-sys-color-on-success-container: #00210c; + --md-sys-color-warning-container: #ffdeae; + --md-sys-color-on-warning-container: #281900; + --md-sys-color-scrim: #000000; + + --md-sys-shape-xs: 4px; + --md-sys-shape-sm: 8px; + --md-sys-shape-md: 12px; + --md-sys-shape-lg: 16px; + --md-sys-shape-xl: 28px; + --md-sys-shape-full: 9999px; + + --md-sys-elevation-1: 0 1px 2px rgb(0 0 0 / 0.3), 0 1px 3px 1px rgb(0 0 0 / 0.15); + --md-sys-elevation-2: 0 1px 2px rgb(0 0 0 / 0.3), 0 2px 6px 2px rgb(0 0 0 / 0.15); + --md-sys-elevation-3: 0 1px 3px rgb(0 0 0 / 0.3), 0 4px 8px 3px rgb(0 0 0 / 0.15); + + --md-sys-motion-easing: cubic-bezier(0.2, 0, 0, 1); + --md-sys-motion-easing-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1); + + --font-sans: Roboto, "Segoe UI", "Helvetica Neue", Arial, system-ui, sans-serif; + --font-mono: "Roboto Mono", ui-monospace, "Cascadia Mono", Consolas, monospace; + + --page-gutter: clamp(16px, 4vw, 40px); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --md-sys-color-primary: #d0bcff; + --md-sys-color-on-primary: #381e72; + --md-sys-color-primary-container: #4f378b; + --md-sys-color-on-primary-container: #eaddff; + --md-sys-color-secondary: #ccc2dc; + --md-sys-color-on-secondary: #332d41; + --md-sys-color-secondary-container: #4a4458; + --md-sys-color-on-secondary-container: #e8def8; + --md-sys-color-tertiary: #efb8c8; + --md-sys-color-on-tertiary: #492532; + --md-sys-color-tertiary-container: #633b48; + --md-sys-color-on-tertiary-container: #ffd8e4; + --md-sys-color-error: #f2b8b5; + --md-sys-color-on-error: #601410; + --md-sys-color-error-container: #8c1d18; + --md-sys-color-on-error-container: #f9dedc; + --md-sys-color-surface: #141218; + --md-sys-color-on-surface: #e6e0e9; + --md-sys-color-on-surface-variant: #cac4d0; + --md-sys-color-outline: #938f99; + --md-sys-color-outline-variant: #49454f; + --md-sys-color-surface-container-lowest: #0f0d13; + --md-sys-color-surface-container-low: #1d1b20; + --md-sys-color-surface-container: #211f26; + --md-sys-color-surface-container-high: #2b2930; + --md-sys-color-surface-container-highest: #36343b; + --md-sys-color-inverse-surface: #e6e0e9; + --md-sys-color-inverse-on-surface: #322f35; + --md-sys-color-inverse-primary: #6750a4; + --md-sys-color-success: #99d6a9; + --md-sys-color-success-container: #00531f; + --md-sys-color-on-success-container: #b5f2c5; + --md-sys-color-warning-container: #5e4200; + --md-sys-color-on-warning-container: #ffdeae; + } +} + +:root[data-theme="dark"] { + --md-sys-color-primary: #d0bcff; + --md-sys-color-on-primary: #381e72; + --md-sys-color-primary-container: #4f378b; + --md-sys-color-on-primary-container: #eaddff; + --md-sys-color-secondary: #ccc2dc; + --md-sys-color-on-secondary: #332d41; + --md-sys-color-secondary-container: #4a4458; + --md-sys-color-on-secondary-container: #e8def8; + --md-sys-color-tertiary: #efb8c8; + --md-sys-color-on-tertiary: #492532; + --md-sys-color-tertiary-container: #633b48; + --md-sys-color-on-tertiary-container: #ffd8e4; + --md-sys-color-error: #f2b8b5; + --md-sys-color-on-error: #601410; + --md-sys-color-error-container: #8c1d18; + --md-sys-color-on-error-container: #f9dedc; + --md-sys-color-surface: #141218; + --md-sys-color-on-surface: #e6e0e9; + --md-sys-color-on-surface-variant: #cac4d0; + --md-sys-color-outline: #938f99; + --md-sys-color-outline-variant: #49454f; + --md-sys-color-surface-container-lowest: #0f0d13; + --md-sys-color-surface-container-low: #1d1b20; + --md-sys-color-surface-container: #211f26; + --md-sys-color-surface-container-high: #2b2930; + --md-sys-color-surface-container-highest: #36343b; + --md-sys-color-inverse-surface: #e6e0e9; + --md-sys-color-inverse-on-surface: #322f35; + --md-sys-color-inverse-primary: #6750a4; + --md-sys-color-success: #99d6a9; + --md-sys-color-success-container: #00531f; + --md-sys-color-on-success-container: #b5f2c5; + --md-sys-color-warning-container: #5e4200; + --md-sys-color-on-warning-container: #ffdeae; +} + +/* ---------- accent: rose (LihonB) ---------- */ + +:root[data-accent="rose"] { + --md-sys-color-primary: #9a405f; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #ffd9e1; + --md-sys-color-on-primary-container: #3f001a; + --md-sys-color-secondary: #74565d; + --md-sys-color-secondary-container: #f4dde1; + --md-sys-color-on-secondary-container: #2b151b; + --md-sys-color-tertiary: #7c5733; + --md-sys-color-tertiary-container: #ffddb8; + --md-sys-color-on-tertiary-container: #2b1700; + --md-sys-color-surface: #fff8f8; + --md-sys-color-on-surface: #22191b; + --md-sys-color-on-surface-variant: #524346; + --md-sys-color-outline: #857375; + --md-sys-color-outline-variant: #d7c1c4; + --md-sys-color-surface-container-lowest: #ffffff; + --md-sys-color-surface-container-low: #fff0f2; + --md-sys-color-surface-container: #fceaec; + --md-sys-color-surface-container-high: #f6e4e6; + --md-sys-color-surface-container-highest: #f0dee0; + --md-sys-color-inverse-surface: #382e30; + --md-sys-color-inverse-on-surface: #ffecee; + --md-sys-color-inverse-primary: #ffb1c6; +} + +@media (prefers-color-scheme: dark) { + :root[data-accent="rose"]:not([data-theme="light"]) { + --md-sys-color-primary: #ffb1c6; + --md-sys-color-on-primary: #5f1132; + --md-sys-color-primary-container: #7c2948; + --md-sys-color-on-primary-container: #ffd9e1; + --md-sys-color-secondary: #e2bdc5; + --md-sys-color-secondary-container: #5b3f47; + --md-sys-color-on-secondary-container: #ffd9e1; + --md-sys-color-tertiary: #f0bd93; + --md-sys-color-tertiary-container: #613f20; + --md-sys-color-on-tertiary-container: #ffddb8; + --md-sys-color-surface: #1a1113; + --md-sys-color-on-surface: #f0dee0; + --md-sys-color-on-surface-variant: #d7c1c4; + --md-sys-color-outline: #9f8c8f; + --md-sys-color-outline-variant: #524346; + --md-sys-color-surface-container-lowest: #140c0e; + --md-sys-color-surface-container-low: #22191b; + --md-sys-color-surface-container: #261d1f; + --md-sys-color-surface-container-high: #312829; + --md-sys-color-surface-container-highest: #3c3234; + --md-sys-color-inverse-surface: #f0dee0; + --md-sys-color-inverse-on-surface: #22191b; + --md-sys-color-inverse-primary: #9a405f; + } +} + +:root[data-accent="rose"][data-theme="dark"] { + --md-sys-color-primary: #ffb1c6; + --md-sys-color-on-primary: #5f1132; + --md-sys-color-primary-container: #7c2948; + --md-sys-color-on-primary-container: #ffd9e1; + --md-sys-color-secondary: #e2bdc5; + --md-sys-color-secondary-container: #5b3f47; + --md-sys-color-on-secondary-container: #ffd9e1; + --md-sys-color-tertiary: #f0bd93; + --md-sys-color-tertiary-container: #613f20; + --md-sys-color-on-tertiary-container: #ffddb8; + --md-sys-color-surface: #1a1113; + --md-sys-color-on-surface: #f0dee0; + --md-sys-color-on-surface-variant: #d7c1c4; + --md-sys-color-outline: #9f8c8f; + --md-sys-color-outline-variant: #524346; + --md-sys-color-surface-container-lowest: #140c0e; + --md-sys-color-surface-container-low: #22191b; + --md-sys-color-surface-container: #261d1f; + --md-sys-color-surface-container-high: #312829; + --md-sys-color-surface-container-highest: #3c3234; + --md-sys-color-inverse-surface: #f0dee0; + --md-sys-color-inverse-on-surface: #22191b; + --md-sys-color-inverse-primary: #9a405f; +} + +/* ---------- accent: gold (support page) ---------- */ + +:root[data-accent="gold"] { + --md-sys-color-primary: #765b00; + --md-sys-color-on-primary: #ffffff; + --md-sys-color-primary-container: #ffdf9e; + --md-sys-color-on-primary-container: #241a00; + --md-sys-color-secondary: #6b5d3f; + --md-sys-color-secondary-container: #f4e0bb; + --md-sys-color-on-secondary-container: #241a04; + --md-sys-color-surface: #fff8f1; + --md-sys-color-on-surface: #1f1b13; + --md-sys-color-on-surface-variant: #4d4639; + --md-sys-color-outline: #7f7667; + --md-sys-color-outline-variant: #d0c5b4; + --md-sys-color-surface-container-lowest: #ffffff; + --md-sys-color-surface-container-low: #fff2dc; + --md-sys-color-surface-container: #faecd6; + --md-sys-color-surface-container-high: #f4e6d0; + --md-sys-color-surface-container-highest: #eee0cb; + --md-sys-color-inverse-surface: #353027; + --md-sys-color-inverse-on-surface: #f9efe2; + --md-sys-color-inverse-primary: #e9c349; +} + +@media (prefers-color-scheme: dark) { + :root[data-accent="gold"]:not([data-theme="light"]) { + --md-sys-color-primary: #e9c349; + --md-sys-color-on-primary: #3e2e00; + --md-sys-color-primary-container: #5b4300; + --md-sys-color-on-primary-container: #ffdf9e; + --md-sys-color-secondary: #d7c4a0; + --md-sys-color-secondary-container: #52452a; + --md-sys-color-on-secondary-container: #f4e0bb; + --md-sys-color-surface: #17130b; + --md-sys-color-on-surface: #eae1d4; + --md-sys-color-on-surface-variant: #d0c5b4; + --md-sys-color-outline: #998f80; + --md-sys-color-outline-variant: #4d4639; + --md-sys-color-surface-container-lowest: #110e07; + --md-sys-color-surface-container-low: #1f1b13; + --md-sys-color-surface-container: #231f17; + --md-sys-color-surface-container-high: #2e2921; + --md-sys-color-surface-container-highest: #39342b; + --md-sys-color-inverse-surface: #eae1d4; + --md-sys-color-inverse-on-surface: #353027; + --md-sys-color-inverse-primary: #765b00; + } +} + +:root[data-accent="gold"][data-theme="dark"] { + --md-sys-color-primary: #e9c349; + --md-sys-color-on-primary: #3e2e00; + --md-sys-color-primary-container: #5b4300; + --md-sys-color-on-primary-container: #ffdf9e; + --md-sys-color-secondary: #d7c4a0; + --md-sys-color-secondary-container: #52452a; + --md-sys-color-on-secondary-container: #f4e0bb; + --md-sys-color-surface: #17130b; + --md-sys-color-on-surface: #eae1d4; + --md-sys-color-on-surface-variant: #d0c5b4; + --md-sys-color-outline: #998f80; + --md-sys-color-outline-variant: #4d4639; + --md-sys-color-surface-container-lowest: #110e07; + --md-sys-color-surface-container-low: #1f1b13; + --md-sys-color-surface-container: #231f17; + --md-sys-color-surface-container-high: #2e2921; + --md-sys-color-surface-container-highest: #39342b; + --md-sys-color-inverse-surface: #eae1d4; + --md-sys-color-inverse-on-surface: #353027; + --md-sys-color-inverse-primary: #765b00; +} + +/* ---------- base ---------- */ + +*, *::before, *::after { box-sizing: border-box; } + +html { + -webkit-text-size-adjust: 100%; + scroll-padding-top: 88px; +} + +@media (prefers-reduced-motion: no-preference) { + html { scroll-behavior: smooth; } +} + +body { + margin: 0; + min-height: 100vh; + background: var(--md-sys-color-surface); + color: var(--md-sys-color-on-surface); + font-family: var(--font-sans); + font-size: 16px; + line-height: 24px; + letter-spacing: 0.5px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4, p, ul, ol, figure, dl, dd { margin: 0; } +ul, ol { padding: 0; list-style: none; } +img, svg { display: block; max-width: 100%; } +a { color: var(--md-sys-color-primary); text-decoration-thickness: 1px; text-underline-offset: 3px; } +a:hover { text-decoration-thickness: 2px; } +code, pre { font-family: var(--font-mono); } +code { + padding: 1px 6px; + border-radius: var(--md-sys-shape-xs); + background: var(--md-sys-color-surface-container-highest); + color: var(--md-sys-color-on-surface); + font-size: 0.875em; + letter-spacing: 0; +} +strong { font-weight: 600; } +::selection { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); } + +:focus-visible { + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: 2px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: fixed; + top: 8px; + left: 16px; + z-index: 100; + padding: 12px 20px; + border-radius: var(--md-sys-shape-full); + background: var(--md-sys-color-inverse-surface); + color: var(--md-sys-color-inverse-on-surface); + transform: translateY(-200%); +} +.skip-link:focus { transform: none; } + +/* ---------- type scale (Material 3) ---------- */ + +.t-display-lg { font-size: clamp(40px, 7vw, 57px); line-height: 1.12; letter-spacing: -0.25px; font-weight: 400; } +.t-display-md { font-size: clamp(34px, 5.6vw, 45px); line-height: 1.16; letter-spacing: 0; font-weight: 400; } +.t-headline-lg { font-size: clamp(26px, 4vw, 32px); line-height: 1.25; letter-spacing: 0; font-weight: 400; } +.t-headline-md { font-size: 28px; line-height: 36px; letter-spacing: 0; font-weight: 400; } +.t-headline-sm { font-size: 24px; line-height: 32px; letter-spacing: 0; font-weight: 400; } +.t-title-lg { font-size: 22px; line-height: 28px; letter-spacing: 0; font-weight: 400; } +.t-title-md { font-size: 16px; line-height: 24px; letter-spacing: 0.15px; font-weight: 500; } +.t-title-sm { font-size: 14px; line-height: 20px; letter-spacing: 0.1px; font-weight: 500; } +.t-body-lg { font-size: 16px; line-height: 24px; letter-spacing: 0.5px; } +.t-body-md { font-size: 14px; line-height: 20px; letter-spacing: 0.25px; } +.t-body-sm { font-size: 12px; line-height: 16px; letter-spacing: 0.4px; } +.t-label-lg { font-size: 14px; line-height: 20px; letter-spacing: 0.1px; font-weight: 500; } +.t-label-md { font-size: 12px; line-height: 16px; letter-spacing: 0.5px; font-weight: 500; } +.t-muted { color: var(--md-sys-color-on-surface-variant); } +.t-primary { color: var(--md-sys-color-primary); } + +/* ---------- layout ---------- */ + +.page { + width: 100%; + max-width: 1240px; + margin-inline: auto; + padding-inline: var(--page-gutter); +} + +.section { padding-block: clamp(40px, 7vw, 80px); } + +.section__head { + display: grid; + gap: 12px; + max-width: 720px; + margin-bottom: clamp(24px, 4vw, 40px); +} +.section__head--center { margin-inline: auto; text-align: center; justify-items: center; } +.section__head .t-label-lg { color: var(--md-sys-color-primary); } + +main { display: block; } + +.grid { display: grid; gap: 16px; } +.grid--2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); } +.grid--3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); } +.grid--4 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); } + +.stack { display: grid; gap: 16px; } +.cluster { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } + +/* ---------- icons ---------- */ + +.icon { + display: inline-block; + width: 1em; + height: 1em; + font-size: 24px; + fill: currentColor; + flex: none; +} +.icon--18 { font-size: 18px; } +.icon--20 { font-size: 20px; } +.icon--32 { font-size: 32px; } + +/* ---------- state layer and ripple ---------- */ + +/* Every interactive surface carries a state layer in its own content colour (hover 8%, focus and press 10%). */ +.state, .btn, .icon-btn, .tabs a, .drawer__list a, a.list__item, .download, .accordion summary { + position: relative; + overflow: hidden; + isolation: isolate; +} +.fab { overflow: hidden; isolation: isolate; } +.state::before, .btn::before, .icon-btn::before, .tabs a::before, .drawer__list a::before, +a.list__item::before, .download::before, .accordion summary::before, .fab::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: currentColor; + opacity: 0; + transition: opacity 150ms var(--md-sys-motion-easing); + pointer-events: none; +} +.state:hover::before, .btn:hover::before, .icon-btn:hover::before, .tabs a:hover::before, +.drawer__list a:hover::before, a.list__item:hover::before, .download:hover::before, +.accordion summary:hover::before, .fab:hover::before { opacity: 0.08; } +.state:focus-visible::before, .btn:focus-visible::before, .icon-btn:focus-visible::before, +.tabs a:focus-visible::before, .drawer__list a:focus-visible::before, a.list__item:focus-visible::before, +.download:focus-visible::before, .accordion summary:focus-visible::before, .fab:focus-visible::before, +.btn:active::before, .icon-btn:active::before, .fab:active::before { opacity: 0.1; } + +.ripple { + position: absolute; + border-radius: 50%; + background: currentColor; + opacity: 0.12; + transform: scale(0); + pointer-events: none; + animation: ripple 450ms var(--md-sys-motion-easing) forwards; +} +@keyframes ripple { to { transform: scale(1); opacity: 0; } } + +/* ---------- buttons ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + padding: 0 24px; + border: 0; + border-radius: var(--md-sys-shape-full); + font: inherit; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.1px; + font-weight: 500; + text-align: center; + text-decoration: none; + cursor: pointer; + transition: box-shadow 200ms var(--md-sys-motion-easing); +} +.btn:hover { text-decoration: none; } +.btn .icon { font-size: 18px; } +.btn--icon-lead { padding-inline: 16px 24px; } +.btn--filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); } +.btn--filled:hover { box-shadow: var(--md-sys-elevation-1); } +.btn--tonal { background: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container); } +.btn--tonal:hover { box-shadow: var(--md-sys-elevation-1); } +.btn--elevated { background: var(--md-sys-color-surface-container-low); color: var(--md-sys-color-primary); box-shadow: var(--md-sys-elevation-1); } +.btn--elevated:hover { box-shadow: var(--md-sys-elevation-2); } +.btn--outlined { background: transparent; color: var(--md-sys-color-primary); box-shadow: inset 0 0 0 1px var(--md-sys-color-outline); } +.btn--text { background: transparent; color: var(--md-sys-color-primary); padding-inline: 12px; } +.btn--lg { min-height: 56px; padding-inline: 32px; font-size: 16px; line-height: 24px; letter-spacing: 0.15px; } +.btn--lg .icon { font-size: 24px; } +.btn--block { width: 100%; } + +.icon-btn { + display: inline-grid; + place-items: center; + width: 40px; + height: 40px; + padding: 0; + border: 0; + border-radius: var(--md-sys-shape-full); + background: transparent; + color: var(--md-sys-color-on-surface-variant); + cursor: pointer; +} +.icon-btn--tonal { background: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container); } + +.fab { + position: fixed; + right: max(16px, env(safe-area-inset-right)); + bottom: max(16px, env(safe-area-inset-bottom)); + z-index: 20; + display: inline-flex; + align-items: center; + gap: 12px; + height: 56px; + padding: 0 20px 0 16px; + border-radius: var(--md-sys-shape-lg); + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + box-shadow: var(--md-sys-elevation-3); + font-size: 14px; + letter-spacing: 0.1px; + font-weight: 500; + text-decoration: none; + opacity: 0; + transform: translateY(24px) scale(0.9); + pointer-events: none; + transition: opacity 250ms var(--md-sys-motion-easing), transform 250ms var(--md-sys-motion-easing); +} +.fab[data-visible] { opacity: 1; transform: none; pointer-events: auto; } +@media (min-width: 840px) { .fab { display: none; } } + +/* ---------- chips, badges ---------- */ + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 12px; + border-radius: var(--md-sys-shape-sm); + box-shadow: inset 0 0 0 1px var(--md-sys-color-outline-variant); + background: transparent; + color: var(--md-sys-color-on-surface-variant); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.1px; + font-weight: 500; + white-space: nowrap; +} +.chip .icon { font-size: 18px; color: var(--md-sys-color-primary); } +.chip--tonal { background: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container); box-shadow: none; } +.chip--primary { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); box-shadow: none; } +.chip--success { background: var(--md-sys-color-success-container); color: var(--md-sys-color-on-success-container); box-shadow: none; } +.chip--success .icon { color: inherit; } + +/* ---------- cards ---------- */ + +.card { + display: grid; + gap: 16px; + align-content: start; + padding: 24px; + border-radius: var(--md-sys-shape-xl); + background: var(--md-sys-color-surface-container-low); + color: var(--md-sys-color-on-surface); +} +.card--filled { background: var(--md-sys-color-surface-container-highest); } +.card--outlined { background: var(--md-sys-color-surface); box-shadow: inset 0 0 0 1px var(--md-sys-color-outline-variant); } +.card--elevated { background: var(--md-sys-color-surface-container-low); box-shadow: var(--md-sys-elevation-1); } +.card--primary { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); } +.card--tertiary { background: var(--md-sys-color-tertiary-container); color: var(--md-sys-color-on-tertiary-container); } +.card--primary .t-muted, .card--tertiary .t-muted { color: inherit; opacity: 0.85; } +.card__head { display: flex; align-items: center; gap: 16px; } + +/* ---------- flower badge (brand shape) ---------- */ + +.flower { + --flower-size: 48px; + --flower-bg: var(--md-sys-color-primary-container); + --flower-fg: var(--md-sys-color-on-primary-container); + position: relative; + display: inline-grid; + place-items: center; + flex: none; + width: var(--flower-size); + height: var(--flower-size); + color: var(--flower-bg); +} +.flower > svg:first-child { position: absolute; inset: 0; width: 100%; height: 100%; fill: currentColor; } +.flower > .icon { position: relative; font-size: calc(var(--flower-size) * 0.5); color: var(--flower-fg); } +.flower--lg { --flower-size: 72px; } +.flower--sm { --flower-size: 40px; } +.flower--secondary { --flower-bg: var(--md-sys-color-secondary-container); --flower-fg: var(--md-sys-color-on-secondary-container); } +.flower--tertiary { --flower-bg: var(--md-sys-color-tertiary-container); --flower-fg: var(--md-sys-color-on-tertiary-container); } +.flower--solid { --flower-bg: var(--md-sys-color-primary); --flower-fg: var(--md-sys-color-on-primary); } + +/* ---------- top app bar and navigation ---------- */ + +.top-bar { + position: sticky; + top: 0; + z-index: 30; + background: var(--md-sys-color-surface); + transition: background-color 250ms var(--md-sys-motion-easing); +} +.top-bar[data-scrolled] { background: var(--md-sys-color-surface-container); } +.top-bar__inner { display: flex; align-items: center; gap: 8px; min-height: 64px; } +.top-bar__menu { margin-inline-start: -8px; } +.top-bar__actions { display: flex; align-items: center; gap: 8px; margin-inline-start: auto; } +.top-bar__actions .btn { display: none; } + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + color: var(--md-sys-color-on-surface); + text-decoration: none; + font-size: 22px; + line-height: 28px; + font-weight: 500; + letter-spacing: 0; +} +.brand:hover { text-decoration: none; } +.brand img, .brand svg { width: 32px; height: 32px; } +.brand b { color: var(--md-sys-color-primary); font-weight: 500; } + +.tabs { display: none; gap: 4px; margin-inline: 24px auto; } +.tabs a { + position: relative; + display: inline-flex; + align-items: center; + height: 48px; + padding: 0 16px; + border-radius: var(--md-sys-shape-full); + color: var(--md-sys-color-on-surface-variant); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.1px; + font-weight: 500; + text-decoration: none; +} +.tabs a::after { + content: ""; + position: absolute; + inset: auto 16px 4px; + height: 3px; + border-radius: 3px 3px 0 0; + background: var(--md-sys-color-primary); + transform: scaleX(0); + transition: transform 250ms var(--md-sys-motion-easing); +} +.tabs a[aria-current="true"] { color: var(--md-sys-color-primary); } +.tabs a[aria-current="true"]::after { transform: scaleX(1); } + +@media (min-width: 960px) { + .top-bar__menu { display: none; } + .tabs { display: flex; } + .top-bar__actions .btn { display: inline-flex; } +} + +/* theme toggle: show the icon of the scheme you would switch to */ +.theme-toggle .icon-dark { display: none; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .theme-toggle .icon-light { display: none; } + :root:not([data-theme="light"]) .theme-toggle .icon-dark { display: block; } +} +:root[data-theme="dark"] .theme-toggle .icon-light { display: none; } +:root[data-theme="dark"] .theme-toggle .icon-dark { display: block; } +:root[data-theme="light"] .theme-toggle .icon-light { display: block; } +:root[data-theme="light"] .theme-toggle .icon-dark { display: none; } + +/* ---------- modal navigation drawer ---------- */ + +.drawer { + width: min(360px, 88vw); + max-width: none; + height: 100%; + max-height: none; + margin: 0; + padding: 12px; + border: 0; + border-radius: 0 var(--md-sys-shape-xl) var(--md-sys-shape-xl) 0; + background: var(--md-sys-color-surface-container-low); + color: var(--md-sys-color-on-surface); + inset: 0 auto 0 0; +} +.drawer::backdrop { background: rgb(0 0 0 / 0.32); } +.drawer[open] { animation: drawer-in 300ms var(--md-sys-motion-easing-emphasized-decelerate); } +@keyframes drawer-in { from { transform: translateX(-24px); opacity: 0; } } +.drawer__title { display: flex; align-items: center; gap: 12px; padding: 16px 16px 20px; } +.drawer__list { display: grid; gap: 4px; } +.drawer__list a { + display: flex; + align-items: center; + gap: 12px; + min-height: 56px; + padding: 0 24px 0 16px; + border-radius: var(--md-sys-shape-full); + color: var(--md-sys-color-on-surface-variant); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.1px; + font-weight: 500; + text-decoration: none; +} +.drawer__list a[aria-current="true"] { background: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container); } + +/* ---------- lists ---------- */ + +.list { display: grid; } +.list__item { + display: flex; + align-items: center; + gap: 16px; + min-height: 56px; + padding: 8px 16px; + color: inherit; + text-decoration: none; +} +.list__item--two { min-height: 72px; } +.list__text { display: grid; flex: 1; min-width: 0; } +.list__headline { font-size: 16px; line-height: 24px; letter-spacing: 0.5px; overflow-wrap: anywhere; } +.list__supporting { font-size: 14px; line-height: 20px; letter-spacing: 0.25px; color: var(--md-sys-color-on-surface-variant); overflow-wrap: anywhere; } +.list__trailing { color: var(--md-sys-color-on-surface-variant); } +.list--check .list__item { align-items: flex-start; min-height: 0; padding: 6px 0; } +.list--check .icon { color: var(--md-sys-color-primary); margin-top: 2px; } + +.download { + display: flex; + align-items: center; + gap: 16px; + padding: 12px 12px 12px 20px; + border-radius: var(--md-sys-shape-lg); + background: var(--md-sys-color-surface-container-highest); + color: var(--md-sys-color-on-surface); + text-decoration: none; +} +.download:hover { text-decoration: none; } +.download__text { display: grid; flex: 1; min-width: 0; } +.download__title { font-size: 14px; line-height: 20px; letter-spacing: 0.1px; font-weight: 500; } +.download__meta { font-family: var(--font-mono); font-size: 12px; line-height: 16px; letter-spacing: 0; color: var(--md-sys-color-on-surface-variant); overflow-wrap: anywhere; } +.download .icon-btn { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); pointer-events: none; } + +.divider { height: 1px; border: 0; margin: 0; background: var(--md-sys-color-outline-variant); } + +/* ---------- accordion (FAQ) ---------- */ + +.accordion { + overflow: hidden; + border-radius: var(--md-sys-shape-xl); + background: var(--md-sys-color-surface-container-low); +} +.accordion details + details { border-top: 1px solid var(--md-sys-color-outline-variant); } +.accordion summary { + display: flex; + align-items: center; + gap: 16px; + min-height: 64px; + padding: 12px 24px; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.15px; + font-weight: 500; + cursor: pointer; + list-style: none; +} +.accordion summary::-webkit-details-marker { display: none; } +.accordion summary > span:first-child { flex: 1; } +.accordion summary .icon { transition: transform 250ms var(--md-sys-motion-easing); color: var(--md-sys-color-on-surface-variant); } +.accordion details[open] { background: var(--md-sys-color-surface-container); } +.accordion details[open] summary .icon { transform: rotate(180deg); } +.accordion__answer { padding: 0 24px 20px; color: var(--md-sys-color-on-surface-variant); font-size: 14px; line-height: 22px; letter-spacing: 0.25px; max-width: 68ch; } + +/* ---------- footer ---------- */ + +.footer { + margin-top: 40px; + padding-block: 40px 56px; + background: var(--md-sys-color-surface-container); +} +.footer__row { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 24px; } +.footer__links { display: flex; flex-wrap: wrap; gap: 4px 8px; } +.footer__links .btn { min-height: 40px; } +.footer__note { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--md-sys-color-outline-variant); color: var(--md-sys-color-on-surface-variant); font-size: 12px; line-height: 16px; letter-spacing: 0.4px; } + +/* ---------- hero and device preview ---------- */ + +.hero { padding-block: clamp(32px, 6vw, 72px) clamp(40px, 7vw, 88px); } +.hero__grid { display: grid; gap: clamp(32px, 6vw, 64px); align-items: center; } +@media (min-width: 960px) { .hero__grid { grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); } } +.hero__copy { display: grid; gap: 24px; justify-items: start; } +.hero__copy .t-body-lg { max-width: 56ch; } +.hero__title em { font-style: normal; color: var(--md-sys-color-primary); } +.hero__chips { display: flex; flex-wrap: wrap; gap: 8px; } + +.stage { + position: relative; + display: grid; + place-items: center; + padding: clamp(24px, 5vw, 48px) 16px; + border-radius: var(--md-sys-shape-xl); + background: var(--md-sys-color-primary-container); + overflow: hidden; +} +.stage__flower { + position: absolute; + width: 78%; + aspect-ratio: 1; + inset: auto -18% -30% auto; + color: var(--md-sys-color-surface); + opacity: 0.35; +} +.stage__flower svg { width: 100%; height: 100%; fill: currentColor; } + +.device { + position: relative; + width: min(100%, 320px); + padding: 10px; + border-radius: 44px; + background: var(--md-sys-color-inverse-surface); + box-shadow: var(--md-sys-elevation-3); +} +.device__screen { + display: grid; + gap: 4px; + align-content: start; + min-height: 500px; + padding: 14px 12px 20px; + border-radius: 34px; + background: var(--md-sys-color-surface); + color: var(--md-sys-color-on-surface); +} +.device__status { display: flex; justify-content: space-between; padding: 2px 12px 6px; font-size: 12px; line-height: 16px; font-weight: 500; letter-spacing: 0.4px; } +.device__bar { display: flex; align-items: center; gap: 12px; padding: 4px 4px 12px; font-size: 18px; line-height: 24px; font-weight: 500; letter-spacing: 0; } +.device__label { padding: 8px 12px 4px; font-size: 12px; line-height: 16px; letter-spacing: 0.5px; font-weight: 500; color: var(--md-sys-color-primary); } +.device__row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px 8px 10px; + border-radius: var(--md-sys-shape-xl); + background: var(--md-sys-color-surface-container-high); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.1px; + font-weight: 500; +} +.device__row > span:nth-child(2) { flex: 1; } +.device__row .icon { color: var(--md-sys-color-on-surface-variant); font-size: 20px; } +.device__row--active { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); } +.device__row--active .icon { color: inherit; } +.device__row .flower { --flower-size: 36px; } + +/* ---------- spotlight, editions, features, comments ---------- */ + +.spotlight { grid-template-columns: auto 1fr auto; align-items: center; gap: 24px 32px; } +@media (max-width: 899px) { + .spotlight { grid-template-columns: auto 1fr; } + .spotlight > .btn { grid-column: 1 / -1; justify-self: stretch; } +} +@media (max-width: 599px) { + .spotlight { grid-template-columns: 1fr; justify-items: start; } +} + +.edition .card__head h3 { margin: 0; } +.edition .t-label-md { color: var(--md-sys-color-primary); } +.edition__downloads { display: grid; gap: 8px; margin-top: 8px; } + +.feature { align-content: start; } +.feature p { color: var(--md-sys-color-on-surface-variant); } + +.comments { overflow: hidden; border-radius: var(--md-sys-shape-xl); background: var(--md-sys-color-surface-container-low); } +.comment { display: flex; gap: 16px; align-items: flex-start; padding: 16px 20px; } +.comment + .comment { border-top: 1px solid var(--md-sys-color-outline-variant); } +.avatar { + display: grid; + place-items: center; + flex: none; + width: 40px; + height: 40px; + border-radius: var(--md-sys-shape-full); + background: var(--md-sys-color-tertiary-container); + color: var(--md-sys-color-on-tertiary-container); + font-size: 16px; + font-weight: 500; + text-transform: uppercase; +} +.comment__body { flex: 1; min-width: 0; } +.comment__author { display: flex; align-items: center; gap: 6px; font-size: 14px; line-height: 20px; font-weight: 500; letter-spacing: 0.1px; } +.comment__author img { width: 16px; height: 16px; } +.comment__text { margin-top: 2px; font-size: 14px; line-height: 20px; letter-spacing: 0.25px; } +.comment__meta { margin-top: 4px; font-size: 12px; line-height: 16px; letter-spacing: 0.4px; color: var(--md-sys-color-on-surface-variant); } +.comment__likes { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; line-height: 16px; letter-spacing: 0.5px; font-weight: 500; color: var(--md-sys-color-tertiary); } +.comment__likes .icon { font-size: 18px; } + +/* ---------- stats, table, code, callout (LihonB) ---------- */ + +.stat { gap: 4px; } +.stat__value { font-size: clamp(28px, 4vw, 36px); line-height: 1.2; font-weight: 500; letter-spacing: 0; color: var(--md-sys-color-primary); } +.stat__label { font-size: 14px; line-height: 20px; letter-spacing: 0.25px; color: var(--md-sys-color-on-surface-variant); } + +.table-wrap { + overflow-x: auto; + border-radius: var(--md-sys-shape-xl); + background: var(--md-sys-color-surface-container-low); +} +.table-wrap:focus-visible { outline-offset: -3px; } +.data-table { width: 100%; min-width: 760px; border-collapse: collapse; font-size: 14px; line-height: 20px; letter-spacing: 0.25px; } +.data-table caption { text-align: start; padding: 20px 24px 8px; color: var(--md-sys-color-on-surface-variant); } +.data-table th, .data-table td { padding: 14px 24px; text-align: start; vertical-align: middle; border-bottom: 1px solid var(--md-sys-color-outline-variant); } +.data-table thead th { font-weight: 500; letter-spacing: 0.1px; color: var(--md-sys-color-on-surface-variant); background: var(--md-sys-color-surface-container); } +.data-table tbody tr:last-child td, .data-table tbody tr:last-child th { border-bottom: 0; } +.data-table tbody th { font-weight: 500; } +.data-table .is-us { background: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container); } +.data-table thead .is-us { font-weight: 500; } +.cell { display: inline-flex; align-items: center; gap: 8px; } +.cell .icon { font-size: 20px; } +.cell--yes .icon { color: var(--md-sys-color-success); } +.cell--no .icon { color: var(--md-sys-color-error); } +.cell--warn .icon { color: #9a6a00; } +:root[data-theme="dark"] .cell--warn .icon { color: #f2c35b; } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .cell--warn .icon { color: #f2c35b; } } +.is-us .cell--yes .icon { color: inherit; } + +.code { + margin: 0; + padding: 20px 24px; + overflow-x: auto; + border-radius: var(--md-sys-shape-lg); + background: var(--md-sys-color-surface-container-highest); + font-size: 14px; + line-height: 24px; + letter-spacing: 0; +} + +.split { display: grid; gap: clamp(24px, 5vw, 56px); align-items: center; } +@media (min-width: 900px) { .split { grid-template-columns: 1fr 1fr; } } + +.callout { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 24px 40px; + padding: clamp(24px, 4vw, 40px); + border-radius: var(--md-sys-shape-xl); + background: var(--md-sys-color-tertiary-container); + color: var(--md-sys-color-on-tertiary-container); +} +.callout > div { max-width: 640px; display: grid; gap: 12px; } +.callout .btn--filled { background: var(--md-sys-color-tertiary); color: var(--md-sys-color-on-tertiary); } + +/* ---------- narrow pages (badge, support) ---------- */ + +.narrow { max-width: 720px; margin-inline: auto; padding-block: clamp(32px, 6vw, 64px) 40px; display: grid; gap: 16px; } +.narrow__hero { display: grid; gap: 16px; justify-items: center; text-align: center; margin-bottom: 16px; } +.narrow__hero .t-body-lg { max-width: 56ch; } + +/* ---------- forms and banners (portal) ---------- */ + +.form { display: grid; gap: 20px; } + +.field { --field-bg: var(--md-sys-color-surface); position: relative; display: block; } +.card--filled .field { --field-bg: var(--md-sys-color-surface-container-highest); } +.card--elevated .field { --field-bg: var(--md-sys-color-surface-container-low); } +.field input { + display: block; + width: 100%; + height: 56px; + padding: 0 16px; + border: 0; + border-radius: var(--md-sys-shape-xs); + background: transparent; + color: var(--md-sys-color-on-surface); + caret-color: var(--md-sys-color-primary); + font: inherit; + box-shadow: inset 0 0 0 1px var(--md-sys-color-outline); + transition: box-shadow 150ms var(--md-sys-motion-easing); +} +.field input:hover { box-shadow: inset 0 0 0 1px var(--md-sys-color-on-surface); } +.field input:focus { outline: 0; box-shadow: inset 0 0 0 2px var(--md-sys-color-primary); } +.field label { + position: absolute; + inset-inline-start: 12px; + top: 16px; + padding-inline: 4px; + background: var(--field-bg); + color: var(--md-sys-color-on-surface-variant); + font-size: 16px; + line-height: 24px; + letter-spacing: 0.5px; + pointer-events: none; + transition: top 150ms var(--md-sys-motion-easing), font-size 150ms var(--md-sys-motion-easing), line-height 150ms var(--md-sys-motion-easing); +} +.field input:focus + label, +.field input:not(:placeholder-shown) + label { top: -8px; font-size: 12px; line-height: 16px; letter-spacing: 0.4px; } +.field input:focus + label { color: var(--md-sys-color-primary); } +.field__hint { margin: 4px 16px 0; font-size: 12px; line-height: 16px; letter-spacing: 0.4px; color: var(--md-sys-color-on-surface-variant); } + +.alert { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border-radius: var(--md-sys-shape-md); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; +} +.alert .icon { flex: none; margin-top: 0; } +.alert--error { background: var(--md-sys-color-error-container); color: var(--md-sys-color-on-error-container); } +.alert--success { background: var(--md-sys-color-success-container); color: var(--md-sys-color-on-success-container); } +.alert a { color: inherit; } + +.top-bar__actions .btn--always { display: inline-flex; } +.section-label { margin-top: 8px; padding-inline: 8px; color: var(--md-sys-color-primary); } +.card--flush { padding: 8px; gap: 0; } +.list--divided .list__item { flex-wrap: wrap; padding-block: 12px; } +.list--divided .list__item + .list__item { border-top: 1px solid var(--md-sys-color-outline-variant); } +.list--divided .list__text { flex: 1 1 160px; } +.list--divided .list__trailing { margin-inline-start: auto; } +.grid--pair { grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); } +.btn--error { background: var(--md-sys-color-error-container); color: var(--md-sys-color-on-error-container); } +.btn--error:hover { box-shadow: var(--md-sys-elevation-1); } +.btn--sm { min-height: 36px; padding-inline: 16px; } + +.icon-btn[data-copied] { color: var(--md-sys-color-primary); } +.hash { display: block; padding: 12px 16px; border-radius: var(--md-sys-shape-md); background: var(--md-sys-color-surface); font-family: var(--font-mono); font-size: 12px; line-height: 16px; letter-spacing: 0; overflow-wrap: anywhere; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } +} diff --git a/src/web/assets/site/md3.js b/src/web/assets/site/md3.js new file mode 100644 index 0000000..244e729 --- /dev/null +++ b/src/web/assets/site/md3.js @@ -0,0 +1,160 @@ +/* LihonT site: theme toggle, navigation drawer, confirm and copy helpers, app bar state, scroll spy, FAB, ripple. No dependencies. */ +(function () { + "use strict"; + + var root = document.documentElement; + var STORAGE_KEY = "lihont-theme"; + + function store(value) { + try { + if (value) window.localStorage.setItem(STORAGE_KEY, value); + else window.localStorage.removeItem(STORAGE_KEY); + } catch (error) { /* private mode or blocked storage: the choice just is not remembered */ } + } + + function systemIsDark() { + return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; + } + + function currentIsDark() { + var forced = root.getAttribute("data-theme"); + return forced ? forced === "dark" : systemIsDark(); + } + + function syncToggle() { + var dark = currentIsDark(); + var buttons = document.querySelectorAll("[data-theme-toggle]"); + for (var i = 0; i < buttons.length; i++) { + buttons[i].setAttribute("aria-label", dark ? "Включить светлую тему" : "Включить тёмную тему"); + buttons[i].setAttribute("aria-pressed", dark ? "true" : "false"); + } + var meta = document.querySelector('meta[name="theme-color"][data-dynamic]'); + if (meta) { + var surface = getComputedStyle(root).getPropertyValue("--md-sys-color-surface").trim(); + if (surface) meta.setAttribute("content", surface); + } + } + + document.addEventListener("click", function (event) { + var toggle = event.target.closest("[data-theme-toggle]"); + if (!toggle) return; + var next = currentIsDark() ? "light" : "dark"; + root.setAttribute("data-theme", next); + store(next); + syncToggle(); + }); + + if (window.matchMedia) { + var query = window.matchMedia("(prefers-color-scheme: dark)"); + var onChange = function () { syncToggle(); }; + if (query.addEventListener) query.addEventListener("change", onChange); + } + syncToggle(); + + /* navigation drawer: a native modal dialog gives focus trapping and Escape for free */ + var drawer = document.getElementById("drawer"); + if (drawer && typeof drawer.showModal === "function") { + document.addEventListener("click", function (event) { + if (event.target.closest("[data-drawer-open]")) { + drawer.showModal(); + } else if (event.target.closest("[data-drawer-close]") || event.target.closest("#drawer a") || event.target === drawer) { + drawer.close(); + } + }); + } else { + var openers = document.querySelectorAll("[data-drawer-open]"); + for (var o = 0; o < openers.length; o++) openers[o].hidden = true; + } + + /* destructive forms ask first; the question lives in data-confirm, not in inline script */ + document.addEventListener("submit", function (event) { + var message = event.target.getAttribute && event.target.getAttribute("data-confirm"); + if (message && !window.confirm(message)) event.preventDefault(); + }); + + /* copy buttons (checksums); hidden where the clipboard API is unavailable */ + var copiers = document.querySelectorAll("[data-copy]"); + for (var c = 0; c < copiers.length; c++) { + if (!(navigator.clipboard && navigator.clipboard.writeText)) copiers[c].hidden = true; + } + document.addEventListener("click", function (event) { + var button = event.target.closest("[data-copy]"); + if (!button || !navigator.clipboard) return; + navigator.clipboard.writeText(button.getAttribute("data-copy")).then(function () { + var label = button.getAttribute("aria-label"); + button.setAttribute("aria-label", "Скопировано"); + button.setAttribute("data-copied", ""); + window.setTimeout(function () { + button.setAttribute("aria-label", label); + button.removeAttribute("data-copied"); + }, 1800); + }, function () { /* denied: nothing to do */ }); + }); + + /* app bar: tonal surface once the page has scrolled */ + var bar = document.querySelector("[data-top-bar]"); + function barState() { + if (!bar) return; + if (window.scrollY > 8) bar.setAttribute("data-scrolled", ""); + else bar.removeAttribute("data-scrolled"); + } + window.addEventListener("scroll", barState, { passive: true }); + barState(); + + /* scroll spy: mark the tab and drawer link of the section on screen */ + var links = document.querySelectorAll('[data-spy] a[href^="#"]'); + if (links.length && "IntersectionObserver" in window) { + var byId = {}; + for (var l = 0; l < links.length; l++) { + var id = links[l].getAttribute("href").slice(1); + (byId[id] = byId[id] || []).push(links[l]); + } + var observer = new IntersectionObserver(function (entries) { + for (var e = 0; e < entries.length; e++) { + if (!entries[e].isIntersecting) continue; + for (var key in byId) { + for (var k = 0; k < byId[key].length; k++) { + if (key === entries[e].target.id) byId[key][k].setAttribute("aria-current", "true"); + else byId[key][k].removeAttribute("aria-current"); + } + } + } + }, { rootMargin: "-35% 0px -55% 0px" }); + for (var section in byId) { + var target = document.getElementById(section); + if (target) observer.observe(target); + } + } + + /* extended FAB on narrow screens, shown after the hero */ + var fab = document.querySelector("[data-fab]"); + if (fab) { + var fabState = function () { + if (window.scrollY > window.innerHeight * 0.6) fab.setAttribute("data-visible", ""); + else fab.removeAttribute("data-visible"); + }; + window.addEventListener("scroll", fabState, { passive: true }); + fabState(); + } + + /* ripple on press for state-layer elements */ + var reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!reduce) { + document.addEventListener("pointerdown", function (event) { + var host = event.target.closest(".btn, .icon-btn, .list__item, .tabs a, .drawer__list a, .download, .fab, summary"); + if (!host || event.button > 0) return; + var box = host.getBoundingClientRect(); + var size = Math.max(box.width, box.height) * 2; + var dot = document.createElement("span"); + dot.className = "ripple"; + dot.style.width = dot.style.height = size + "px"; + dot.style.left = event.clientX - box.left - size / 2 + "px"; + dot.style.top = event.clientY - box.top - size / 2 + "px"; + var style = getComputedStyle(host); + if (style.position === "static") host.style.position = "relative"; + host.style.overflow = "hidden"; + host.appendChild(dot); + dot.addEventListener("animationend", function () { dot.remove(); }); + }); + } +})(); diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..69e206e --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,376 @@ + + + + + + + + + + + + + + LihonT — TikTok в стиле Material 3 + + + + + + + + +
+ +
+ + +
+ + LihonT + +
+ +
+ +
+ +
+ +
+
+
+ + + Material Design 3 + +

Твой TikTok
в стиле Material 3

+

Адаптивная палитра Monet, отдельные скруглённые карточки настроек, фирменные цветочки-бейджи, перенесённые оригинальные настройки TikTok и выбор любого региона мира.

+ + +
+ Android 6.0+ + arm64-v8a + Без root + TikTok 47.1.3 +
+
+ + +
+
+ + +
+
+ +
+
+

LihonT 2.0 Native

+ Закрытое тестирование +
+

Новая версия мода на актуальной базе TikTok 47.1.3 в дизайне Material 3: отдельные скруглённые кнопки в настройках, палитра Monet, обновлённый интерфейс и расширенные настройки.

+
+ + + Вход для участников + +
+
+ + +
+
+ Доступные сборки +

Классические сборки LihonT 1.2

+

Проверенные временем версии. Обычный APK обновляет установленный мод, отдельный клон ставится рядом как второй независимый TikTok.

+
+ +
+
+
+ +
+
Основная версия
+

LihonT Serena 1.2

+
+
+

Стабильная орбита для каждого дня. Базовые функции мода, сохранение без водяных знаков и фон за аватаркой.

+
    +
  • Скачивание видео и фото-слайдов без водяных знаков
  • +
  • Японский регион по умолчанию
  • +
  • Фоновые обои в профиле и взаимодействия с репостами
  • +
+ +
+ +
+
+ +
+
Со значком сообщества
+

LihonT Luna 1.2

+
+
+

Значки LihonT в профилях и комментариях, расширенные настройки мода и выбор стран.

+
    +
  • Все основные функции Serena
  • +
  • Значки LihonT в комментариях и профилях
  • +
  • Можно скрыть значок или переключить MargyT / TikTok You
  • +
+ +
+
+
+ + +
+
+ Больше свободы +

Всё, за что любят LihonT

+

Каждая деталь продумана для комфортного использования без рекламы и лишних ограничений.

+
+
+
+ +

Material 3

+

Цветочки-бейджи, палитра Monet, отдельные скруглённые карточки и полное погружение в Material You.

+
+
+ +

Сохраняй чисто

+

Скачивай любимые видео, фото-слайды и звуковые дорожки без водяных знаков в оригинальном качестве.

+
+
+ +

Любые регионы

+

Свободный выбор страны в настройках мода. Переключись на любой регион, а мод очистит кэш и применит настройки.

+
+
+ +

Без root-прав

+

Ставится на любой Android 6.0+ без разблокировки загрузчика и без изменения системы.

+
+
+ +

Твой профиль

+

Фоновые картинки за аватаркой, значок участника сообщества и удобный просмотр репостов.

+
+
+ +

Приватность

+

Никаких сторонних трекеров. Учётные данные остаются между тобой и TikTok.

+
+
+
+ + +
+
+
+ Свои среди своих +

Значок LihonT в комментариях

+

Участники видят маленькую фирменную ноту рядом с именами друг друга и находят своих в комментариях и профилях.

+ +
+
+
+ +
+
anna.cosmo Значок LihonT
+

Смотрите, какой вайб в новой версии LihonT!

+

2 мин. назад · Ответить

+
+ +
+
+ +
+
luna.explorer Значок LihonT
+

Нашёл своих. Цветочки в Material 3 топ!

+

Только что · Ответить

+
+ +
+
+
+
+ + +
+
+ Перед стартом +

Часто задаваемые вопросы

+

Коротко о версиях, установке, обновлении и смене региона.

+
+
+
+ Что нового в LihonT 2.0? +
LihonT 2.0 — переход на Material Design 3. Экраны «Настройки и конфиденциальность» переписаны в Material You: каждая строка — отдельная скруглённая кнопка, фирменные цветочки-бейджи, диалог выбора региона с очисткой кэша и быстрой перезагрузкой.
+
+
+ Какую версию выбрать: Serena, Luna или 2.0? +
Если участвуешь в тестировании — выбирай LihonT 2.0. Нужна проверенная стабильная сборка — Serena 1.2 (классика) или Luna 1.2 (со значком сообщества).
+
+
+ Какой APK скачать: обычный или отдельный (клон)? +
Обычный APK (пакет com.zhiliaoapp.musically) обновляет уже установленный мод. Отдельный APK (пакет online.lihon.tiktok) ставится параллельно как второй независимый TikTok со своими данными и входом.
+
+
+ Как установить APK на Android? +
Скачай APK, открой его через проводник или браузер и разреши установку из этого источника. Нужен Android 6.0 или новее с 64-битным процессором (arm64-v8a).
+
+
+ Как сменить регион в моде? +
В TikTok открой «Настройки и конфиденциальность» → «Настройки мода» → «Регион мода». Выбери страну (по умолчанию Япония) и подтверди перезагрузку, чтобы очистить кэш.
+
+
+
+
+ + + + + + Скачать + + + diff --git a/src/web/lihont2-location.conf b/src/web/lihont2-location.conf new file mode 100644 index 0000000..54f4851 --- /dev/null +++ b/src/web/lihont2-location.conf @@ -0,0 +1,11 @@ +location ^~ /lihont2 { + client_max_body_size 64k; + proxy_pass http://127.0.0.1:18750; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; +} diff --git a/src/web/lihont2.service b/src/web/lihont2.service new file mode 100644 index 0000000..af7b8f7 --- /dev/null +++ b/src/web/lihont2.service @@ -0,0 +1,14 @@ +[Unit] +Description=LihonT 2.0 Web Portal & Private Distribution Service +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/lihont2 +ExecStart=/usr/bin/python3 /opt/lihont2/server.py --host 127.0.0.1 --port 18750 --db /var/lib/lihont2/data.db --apk /opt/lihont2/files/lihont2.apk +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/src/web/md3-theme.css b/src/web/md3-theme.css new file mode 100644 index 0000000..3c1ac97 --- /dev/null +++ b/src/web/md3-theme.css @@ -0,0 +1,859 @@ +/* LihonT Material Design 3 Expressive Theme */ +:root { + --md-sys-color-background: #141218; + --md-sys-color-on-background: #e6e0e9; + --md-sys-color-surface: #1d1b20; + --md-sys-color-on-surface: #e6e0e9; + --md-sys-color-surface-container-lowest: #0f0d13; + --md-sys-color-surface-container-low: #1d1b20; + --md-sys-color-surface-container: #211f26; + --md-sys-color-surface-container-high: #2b2930; + --md-sys-color-surface-container-highest: #36343b; + --md-sys-color-on-surface-variant: #cac4d0; + + --md-sys-color-primary: #d0bcff; + --md-sys-color-on-primary: #381e72; + --md-sys-color-primary-container: #4f378b; + --md-sys-color-on-primary-container: #eaddff; + + --md-sys-color-secondary: #ccc2dc; + --md-sys-color-on-secondary: #332d41; + --md-sys-color-secondary-container: #4a4458; + --md-sys-color-on-secondary-container: #e8def8; + + --md-sys-color-tertiary: #efb8c8; + --md-sys-color-on-tertiary: #492532; + --md-sys-color-tertiary-container: #633b48; + --md-sys-color-on-tertiary-container: #ffd8e4; + + --md-sys-color-error: #f2b8b5; + --md-sys-color-on-error: #601410; + --md-sys-color-error-container: #8c1d18; + --md-sys-color-on-error-container: #f9dedc; + + --md-sys-color-outline: #938f99; + --md-sys-color-outline-variant: #49454f; + + --md-sys-shape-corner-extra-large: 28px; + --md-sys-shape-corner-large: 16px; + --md-sys-shape-corner-medium: 12px; + --md-sys-shape-corner-small: 8px; + --md-sys-shape-corner-full: 9999px; + + --font-family: -apple-system, BlinkMacSystemFont, "Google Sans", "Segoe UI", Roboto, sans-serif; +} + +/* Base reset */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; + color-scheme: dark; +} + +body { + background-color: var(--md-sys-color-background); + color: var(--md-sys-color-on-surface); + font-family: var(--font-family); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + position: relative; + min-height: 100vh; +} + +/* Background Ambient Glows & Flowers */ +.ambient-glow-wrapper { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.ambient-blob { + position: absolute; + border-radius: 50%; + filter: blur(120px); + opacity: 0.18; + pointer-events: none; +} + +.ambient-blob-1 { + width: 500px; + height: 500px; + top: -150px; + left: 10%; + background: radial-gradient(circle, #8553f2, #4f378b); +} + +.ambient-blob-2 { + width: 450px; + height: 450px; + top: 30%; + right: -100px; + background: radial-gradient(circle, #efb8c8, #633b48); +} + +.ambient-blob-3 { + width: 400px; + height: 400px; + bottom: 10%; + left: -80px; + background: radial-gradient(circle, #70b0ff, #294677); +} + +/* Decorative floating flower SVGs in the background */ +.floating-flower { + position: absolute; + color: #d0bcff; + opacity: 0.05; + pointer-events: none; + animation: floatSlow 24s ease-in-out infinite alternate; +} + +@keyframes floatSlow { + 0% { transform: translateY(0) rotate(0deg); } + 50% { transform: translateY(-25px) rotate(180deg); } + 100% { transform: translateY(0) rotate(360deg); } +} + +.container { + width: 100%; + max-width: 1120px; + margin-inline: auto; + padding-inline: 24px; + position: relative; + z-index: 1; +} + +/* Top App Bar */ +.top-app-bar { + position: sticky; + top: 0; + z-index: 100; + background: rgba(20, 18, 24, 0.82); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 12px 0; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.top-bar-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: var(--md-sys-color-on-surface); + font-weight: 700; + font-size: 20px; + letter-spacing: -0.3px; +} + +.brand-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px 8px; + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; +} + +.nav-links { + display: flex; + align-items: center; + gap: 8px; +} + +.nav-link { + color: var(--md-sys-color-on-surface-variant); + text-decoration: none; + font-size: 14px; + font-weight: 500; + padding: 8px 14px; + border-radius: var(--md-sys-shape-corner-full); + transition: all 0.2s ease; +} + +.nav-link:hover { + color: var(--md-sys-color-primary); + background-color: rgba(208, 188, 255, 0.08); +} + +.nav-link.active { + color: var(--md-sys-color-on-secondary-container); + background-color: var(--md-sys-color-secondary-container); +} + +.top-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* Material 3 Scalloped Flower Badge ("Цветочки") */ +.flower-badge-wrapper { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.flower-badge-shape { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.flower-badge-icon { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; +} + +.flower-badge-icon svg { + width: 52%; + height: 52%; + fill: currentColor; +} + +/* Flower color themes */ +.flower-primary { + color: var(--md-sys-color-primary-container); +} +.flower-primary .flower-badge-icon { + color: var(--md-sys-color-on-primary-container); +} + +.flower-secondary { + color: #384b66; +} +.flower-secondary .flower-badge-icon { + color: #cfe5ff; +} + +.flower-tertiary { + color: var(--md-sys-color-tertiary-container); +} +.flower-tertiary .flower-badge-icon { + color: var(--md-sys-color-on-tertiary-container); +} + +.flower-gold { + color: #634f19; +} +.flower-gold .flower-badge-icon { + color: #ffdf9e; +} + +.flower-green { + color: #245233; +} +.flower-green .flower-badge-icon { + color: #bbf2c8; +} + +/* MD3 Buttons */ +.md3-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + border-radius: var(--md-sys-shape-corner-full); + font-family: inherit; + font-size: 14px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + border: none; + outline: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; +} + +.md3-btn:active { + transform: scale(0.97); +} + +.md3-btn-filled { + background-color: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + box-shadow: 0 1px 3px rgba(0,0,0,0.3); +} + +.md3-btn-filled:hover { + background-color: #dfceff; + box-shadow: 0 4px 12px rgba(208, 188, 255, 0.25); + transform: translateY(-1px); +} + +.md3-btn-tonal { + background-color: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); +} + +.md3-btn-tonal:hover { + background-color: #565064; + transform: translateY(-1px); +} + +.md3-btn-outlined { + background: transparent; + color: var(--md-sys-color-primary); + border: 1px solid var(--md-sys-color-outline-variant); +} + +.md3-btn-outlined:hover { + background-color: rgba(208, 188, 255, 0.08); + border-color: var(--md-sys-color-primary); +} + +.md3-btn-tertiary { + background-color: var(--md-sys-color-tertiary-container); + color: var(--md-sys-color-on-tertiary-container); +} + +.md3-btn-tertiary:hover { + background-color: #744555; + transform: translateY(-1px); +} + +.md3-btn-small { + padding: 8px 16px; + font-size: 13px; +} + +.md3-btn-large { + padding: 16px 32px; + font-size: 16px; + font-weight: 700; +} + +/* MD3 Cards */ +.md3-card { + background-color: var(--md-sys-color-surface-container); + border-radius: var(--md-sys-shape-corner-extra-large); + border: 1px solid rgba(255, 255, 255, 0.05); + padding: 28px; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +.md3-card-high { + background-color: var(--md-sys-color-surface-container-high); +} + +.md3-card-highest { + background-color: var(--md-sys-color-surface-container-highest); +} + +.md3-card:hover { + border-color: rgba(208, 188, 255, 0.2); + transform: translateY(-2px); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3); +} + +/* Eyebrows & Badges */ +.md3-eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--md-sys-color-primary); + margin-bottom: 12px; +} + +.md3-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-surface-container-highest); + color: var(--md-sys-color-on-surface); + font-size: 13px; + font-weight: 500; + border: 1px solid var(--md-sys-color-outline-variant); +} + +.md3-chip-primary { + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + border-color: transparent; +} + +.md3-chip-tertiary { + background-color: var(--md-sys-color-tertiary-container); + color: var(--md-sys-color-on-tertiary-container); + border-color: transparent; +} + +/* Hero Section */ +.hero-section { + padding: 60px 0 80px; + display: grid; + grid-template-columns: 1.15fr 0.85fr; + align-items: center; + gap: 48px; +} + +.hero-title { + font-size: clamp(38px, 5.5vw, 62px); + font-weight: 800; + line-height: 1.08; + letter-spacing: -1.5px; + margin-bottom: 20px; +} + +.gradient-primary { + background: linear-gradient(135deg, #d0bcff 0%, #efb8c8 50%, #9ecaff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero-sub { + font-size: clamp(16px, 2vw, 19px); + color: var(--md-sys-color-on-surface-variant); + line-height: 1.6; + margin-bottom: 32px; + max-width: 580px; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; + margin-bottom: 32px; +} + +.hero-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Interactive Settings Card Preview in Hero */ +.hero-preview-wrapper { + position: relative; +} + +.hero-preview-card { + background: var(--md-sys-color-surface-container); + border-radius: var(--md-sys-shape-corner-extra-large); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + overflow: hidden; + position: relative; +} + +.preview-header { + padding: 18px 22px; + background: var(--md-sys-color-surface-container-high); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: space-between; +} + +.preview-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.preview-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-radius: var(--md-sys-shape-corner-large); + background: var(--md-sys-color-surface-container-high); + transition: all 0.2s ease; +} + +.preview-item:hover { + background: var(--md-sys-color-surface-container-highest); +} + +.preview-item-left { + display: flex; + align-items: center; + gap: 14px; +} + +/* Spotlight Card for LihonT 2.0 */ +.spotlight-card { + background: linear-gradient(135deg, rgba(79, 55, 139, 0.35) 0%, rgba(33, 31, 38, 0.95) 70%); + border: 1px solid rgba(208, 188, 255, 0.25); + border-radius: var(--md-sys-shape-corner-extra-large); + padding: 36px; + margin-bottom: 60px; + position: relative; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4); +} + +.spotlight-inner { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 28px; +} + +/* Versions Grid */ +.section-title-wrap { + text-align: center; + max-width: 680px; + margin-inline: auto; + margin-bottom: 48px; +} + +.section-title { + font-size: clamp(28px, 4vw, 42px); + font-weight: 800; + letter-spacing: -1px; + margin-bottom: 12px; +} + +.section-desc { + font-size: 16px; + color: var(--md-sys-color-on-surface-variant); +} + +.editions-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 24px; + margin-bottom: 64px; +} + +.edition-card { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.edition-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.edition-meta { + display: flex; + flex-direction: column; +} + +.edition-name { + font-size: 24px; + font-weight: 700; +} + +.features-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; + margin: 20px 0; +} + +.features-list li { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 14px; + color: var(--md-sys-color-on-surface-variant); + line-height: 1.5; +} + +.features-list li svg { + width: 18px; + height: 18px; + color: var(--md-sys-color-primary); + flex-shrink: 0; + margin-top: 2px; +} + +.download-options { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 24px; +} + +.download-btn-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-radius: var(--md-sys-shape-corner-large); + background-color: var(--md-sys-color-surface-container-high); + text-decoration: none; + color: var(--md-sys-color-on-surface); + transition: all 0.2s ease; + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.download-btn-card:hover { + background-color: var(--md-sys-color-surface-container-highest); + border-color: rgba(208, 188, 255, 0.25); + transform: translateY(-1px); +} + +.pkg-code { + font-family: monospace; + font-size: 11px; + color: var(--md-sys-color-outline); + margin-top: 2px; +} + +/* Features Grid */ +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin-bottom: 72px; +} + +.feature-box { + padding: 24px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.feature-box h3 { + font-size: 18px; + font-weight: 700; + color: var(--md-sys-color-on-surface); +} + +.feature-box p { + font-size: 14px; + color: var(--md-sys-color-on-surface-variant); + line-height: 1.6; +} + +/* Comments Demo */ +.community-section { + margin-bottom: 80px; +} + +.comments-demo-card { + max-width: 580px; + margin-inline: auto; + background-color: #121212; + border-radius: 24px; + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.comment-row { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.comment-row:last-child { + border-bottom: none; +} + +.comment-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + background: #332d41; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 15px; + color: #fff; + flex-shrink: 0; +} + +.comment-content { + flex: 1; +} + +.comment-author { + font-size: 13px; + font-weight: 700; + color: #fff; + display: flex; + align-items: center; + gap: 6px; +} + +.comment-text { + font-size: 14px; + color: #e1e1e1; + margin-top: 3px; +} + +.comment-meta { + font-size: 11px; + color: #888; + margin-top: 4px; +} + +/* FAQ Details/Accordion */ +.faq-grid { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 820px; + margin-inline: auto; + margin-bottom: 80px; +} + +.faq-item { + background-color: var(--md-sys-color-surface-container); + border-radius: var(--md-sys-shape-corner-large); + border: 1px solid rgba(255, 255, 255, 0.04); + overflow: hidden; + transition: all 0.2s ease; +} + +.faq-item[open] { + background-color: var(--md-sys-color-surface-container-high); + border-color: rgba(208, 188, 255, 0.15); +} + +.faq-summary { + padding: 18px 24px; + cursor: pointer; + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; + list-style: none; + user-select: none; +} + +.faq-summary::-webkit-details-marker { + display: none; +} + +.faq-chevron { + width: 20px; + height: 20px; + transition: transform 0.25s ease; + color: var(--md-sys-color-primary); +} + +.faq-item[open] .faq-chevron { + transform: rotate(180deg); +} + +.faq-answer { + padding: 0 24px 20px; + font-size: 14px; + line-height: 1.7; + color: var(--md-sys-color-on-surface-variant); +} + +/* Footer */ +.site-footer { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding: 48px 0 32px; + background-color: var(--md-sys-color-surface-container-lowest); + font-size: 13px; + color: var(--md-sys-color-outline); +} + +.footer-top { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 24px; +} + +.footer-links { + display: flex; + gap: 20px; +} + +.footer-links a { + color: var(--md-sys-color-on-surface-variant); + text-decoration: none; +} + +.footer-links a:hover { + color: var(--md-sys-color-primary); +} + +.footer-bottom { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + padding-top: 20px; +} + +/* Responsive adjustments */ +@media (max-width: 900px) { + .hero-section { + grid-template-columns: 1fr; + text-align: center; + gap: 36px; + } + .hero-sub { + margin-inline: auto; + } + .hero-actions { + justify-content: center; + } + .hero-chips { + justify-content: center; + } + .spotlight-inner { + grid-template-columns: 1fr; + text-align: center; + } + .spotlight-inner .flower-badge-wrapper { + margin-inline: auto; + } + .nav-links { + display: none; + } +} diff --git a/src/web/members_index.html b/src/web/members_index.html new file mode 100644 index 0000000..d2887a0 --- /dev/null +++ b/src/web/members_index.html @@ -0,0 +1,105 @@ + + + + + + + + + + LihonT — значок сообщества в Material 3 + + + + + + + + +
+ +
+ +
+
+
+ + Сообщество LihonT · значок +

На одной орбите. Узнавай своих.

+

Маленькая фирменная нота рядом с именем помогает участникам сообщества LihonT находить друг друга в комментариях и профилях TikTok.

+
+ +
+
+ +

Быстрый запуск настроек значка

+
+

Нажми кнопку для твоего установленного пакета, и настройки мода откроются прямо внутри TikTok:

+ + Открыть настройки обычной версииcom.zhiliaoapp.musically + + + + Открыть настройки клона (отдельного)online.lihon.tiktok + + +
+ +
+

Как это работает и что передаётся

+

Сервису передаются только публичный ID аккаунта и случайный код установки приложения. Пароли, личные переписки, cookies и сессии TikTok никогда не покидают твоё устройство.

+

Значок можно скрыть в любой момент в «Настройках мода». У других участников отметка обновится в течение 30 минут.

+
+ +
+
+ +
+

Ещё не установлена Luna?

+

Актуальная сборка с поддержкой значков

+
+
+ +
+
+
+ + + + diff --git a/src/web/pay_index.html b/src/web/pay_index.html new file mode 100644 index 0000000..bf36fb5 --- /dev/null +++ b/src/web/pay_index.html @@ -0,0 +1,81 @@ + + + + + + + + + + LihonT — поддержать разработку + + + + + + + + +
+ +
+ +
+
+
+ + Поддержка проекта +

Поддержи развитие LihonT

+

Если нравится мод и хочется помочь проекту расти, поддержи разработку через CloudTips на любую комфортную сумму. Все средства идут на оплату серверов и развитие новых версий.

+
+ +
+
+ + +
+

Оплата принимается через сервис CloudTips с банковских карт и СБП. Спасибо каждому, кто помогает проекту оставаться бесплатным и развиваться.

+ +
+
+
+ + + + diff --git a/src/web/server.py b/src/web/server.py new file mode 100644 index 0000000..3573bef --- /dev/null +++ b/src/web/server.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LihonT 2.0 Private Distribution Portal (/lihont2) +Material 3 authenticated web application for private APK distribution. +""" + +from __future__ import annotations + +import argparse +import datetime +import hashlib +import hmac +import html +import http.cookies +import http.server +import json +import os +import secrets +import socketserver +import sqlite3 +import sys +import urllib.parse +from pathlib import Path + +# --- Configuration defaults --- +DEFAULT_PORT = 18750 +DEFAULT_HOST = "127.0.0.1" +BASE_PATH = "/lihont2" +SESSION_COOKIE_NAME = "lihont2_session" +SESSION_DURATION_DAYS = 30 +APK_FILE_PATH = "/opt/lihont2/files/lihont2.apk" +DEFAULT_DB_PATH = "/var/lib/lihont2/data.db" + +# Admin credentials come from the environment; nothing is seeded without both. +ADMIN_USERNAME = os.environ.get("LIHONT2_ADMIN_USER", "") +ADMIN_PASSWORD = os.environ.get("LIHONT2_ADMIN_PASSWORD", "") + + +# --- Database helpers --- + +def get_db(db_path: str) -> sqlite3.Connection: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(db_path: str) -> None: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + with get_db(db_path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + note TEXT DEFAULT '', + created_at TEXT NOT NULL, + last_login TEXT + ); + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + downloaded_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + """) + conn.commit() + + # Seed admin user if not exists + if not (ADMIN_USERNAME and ADMIN_PASSWORD): + print("[INIT] LIHONT2_ADMIN_USER / LIHONT2_ADMIN_PASSWORD not set; admin user not seeded.") + return + row = conn.execute("SELECT id FROM users WHERE username = ?", (ADMIN_USERNAME,)).fetchone() + if not row: + hashed = hash_password(ADMIN_PASSWORD) + now = datetime.datetime.utcnow().isoformat() + conn.execute( + "INSERT INTO users (username, password_hash, is_admin, note, created_at) VALUES (?, ?, 1, ?, ?)", + (ADMIN_USERNAME, hashed, "Системный администратор LihonT", now) + ) + conn.commit() + print(f"[INIT] Created admin user '{ADMIN_USERNAME}'.") + + +# --- Password hashing (PBKDF2-HMAC-SHA256) --- + +def hash_password(password: str, salt: bytes | None = None) -> str: + if salt is None: + salt = secrets.token_bytes(16) + iterations = 100_000 + derived = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return f"pbkdf2:sha256:{iterations}${salt.hex()}${derived.hex()}" + + +def verify_password(password: str, stored_hash: str) -> bool: + try: + algorithm, rest = stored_hash.split(":", 1) + sub_algo, rest = rest.split(":", 1) + iterations_str, salt_hex, hash_hex = rest.split("$") + iterations = int(iterations_str) + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(hash_hex) + actual = hashlib.pbkdf2_hmac(sub_algo, password.encode("utf-8"), salt, iterations) + return hmac.compare_digest(actual, expected) + except Exception: + return False + + +# --- HTML Templates (Material 3; styles, icons and scripts are the public site's, under /assets/site/) --- + +ASSETS = "/assets/site" +ICONS = ASSETS + "/icons.svg" + + +def icon(name: str) -> str: + return f'' + + +def flower(name: str, size: str = "", tone: str = "") -> str: + classes = "flower" + (f" flower--{size}" if size else "") + (f" flower--{tone}" if tone else "") + return f'{icon(name)}' + + +def alert(kind: str, message: str) -> str: + if not message: + return "" + name, role = ("warning", "alert") if kind == "error" else ("check-circle", "status") + return f'
{icon(name)}{html.escape(message)}
' + + +def field(field_id: str, label: str, kind: str = "text", autocomplete: str = "off", required: bool = True, autofocus: bool = False) -> str: + return ( + f'
' + f'
' + ) + + +def render_html_page(title: str, body_html: str, actions_html: str = "") -> str: + return f""" + + + + + + + + + {html.escape(title)} + + + + + + + + +
+
+ + + LihonT 2.0 + +
+ + {actions_html} +
+
+
+ +
+
+ {body_html} +
+
+ +
+
+ +
+
+ +""" + + +def logout_button() -> str: + return f'{icon("logout")}Выйти' + + +def render_message_page(title: str, heading: str, text: str, status_icon: str = "warning") -> str: + body = f""" +
+ {flower(status_icon, "lg", "tertiary")} +

{html.escape(heading)}

+

{html.escape(text)}

+ {icon("arrow-back")}На главную +
+ """ + return render_html_page(title, body) + + +def render_login_page(error: str = "") -> str: + body = f""" +
+ {flower("lock", "lg")} +

Вход в LihonT 2.0

+

Закрытое тестирование. Войди логином и паролем, которые выдал автор.

+
+ + {alert("error", error)} + +
+

Вход в учётную запись

+
+ {field("username", "Логин", autocomplete="username", autofocus=True)} + {field("password", "Пароль", "password", "current-password")} + +
+
+ """ + return render_html_page("LihonT 2.0 — Вход", body) + + +def is_sha256(value: str) -> bool: + return len(value) == 64 and all(c in "0123456789abcdef" for c in value) + + +def render_user_dashboard(user: sqlite3.Row, apk_stat: dict, message: str = "") -> str: + size_mb = f"{apk_stat.get('size', 0) / (1024 * 1024):.1f} МБ" + sha = apk_stat.get("sha256", "—") + copy = ( + f'' + if is_sha256(sha) else "" + ) + + body = f""" +
+ {flower("music-note", "lg")} + Release v2.0 Native +

Твоя сборка LihonT 2.0

+

Ты вошёл как {html.escape(user['username'])}.

+
+ + {alert("success", message)} + + +
+
+ {flower("folder-zip", "sm")} +

lihont2.apk

+
+

База TikTok 47.1.3 (arm64-v8a). Полный редизайн настроек в Material 3 Expressive, фестончатые бейджи, выбор любого региона без ограничений.

+ +
+
+ Размер файла + {size_mb} +
+
+ Архитектура + arm64-v8a +
+
+ +
+
+ Контрольная сумма SHA-256 + {copy} +
+ {html.escape(sha)} +
+ + {icon("download")}Скачать lihont2.apk +

Загрузка защищена авторизацией вашей учётной записи

+
+ """ + return render_html_page("LihonT 2.0 — Загрузка", body, logout_button()) + + +def render_admin_dashboard(user: sqlite3.Row, users: list[sqlite3.Row], downloads: list[sqlite3.Row], apk_stat: dict, message: str = "", error: str = "") -> str: + size_mb = f"{apk_stat.get('size', 0) / (1024 * 1024):.1f} МБ" + sha = apk_stat.get("sha256", "—") + + users_rows = [] + for u in users: + is_me = (u["id"] == user["id"]) + role_chip = ( + f'{icon("admin-panel-settings")}Админ' + if u["is_admin"] else 'Тестер' + ) + note = f' · {html.escape(u["note"])}' if u["note"] else "" + if is_me: + action = 'Вы' + else: + question = html.escape(f"Удалить пользователя {u['username']}?", quote=True) + action = f""" +
+ + +
+ """ + users_rows.append(f""" +
  • + + + {html.escape(u['username'])} + Создан: {html.escape(u['created_at'][:16])}{note} + + {role_chip}{action} +
  • + """) + + users_list_html = ( + '
      ' + "".join(users_rows) + "
    " + if users_rows else '

    Нет пользователей

    ' + ) + + body = f""" +
    + {flower("admin-panel-settings", "lg")} +

    Панель управления

    +

    Администратор: {html.escape(user['username'])}

    +
    + + {alert("success", message)} + {alert("error", error)} + + + + + Скачать lihont2.apk + {size_mb} · SHA-256 {html.escape(sha[:16])}… + + {icon("download")} + + + +
    +
    + {field("username", "Логин тестера")} + {field("password", "Пароль тестера")} + {field("note", "Заметка (для кого аккаунт)", required=False)} + +
    +
    + + +
    + {users_list_html} +
    + """ + return render_html_page("LihonT 2.0 — Панель администратора", body, logout_button()) + + +# --- HTTP Request Handler --- + +class LihonTHandler(http.server.BaseHTTPRequestHandler): + db_path: str = DEFAULT_DB_PATH + apk_path: str = APK_FILE_PATH + + _cached_stat: tuple[float, int, str] = (0.0, 0, "") + + def get_apk_stat(self) -> dict: + p = Path(self.apk_path) + if not p.exists(): + return {"size": 0, "sha256": "Файл еще не загружен"} + try: + st = p.stat() + size = st.st_size + mtime = st.st_mtime + cached_mtime, cached_size, cached_sha = LihonTHandler._cached_stat + if cached_mtime == mtime and cached_size == size and cached_sha: + return {"size": size, "sha256": cached_sha} + h = hashlib.sha256() + with open(p, "rb") as f: + while chunk := f.read(1048576): + h.update(chunk) + sha = h.hexdigest() + LihonTHandler._cached_stat = (mtime, size, sha) + return {"size": size, "sha256": sha} + except Exception as e: + return {"size": 0, "sha256": str(e)} + + def get_current_user(self) -> sqlite3.Row | None: + cookie_header = self.headers.get("Cookie") + if not cookie_header: + return None + cookies = http.cookies.SimpleCookie(cookie_header) + if SESSION_COOKIE_NAME not in cookies: + return None + token = cookies[SESSION_COOKIE_NAME].value + now = datetime.datetime.utcnow().isoformat() + with get_db(self.db_path) as conn: + row = conn.execute(""" + SELECT u.* FROM users u + JOIN sessions s ON u.id = s.user_id + WHERE s.session_id = ? AND s.expires_at > ? + """, (token, now)).fetchone() + return row + + def create_session(self, user_id: int) -> str: + token = secrets.token_hex(32) + created_at = datetime.datetime.utcnow() + expires_at = created_at + datetime.timedelta(days=SESSION_DURATION_DAYS) + with get_db(self.db_path) as conn: + conn.execute( + "INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", + (token, user_id, created_at.isoformat(), expires_at.isoformat()) + ) + conn.execute("UPDATE users SET last_login = ? WHERE id = ?", (created_at.isoformat(), user_id)) + conn.commit() + return token + + def delete_session(self) -> None: + cookie_header = self.headers.get("Cookie") + if not cookie_header: + return + cookies = http.cookies.SimpleCookie(cookie_header) + if SESSION_COOKIE_NAME in cookies: + token = cookies[SESSION_COOKIE_NAME].value + with get_db(self.db_path) as conn: + conn.execute("DELETE FROM sessions WHERE session_id = ?", (token,)) + conn.commit() + + def send_html(self, content: str, status: int = 200, cookie: str | None = None) -> None: + body = content.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + if cookie: + self.send_header("Set-Cookie", cookie) + self.end_headers() + self.wfile.write(body) + + def redirect(self, location: str, cookie: str | None = None) -> None: + self.send_response(303) + self.send_header("Location", location) + if cookie: + self.send_header("Set-Cookie", cookie) + self.end_headers() + + def parse_post_data(self) -> dict[str, str]: + length = int(self.headers.get("Content-Length", 0)) + if length > 65536: + return {} + raw = self.rfile.read(length).decode("utf-8", errors="replace") + parsed = urllib.parse.parse_qs(raw) + return {k: v[0] for k, v in parsed.items()} + + def do_HEAD(self) -> None: + self.do_GET() + + def do_GET(self) -> None: + parsed_url = urllib.parse.urlparse(self.path) + path = parsed_url.path.rstrip("/") + if not path: + path = "/" + + # Normalize relative to BASE_PATH + if path == BASE_PATH or path == "" or path == "/": + user = self.get_current_user() + if not user: + self.send_html(render_login_page()) + return + apk_stat = self.get_apk_stat() + if user["is_admin"]: + with get_db(self.db_path) as conn: + users = conn.execute("SELECT * FROM users ORDER BY is_admin DESC, id DESC").fetchall() + downloads = conn.execute("SELECT * FROM downloads ORDER BY id DESC LIMIT 50").fetchall() + self.send_html(render_admin_dashboard(user, users, downloads, apk_stat)) + else: + self.send_html(render_user_dashboard(user, apk_stat)) + return + + if path == f"{BASE_PATH}/logout": + self.delete_session() + cookie = f"{SESSION_COOKIE_NAME}=; Path={BASE_PATH}; Max-Age=0; HttpOnly; SameSite=Lax" + self.redirect(f"{BASE_PATH}/", cookie) + return + + if path == f"{BASE_PATH}/download": + user = self.get_current_user() + if not user: + # Must be logged in to download lihont2.apk! + self.redirect(f"{BASE_PATH}/") + return + + apk_file = Path(self.apk_path) + if not apk_file.exists(): + self.send_html(render_message_page("LihonT 2.0 — Ошибка", "Файл ещё загружается", "Файл lihont2.apk ещё загружается на сервер. Попробуйте через пару минут.", "info"), status=404) + return + + # Log download in database + client_ip = self.headers.get("X-Real-IP") or self.headers.get("X-Forwarded-For") or self.client_address[0] + ua = self.headers.get("User-Agent", "") + now = datetime.datetime.utcnow().isoformat() + with get_db(self.db_path) as conn: + conn.execute( + "INSERT INTO downloads (user_id, username, ip, user_agent, downloaded_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], user["username"], client_ip, ua, now) + ) + conn.commit() + + # Stream APK file + file_size = apk_file.stat().st_size + self.send_response(200) + self.send_header("Content-Type", "application/vnd.android.package-archive") + self.send_header("Content-Disposition", 'attachment; filename="lihont2.apk"') + self.send_header("Content-Length", str(file_size)) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + + with open(apk_file, "rb") as f: + while chunk := f.read(65536): + try: + self.wfile.write(chunk) + except (BrokenPipeError, ConnectionResetError): + break + return + + self.send_html(render_message_page("LihonT 2.0 — 404", "Страница не найдена", "Такой страницы здесь нет. Вернись на главную портала.", "search"), status=404) + + def do_POST(self) -> None: + parsed_url = urllib.parse.urlparse(self.path) + path = parsed_url.path.rstrip("/") + form = self.parse_post_data() + + if path == f"{BASE_PATH}/login": + username = form.get("username", "").strip() + password = form.get("password", "").strip() + with get_db(self.db_path) as conn: + user = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() + if user and verify_password(password, user["password_hash"]): + token = self.create_session(user["id"]) + cookie = f"{SESSION_COOKIE_NAME}={token}; Path={BASE_PATH}; Max-Age={SESSION_DURATION_DAYS * 86400}; HttpOnly; SameSite=Lax" + self.redirect(f"{BASE_PATH}/", cookie) + return + else: + self.send_html(render_login_page("Неверный логин или пароль"), status=401) + return + + if path == f"{BASE_PATH}/admin/create-user": + user = self.get_current_user() + if not user or not user["is_admin"]: + self.redirect(f"{BASE_PATH}/") + return + new_user = form.get("username", "").strip() + new_pass = form.get("password", "").strip() + note = form.get("note", "").strip() + if not new_user or not new_pass: + apk_stat = self.get_apk_stat() + with get_db(self.db_path) as conn: + users = conn.execute("SELECT * FROM users ORDER BY is_admin DESC, id DESC").fetchall() + downloads = conn.execute("SELECT * FROM downloads ORDER BY id DESC LIMIT 50").fetchall() + self.send_html(render_admin_dashboard(user, users, downloads, apk_stat, error="Логин и пароль обязательны"), status=400) + return + + hashed = hash_password(new_pass) + now = datetime.datetime.utcnow().isoformat() + try: + with get_db(self.db_path) as conn: + conn.execute( + "INSERT INTO users (username, password_hash, is_admin, note, created_at) VALUES (?, ?, 0, ?, ?)", + (new_user, hashed, note, now) + ) + conn.commit() + users = conn.execute("SELECT * FROM users ORDER BY is_admin DESC, id DESC").fetchall() + downloads = conn.execute("SELECT * FROM downloads ORDER BY id DESC LIMIT 50").fetchall() + apk_stat = self.get_apk_stat() + self.send_html(render_admin_dashboard(user, users, downloads, apk_stat, message=f"Учетная запись '{new_user}' успешно создана!")) + except sqlite3.IntegrityError: + with get_db(self.db_path) as conn: + users = conn.execute("SELECT * FROM users ORDER BY is_admin DESC, id DESC").fetchall() + downloads = conn.execute("SELECT * FROM downloads ORDER BY id DESC LIMIT 50").fetchall() + apk_stat = self.get_apk_stat() + self.send_html(render_admin_dashboard(user, users, downloads, apk_stat, error=f"Пользователь '{new_user}' уже существует"), status=400) + return + + if path == f"{BASE_PATH}/admin/delete-user": + user = self.get_current_user() + if not user or not user["is_admin"]: + self.redirect(f"{BASE_PATH}/") + return + try: + target_id = int(form.get("user_id", 0)) + except ValueError: + target_id = 0 + + with get_db(self.db_path) as conn: + if target_id != user["id"]: + conn.execute("DELETE FROM users WHERE id = ? AND is_admin = 0", (target_id,)) + conn.commit() + users = conn.execute("SELECT * FROM users ORDER BY is_admin DESC, id DESC").fetchall() + downloads = conn.execute("SELECT * FROM downloads ORDER BY id DESC LIMIT 50").fetchall() + apk_stat = self.get_apk_stat() + self.send_html(render_admin_dashboard(user, users, downloads, apk_stat, message="Пользователь удален.")) + return + + self.redirect(f"{BASE_PATH}/") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default=DEFAULT_HOST) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--db", default=DEFAULT_DB_PATH) + parser.add_argument("--apk", default=APK_FILE_PATH) + args = parser.parse_args() + + init_db(args.db) + LihonTHandler.db_path = args.db + LihonTHandler.apk_path = args.apk + + class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + allow_reuse_address = True + + server = ThreadedHTTPServer((args.host, args.port), LihonTHandler) + print(f"LihonT 2.0 Server started on http://{args.host}:{args.port}{BASE_PATH}/") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down server.") + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/src/web/test_portal.py b/src/web/test_portal.py new file mode 100644 index 0000000..c7a9c68 --- /dev/null +++ b/src/web/test_portal.py @@ -0,0 +1,69 @@ +import os +import urllib.request +import urllib.parse +import http.cookiejar + +ADMIN_USER = os.environ["LIHONT2_ADMIN_USER"] +ADMIN_PASSWORD = os.environ["LIHONT2_ADMIN_PASSWORD"] + +cookie_jar = http.cookiejar.CookieJar() +opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookie_jar)) + +# 1. Test unauthenticated download redirect +try: + req = urllib.request.Request('http://127.0.0.1:18750/lihont2/download') + resp = opener.open(req) + print('Unauth download redirected to:', resp.geturl()) +except Exception as e: + print('Unauth download error:', e) + +# 2. Test login +data = urllib.parse.urlencode({'username': ADMIN_USER, 'password': ADMIN_PASSWORD}).encode() +req = urllib.request.Request('http://127.0.0.1:18750/lihont2/login', data=data) +resp = opener.open(req) +print('Login URL after redirect:', resp.geturl()) +body = resp.read().decode('utf-8', errors='replace') +print('Admin panel present in HTML:', 'Панель управления' in body) +print('Admin username in HTML:', ADMIN_USER in body) +print('APK SHA present in HTML:', '6b55df52477e0c60' in body) + +# 3. Test authenticated download +req = urllib.request.Request('http://127.0.0.1:18750/lihont2/download') +resp = opener.open(req) +print('Auth download Content-Disposition:', resp.headers.get('Content-Disposition')) +print('Auth download Content-Length:', resp.headers.get('Content-Length')) +print('Auth download Content-Type:', resp.headers.get('Content-Type')) +first_bytes = resp.read(4) +print('PK zip header valid:', first_bytes == b'PK\x03\x04') + +# 4. Test creating a tester user +create_data = urllib.parse.urlencode({'username': 'tester_alpha', 'password': 'TesterPassword123!', 'note': 'First QA Tester'}).encode() +req = urllib.request.Request('http://127.0.0.1:18750/lihont2/admin/create-user', data=create_data) +resp = opener.open(req) +body_after_create = resp.read().decode('utf-8', errors='replace') +print('Tester user created successfully:', 'tester_alpha' in body_after_create) + +# 5. Test logging in as the tester user +tester_jar = http.cookiejar.CookieJar() +tester_opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(tester_jar)) +tester_login_data = urllib.parse.urlencode({'username': 'tester_alpha', 'password': 'TesterPassword123!'}).encode() +req = urllib.request.Request('http://127.0.0.1:18750/lihont2/login', data=tester_login_data) +resp = tester_opener.open(req) +tester_body = resp.read().decode('utf-8', errors='replace') +print('Tester dashboard loaded:', 'Release v2.0 Native' in tester_body) +print('Tester CANNOT see admin panel:', 'Панель управления' not in tester_body) +print('Tester CAN see download button:', 'Скачать lihont2.apk' in tester_body) + +# 6. Test tester download +req = urllib.request.Request('http://127.0.0.1:18750/lihont2/download') +resp = tester_opener.open(req) +print('Tester download size:', resp.headers.get('Content-Length')) + +# 7. Clean up tester user +del_data = urllib.parse.urlencode({'user_id': 2}).encode() +req = urllib.request.Request('http://127.0.0.1:18750/lihont2/admin/delete-user', data=del_data) +resp = opener.open(req) +body_after_del = resp.read().decode('utf-8', errors='replace') +print('Tester user deleted successfully:', 'tester_alpha' not in body_after_del) + +print('\nALL VERIFICATION TESTS COMPLETED SUCCESSFULLY!') diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2e94af7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""LihonT 2 Python test package.""" diff --git a/tests/java/lihont/two/FlagsTest.java b/tests/java/lihont/two/FlagsTest.java new file mode 100644 index 0000000..4fe4537 --- /dev/null +++ b/tests/java/lihont/two/FlagsTest.java @@ -0,0 +1,116 @@ +package lihont.two; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Set; +import java.util.TreeSet; +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsCodec; +import lihont.two.settings.SettingsHost; +import lihont.two.settings.SettingsRepository; + +/** + * Runs LihonTFlags on a real JVM. Arguments: a scenario, then "group:key:forced" entries + * (group is banners or reposts), taken from lihont/profile_features.py by the caller. + */ +public final class FlagsTest { + private static final class Memory implements SettingsRepository.Storage { + String document; + + @Override public String read() { return document; } + + @Override public void write(String value) { document = value; } + } + + public static void main(String[] args) throws Exception { + String scenario = args[0]; + switch (scenario) { + case "matrix": matrix(args); break; + case "snapshot": snapshot(args); break; + case "unavailable": unavailable(args); break; + case "surface": surface(); return; + default: throw new AssertionError("Unknown scenario " + scenario); + } + System.out.println("PASS " + scenario); + } + + private static SettingsRepository install(Memory storage, boolean banners, boolean reposts) throws IOException { + storage.document = SettingsCodec.encode( + ModSettings.defaults().withProfileBanners(banners).withCommentReposts(reposts)); + SettingsRepository repository = new SettingsRepository(storage); + SettingsHost.install(repository); + return repository; + } + + private static void matrix(String[] args) throws Exception { + for (boolean banners : new boolean[] {true, false}) { + for (boolean reposts : new boolean[] {true, false}) { + install(new Memory(), banners, reposts); + for (int i = 1; i < args.length; i++) { + String[] entry = args[i].split(":"); + boolean on = entry[0].equals("banners") ? banners : reposts; + check(on, entry[1], Integer.parseInt(entry[2])); + } + } + } + } + + private static void check(boolean on, String key, int forced) throws Exception { + Method integer = LihonTFlags.class.getMethod(key, int.class); + Method bool = LihonTFlags.class.getMethod(key, boolean.class); + for (int original : new int[] {0, 2, 5, -1}) { + int expected = on ? forced : original; + equal(expected, (Integer) integer.invoke(null, original), key + " int original " + original + " on=" + on); + } + for (boolean original : new boolean[] {false, true}) { + boolean expected = on || original; + equal(expected, (Boolean) bool.invoke(null, original), key + " bool original " + original + " on=" + on); + } + } + + /** A saved change waits for the next process; a new process then sees it. */ + private static void snapshot(String[] args) throws Exception { + Memory storage = new Memory(); + SettingsRepository first = install(storage, true, true); + first.save(first.current().withProfileBanners(false)); + check(true, "profile_left_align", 1); + if (!first.restartRequired()) throw new AssertionError("Saved change must ask for a restart"); + SettingsRepository second = new SettingsRepository(storage); + SettingsHost.install(second); + check(false, "profile_left_align", 1); + check(true, "repost_support_digg", 1); + if (second.restartRequired()) throw new AssertionError("A fresh process has nothing pending"); + } + + /** Before the application exists nothing can be read: behave as with the defaults. */ + private static void unavailable(String[] args) throws Exception { + SettingsHost.install(null); + for (int i = 1; i < args.length; i++) { + String[] entry = args[i].split(":"); + check(true, entry[1], Integer.parseInt(entry[2])); + } + } + + private static void surface() { + Set found = new TreeSet(); + for (Method method : LihonTFlags.class.getDeclaredMethods()) { + if (!Modifier.isPublic(method.getModifiers())) continue; + found.add(method.getName() + "(" + code(method.getParameterTypes()[0]) + ")" + + code(method.getReturnType())); + } + for (String signature : found) System.out.println(signature); + } + + private static String code(Class type) { + if (type == int.class) return "I"; + if (type == boolean.class) return "Z"; + throw new AssertionError("Unexpected type " + type); + } + + private static void equal(Object expected, Object actual, String message) { + if (!expected.equals(actual)) { + throw new AssertionError(message + ": expected " + expected + " but was " + actual); + } + } +} diff --git a/tests/java/lihont/two/settings/IconRenderTest.java b/tests/java/lihont/two/settings/IconRenderTest.java new file mode 100644 index 0000000..25f3505 --- /dev/null +++ b/tests/java/lihont/two/settings/IconRenderTest.java @@ -0,0 +1,183 @@ +package lihont.two.settings; + +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.AffineTransform; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Path2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.File; +import javax.imageio.ImageIO; + +/** Parses every icon path on a desktop JVM and checks, or draws, the resulting shapes. */ +public final class IconRenderTest { + private static final String[] NAMES = { + "BACK", "CLOSE", "CHECK", "CHEVRON_RIGHT", "SEARCH", "BANNER", "REPOST", "SUN", "MOON", "INFO", "NOTE", + "SEND", "TIKTOK", "ADD_PHOTO", "DELETE", "BOTTOM_BAR", "DOWNLOAD", "AD_OFF", "LIVE", "SHOP", + }; + + /** Where each icon appears in the app, for the preview sheet. */ + private static final String[] USES = { + "Назад (шапка)", "Закрыть (окна)", "Галочка выбора", "Стрелка в строках", "Поиск страны", + "Баннеры профиля", "Репост комментариев", "Тема: светлая", "Тема: тёмная", "Версия мода", + "Строка в настройках TikTok", "Telegram-канал", "База TikTok", "Свой значок", "Удалить значок", "Нижнее меню", "Скачивание", + "Убрать рекламу", "Убрать эфиры", "Убрать «Магазин»", + }; + + private static Path2D.Float shape(int kind) { + final Path2D.Float path = new Path2D.Float(Path2D.WIND_NON_ZERO); + Md3Icon.build(kind, new SvgPath.Sink() { + @Override public void moveTo(float x, float y) { path.moveTo(x, y); } + + @Override public void lineTo(float x, float y) { path.lineTo(x, y); } + + @Override public void cubicTo(float x1, float y1, float x2, float y2, float x, float y) { + path.curveTo(x1, y1, x2, y2, x, y); + } + + @Override public void close() { path.closePath(); } + }); + return path; + } + + private static Path2D.Float shape(String data) { + final Path2D.Float path = new Path2D.Float(Path2D.WIND_NON_ZERO); + SvgPath.parse(data, new SvgPath.Sink() { + @Override public void moveTo(float x, float y) { path.moveTo(x, y); } + + @Override public void lineTo(float x, float y) { path.lineTo(x, y); } + + @Override public void cubicTo(float x1, float y1, float x2, float y2, float x, float y) { + path.curveTo(x1, y1, x2, y2, x, y); + } + + @Override public void close() { path.closePath(); } + }); + return path; + } + + public static void main(String[] args) throws Exception { + switch (args[0]) { + case "bounds": bounds(); break; + case "tokens": tokens(); break; + case "render": render(new File(args[1])); return; + default: throw new AssertionError("Unknown scenario " + args[0]); + } + System.out.println("PASS " + args[0]); + } + + private static void bounds() { + if (Md3Icon.PATHS.length != NAMES.length) throw new AssertionError("Icon table and names differ"); + if (Md3Icon.GRID.length != NAMES.length || Md3Icon.ORIGIN_Y.length != NAMES.length) { + throw new AssertionError("Grid tables and names differ"); + } + for (int i = 0; i < NAMES.length; i++) { + Rectangle2D box = shape(i).getBounds2D(); + if (box.getMinX() < -0.01 || box.getMinY() < -0.01 || box.getMaxX() > 24.01 || box.getMaxY() > 24.01) { + throw new AssertionError(NAMES[i] + " leaves the 24 unit grid: " + box); + } + if (Math.max(box.getWidth(), box.getHeight()) < 10) { + throw new AssertionError(NAMES[i] + " is too small to read as an icon: " + box); + } + double ink = coverage(shape(i)); + if (ink < 0.03 || ink > 0.8) throw new AssertionError(NAMES[i] + " ink coverage " + ink); + } + } + + /** Share of the 24x24 grid the icon paints, found by rasterising it. */ + private static double coverage(Path2D.Float path) { + int size = 96; + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(Color.BLACK); + g.fill(new Path2D.Float(path, AffineTransform.getScaleInstance(size / 24.0, size / 24.0))); + g.dispose(); + int painted = 0; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + if ((image.getRGB(x, y) >>> 24) > 127) painted++; + } + } + return painted / (double) (size * size); + } + + private static void tokens() { + // Shortened forms: no separator before a minus or a second dot, implicit repeats, relative moves. + // After the z the pen is back at (0.5, 0.5), so "m4 0" starts the second shape at x = 4.5. + Rectangle2D box = shape("M.5.5h1-1v1zm4 0l1 1-1 1z").getBounds2D(); + near(0.5, box.getMinX()); + near(5.5, box.getMaxX()); + near(2.5, box.getMaxY()); + // Arc flags written without separators, as Material icons do: a2 2 0 00-2 2. + Rectangle2D arc = shape("M4 4v2a2 2 0 00 2 2h2").getBounds2D(); + near(4.0, arc.getMinX()); + near(8.0, arc.getMaxX()); + near(8.0, arc.getMaxY()); + // A half circle of radius 5 drawn with an arc spans 10 units. + Rectangle2D half = shape("M0 5a5 5 0 0010 0").getBounds2D(); + near(10.0, half.getWidth()); + near(5.0, half.getHeight()); + // Smooth quadratics, which Material Symbols use for rounded ends: T repeats the last control point. + Rectangle2D smooth = shape("M0 0q4 0 4 4T8 8").getBounds2D(); + near(8.0, smooth.getMaxX()); + boolean rejected = false; + try { shape("M1 1 5"); } catch (IllegalArgumentException expected) { rejected = true; } + if (!rejected) throw new AssertionError("A lone coordinate must be rejected"); + } + + private static void near(double expected, double actual) { + if (Math.abs(expected - actual) > 0.15) { + throw new AssertionError("expected " + expected + " but was " + actual); + } + } + + /** A flower badge like the one in the app: a disc and eight petals. */ + private static void flower(Graphics2D g, double cx, double cy, double r) { + g.fill(new Ellipse2D.Double(cx - r * 0.70, cy - r * 0.70, r * 1.40, r * 1.40)); + double petal = r * 0.38, dist = r * 0.62; + for (int i = 0; i < 8; i++) { + double angle = i * (2 * Math.PI / 8) + Math.PI / 8; + g.fill(new Ellipse2D.Double(cx + Math.cos(angle) * dist - petal, cy + Math.sin(angle) * dist - petal, + petal * 2, petal * 2)); + } + } + + private static void render(File out) throws Exception { + int columns = 4, tileW = 240, tileH = 190, rows = (NAMES.length + columns - 1) / columns; + BufferedImage image = new BufferedImage(tileW * columns, tileH * rows, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setColor(new Color(0x1a1b21)); + g.fillRect(0, 0, image.getWidth(), image.getHeight()); + Font number = new Font("Dialog", Font.BOLD, 26), label = new Font("Dialog", Font.PLAIN, 16); + for (int i = 0; i < NAMES.length; i++) { + int ox = (i % columns) * tileW, oy = (i / columns) * tileH; + g.setColor(new Color(0x2b2930)); + g.fillRoundRect(ox + 8, oy + 8, tileW - 16, tileH - 16, 28, 28); + g.setColor(new Color(0x4a4458)); + flower(g, ox + tileW / 2.0, oy + 76, 64); + g.setColor(new Color(0xe8def8)); + double scale = 3.6; + AffineTransform move = new AffineTransform(); + move.translate(ox + tileW / 2.0 - 12 * scale, oy + 76 - 12 * scale); + move.scale(scale, scale); + g.fill(new Path2D.Float(shape(i), move)); + g.setFont(number); + g.setColor(Color.WHITE); + g.drawString(String.valueOf(i + 1), ox + 22, oy + 40); + g.setFont(label); + g.setColor(new Color(0xc9c5d0)); + FontMetrics metrics = g.getFontMetrics(); + g.drawString(USES[i], ox + (tileW - metrics.stringWidth(USES[i])) / 2, oy + tileH - 22); + } + g.dispose(); + ImageIO.write(image, "png", out); + System.out.println("wrote " + out); + } +} diff --git a/tests/java/lihont/two/settings/IconStoreTest.java b/tests/java/lihont/two/settings/IconStoreTest.java new file mode 100644 index 0000000..5a88041 --- /dev/null +++ b/tests/java/lihont/two/settings/IconStoreTest.java @@ -0,0 +1,129 @@ +package lihont.two.settings; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +/** The custom-icon store and crop geometry on a desktop JVM: java.io only, no Android classes. */ +public final class IconStoreTest { + private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3}; + + public static void main(String[] args) throws Exception { + String name = args[0]; + File dir = Files.createTempDirectory("lihont-icon-store").toFile(); + try { + switch (name) { + case "crop": crop(); break; + case "names": names(); break; + case "store": store(new File(dir, "icons")); break; + case "damaged": damaged(new File(dir, "icons")); break; + default: throw new AssertionError("Unknown scenario " + name); + } + } finally { + delete(dir); + } + System.out.println("PASS " + name); + } + + private static void crop() { + check(IconCrop.VISIBLE_PIXELS == 288, "the launcher shows 72 of 108 dp"); + int[] wide = IconCrop.centerSquare(4000, 3000); + check(wide[0] == 500 && wide[1] == 0 && wide[2] == 3000, "landscape is cut at the sides"); + int[] tall = IconCrop.centerSquare(3000, 4001); + check(tall[0] == 0 && tall[1] == 500 && tall[2] == 3000, "portrait is cut top and bottom"); + int[] square = IconCrop.centerSquare(512, 512); + check(square[0] == 0 && square[1] == 0 && square[2] == 512, "a square stays whole"); + check(IconCrop.sampleSize(4000, 3000, 432) == 4, "3000 / 4 = 750 still covers 432, 3000 / 8 = 375 does not"); + check(IconCrop.sampleSize(500, 500, 432) == 1, "small pictures are not shrunk"); + check(IconCrop.sampleSize(864, 5000, 432) == 2, "the shorter side decides"); + try { + IconCrop.centerSquare(0, 10); + throw new AssertionError("an empty picture must be refused"); + } catch (IllegalArgumentException expected) { + // refused + } + } + + private static void names() { + check("TikTok".equals(CustomIconStore.cleanName(null)), "null falls back to the default"); + check("TikTok".equals(CustomIconStore.cleanName(" \t\n ")), "blank falls back to the default"); + check("Мой значок".equals(CustomIconStore.cleanName(" Мой \n значок ")), "whitespace collapses to single spaces"); + check("a b".equals(CustomIconStore.cleanName("a" + (char) 1 + "b")), "control characters become gaps"); + String long50 = "x".repeat(50); + check(CustomIconStore.cleanName(long50).length() == CustomIconStore.MAX_NAME, "names are capped"); + check(CustomIconStore.validId("0a1b2c3d"), "a generated id is valid"); + check(!CustomIconStore.validId("../etc/p") && !CustomIconStore.validId("0A1B2C3D") && !CustomIconStore.validId("1234567") + && !CustomIconStore.validId(null), "anything else is not an id"); + } + + private static void store(File dir) throws IOException { + CustomIconStore store = new CustomIconStore(dir); + check(store.list().isEmpty(), "a missing folder is an empty list"); + + CustomIconStore.Entry first = store.add(PNG, "Первый"); + CustomIconStore.Entry second = store.add(PNG, " Second\ticon "); + List entries = store.list(); + check(entries.size() == 2, "two icons stored"); + check("Первый".equals(entries.get(0).name) && "Second icon".equals(entries.get(1).name), "names survive a round trip, in order"); + check(entries.get(0).id.equals(first.id) && first.image.isFile(), "the picture is on disk"); + check(!first.id.equals(second.id), "ids are distinct"); + + check(!store.remove("zzzzzzzz") && !store.remove("../../x"), "unknown ids are refused"); + check(store.remove(first.id), "removing a stored icon works"); + check(!first.image.exists(), "its picture is deleted too"); + check(store.list().size() == 1 && store.list().get(0).id.equals(second.id), "the other one stays"); + + try { + store.add(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9}, "not a png"); + throw new AssertionError("a non-PNG must be refused"); + } catch (IOException expected) { + // refused + } + try { + store.add(new byte[CustomIconStore.MAX_PNG_BYTES + 1], "huge"); + throw new AssertionError("an oversized picture must be refused"); + } catch (IOException expected) { + // refused + } + + for (int i = 1; i < CustomIconStore.MAX_ICONS; i++) store.add(PNG, "n" + i); + check(store.list().size() == CustomIconStore.MAX_ICONS, "the folder fills up to the cap"); + try { + store.add(PNG, "one too many"); + throw new AssertionError("the cap must hold"); + } catch (IOException expected) { + // refused + } + check(store.list().size() == CustomIconStore.MAX_ICONS, "a refused add changes nothing"); + } + + private static void damaged(File dir) throws IOException { + CustomIconStore store = new CustomIconStore(dir); + CustomIconStore.Entry kept = store.add(PNG, "kept"); + File lost = store.add(PNG, "lost").image; + check(lost.delete(), "simulate a picture that vanished"); + String index = "no tab here\n" + + "../../evil\tbad id\n" + + "0a1b2c3d\tpicture missing\n" + + kept.id + "\tkept\n" + + "\n"; + Files.write(new File(dir, "index.txt").toPath(), index.getBytes(StandardCharsets.UTF_8)); + List entries = store.list(); + check(entries.size() == 1 && entries.get(0).id.equals(kept.id), "only the sound entry survives: " + entries.size()); + check(store.add(PNG, "after damage").image.isFile(), "the store keeps working after damage"); + } + + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static void delete(File file) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) delete(child); + } + file.delete(); + } +} diff --git a/tests/java/lihont/two/settings/SettingsModelTest.java b/tests/java/lihont/two/settings/SettingsModelTest.java new file mode 100644 index 0000000..cabfb36 --- /dev/null +++ b/tests/java/lihont/two/settings/SettingsModelTest.java @@ -0,0 +1,369 @@ +package lihont.two.settings; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; + +/** Behavioral tests without Android framework mocks or third-party test dependencies. */ +public final class SettingsModelTest { + private interface Action { void run() throws Exception; } + + public static void main(String[] args) throws Exception { + if (args.length != 1) throw new AssertionError("Expected a test scenario"); + switch (args[0]) { + case "defaults": defaults(); break; + case "countries": countries(); break; + case "validation": validation(); break; + case "restart": restart(); break; + case "codec": codec(); break; + case "invalid": invalid(); break; + case "persistence": persistence(); break; + case "snapshot": snapshot(); break; + case "reset": reset(); break; + case "atomicImport": atomicImport(); break; + case "ioFailures": ioFailures(); break; + case "corruptStorage": corruptStorage(); break; + default: throw new AssertionError("Unknown test scenario"); + } + System.out.println("PASS " + args[0]); + } + + private static void defaults() { + ModSettings settings = ModSettings.defaults(); + equal("JP", settings.region(), "Japan is the initial selection"); + equal("jp", settings.regionIso(), "Runtime country code uses lowercase"); + check(settings.profileBanners(), "Banners default enabled"); + check(settings.commentReposts(), "Comment reposts default enabled"); + check(settings.md3(), "MD3 interface default enabled"); + check(settings.noWatermark(), "Watermark-free downloads default enabled"); + check(settings.hideAds(), "Ad removal default enabled"); + check(settings.hideLive(), "Live removal default enabled"); + check(!settings.hideShop(), "Shop tab removal default disabled"); + equal(ModSettings.Theme.SYSTEM, settings.theme(), "Follow system theme initially"); + check(!settings.needsBannerWarning(), "Japan requires no banner warning"); + check(settings.withRegion("AL").needsBannerWarning(), "Albania warns about banners"); + check(!settings.withRegion("AL").withRegion("JP").needsBannerWarning(), + "Returning to Japan clears warning"); + } + + private static void countries() throws Exception { + equal(249, ModSettings.COUNTRY_CODES.size(), "Full ISO 3166-1 list"); + equal(new HashSet(Arrays.asList(Locale.getISOCountries())), + ModSettings.COUNTRY_CODES, "Country set matches JDK ISO registry"); + Locale previous = Locale.getDefault(); + try { + Locale.setDefault(new Locale("tr", "TR")); + for (String country : ModSettings.COUNTRY_CODES) { + ModSettings value = ModSettings.defaults().withRegion(country); + equal(country.toLowerCase(Locale.ROOT), value.regionIso(), + "Country lowercasing is independent of device language"); + equal(value, SettingsCodec.decode(SettingsCodec.encode(value)), + "Every supported country survives export/import"); + equal(!"JP".equals(country), value.needsBannerWarning(), + "Only Japan suppresses region warning"); + } + } finally { + Locale.setDefault(previous); + } + expect(UnsupportedOperationException.class, () -> ModSettings.COUNTRY_CODES.add("ZZ")); + } + + private static void validation() throws Exception { + for (String country : new String[] {null, "", "jp", " JP", "JP ", "JPN", "ZZ", "XK"}) { + expect(IllegalArgumentException.class, () -> ModSettings.defaults().withRegion(country)); + } + expect(IllegalArgumentException.class, () -> ModSettings.defaults().withTheme(null)); + expect(IllegalArgumentException.class, () -> ModSettings.Theme.fromValue("DARK")); + expect(IllegalArgumentException.class, () -> ModSettings.Theme.fromValue(null)); + expect(IllegalArgumentException.class, () -> SettingsCodec.encode(null)); + expect(IllegalArgumentException.class, () -> new SettingsRepository(null)); + } + + private static void restart() throws Exception { + ModSettings active = ModSettings.defaults(); + check(!active.requiresRestartComparedTo(active), "Unchanged settings need no restart"); + check(!active.withTheme(ModSettings.Theme.DARK).requiresRestartComparedTo(active), + "Theme is available without process restart"); + check(active.withRegion("AL").requiresRestartComparedTo(active), "Region requires restart"); + check(active.withProfileBanners(false).requiresRestartComparedTo(active), + "Banner feature state requires restart"); + check(active.withCommentReposts(false).requiresRestartComparedTo(active), + "Comment feature state requires restart"); + check(active.withMd3(false).requiresRestartComparedTo(active), + "MD3 interface state requires restart"); + check(active.withNoWatermark(false).requiresRestartComparedTo(active), + "Download source requires restart"); + check(active.withHideAds(false).requiresRestartComparedTo(active), "Ad removal requires restart"); + check(active.withHideLive(false).requiresRestartComparedTo(active), "Live removal requires restart"); + check(active.withHideShop(true).requiresRestartComparedTo(active), "Shop tab removal requires restart"); + expect(IllegalArgumentException.class, () -> active.requiresRestartComparedTo(null)); + } + + private static void codec() { + String compact = document("US", false, true, "dark"); + ModSettings expected = new ModSettings("US", false, true, ModSettings.Theme.DARK); + equal(expected, SettingsCodec.decode(compact), "Portable compact JSON is accepted"); + String reordered = "{\"values\":{\"theme\":\"light\",\"commentReposts\":false," + + "\"region\":\"\\u004A\\u0050\",\"profileBanners\":true}," + + "\"schemaVersion\":1,\"format\":\"lihont.settings\"}"; + equal(new ModSettings("JP", true, false, ModSettings.Theme.LIGHT), + SettingsCodec.decode(reordered), "Object order and JSON unicode escapes are supported"); + for (ModSettings.Theme theme : ModSettings.Theme.values()) { + for (boolean banners : new boolean[] {true, false}) { + for (boolean reposts : new boolean[] {true, false}) { + ModSettings value = new ModSettings("AL", banners, reposts, theme); + equal(value, SettingsCodec.decode(SettingsCodec.encode(value)), + "Typed combination survives roundtrip"); + } + } + } + // files written before the MD3 switch existed have four values and mean "on" + check(SettingsCodec.decode(compact).md3(), "A file without md3 means on"); + ModSettings off = expected.withMd3(false); + equal(off, SettingsCodec.decode(SettingsCodec.encode(off)), "MD3 off survives roundtrip"); + check(!SettingsCodec.decode(SettingsCodec.encode(off)).md3(), "and stays off"); + check(SettingsCodec.encode(off).contains("\"md3\": false"), "Export carries the MD3 value"); + check(!SettingsCodec.encode(off).contains("bottomBar"), "Export no longer uses the old key"); + // the switch was called bottomBar when it only restyled the bottom bar: such files still decode + String legacy = compact.replace("\"theme\":", "\"bottomBar\":false,\"theme\":"); + check(!SettingsCodec.decode(legacy).md3(), "The old bottomBar key still sets the MD3 switch"); + boolean both = false; + try { + SettingsCodec.decode(compact.replace("\"theme\":", "\"md3\":true,\"bottomBar\":true,\"theme\":")); + } catch (IllegalArgumentException rejected) { + both = true; + } + check(both, "A file with both md3 and bottomBar is rejected"); + check(SettingsCodec.decode(compact).noWatermark(), "A file without noWatermark means on"); + ModSettings feedOff = expected.withHideAds(false).withHideLive(false).withHideShop(false); + check(SettingsCodec.decode(compact).hideAds() && SettingsCodec.decode(compact).hideLive() + && !SettingsCodec.decode(compact).hideShop(), + "A file without feed switches means ads and live off the feed, Shop tab kept"); + equal(feedOff, SettingsCodec.decode(SettingsCodec.encode(feedOff)), "Feed switches off survive roundtrip"); + ModSettings shopOn = expected.withHideShop(true); + equal(shopOn, SettingsCodec.decode(SettingsCodec.encode(shopOn)), "Shop tab removal on survives roundtrip"); + check(!SettingsCodec.decode(SettingsCodec.encode(feedOff)).hideLive(), "and stay off"); + check(SettingsCodec.encode(feedOff).contains("\"hideAds\": false") + && SettingsCodec.encode(feedOff).contains("\"hideLive\": false") + && SettingsCodec.encode(feedOff).contains("\"hideShop\": false"), "Export carries the feed switches"); + ModSettings plain = expected.withNoWatermark(false); + equal(plain, SettingsCodec.decode(SettingsCodec.encode(plain)), "Watermark switch off survives roundtrip"); + check(!SettingsCodec.decode(SettingsCodec.encode(plain)).noWatermark(), "and stays off"); + check(plain.withMd3(false).equals(SettingsCodec.decode(SettingsCodec.encode(plain.withMd3(false)))), + "Both optional switches together survive roundtrip"); + String exported = SettingsCodec.encode(expected); + check(exported.contains("\"schemaVersion\": 1"), "Export carries schema version"); + check(!exported.contains("uuid") && !exported.contains("token"), + "Export contains preferences, without account identifiers"); + } + + private static String[] invalidDocuments() { + String good = document("JP", true, true, "system"); + return new String[] { + null, "", " ", "{}", "[]", "null", "true", "1", + good + "{}", good + " trailing", good.substring(0, good.length() - 1), + good.replace("lihont.settings", "another.application"), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":0"), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":2"), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":\"1\""), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":true"), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":1.0"), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":1e0"), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":01"), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":2147483648"), + good.replace("\"schemaVersion\":1", "\"schemaVersion\":-1"), + good.replace("\"schemaVersion\":1,", ""), + good.replace("\"values\":{", "\"unknown\":true,\"values\":{"), + good.replace("\"values\":{", "\"values\":null,\"unused\":{"), + good.replace("\"region\":\"JP\"", "\"region\":\"ZZ\""), + good.replace("\"region\":\"JP\"", "\"region\":\"jp\""), + good.replace("\"region\":\"JP\"", "\"region\":null"), + good.replace("\"region\":\"JP\"", "\"region\":[\"JP\"]"), + good.replace("\"region\":\"JP\",", ""), + good.replace("\"region\":\"JP\",", "\"region\":\"JP\",\"unknown\":true,"), + good.replace("\"region\":\"JP\",", "\"region\":\"JP\",\"region\":\"US\","), + good.replace("\"region\":\"JP\",", "\"region\":\"JP\",\"\\u0072egion\":\"US\","), + good.replace("\"profileBanners\":true", "\"profileBanners\":\"true\""), + good.replace("\"commentReposts\":true", "\"commentReposts\":1"), + good.replace("\"theme\":\"system\"", "\"bottomBar\":1,\"theme\":\"system\""), + good.replace("\"theme\":\"system\"", "\"md3\":1,\"theme\":\"system\""), + good.replace("\"theme\":\"system\"", "\"hideAds\":1,\"theme\":\"system\""), + good.replace("\"theme\":\"system\"", "\"hideLive\":\"no\",\"theme\":\"system\""), + good.replace("\"theme\":\"system\"", "\"hideShop\":null,\"theme\":\"system\""), + good.replace("\"theme\":\"system\"", "\"md3\":true,\"md3\":false,\"theme\":\"system\""), + good.replace("\"theme\":\"system\"", "\"noWatermark\":\"yes\",\"theme\":\"system\""), + good.replace("\"theme\":\"system\"", "\"bottomBar\":true,\"bottomBar\":false,\"theme\":\"system\""), + good.replace("\"theme\":\"system\"", "\"theme\":\"automatic\""), + good.replace("\"theme\":\"system\"", "\"theme\":{}"), + good.replace("JP", "J\\qP"), good.replace("JP", "J\\uGGGGP"), + good.replace("JP", "J\nP"), good.replace("JP", "J\\u00"), + good.replace("\"theme\":\"system\"}", "\"theme\":\"system\",}"), + good.substring(0, good.length() - 1) + ",}", + "{\"format\":\"lihont.settings\",\"format\":\"lihont.settings\"," + + good.substring(1 + "\"format\":\"lihont.settings\",".length()), + "{\"a\":{\"b\":{\"c\":{\"d\":true}}}}", + good + " ".repeat(SettingsCodec.MAX_DOCUMENT_LENGTH), + }; + } + + private static void invalid() throws Exception { + for (String document : invalidDocuments()) { + expect(IllegalArgumentException.class, () -> SettingsCodec.decode(document)); + } + String good = document("JP", true, true, "system"); + String boundary = good + " ".repeat(SettingsCodec.MAX_DOCUMENT_LENGTH - good.length()); + equal(ModSettings.defaults(), SettingsCodec.decode(boundary), + "Maximum-size valid input is accepted"); + } + + private static void persistence() throws Exception { + MemoryStorage storage = new MemoryStorage(null); + SettingsRepository first = new SettingsRepository(storage); + equal(1, storage.writes, "First launch creates persistent defaults"); + equal(ModSettings.defaults(), first.current(), "First launch starts with defaults"); + ModSettings selected = new ModSettings("AL", false, true, ModSettings.Theme.DARK); + first.save(selected); + equal(selected, SettingsCodec.decode(storage.document), "Save reaches persistent storage"); + SettingsRepository restarted = new SettingsRepository(storage); + equal(selected, restarted.current(), "Next process loads saved values"); + equal(selected, restarted.active(), "Saved values become active in the next process"); + check(!restarted.restartRequired(), "Fresh process needs no additional restart"); + equal(2, storage.writes, "Loading valid stored settings performs no write"); + } + + private static void snapshot() throws Exception { + MemoryStorage storage = new MemoryStorage(null); + SettingsRepository repository = new SettingsRepository(storage); + ModSettings initial = repository.active(); + repository.save(ModSettings.defaults()); + equal(1, storage.writes, "No-op save does not touch storage"); + repository.save(initial.withTheme(ModSettings.Theme.LIGHT)); + check(!repository.restartRequired(), "Theme alone needs no restart"); + ModSettings other = initial.withRegion("US"); + repository.save(other); + equal(initial, repository.active(), "Changing selection leaves process-start snapshot intact"); + equal(other, repository.current(), "UI reads the newly saved selection"); + equal(other, SettingsCodec.decode(repository.exportJson()), + "Export reflects the selected settings, not the startup snapshot"); + check(repository.restartRequired(), "Changed region requests restart"); + repository.save(initial); + check(!repository.restartRequired(), "Reverting selection removes restart requirement"); + expect(IllegalArgumentException.class, () -> repository.save(null)); + } + + private static void reset() throws Exception { + ModSettings old = new ModSettings("US", false, false, ModSettings.Theme.DARK); + MemoryStorage storage = new MemoryStorage(SettingsCodec.encode(old)); + SettingsRepository repository = new SettingsRepository(storage); + repository.reset(); + equal(ModSettings.defaults(), repository.current(), "Reset restores every default"); + equal(ModSettings.defaults(), SettingsCodec.decode(storage.document), "Reset is persistent"); + equal(old, repository.active(), "Reset keeps active process snapshot unchanged"); + check(repository.restartRequired(), "Reset requests restart when runtime fields changed"); + repository.reset(); + equal(1, storage.writes, "Repeated reset avoids redundant writes"); + check(!new SettingsRepository(storage).restartRequired(), "Restart completes the reset"); + } + + private static void atomicImport() throws Exception { + ModSettings initial = new ModSettings("AL", false, true, ModSettings.Theme.LIGHT); + MemoryStorage storage = new MemoryStorage(SettingsCodec.encode(initial)); + SettingsRepository repository = new SettingsRepository(storage); + String before = storage.document; + for (String document : invalidDocuments()) { + expect(IllegalArgumentException.class, () -> repository.importJson(document)); + equal(initial, repository.current(), "Rejected import preserves memory"); + equal(before, storage.document, "Rejected import preserves stored document"); + equal(0, storage.writes, "Validation finishes before any persistence attempt"); + } + repository.importJson(document("US", true, false, "dark")); + equal(new ModSettings("US", true, false, ModSettings.Theme.DARK), repository.current(), + "Valid import applies every value"); + equal(1, storage.writes, "Valid import is a single full-document transaction"); + } + + private static void ioFailures() throws Exception { + ModSettings initial = ModSettings.defaults(); + MemoryStorage storage = new MemoryStorage(SettingsCodec.encode(initial)); + SettingsRepository repository = new SettingsRepository(storage); + String original = storage.document; + storage.failWrites = true; + expect(IOException.class, () -> repository.save(initial.withRegion("AL"))); + equal(initial, repository.current(), "Failed save preserves memory"); + equal(original, storage.document, "Failed save preserves disk"); + check(!repository.restartRequired(), "Failed save cannot create pending runtime state"); + expect(IOException.class, () -> repository.importJson(document("US", false, false, "dark"))); + equal(initial, repository.current(), "Failed import write preserves memory"); + storage.failWrites = false; + repository.save(initial.withRegion("AL")); + original = storage.document; + storage.failWrites = true; + expect(IOException.class, repository::reset); + equal("AL", repository.current().region(), "Failed reset preserves current values"); + equal(original, storage.document, "Failed reset preserves stored values"); + storage.failReads = true; + expect(IOException.class, () -> new SettingsRepository(storage)); + equal(original, storage.document, "Read error never overwrites stored values"); + MemoryStorage empty = new MemoryStorage(null); + empty.failWrites = true; + expect(IOException.class, () -> new SettingsRepository(empty)); + equal(null, empty.document, "Failed first-launch write is surfaced"); + } + + private static void corruptStorage() throws Exception { + for (String document : new String[] {"not JSON", document("JP", true, true, "system") + .replace("\"schemaVersion\":1", "\"schemaVersion\":2")}) { + MemoryStorage storage = new MemoryStorage(document); + expect(IllegalArgumentException.class, () -> new SettingsRepository(storage)); + equal(document, storage.document, "Unrecognized saved data is kept for recovery"); + equal(0, storage.writes, "No silent downgrade or replacement of corrupt data"); + } + } + + private static String document(String region, boolean banners, boolean reposts, String theme) { + return "{\"format\":\"lihont.settings\",\"schemaVersion\":1,\"values\":{" + + "\"region\":\"" + region + "\",\"profileBanners\":" + banners + + ",\"commentReposts\":" + reposts + ",\"theme\":\"" + theme + "\"}}"; + } + + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static void equal(Object expected, Object actual, String message) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw new AssertionError(message + "; expected=" + expected + "; actual=" + actual); + } + } + + private static void expect(Class expected, Action action) throws Exception { + try { + action.run(); + } catch (Throwable thrown) { + if (expected.isInstance(thrown)) return; + throw new AssertionError("Expected " + expected.getName() + ", got " + thrown, thrown); + } + throw new AssertionError("Expected " + expected.getName()); + } + + private static final class MemoryStorage implements SettingsRepository.Storage { + String document; + int writes; + boolean failReads; + boolean failWrites; + + MemoryStorage(String document) { this.document = document; } + + @Override public String read() throws IOException { + if (failReads) throw new IOException("Injected read failure"); + return document; + } + + @Override public void write(String replacement) throws IOException { + if (failWrites) throw new IOException("Injected atomic write failure"); + document = replacement; + writes++; + } + } +} diff --git a/tests/test_app_icons.py b/tests/test_app_icons.py new file mode 100644 index 0000000..aa57726 --- /dev/null +++ b/tests/test_app_icons.py @@ -0,0 +1,172 @@ +"""Launcher icon aliases and the dead drawables that carry their pictures.""" + +import struct +import tempfile +import unittest +import zlib +from pathlib import Path + +from lihont import app_icons +from lihont.app_icons import Icon, IconError +from lihont.apkzip import Apk +from lihont.arsc import Arsc +from lihont.axml import Axml +from lihont.manifest import entry_icon, launcher_elements + +ROOT = Path(__file__).resolve().parents[1] +CATALOG = ROOT / "src" / "icons" +BASE = ROOT / "OriginalAPKtiktok" / ( + "com.zhiliaoapp.musically_47.1.3-2024701030_minAPI23(arm64-v8a)(nodpi)_apkmirror.com.apk") + + +def png(width, height): + """The smallest thing png_side will accept: signature and an IHDR chunk.""" + header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + chunk = b"IHDR" + header + return (app_icons.PNG_SIGNATURE + struct.pack(">I", len(header)) + chunk + + struct.pack(">I", zlib.crc32(chunk) & 0xFFFFFFFF) + b"\0" * 8) + + +class CatalogTest(unittest.TestCase): + def test_the_shipped_catalog_is_valid(self): + icons = app_icons.load_catalog(CATALOG) + self.assertEqual(["tolstyj", "elmacho", "pushistik", "original"], [i.slug for i in icons]) + self.assertEqual(["Эй толстый", "Эль мачо", "Пушистик", "Оригинал"], [i.title for i in icons]) + for icon in icons: + self.assertEqual(256, app_icons.png_side(icon.png)) + + def test_pictures_must_be_small_square_pngs(self): + self.assertEqual(256, app_icons.png_side(png(256, 256))) + for bad in (b"", b"not a png at all, not at all, not at all!!", png(256, 200), png(64, 64), png(2048, 2048)): + with self.assertRaises(IconError): + app_icons.png_side(bad) + + def test_catalog_errors_are_reported(self): + with tempfile.TemporaryDirectory() as folder: + directory = Path(folder) + (directory / "ok.png").write_bytes(png(256, 256)) + for text in ("a\tonly two\n", "Bad Slug\tTitle\tok.png\n", "dup\tT\tok.png\ndup\tU\tok.png\n", + "fine\t\tok.png\n", "fine\t" + "x" * 33 + "\tok.png\n"): + (directory / "catalog.tsv").write_text(text, encoding="utf-8") + with self.assertRaises(IconError, msg=text): + app_icons.load_catalog(directory) + (directory / "catalog.tsv").write_text("# comment\n\nfine\tTitle\tok.png\n", encoding="utf-8") + self.assertEqual(["fine"], [i.slug for i in app_icons.load_catalog(directory)]) + + def test_new_path_keeps_the_encoded_length(self): + for _, old in app_icons.SLOTS: + for index in range(len(app_icons.SLOTS)): + new = app_icons.slot_path(old, index) + self.assertEqual(len(old), len(new)) + self.assertEqual(old.rpartition("/")[0], new.rpartition("/")[0]) + self.assertTrue(new.endswith(".png")) + self.assertEqual("res/c/li0.png", app_icons.slot_path("res/c/a0g.png", 0)) + with self.assertRaises(IconError): + app_icons.slot_path("res/c/a.png", 0) + + def test_slots_are_distinct(self): + self.assertEqual(len(app_icons.SLOTS), len({r for r, _ in app_icons.SLOTS})) + self.assertEqual(len(app_icons.SLOTS), len({p for _, p in app_icons.SLOTS})) + + +class OnTheRealApkTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not BASE.exists(): + raise unittest.SkipTest("clean TikTok base APK is not available") + + def setUp(self): + self.apk = Apk(str(BASE)) + self.addCleanup(self.apk.close) + + def test_every_pinned_slot_is_what_the_table_says(self): + arsc = Arsc(self.apk.read("resources.arsc")) + for res_id, path in app_icons.SLOTS: + app_icons.verify_slot(arsc, res_id, path) + self.assertTrue(self.apk.has(path), path) + + def test_a_slot_that_moved_is_refused(self): + arsc = Arsc(self.apk.read("resources.arsc")) + with self.assertRaises(IconError): + app_icons.verify_slot(arsc, app_icons.SLOTS[0][0], "res/c/zzz.png") + with self.assertRaises(IconError): + app_icons.verify_slot(arsc, 0x7F010000, "res/c/a0g.png") + + def test_aliases_are_added_disabled_and_carry_their_pictures(self): + xml = Axml.parse(self.apk.read("AndroidManifest.xml")) + before = len(launcher_elements(xml)) + icons = app_icons.load_catalog(CATALOG) + report = app_icons.apply(self.apk, xml, icons) + self.assertEqual(4, report["icons"]) + self.assertEqual(app_icons.ORIGINAL_ENTRY, report["original_entry"]) + + reparsed = Axml.parse(self.apk.read("AndroidManifest.xml")) + self.assertEqual(before + 4, len(launcher_elements(reparsed))) + aliases = {reparsed.attr_string(n, "name"): n for n in reparsed.elements("activity-alias")} + arsc = Arsc(self.apk.read("resources.arsc")) + for index, icon in enumerate(icons): + name = app_icons.alias_name(icon.slug) + node = aliases[name] + res_id, old_path = app_icons.SLOTS[index] + self.assertEqual(0, reparsed.attr(node, "enabled").data, "starts disabled") + self.assertEqual(0xFFFFFFFF, reparsed.attr(node, "exported").data) + self.assertEqual(report["target"], reparsed.attr_string(node, "targetActivity")) + self.assertEqual("TikTok", reparsed.attr_string(node, "label")) + self.assertEqual(res_id, reparsed.attr(node, "icon").data) + # the table now sends that id to our file, and the file is in the zip + new_path = app_icons.slot_path(old_path, index) + self.assertEqual(new_path, arsc.file_path(arsc.values(res_id)[0])) + self.assertEqual(icon.png, self.apk.read(new_path)) + self.assertFalse(self.apk.has(old_path)) + titles = [reparsed.attr_string(n, "value") for n in reparsed.elements("meta-data") + if reparsed.attr_string(n, "name") == "lihont.title"] + self.assertEqual([app_icons.STOCK_TITLE] + [i.title for i in icons], titles, "stock icon first, then the catalog") + + def test_the_original_entry_stays_callable_but_leaves_the_home_screen(self): + xml = Axml.parse(self.apk.read("AndroidManifest.xml")) + original_icon = entry_icon(xml, app_icons.ORIGINAL_ENTRY) + app_icons.apply(self.apk, xml, app_icons.load_catalog(CATALOG)) + reparsed = Axml.parse(self.apk.read("AndroidManifest.xml")) + original = next(n for n in reparsed.elements("activity-alias") + if reparsed.attr_string(n, "name") == app_icons.ORIGINAL_ENTRY) + enabled = reparsed.attr(original, "enabled") + self.assertTrue(enabled is None or enabled.data != 0, "TikTok starts it by name, so it must stay on") + launchers = [reparsed.attr_string(n, "name") for n in launcher_elements(reparsed)] + self.assertNotIn(app_icons.ORIGINAL_ENTRY, launchers) + self.assertEqual(5, len(launchers)) + + stock = next(n for n in reparsed.elements("activity-alias") + if reparsed.attr_string(n, "name") == app_icons.alias_name(app_icons.STOCK_SLUG)) + self.assertEqual(0xFFFFFFFF, reparsed.attr(stock, "enabled").data, "the stock icon is on by default") + self.assertEqual(original_icon, reparsed.attr(stock, "icon").data, "and it is TikTok's own picture") + + def test_exactly_one_icon_is_on(self): + xml = Axml.parse(self.apk.read("AndroidManifest.xml")) + app_icons.apply(self.apk, xml, app_icons.load_catalog(CATALOG)) + reparsed = Axml.parse(self.apk.read("AndroidManifest.xml")) + on = [reparsed.attr_string(n, "name") for n in launcher_elements(reparsed) + if reparsed.attr(n, "enabled") is None or reparsed.attr(n, "enabled").data != 0] + self.assertEqual([app_icons.alias_name(app_icons.STOCK_SLUG)], on) + + def test_a_catalog_icon_cannot_take_the_stock_slug(self): + with tempfile.TemporaryDirectory() as folder: + directory = Path(folder) + (directory / "p.png").write_bytes(png(256, 256)) + (directory / "catalog.tsv").write_text("stock\tTikTok\tp.png\n", encoding="utf-8") + with self.assertRaises(IconError): + app_icons.load_catalog(directory) + + def test_more_icons_than_slots_are_refused(self): + icons = [Icon("i%02d" % n, "T", png(256, 256)) for n in range(len(app_icons.SLOTS) + 1)] + xml = Axml.parse(self.apk.read("AndroidManifest.xml")) + with tempfile.TemporaryDirectory() as folder: + directory = Path(folder) + (directory / "p.png").write_bytes(png(256, 256)) + (directory / "catalog.tsv").write_text( + "".join("i%02d\tT\tp.png\n" % n for n in range(len(icons))), encoding="utf-8") + with self.assertRaises(IconError): + app_icons.load_catalog(directory) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_badge_patch.py b/tests/test_badge_patch.py new file mode 100644 index 0000000..ecb69ab --- /dev/null +++ b/tests/test_badge_patch.py @@ -0,0 +1,80 @@ +import unittest +from pathlib import Path + +from lihont import badge_patch as bp + +USER = """\ +.class public Lcom/ss/android/ugc/aweme/profile/model/User; +.super Ljava/lang/Object; + +.method public getNickname()Ljava/lang/String; + .registers 2 + + iget-object v0, p0, Lx;->nickname:Ljava/lang/String; + + return-object v0 +.end method + +.method public getUniqueId()Ljava/lang/String; + .registers 2 + + iget-object v0, p0, Lx;->uniqueId:Ljava/lang/String; + + return-object v0 +.end method + +.method public getUserDisplayName()Ljava/lang/String; + .registers 2 + + iget-object v0, p0, Lx;->d:Ljava/lang/String; + + return-object v0 +.end method +""" + +TUX = """\ +.class public Lcom/bytedance/tux/input/TuxTextView; +.super Landroid/widget/TextView; + +.method public setText(Ljava/lang/CharSequence;Landroid/widget/TextView$BufferType;)V + .registers 10 + + return-void +.end method +""" + + +class BadgePatchTest(unittest.TestCase): + def test_user_getters_remember_their_names(self): + patched, hits = bp.rewrite_user(USER) + self.assertEqual(3, len(hits)) + self.assertEqual(3, patched.count(bp.REMEMBER)) + self.assertLess(patched.index(bp.REMEMBER), patched.index("return-object v0")) + + def test_text_view_hook_runs_first_and_replaces_the_text(self): + patched, _ = bp.rewrite_text_view(TUX) + self.assertLess(patched.index(".registers 10"), patched.index(bp.PREPARE)) + self.assertLess(patched.index("move-result-object p1"), patched.index("return-void")) + + def test_second_pass_and_changed_shapes_are_refused(self): + with self.assertRaises(ValueError): + bp.rewrite_user(bp.rewrite_user(USER)[0]) + with self.assertRaises(ValueError): + bp.rewrite_user(USER.replace(".registers 2", ".registers 3", 1)) + with self.assertRaises(ValueError): + bp.rewrite_text_view(TUX.replace("setText", "setLabel")) + + def test_every_target_has_a_dex_and_the_runtime_calls_exist(self): + self.assertEqual(set(bp.REWRITES), set(bp.DEXES)) + self.assertEqual(4, len(bp.runtime_targets())) + + def test_the_bundled_pictures_are_there(self): + # the pictures are third-party artwork: a checkout without them still builds, without those two badges + for name in ("supporter.png", "developer.png"): + path = Path("src/compat", name) + if path.is_file(): + self.assertTrue(path.stat().st_size > 500) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_bar_patch.py b/tests/test_bar_patch.py new file mode 100644 index 0000000..a6ceb73 --- /dev/null +++ b/tests/test_bar_patch.py @@ -0,0 +1,71 @@ +import unittest + +from lihont import bar_patch + +ON_RESUME = """\ +.class public Lcom/ss/android/ugc/aweme/main/MainActivity; +.super LX/0tPX; + +.method public onResume()V + .registers 5 + + const v0, 0x117de + + invoke-static {v0}, Lcom/bytedance/pumbaa/utility/method_id/MethodIDManager;->push(I)Z + + move-result v3 + + const/4 v0, 0x1 + + const-string v2, "com.ss.android.ugc.aweme.main.MainActivity" + + const-string v1, "onResume" + + invoke-static {v2, v1, v0}, Lcom/bytedance/apm/agent/v2/instrumentation/ActivityAgent;->onTrace(Ljava/lang/String;Ljava/lang/String;Z)V + + invoke-super {p0}, LX/1EKk;->onResume()V + + const/4 v0, 0x0 + + invoke-static {v2, v1, v0}, Lcom/bytedance/apm/agent/v2/instrumentation/ActivityAgent;->onTrace(Ljava/lang/String;Ljava/lang/String;Z)V + + return-void +.end method + +.method public onStart()V + .registers 4 + + invoke-super {p0}, LX/1EKk;->onStart()V + + return-void +.end method +""" + + +class BarPatchTest(unittest.TestCase): + def test_hook_follows_the_super_call_and_nothing_else_changes(self): + patched = bar_patch.rewrite_main_activity(ON_RESUME) + call = " invoke-static {p0}, " + bar_patch.HOOK + self.assertEqual(1, patched.count(call)) + self.assertLess(patched.index("->onResume()V"), patched.index(call)) + self.assertLess(patched.index(call), patched.index("const/4 v0, 0x0")) + # onStart's own super call is left alone and the rest of the file is byte for byte the same + self.assertEqual(ON_RESUME, patched.replace("\n" + call + "\n", "", 1)) + + def test_it_refuses_to_patch_twice(self): + patched = bar_patch.rewrite_main_activity(ON_RESUME) + with self.assertRaises(ValueError): + bar_patch.rewrite_main_activity(patched) + + def test_it_refuses_a_method_it_does_not_recognise(self): + with self.assertRaises(ValueError): + bar_patch.rewrite_main_activity(ON_RESUME.replace("->onResume()V", "->onPause()V")) + with self.assertRaises(ValueError): + bar_patch.rewrite_main_activity(".class public Lx;\n.super Ljava/lang/Object;\n") + + def test_runtime_target_is_the_hook(self): + self.assertEqual([bar_patch.HOOK], bar_patch.runtime_targets()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_custom_icons.py b/tests/test_custom_icons.py new file mode 100644 index 0000000..e5d507a --- /dev/null +++ b/tests/test_custom_icons.py @@ -0,0 +1,67 @@ +"""The custom launcher icon store and the picture-crop geometry. + +Both are plain java.io / arithmetic, so they run on a desktop JVM; the Android side (picking a photo, +pinning the shortcut) is checked on the phone. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / "src" / "lihont" / "two" / "settings" +HARNESS = ROOT / "tests" / "java" / "lihont" / "two" / "settings" / "IconStoreTest.java" + + +class CustomIconTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.javac = shutil.which("javac") + cls.java = shutil.which("java") + if not cls.javac or not cls.java: + raise RuntimeError("These tests require a JDK with java and javac") + cls.temporary = tempfile.TemporaryDirectory(prefix="lihont-custom-icon-tests-") + cls.addClassCleanup(cls.temporary.cleanup) + cls.classes = Path(cls.temporary.name) + cls.environment = dict(os.environ) + cls.environment.pop("JAVA_TOOL_OPTIONS", None) + cls.run_command([ + cls.javac, "--release", "17", "-encoding", "UTF-8", "-nowarn", "-Xlint:-options", + "-d", str(cls.classes), + str(PACKAGE / "IconCrop.java"), str(PACKAGE / "CustomIconStore.java"), str(HARNESS), + ]) + + @classmethod + def run_command(cls, command): + result = subprocess.run( + command, capture_output=True, text=True, encoding="utf-8", + env=cls.environment, timeout=60, + ) + if result.returncode: + raise AssertionError( + f"Command failed with exit {result.returncode}: {command[0]}\n" + result.stdout + result.stderr) + return result + + def run_scenario(self, name): + result = self.run_command([ + self.java, "-cp", str(self.classes), "lihont.two.settings.IconStoreTest", name]) + self.assertEqual(result.stdout.strip(), "PASS " + name) + + def test_the_photo_is_cut_to_the_centre_square(self): + self.run_scenario("crop") + + def test_names_and_ids_are_cleaned(self): + self.run_scenario("names") + + def test_icons_are_stored_listed_capped_and_removed(self): + self.run_scenario("store") + + def test_a_damaged_index_is_survived(self): + self.run_scenario("damaged") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_feed_patch.py b/tests/test_feed_patch.py new file mode 100644 index 0000000..68e87d6 --- /dev/null +++ b/tests/test_feed_patch.py @@ -0,0 +1,210 @@ +import unittest + +from lihont import feed_patch as fp + +AWEME = "Lcom/ss/android/ugc/aweme/feed/model/Aweme;" + +FEED_PAGE = """\ +.class public Lcom/ss/android/ugc/aweme/feed/model/FeedItemList; +.super Ljava/lang/Object; + +.method public getItems()Ljava/util/List; + .registers 4 + .annotation system Ldalvik/annotation/Signature; + value = { + "()", + "Ljava/util/List<", + "Lcom/ss/android/ugc/aweme/feed/model/Aweme;", + ">;" + } + .end annotation + + .prologue + .line 1 + iget-object v0, p0, Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;->items:Ljava/util/List; + + return-object v0 +.end method + +.method public getPreloadAds()Ljava/util/List; + .registers 2 + + iget-object v0, p0, Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;->preloadAds:Ljava/util/List; + + return-object v0 +.end method + +.method public isHasAd()Z + .registers 2 + + iget-boolean v0, p0, Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;->hasAd:Z + + return v0 +.end method + +.method public setItems(Ljava/util/List;)V + .registers 4 + + iput-object p1, p0, Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;->items:Ljava/util/List; + + return-void +.end method +""" + +MID_AD = """\ +.class public final Lcom/ss/android/ugc/aweme/commercialize/feed/assem/midad/MidAdResponse; +.super Ljava/lang/Object; + +.method public final getAweme()Lcom/ss/android/ugc/aweme/feed/model/Aweme; + .registers 2 + + iget-object v0, p0, Lcom/ss/android/ugc/aweme/commercialize/feed/assem/midad/MidAdResponse;->aweme:Lcom/ss/android/ugc/aweme/feed/model/Aweme; + + return-object v0 +.end method +""" + +CANDIDATES = """\ +.class public final Lcom/ss/android/ugc/aweme/commercialize/feed/candidate/CandidateRequestImpl; +.super Ljava/lang/Object; + +.method public final LIZ(Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V + .registers 4 + + invoke-interface {v0}, Lcom/ss/android/ugc/aweme/commercialize/feed/candidate/CandidateAdRequestApi;->request(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)LX/12b3; + + return-void +.end method +""" + +TOP_VIEW = """\ +.class public final Lcom/bytedance/ies/ugc/aweme/commercialize/splash/topview/TopViewJsonManager; +.super Ljava/lang/Object; + +.method public final LIZIZ(Ljava/lang/String;)Lcom/ss/android/ugc/aweme/feed/model/Aweme; + .registers 5 + + invoke-virtual {p0, p1}, Lcom/bytedance/ies/ugc/aweme/commercialize/splash/topview/TopViewJsonManager;->LIZLLL(Ljava/lang/String;)Ljava/util/Map; + + const/4 v0, 0x0 + + return-object v0 +.end method + +.method public final declared-synchronized LJ()Ljava/util/Map; + .registers 2 + .annotation system Ldalvik/annotation/Signature; + value = { + "()", + "Ljava/util/Map<", + "Ljava/lang/String;", + "LX/0cWK;", + ">;" + } + .end annotation + + monitor-enter p0 + + iget-object v0, p0, Lcom/bytedance/ies/ugc/aweme/commercialize/splash/topview/TopViewJsonManager;->LJ:Ljava/util/concurrent/ConcurrentHashMap; + + monitor-exit p0 + + return-object v0 +.end method +""" + + +class HelperTest(unittest.TestCase): + def test_parameter_registers_count_this_and_wide_arguments(self): + self.assertEqual(1, fp._parameter_registers("public final x()V")) + self.assertEqual(0, fp._parameter_registers("public static x()V")) + self.assertEqual(4, fp._parameter_registers("public x(IJ)V")) + self.assertEqual(4, fp._parameter_registers("public x(Ljava/lang/String;[ILjava/util/List;)V")) + self.assertEqual(3, fp._parameter_registers("public static x(DZ)V")) + + def test_a_local_is_made_when_the_method_has_none(self): + self.assertIn(".registers 3", fp._with_local("public x(I)V", " .registers 2\n return-void\n")) + self.assertIn(".registers 4", fp._with_local("public x(I)V", " .registers 4\n return-void\n")) + + def test_the_first_instruction_comes_after_annotations_and_debug_lines(self): + body = (" .registers 4\n .annotation system Lx/Y;\n value = {\n }\n .end annotation\n\n" + " .prologue\n .line 3\n const/4 v0, 0x1\n") + self.assertEqual(" const/4 v0, 0x1\n", body[fp._first_instruction(body):]) + + +class RewriteTest(unittest.TestCase): + def test_the_feed_page_is_cleaned_when_read_or_written(self): + patched, hits = fp.rewrite_feed_page(FEED_PAGE) + self.assertEqual({"items_read", "items_write", "preload_read", "ad_flag"}, set(hits)) + self.assertEqual(4, patched.count(fp.MARKER)) + self.assertEqual(4, patched.count(fp.CLEAN)) + self.assertEqual(1, patched.count(fp.FILTER)) + # the call goes after the annotation block and before the method's own code + items = patched.split(".method public getItems()")[1].split(".end method")[0] + self.assertLess(items.index(".end annotation"), items.index(fp.CLEAN)) + self.assertLess(items.index(fp.CLEAN), items.index("iget-object v0")) + self.assertEqual(FEED_PAGE.count(".method "), patched.count(".method ")) + + def test_the_filtered_list_replaces_the_argument_of_set_items(self): + patched, _ = fp.rewrite_feed_page(FEED_PAGE) + setter = patched.split(".method public setItems(")[1] + self.assertLess(setter.index("move-result-object p1"), setter.index("iput-object p1")) + + def test_the_mid_ad_response_ends_early_while_ads_are_off(self): + patched, hits = fp.rewrite_mid_ad(MID_AD) + self.assertEqual({"mid_ad": 1}, hits) + self.assertLess(patched.index(fp.ADS), patched.index("\n :lihont_keep\n")) + self.assertIn("const/4 v0, 0x0\n\n return-object v0", patched) + self.assertIn("iget-object v0, p0, Lcom/ss/android/ugc/aweme/commercialize/feed/assem/midad/MidAdResponse;->aweme", patched) + + def test_the_candidate_request_is_not_sent_while_ads_are_off(self): + patched, hits = fp.rewrite_candidates(CANDIDATES) + self.assertEqual({"candidate_request": 1}, hits) + label = "\n :lihont_keep\n" + self.assertLess(patched.index("return-void"), patched.index(label)) + self.assertLess(patched.index(label), patched.index("CandidateAdRequestApi")) + + def test_the_top_view_store_is_empty_while_ads_are_off(self): + patched, hits = fp.rewrite_top_view(TOP_VIEW) + self.assertEqual({"top_view", "top_view_store"}, set(hits)) + store = patched.split("declared-synchronized LJ()")[1] + self.assertLess(store.index(fp.ADS), store.index("monitor-enter p0")) + self.assertIn("new-instance v0, Ljava/util/HashMap;", store) + # LIZIZ(String) has p0 and p1 (two registers) and five in all: v0 is already a local there + self.assertIn(".registers 5", patched) + + def test_a_method_without_a_local_gets_one(self): + tight = MID_AD.replace(".registers 2", ".registers 1").replace("iget-object v0, p0,", "iget-object p0, p0,").replace("return-object v0", "return-object p0") + patched, _ = fp.rewrite_mid_ad(tight) + self.assertIn(".registers 2", patched) + + def test_a_second_pass_is_refused(self): + for rewrite, text in ((fp.rewrite_feed_page, FEED_PAGE), (fp.rewrite_mid_ad, MID_AD), + (fp.rewrite_candidates, CANDIDATES), (fp.rewrite_top_view, TOP_VIEW)): + with self.assertRaises(ValueError): + rewrite(rewrite(text)[0]) + + def test_a_class_that_changed_is_refused(self): + with self.assertRaises(ValueError): + fp.rewrite_feed_page(FEED_PAGE.replace("->items:Ljava/util/List;", "->list:Ljava/util/List;")) + with self.assertRaises(ValueError): + fp.rewrite_feed_page(FEED_PAGE.replace("isHasAd", "hasAnAd")) + with self.assertRaises(ValueError): + fp.rewrite_mid_ad(MID_AD.replace("->aweme:", "->other:")) + with self.assertRaises(ValueError): + fp.rewrite_candidates(CANDIDATES.replace("CandidateAdRequestApi", "Other")) + with self.assertRaises(ValueError): + fp.rewrite_top_view(TOP_VIEW.replace("->LJ:Ljava/util/concurrent/ConcurrentHashMap;", "->LJ:Ljava/util/Map;")) + with self.assertRaises(ValueError): + fp.rewrite_feed_page(MID_AD) + + def test_every_rewrite_has_its_dex_and_runtime_calls(self): + self.assertEqual(set(fp.REWRITES), set(fp.DEXES)) + both = "".join(fp.REWRITES[owner](text)[0] for owner, text in ( + (fp.FEED_PAGE, FEED_PAGE), (fp.MID_AD, MID_AD), (fp.CANDIDATES, CANDIDATES), (fp.TOP_VIEW, TOP_VIEW))) + for target in fp.runtime_targets(): + self.assertIn(target, both) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_icons.py b/tests/test_icons.py new file mode 100644 index 0000000..f9b0256 --- /dev/null +++ b/tests/test_icons.py @@ -0,0 +1,70 @@ +"""The mod's icons are standard Material glyphs parsed from SVG path data. + +The parser and the icon table run on a desktop JVM; Android is only on the classpath. +To look at the icons, render them with the harness: + java -cp ;tools/android.jar lihont.two.settings.IconRenderTest render out.png +""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / "src" / "lihont" / "two" / "settings" +HARNESS = ROOT / "tests" / "java" / "lihont" / "two" / "settings" / "IconRenderTest.java" + + +class IconTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.javac = shutil.which("javac") + cls.java = shutil.which("java") + if not cls.javac or not cls.java: + raise RuntimeError("Icon tests require a JDK with java and javac") + cls.android_jar = ROOT / "tools" / "android.jar" + if not cls.android_jar.is_file(): + raise RuntimeError("tools/android.jar must be available") + cls.temporary = tempfile.TemporaryDirectory(prefix="lihont-icon-tests-") + cls.addClassCleanup(cls.temporary.cleanup) + cls.classes = Path(cls.temporary.name) + cls.environment = dict(os.environ) + cls.environment.pop("JAVA_TOOL_OPTIONS", None) + cls.run_command([ + cls.javac, "--release", "17", "-encoding", "UTF-8", "-nowarn", "-Xlint:-options", + "-cp", str(cls.android_jar), "-d", str(cls.classes), + str(PACKAGE / "SvgPath.java"), str(PACKAGE / "Md3Icon.java"), str(HARNESS), + ]) + + @classmethod + def run_command(cls, command): + result = subprocess.run( + command, capture_output=True, text=True, encoding="utf-8", + env=cls.environment, timeout=60, + ) + if result.returncode: + raise AssertionError( + f"Command failed with exit {result.returncode}: {command[0]}\n" + + result.stdout + result.stderr + ) + return result + + def run_scenario(self, name): + result = self.run_command([ + self.java, "-Djava.awt.headless=true", + "-cp", str(self.classes) + os.pathsep + str(self.android_jar), + "lihont.two.settings.IconRenderTest", name, + ]) + self.assertEqual(result.stdout.strip(), "PASS " + name) + + def test_every_icon_parses_inside_the_grid_and_is_legible(self): + self.run_scenario("bounds") + + def test_path_data_shortcuts_and_arcs_are_understood(self): + self.run_scenario("tokens") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest_activity.py b/tests/test_manifest_activity.py new file mode 100644 index 0000000..fd405fa --- /dev/null +++ b/tests/test_manifest_activity.py @@ -0,0 +1,46 @@ +from pathlib import Path +import unittest + +from lihont.axml import Axml +from lihont.manifest import ManifestError, add_activity, has_activity + + +class ManifestActivityTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.apk = Path('OriginalAPKtiktok/com.zhiliaoapp.musically_47.1.3-2024701030_minAPI23(arm64-v8a)(nodpi)_apkmirror.com.apk') + if not cls.apk.exists(): + raise unittest.SkipTest('clean TikTok base APK is not available') + + def _manifest(self): + from lihont.apkzip import Apk + apk = Apk(str(self.apk)) + try: + return Axml.parse(apk.read('AndroidManifest.xml')) + finally: + apk.close() + + def test_private_settings_activity_is_non_exported(self): + xml = self._manifest() + name = 'lihont.two.settings.LihonTSettingsActivity' + add_activity(xml, name, 'LihonT', 0x7F120364) + self.assertTrue(has_activity(xml, name)) + node = next(n for n in xml.elements('activity') if xml.attr_string(n, 'name') == name) + exported = xml.attr(node, 'exported') + self.assertIsNotNone(exported) + self.assertEqual(0, exported.data) + self.assertEqual(0x7F120364, xml.attr(node, 'theme').data) + # A second parse verifies the binary XML remains valid after appending + # framework attributes and a private activity subtree. + reparsed = Axml.parse(xml.build()) + self.assertTrue(has_activity(reparsed, name)) + + def test_launcher_activity_must_explicitly_be_exported(self): + xml = self._manifest() + with self.assertRaises(ManifestError): + add_activity(xml, 'lihont.two.TestLauncher', 'LihonT', 0x7F120364, + affinity='lihont.test', launcher=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_media_patch.py b/tests/test_media_patch.py new file mode 100644 index 0000000..684bf33 --- /dev/null +++ b/tests/test_media_patch.py @@ -0,0 +1,147 @@ +import unittest +from pathlib import Path + +from lihont import media_patch + +ROOT = Path(__file__).resolve().parents[1] +SMALI24 = ROOT / "work" / "tabs" / "smali24" / "X" +REAL_SOURCE = SMALI24 / "0qvH.2.smali" +REAL_CONTEXT = SMALI24 / "0qwE.2.smali" + +AWEME = media_patch.AWEME + +SOURCE = """\ +.class public final LX/0qvH; +.super Ljava/lang/Object; + +.method public final LIZ(%(aweme)sZ)V + .registers 7 + + return-void +.end method + +.method public final LIZIZ(%(aweme)s)V + .registers 11 + + .prologue + .line 16777216 + iget-object v1, p0, LX/0qvH;->LJ:Ljava/lang/String; + + return-void +.end method +""" % {"aweme": AWEME} + +CONTEXT = """\ +.class public final LX/0qwE; +.super LX/0qzh; + +.method public final LJFF()Z + .registers 2 + + iget-boolean v0, p0, LX/0qwE;->LJIJI:Z + + return v0 +.end method + +.method public final LJIIIZ()Z + .registers 2 + + .prologue + .line 0 + iget-boolean v0, p0, LX/0qwE;->LJIJ:Z + + .line 1 + return v0 +.end method + +.method public final LJIIJ()Z + .registers 2 + + iget-boolean v0, p0, LX/0qwE;->LJIIZILJ:Z + + return v0 +.end method +""" + + +class SourceRewriteTest(unittest.TestCase): + def test_the_hook_goes_in_front_of_the_original_body(self): + patched = media_patch.rewrite_source(SOURCE) + head, tail = patched.split(".method public final LIZIZ(", 1) + self.assertIn(" .registers 11\n\n" + media_patch.PICK_HOOK, tail) + self.assertTrue(tail.index(media_patch.PICK_HOOK) < tail.index("iget-object v1, p0, LX/0qvH;->LJ")) + self.assertEqual(SOURCE.split(".method public final LIZIZ(", 1)[0], head) + + def test_it_hands_over_to_the_plain_pick_and_returns(self): + hook = media_patch.PICK_HOOK + self.assertIn("invoke-virtual {p0, p1, v0}, LX/0qvH;->LIZ(%sZ)V" % AWEME, hook) + self.assertLess(hook.index("LIZ(" + AWEME), hook.index("return-void")) + self.assertEqual(1, sum(1 for line in hook.splitlines() if line.strip() == ":lihont_normal")) + self.assertEqual(1, hook.count("if-eqz v0, :lihont_normal")) + # only v0 is touched, which is a local in a method with eleven registers + for register in ("v1", "v2", "v9", "v10"): + self.assertNotIn(register, hook) + + def test_it_refuses_a_second_pass(self): + with self.assertRaises(ValueError): + media_patch.rewrite_source(media_patch.rewrite_source(SOURCE)) + + def test_it_refuses_a_class_without_the_methods(self): + with self.assertRaises(ValueError): + media_patch.rewrite_source(".class public Lx;\n.super Ljava/lang/Object;\n") + with self.assertRaises(ValueError): + media_patch.rewrite_source(SOURCE.replace(".method public final LIZ(" + AWEME + "Z)V", ".method public final LIZ(I)V")) + + @unittest.skipUnless(REAL_SOURCE.exists(), "the disassembled X.0qvH is not in work/") + def test_the_real_class_is_rewritten(self): + text = REAL_SOURCE.read_text(encoding="utf-8").replace(chr(13) + chr(10), chr(10)) + patched = media_patch.rewrite_source(text) + self.assertEqual(1, patched.count(media_patch.SWITCH)) + self.assertEqual(text.count(".method "), patched.count(".method "), "no method added or lost") + + +class ContextRewriteTest(unittest.TestCase): + def test_only_the_logo_getter_changes(self): + patched = media_patch.rewrite_context(CONTEXT) + self.assertIn(media_patch.LOGO, patched) + self.assertIn("iget-boolean v0, p0, LX/0qwE;->LJIJ:Z\n\n invoke-static {v0}, " + media_patch.LOGO, patched) + # the other flags, including the AI-content ones, keep TikTok's own answers + self.assertIn("iget-boolean v0, p0, LX/0qwE;->LJIJI:Z\n\n return v0", patched) + self.assertIn("iget-boolean v0, p0, LX/0qwE;->LJIIZILJ:Z\n\n return v0", patched) + self.assertEqual(CONTEXT.count(".method "), patched.count(".method ")) + + def test_registers_cover_what_the_body_uses(self): + body = media_patch.LOGO_BODY + self.assertIn(".registers 2", body) + for register in ("v1", "v2"): + self.assertNotIn(register, body) + + def test_it_refuses_a_second_pass(self): + with self.assertRaises(ValueError): + media_patch.rewrite_context(media_patch.rewrite_context(CONTEXT)) + + def test_it_refuses_a_getter_that_is_not_the_plain_one(self): + changed = CONTEXT.replace(" return v0\n.end method\n\n.method public final LJIIJ()Z", + " invoke-static {v0}, Lx/Y;->z(Z)V\n return v0\n.end method\n\n.method public final LJIIJ()Z") + with self.assertRaises(ValueError): + media_patch.rewrite_context(changed) + with self.assertRaises(ValueError): + media_patch.rewrite_context(".class public Lx;\n.super Ljava/lang/Object;\n") + + @unittest.skipUnless(REAL_CONTEXT.exists(), "the disassembled X.0qwE is not in work/") + def test_the_real_class_is_rewritten(self): + text = REAL_CONTEXT.read_text(encoding="utf-8").replace(chr(13) + chr(10), chr(10)) + patched = media_patch.rewrite_context(text) + self.assertEqual(1, patched.count(media_patch.LOGO)) + self.assertEqual(text.count(".method "), patched.count(".method "), "no method added or lost") + + +class RuntimeTest(unittest.TestCase): + def test_runtime_targets_are_what_the_rewrites_call(self): + both = media_patch.rewrite_source(SOURCE) + media_patch.rewrite_context(CONTEXT) + for target in media_patch.runtime_targets(): + self.assertIn(target, both) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_members_server.py b/tests/test_members_server.py new file mode 100644 index 0000000..2697f5c --- /dev/null +++ b/tests/test_members_server.py @@ -0,0 +1,73 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + +SPEC = importlib.util.spec_from_file_location("members_server", Path(__file__).resolve().parents[1] / "services" / "members" / "server.py") +server = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(server) + +UID = "6812345678901234" + + +class ClaimTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.now = 1_790_000_000 + self.bio = "" + self.found = UID + self.registry = server.Registry(Path(self.tmp.name) / "r.sqlite3", clock=lambda: self.now, fetcher=self.fetch) + + def fetch(self, unique_id): + return (self.found, self.bio) + + def test_code_is_stable_and_per_account(self): + code = self.registry.code(UID) + self.assertEqual(code, self.registry.code(UID)) + self.assertNotEqual(code, self.registry.code("6812345678901235")) + self.assertRegex(code, r"^LT-[A-Z2-7]{8}$") + + def test_bio_with_the_code_gets_the_badge(self): + self.assertFalse(self.registry.lookup([UID])["niche"][UID]) + self.assertEqual("code_missing", self.registry.claim(UID, "someone")["reason"]) + self.bio = "hello " + self.registry.code(UID) + " bye" + self.assertTrue(self.registry.claim(UID, "someone")["granted"]) + self.assertTrue(self.registry.lookup([UID])["niche"][UID]) + + def test_someone_elses_profile_is_refused(self): + self.found = "999999999" + self.bio = self.registry.code(UID) + with self.assertRaises(ValueError): + self.registry.claim(UID, "someone") + + def test_nothing_is_issued_after_the_deadline_but_holders_stay(self): + self.bio = self.registry.code(UID) + self.registry.claim(UID, "someone") + self.now = server.CLAIM_DEADLINE + 1 + other = "6812345678901299" + self.found, self.bio = other, self.registry.code(other) + with self.assertRaises(ValueError): + self.registry.claim(other, "other") + self.assertTrue(self.registry.lookup([UID, other])["niche"][UID]) + self.assertFalse(self.registry.lookup([UID, other])["niche"][other]) + + def test_blocked_accounts_get_nothing(self): + with self.registry.connect() as db: + db.execute("INSERT INTO blocked(uid) VALUES(?)", (UID,)) + self.bio = self.registry.code(UID) + with self.assertRaises(ValueError): + self.registry.claim(UID, "someone") + + def test_bad_input_and_unreadable_profiles(self): + with self.assertRaises(ValueError): + self.registry.claim("abc", "someone") + with self.assertRaises(ValueError): + self.registry.claim(UID, "bad name!") + self.registry.fetcher = lambda u: (_ for _ in ()).throw(OSError("blocked")) + with self.assertRaises(ValueError): + self.registry.claim(UID, "someone") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_portal.py b/tests/test_portal.py new file mode 100644 index 0000000..ad9d3bc --- /dev/null +++ b/tests/test_portal.py @@ -0,0 +1,201 @@ +"""The private /lihont2 portal: real requests against a server on a throw-away database. + +The credentials are random per run and live only in this process. The pages are checked for +the content each role must (and must not) see, for escaping of user-controlled text, and for +icon ids that exist in the shared sprite. +""" + +import gc +import http.cookiejar +import importlib.util +import re +import secrets +import sys +import tempfile +import threading +import unittest +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SERVER = ROOT / "src" / "web" / "server.py" +SPRITE = ROOT / "src" / "web" / "assets" / "site" / "icons.svg" + + +def load_server(): + spec = importlib.util.spec_from_file_location("lihont_portal_server", SERVER) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +class PortalTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.portal = load_server() + cls.directory = tempfile.TemporaryDirectory(prefix="lihont-portal-test-", ignore_cleanup_errors=True) + base = Path(cls.directory.name) + cls.apk = base / "lihont2.apk" + cls.apk.write_bytes(b"PK\x03\x04" + b"0" * 2048) + cls.admin_user = "admin_" + secrets.token_hex(4) + cls.admin_password = secrets.token_urlsafe(18) + cls.portal.ADMIN_USERNAME = cls.admin_user + cls.portal.ADMIN_PASSWORD = cls.admin_password + db = str(base / "data.db") + cls.portal.init_db(db) + handler = cls.portal.LihonTHandler + handler.db_path = db + handler.apk_path = str(cls.apk) + handler._cached_stat = (0.0, 0, "") + + class Server(cls.portal.socketserver.ThreadingMixIn, cls.portal.http.server.HTTPServer): + daemon_threads = True + + cls.httpd = Server(("127.0.0.1", 0), handler) + cls.base_url = "http://127.0.0.1:%d%s" % (cls.httpd.server_address[1], cls.portal.BASE_PATH) + threading.Thread(target=cls.httpd.serve_forever, daemon=True).start() + cls.sprite_ids = set(re.findall(r'note"}) + self.assertEqual(status, 200) + self.assertNotIn("note", page) + self.assertIn("<img src=x", page) + user_id = re.search(r'name="user_id" value="(\d+)"', page).group(1) + self.fetch(opener, "/admin/delete-user", {"user_id": user_id}) + + def test_missing_page_is_styled(self): + status, page, _ = self.fetch(self.session(), "/nope") + self.assertEqual(status, 404) + self.assertIn("Страница не найдена", page) + self.assertIn("/assets/site/md3.css", page) + self.assert_icons_exist(page) + + def test_pages_use_only_shared_assets(self): + _, login_page, _ = self.fetch(self.session(), "/") + self.assertNotIn("LIZ(Ljava/lang/String;I)I + + move-result v2 + + return v2 +.end method +""" + +BOOL_READ = INT_READ.replace("(Ljava/lang/String;I)I", "(Ljava/lang/String;Z)Z").replace( + "read()I", "read()Z") + +PARAMETER_READ = """\ +.class public Lcom/example/Reader; +.super Ljava/lang/Object; + +.method public run(I)I + .registers 5 + + const-string v0, "repost_support_digg" + + invoke-static {v0, p1}, LX/0abc;->LIZ(Ljava/lang/String;I)I + + move-result p1 + + return p1 +.end method +""" + +LEGACY = """\ +.class public Lcom/example/Reader; +.super Ljava/lang/Object; + +.method public static read()I + .registers 3 + + const-string v0, "profile_left_align" + + const/4 v1, 0x0 + + invoke-static {v0, v1}, Lcom/bytedance/ies/abmock/SettingsManager;->LIZ(Ljava/lang/String;I)I + + move-result v2 + # lihont feature flag: profile_left_align + const/16 v2, 0x1 + + return v2 +.end method +""" + + +class RewriteTest(unittest.TestCase): + def test_int_read_goes_through_the_hook(self): + text, hits = profile_features.rewrite_smali(INT_READ, ALL_FLAGS) + self.assertEqual(hits, {"profile_left_align": 1}) + self.assertIn( + " move-result v2\n" + " # lihont feature flag: profile_left_align\n" + " invoke-static/range {v2 .. v2}, %s->profile_left_align(I)I\n" + " move-result v2\n" % HOOK, text) + self.assertNotIn("const/16", text) + self.assertIn("SettingsManager;->LIZ(Ljava/lang/String;I)I", text) + self.assertIn(" return v2\n", text) + + def test_boolean_read_uses_the_boolean_overload(self): + text, hits = profile_features.rewrite_smali(BOOL_READ, ALL_FLAGS) + self.assertEqual(hits, {"profile_left_align": 1}) + self.assertIn("%s->profile_left_align(Z)Z" % HOOK, text) + + def test_parameter_register_is_kept(self): + text, hits = profile_features.rewrite_smali(PARAMETER_READ, ALL_FLAGS) + self.assertEqual(hits, {"repost_support_digg": 1}) + self.assertIn("invoke-static/range {p1 .. p1}, %s->repost_support_digg(I)I" % HOOK, text) + self.assertIn(" move-result p1\n\n return p1\n", text) + + def test_unknown_keys_are_left_alone(self): + text, hits = profile_features.rewrite_smali( + INT_READ.replace("profile_left_align", "some_other_flag"), ALL_FLAGS) + self.assertEqual(hits, {}) + self.assertNotIn("LihonTFlags", text) + + def test_rewriting_twice_changes_nothing(self): + once, _ = profile_features.rewrite_smali(INT_READ, ALL_FLAGS) + twice, hits = profile_features.rewrite_smali(once, ALL_FLAGS) + self.assertEqual(hits, {}) + self.assertEqual(once, twice) + + def test_old_const_override_is_replaced(self): + text, hits = profile_features.rewrite_smali(LEGACY, ALL_FLAGS) + self.assertEqual(hits, {"profile_left_align": 1}) + self.assertNotIn("const/16", text) + self.assertEqual(text.count("# lihont feature flag"), 1) + self.assertIn("(I)I\n move-result v2\n\n return v2\n", text) + + def test_marker_without_a_known_block_does_not_eat_code(self): + stray = INT_READ.replace( + " move-result v2\n", + " move-result v2\n # lihont feature flag: profile_left_align\n") + text, hits = profile_features.rewrite_smali(stray, ALL_FLAGS) + self.assertEqual(hits, {"profile_left_align": 1}) + self.assertIn(" return v2\n", text) + + +class RuntimeTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.javac = shutil.which("javac") + cls.java = shutil.which("java") + if not cls.javac or not cls.java: + raise RuntimeError("Flag runtime tests require a JDK with java and javac") + cls.android_jar = ROOT / "tools" / "android.jar" + if not cls.android_jar.is_file(): + raise RuntimeError("tools/android.jar must be available") + cls.temporary = tempfile.TemporaryDirectory(prefix="lihont-flags-tests-") + cls.addClassCleanup(cls.temporary.cleanup) + cls.classes = Path(cls.temporary.name) / "classes" + cls.classes.mkdir() + cls.environment = dict(os.environ) + cls.environment.pop("JAVA_TOOL_OPTIONS", None) + settings = SRC / "settings" + cls.run_command([ + cls.javac, "--release", "17", "-encoding", "UTF-8", + "-cp", str(cls.android_jar), "-d", str(cls.classes), + str(settings / "ModSettings.java"), str(settings / "SettingsCodec.java"), + str(settings / "SettingsRepository.java"), str(settings / "AndroidSettingsStorage.java"), + str(settings / "SettingsHost.java"), str(SRC / "LihonTFlags.java"), str(HARNESS), + ]) + + @classmethod + def run_command(cls, command): + result = subprocess.run( + command, capture_output=True, text=True, encoding="utf-8", + env=cls.environment, timeout=60, + ) + if result.returncode: + raise AssertionError( + f"Command failed with exit {result.returncode}: {command[0]}\n" + + result.stdout + result.stderr + ) + return result + + @staticmethod + def entries(): + return (["banners:%s:%d" % item for item in profile_features.PROFILE_FLAGS.items()] + + ["reposts:%s:%d" % item for item in profile_features.REPOST_FLAGS.items()]) + + def run_scenario(self, name): + result = self.run_command([ + self.java, "-cp", str(self.classes) + os.pathsep + str(self.android_jar), + "lihont.two.FlagsTest", name, + ] + self.entries()) + return result.stdout.strip() + + def test_each_switch_controls_only_its_own_flags(self): + self.assertEqual(self.run_scenario("matrix"), "PASS matrix") + + def test_saved_change_applies_to_the_next_process(self): + self.assertEqual(self.run_scenario("snapshot"), "PASS snapshot") + + def test_defaults_apply_while_settings_cannot_be_read(self): + self.assertEqual(self.run_scenario("unavailable"), "PASS unavailable") + + def test_runtime_offers_exactly_what_the_patcher_calls(self): + offered = set(self.run_scenario("surface").splitlines()) + called = {target.split("->", 1)[1] for target in profile_features.runtime_targets()} + self.assertEqual(offered, called) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_region.py b/tests/test_region.py new file mode 100644 index 0000000..77003d3 --- /dev/null +++ b/tests/test_region.py @@ -0,0 +1,98 @@ +import unittest + +from lihont import region + + +def wrap(body: str, header: str) -> str: + return header + "\n" + body + ".end method\n" + + +BPEA_HEADER = ".method public static LIZIZ(Landroid/telephony/TelephonyManager;Lcom/bytedance/bpea/basics/Cert;)Ljava/lang/String;" + +BPEA_BODY = """ + .registers 6 + const-string v1, "TelephonyManager_getNetworkCountryIso" + const v0, 0x18a39 + invoke-static {v0, v1, p1}, LX/1DHU;->LIZ(ILjava/lang/String;Lcom/bytedance/bpea/basics/Cert;)LX/0kmX; + return-object v0 +""" + +DELEGATE_HEADER = ".method public static LIZJ(Landroid/telephony/TelephonyManager;)Ljava/lang/String;" + +DELEGATE_BODY = """ + .registers 3 + invoke-static {p0, v0}, LX/1DHU;->LIZIZ(Landroid/telephony/TelephonyManager;Lcom/bytedance/bpea/basics/Cert;)Ljava/lang/String; + move-result-object v0 + return-object v0 +""" + +MONITOR_HEADER = ".method public static LLIIIL(Landroid/telephony/TelephonyManager;LX/0yw4;)Ljava/lang/String;" + +MONITOR_BODY = """ + .registers 19 + const/16 v4, 0x2866 + const-string v14, "getSimCountryIso" + invoke-virtual {v9}, Landroid/telephony/TelephonyManager;->getSimCountryIso()Ljava/lang/String; + return-object v7 +""" + +LAMBDA_HEADER = ".class public final LX/0kmK;\n.super LX/0MXs;\n.method public final invoke(Ljava/lang/Object;)Ljava/lang/Object;" + +LAMBDA_BODY = """ + .registers 3 + iget-object v0, p0, LX/0kmK;->LL:Landroid/telephony/TelephonyManager; + invoke-virtual {v0}, Landroid/telephony/TelephonyManager;->getNetworkOperator()Ljava/lang/String; + move-result-object v0 + return-object v0 +""" + + +class RegionPatchTest(unittest.TestCase): + def test_bpea_wrapper_is_replaced_with_constant(self): + text = wrap(BPEA_BODY, BPEA_HEADER) + changed, hits = region.rewrite(text, "LX/1DHU;") + self.assertEqual(hits, {"bpea_LIZIZ": 1}) + self.assertIn("LihonTRegion;->iso()", changed) + self.assertNotIn("TelephonyManager_getNetworkCountryIso", changed) + self.assertEqual(region.rewrite(changed, "LX/1DHU;"), (changed, {})) + + def test_bpea_delegate_is_replaced(self): + text = wrap(DELEGATE_BODY, DELEGATE_HEADER) + changed, hits = region.rewrite(text, "LX/1DHU;") + self.assertEqual(hits, {"bpea_delegate_LIZJ": 1}) + self.assertIn("LihonTRegion;->iso()", changed) + + def test_bpea_wrapper_drift_raises(self): + text = wrap(BPEA_BODY.replace("getNetworkCountryIso", "getSomethingElse").replace("TelephonyManager_", "TelephonyManager_getSomething"), BPEA_HEADER) + with self.assertRaises(ValueError): + region.rewrite(text, "LX/1DHU;") + + def test_monitor_body_replaced_by_marker(self): + text = wrap(MONITOR_BODY, MONITOR_HEADER) + changed, hits = region.rewrite_monitor(text) + self.assertEqual(hits, {"monitor_LLIIIL": 1}) + self.assertIn("LihonTRegion;->iso()", changed) + + def test_monitor_other_methods_untouched(self): + text = wrap(MONITOR_BODY.replace("LLIIIL", "LLIIJL").replace("0x2866", "0x2999"), MONITOR_HEADER.replace("LLIIIL", "LLIIJL")) + changed, hits = region.rewrite_monitor(text) + self.assertEqual(hits, {}) + self.assertEqual(changed, text) + + def test_lambda_is_replaced(self): + text = LAMBDA_HEADER + "\n" + LAMBDA_BODY + ".end method\n" + changed, hits = region.rewrite_lambda(text, "LX/0kmK;") + self.assertEqual(hits, {"lambda_LX/0kmK;": 1}) + self.assertIn("LihonTRegion;->operator()", changed) + self.assertNotIn("getNetworkOperator", changed) + + def test_expected_hits_cover_every_audited_point(self): + expected = region.expected_hits() + self.assertEqual(len(expected), 6 + 5 + 5 + 2) + self.assertIn("bpea_LJIIJJI", expected) + self.assertIn("monitor_LLIIJI", expected) + self.assertIn("lambda_LX/0kmL;", expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_compiles.py b/tests/test_runtime_compiles.py new file mode 100644 index 0000000..8645008 --- /dev/null +++ b/tests/test_runtime_compiles.py @@ -0,0 +1,35 @@ +"""The whole LihonT runtime (settings screen, hooks, region, flags) compiles against Android. + +Nothing here is executed: screens need a device. This catches a broken edit in the UI code +before a 350 MB build does. +""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class RuntimeCompileTest(unittest.TestCase): + def test_runtime_sources_compile_against_android_jar(self): + javac = shutil.which("javac") + android_jar = ROOT / "tools" / "android.jar" + if not javac: + raise RuntimeError("This test requires a JDK with javac") + self.assertTrue(android_jar.is_file(), "tools/android.jar must be available") + sources = sorted(str(path) for path in (ROOT / "src" / "lihont" / "two").rglob("*.java")) + self.assertTrue(sources) + with tempfile.TemporaryDirectory(prefix="lihont-runtime-compile-") as out: + result = subprocess.run( + [javac, "--release", "17", "-nowarn", "-Xlint:-options", "-encoding", "UTF-8", + "-cp", str(android_jar), "-d", out] + sources, + capture_output=True, text=True, encoding="utf-8", timeout=120, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings_model.py b/tests/test_settings_model.py new file mode 100644 index 0000000..a2c4e04 --- /dev/null +++ b/tests/test_settings_model.py @@ -0,0 +1,108 @@ +"""Run the platform-independent settings contract on a real JVM. + +The Android file adapter is compiled against android.jar, not executed under +desktop stubs. Device/storage integration belongs to the later APK stage. +""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / "src" / "lihont" / "two" / "settings" +HARNESS = ROOT / "tests" / "java" / "lihont" / "two" / "settings" / "SettingsModelTest.java" + + +class SettingsModelTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.javac = shutil.which("javac") + cls.java = shutil.which("java") + if not cls.javac or not cls.java: + raise RuntimeError("Settings model tests require a JDK with java and javac") + cls.temporary = tempfile.TemporaryDirectory(prefix="lihont-settings-tests-") + cls.addClassCleanup(cls.temporary.cleanup) + cls.classes = Path(cls.temporary.name) / "classes" + cls.classes.mkdir() + cls.environment = dict(os.environ) + cls.environment.pop("JAVA_TOOL_OPTIONS", None) + cls.run_command([ + cls.javac, "--release", "17", "-encoding", "UTF-8", "-d", str(cls.classes), + str(PACKAGE / "ModSettings.java"), + str(PACKAGE / "SettingsCodec.java"), + str(PACKAGE / "SettingsRepository.java"), + str(HARNESS), + ]) + + @classmethod + def run_command(cls, command): + result = subprocess.run( + command, capture_output=True, text=True, encoding="utf-8", + env=cls.environment, timeout=60, + ) + if result.returncode: + raise AssertionError( + f"Command failed with exit {result.returncode}: {command[0]}\n" + + result.stdout + result.stderr + ) + return result + + def run_scenario(self, name): + result = self.run_command([ + self.java, "-cp", str(self.classes), + "lihont.two.settings.SettingsModelTest", name, + ]) + self.assertEqual(result.stdout.strip(), "PASS " + name) + + def test_defaults_and_region_warning(self): + self.run_scenario("defaults") + + def test_all_iso_countries_roundtrip(self): + self.run_scenario("countries") + + def test_typed_inputs_reject_invalid_values(self): + self.run_scenario("validation") + + def test_restart_policy(self): + self.run_scenario("restart") + + def test_valid_json_and_portable_roundtrip(self): + self.run_scenario("codec") + + def test_invalid_documents_are_rejected(self): + self.run_scenario("invalid") + + def test_settings_persist_across_repository_instances(self): + self.run_scenario("persistence") + + def test_active_snapshot_and_noop_write(self): + self.run_scenario("snapshot") + + def test_reset_restores_defaults_and_persists_them(self): + self.run_scenario("reset") + + def test_invalid_imports_preserve_memory_and_disk(self): + self.run_scenario("atomicImport") + + def test_io_failures_are_visible_and_preserve_previous_settings(self): + self.run_scenario("ioFailures") + + def test_corrupt_or_future_stored_data_is_not_overwritten(self): + self.run_scenario("corruptStorage") + + def test_android_adapter_compiles_against_android_api(self): + android_jar = ROOT / "tools" / "android.jar" + self.assertTrue(android_jar.is_file(), "tools/android.jar must be available") + self.run_command([ + self.javac, "--release", "17", "-encoding", "UTF-8", + "-cp", str(android_jar) + os.pathsep + str(self.classes), + "-d", str(self.classes), str(PACKAGE / "AndroidSettingsStorage.java"), + ]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings_patch.py b/tests/test_settings_patch.py new file mode 100644 index 0000000..82b0d73 --- /dev/null +++ b/tests/test_settings_patch.py @@ -0,0 +1,152 @@ +import unittest + +from lihont import settings_patch + + +DVK_SAMPLE = """ +.class public final LX/1DvK; +.super Ljava/lang/Object; + +.method public static final LIZ(LX/0CWi;ZZLX/0LYA;I)V + .registers 40 + move-object/from16 v5, p0 + invoke-virtual {v6}, LX/0MNM;->LJJJJZ()LX/0MN8; + move-result-object v1 + if-eqz v1, :cond_5a + new-instance v0, Lkotlin/jvm/internal/AwS2S0121000_35; + + .line 83886157 + .line 83886158 + const/16 v11, 0x23 + + .line 83886159 + .line 83886160 + move-object v6, v0 + + .line 83886161 + move-object v7, v5 + + .line 83886162 + move v8, v4 + + .line 83886163 + move v9, v3 + + .line 83886164 + move v10, v2 + + .line 83886165 + invoke-direct/range {v6 .. v11}, Lkotlin/jvm/internal/AwS2S0121000_35;->(LX/0CWi;ZZII)V + iput-object v0, v1, LX/0MN8;->LIZLLL:Lkotlin/jvm/functions/Function2; +:cond_5a + return-void + + iget-object v0, v5, LX/0CWi;->LLJJIJIIJIL:Ljava/lang/Integer; + + .line 83886180 + .line 83886181 + const/4 v12, 0x0 + + .line 83886182 + if-eqz v0, :cond_e5 + + .line 83886183 + .line 83886184 + invoke-virtual {v0}, Ljava/lang/Integer;->intValue()I + + .line 83886185 + .line 83886186 + .line 83886187 + move-result v0 + + .line 83886188 + :goto_6c + invoke-static {v0, v6}, LX/0KwU;->LIZIZ(ILX/0LYA;)Ljava/lang/String; + + .line 83886189 + .line 83886190 + .line 83886191 + move-result-object v11 + + .line 83886192 + const/4 v10, 0x0 + + .line 83886193 + iget-object v0, v5, LX/0CWi;->LLJJIJI:LX/0LGU; + + .line 83886194 + .line 83886195 + if-nez v0, :cond_77 + + .line 83886196 + .line 83886197 + sget-object v0, LX/0L1b;->LIZ:LX/0LGU; + + .line 83886198 + .line 83886199 + :cond_77 + invoke-static {v0, v6}, LX/0Lis;->LJFF(LX/0LGU;LX/0LYA;)LX/0MIo; + + :cond_e5 + const/4 v0, 0x0 + + .line 83886310 + goto :goto_6c +.end method +""" + +DUR_SAMPLE = """ +.class public final LX/1DuR; +.super Ljava/lang/Object; + +.method public static final LJIIIZ(LX/1Dgo;ZZLX/0LYA;I)V + const/4 v14, 0x4 + + .line 83886338 + move-object v12, v6 + + .line 83886339 + invoke-static/range {v9 .. v14}, LX/1Dvf;->LIZIZ(ZZLcom/ss/android/ugc/aweme/setting/ui/rvmpcompose/group/top/cell/ManagePostsVM;LX/0LYA;II)V +.end method +""" + +LDM_SAMPLE = """ +.class public final LX/0LDm; +.super Ljava/lang/Object; + +.method public static final LIZ(LX/0M1d;Ljava/lang/String;ILjava/lang/String;ZZLX/0LDk;ZLX/0LRh;ZLX/0LDv;ZZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;LX/1Dw4;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function2;LX/0LYA;IIII)V + .registers 78 + + .prologue + .line 503316480 + move-object/from16 p20, p20 + return-void +.end method +""" + + +class SettingsPatchTest(unittest.TestCase): + def test_rewrite_1dvk_injects_lihont_method(self): + patched = settings_patch.rewrite_1dvk(DVK_SAMPLE) + self.assertIn(".method public static final lihont(", patched) + self.assertIn("SettingsCell;->title()Ljava/lang/String;", patched) + self.assertIn("LX/0LB8;->LLLLIIL:LX/0LGU;", patched) + self.assertIn("SettingsCell;->again(", patched) + self.assertNotIn(":cond_e5", patched.split("lihont(")[1]) + with self.assertRaises(ValueError): + settings_patch.rewrite_1dvk(patched) + + def test_rewrite_1dur_injects_draw_call(self): + patched = settings_patch.rewrite_1dur(DUR_SAMPLE) + self.assertIn("invoke-static {v9, v6}, Llihont/two/settings/SettingsCell;->draw(ZLjava/lang/Object;)Z", patched) + self.assertIn("move-result v9", patched) + with self.assertRaises(ValueError): + settings_patch.rewrite_1dur(patched) + + def test_rewrite_0ldm_injects_transform_icon(self): + patched = settings_patch.rewrite_0ldm(LDM_SAMPLE) + self.assertIn("SettingsCell;->transformIcon(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object;", patched) + self.assertIn("move-object/from16 p8, v0", patched) + with self.assertRaises(ValueError): + settings_patch.rewrite_0ldm(patched) + diff --git a/tests/test_theme_patch.py b/tests/test_theme_patch.py new file mode 100644 index 0000000..80f4f4a --- /dev/null +++ b/tests/test_theme_patch.py @@ -0,0 +1,259 @@ +import unittest + +from lihont import theme_patch + +ACCESSOR = """\ +.class public final LX/0LAr; +.super Ljava/lang/Object; +.source "SourceFile" + +.method public static LIZ(LX/0LYA;)LX/0LFj; + .registers 2 + + sget-object v0, LX/0LAs;->LIZ:LX/0MO7; + + invoke-interface {p0, v0}, LX/0LYA;->LJJIZ(LX/0MNv;)Ljava/lang/Object; + + move-result-object v0 + + check-cast v0, LX/0LFj; + + return-object v0 +.end method + +.method public static LIZIZ(LX/0LYA;)LX/1MiX; + .registers 2 + .prologue + + sget-object v0, LX/1MiY;->LIZ:LX/0MO7; + + invoke-interface {p0, v0}, LX/0LYA;->LJJIZ(LX/0MNv;)Ljava/lang/Object; + + move-result-object v0 + + check-cast v0, LX/1MiX; + + return-object v0 +.end method +""" + +LABEL = """.class public final LX/0Lis; +.super Ljava/lang/Object; + +.method public static final LIZIZ(LX/0DMR;LX/0LYA;I)V + .registers 20 + + invoke-static {v0}, LX/0LAr;->LIZIZ(LX/0LYA;)LX/1MiX; + + move-result-object v1 + + iget-wide v14, v1, LX/1MiX;->d4:J + + return-void +.end method + +.method public static final LIZJ(Ljava/lang/String;ZZZLkotlin/jvm/functions/Function0;LX/0LYA;II)V + .registers 40 + + invoke-static {v0}, LX/0LAr;->LIZIZ(LX/0LYA;)LX/1MiX; + + move-result-object v11 + + iget-wide v12, v11, LX/1MiX;->d4:J + + invoke-static {v0}, LX/0LAr;->LJ(LX/0LYA;)LX/0LAb; + + .line 83886186 + move-result-object v3 + + .line 83886187 + iget-wide v8, v3, LX/1MiX;->d4:J + + :goto_6d + return-void +.end method +""" + +ROW = """\ +.class public final LX/0LDf; +.super Ljava/lang/Object; + +.method public final invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + .registers 52 + + invoke-interface {v0}, LX/0LYA;->LJ()V + + .line 33555008 + .line 33555009 + .line 33555010 + move-object/from16 v9, v17 + + .line 33555011 + .line 33555012 + invoke-static {v9, v1, v13}, LX/0M8m;->LIZIZ(LX/0M1d;ZLkotlin/jvm/functions/Function1;)LX/0M1d; + + .line 33555013 + move-result-object v1 + + invoke-interface {v4, v0}, LX/0LDi;->LJIIJJI(LX/0LYA;)J + + move-result-wide v4 + + .line 33555175 + invoke-static {v8, v8, v7, v7}, LX/0Kzn;->LIZJ(FFFF)LX/0LQf; + + .line 33555176 + .line 33555177 + move-result-object v7 + + move-object/from16 v6, v17 + + return-object v1 +.end method +""" + + +CARD = """.class public final LX/0Lk9; +.super Ljava/lang/Object; + +.method public static final LIZ(LX/0CX2;ZZLcom/ss/android/ugc/aweme/setting/ui/rvmpcompose/group/content/cell/LanguageVM;LX/0LYA;I)V + .registers 40 + + invoke-static {v15}, LX/0Kzn;->LIZIZ(F)LX/0LQf; + + move-result-object v15 + + invoke-static {v11, v0, v1, v15}, LX/0L5k;->LIZIZ(LX/0M1d;JLX/0LQx;)LX/0M1d; + + .line 33555200 + .line 33555201 + move-result-object v0 + + sget-object v1, LX/0L0L;->LIZ:LX/0L0J; + + return-void +.end method +""" + + +class ColoursAccessorTest(unittest.TestCase): + def test_hook_sits_between_the_read_and_the_cast(self): + text = theme_patch.rewrite_colors_accessor(ACCESSOR) + self.assertIn( + " move-result-object v0\n\n" + " invoke-static {v0, p0}, %s\n\n" + " move-result-object v0\n\n" + " check-cast v0, LX/1MiX;\n" % theme_patch.HOOK, text) + + def test_other_methods_are_untouched(self): + text = theme_patch.rewrite_colors_accessor(ACCESSOR) + self.assertEqual(text.count("NativeTheme"), 1) + self.assertIn("check-cast v0, LX/0LFj;\n\n return-object v0\n", text) + + def test_a_second_pass_is_refused(self): + once = theme_patch.rewrite_colors_accessor(ACCESSOR) + with self.assertRaises(ValueError): + theme_patch.rewrite_colors_accessor(once) + + def test_a_missing_method_is_refused(self): + with self.assertRaises(ValueError): + theme_patch.rewrite_colors_accessor(ACCESSOR.replace("LIZIZ", "LIZZZ")) + + def test_an_unexpected_register_layout_is_refused(self): + with self.assertRaises(ValueError): + theme_patch.rewrite_colors_accessor( + ACCESSOR.replace(".registers 2\n .prologue", ".registers 3\n .prologue")) + + def test_runtime_target_matches_the_hook(self): + self.assertEqual(theme_patch.runtime_targets(), [theme_patch.HOOK]) + + +class SectionLabelTest(unittest.TestCase): + def test_both_reads_in_the_group_title_are_rewritten(self): + text = theme_patch.rewrite_section_label(LABEL) + self.assertIn( + " invoke-static/range {v11 .. v11}, %s\n\n move-result-wide v12\n" + % theme_patch.SECTION_HOOK, text) + self.assertIn( + " invoke-static/range {v3 .. v3}, %s\n\n move-result-wide v8\n" + % theme_patch.SECTION_HOOK, text) + self.assertEqual(text.count("sectionLabel"), 2) + # The other composable keeps reading the token itself. + self.assertIn("iget-wide v14, v1, LX/1MiX;->d4:J", text) + + def test_a_second_pass_is_refused(self): + once = theme_patch.rewrite_section_label(LABEL) + with self.assertRaises(ValueError): + theme_patch.rewrite_section_label(once) + + def test_a_missing_composable_is_refused(self): + with self.assertRaises(ValueError): + theme_patch.rewrite_section_label( + LABEL.replace(".method public static final LIZJ(", ".method public static final LIZZ(")) + + def test_a_title_with_a_different_number_of_reads_is_refused(self): + with self.assertRaises(ValueError): + theme_patch.rewrite_section_label( + LABEL.replace("v8, v3, LX/1MiX;->d4:J", "v8, v3, LX/1MiX;->a4:J")) + + def test_section_target_is_the_hook(self): + self.assertEqual(theme_patch.section_targets(), [theme_patch.SECTION_HOOK]) + + +class RowLambdaTest(unittest.TestCase): + def test_modifier_and_shape_are_routed_through_the_hooks(self): + text = theme_patch.rewrite_row_lambda(ROW) + self.assertIn( + " move-object/from16 v9, v17\n" + " invoke-static {v9, v0}, %s\n\n" + " move-result-object v9\n\n" + " check-cast v9, LX/0M1d;\n" % theme_patch.ROW_MODIFIER_HOOK, text) + self.assertIn("invoke-static {v8, v8, v7, v7, v0}, %s\n" % theme_patch.ROW_SHAPE_HOOK, text) + self.assertIn(" move-result-object v7\n\n check-cast v7, LX/0LQf;\n", text) + self.assertNotIn("LX/0Kzn;->LIZJ", text) + # The original semantics call still follows the padded modifier. + self.assertIn("invoke-static {v9, v1, v13}, LX/0M8m;->LIZIZ", text) + + def test_a_second_pass_is_refused(self): + once = theme_patch.rewrite_row_lambda(ROW) + with self.assertRaises(ValueError): + theme_patch.rewrite_row_lambda(once) + + def test_a_missing_shape_call_is_refused(self): + with self.assertRaises(ValueError): + theme_patch.rewrite_row_lambda(ROW.replace("LIZJ(FFFF)", "LIZJ(FF)")) + + def test_a_missing_modifier_start_is_refused(self): + with self.assertRaises(ValueError): + theme_patch.rewrite_row_lambda( + ROW.replace("move-object/from16 v9, v17", "move-object/from16 v9, v18")) + + def test_row_targets_are_the_hooks(self): + self.assertEqual(theme_patch.row_targets(), + [theme_patch.ROW_MODIFIER_HOOK, theme_patch.ROW_SHAPE_HOOK]) + + +class LanguageCardTest(unittest.TestCase): + def test_the_card_background_call_goes_through_the_hook(self): + text = theme_patch.rewrite_language_card(CARD) + self.assertIn(" invoke-static {v11, v0, v1, v15, v3}, %s\n" % theme_patch.CARD_HOOK, text) + self.assertIn(" move-result-object v0\n\n check-cast v0, LX/0M1d;\n", text) + self.assertNotIn("LX/0L5k;->LIZIZ", text) + # The rounded shape built just before it is TikTok's own call and stays. + self.assertIn("LX/0Kzn;->LIZIZ(F)LX/0LQf;", text) + + def test_a_second_pass_is_refused(self): + once = theme_patch.rewrite_language_card(CARD) + with self.assertRaises(ValueError): + theme_patch.rewrite_language_card(once) + + def test_a_missing_background_call_is_refused(self): + with self.assertRaises(ValueError): + theme_patch.rewrite_language_card(CARD.replace("v11, v0, v1, v15}", "v10, v0, v1, v15}")) + + def test_card_target_is_the_hook(self): + self.assertEqual(theme_patch.card_targets(), [theme_patch.CARD_HOOK]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/adb_controller.py b/tools/adb_controller.py new file mode 100644 index 0000000..9fbee27 --- /dev/null +++ b/tools/adb_controller.py @@ -0,0 +1,834 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LihonT ADB Phone Controller (Mini-App) +Control your Android phone with mouse clicks, gestures, hotkeys, and color eyedropper over ADB. +""" + +from __future__ import annotations + +import io +import os +import queue +import re +import subprocess +import sys +import threading +import time +from pathlib import Path + +try: + import customtkinter as ctk + from PIL import Image, ImageTk +except ImportError as err: + print(f"Missing required GUI package: {err}") + print("Please run: pip install customtkinter pillow") + sys.exit(1) + +# Default ADB locations on Windows +ADB_CANDIDATES = [ + os.path.expandvars(r"%LOCALAPPDATA%\Android\Sdk\platform-tools\adb.exe"), + "adb", + r"C:\platform-tools\adb.exe", +] + + +def find_adb() -> str: + for candidate in ADB_CANDIDATES: + try: + res = subprocess.run([candidate, "version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if res.returncode == 0: + return candidate + except Exception: + continue + return "adb" + + +ADB_BIN = find_adb() + + +class AdbDeviceManager: + """Manages ADB commands and worker thread queue.""" + + def __init__(self, adb_path: str = ADB_BIN): + self.adb = adb_path + self.cmd_queue: queue.Queue[list[str]] = queue.Queue() + self.worker_thread = threading.Thread(target=self._worker_loop, daemon=True) + self.worker_thread.start() + + def _worker_loop(self) -> None: + while True: + cmd = self.cmd_queue.get() + try: + subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + print("ADB worker error:", e) + finally: + self.cmd_queue.task_done() + + def list_devices(self) -> list[str]: + try: + res = subprocess.run([self.adb, "devices"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + lines = res.stdout.strip().splitlines() + devices = [] + for line in lines[1:]: + parts = line.strip().split() + if len(parts) >= 2 and parts[1] == "device": + devices.append(parts[0]) + return devices + except Exception as e: + print("Error listing devices:", e) + return [] + + def get_screen_size(self, serial: str) -> tuple[int, int]: + try: + res = subprocess.run([self.adb, "-s", serial, "shell", "wm", "size"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + match = re.search(r"(\d+)x(\d+)", res.stdout) + if match: + return int(match.group(1)), int(match.group(2)) + except Exception as e: + print("Error getting screen size:", e) + return (1080, 2400) + + def capture_screen(self, serial: str) -> Image.Image | None: + try: + proc = subprocess.run([self.adb, "-s", serial, "exec-out", "screencap", "-p"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if proc.returncode == 0 and len(proc.stdout) > 1000: + return Image.open(io.BytesIO(proc.stdout)) + except Exception as e: + pass + return None + + def tap(self, serial: str, x: int, y: int) -> None: + self.cmd_queue.put([self.adb, "-s", serial, "shell", "input", "tap", str(x), str(y)]) + + def swipe(self, serial: str, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300) -> None: + self.cmd_queue.put([self.adb, "-s", serial, "shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(duration_ms)]) + + def keyevent(self, serial: str, code: int | str) -> None: + self.cmd_queue.put([self.adb, "-s", serial, "shell", "input", "keyevent", str(code)]) + + def input_text(self, serial: str, text: str) -> None: + escaped = text.replace(" ", "%s").replace("&", "\\&").replace("<", "\\<").replace(">", "\\>") + self.cmd_queue.put([self.adb, "-s", serial, "shell", "input", "text", escaped]) + + def connect_wifi(self, address: str) -> tuple[bool, str]: + try: + res = subprocess.run([self.adb, "connect", address], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + return ("connected" in res.stdout.lower(), res.stdout.strip()) + except Exception as e: + return (False, str(e)) + + +class PhoneControllerApp(ctk.CTk): + def __init__(self): + super().__init__() + + self.title("LihonT ADB Phone Controller") + self.geometry("960x940") + self.minsize(750, 750) + + ctk.set_appearance_mode("dark") + ctk.set_default_color_theme("blue") + + self.mgr = AdbDeviceManager() + self.current_serial: str | None = None + self.native_w: int = 1080 + self.native_h: int = 2400 + + # Viewport geometry cache + self.img_disp_x: int = 0 + self.img_disp_y: int = 0 + self.img_disp_w: int = 0 + self.img_disp_h: int = 0 + + # Interaction tracking + self.drag_start_x: int | None = None + self.drag_start_y: int | None = None + self.drag_start_time: float = 0.0 + + # Eyedropper (Color Picker) state + self.picker_mode: bool = False + self.latest_frame: Image.Image | None = None + self.picked_color: str = "#D0BCFF" + self.picked_rgb: str = "rgb(208, 188, 255)" + self.color_history: list[str] = ["#D0BCFF", "#381E72", "#141218", "#E6E0E9", "#4F378B", "#8C1D40"] + self.loupe_photo: ImageTk.PhotoImage | None = None + + # Threading control + self.is_streaming = True + self.photo_image: ImageTk.PhotoImage | None = None + self.pending_frame: Image.Image | None = None + self.frame_lock = threading.Lock() + + self._build_ui() + self._refresh_device_list() + + # Start background screen grab loop + self.stream_thread = threading.Thread(target=self._stream_loop, daemon=True) + self.stream_thread.start() + + # Periodic UI update (30 FPS check) + self.after(33, self._update_canvas_loop) + + def _build_ui(self) -> None: + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=0) + self.grid_rowconfigure(0, weight=1) + + # ------------------- LEFT: Phone Viewport ------------------- + self.left_frame = ctk.CTkFrame(self, corner_radius=16, fg_color="#141218") + self.left_frame.grid(row=0, column=0, padx=12, pady=12, sticky="nsew") + self.left_frame.grid_rowconfigure(0, weight=1) + self.left_frame.grid_columnconfigure(0, weight=1) + + self.canvas = ctk.CTkCanvas(self.left_frame, bg="#141218", highlightthickness=0, cursor="hand2") + self.canvas.grid(row=0, column=0, padx=8, pady=8, sticky="nsew") + + # Mouse event bindings + self.canvas.bind("", self._on_canvas_press) + self.canvas.bind("", self._on_canvas_release) + self.canvas.bind("", self._on_canvas_motion) + self.canvas.bind("", self._on_canvas_right_click) + self.canvas.bind("", self._on_canvas_mousewheel) + self.canvas.bind("", self._on_canvas_double_click) + self.canvas.bind("", self._on_canvas_resize) + + # Global hotkeys + self.bind("", lambda e: self._on_esc()) + self.bind("", lambda e: self._on_paste()) + self.bind("

    ", lambda e: self._toggle_picker_mode()) + self.bind("

    ", lambda e: self._toggle_picker_mode()) + self.bind("", lambda e: self._toggle_picker_mode()) + self.bind("", lambda e: self._toggle_picker_mode()) + + # ------------------- RIGHT: Controls Sidebar ------------------- + self.side_frame = ctk.CTkScrollableFrame(self, width=340, corner_radius=16, fg_color="#1E1A22") + self.side_frame.grid(row=0, column=1, padx=(0, 12), pady=12, sticky="nsew") + + # Header Title + title_label = ctk.CTkLabel( + self.side_frame, + text="LihonT Remote", + font=ctk.CTkFont(size=22, weight="bold"), + text_color="#D0BCFF" + ) + title_label.pack(pady=(10, 2), anchor="w") + + sub_label = ctk.CTkLabel( + self.side_frame, + text="Управление кликом, свайпами и пипетка цвета по ADB", + font=ctk.CTkFont(size=12), + text_color="#CAC4D0" + ) + sub_label.pack(pady=(0, 12), anchor="w") + + # Device selection card + dev_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") + dev_card.pack(fill="x", pady=6) + + ctk.CTkLabel(dev_card, text="Устройство ADB:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 4)) + self.dev_dropdown = ctk.CTkComboBox(dev_card, values=["Поиск..."], command=self._on_select_device) + self.dev_dropdown.pack(fill="x", padx=12, pady=(0, 8)) + + btn_row = ctk.CTkFrame(dev_card, fg_color="transparent") + btn_row.pack(fill="x", padx=12, pady=(0, 10)) + btn_row.grid_columnconfigure(0, weight=1) + btn_row.grid_columnconfigure(1, weight=1) + + self.btn_refresh = ctk.CTkButton(btn_row, text="Обновить", command=self._refresh_device_list, height=28, fg_color="#4F378B", hover_color="#654B9E") + self.btn_refresh.grid(row=0, column=0, padx=(0, 4), sticky="ew") + + self.btn_wifi = ctk.CTkButton(btn_row, text="Wi-Fi ADB", command=self._open_wifi_dialog, height=28, fg_color="#382E47", hover_color="#493D5C") + self.btn_wifi.grid(row=0, column=1, padx=(4, 0), sticky="ew") + + self.status_lbl = ctk.CTkLabel(dev_card, text="Ожидание устройства...", text_color="#E0A040", font=ctk.CTkFont(size=12)) + self.status_lbl.pack(anchor="w", padx=12, pady=(0, 10)) + + # ------------------- Color Eyedropper Card ------------------- + self.picker_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") + self.picker_card.pack(fill="x", pady=8) + + picker_header = ctk.CTkFrame(self.picker_card, fg_color="transparent") + picker_header.pack(fill="x", padx=12, pady=(10, 4)) + + ctk.CTkLabel( + picker_header, + text="Пипетка цвета (Eyedropper):", + font=ctk.CTkFont(size=13, weight="bold"), + text_color="#E6E0E9" + ).pack(side="left") + + self.picker_badge = ctk.CTkLabel( + picker_header, + text="ВЫКЛ", + font=ctk.CTkFont(size=10, weight="bold"), + text_color="#CAC4D0", + fg_color="#382E47", + corner_radius=6, + width=54, + height=20 + ) + self.picker_badge.pack(side="right") + + self.btn_picker = ctk.CTkButton( + self.picker_card, + text="Включить пипетку [P]", + command=self._toggle_picker_mode, + fg_color="#4F378B", + hover_color="#654B9E", + height=32, + font=ctk.CTkFont(size=12, weight="bold") + ) + self.btn_picker.pack(fill="x", padx=12, pady=4) + + inspect_frame = ctk.CTkFrame(self.picker_card, fg_color="#1E1A22", corner_radius=8) + inspect_frame.pack(fill="x", padx=12, pady=6) + inspect_frame.grid_columnconfigure(0, weight=0) + inspect_frame.grid_columnconfigure(1, weight=1) + + # Magnifier loupe canvas + self.loupe_canvas = ctk.CTkCanvas( + inspect_frame, + width=100, + height=100, + bg="#141218", + highlightthickness=1, + highlightbackground="#4F378B" + ) + self.loupe_canvas.grid(row=0, column=0, padx=8, pady=8) + + # Color info + info_col = ctk.CTkFrame(inspect_frame, fg_color="transparent") + info_col.grid(row=0, column=1, padx=(0, 8), pady=8, sticky="nsew") + + swatch_row = ctk.CTkFrame(info_col, fg_color="transparent") + swatch_row.pack(fill="x", pady=(2, 4)) + + self.swatch_view = ctk.CTkFrame(swatch_row, width=28, height=28, corner_radius=6, fg_color=self.picked_color) + self.swatch_view.pack(side="left", padx=(0, 6)) + self.swatch_view.pack_propagate(False) + + self.hex_label = ctk.CTkLabel( + swatch_row, + text=self.picked_color, + font=ctk.CTkFont(size=15, weight="bold"), + text_color="#FFFFFF", + cursor="hand2" + ) + self.hex_label.pack(side="left", anchor="w") + self.hex_label.bind("", lambda e: self._copy_color(self.picked_color)) + + self.rgb_label = ctk.CTkLabel( + info_col, + text=self.picked_rgb, + font=ctk.CTkFont(size=11), + text_color="#CAC4D0", + anchor="w" + ) + self.rgb_label.pack(fill="x", pady=(0, 2)) + + self.coords_label = ctk.CTkLabel( + info_col, + text="X: --- Y: ---", + font=ctk.CTkFont(size=11), + text_color="#938F99", + anchor="w" + ) + self.coords_label.pack(fill="x", pady=(0, 2)) + + copy_row = ctk.CTkFrame(self.picker_card, fg_color="transparent") + copy_row.pack(fill="x", padx=12, pady=4) + copy_row.grid_columnconfigure(0, weight=1) + copy_row.grid_columnconfigure(1, weight=1) + + self.btn_copy_hex = ctk.CTkButton( + copy_row, + text="Копировать HEX", + command=lambda: self._copy_color(self.picked_color), + height=28, + fg_color="#382E47", + hover_color="#493D5C", + font=ctk.CTkFont(size=11) + ) + self.btn_copy_hex.grid(row=0, column=0, padx=(0, 4), sticky="ew") + + self.btn_copy_rgb = ctk.CTkButton( + copy_row, + text="Копировать RGB", + command=lambda: self._copy_color(self.picked_rgb), + height=28, + fg_color="#382E47", + hover_color="#493D5C", + font=ctk.CTkFont(size=11) + ) + self.btn_copy_rgb.grid(row=0, column=1, padx=(4, 0), sticky="ew") + + ctk.CTkLabel( + self.picker_card, + text="История цветов (клик для копирования):", + font=ctk.CTkFont(size=11), + text_color="#CAC4D0" + ).pack(anchor="w", padx=12, pady=(6, 2)) + + self.history_frame = ctk.CTkFrame(self.picker_card, fg_color="transparent") + self.history_frame.pack(fill="x", padx=12, pady=(0, 10)) + self._refresh_history_ui() + + # ------------------- TikTok Actions Card ------------------- + tt_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") + tt_card.pack(fill="x", pady=8) + + ctk.CTkLabel(tt_card, text="Жесты TikTok / Видео:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 6)) + + tt_btn_up = ctk.CTkButton(tt_card, text="Следующее видео (Свайп вверх)", command=self._swipe_up, fg_color="#6750A4", hover_color="#7F67BE") + tt_btn_up.pack(fill="x", padx=12, pady=4) + + tt_btn_down = ctk.CTkButton(tt_card, text="Предыдущее видео", command=self._swipe_down, fg_color="#382E47", hover_color="#493D5C") + tt_btn_down.pack(fill="x", padx=12, pady=4) + + tt_btn_like = ctk.CTkButton(tt_card, text="Лайк (Двойной тап)", command=self._double_tap_center, fg_color="#8C1D40", hover_color="#B32B52") + tt_btn_like.pack(fill="x", padx=12, pady=(4, 10)) + + # ------------------- System Navigation Card ------------------- + nav_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") + nav_card.pack(fill="x", pady=8) + + ctk.CTkLabel(nav_card, text="Системная навигация:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 6)) + + nav_row1 = ctk.CTkFrame(nav_card, fg_color="transparent") + nav_row1.pack(fill="x", padx=12, pady=4) + nav_row1.grid_columnconfigure(0, weight=1) + nav_row1.grid_columnconfigure(1, weight=1) + nav_row1.grid_columnconfigure(2, weight=1) + + ctk.CTkButton(nav_row1, text="Назад", command=self._on_back, fg_color="#4F378B").grid(row=0, column=0, padx=2, sticky="ew") + ctk.CTkButton(nav_row1, text="Домой", command=self._on_home, fg_color="#4F378B").grid(row=0, column=1, padx=2, sticky="ew") + ctk.CTkButton(nav_row1, text="Меню", command=self._on_recents, fg_color="#4F378B").grid(row=0, column=2, padx=2, sticky="ew") + + nav_row2 = ctk.CTkFrame(nav_card, fg_color="transparent") + nav_row2.pack(fill="x", padx=12, pady=(4, 10)) + nav_row2.grid_columnconfigure(0, weight=1) + nav_row2.grid_columnconfigure(1, weight=1) + nav_row2.grid_columnconfigure(2, weight=1) + + ctk.CTkButton(nav_row2, text="Громкость -", command=lambda: self.mgr.keyevent(self.current_serial, 25), fg_color="#382E47").grid(row=0, column=0, padx=2, sticky="ew") + ctk.CTkButton(nav_row2, text="Громкость +", command=lambda: self.mgr.keyevent(self.current_serial, 24), fg_color="#382E47").grid(row=0, column=1, padx=2, sticky="ew") + ctk.CTkButton(nav_row2, text="Экран", command=lambda: self.mgr.keyevent(self.current_serial, 26), fg_color="#382E47").grid(row=0, column=2, padx=2, sticky="ew") + + # ------------------- Text Typing Card ------------------- + txt_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") + txt_card.pack(fill="x", pady=8) + + ctk.CTkLabel(txt_card, text="Ввод текста на телефоне:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 6)) + + self.text_entry = ctk.CTkEntry(txt_card, placeholder_text="Текст для отправки...") + self.text_entry.pack(fill="x", padx=12, pady=4) + self.text_entry.bind("", lambda e: self._send_text()) + + btn_send_txt = ctk.CTkButton(txt_card, text="Отправить в телефон", command=self._send_text, fg_color="#6750A4", hover_color="#7F67BE") + btn_send_txt.pack(fill="x", padx=12, pady=(4, 10)) + + # ------------------- Utilities Card ------------------- + util_card = ctk.CTkFrame(self.side_frame, corner_radius=12, fg_color="#2B2733") + util_card.pack(fill="x", pady=8) + + ctk.CTkLabel(util_card, text="Дополнительно:", font=ctk.CTkFont(size=13, weight="bold"), text_color="#E6E0E9").pack(anchor="w", padx=12, pady=(10, 6)) + + btn_shot = ctk.CTkButton(util_card, text="Сохранить скриншот", command=self._save_screenshot, fg_color="#382E47", hover_color="#493D5C") + btn_shot.pack(fill="x", padx=12, pady=(4, 10)) + + # Hint box + hint_label = ctk.CTkLabel( + self.side_frame, + text="Подсказки:\n• Клавиша P или C: Режим пипетки\n• Зажать Alt: Быстрый забор цвета кликом\n• ЛКМ: Клик / Тап по экрану\n• Перетаскивание: Свайп\n• Колёсико мыши: Прокрутка\n• ПКМ или Esc: Кнопка «Назад»\n• Двойной клик: Лайк в TikTok", + font=ctk.CTkFont(size=11), + text_color="#938F99", + justify="left" + ) + hint_label.pack(anchor="w", padx=6, pady=(10, 10)) + + # ------------------- Device Management ------------------- + def _refresh_device_list(self) -> None: + devices = self.mgr.list_devices() + if devices: + self.dev_dropdown.configure(values=devices) + if self.current_serial not in devices: + self.current_serial = devices[0] + self.dev_dropdown.set(self.current_serial) + self._update_device_info() + self.status_lbl.configure(text=f"Подключено: {self.current_serial}", text_color="#80E89E") + else: + self.current_serial = None + self.dev_dropdown.configure(values=["Нет устройств"]) + self.dev_dropdown.set("Нет устройств") + self.status_lbl.configure(text="Ожидание подключения USB / Wi-Fi...", text_color="#E0A040") + + def _on_select_device(self, choice: str) -> None: + if choice and choice != "Нет устройств": + self.current_serial = choice + self._update_device_info() + + def _update_device_info(self) -> None: + if not self.current_serial: + return + nw, nh = self.mgr.get_screen_size(self.current_serial) + self.native_w = nw + self.native_h = nh + print(f"Device {self.current_serial} screen resolution: {nw}x{nh}") + + def _open_wifi_dialog(self) -> None: + dialog = ctk.CTkInputDialog(text="Введите IP и порт (например 192.168.1.105:5555):", title="Wi-Fi ADB Connect") + ip = dialog.get_input() + if ip: + ok, msg = self.mgr.connect_wifi(ip.strip()) + time.sleep(0.5) + self._refresh_device_list() + + # ------------------- Eyedropper / Color Picker ------------------- + def _toggle_picker_mode(self) -> None: + self.picker_mode = not self.picker_mode + if self.picker_mode: + self.canvas.configure(cursor="crosshair") + self.picker_badge.configure(text="АКТИВНА", fg_color="#8C1D40", text_color="#FFD8E4") + self.btn_picker.configure(text="Выключить пипетку [P]", fg_color="#8C1D40", hover_color="#B32B52") + self.status_lbl.configure(text="Пипетка активна: наведите на экран и кликните", text_color="#D0BCFF") + else: + self.canvas.configure(cursor="hand2") + self.picker_badge.configure(text="ВЫКЛ", fg_color="#382E47", text_color="#CAC4D0") + self.btn_picker.configure(text="Включить пипетку [P]", fg_color="#4F378B", hover_color="#654B9E") + self.status_lbl.configure(text="Пипетка выключена", text_color="#CAC4D0") + + def _sample_color_at(self, px: int, py: int, from_click: bool = False) -> tuple[str, str] | None: + if not self.latest_frame: + return None + fw, fh = self.latest_frame.size + if not (0 <= px < fw and 0 <= py < fh): + return None + + try: + pixel = self.latest_frame.getpixel((px, py)) + if isinstance(pixel, (tuple, list)): + r, g, b = pixel[0], pixel[1], pixel[2] + else: + r = g = b = int(pixel) + except Exception: + return None + + hex_color = f"#{r:02X}{g:02X}{b:02X}" + rgb_color = f"rgb({r}, {g}, {b})" + + self.picked_color = hex_color + self.picked_rgb = rgb_color + + self.swatch_view.configure(fg_color=hex_color) + self.hex_label.configure(text=hex_color) + self.rgb_label.configure(text=rgb_color) + self.coords_label.configure(text=f"X: {px} Y: {py}") + + self._update_loupe(px, py, fw, fh) + + if from_click: + self._copy_color(hex_color) + self._add_to_history(hex_color) + self.status_lbl.configure(text=f"Цвет скопирован: {hex_color} ({rgb_color})", text_color="#80E89E") + + return hex_color, rgb_color + + def _update_loupe(self, px: int, py: int, fw: int, fh: int) -> None: + if not self.latest_frame: + return + r = 5 + x1 = max(0, px - r) + y1 = max(0, py - r) + x2 = min(fw, px + r + 1) + y2 = min(fh, py + r + 1) + + patch = self.latest_frame.crop((x1, y1, x2, y2)) + loupe_size = 100 + resized = patch.resize((loupe_size, loupe_size), Image.Resampling.NEAREST) + self.loupe_photo = ImageTk.PhotoImage(resized) + + self.loupe_canvas.delete("all") + self.loupe_canvas.create_image(0, 0, anchor="nw", image=self.loupe_photo) + + cx, cy = loupe_size // 2, loupe_size // 2 + pixel_box_half = (loupe_size / (2 * r + 1)) / 2 + + self.loupe_canvas.create_rectangle( + cx - pixel_box_half - 1, cy - pixel_box_half - 1, + cx + pixel_box_half + 1, cy + pixel_box_half + 1, + outline="#000000", width=2 + ) + self.loupe_canvas.create_rectangle( + cx - pixel_box_half, cy - pixel_box_half, + cx + pixel_box_half, cy + pixel_box_half, + outline="#FFFFFF", width=2 + ) + + self.loupe_canvas.create_line(cx, 0, cx, cy - pixel_box_half - 2, fill="#FFFFFF", width=1) + self.loupe_canvas.create_line(cx, cy + pixel_box_half + 2, cx, loupe_size, fill="#FFFFFF", width=1) + self.loupe_canvas.create_line(0, cy, cx - pixel_box_half - 2, cy, fill="#FFFFFF", width=1) + self.loupe_canvas.create_line(cx + pixel_box_half + 2, cy, loupe_size, cy, fill="#FFFFFF", width=1) + + def _copy_color(self, val: str) -> None: + try: + self.clipboard_clear() + self.clipboard_append(val) + self.update() + self.status_lbl.configure(text=f"Скопировано в буфер: {val}", text_color="#D0BCFF") + except Exception as e: + print("Clipboard error:", e) + + def _add_to_history(self, hex_color: str) -> None: + if hex_color in self.color_history: + self.color_history.remove(hex_color) + self.color_history.insert(0, hex_color) + if len(self.color_history) > 8: + self.color_history = self.color_history[:8] + self._refresh_history_ui() + + def _refresh_history_ui(self) -> None: + for child in self.history_frame.winfo_children(): + child.destroy() + + for col in self.color_history: + btn = ctk.CTkButton( + self.history_frame, + text="", + width=26, + height=26, + corner_radius=6, + fg_color=col, + hover_color=col, + command=lambda c=col: self._select_history_color(c) + ) + btn.pack(side="left", padx=2, pady=2) + + def _select_history_color(self, hex_color: str) -> None: + self.picked_color = hex_color + self.swatch_view.configure(fg_color=hex_color) + self.hex_label.configure(text=hex_color) + try: + hc = hex_color.lstrip("#") + r, g, b = tuple(int(hc[i:i+2], 16) for i in (0, 2, 4)) + self.picked_rgb = f"rgb({r}, {g}, {b})" + self.rgb_label.configure(text=self.picked_rgb) + except Exception: + pass + self._copy_color(hex_color) + + # ------------------- Background Stream & Rendering ------------------- + def _stream_loop(self) -> None: + while self.is_streaming: + if not self.current_serial: + time.sleep(1.0) + continue + + frame = self.mgr.capture_screen(self.current_serial) + if frame: + with self.frame_lock: + self.pending_frame = frame + time.sleep(0.04) + else: + time.sleep(0.5) + + def _update_canvas_loop(self) -> None: + with self.frame_lock: + frame = self.pending_frame + self.pending_frame = None + + if frame: + self.latest_frame = frame + cw = self.canvas.winfo_width() + ch = self.canvas.winfo_height() + + if cw > 50 and ch > 50: + fw, fh = frame.size + scale = min(cw / fw, ch / fh) + disp_w = int(fw * scale) + disp_h = int(fh * scale) + disp_x = (cw - disp_w) // 2 + disp_y = (ch - disp_h) // 2 + + self.img_disp_x = disp_x + self.img_disp_y = disp_y + self.img_disp_w = disp_w + self.img_disp_h = disp_h + + resized = frame.resize((disp_w, disp_h), Image.Resampling.BILINEAR) + self.photo_image = ImageTk.PhotoImage(resized) + + self.canvas.delete("all") + self.canvas.create_image(disp_x, disp_y, anchor="nw", image=self.photo_image) + elif not self.current_serial: + cw = self.canvas.winfo_width() + ch = self.canvas.winfo_height() + if cw > 50 and ch > 50: + self.canvas.delete("all") + self.canvas.create_text( + cw // 2, ch // 2, + text="Подключите Android по USB или Wi-Fi\nи включите «Отладку по USB» в настройках", + fill="#CAC4D0", + font=("Segoe UI", 14), + justify="center" + ) + + self.after(40, self._update_canvas_loop) + + def _on_canvas_resize(self, event) -> None: + pass + + # ------------------- Coordinate Mapping & Events ------------------- + def _canvas_to_phone(self, cx: int, cy: int) -> tuple[int, int] | None: + if self.img_disp_w <= 0 or self.img_disp_h <= 0: + return None + rel_x = cx - self.img_disp_x + rel_y = cy - self.img_disp_y + if 0 <= rel_x <= self.img_disp_w and 0 <= rel_y <= self.img_disp_h: + px = int(rel_x / self.img_disp_w * self.native_w) + py = int(rel_y / self.img_disp_h * self.native_h) + return max(0, min(self.native_w - 1, px)), max(0, min(self.native_h - 1, py)) + return None + + def _on_canvas_motion(self, event) -> None: + coords = self._canvas_to_phone(event.x, event.y) + if coords: + px, py = coords + is_alt_pressed = bool(event.state & 0x0008) or bool(event.state & 0x20000) + if self.picker_mode or is_alt_pressed: + self._sample_color_at(px, py, from_click=False) + + def _on_canvas_press(self, event) -> None: + coords = self._canvas_to_phone(event.x, event.y) + if coords: + px, py = coords + is_alt_pressed = bool(event.state & 0x0008) or bool(event.state & 0x20000) + if self.picker_mode or is_alt_pressed: + self._sample_color_at(px, py, from_click=True) + return + + self.drag_start_x, self.drag_start_y = coords + self.drag_start_time = time.time() + + def _on_canvas_release(self, event) -> None: + if self.picker_mode: + return + + if not self.current_serial or self.drag_start_x is None or self.drag_start_y is None: + return + + end_coords = self._canvas_to_phone(event.x, event.y) + if not end_coords: + end_coords = (self.drag_start_x, self.drag_start_y) + + end_x, end_y = end_coords + dx = abs(end_x - self.drag_start_x) + dy = abs(end_y - self.drag_start_y) + duration_ms = int((time.time() - self.drag_start_time) * 1000) + + if dx < 25 and dy < 25: + self.mgr.tap(self.current_serial, self.drag_start_x, self.drag_start_y) + else: + dur = max(120, min(1200, duration_ms)) + self.mgr.swipe(self.current_serial, self.drag_start_x, self.drag_start_y, end_x, end_y, dur) + + self.drag_start_x = None + self.drag_start_y = None + + def _on_canvas_right_click(self, event) -> None: + self._on_back() + + def _on_canvas_double_click(self, event) -> None: + if self.picker_mode: + return + coords = self._canvas_to_phone(event.x, event.y) + if coords and self.current_serial: + px, py = coords + self.mgr.tap(self.current_serial, px, py) + time.sleep(0.08) + self.mgr.tap(self.current_serial, px, py) + + def _on_canvas_mousewheel(self, event) -> None: + if not self.current_serial or self.picker_mode: + return + cx = self.native_w // 2 + top_y = int(self.native_h * 0.25) + bot_y = int(self.native_h * 0.75) + + if event.delta < 0: + self.mgr.swipe(self.current_serial, cx, bot_y, cx, top_y, 250) + else: + self.mgr.swipe(self.current_serial, cx, top_y, cx, bot_y, 250) + + # ------------------- Button Action Handlers ------------------- + def _on_esc(self) -> None: + if self.picker_mode: + self._toggle_picker_mode() + else: + self._on_back() + + def _on_back(self) -> None: + if self.current_serial: + self.mgr.keyevent(self.current_serial, 4) + + def _on_home(self) -> None: + if self.current_serial: + self.mgr.keyevent(self.current_serial, 3) + + def _on_recents(self) -> None: + if self.current_serial: + self.mgr.keyevent(self.current_serial, 187) + + def _swipe_up(self) -> None: + if self.current_serial: + cx = self.native_w // 2 + self.mgr.swipe(self.current_serial, cx, int(self.native_h * 0.80), cx, int(self.native_h * 0.20), 220) + + def _swipe_down(self) -> None: + if self.current_serial: + cx = self.native_w // 2 + self.mgr.swipe(self.current_serial, cx, int(self.native_h * 0.25), cx, int(self.native_h * 0.80), 220) + + def _double_tap_center(self) -> None: + if self.current_serial: + cx = self.native_w // 2 + cy = self.native_h // 2 + self.mgr.tap(self.current_serial, cx, cy) + time.sleep(0.08) + self.mgr.tap(self.current_serial, cx, cy) + + def _send_text(self) -> None: + txt = self.text_entry.get() + if txt and self.current_serial: + self.mgr.input_text(self.current_serial, txt) + self.text_entry.delete(0, "end") + + def _on_paste(self) -> None: + try: + clip = self.clipboard_get() + if clip and self.current_serial: + self.mgr.input_text(self.current_serial, clip) + except Exception: + pass + + def _save_screenshot(self) -> None: + if not self.current_serial: + return + frame = self.mgr.capture_screen(self.current_serial) + if frame: + dt = time.strftime("%Y%m%d_%H%M%S") + desktop = (Path.home() / r"Desktop") + out_file = desktop / f"screenshot_{dt}.png" + frame.save(out_file) + print("Saved screenshot to:", out_file) + self.status_lbl.configure(text=f"Скриншот сохранён: {out_file.name}", text_color="#D0BCFF") + + +def main() -> None: + app = PhoneControllerApp() + app.mainloop() + + +if __name__ == "__main__": + main()