commit ce1d870d54ebbf845d28b11e14b940dc22c3396a Author: cheb0chik Date: Thu Oct 22 20:32:13 2026 +0300 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 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 0000000..dad6bfa Binary files /dev/null and b/src/brand/logo_note_flower_1024.png differ diff --git a/src/brand/logo_note_flower_512.png b/src/brand/logo_note_flower_512.png new file mode 100644 index 0000000..13ebcf7 Binary files /dev/null and b/src/brand/logo_note_flower_512.png differ diff --git a/src/compat/README.md b/src/compat/README.md new file mode 100644 index 0000000..e003ca0 --- /dev/null +++ b/src/compat/README.md @@ -0,0 +1,11 @@ +# Badge pictures of other mods + +The "Значки других модов" switch draws the badges that TikTok You and MargyT put next to a name. +It needs two pictures that those mods carry in their own APKs: + +- `supporter.png` +- `developer.png` + +They are other people's artwork, so this repository does not include them. Take them from the TikTok You +APK (the badge drawables) and put them in this folder before building. A build without them works, but +those two badges are then missing from the APK. diff --git a/src/icons/catalog.tsv b/src/icons/catalog.tsv new file mode 100644 index 0000000..beab205 --- /dev/null +++ b/src/icons/catalog.tsv @@ -0,0 +1,5 @@ +# slug title shown in the picker png (square, full-bleed, 192..512 px) +tolstyj Эй толстый tolstyj.png +elmacho Эль мачо elmacho.png +pushistik Пушистик pushistik.png +original Оригинал original.png diff --git a/src/icons/elmacho.png b/src/icons/elmacho.png new file mode 100644 index 0000000..dac8f5b Binary files /dev/null and b/src/icons/elmacho.png differ diff --git a/src/icons/original.png b/src/icons/original.png new file mode 100644 index 0000000..459c642 Binary files /dev/null and b/src/icons/original.png differ diff --git a/src/icons/pushistik.png b/src/icons/pushistik.png new file mode 100644 index 0000000..e67fdff Binary files /dev/null and b/src/icons/pushistik.png differ diff --git a/src/icons/tolstyj.png b/src/icons/tolstyj.png new file mode 100644 index 0000000..ce7c341 Binary files /dev/null and b/src/icons/tolstyj.png differ 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()