From e63ca75eac98ea705cc366a0164281976dd7ae06 Mon Sep 17 00:00:00 2001 From: cheb0chik Date: Fri, 23 Oct 2026 19:03:48 +0300 Subject: [PATCH] LihonT 2 Luna pre-release Avatar shapes (circle, flower, rounded square, hexagon, heart, triangle): the own shape everywhere and, through the members service, the shapes other members published after proving the account with the code in the bio. Own accent colour and AMOLED, compact bottom bar, Material 3 profile and plus button, save menu (quality, audio only, photo posts without the watermark), fixed comment cards, "Luna (pre-release)" in the mod screen. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 13 +- create_by.md | 2 +- lihont/avatar_patch.py | 47 + lihont/media_patch.py | 62 +- lihont/upgrade.py | 21 +- scripts/deploy_members.py | 111 + scripts/make_lihont_cards.py | 164 ++ scripts/make_lihont_promo.py | 99 +- services/members/server.py | 71 +- src/lihont/two/avatar/AvatarShapes.java | 67 + src/lihont/two/avatar/Installation.java | 54 + src/lihont/two/avatar/Owner.java | 99 + src/lihont/two/avatar/PictureOwners.java | 88 + src/lihont/two/avatar/Pictures.java | 19 + src/lihont/two/avatar/ShapeCatalog.java | 151 + src/lihont/two/avatar/ShapePublisher.java | 81 + src/lihont/two/avatar/Shapes.java | 72 + src/lihont/two/badge/AccountNames.java | 1 + src/lihont/two/badge/MemberClient.java | 4 +- src/lihont/two/bar/AvatarBadge.java | 35 +- src/lihont/two/bar/BarStyle.java | 56 + src/lihont/two/bar/BottomBar.java | 5 +- src/lihont/two/bar/PlusPill.java | 51 + src/lihont/two/bar/UserAvatar.java | 125 +- src/lihont/two/comments/CommentPanel.java | 16 +- src/lihont/two/look/Windows.java | 61 + src/lihont/two/media/DownloadJob.java | 233 ++ src/lihont/two/media/DownloadMenu.java | 423 +++ src/lihont/two/profile/PlusBadge.java | 49 + src/lihont/two/profile/ProfilePanel.java | 374 +++ src/lihont/two/profile/ProfileSkin.java | 103 + src/lihont/two/profile/Tone.java | 11 + src/lihont/two/settings/HuePicker.java | 93 + .../two/settings/LihonTSettingsActivity.java | 2619 +++++++++-------- src/lihont/two/settings/Md3.java | 86 +- src/lihont/two/settings/Md3Icon.java | 11 +- src/lihont/two/settings/ModSettings.java | 550 ++-- src/lihont/two/settings/SettingsCell.java | 16 +- src/lihont/two/settings/SettingsCodec.java | 408 +-- src/lihont/two/settings/SettingsHost.java | 36 + src/lihont/two/settings/ShapeView.java | 30 + src/lihont/two/settings/TonalPalette.java | 82 + .../java/lihont/two/avatar/PicturesTest.java | 24 + .../lihont/two/settings/IconRenderTest.java | 4 +- .../two/settings/SettingsModelTest.java | 58 + tests/test_avatar_patch.py | 80 + tests/test_media_patch.py | 90 +- tests/test_members_server.py | 92 + tests/test_pictures.py | 29 + tests/test_settings_model.py | 7 + 50 files changed, 5422 insertions(+), 1661 deletions(-) create mode 100644 lihont/avatar_patch.py create mode 100644 scripts/deploy_members.py create mode 100644 scripts/make_lihont_cards.py create mode 100644 src/lihont/two/avatar/AvatarShapes.java create mode 100644 src/lihont/two/avatar/Installation.java create mode 100644 src/lihont/two/avatar/Owner.java create mode 100644 src/lihont/two/avatar/PictureOwners.java create mode 100644 src/lihont/two/avatar/Pictures.java create mode 100644 src/lihont/two/avatar/ShapeCatalog.java create mode 100644 src/lihont/two/avatar/ShapePublisher.java create mode 100644 src/lihont/two/avatar/Shapes.java create mode 100644 src/lihont/two/bar/PlusPill.java create mode 100644 src/lihont/two/look/Windows.java create mode 100644 src/lihont/two/media/DownloadJob.java create mode 100644 src/lihont/two/media/DownloadMenu.java create mode 100644 src/lihont/two/profile/PlusBadge.java create mode 100644 src/lihont/two/profile/ProfilePanel.java create mode 100644 src/lihont/two/profile/ProfileSkin.java create mode 100644 src/lihont/two/profile/Tone.java create mode 100644 src/lihont/two/settings/HuePicker.java create mode 100644 src/lihont/two/settings/ShapeView.java create mode 100644 src/lihont/two/settings/TonalPalette.java create mode 100644 tests/java/lihont/two/avatar/PicturesTest.java create mode 100644 tests/test_avatar_patch.py create mode 100644 tests/test_pictures.py diff --git a/README.md b/README.md index 63c6ff7..d5a92b5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# LihonT 2 · Luna (бета) +# LihonT 2 · Luna (пре-релиз) Мод для TikTok 47.1.3 (arm64) в стиле Material 3. Telegram: [@lihont](https://t.me/lihont). Готовую сборку берите на странице [релизов](https://git.lihont.space/lihont/LihonT/releases). @@ -12,6 +12,11 @@ - Скачивание видео без водяного знака. - Убрать рекламу, прямые эфиры в ленте и вкладку «Магазин» (по отдельным переключателям). - Значки у ников: значки TikTok You и MargyT рядом с ником и значок «Нишевый мод» (его выдаёт сервер, см. `services/members`). +- Форма аватарок: круг, цветочек, скруглённый квадрат, шестиугольник, сердечко, треугольник. Форма видна + вам везде, а другим пользователям мода её показывает сервер (она публикуется после проверки кода в + описании профиля, как у значка «Нишевый мод»). У остальных аватарки круглые. +- Свой цвет оформления, чёрный фон AMOLED, компактная нижняя панель, меню сохранения (качество, только звук, + фото без водяного знака). - Баннеры профиля, репост комментариев, регион мода, сменные иконки приложения, своя иконка. - Экран «Настройки мода» открывается из «Настройки и конфиденциальность». @@ -21,14 +26,14 @@ `OriginalAPKtiktok/`, затем из корня репозитория: ``` -py -B -m lihont.upgrade "OriginalAPKtiktok/.apk" -o ComplectAPK/lihont-2.0-luna-beta.apk --work work/build +py -B -m lihont.upgrade "OriginalAPKtiktok/.apk" -o ComplectAPK/lihont-2.0-luna-prerelease.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 +py -B scripts/quick_runtime.py ComplectAPK/lihont-2.0-luna-prerelease.apk -o ComplectAPK/lihont-2.0-luna-prerelease-next.apk ``` Помощники сборки (`smali.jar`, `r8.jar`, `android.jar`, подписчик APK) скачиваются в `tools/` при первой @@ -43,7 +48,7 @@ py -B scripts/quick_runtime.py ComplectAPK/lihont-2.0-luna-beta.apk -o ComplectA - `src/lihont/two/` — Java-код мода (экран настроек, MD3, панель, комментарии, значки, лента, загрузки). Он собирается в dex и вшивается в `classes35.dex` TikTok. - `src/icons/`, `src/brand/` — иконки приложения и логотип. `src/web/` — сайт проекта. -- `services/members/` — сервер значков (чистый Python и sqlite). +- `services/members/` — сервер значков и форм аватарок (чистый Python и sqlite). - `tests/` — тесты (`py -B -m unittest discover -s tests -t .`); часть из них компилирует и запускает Java. - `scripts/` — выкладка сайта, быстрая пересборка, генерация промо-ролика. - `tools/adb_controller.py` — управление телефоном по ADB при проверке. diff --git a/create_by.md b/create_by.md index e550be0..25af328 100644 --- a/create_by.md +++ b/create_by.md @@ -1,6 +1,6 @@ # create_by -LihonT 2.0 («Luna», бета) написан с помощью ИИ. По правилу лицензии EVHNMKHU вот модели. +LihonT 2.0 («Luna», пре-релиз) написан с помощью ИИ. По правилу лицензии EVHNMKHU вот модели. - Claude Sonnet 5.5 (Anthropic), через Claude Code: этот и ряд предыдущих сеансов. - Более ранние сеансы шли в Claude Code на других моделях Claude; точный список не сохранился. diff --git a/lihont/avatar_patch.py b/lihont/avatar_patch.py new file mode 100644 index 0000000..6b4addd --- /dev/null +++ b/lihont/avatar_patch.py @@ -0,0 +1,47 @@ +"""Let lihont.two.avatar.AvatarShapes cut every avatar to the shape chosen in the settings. + +Every avatar of the application is drawn by X.1Bem.onDraw, the base of SmartAvatarImageView (comments, lists), +of the Tux avatar view (profile, bars) and of the feed's own avatar view. The rewrite brackets that method: +AvatarShapes.begin(view, canvas) clips the canvas to the shape (and zooms the picture when the shape needs it), +and AvatarShapes.end(canvas) takes the clip off again before the method returns. With the round shape, which +is TikTok's own, both calls do nothing. +""" + +from __future__ import annotations + +import re + +DEX = "classes35.dex" +OWNER = "LX/1Bem;" +BEGIN = "Llihont/two/avatar/AvatarShapes;->begin(Landroid/view/View;Landroid/graphics/Canvas;)V" +END = "Llihont/two/avatar/AvatarShapes;->end(Landroid/graphics/Canvas;)V" + +_ON_DRAW = re.compile(r"^\.method public (?:final )?onDraw\(Landroid/graphics/Canvas;\)V\n( \.registers \d+\n)(.*?)^\.end method", + re.M | re.S) +_DRAWS = re.compile(r"invoke-(?:super|virtual) \{p0, p1\}, L[^;]+;->\w+\(Landroid/graphics/Canvas;\)V") + +BEGIN_CALL = " invoke-static {p0, p1}, %s\n\n" % BEGIN +END_CALL = " invoke-static {p1}, %s\n\n" % END + + +def runtime_targets(): + """Runtime methods the rewrite calls, as they appear in a method table.""" + return [BEGIN, END] + + +def rewrite_avatar(text: str) -> str: + """Bracket the avatar base class's onDraw with begin and end.""" + match = _ON_DRAW.search(text) + if match is None: + raise ValueError("onDraw not found in the avatar base class") + body = match.group(2) + if "lihont" in body: + raise ValueError("Avatar hook already present in onDraw") + if body.count("return-void") != 1: + raise ValueError("onDraw must hold exactly one return-void, found %d" % body.count("return-void")) + if _DRAWS.search(body) is None: + raise ValueError("onDraw no longer hands the canvas on to a drawing method") + head = match.group(1) + patched = "\n" + BEGIN_CALL + body.lstrip("\n").replace(" return-void", END_CALL + " return-void") + start = match.start(1) + return text[:start] + head + patched + text[match.end(2):] diff --git a/lihont/media_patch.py b/lihont/media_patch.py index 98b333e..0caf9e2 100644 --- a/lihont/media_patch.py +++ b/lihont/media_patch.py @@ -69,9 +69,69 @@ LOGO_BODY = """\ .end method""" +# the video save, its second variant, and the photo save ("save_photo", which opens TikTok's picture picker) +SAVE_OWNERS = ("LX/0qv6;", "LX/0qvL;", "LX/0qvu;") +PHOTO_OWNER = "Lcom/ss/android/ugc/aweme/share/improve/handler/AwemePhotoDownloadShareAbilityHandler;" +GATE = "Llihont/two/media/DownloadMenu;->gate(Ljava/lang/Object;Landroid/content/Context;Ljava/lang/Object;)Z" +GATE_PHOTO = ("Llihont/two/media/DownloadMenu;->gatePhoto(" + "Ljava/lang/Object;Landroid/content/Context;Ljava/lang/Object;)Z") + +_SAVE_ACTION = re.compile(r"^\.method public final LJ\(Landroid/content/Context;LX/0qvX;\)V\n( \.registers \d+\n)", re.M) +_PHOTO_CLICK = re.compile( + r"^\.method public final LJIILIIL\(Landroid/content/Context;" + r"Lcom/ss/android/ugc/aweme/share/base/model/BaseSharePackage;LX/0qrm;Landroid/view/View;LX/01h3;\)" + r"Ljava/lang/Object;\n( \.registers \d+\n)", re.M) + +SAVE_GATE = """\ + invoke-static/range {p0 .. p2}, %(gate)s + + move-result v0 + + if-eqz v0, :lihont_go + + return-void + + :lihont_go +""" % {"gate": GATE} + +PHOTO_GATE = """\ + invoke-static/range {p0 .. p2}, %(gate)s + + move-result v0 + + if-eqz v0, :lihont_go + + sget-object v0, Ljava/lang/Boolean;->TRUE:Ljava/lang/Boolean; + + return-object v0 + + :lihont_go +""" % {"gate": GATE_PHOTO} + + +def _gate(text: str, pattern, code: str, what: str) -> str: + match = pattern.search(text) + if match is None: + raise ValueError(what + " not found") + method_end = text.index("\n.end method", match.start()) + if "lihont" in text[match.start():method_end]: + raise ValueError("Download menu hook already present in " + what) + return text[:match.end()] + "\n" + code + text[match.end():] + + +def rewrite_save(text: str) -> str: + """Put the download menu in front of a Save action (LJ(Context, X.0qvX)): the gate may take it over.""" + return _gate(text, _SAVE_ACTION, SAVE_GATE, "Save action LJ(Context, X.0qvX)") + + +def rewrite_photo(text: str) -> str: + """Put the photo gate in front of the photo save click; a taken-over click completes with true.""" + return _gate(text, _PHOTO_CLICK, PHOTO_GATE, "photo save click LJIILIIL") + + def runtime_targets(): """Runtime methods the rewrites call, as they appear in a method table.""" - return [SWITCH, LOGO, ROUTE] + return [SWITCH, LOGO, ROUTE, GATE, GATE_PHOTO] def rewrite_source(text: str) -> str: diff --git a/lihont/upgrade.py b/lihont/upgrade.py index 54036de..15b4e5f 100644 --- a/lihont/upgrade.py +++ b/lihont/upgrade.py @@ -14,7 +14,7 @@ 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 . import avatar_patch, 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 @@ -336,6 +336,13 @@ def patch_downloads(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: "context": patch_class(apk, tools, room, api, media_patch.DEX, media_patch.CONTEXT_OWNER, "download-context", media_patch.rewrite_context, marker), } + menu = b"Llihont/two/media/DownloadMenu;" + for owner in media_patch.SAVE_OWNERS: + label = "download-save-" + owner[1:-1].rsplit("/", 1)[1].lower() + report["save " + owner] = patch_class(apk, tools, room, api, media_patch.DEX, owner, label, + media_patch.rewrite_save, menu) + report["photo"] = patch_class(apk, tools, room, api, media_patch.DEX, media_patch.PHOTO_OWNER, + "download-photo", media_patch.rewrite_photo, menu) report["hooks"] = media_patch.runtime_targets() return report @@ -352,6 +359,15 @@ def patch_feed(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: return report +def patch_avatars(apk: Apk, tools: Toolchain, room: Path, api: int) -> dict: + """Let lihont.two.avatar.AvatarShapes cut every avatar to the shape chosen in the settings.""" + marker = b"Llihont/two/avatar/AvatarShapes;" + report = {"avatar": patch_class(apk, tools, room, api, avatar_patch.DEX, avatar_patch.OWNER, "avatar-view", + avatar_patch.rewrite_avatar, marker)} + report["hooks"] = avatar_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;" @@ -519,6 +535,7 @@ def build(source: str, output: str, workspace: str = "work/build") -> dict: 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["avatars"] = patch_avatars(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() @@ -535,7 +552,7 @@ def build(source: str, output: str, workspace: str = "work/build") -> dict: + 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] + + badge_patch.runtime_targets() + avatar_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 diff --git a/scripts/deploy_members.py b/scripts/deploy_members.py new file mode 100644 index 0000000..567dfdc --- /dev/null +++ b/scripts/deploy_members.py @@ -0,0 +1,111 @@ +"""Swap the LihonT members service (badges and avatar shapes) on the Helsinki server and roll it back. + + py scripts/deploy_members.py --dry-run compare the live server.py with the local one, change nothing + py scripts/deploy_members.py back up, upload, restart lihont-members, roll back on failure + py scripts/deploy_members.py --rollback restore the newest backup (or --stamp ) + +Only /opt/lihont-members/server.py is replaced; its database is never touched (new tables are created by the +service itself when it starts, so an older server.py keeps working on the same file). The live file is copied to +server.py.bak- first, the new one is compiled on the server before it goes live, and the service is +asked for its health after the restart; a failed check puts the backup back. +""" + +from __future__ import annotations + +import argparse +import hashlib +import shlex +import subprocess +import sys +import time +from pathlib import Path + +HOST = "hels" +FOLDER = "/opt/lihont-members" +LIVE = FOLDER + "/server.py" +SERVICE = "lihont-members" +HEALTH = "http://127.0.0.1:18746/members/v1/health" +SOURCE = Path(__file__).resolve().parents[1] / "services" / "members" / "server.py" +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=120) + + +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 digest_of_live() -> str: + result = remote("sha256sum %s" % shlex.quote(LIVE)) + return result.stdout.decode().split()[0] if result.returncode == 0 and result.stdout else "?" + + +def healthy() -> bool: + return remote("curl -sf %s" % shlex.quote(HEALTH)).returncode == 0 + + +def deploy(dry_run: bool) -> int: + data = SOURCE.read_bytes() + local = hashlib.sha256(data).hexdigest() + live = digest_of_live() + print("local %s\nlive %s" % (local, live)) + if dry_run: + print("Dry run: nothing was changed." if local != live else "Already deployed.") + return 0 + if local == live: + print("The server already runs this file.") + return 0 + stamp = time.strftime("%Y%m%d-%H%M%S") + backup = "%s.bak-%s" % (LIVE, stamp) + result = remote("set -e; cat > /tmp/members-server.new && python3 -m py_compile /tmp/members-server.new " + "&& cp -p %s %s && install -m 644 /tmp/members-server.new %s && rm -f /tmp/members-server.new" + % (shlex.quote(LIVE), shlex.quote(backup), shlex.quote(LIVE)), data) + if result.returncode: + print("Upload failed, the live file is untouched:\n" + text(result)) + return 1 + print("backup: %s" % backup) + remote("systemctl restart %s" % SERVICE) + time.sleep(2) + if healthy(): + print("The service answers; deployed.") + return 0 + print("The service did not answer: putting the backup back.") + remote("cp -p %s %s && systemctl restart %s" % (shlex.quote(backup), shlex.quote(LIVE), SERVICE)) + time.sleep(2) + print("Rolled back; the service answers: %s" % healthy()) + return 1 + + +def rollback(stamp: str | None) -> int: + if stamp: + backup = "%s.bak-%s" % (LIVE, stamp) + else: + listing = remote("ls -1 %s.bak-2* 2>/dev/null | tail -1" % shlex.quote(LIVE)).stdout.decode().strip() + if not listing: + print("No dated backup found.") + return 1 + backup = listing + result = remote("cp -p %s %s && systemctl restart %s" % (shlex.quote(backup), shlex.quote(LIVE), SERVICE)) + if result.returncode: + print("Rollback failed:\n" + text(result)) + return 1 + time.sleep(2) + print("Restored %s; the service answers: %s" % (backup, healthy())) + return 0 if healthy() else 1 + + +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") + args = parser.parse_args() + return rollback(args.stamp) if args.rollback else deploy(args.dry_run) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/make_lihont_cards.py b/scripts/make_lihont_cards.py new file mode 100644 index 0000000..eb77da8 --- /dev/null +++ b/scripts/make_lihont_cards.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +News cards for the Telegram post about LihonT 2 «Luna pre-release»: 1080x1350 PNGs in work/promo/cards/. +Uses the same screenshots and drawing helpers as the promo video (make_lihont_promo.py). + +usage (from the repository root): py -B scripts/make_lihont_cards.py +""" + +from __future__ import annotations + +import math +import random +import sys +from pathlib import Path + +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import make_lihont_promo as P # noqa: E402 + +CW, CH = 1080, 1350 +OUT_DIR = P.ROOT / "work" / "promo" / "cards" + + +def backdrop(seed: int) -> Image.Image: + img = Image.new("RGBA", (CW, CH), P.BG + (255,)) + rnd = random.Random(seed) + spots = [(0.25, 0.2), (0.8, 0.55), (0.3, 0.9)] + for n, blob_img in enumerate(P.ASSETS["blobs"]): + fx, fy = spots[n] + img.alpha_composite(blob_img, (int(CW * fx - blob_img.width / 2), int(CH * fy - blob_img.height / 2))) + for _ in range(12): + img.alpha_composite(P.ASSETS["flower"], (int(rnd.random() * CW), int(rnd.random() * CH))) + return img + + +def head(layer: Image.Image, title: str, sub: str, y: int = 150) -> None: + P.draw_text(layer, (CW / 2, y), title, 84, P.ON + (255,)) + P.draw_text(layer, (CW / 2, y + 96), sub, 42, P.PRIMARY + (255,), bold=False) + + +def footer(layer: Image.Image) -> None: + P.draw_text(layer, (CW / 2, CH - 44), "LihonT 2 · Luna pre-release · @lihont", 32, P.MUTED + (255,), bold=False) + + +def card_hero() -> Image.Image: + img = backdrop(1) + layer = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) + P.place(layer, P.ASSETS["blobs"][1], CW / 2, 470, 1.4) + P.place(layer, P.ASSETS["logo"], CW / 2, 470, 0.5) + P.draw_text(layer, (CW / 2, 830), "LihonT 2", 150, P.ON + (255,)) + P.draw_text(layer, (CW / 2, 960), "Luna · пре-релиз", 64, P.PRIMARY + (255,)) + P.draw_text(layer, (CW / 2, 1040), "Мод для TikTok в стиле Material 3", 44, P.MUTED + (255,), bold=False) + P.chip(layer, CW / 2, 1170, "Скачать: git.lihont.space", 1.0, size=48, fill=P.PRIMARY, fg=P.BG) + P.chip(layer, CW / 2, 1270, "Telegram · @lihont", 1.0, size=40) + img.alpha_composite(layer) + return img + + +def card_system() -> Image.Image: + img = backdrop(2) + layer = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) + head(layer, "Всё в стиле Material 3", "Меню, настройки, нижняя панель") + P.place(layer, P.ASSETS["phones"]["menu"], 300, 930, 0.82, 6) + P.place(layer, P.ASSETS["phones"]["native"], 790, 990, 0.82, -5) + img.alpha_composite(layer) + return img + + +def card_comments() -> Image.Image: + img = backdrop(3) + layer = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) + head(layer, "Новые комментарии", "Карточки, ручка шторки, поле-пилюля") + P.place(layer, P.ASSETS["phones"]["comments"], CW / 2, 930, 0.95) + P.chip(layer, 190, 560, "Карточки", 1.0, size=38) + P.chip(layer, 890, 470, "Ручка шторки", 1.0, size=38) + P.chip(layer, 860, 1230, "Поле-пилюля", 1.0, size=38) + img.alpha_composite(layer) + return img + + +def card_shapes() -> Image.Image: + img = backdrop(7) + layer = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) + head(layer, "Форма аватарок", "Её видят все, у кого есть мод") + P.place(layer, P.ASSETS["phones"]["shapes"], CW / 2, 800, 0.62, -3) + for n, kind in enumerate(P.SHAPE_KINDS): + P.place(layer, P.shape_silhouette(kind, 120, P.PRIMARY), 190 + n * 140, 1230, 1.0) + P.chip(layer, 200, 560, "Круг, как в TikTok", 1.0, size=34) + P.chip(layer, 880, 640, "Без мода — круг", 1.0, size=34) + footer(layer) + img.alpha_composite(layer) + return img + + +def card_save() -> Image.Image: + img = backdrop(8) + layer = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) + head(layer, "Скачать как надо", "Качество, звук, все фото") + P.place(layer, P.ASSETS["phones"]["save"], CW / 2, 930, 0.95) + P.chip(layer, 190, 520, "Без водяного знака", 1.0, size=36) + P.chip(layer, 890, 760, "Только звук", 1.0, size=36) + P.chip(layer, 880, 1230, "Фото карусели", 1.0, size=36) + img.alpha_composite(layer) + return img + + +def card_feed() -> Image.Image: + img = backdrop(4) + layer = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) + head(layer, "Лента без лишнего", "Включай только нужное") + first, _second = P.ASSETS["mod_screens"] + phone = P.mount(P.ASSETS["mod_shell"], first) + P.place(layer, phone, 640, 960, 0.9) + for n, text in enumerate(["Без водяного знака", "Убрать рекламу", "Убрать эфиры", "Скрыть «Магазин»"]): + P.chip(layer, 230, 520 + n * 190, text, 1.0, size=34) + img.alpha_composite(layer) + return img + + +def card_badges() -> Image.Image: + img = backdrop(5) + layer = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) + head(layer, "Значки у ников", "TikTok You, MargyT и нишевый мод") + profile = P.ASSETS["shots"]["profile"] + card = P.rounded(profile.crop((0, 0, 1080, 900)).resize((940, 783), Image.Resampling.LANCZOS), 56) + P.place(layer, card, CW / 2, 720, 1.0) + zoom = P.rounded(profile.crop((20, 166, 700, 270)).resize((880, 135), Image.Resampling.LANCZOS), 40) + ring = P.rounded(Image.new("RGBA", (896, 151), P.PRIMARY + (255,)), 48) + P.place(layer, ring, CW / 2, 1210) + P.place(layer, zoom, CW / 2, 1210) + footer(layer) + img.alpha_composite(layer) + return img + + +def card_icons() -> Image.Image: + img = backdrop(6) + layer = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) + head(layer, "Иконки приложения", "Выбери свою или добавь свою") + spots = [(340, 560), (740, 560), (340, 980), (740, 980)] + for (slug, label), (x, y) in zip(P.ICONS, spots): + P.place(layer, P.ASSETS["icons"][slug], x, y, 1.0) + P.draw_text(layer, (x, y + 205), label, 40, P.ON + (255,)) + footer(layer) + img.alpha_composite(layer) + return img + + +def main() -> None: + P.init_assets() + OUT_DIR.mkdir(parents=True, exist_ok=True) + cards = [("1_hero", card_hero), ("2_material3", card_system), ("3_comments", card_comments), + ("4_shapes", card_shapes), ("5_save", card_save), ("6_feed", card_feed), + ("7_badges", card_badges), ("8_icons", card_icons)] + for name, make in cards: + path = OUT_DIR / ("card_%s.png" % name) + make().convert("RGB").save(path) + print(path) + + +if __name__ == "__main__": + main() diff --git a/scripts/make_lihont_promo.py b/scripts/make_lihont_promo.py index d6b3738..6bce553 100644 --- a/scripts/make_lihont_promo.py +++ b/scripts/make_lihont_promo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -LihonT 2 «Luna beta» promo video: 1080x1920, 30 fps, about 28 seconds, with a synthesised soundtrack. +LihonT 2 «Luna pre-release» promo video: 1080x1920, 30 fps, about 37 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. @@ -38,10 +38,12 @@ 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), + ("shapes", 12.4, 17.2), + ("save", 17.2, 21.8), + ("badges", 21.8, 25.8), + ("settings", 25.8, 30.6), + ("icons", 30.6, 33.8), + ("outro", 33.8, 37.8), ] DURATION = SCENES[-1][2] FRAMES = int(round(DURATION * FPS)) @@ -60,10 +62,10 @@ 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), + "comments.png": [ + (56, 1312, 184, 1436), (206, 1304, 580, 1360), + (206, 1628, 300, 1718), (306, 1618, 654, 1676), + (54, 1892, 184, 2016), (206, 1884, 554, 1940), ], } @@ -235,12 +237,13 @@ def init_assets() -> None: 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", + "comments": "comments.png", "menu": "menu.png", "native": "settings_native.png", "mod1": "mod_settings1.png", "mod2": "mod_settings2.png", "profile": "profile.png", + "shapes": "shapes.png", "save": "save.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["phones"] = {key: build_phone(cache[key]) for key in ("comments", "menu", "native", "shapes", "save")} 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) @@ -293,7 +296,7 @@ def scene_intro(layer: Image.Image, lt: float) -> None: 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),)) + 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) @@ -319,6 +322,69 @@ def scene_comments(layer: Image.Image, lt: float) -> None: chip(layer, 850, 1780, "Поле-пилюля", pop(lt, 1.7)) +def shape_silhouette(kind: str, size: int, color: tuple) -> Image.Image: + """The six avatar outlines of the mod (the geometry of lihont.two.avatar.Shapes), filled with one colour.""" + scale = 4 + s = size * scale + layer = Image.new("RGBA", (s, s), (0, 0, 0, 0)) + d = ImageDraw.Draw(layer) + fill = color + (255,) if len(color) == 3 else color + if kind == "flower": + r, pr = s * 0.31, s * 0.19 + d.ellipse([s / 2 - r, s / 2 - r, s / 2 + r, s / 2 + r], fill=fill) + for k in range(6): + a = k * math.pi / 3 - math.pi / 2 + px, py = s / 2 + math.cos(a) * s * 0.31, s / 2 + math.sin(a) * s * 0.31 + d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=fill) + elif kind == "squircle": + d.rounded_rectangle([0, 0, s - 1, s - 1], radius=int(s * 0.34), fill=fill) + elif kind == "hexagon": + points = [(s * (0.5 + 0.5 * math.cos(math.pi / 6 + k * math.pi / 3 - math.pi / 2 + math.pi / 6)), + s * (0.5 + 0.5 * math.sin(math.pi / 6 + k * math.pi / 3 - math.pi / 2 + math.pi / 6))) + for k in range(6)] + d.polygon(points, fill=fill) + elif kind == "heart": + def bezier(p0, p1, p2, p3, n=24): + return [((1 - t) ** 3 * p0[0] + 3 * (1 - t) ** 2 * t * p1[0] + 3 * (1 - t) * t * t * p2[0] + t ** 3 * p3[0], + (1 - t) ** 3 * p0[1] + 3 * (1 - t) ** 2 * t * p1[1] + 3 * (1 - t) * t * t * p2[1] + t ** 3 * p3[1]) + for t in [i / n for i in range(n + 1)]] + q = lambda x, y: (s * x, s * y) # noqa: E731 + points = (bezier(q(0.5, 0.94), q(0.10, 0.62), q(0.02, 0.40), q(0.10, 0.24)) + + bezier(q(0.10, 0.24), q(0.18, 0.08), q(0.40, 0.06), q(0.5, 0.27)) + + bezier(q(0.5, 0.27), q(0.60, 0.06), q(0.82, 0.08), q(0.90, 0.24)) + + bezier(q(0.90, 0.24), q(0.98, 0.40), q(0.90, 0.62), q(0.5, 0.94))) + d.polygon(points, fill=fill) + elif kind == "triangle": + d.polygon([(s * 0.5, s * 0.05), (s * 0.96, s * 0.86), (s * 0.04, s * 0.86)], fill=fill) + else: + d.ellipse([0, 0, s - 1, s - 1], fill=fill) + return layer.resize((size, size), Image.Resampling.LANCZOS) + + +SHAPE_KINDS = ["circle", "flower", "squircle", "hexagon", "heart", "triangle"] + + +def scene_shapes(layer: Image.Image, lt: float) -> None: + headline(layer, lt, "Форма аватарок", "Её видят все, у кого есть мод") + rise = ease_out_cubic((lt - 0.15) / 0.9) + place(layer, ASSETS["phones"]["shapes"], W / 2, 1010 + (1 - rise) * 900 + math.sin(lt * 2) * 6, 0.72, -3) + for n, kind in enumerate(SHAPE_KINDS): + k = pop(lt, 1.0 + n * 0.18) + place(layer, shape_silhouette(kind, 130, PRIMARY), 170 + n * 148, 1650, k) + chip(layer, 215, 640, "Круг, как в TikTok", pop(lt, 2.0), size=36) + chip(layer, 870, 760, "Без мода — круг", pop(lt, 2.4), size=36) + + +def scene_save(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)) * 8 + place(layer, ASSETS["phones"]["save"], W / 2, 1200 + (1 - rise) * 900 + math.sin(lt * 2) * 6, 1.0, tilt) + chip(layer, 190, 690, "Без водяного знака", pop(lt, 0.9), size=36) + chip(layer, 890, 1010, "Только звук", pop(lt, 1.3), size=36) + chip(layer, 880, 1500, "Фото карусели", pop(lt, 1.7), size=36) + + def scene_badges(layer: Image.Image, lt: float) -> None: headline(layer, lt, "Значки у ников", "TikTok You, MargyT и нишевый мод") profile = ASSETS["shots"]["profile"] @@ -351,7 +417,7 @@ def scene_settings(layer: Image.Image, lt: float) -> None: 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) + chip(layer, 890, 1500, "Форма и цвет аватарок", pop(lt, 2.4), size=36) def scene_icons(layer: Image.Image, lt: float) -> None: @@ -370,7 +436,7 @@ def scene_outro(layer: Image.Image, lt: float) -> None: 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),)) + draw_text(layer, (W / 2, 960 + (1 - a) * 40), "Скачай пре-релиз", 100, 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) @@ -380,8 +446,9 @@ def scene_outro(layer: Image.Image, lt: float) -> None: SCENE_DRAW = { - "intro": scene_intro, "system": scene_system, "comments": scene_comments, "badges": scene_badges, - "settings": scene_settings, "icons": scene_icons, "outro": scene_outro, + "intro": scene_intro, "system": scene_system, "comments": scene_comments, "shapes": scene_shapes, + "save": scene_save, "badges": scene_badges, "settings": scene_settings, "icons": scene_icons, + "outro": scene_outro, } diff --git a/services/members/server.py b/services/members/server.py index 1a5e7d6..ebfd8c4 100644 --- a/services/members/server.py +++ b/services/members/server.py @@ -4,6 +4,10 @@ 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. + +Avatar outlines are different: one is published for an account only after that +account's public bio held its code (the same proof as the niche badge), and the +installation that proved it keeps the right to change it. """ from __future__ import annotations import argparse @@ -26,6 +30,8 @@ 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") +# Avatar outlines a member can publish; the round one is TikTok's own and is never stored. +SHAPES = ("flower", "squircle", "hexagon", "heart", "triangle") # The niche-mod badge can be claimed until this moment (Moscow time). CLAIM_DEADLINE = 1794735240 # 2026-11-15 12:34:00 +03:00 @@ -65,6 +71,8 @@ class Registry: 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 shapes (uid TEXT PRIMARY KEY, shape TEXT NOT NULL, updated_at INTEGER NOT NULL)") + db.execute("CREATE TABLE IF NOT EXISTS shape_owner (uid TEXT PRIMARY KEY, installation TEXT NOT NULL, unique_id TEXT NOT NULL, verified_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),)) @@ -133,6 +141,54 @@ class Registry: (uid, unique_id, int(self.clock()))) return {"ok": True, "granted": True} + def shape(self, uid, installation, shape, unique_id=None): + """Publishes (or, for "circle", withdraws) the avatar outline of `uid`. + + The installation that proved the account stays bound to it, so that installation changes the outline + again without another proof. Any other installation has to prove the account first: its public bio + must hold the account's code, exactly as for the niche badge. + """ + if not valid_uid(uid) or not isinstance(installation, str) or not INSTALL.fullmatch(installation): + raise ValueError("Invalid account ID or installation code") + if shape != "circle" and shape not in SHAPES: + raise ValueError("Unknown shape") + digest = hashlib.sha256(installation.encode("ascii")).hexdigest() + with self.connect() as db: + if db.execute("SELECT 1 FROM blocked WHERE uid=?", (uid,)).fetchone(): + raise ValueError("Account excluded") + owner = db.execute("SELECT installation FROM shape_owner WHERE uid=?", (uid,)).fetchone() + has_shape = db.execute("SELECT 1 FROM shapes WHERE uid=?", (uid,)).fetchone() + proven = owner is not None and hmac.compare_digest(owner[0], digest) + if not proven and shape == "circle" and not has_shape: + return {"ok": True, "published": True, "shape": "circle"} # nothing to withdraw + if not proven: + if not isinstance(unique_id, str) or not UNIQUE.fullmatch(unique_id): + return {"ok": True, "published": False, "reason": "proof_needed", "code": self.code(uid)} + 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, "published": False, "reason": "code_missing", "code": self.code(uid)} + with self.connect() as db: + db.execute("INSERT INTO shape_owner(uid,installation,unique_id,verified_at) VALUES(?,?,?,?) " + "ON CONFLICT(uid) DO UPDATE SET installation=excluded.installation," + "unique_id=excluded.unique_id,verified_at=excluded.verified_at", + (uid, digest, unique_id, int(self.clock()))) + with self.connect() as db: + if shape == "circle": + db.execute("DELETE FROM shapes WHERE uid=?", (uid,)) + else: + db.execute("INSERT INTO shapes(uid,shape,updated_at) VALUES(?,?,?) " + "ON CONFLICT(uid) DO UPDATE SET shape=excluded.shape,updated_at=excluded.updated_at", + (uid, shape, int(self.clock()))) + return {"ok": True, "published": True, "shape": shape} + 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") @@ -148,12 +204,15 @@ class Registry: supporters = {row[0] for row in db.execute( "SELECT uid FROM supporters WHERE uid IN (" + placeholders + ")", uids ).fetchall()} + shapes = {row[0]: row[1] for row in db.execute( + "SELECT uid,shape FROM shapes WHERE uid IN (" + placeholders + ") AND uid NOT IN (SELECT uid FROM blocked)", uids + ).fetchall() if row[1] in SHAPES} 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} + "niche": {uid: uid in niche for uid in uids}, "shapes": shapes, "ttl": 300} def set_supporter(self, uid, enabled=True): """Admin-only when invoked from the server CLI; never exposed over HTTP.""" @@ -175,7 +234,8 @@ class Limits: def allow(self, ip, operation): key = ip, operation - window, count = (60, 120) if operation == "lookup" else (3600, 10) if operation == "claim" else (3600, 40) + window, count = ((60, 120) if operation == "lookup" else (3600, 10) if operation == "claim" + else (3600, 30) if operation == "shape" else (3600, 40)) now = time.monotonic() with self.lock: if len(self.events) > 10000: @@ -215,11 +275,11 @@ class Handler(BaseHTTPRequestHandler): 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"): + if self.path != "/members/v1/" + operation or operation not in ("presence", "lookup", "withdraw", "code", "claim", "shape"): 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"): + if not self.server.limits.allow(ip, operation if operation in ("lookup", "claim", "shape") else "presence"): return self.send_json(429, {"error": "Try again later"}) try: length = int(self.headers.get("Content-Length", "0")) @@ -236,6 +296,9 @@ class Handler(BaseHTTPRequestHandler): "deadline": CLAIM_DEADLINE, "open": time.time() <= CLAIM_DEADLINE} elif operation == "claim": result = self.server.registry.claim(payload.get("uid"), payload.get("unique_id")) + elif operation == "shape": + result = self.server.registry.shape(payload.get("uid"), payload.get("installation"), + payload.get("shape"), 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) diff --git a/src/lihont/two/avatar/AvatarShapes.java b/src/lihont/two/avatar/AvatarShapes.java new file mode 100644 index 0000000..e36b877 --- /dev/null +++ b/src/lihont/two/avatar/AvatarShapes.java @@ -0,0 +1,67 @@ +package lihont.two.avatar; + +import android.graphics.Canvas; +import android.graphics.Path; +import android.view.View; + +import lihont.two.settings.ModSettings; + +/** + * What TikTok's avatar view asks the mod around its drawing (see lihont/avatar_patch.py). + * + * With a shape other than the round one, begin() clips the canvas to that shape, sized to the view, and zooms + * the picture when the shape reaches out of the circle TikTok already cut the picture to; end() takes it off. + * Every shape is drawn inside the circle (see Shapes), so the picture, which is round, always covers it. + * Only the account's own picture and those of members who published a shape take one (see Owner); every other + * avatar stays round. + */ +public final class AvatarShapes { + private static final java.util.Set SEEN = new java.util.HashSet(); + private static Canvas openCanvas; + private static int openSave; + private static final java.util.Map PATHS = new java.util.HashMap(); + + private AvatarShapes() {} + + /** Called before the avatar is drawn. Never throws. */ + public static void begin(View view, Canvas canvas) { + try { + int width = view.getWidth(); + int height = view.getHeight(); + if (width <= 0 || height <= 0 || Math.abs(width - height) > 2) return; + ModSettings.AvatarShape shape = Owner.shapeFor(view); + if (shape == null || shape == ModSettings.AvatarShape.CIRCLE) return; + String name = view.getClass().getName(); + if (SEEN.size() < 40 && SEEN.add(shape + " " + name + " " + width + "x" + height)) { + android.util.Log.i("LIHONT", "avatar-shape: " + shape + " on " + name + " " + width + "x" + height); + } + long key = ((long) shape.ordinal() << 40) | ((long) width << 20) | height; + Path path = PATHS.get(key); + if (path == null) { + if (PATHS.size() > 64) PATHS.clear(); + path = Shapes.path(shape, width, height); + PATHS.put(key, path); + } + int save = canvas.save(); + canvas.clipPath(path); + float zoom = Shapes.zoom(shape); + if (zoom != 1f) canvas.scale(zoom, zoom, width / 2f, height / 2f); + openCanvas = canvas; + openSave = save; + } catch (Throwable ignored) { + // Decoration: the avatar is drawn as TikTok draws it. + } + } + + /** Called when the avatar has been drawn: takes off what begin() put on the canvas. Never throws. */ + public static void end(Canvas canvas) { + try { + if (openCanvas == canvas) { + canvas.restoreToCount(openSave); + openCanvas = null; + } + } catch (Throwable ignored) { + openCanvas = null; + } + } +} diff --git a/src/lihont/two/avatar/Installation.java b/src/lihont/two/avatar/Installation.java new file mode 100644 index 0000000..b32234f --- /dev/null +++ b/src/lihont/two/avatar/Installation.java @@ -0,0 +1,54 @@ +package lihont.two.avatar; + +import android.content.Context; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.SecureRandom; + +/** + * The secret of this installation: 64 hex digits made once and kept in one private file. The members service + * binds an account to the installation that proved it, so only this installation may change its published shape + * without proving the account again. It never leaves the phone except in requests to that service. + */ +final class Installation { + private static final String FILE = "lihont-installation"; + private static String secret; + + private Installation() {} + + static synchronized String secret(Context context) throws IOException { + if (secret != null) return secret; + File file = new File(context.getFilesDir(), FILE); + if (file.isFile()) { + byte[] raw = new byte[64]; + try (FileInputStream in = new FileInputStream(file)) { + int read = in.read(raw); + if (read == 64 && valid(new String(raw, "US-ASCII"))) { + secret = new String(raw, "US-ASCII"); + return secret; + } + } + } + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + StringBuilder hex = new StringBuilder(64); + for (byte b : bytes) hex.append(Character.forDigit((b >> 4) & 15, 16)).append(Character.forDigit(b & 15, 16)); + try (FileOutputStream out = new FileOutputStream(file)) { + out.write(hex.toString().getBytes("US-ASCII")); + } + secret = hex.toString(); + return secret; + } + + private static boolean valid(String text) { + if (text.length() != 64) return false; + for (int i = 0; i < 64; i++) { + char c = text.charAt(i); + if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) return false; + } + return true; + } +} diff --git a/src/lihont/two/avatar/Owner.java b/src/lihont/two/avatar/Owner.java new file mode 100644 index 0000000..f3caae1 --- /dev/null +++ b/src/lihont/two/avatar/Owner.java @@ -0,0 +1,99 @@ +package lihont.two.avatar; + +import android.net.Uri; +import android.os.SystemClock; +import android.view.View; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import lihont.two.bar.UserAvatar; +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; + +/** + * Whose picture an avatar view shows, and so which shape it is drawn in. + * + * Every avatar view of TikTok 47.1.3 keeps the request it was loaded from, and the request keeps the picture's + * address. The last part of that address names the picture. The account's own picture is known from its user + * model and takes the shape chosen in the settings; another account's picture is traced back to the account + * (see PictureOwners) and takes the shape that account published, if it did (see ShapeCatalog). Everything else + * stays round. + */ +final class Owner { + private static final String REQUEST = "LLJJIJI"; + private static final String ADDRESS = "LIZLLL"; + private static final long REFRESH_MS = 3000L; + private static final int MAX_CACHED = 256; + private static final Object MISSING = new Object(); + + private static final Map, Object> REQUESTS = new HashMap, Object>(); + private static final Map, Object> ADDRESSES = new HashMap, Object>(); + private static final Map VERDICTS = new HashMap(); + private static Set mine; + private static long refreshAt; + + private Owner() {} + + /** The shape this view is drawn in, or null for TikTok's round one. Never throws. */ + static ModSettings.AvatarShape shapeFor(View view) { + try { + String id = Pictures.id(addressOf(view)); + if (id == null) return null; + if (isMine(id)) return SettingsHost.avatarShape(); + String uid = PictureOwners.uidOf(id); + return uid == null ? null : ShapeCatalog.shapeOf(uid, view); + } catch (Throwable unreadable) { + return null; + } + } + + private static boolean isMine(String id) { + long now = SystemClock.uptimeMillis(); + if (mine == null || now >= refreshAt) { + Set fresh = UserAvatar.pictureIds(); + if (mine == null || !fresh.equals(mine)) VERDICTS.clear(); + mine = fresh; + refreshAt = now + REFRESH_MS; + } + Boolean known = VERDICTS.get(id); + if (known == null) { + if (VERDICTS.size() > MAX_CACHED) VERDICTS.clear(); + known = Boolean.valueOf(mine.contains(id)); + VERDICTS.put(id, known); + } + return known.booleanValue(); + } + + private static String addressOf(View view) throws Exception { + Field request = field(REQUESTS, view.getClass(), REQUEST); + if (request == null) return null; + Object value = request.get(view); + if (value == null) return null; + Field address = field(ADDRESSES, value.getClass(), ADDRESS); + if (address == null) return null; + Object text = address.get(value); + return text instanceof String ? (String) text : text instanceof Uri ? text.toString() : null; + } + + private static Field field(Map, Object> cache, Class type, String name) { + Object found = cache.get(type); + if (found == null) { + found = MISSING; + for (Class level = type; level != null && level != Object.class; level = level.getSuperclass()) { + try { + Field candidate = level.getDeclaredField(name); + candidate.setAccessible(true); + found = candidate; + break; + } catch (Throwable absent) { + // Keep looking in the superclass. + } + } + cache.put(type, found); + } + return found == MISSING ? null : (Field) found; + } +} diff --git a/src/lihont/two/avatar/PictureOwners.java b/src/lihont/two/avatar/PictureOwners.java new file mode 100644 index 0000000..0542c83 --- /dev/null +++ b/src/lihont/two/avatar/PictureOwners.java @@ -0,0 +1,88 @@ +package lihont.two.avatar; + +import android.os.SystemClock; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Which account a profile picture belongs to. + * + * An avatar view only knows the address of its picture, but the user models that pass through the name getters + * (see lihont.two.badge.AccountNames) know the account and the addresses of its pictures in every size. Each + * model is read once in a while and the name of every picture (the last part of its address) is kept for the + * account, so that a picture on screen can be traced back to an account whose avatar shape may be published. + */ +public final class PictureOwners { + private static final long RELEARN_MS = 60000L; + private static final int MAX_PICTURES = 6000; + private static final int MAX_ACCOUNTS = 3000; + private static final String[] GETTERS = {"getAvatarThumb", "getAvatarMedium", "getAvatarLarger"}; + + private static final Map OWNERS = new LinkedHashMap(); + private static final Map LEARNED = new LinkedHashMap(); + private static final Map, Method[]> METHODS = new HashMap, Method[]>(); + + private PictureOwners() {} + + /** Called with a user model and its account id; never throws. */ + public static void learn(Object user, String uid) { + try { + long now = SystemClock.uptimeMillis(); + synchronized (OWNERS) { + Long last = LEARNED.get(uid); + if (last != null && now - last.longValue() < RELEARN_MS) return; + LEARNED.put(uid, Long.valueOf(now)); + if (LEARNED.size() > MAX_ACCOUNTS) LEARNED.remove(LEARNED.keySet().iterator().next()); + } + for (Method getter : getters(user.getClass())) { + if (getter == null) continue; + Object model = getter.invoke(user); + if (model == null) continue; + Object urls = model.getClass().getMethod("getUrlList").invoke(model); + if (!(urls instanceof List)) continue; + for (Object item : (List) urls) { + String id = item instanceof String ? Pictures.id((String) item) : null; + if (id != null) remember(id, uid); + } + } + } catch (Throwable unreadable) { + // The picture stays round: an unknown owner never gets a shape. + } + } + + /** The account that a picture of this name was seen with, or null. */ + public static String uidOf(String pictureId) { + synchronized (OWNERS) { + return OWNERS.get(pictureId); + } + } + + private static void remember(String id, String uid) { + synchronized (OWNERS) { + OWNERS.put(id, uid); + if (OWNERS.size() > MAX_PICTURES) OWNERS.remove(OWNERS.keySet().iterator().next()); + } + } + + private static Method[] getters(Class type) { + synchronized (METHODS) { + Method[] found = METHODS.get(type); + if (found == null) { + found = new Method[GETTERS.length]; + for (int i = 0; i < GETTERS.length; i++) { + try { + found[i] = type.getMethod(GETTERS[i]); + } catch (Throwable absent) { + found[i] = null; + } + } + METHODS.put(type, found); + } + return found; + } + } +} diff --git a/src/lihont/two/avatar/Pictures.java b/src/lihont/two/avatar/Pictures.java new file mode 100644 index 0000000..e0d0211 --- /dev/null +++ b/src/lihont/two/avatar/Pictures.java @@ -0,0 +1,19 @@ +package lihont.two.avatar; + +/** Names of profile pictures, taken from their addresses. */ +public final class Pictures { + private Pictures() {} + + /** + * The name of the picture inside an address: the last path part, without the size template after the "~" + * (the same picture is served in many sizes). Null for anything that is not an http address. + */ + public static String id(String url) { + if (url == null || !url.startsWith("http")) return null; + int end = url.indexOf('~'); + if (end < 0) end = url.indexOf('?'); + if (end < 0) end = url.length(); + int start = url.lastIndexOf('/', end); + return start < 0 || start + 1 >= end ? null : url.substring(start + 1, end); + } +} diff --git a/src/lihont/two/avatar/ShapeCatalog.java b/src/lihont/two/avatar/ShapeCatalog.java new file mode 100644 index 0000000..a1685a6 --- /dev/null +++ b/src/lihont/two/avatar/ShapeCatalog.java @@ -0,0 +1,151 @@ +package lihont.two.avatar; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.view.View; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import lihont.two.badge.MemberClient; +import lihont.two.settings.ModSettings; + +/** + * The avatar shapes that other members have published, asked from the members service in batches. + * + * An account's shape is looked up when one of its pictures is first drawn; the answer (a shape or "none") is + * kept for a few minutes and a failed lookup only for one, so a service that is down costs nothing but a + * round avatar. Views that were drawn before the answer came are redrawn when it arrives. + */ +public final class ShapeCatalog { + private static final long KEEP_MS = 300000L; + private static final long FAILED_MS = 60000L; + private static final int BATCH = 100; + private static final int MAX_KNOWN = 4096; + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static final ExecutorService NETWORK = Executors.newSingleThreadExecutor(); + + /** uid -> the shape, or null for "round"; with the moment the answer stops being fresh. */ + private static final Map KNOWN = new LinkedHashMap(); + private static final Set PENDING = new HashSet(); + private static final List> WAITING = new ArrayList>(); + private static boolean scheduled; + + private static final class Entry { + final ModSettings.AvatarShape shape; + final long until; + + Entry(ModSettings.AvatarShape shape, long until) { + this.shape = shape; + this.until = until; + } + } + + private ShapeCatalog() {} + + /** + * The shape published for `uid`, or null while it is round or not known yet. A view that asked is redrawn + * when the answer arrives. + */ + static ModSettings.AvatarShape shapeOf(String uid, View asking) { + long now = SystemClock.uptimeMillis(); + Entry entry = KNOWN.get(uid); + if (entry != null && now < entry.until) return entry.shape; + if (asking != null && WAITING.size() < 200) WAITING.add(new WeakReference(asking)); + if (validUid(uid) && PENDING.add(uid) && !scheduled) { + scheduled = true; + MAIN.postDelayed(new Runnable() { + @Override public void run() { + flush(); + } + }, 250L); + } + return entry == null ? null : entry.shape; + } + + private 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; + } + + private static void flush() { + scheduled = false; + if (PENDING.isEmpty()) return; + final List ids = new ArrayList(); + for (Iterator it = PENDING.iterator(); it.hasNext() && ids.size() < BATCH; ) { + ids.add(it.next()); + it.remove(); + } + NETWORK.execute(new Runnable() { + @Override public void run() { + JSONObject shapes = null; + try { + JSONObject reply = MemberClient.post("lookup", new JSONObject().put("uids", new JSONArray(ids))); + shapes = reply.optJSONObject("shapes"); + if (shapes == null) shapes = new JSONObject(); + } catch (Exception ignored) { + // A failed lookup leaves everyone round for a minute. + } + final JSONObject found = shapes; + MAIN.post(new Runnable() { + @Override public void run() { + record(ids, found); + } + }); + } + }); + } + + private static void record(List ids, JSONObject shapes) { + long now = SystemClock.uptimeMillis(); + if (KNOWN.size() > MAX_KNOWN) KNOWN.clear(); + for (String uid : ids) { + if (shapes == null) { + KNOWN.put(uid, new Entry(null, now + FAILED_MS)); + continue; + } + ModSettings.AvatarShape shape = null; + try { + if (shapes.has(uid)) shape = ModSettings.AvatarShape.fromValue(shapes.getString(uid)); + } catch (Exception unknown) { + shape = null; // a shape this version does not know stays round + } + KNOWN.put(uid, new Entry(shape, now + KEEP_MS)); + } + if (shapes != null) redraw(); + if (!PENDING.isEmpty() && !scheduled) { + scheduled = true; + MAIN.postDelayed(new Runnable() { + @Override public void run() { + flush(); + } + }, 500L); + } + } + + private static void redraw() { + List> views = new ArrayList>(WAITING); + WAITING.clear(); + for (WeakReference reference : views) { + View view = reference.get(); + if (view != null && view.isAttachedToWindow()) view.invalidate(); + } + } +} diff --git a/src/lihont/two/avatar/ShapePublisher.java b/src/lihont/two/avatar/ShapePublisher.java new file mode 100644 index 0000000..6c2ac24 --- /dev/null +++ b/src/lihont/two/avatar/ShapePublisher.java @@ -0,0 +1,81 @@ +package lihont.two.avatar; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; + +import org.json.JSONObject; + +import lihont.two.badge.MemberClient; +import lihont.two.badge.NicheClaim; +import lihont.two.settings.ModSettings; + +/** + * Puts the account's own avatar shape on the members service, where other members' phones read it. + * + * The service wants the account proven once: its public bio must hold the account's code (the same code as for + * the niche badge). After that this installation changes the shape on its own. Only the numeric account id, + * the public username, the shape and this installation's secret are sent. + */ +public final class ShapePublisher { + public static final class Result { + public final boolean published; + /** The code to put into the bio when the account still has to be proven, else null. */ + public final String code; + public final String message; + + Result(boolean published, String code, String message) { + this.published = published; + this.code = code; + this.message = message; + } + } + + public interface Callback { + void done(Result result); + } + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + + private ShapePublisher() {} + + /** Publishes `shape` for the signed-in account; "circle" withdraws a published shape. Never throws. */ + public static void publish(final Context context, final ModSettings.AvatarShape shape, final Callback callback) { + final NicheClaim.Account account = NicheClaim.account(); + if (account == null) { + callback.done(new Result(false, null, "Войдите в TikTok, чтобы показывать форму другим")); + return; + } + new Thread(new Runnable() { + @Override public void run() { + Result result; + try { + JSONObject reply = MemberClient.post("shape", new JSONObject() + .put("uid", account.uid) + .put("installation", Installation.secret(context.getApplicationContext() != null + ? context.getApplicationContext() : context)) + .put("shape", shape.value()) + .put("unique_id", account.uniqueId)); + if (reply.optBoolean("published", false)) { + result = new Result(true, null, shape == ModSettings.AvatarShape.CIRCLE + ? "Форма снята: у вас снова круг для всех" + : "Форма опубликована: её видят все с модом"); + } else { + String code = reply.optString("code", ""); + result = new Result(false, code.length() == 0 ? null : code, + "Кода в описании профиля не нашли. Вставьте его и подождите минуту."); + } + } catch (Exception error) { + result = new Result(false, null, + error.getMessage() == null ? "Нет связи с сервером" : error.getMessage()); + } + final Result outcome = result; + MAIN.post(new Runnable() { + @Override public void run() { + callback.done(outcome); + } + }); + } + }, "lihont-shape").start(); + } +} diff --git a/src/lihont/two/avatar/Shapes.java b/src/lihont/two/avatar/Shapes.java new file mode 100644 index 0000000..3405289 --- /dev/null +++ b/src/lihont/two/avatar/Shapes.java @@ -0,0 +1,72 @@ +package lihont.two.avatar; + +import android.graphics.Path; +import android.graphics.RectF; + +import lihont.two.settings.ModSettings; + +/** The outlines an avatar can take, as paths that fit a width by a height (the shape sits in the middle square). */ +public final class Shapes { + private Shapes() {} + + /** + * How much the (round) picture is enlarged around its centre so that the shape is covered to its edge: + * a shape that reaches past the circle would otherwise show the empty corners of the picture. + */ + public static float zoom(ModSettings.AvatarShape shape) { + switch (shape) { + case SQUIRCLE: return 1.14f; + case TRIANGLE: return 1.17f; + default: return 1f; + } + } + + public static Path path(ModSettings.AvatarShape shape, float width, float height) { + float s = Math.min(width, height); + float x = (width - s) / 2f; + float y = (height - s) / 2f; + Path path = new Path(); + switch (shape) { + case FLOWER: { + path.addCircle(x + s * 0.5f, y + s * 0.5f, s * 0.31f, Path.Direction.CW); + for (int petal = 0; petal < 6; petal++) { + double angle = petal * Math.PI / 3.0 - Math.PI / 2.0; + path.addCircle(x + s * (0.5f + 0.31f * (float) Math.cos(angle)), + y + s * (0.5f + 0.31f * (float) Math.sin(angle)), s * 0.19f, Path.Direction.CW); + } + break; + } + case SQUIRCLE: + path.addRoundRect(new RectF(x, y, x + s, y + s), s * 0.34f, s * 0.34f, Path.Direction.CW); + break; + case HEXAGON: { + for (int corner = 0; corner < 6; corner++) { + double angle = Math.PI / 6.0 + corner * Math.PI / 3.0 - Math.PI / 2.0 + Math.PI / 6.0; + float px = x + s * (0.5f + 0.5f * (float) Math.cos(angle)); + float py = y + s * (0.5f + 0.5f * (float) Math.sin(angle)); + if (corner == 0) path.moveTo(px, py); + else path.lineTo(px, py); + } + path.close(); + break; + } + case HEART: + path.moveTo(x + s * 0.5f, y + s * 0.94f); + path.cubicTo(x + s * 0.10f, y + s * 0.62f, x + s * 0.02f, y + s * 0.40f, x + s * 0.10f, y + s * 0.24f); + path.cubicTo(x + s * 0.18f, y + s * 0.08f, x + s * 0.40f, y + s * 0.06f, x + s * 0.5f, y + s * 0.27f); + path.cubicTo(x + s * 0.60f, y + s * 0.06f, x + s * 0.82f, y + s * 0.08f, x + s * 0.90f, y + s * 0.24f); + path.cubicTo(x + s * 0.98f, y + s * 0.40f, x + s * 0.90f, y + s * 0.62f, x + s * 0.5f, y + s * 0.94f); + path.close(); + break; + case TRIANGLE: + path.moveTo(x + s * 0.5f, y + s * 0.05f); + path.lineTo(x + s * 0.96f, y + s * 0.86f); + path.lineTo(x + s * 0.04f, y + s * 0.86f); + path.close(); + break; + default: + path.addCircle(x + s * 0.5f, y + s * 0.5f, s * 0.5f, Path.Direction.CW); + } + return path; + } +} diff --git a/src/lihont/two/badge/AccountNames.java b/src/lihont/two/badge/AccountNames.java index a3d2b6c..2306492 100644 --- a/src/lihont/two/badge/AccountNames.java +++ b/src/lihont/two/badge/AccountNames.java @@ -39,6 +39,7 @@ final class AccountNames { try { String uid = (String) user.getClass().getMethod("getUid").invoke(user); if (!validUid(uid)) return name; + lihont.two.avatar.PictureOwners.learn(user, uid); String copy = new String(name); synchronized (NAMES) { NAMES.put(copy, uid); diff --git a/src/lihont/two/badge/MemberClient.java b/src/lihont/two/badge/MemberClient.java index f3a9334..6b5f742 100644 --- a/src/lihont/two/badge/MemberClient.java +++ b/src/lihont/two/badge/MemberClient.java @@ -20,7 +20,7 @@ import java.util.Set; * 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 { +public final class MemberClient { static final String API = "https://lihont.space/members/v1/"; private MemberClient() {} @@ -37,7 +37,7 @@ final class MemberClient { return result; } - static JSONObject post(String operation, JSONObject payload) throws Exception { + public static JSONObject post(String operation, JSONObject payload) throws Exception { HttpURLConnection connection = (HttpURLConnection) new URL(API + operation).openConnection(); connection.setConnectTimeout(5000); connection.setReadTimeout(8000); diff --git a/src/lihont/two/bar/AvatarBadge.java b/src/lihont/two/bar/AvatarBadge.java index 303b60a..558233d 100644 --- a/src/lihont/two/bar/AvatarBadge.java +++ b/src/lihont/two/bar/AvatarBadge.java @@ -6,13 +6,19 @@ import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Matrix; import android.graphics.Paint; +import android.graphics.Path; import android.graphics.PixelFormat; import android.graphics.Shader; import android.graphics.drawable.Drawable; import android.view.View; +import lihont.two.avatar.Shapes; +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; + /** - * The user's avatar in a circle, drawn over the profile tab's icon, with a ring while the tab is selected. + * The user's avatar in the shape chosen in the settings (a circle unless another is chosen), drawn over the + * profile tab's icon, with a ring of the same shape while the tab is selected. * * It lives in the tab's view overlay: above the icon, outside the layout, never taking a touch. */ @@ -68,12 +74,29 @@ final class AvatarBadge extends Drawable { 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); + ModSettings.AvatarShape shape = SettingsHost.avatarShape(); + if (shape == ModSettings.AvatarShape.CIRCLE) { + 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); + } + return; } + canvas.drawPath(outline(shape, cx, cy, radius), fill); + if (item.isSelected()) { + canvas.drawPath(outline(shape, cx, cy, radius + (RING_GAP_DP + RING_WIDTH_DP / 2f) * density), ring); + } else { + canvas.drawPath(outline(shape, cx, cy, radius - edge.getStrokeWidth() / 2f), edge); + } + } + + /** The shape as a path around (cx, cy) that fits a circle of this radius. */ + private Path outline(ModSettings.AvatarShape shape, float cx, float cy, float radius) { + Path path = Shapes.path(shape, 2f * radius, 2f * radius); + path.offset(cx - radius, cy - radius); + return path; } /** The badge is always opaque; the thin edge keeps its own translucency. */ diff --git a/src/lihont/two/bar/BarStyle.java b/src/lihont/two/bar/BarStyle.java index 173cd80..46dcb60 100644 --- a/src/lihont/two/bar/BarStyle.java +++ b/src/lihont/two/bar/BarStyle.java @@ -13,6 +13,7 @@ import android.widget.TextView; import lihont.two.settings.Md3; import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; /** * Material 3 styling for TikTok's five-tab bottom bar, laid over the views TikTok built. @@ -38,6 +39,12 @@ final class BarStyle implements ViewTreeObserver.OnPreDrawListener, View.OnAttac private final ModSettings.Theme theme; private final float density; private final NavPill[] pills = new NavPill[TABS]; + /** The labels under the icons; with the compact bar on they stay out of the layout. */ + private final TextView[] labels = new TextView[TABS]; + private final boolean compact = SettingsHost.compactBar(); + /** The create button (the middle tab) and the pill drawn in place of TikTok's own plus. */ + private View create; + private PlusPill plus; private ColorDrawable tint; private AvatarBadge badge; private View profileItem; @@ -75,11 +82,17 @@ final class BarStyle implements ViewTreeObserver.OnPreDrawListener, View.OnAttac tint = new ColorDrawable(tintColor); bar.setBackground(tint); paintNavigationBar(); + create = bar.getChildAt(CREATE); + if (create != null) { + plus = new PlusPill(density, palette.primary, palette.onPrimary); + android.util.Log.i("LihonT", "bar-style create button: " + describe(create)); + } int styled = 0; for (int i = 0; i < TABS; i++) { View item = bar.getChildAt(i); View icon = firstImage(item); if (icon == null) continue; + labels[i] = firstText(item); if (i == PROFILE) { profileItem = item; profileIcon = icon; @@ -93,6 +106,8 @@ final class BarStyle implements ViewTreeObserver.OnPreDrawListener, View.OnAttac pills[i] = pill; styled++; } + hideLabels(); + styleCreate(); bar.getViewTreeObserver().addOnPreDrawListener(this); bar.addOnAttachStateChangeListener(this); android.util.Log.i("LihonT", "bar-style pills=" + styled + " profile=" + (profileIcon != null) @@ -131,6 +146,44 @@ final class BarStyle implements ViewTreeObserver.OnPreDrawListener, View.OnAttac if (Color.alpha(fill.getColor()) != 0 && fill.getColor() != tintColor) fill.setColor(tintColor); } + /** + * The create button gets the pill as its background and TikTok's own plus, which is a picture inside it, + * is kept invisible; both are re-checked every frame, like the rest of the bar. + */ + private void styleCreate() { + if (create == null || plus == null) return; + if (create.getBackground() != plus) create.setBackground(plus); + if (create instanceof ViewGroup) { + ViewGroup group = (ViewGroup) create; + for (int i = 0; i < group.getChildCount(); i++) { + View child = group.getChildAt(i); + if (child.getAlpha() != 0f) child.setAlpha(0f); + } + } + } + + private static String describe(View view) { + StringBuilder out = new StringBuilder(view.getClass().getName()) + .append(" ").append(view.getWidth()).append("x").append(view.getHeight()) + .append(" bg=").append(view.getBackground()); + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + View child = group.getChildAt(i); + out.append(" [").append(child.getClass().getSimpleName()).append(" ") + .append(child.getWidth()).append("x").append(child.getHeight()).append("]"); + } + } + return out.toString(); + } + + private void hideLabels() { + if (!compact) return; + for (TextView label : labels) { + if (label != null && label.getVisibility() != View.GONE) label.setVisibility(View.GONE); + } + } + private void refreshAvatar() { if (profileItem != null) AvatarCache.refresh(activity, this); } @@ -173,6 +226,7 @@ final class BarStyle implements ViewTreeObserver.OnPreDrawListener, View.OnAttac if (pill != null) pill.setColor(palette.secondaryContainer); } if (badge != null) badge.setRingColor(palette.onSurface); + if (plus != null) plus.setColors(palette.primary, palette.onPrimary); } // 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. @@ -184,6 +238,8 @@ final class BarStyle implements ViewTreeObserver.OnPreDrawListener, View.OnAttac tint.setColor(tintColor); } paintNavigationBar(); + hideLabels(); + styleCreate(); } catch (Throwable failure) { // Decoration must not break a frame. if (replaced++ < 6) android.util.Log.i("LihonT", "bar-style frame check failed: " + failure); diff --git a/src/lihont/two/bar/BottomBar.java b/src/lihont/two/bar/BottomBar.java index 91fbae2..c9d575d 100644 --- a/src/lihont/two/bar/BottomBar.java +++ b/src/lihont/two/bar/BottomBar.java @@ -4,8 +4,8 @@ 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.look.Windows; import lihont.two.settings.ModSettings; import lihont.two.settings.SettingsHost; @@ -34,7 +34,8 @@ public final class BottomBar { if (settings != null && !settings.md3()) return; ModSettings.Theme theme = settings != null ? settings.theme() : ModSettings.defaults().theme(); ProfileMenu.watch(activity, theme); - CommentSkin.watch(activity, theme); + Windows.install(activity.getApplication()); + Windows.attach(activity); locate(activity, theme, 0); } catch (Throwable ignored) { // The bar is decoration: never let it take the main screen down. diff --git a/src/lihont/two/bar/PlusPill.java b/src/lihont/two/bar/PlusPill.java new file mode 100644 index 0000000..77a3dec --- /dev/null +++ b/src/lihont/two/bar/PlusPill.java @@ -0,0 +1,51 @@ +package lihont.two.bar; + +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; + +/** The create button of the bottom bar as a Material 3 pill: the accent colour with a plus on it. */ +final class PlusPill extends Drawable { + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glyph = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF body = new RectF(); + private final float density; + + PlusPill(float density, int fillColor, int glyphColor) { + this.density = density; + setColors(fillColor, glyphColor); + } + + void setColors(int fillColor, int glyphColor) { + fill.setColor(fillColor); + glyph.setColor(glyphColor); + invalidateSelf(); + } + + @Override public void draw(Canvas canvas) { + Rect bounds = getBounds(); + float width = 58f * density; + float height = 38f * density; + float cx = bounds.exactCenterX(); + float cy = bounds.exactCenterY(); + body.set(cx - width / 2f, cy - height / 2f, cx + width / 2f, cy + height / 2f); + float radius = height / 2.4f; + canvas.drawRoundRect(body, radius, radius, fill); + float arm = 9f * density; + float thick = 1.6f * density; + canvas.drawRoundRect(cx - arm, cy - thick, cx + arm, cy + thick, thick, thick, glyph); + canvas.drawRoundRect(cx - thick, cy - arm, cx + thick, cy + arm, thick, thick, glyph); + } + + @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/bar/UserAvatar.java b/src/lihont/two/bar/UserAvatar.java index 8eaca2c..c5979f1 100644 --- a/src/lihont/two/bar/UserAvatar.java +++ b/src/lihont/two/bar/UserAvatar.java @@ -1,46 +1,79 @@ -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(); - } - } -} +package lihont.two.bar; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 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. + */ +public 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 { + Object user = currentUser(); + return user == null ? Collections.emptyList() : urlsOf(user, "getAvatarThumb"); + } catch (Throwable unavailable) { + return Collections.emptyList(); + } + } + + /** + * What names the current user's picture in every size: the last part of each avatar address, without + * the size template after the "~". Empty when logged out or unreadable. + */ + public static Set pictureIds() { + Set ids = new HashSet(); + try { + Object user = currentUser(); + if (user == null) return ids; + for (String getter : new String[] {"getAvatarThumb", "getAvatarMedium", "getAvatarLarger"}) { + for (String url : urlsOf(user, getter)) { + String id = lihont.two.avatar.Pictures.id(url); + if (id != null) ids.add(id); + } + } + } catch (Throwable unavailable) { + // An empty set means "nobody's picture is known". + } + return ids; + } + + private static Object currentUser() throws Exception { + 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); + return service == null ? null : serviceClass.getMethod("getCurUser").invoke(service); + } + + private static List urlsOf(Object user, String getter) { + List urls = new ArrayList(); + try { + Object model = user.getClass().getMethod(getter).invoke(user); + if (model == null) return urls; + Method listMethod = model.getClass().getMethod("getUrlList"); + Object raw = listMethod.invoke(model); + if (raw instanceof List) { + for (Object item : (List) raw) { + if (item instanceof String && ((String) item).startsWith("http")) urls.add((String) item); + } + } + } catch (Throwable missing) { + // This size is simply not known. + } + return urls; + } +} diff --git a/src/lihont/two/comments/CommentPanel.java b/src/lihont/two/comments/CommentPanel.java index 783ee23..69d10bb 100644 --- a/src/lihont/two/comments/CommentPanel.java +++ b/src/lihont/two/comments/CommentPanel.java @@ -78,10 +78,15 @@ final class CommentPanel { private final PorterDuffColorFilter accentIcon; private final HashMap ids = new HashMap(); private final HashMap cache = new HashMap(); - private final WeakHashMap cards = new WeakHashMap(); + /** + * What the skin did to each card view. TikTok keeps its recycled card views between two openings of the + * panel, so this outlives a CommentPanel: a card seen again must not get its margins added a second time. + */ + private static final WeakHashMap CARDS = new WeakHashMap(); private static final class CardState { final Fill fill; + int color; int left = Integer.MIN_VALUE; int right = Integer.MIN_VALUE; int top = Integer.MIN_VALUE; @@ -288,14 +293,19 @@ final class CommentPanel { /** One comment as a rounded card; returns true when its margins changed. */ private boolean card(View card) { - CardState state = cards.get(card); + CardState state = CARDS.get(card); if (state == null) { state = new CardState(new Fill(palette.surfaceContainer, CARD_RADIUS_DP * density)); - cards.put(card, state); + state.color = palette.surfaceContainer; + 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()); } + if (state.color != palette.surfaceContainer) { + state.color = palette.surfaceContainer; // light or dark has changed since this card was last seen + state.fill.setColor(state.color); + } // A background TikTok put there itself (the highlight of a linked comment) is left alone. if (card.getBackground() == null) card.setBackground(state.fill); diff --git a/src/lihont/two/look/Windows.java b/src/lihont/two/look/Windows.java new file mode 100644 index 0000000..3503bce --- /dev/null +++ b/src/lihont/two/look/Windows.java @@ -0,0 +1,61 @@ +package lihont.two.look; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import android.util.Log; + +import lihont.two.comments.CommentSkin; +import lihont.two.profile.ProfileSkin; +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; + +/** + * Puts the Material 3 skins on every TikTok screen, not only on the main one. + * + * The main screen calls install() once (through BottomBar.onResumed); from then on every activity that + * comes to the front gets the comment and profile watchers, so a profile opened from a search, or the + * comments of a video opened from a profile, look the same as on the main screen. + */ +public final class Windows implements Application.ActivityLifecycleCallbacks { + private static final String TAG = "LIHONT"; + private static boolean installed; + + private Windows() {} + + public static void install(Application application) { + if (installed || application == null) return; + installed = true; + application.registerActivityLifecycleCallbacks(new Windows()); + Log.i(TAG, "look: skins follow every screen"); + } + + /** Watchers for a screen that has just come to the front; also called by the main screen itself. */ + public static void attach(Activity activity) { + try { + if (!SettingsHost.md3()) return; + ModSettings active = SettingsHost.active(); + ModSettings.Theme theme = active != null ? active.theme() : ModSettings.Theme.SYSTEM; + CommentSkin.watch(activity, theme); + ProfileSkin.watch(activity, theme); + } catch (Throwable ignored) { + // Decoration: never let it take a screen down. + } + } + + @Override public void onActivityResumed(Activity activity) { + attach(activity); + } + + @Override public void onActivityCreated(Activity activity, Bundle state) {} + + @Override public void onActivityStarted(Activity activity) {} + + @Override public void onActivityPaused(Activity activity) {} + + @Override public void onActivityStopped(Activity activity) {} + + @Override public void onActivitySaveInstanceState(Activity activity, Bundle state) {} + + @Override public void onActivityDestroyed(Activity activity) {} +} diff --git a/src/lihont/two/media/DownloadJob.java b/src/lihont/two/media/DownloadJob.java new file mode 100644 index 0000000..5b4ce11 --- /dev/null +++ b/src/lihont/two/media/DownloadJob.java @@ -0,0 +1,233 @@ +package lihont.two.media; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.Handler; +import android.os.Looper; +import android.provider.MediaStore; +import android.util.Log; +import android.widget.Toast; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.Locale; + +/** + * One file taken from TikTok's servers and put into the phone's gallery or music folder. + * + * TikTok's own save chain is left out: the address of the chosen quality, track or picture is fetched here + * and handed to MediaStore, so what lands in the gallery is exactly that file, without the logo. + */ +final class DownloadJob { + enum Kind { VIDEO, AUDIO, IMAGE } + + private static final String TAG = "LIHONT"; + private static final String FOLDER = "LihonT"; + private static final String AGENT = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0 Mobile Safari/537.36"; + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + + private final Context context; + private final Kind kind; + private final List addresses; + private final String name; + + DownloadJob(Context context, Kind kind, List addresses, String name) { + this.context = context.getApplicationContext() != null ? context.getApplicationContext() : context; + this.kind = kind; + this.addresses = addresses; + this.name = name; + } + + /** Runs on its own thread and reports with toasts; never throws. */ + void start(final String label) { + say(label.length() == 0 ? "Скачиваю…" : "Скачиваю: " + label); + Thread worker = new Thread(new Runnable() { + @Override public void run() { + try { + String where = fetch(); + say("Сохранено: " + where); + } catch (Throwable failure) { + Log.i(TAG, "download: failed " + failure); + say("Не удалось скачать: " + (failure.getMessage() == null ? failure.getClass().getSimpleName() + : failure.getMessage())); + } + } + }, "lihont-download"); + worker.start(); + } + + private String fetch() throws IOException { + IOException last = null; + for (String address : addresses) { + if (address == null || !address.startsWith("http")) continue; + try { + return fetchFrom(address); + } catch (IOException failure) { + Log.i(TAG, "download: " + shorten(address) + " -> " + failure.getMessage()); + last = failure; + } + } + throw last != null ? last : new IOException("нет адреса файла"); + } + + private static String shorten(String address) { + int query = address.indexOf('?'); + return query < 0 ? address : address.substring(0, query); + } + + private String fetchFrom(String address) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(address).openConnection(); + connection.setConnectTimeout(15000); + connection.setReadTimeout(30000); + connection.setRequestProperty("User-Agent", AGENT); + connection.setRequestProperty("Referer", "https://www.tiktok.com/"); + try { + int code = connection.getResponseCode(); + if (code / 100 != 2) throw new IOException("сервер ответил " + code); + String type = connection.getContentType(); + InputStream in = connection.getInputStream(); + try { + if (kind == Kind.IMAGE && type != null && (type.toLowerCase(Locale.ROOT).contains("heic") + || type.toLowerCase(Locale.ROOT).contains("heif"))) { + return saveAsJpeg(in, type); + } + String extension = extension(type); + return save(in, name + extension, mime(type, extension)); + } finally { + in.close(); + } + } finally { + connection.disconnect(); + } + } + + /** TikTok serves pictures as HEIC; they are saved as JPEG, which opens everywhere (HEIC stays if it cannot be decoded). */ + private String saveAsJpeg(InputStream in, String type) throws IOException { + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + byte[] buffer = new byte[16384]; + int read; + while ((read = in.read(buffer)) > 0) raw.write(buffer, 0, read); + byte[] data = raw.toByteArray(); + Bitmap picture = BitmapFactory.decodeByteArray(data, 0, data.length); + if (picture != null) { + ByteArrayOutputStream jpeg = new ByteArrayOutputStream(); + if (picture.compress(Bitmap.CompressFormat.JPEG, 95, jpeg)) { + picture.recycle(); + return save(new ByteArrayInputStream(jpeg.toByteArray()), name + ".jpg", "image/jpeg"); + } + picture.recycle(); + } + return save(new ByteArrayInputStream(data), name + ".heic", mime(type, ".heic")); + } + + private String extension(String type) { + String lower = type == null ? "" : type.toLowerCase(Locale.ROOT); + if (kind == Kind.IMAGE) { + if (lower.contains("webp")) return ".webp"; + if (lower.contains("png")) return ".png"; + if (lower.contains("heic")) return ".heic"; + return ".jpg"; + } + if (kind == Kind.AUDIO) { + if (lower.contains("mpeg") || lower.contains("mp3")) return ".mp3"; + if (lower.contains("ogg")) return ".ogg"; + return ".m4a"; + } + return ".mp4"; + } + + private String mime(String type, String extension) { + if (type != null && type.indexOf('/') > 0 && !type.startsWith("application")) { + int semicolon = type.indexOf(';'); + return semicolon > 0 ? type.substring(0, semicolon) : type; + } + if (".mp3".equals(extension)) return "audio/mpeg"; + if (".m4a".equals(extension)) return "audio/mp4"; + if (".webp".equals(extension)) return "image/webp"; + if (".png".equals(extension)) return "image/png"; + if (".jpg".equals(extension)) return "image/jpeg"; + return kind == Kind.VIDEO ? "video/mp4" : kind == Kind.AUDIO ? "audio/mp4" : "image/jpeg"; + } + + private String save(InputStream in, String fileName, String mime) throws IOException { + String folder = kind == Kind.VIDEO ? Environment.DIRECTORY_MOVIES + : kind == Kind.AUDIO ? Environment.DIRECTORY_MUSIC : Environment.DIRECTORY_PICTURES; + if (Build.VERSION.SDK_INT >= 29) { + ContentResolver resolver = context.getContentResolver(); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); + values.put(MediaStore.MediaColumns.MIME_TYPE, mime); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, folder + "/" + FOLDER); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + Uri uri = resolver.insert(collection(), values); + if (uri == null) throw new IOException("галерея не приняла файл"); + try { + OutputStream out = resolver.openOutputStream(uri); + if (out == null) throw new IOException("нет места для файла"); + try { + copy(in, out); + } finally { + out.close(); + } + ContentValues done = new ContentValues(); + done.put(MediaStore.MediaColumns.IS_PENDING, 0); + resolver.update(uri, done, null, null); + } catch (IOException | RuntimeException failure) { + resolver.delete(uri, null, null); + throw failure instanceof IOException ? (IOException) failure : new IOException(failure); + } + return folder + "/" + FOLDER; + } + File directory = new File(Environment.getExternalStoragePublicDirectory(folder), FOLDER); + if (!directory.isDirectory() && !directory.mkdirs()) throw new IOException("нет доступа к памяти"); + File file = new File(directory, fileName); + OutputStream out = new FileOutputStream(file); + try { + copy(in, out); + } finally { + out.close(); + } + android.media.MediaScannerConnection.scanFile(context, new String[] {file.getAbsolutePath()}, + new String[] {mime}, null); + return folder + "/" + FOLDER; + } + + private Uri collection() { + if (kind == Kind.VIDEO) return MediaStore.Video.Media.EXTERNAL_CONTENT_URI; + if (kind == Kind.AUDIO) return MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; + return MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + } + + private static void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = in.read(buffer)) > 0) out.write(buffer, 0, read); + } + + private void say(final String message) { + MAIN.post(new Runnable() { + @Override public void run() { + try { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + } catch (Throwable noToast) { + // Reporting only. + } + } + }); + } +} diff --git a/src/lihont/two/media/DownloadMenu.java b/src/lihont/two/media/DownloadMenu.java new file mode 100644 index 0000000..57c46f2 --- /dev/null +++ b/src/lihont/two/media/DownloadMenu.java @@ -0,0 +1,423 @@ +package lihont.two.media; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.content.ContextWrapper; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.GradientDrawable; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import lihont.two.settings.Md3; +import lihont.two.settings.Md3Icon; +import lihont.two.settings.ModSettings; +import lihont.two.settings.SettingsHost; + +/** + * The save menu of a video: TikTok's own "Save" button opens a Material 3 sheet with the qualities of the + * video from the smallest to the largest, the sound alone, and the pictures of a photo post. + * + * lihont/media_patch.py puts a call to gate() in front of the Save actions (X.0qv6 and X.0qvL) and to + * gatePhoto() in front of the photo save. When a gate answers true, TikTok's own save does not run: the + * sheet is shown instead and DownloadJob fetches the chosen file, without the logo. The last row of the + * video sheet hands the click back to TikTok's own save. + * + * The video, music and picture objects are read by reflection through their public getters, which + * TikTok keeps by name, so nothing here depends on obfuscated names except the one field that holds the + * video, which is found by its type. + */ +public final class DownloadMenu { + private static final String TAG = "LIHONT"; + private static final String AWEME = "com.ss.android.ugc.aweme.feed.model.Aweme"; + + /** True while TikTok's own save is re-run from the sheet: the gate lets that call through. */ + private static boolean handBack; + + private DownloadMenu() {} + + /** The Save action (X.0qv6.LJ, X.0qvL.LJ): true means the sheet replaced it. */ + public static boolean gate(Object action, Context context, Object argument) { + try { + Log.i(TAG, "download-menu: save action " + (action == null ? "null" : action.getClass().getName()) + + " handBack=" + handBack + " menu=" + SettingsHost.downloadMenu()); + if (handBack || !SettingsHost.downloadMenu()) return false; + Object aweme = find(action, AWEME); + Activity activity = activity(context); + Log.i(TAG, "download-menu: video " + (aweme != null) + " activity " + (activity != null)); + if (aweme == null || activity == null || activity.isFinishing()) return false; + return show(activity, aweme, action, context, argument); + } catch (Throwable failure) { + Log.i(TAG, "download-menu: gate failed " + failure); + return false; + } + } + + /** The photo save: true means the pictures are saved by the mod, without the logo. */ + public static boolean gatePhoto(Object handler, Context context, Object sharePackage) { + try { + Log.i(TAG, "download-menu: photo click, package " + + (sharePackage == null ? "null" : sharePackage.getClass().getName() + " fields " + + fieldTypes(sharePackage))); + if (!SettingsHost.downloadMenu() && !Downloads.noWatermark()) return false; + Object aweme = find(sharePackage, AWEME); + Activity activity = activity(context); + Log.i(TAG, "download-menu: photo aweme " + (aweme != null) + " activity " + (activity != null)); + if (aweme == null || activity == null || activity.isFinishing()) return false; + if (images(aweme).isEmpty()) return false; + return show(activity, aweme, null, context, null); + } catch (Throwable failure) { + Log.i(TAG, "download-menu: photo gate failed " + failure); + return false; + } + } + + // ---- what can be saved ------------------------------------------------------------- + + private static final class Option { + final String title; + final String detail; + final int icon; + final DownloadJob.Kind kind; + final List> files; + final String suffix; + final int sort; + + Option(String title, String detail, int icon, DownloadJob.Kind kind, List> files, + String suffix, int sort) { + this.title = title; + this.detail = detail; + this.icon = icon; + this.kind = kind; + this.files = files; + this.suffix = suffix; + this.sort = sort; + } + } + + private static List