LihonT 2 Luna pre-release

Avatar shapes (circle, flower, rounded square, hexagon, heart, triangle): the own shape everywhere and, through
the members service, the shapes other members published after proving the account with the code in the bio.
Own accent colour and AMOLED, compact bottom bar, Material 3 profile and plus button, save menu (quality, audio
only, photo posts without the watermark), fixed comment cards, "Luna (pre-release)" in the mod screen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cheb0chik
2026-10-23 19:03:48 +03:00
parent ce1d870d54
commit e63ca75eac
50 changed files with 5422 additions and 1661 deletions

View File

@@ -0,0 +1,24 @@
package lihont.two.avatar;
/** Pictures.id on a real JVM: the same picture in every size has one name, anything else has none. */
public final class PicturesTest {
private static void check(boolean condition, String what) {
if (!condition) throw new AssertionError(what);
}
public static void main(String[] args) {
String thumb = "https://p16-common-sign.tiktokcdn.com/tos-maliva-avt-0068/da78f2e6a0a06f3bc34ee93c50be8052"
+ "~tplv-tiktokx-cropcenter-q:100:100:q70.heic?dr=9640&x-expires=1&x-signature=abc";
String large = "https://p19-common-sign.tiktokcdn.com/tos-maliva-avt-0068/da78f2e6a0a06f3bc34ee93c50be8052"
+ "~tplv-tiktokx-cropcenter:1080:1080.jpeg?x-signature=zzz";
check("da78f2e6a0a06f3bc34ee93c50be8052".equals(Pictures.id(thumb)), "thumbnail");
check(Pictures.id(thumb).equals(Pictures.id(large)), "sizes and hosts share one name");
check("abc123".equals(Pictures.id("https://example.com/dir/abc123?token=1")), "no template, query only");
check("abc123".equals(Pictures.id("https://example.com/dir/abc123")), "plain path");
check(Pictures.id(null) == null, "null");
check(Pictures.id("res://com.zhiliaoapp.musically/2130971876") == null, "resources are not pictures");
check(Pictures.id("https://example.com/") == null, "no file name");
check(Pictures.id("https://example.com/~size") == null, "template without a name");
System.out.println("PASS");
}
}

View File

@@ -17,7 +17,7 @@ import javax.imageio.ImageIO;
public final class IconRenderTest {
private static final String[] NAMES = {
"BACK", "CLOSE", "CHECK", "CHEVRON_RIGHT", "SEARCH", "BANNER", "REPOST", "SUN", "MOON", "INFO", "NOTE",
"SEND", "TIKTOK", "ADD_PHOTO", "DELETE", "BOTTOM_BAR", "DOWNLOAD", "AD_OFF", "LIVE", "SHOP",
"SEND", "TIKTOK", "ADD_PHOTO", "DELETE", "BOTTOM_BAR", "DOWNLOAD", "AD_OFF", "LIVE", "SHOP", "CODE",
};
/** Where each icon appears in the app, for the preview sheet. */
@@ -25,7 +25,7 @@ public final class IconRenderTest {
"Назад (шапка)", "Закрыть (окна)", "Галочка выбора", "Стрелка в строках", "Поиск страны",
"Баннеры профиля", "Репост комментариев", "Тема: светлая", "Тема: тёмная", "Версия мода",
"Строка в настройках TikTok", "Telegram-канал", "База TikTok", "Свой значок", "Удалить значок", "Нижнее меню", "Скачивание",
"Убрать рекламу", "Убрать эфиры", "Убрать «Магазин»",
"Убрать рекламу", "Убрать эфиры", "Убрать «Магазин»", "Исходный код (О моде)",
};
private static Path2D.Float shape(int kind) {

View File

@@ -24,11 +24,69 @@ public final class SettingsModelTest {
case "atomicImport": atomicImport(); break;
case "ioFailures": ioFailures(); break;
case "corruptStorage": corruptStorage(); break;
case "look": look(); break;
case "tonal": tonal(); break;
default: throw new AssertionError("Unknown test scenario");
}
System.out.println("PASS " + args[0]);
}
private static void look() throws Exception {
ModSettings settings = ModSettings.defaults();
check(settings.accent() == ModSettings.Accent.SYSTEM, "The phone's colours are the default accent");
check(!settings.amoled() && !settings.compactBar(), "Black surfaces and the compact bar default to off");
ModSettings changed = settings.withAccent(ModSettings.Accent.ROSE).withAmoled(true).withCompactBar(true);
check(changed.accent() == ModSettings.Accent.ROSE && changed.amoled() && changed.compactBar(),
"The look choices are kept");
check(changed.equals(SettingsCodec.decode(SettingsCodec.encode(changed))), "The look survives an export");
check(SettingsCodec.encode(changed).contains("\"accent\": \"rose\""), "Export carries the accent");
String old = "{\"format\":\"lihont.settings\",\"schemaVersion\":1,\"values\":{\"region\":\"JP\","
+ "\"profileBanners\":true,\"commentReposts\":true,\"theme\":\"dark\"}}";
ModSettings read = SettingsCodec.decode(old);
check(read.accent() == ModSettings.Accent.SYSTEM && !read.amoled() && !read.compactBar(),
"A file written before the look choices keeps the old look");
String bad = old.replace("\"theme\"", "\"accent\":\"pink\",\"theme\"");
boolean rejected = false;
try { SettingsCodec.decode(bad); } catch (IllegalArgumentException expected) { rejected = true; }
check(rejected, "An unknown accent is rejected");
ModSettings own = settings.withAccent(ModSettings.Accent.CUSTOM).withAccentHue(123);
check(own.accent() == ModSettings.Accent.CUSTOM && own.accentHue() == 123, "The own colour is kept");
check(own.equals(SettingsCodec.decode(SettingsCodec.encode(own))), "The own colour survives an export");
check(SettingsCodec.encode(own).contains("\"accentHue\": 123"), "Export carries the hue");
check(read.accentHue() == ModSettings.DEFAULT_HUE, "A file without a hue gets the default one");
check(own.requiresRestartComparedTo(settings.withAccent(ModSettings.Accent.CUSTOM)), "Another hue needs a restart");
expect(IllegalArgumentException.class, () -> ModSettings.defaults().withAccentHue(360));
expect(IllegalArgumentException.class, () -> ModSettings.defaults().withAccentHue(-1));
check(changed.requiresRestartComparedTo(settings), "The look needs a restart");
check(!settings.withAccent(ModSettings.Accent.SYSTEM).requiresRestartComparedTo(settings), "Same look, no restart");
for (ModSettings.Accent accent : ModSettings.Accent.values()) {
check(ModSettings.Accent.fromValue(accent.value()) == accent, "Accent " + accent + " is found by its name");
}
}
/** Every fixed accent keeps the contrast of the roles Material 3 pairs, in both schemes. */
private static void tonal() {
for (ModSettings.Accent accent : ModSettings.Accent.values()) {
if (accent.hue() < 0) continue;
double h = accent.hue();
String name = accent.value();
check(TonalPalette.contrast(TonalPalette.argb(80, 50, h), TonalPalette.argb(20, 50, h)) >= 4.5,
name + ": primary on its content, dark");
check(TonalPalette.contrast(TonalPalette.argb(90, 50, h), TonalPalette.argb(30, 50, h)) >= 4.5,
name + ": container text, dark");
check(TonalPalette.contrast(TonalPalette.argb(40, 50, h), 0xFFFFFFFF) >= 4.5,
name + ": white on primary, light");
check(TonalPalette.contrast(TonalPalette.argb(10, 50, h), TonalPalette.argb(90, 50, h)) >= 4.5,
name + ": container text, light");
check(TonalPalette.contrast(TonalPalette.argb(90, 3, h), TonalPalette.argb(30, 3, h)) >= 4.5,
name + ": text on a card, dark");
check(TonalPalette.contrast(TonalPalette.argb(10, 3, h), TonalPalette.argb(90, 3, h)) >= 4.5,
name + ": text on a card, light");
}
check(TonalPalette.argb(0, 50, 100) == 0xFF000000, "Tone 0 is black");
check(TonalPalette.argb(100, 50, 100) == 0xFFFFFFFF, "Tone 100 is white");
}
private static void defaults() {
ModSettings settings = ModSettings.defaults();
equal("JP", settings.region(), "Japan is the initial selection");

View File

@@ -0,0 +1,80 @@
import unittest
from pathlib import Path
from lihont import avatar_patch
ROOT = Path(__file__).resolve().parents[1]
REAL = ROOT / "work" / "dl" / "avatar" / "X" / "1Bem.smali"
VIEW = """\
.class public LX/1Bem;
.super Lcom/bytedance/lighten/loader/SmartImageView;
.method public final onDetachedFromWindow()V
.registers 1
return-void
.end method
.method public onDraw(Landroid/graphics/Canvas;)V
.registers 3
:try_start_0
invoke-virtual {p0, p1}, LX/1Bem;->LJIIIIZZ(Landroid/graphics/Canvas;)V
goto :goto_9
:catch_4
move-exception v0
throw v0
:goto_9
return-void
.end method
"""
class AvatarRewriteTest(unittest.TestCase):
def test_the_hand_over_is_bracketed(self):
patched = avatar_patch.rewrite_avatar(VIEW)
begin = patched.index(avatar_patch.BEGIN)
end = patched.index(avatar_patch.END)
self.assertLess(begin, patched.index("invoke-virtual {p0, p1}, LX/1Bem;->LJIIIIZZ"))
self.assertLess(patched.index("invoke-virtual {p0, p1}, LX/1Bem;->LJIIIIZZ"), end)
self.assertLess(end, patched.index(" return-void\n.end method", end))
self.assertIn(" .registers 3\n\n" + avatar_patch.BEGIN_CALL, patched)
def test_only_the_draw_method_changes(self):
patched = avatar_patch.rewrite_avatar(VIEW)
self.assertEqual(1, patched.count(avatar_patch.BEGIN))
self.assertEqual(1, patched.count(avatar_patch.END))
self.assertEqual(VIEW.count(".method "), patched.count(".method "))
self.assertTrue(patched.startswith(VIEW.split(".method public onDraw")[0]))
# the calls use the parameter registers only, so no register is added to a method with three
for register in ("v0", "v1", "v2"):
self.assertNotIn(register, avatar_patch.BEGIN_CALL + avatar_patch.END_CALL)
def test_second_passes_and_foreign_classes_are_refused(self):
with self.assertRaises(ValueError):
avatar_patch.rewrite_avatar(avatar_patch.rewrite_avatar(VIEW))
with self.assertRaises(ValueError):
avatar_patch.rewrite_avatar(".class public Lx;\n.super Ljava/lang/Object;\n")
with self.assertRaises(ValueError):
avatar_patch.rewrite_avatar(VIEW.replace("invoke-virtual", "invoke-direct"))
with self.assertRaises(ValueError):
avatar_patch.rewrite_avatar(VIEW.replace(" goto :goto_9", " return-void"))
def test_runtime_targets_are_what_the_rewrite_calls(self):
patched = avatar_patch.rewrite_avatar(VIEW)
for target in avatar_patch.runtime_targets():
self.assertIn(target, patched)
@unittest.skipUnless(REAL.exists(), "the disassembled X.1Bem is not in work/")
def test_the_real_class_is_rewritten(self):
text = REAL.read_text(encoding="utf-8").replace(chr(13) + chr(10), chr(10))
patched = avatar_patch.rewrite_avatar(text)
self.assertEqual(text.count(".method "), patched.count(".method "))
self.assertEqual(1, patched.count(avatar_patch.BEGIN))
if __name__ == "__main__":
unittest.main()

View File

@@ -136,11 +136,97 @@ class ContextRewriteTest(unittest.TestCase):
self.assertEqual(text.count(".method "), patched.count(".method "), "no method added or lost")
SAVE = """\
.class public final LX/0qv6;
.super LX/0qvW;
.method public final LJ(Landroid/content/Context;LX/0qvX;)V
.registers 18
.prologue
move-object/from16 v2, p2
return-void
.end method
.method public final LJIL(LX/0bqR;)V
.registers 3
return-void
.end method
"""
PHOTO = """\
.class public final Lcom/ss/android/ugc/aweme/share/improve/handler/AwemePhotoDownloadShareAbilityHandler;
.super Lcom/ss/android/ugc/aweme/share/handler/TTShareDefaultHandler;
.method public final LJIILIIL(Landroid/content/Context;Lcom/ss/android/ugc/aweme/share/base/model/BaseSharePackage;LX/0qrm;Landroid/view/View;LX/01h3;)Ljava/lang/Object;
.registers 7
.annotation system Ldalvik/annotation/Signature;
.end annotation
invoke-super/range {p0 .. p5}, Lx/Y;->LJIILIIL()V
move-result-object v0
return-object v0
.end method
"""
class GateRewriteTest(unittest.TestCase):
def test_the_save_gate_comes_first_and_can_end_the_method(self):
patched = media_patch.rewrite_save(SAVE)
self.assertIn(" .registers 18\n\n" + media_patch.SAVE_GATE, patched)
self.assertLess(patched.index(media_patch.SAVE_GATE), patched.index("move-object/from16 v2, p2"))
gate = media_patch.SAVE_GATE
# the arguments are passed as a range, so registers above v15 (p1, p2 here) are fine
self.assertIn("invoke-static/range {p0 .. p2}, " + media_patch.GATE, gate)
self.assertEqual(1, gate.count("return-void"))
self.assertEqual(1, sum(1 for line in gate.splitlines() if line.strip() == ":lihont_go"))
self.assertEqual(1, gate.count("if-eqz v0, :lihont_go"))
for register in ("v1", "v2", "v3"):
self.assertNotIn(register, gate)
self.assertEqual(SAVE.count(".method "), patched.count(".method "))
def test_the_photo_gate_completes_a_taken_over_click_with_true(self):
patched = media_patch.rewrite_photo(PHOTO)
gate = media_patch.PHOTO_GATE
self.assertIn(" .registers 7\n\n" + gate, patched)
self.assertIn("sget-object v0, Ljava/lang/Boolean;->TRUE:Ljava/lang/Boolean;\n\n return-object v0", gate)
self.assertLess(patched.index(gate), patched.index("invoke-super/range"))
def test_second_passes_and_foreign_classes_are_refused(self):
with self.assertRaises(ValueError):
media_patch.rewrite_save(media_patch.rewrite_save(SAVE))
with self.assertRaises(ValueError):
media_patch.rewrite_photo(media_patch.rewrite_photo(PHOTO))
with self.assertRaises(ValueError):
media_patch.rewrite_save(".class public Lx;\n.super Ljava/lang/Object;\n")
with self.assertRaises(ValueError):
media_patch.rewrite_photo(PHOTO.replace("LJIILIIL(", "LJIILIIX("))
def test_the_gates_are_aimed_at_the_menu_class(self):
self.assertEqual({"LX/0qv6;", "LX/0qvL;", "LX/0qvu;"}, set(media_patch.SAVE_OWNERS))
self.assertIn("lihont/two/media/DownloadMenu;->gate(", media_patch.GATE)
self.assertIn("lihont/two/media/DownloadMenu;->gatePhoto(", media_patch.GATE_PHOTO)
REAL_SAVE = ROOT / "work" / "dl" / "smali" / "X" / "0qv6.smali"
@unittest.skipUnless(REAL_SAVE.exists(), "the disassembled X.0qv6 is not in work/")
def test_the_real_save_action_is_rewritten(self):
text = self.REAL_SAVE.read_text(encoding="utf-8").replace(chr(13) + chr(10), chr(10))
patched = media_patch.rewrite_save(text)
self.assertEqual(1, patched.count(media_patch.GATE))
self.assertEqual(text.count(".method "), patched.count(".method "))
class RuntimeTest(unittest.TestCase):
def test_runtime_targets_are_what_the_rewrites_call(self):
both = media_patch.rewrite_source(SOURCE) + media_patch.rewrite_context(CONTEXT)
everything = (media_patch.rewrite_source(SOURCE) + media_patch.rewrite_context(CONTEXT)
+ media_patch.rewrite_save(SAVE) + media_patch.rewrite_photo(PHOTO))
for target in media_patch.runtime_targets():
self.assertIn(target, both)
self.assertIn(target, everything)
if __name__ == "__main__":

View File

@@ -69,5 +69,97 @@ class ClaimTest(unittest.TestCase):
self.registry.claim(UID, "someone")
INSTALLATION = "a" * 64
OTHER_INSTALLATION = "b" * 64
class ShapeTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.now = 1_790_000_000
self.bio = ""
self.found = UID
self.fetches = 0
self.registry = server.Registry(Path(self.tmp.name) / "r.sqlite3", clock=lambda: self.now, fetcher=self.fetch)
def fetch(self, unique_id):
self.fetches += 1
return (self.found, self.bio)
def publish(self, shape, installation=INSTALLATION, unique_id="someone", uid=UID):
return self.registry.shape(uid, installation, shape, unique_id)
def test_nothing_is_published_without_the_code_in_the_bio(self):
self.assertEqual("code_missing", self.publish("flower")["reason"])
self.assertEqual({}, self.registry.lookup([UID])["shapes"])
self.assertEqual("proof_needed", self.registry.shape(UID, INSTALLATION, "flower")["reason"])
def test_the_code_in_the_bio_publishes_the_shape(self):
self.bio = "me " + self.registry.code(UID)
self.assertTrue(self.publish("flower")["published"])
self.assertEqual({UID: "flower"}, self.registry.lookup([UID])["shapes"])
def test_the_proven_installation_changes_it_without_another_proof(self):
self.bio = self.registry.code(UID)
self.publish("flower")
fetched = self.fetches
self.bio = ""
self.assertTrue(self.registry.shape(UID, INSTALLATION, "heart")["published"])
self.assertEqual(fetched, self.fetches)
self.assertEqual({UID: "heart"}, self.registry.lookup([UID])["shapes"])
def test_another_installation_must_prove_the_account_itself(self):
self.bio = self.registry.code(UID)
self.publish("flower")
self.bio = ""
self.assertFalse(self.publish("hexagon", OTHER_INSTALLATION)["published"])
self.assertEqual({UID: "flower"}, self.registry.lookup([UID])["shapes"])
self.bio = self.registry.code(UID)
self.assertTrue(self.publish("hexagon", OTHER_INSTALLATION)["published"])
self.assertEqual({UID: "hexagon"}, self.registry.lookup([UID])["shapes"])
# the first installation lost its right with that
self.bio = ""
self.assertFalse(self.publish("heart", INSTALLATION)["published"])
def test_circle_withdraws_the_shape(self):
self.bio = self.registry.code(UID)
self.publish("triangle")
self.assertTrue(self.registry.shape(UID, INSTALLATION, "circle")["published"])
self.assertEqual({}, self.registry.lookup([UID])["shapes"])
def test_circle_for_an_account_without_a_shape_needs_nothing(self):
self.assertTrue(self.publish("circle")["published"])
self.assertEqual(0, self.fetches)
def test_someone_elses_profile_and_blocked_accounts_are_refused(self):
self.found = "999999999"
self.bio = self.registry.code(UID)
with self.assertRaises(ValueError):
self.publish("flower")
self.found = UID
with self.registry.connect() as db:
db.execute("INSERT INTO blocked(uid) VALUES(?)", (UID,))
with self.assertRaises(ValueError):
self.publish("flower")
def test_a_blocked_account_loses_its_shape_in_lookups(self):
self.bio = self.registry.code(UID)
self.publish("flower")
with self.registry.connect() as db:
db.execute("INSERT INTO blocked(uid) VALUES(?)", (UID,))
self.assertEqual({}, self.registry.lookup([UID])["shapes"])
def test_bad_input(self):
for shape in ("blob", "", None, 5):
with self.assertRaises(ValueError):
self.publish(shape)
with self.assertRaises(ValueError):
self.registry.shape("abc", INSTALLATION, "flower", "someone")
with self.assertRaises(ValueError):
self.registry.shape(UID, "short", "flower", "someone")
self.assertEqual("proof_needed", self.registry.shape(UID, INSTALLATION, "flower", "bad name!")["reason"])
if __name__ == "__main__":
unittest.main()

29
tests/test_pictures.py Normal file
View File

@@ -0,0 +1,29 @@
"""Pictures.id (the name of a profile picture inside an avatar address) on a real JVM."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "src" / "lihont" / "two" / "avatar" / "Pictures.java"
HARNESS = ROOT / "tests" / "java" / "lihont" / "two" / "avatar" / "PicturesTest.java"
class PicturesTest(unittest.TestCase):
def test_picture_names(self):
javac, java = shutil.which("javac"), shutil.which("java")
if not javac or not java:
raise RuntimeError("This test requires a JDK with java and javac")
with tempfile.TemporaryDirectory(prefix="lihont-pictures-") as out:
subprocess.run([javac, "--release", "17", "-encoding", "UTF-8", "-d", out, str(SOURCE), str(HARNESS)],
check=True, capture_output=True)
result = subprocess.run([java, "-cp", out, "lihont.two.avatar.PicturesTest"],
capture_output=True, text=True, encoding="utf-8", timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(result.stdout.strip(), "PASS")
if __name__ == "__main__":
unittest.main()

View File

@@ -35,6 +35,7 @@ class SettingsModelTest(unittest.TestCase):
str(PACKAGE / "ModSettings.java"),
str(PACKAGE / "SettingsCodec.java"),
str(PACKAGE / "SettingsRepository.java"),
str(PACKAGE / "TonalPalette.java"),
str(HARNESS),
])
@@ -94,6 +95,12 @@ class SettingsModelTest(unittest.TestCase):
def test_corrupt_or_future_stored_data_is_not_overwritten(self):
self.run_scenario("corruptStorage")
def test_look_choices_roundtrip_and_old_files_keep_the_old_look(self):
self.run_scenario("look")
def test_fixed_accents_keep_their_contrast(self):
self.run_scenario("tonal")
def test_android_adapter_compiles_against_android_api(self):
android_jar = ROOT / "tools" / "android.jar"
self.assertTrue(android_jar.is_file(), "tools/android.jar must be available")