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