LihonT 2 Luna beta: source release

Mod for TikTok 47.1.3 (arm64) in Material 3 style: bottom bar, profile menu, TikTok settings and
comments; downloads without the watermark; switches for ads, live streams and the Shop tab; badges of
other mods next to names and the niche mod badge (members service); app icons; mod settings screen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cheb0chik
2026-10-22 20:32:13 +03:00
commit ce1d870d54
121 changed files with 21570 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""LihonT 2 Python test package."""

View File

@@ -0,0 +1,116 @@
package lihont.two;
import java.io.IOException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Set;
import java.util.TreeSet;
import lihont.two.settings.ModSettings;
import lihont.two.settings.SettingsCodec;
import lihont.two.settings.SettingsHost;
import lihont.two.settings.SettingsRepository;
/**
* Runs LihonTFlags on a real JVM. Arguments: a scenario, then "group:key:forced" entries
* (group is banners or reposts), taken from lihont/profile_features.py by the caller.
*/
public final class FlagsTest {
private static final class Memory implements SettingsRepository.Storage {
String document;
@Override public String read() { return document; }
@Override public void write(String value) { document = value; }
}
public static void main(String[] args) throws Exception {
String scenario = args[0];
switch (scenario) {
case "matrix": matrix(args); break;
case "snapshot": snapshot(args); break;
case "unavailable": unavailable(args); break;
case "surface": surface(); return;
default: throw new AssertionError("Unknown scenario " + scenario);
}
System.out.println("PASS " + scenario);
}
private static SettingsRepository install(Memory storage, boolean banners, boolean reposts) throws IOException {
storage.document = SettingsCodec.encode(
ModSettings.defaults().withProfileBanners(banners).withCommentReposts(reposts));
SettingsRepository repository = new SettingsRepository(storage);
SettingsHost.install(repository);
return repository;
}
private static void matrix(String[] args) throws Exception {
for (boolean banners : new boolean[] {true, false}) {
for (boolean reposts : new boolean[] {true, false}) {
install(new Memory(), banners, reposts);
for (int i = 1; i < args.length; i++) {
String[] entry = args[i].split(":");
boolean on = entry[0].equals("banners") ? banners : reposts;
check(on, entry[1], Integer.parseInt(entry[2]));
}
}
}
}
private static void check(boolean on, String key, int forced) throws Exception {
Method integer = LihonTFlags.class.getMethod(key, int.class);
Method bool = LihonTFlags.class.getMethod(key, boolean.class);
for (int original : new int[] {0, 2, 5, -1}) {
int expected = on ? forced : original;
equal(expected, (Integer) integer.invoke(null, original), key + " int original " + original + " on=" + on);
}
for (boolean original : new boolean[] {false, true}) {
boolean expected = on || original;
equal(expected, (Boolean) bool.invoke(null, original), key + " bool original " + original + " on=" + on);
}
}
/** A saved change waits for the next process; a new process then sees it. */
private static void snapshot(String[] args) throws Exception {
Memory storage = new Memory();
SettingsRepository first = install(storage, true, true);
first.save(first.current().withProfileBanners(false));
check(true, "profile_left_align", 1);
if (!first.restartRequired()) throw new AssertionError("Saved change must ask for a restart");
SettingsRepository second = new SettingsRepository(storage);
SettingsHost.install(second);
check(false, "profile_left_align", 1);
check(true, "repost_support_digg", 1);
if (second.restartRequired()) throw new AssertionError("A fresh process has nothing pending");
}
/** Before the application exists nothing can be read: behave as with the defaults. */
private static void unavailable(String[] args) throws Exception {
SettingsHost.install(null);
for (int i = 1; i < args.length; i++) {
String[] entry = args[i].split(":");
check(true, entry[1], Integer.parseInt(entry[2]));
}
}
private static void surface() {
Set<String> found = new TreeSet<String>();
for (Method method : LihonTFlags.class.getDeclaredMethods()) {
if (!Modifier.isPublic(method.getModifiers())) continue;
found.add(method.getName() + "(" + code(method.getParameterTypes()[0]) + ")"
+ code(method.getReturnType()));
}
for (String signature : found) System.out.println(signature);
}
private static String code(Class<?> type) {
if (type == int.class) return "I";
if (type == boolean.class) return "Z";
throw new AssertionError("Unexpected type " + type);
}
private static void equal(Object expected, Object actual, String message) {
if (!expected.equals(actual)) {
throw new AssertionError(message + ": expected " + expected + " but was " + actual);
}
}
}

View File

@@ -0,0 +1,183 @@
package lihont.two.settings;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Path2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.File;
import javax.imageio.ImageIO;
/** Parses every icon path on a desktop JVM and checks, or draws, the resulting shapes. */
public final class IconRenderTest {
private static final String[] NAMES = {
"BACK", "CLOSE", "CHECK", "CHEVRON_RIGHT", "SEARCH", "BANNER", "REPOST", "SUN", "MOON", "INFO", "NOTE",
"SEND", "TIKTOK", "ADD_PHOTO", "DELETE", "BOTTOM_BAR", "DOWNLOAD", "AD_OFF", "LIVE", "SHOP",
};
/** Where each icon appears in the app, for the preview sheet. */
private static final String[] USES = {
"Назад (шапка)", "Закрыть (окна)", "Галочка выбора", "Стрелка в строках", "Поиск страны",
"Баннеры профиля", "Репост комментариев", "Тема: светлая", "Тема: тёмная", "Версия мода",
"Строка в настройках TikTok", "Telegram-канал", "База TikTok", "Свой значок", "Удалить значок", "Нижнее меню", "Скачивание",
"Убрать рекламу", "Убрать эфиры", "Убрать «Магазин»",
};
private static Path2D.Float shape(int kind) {
final Path2D.Float path = new Path2D.Float(Path2D.WIND_NON_ZERO);
Md3Icon.build(kind, new SvgPath.Sink() {
@Override public void moveTo(float x, float y) { path.moveTo(x, y); }
@Override public void lineTo(float x, float y) { path.lineTo(x, y); }
@Override public void cubicTo(float x1, float y1, float x2, float y2, float x, float y) {
path.curveTo(x1, y1, x2, y2, x, y);
}
@Override public void close() { path.closePath(); }
});
return path;
}
private static Path2D.Float shape(String data) {
final Path2D.Float path = new Path2D.Float(Path2D.WIND_NON_ZERO);
SvgPath.parse(data, new SvgPath.Sink() {
@Override public void moveTo(float x, float y) { path.moveTo(x, y); }
@Override public void lineTo(float x, float y) { path.lineTo(x, y); }
@Override public void cubicTo(float x1, float y1, float x2, float y2, float x, float y) {
path.curveTo(x1, y1, x2, y2, x, y);
}
@Override public void close() { path.closePath(); }
});
return path;
}
public static void main(String[] args) throws Exception {
switch (args[0]) {
case "bounds": bounds(); break;
case "tokens": tokens(); break;
case "render": render(new File(args[1])); return;
default: throw new AssertionError("Unknown scenario " + args[0]);
}
System.out.println("PASS " + args[0]);
}
private static void bounds() {
if (Md3Icon.PATHS.length != NAMES.length) throw new AssertionError("Icon table and names differ");
if (Md3Icon.GRID.length != NAMES.length || Md3Icon.ORIGIN_Y.length != NAMES.length) {
throw new AssertionError("Grid tables and names differ");
}
for (int i = 0; i < NAMES.length; i++) {
Rectangle2D box = shape(i).getBounds2D();
if (box.getMinX() < -0.01 || box.getMinY() < -0.01 || box.getMaxX() > 24.01 || box.getMaxY() > 24.01) {
throw new AssertionError(NAMES[i] + " leaves the 24 unit grid: " + box);
}
if (Math.max(box.getWidth(), box.getHeight()) < 10) {
throw new AssertionError(NAMES[i] + " is too small to read as an icon: " + box);
}
double ink = coverage(shape(i));
if (ink < 0.03 || ink > 0.8) throw new AssertionError(NAMES[i] + " ink coverage " + ink);
}
}
/** Share of the 24x24 grid the icon paints, found by rasterising it. */
private static double coverage(Path2D.Float path) {
int size = 96;
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = image.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(Color.BLACK);
g.fill(new Path2D.Float(path, AffineTransform.getScaleInstance(size / 24.0, size / 24.0)));
g.dispose();
int painted = 0;
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
if ((image.getRGB(x, y) >>> 24) > 127) painted++;
}
}
return painted / (double) (size * size);
}
private static void tokens() {
// Shortened forms: no separator before a minus or a second dot, implicit repeats, relative moves.
// After the z the pen is back at (0.5, 0.5), so "m4 0" starts the second shape at x = 4.5.
Rectangle2D box = shape("M.5.5h1-1v1zm4 0l1 1-1 1z").getBounds2D();
near(0.5, box.getMinX());
near(5.5, box.getMaxX());
near(2.5, box.getMaxY());
// Arc flags written without separators, as Material icons do: a2 2 0 00-2 2.
Rectangle2D arc = shape("M4 4v2a2 2 0 00 2 2h2").getBounds2D();
near(4.0, arc.getMinX());
near(8.0, arc.getMaxX());
near(8.0, arc.getMaxY());
// A half circle of radius 5 drawn with an arc spans 10 units.
Rectangle2D half = shape("M0 5a5 5 0 0010 0").getBounds2D();
near(10.0, half.getWidth());
near(5.0, half.getHeight());
// Smooth quadratics, which Material Symbols use for rounded ends: T repeats the last control point.
Rectangle2D smooth = shape("M0 0q4 0 4 4T8 8").getBounds2D();
near(8.0, smooth.getMaxX());
boolean rejected = false;
try { shape("M1 1 5"); } catch (IllegalArgumentException expected) { rejected = true; }
if (!rejected) throw new AssertionError("A lone coordinate must be rejected");
}
private static void near(double expected, double actual) {
if (Math.abs(expected - actual) > 0.15) {
throw new AssertionError("expected " + expected + " but was " + actual);
}
}
/** A flower badge like the one in the app: a disc and eight petals. */
private static void flower(Graphics2D g, double cx, double cy, double r) {
g.fill(new Ellipse2D.Double(cx - r * 0.70, cy - r * 0.70, r * 1.40, r * 1.40));
double petal = r * 0.38, dist = r * 0.62;
for (int i = 0; i < 8; i++) {
double angle = i * (2 * Math.PI / 8) + Math.PI / 8;
g.fill(new Ellipse2D.Double(cx + Math.cos(angle) * dist - petal, cy + Math.sin(angle) * dist - petal,
petal * 2, petal * 2));
}
}
private static void render(File out) throws Exception {
int columns = 4, tileW = 240, tileH = 190, rows = (NAMES.length + columns - 1) / columns;
BufferedImage image = new BufferedImage(tileW * columns, tileH * rows, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setColor(new Color(0x1a1b21));
g.fillRect(0, 0, image.getWidth(), image.getHeight());
Font number = new Font("Dialog", Font.BOLD, 26), label = new Font("Dialog", Font.PLAIN, 16);
for (int i = 0; i < NAMES.length; i++) {
int ox = (i % columns) * tileW, oy = (i / columns) * tileH;
g.setColor(new Color(0x2b2930));
g.fillRoundRect(ox + 8, oy + 8, tileW - 16, tileH - 16, 28, 28);
g.setColor(new Color(0x4a4458));
flower(g, ox + tileW / 2.0, oy + 76, 64);
g.setColor(new Color(0xe8def8));
double scale = 3.6;
AffineTransform move = new AffineTransform();
move.translate(ox + tileW / 2.0 - 12 * scale, oy + 76 - 12 * scale);
move.scale(scale, scale);
g.fill(new Path2D.Float(shape(i), move));
g.setFont(number);
g.setColor(Color.WHITE);
g.drawString(String.valueOf(i + 1), ox + 22, oy + 40);
g.setFont(label);
g.setColor(new Color(0xc9c5d0));
FontMetrics metrics = g.getFontMetrics();
g.drawString(USES[i], ox + (tileW - metrics.stringWidth(USES[i])) / 2, oy + tileH - 22);
}
g.dispose();
ImageIO.write(image, "png", out);
System.out.println("wrote " + out);
}
}

View File

@@ -0,0 +1,129 @@
package lihont.two.settings;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
/** The custom-icon store and crop geometry on a desktop JVM: java.io only, no Android classes. */
public final class IconStoreTest {
private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3};
public static void main(String[] args) throws Exception {
String name = args[0];
File dir = Files.createTempDirectory("lihont-icon-store").toFile();
try {
switch (name) {
case "crop": crop(); break;
case "names": names(); break;
case "store": store(new File(dir, "icons")); break;
case "damaged": damaged(new File(dir, "icons")); break;
default: throw new AssertionError("Unknown scenario " + name);
}
} finally {
delete(dir);
}
System.out.println("PASS " + name);
}
private static void crop() {
check(IconCrop.VISIBLE_PIXELS == 288, "the launcher shows 72 of 108 dp");
int[] wide = IconCrop.centerSquare(4000, 3000);
check(wide[0] == 500 && wide[1] == 0 && wide[2] == 3000, "landscape is cut at the sides");
int[] tall = IconCrop.centerSquare(3000, 4001);
check(tall[0] == 0 && tall[1] == 500 && tall[2] == 3000, "portrait is cut top and bottom");
int[] square = IconCrop.centerSquare(512, 512);
check(square[0] == 0 && square[1] == 0 && square[2] == 512, "a square stays whole");
check(IconCrop.sampleSize(4000, 3000, 432) == 4, "3000 / 4 = 750 still covers 432, 3000 / 8 = 375 does not");
check(IconCrop.sampleSize(500, 500, 432) == 1, "small pictures are not shrunk");
check(IconCrop.sampleSize(864, 5000, 432) == 2, "the shorter side decides");
try {
IconCrop.centerSquare(0, 10);
throw new AssertionError("an empty picture must be refused");
} catch (IllegalArgumentException expected) {
// refused
}
}
private static void names() {
check("TikTok".equals(CustomIconStore.cleanName(null)), "null falls back to the default");
check("TikTok".equals(CustomIconStore.cleanName(" \t\n ")), "blank falls back to the default");
check("Мой значок".equals(CustomIconStore.cleanName(" Мой \n значок ")), "whitespace collapses to single spaces");
check("a b".equals(CustomIconStore.cleanName("a" + (char) 1 + "b")), "control characters become gaps");
String long50 = "x".repeat(50);
check(CustomIconStore.cleanName(long50).length() == CustomIconStore.MAX_NAME, "names are capped");
check(CustomIconStore.validId("0a1b2c3d"), "a generated id is valid");
check(!CustomIconStore.validId("../etc/p") && !CustomIconStore.validId("0A1B2C3D") && !CustomIconStore.validId("1234567")
&& !CustomIconStore.validId(null), "anything else is not an id");
}
private static void store(File dir) throws IOException {
CustomIconStore store = new CustomIconStore(dir);
check(store.list().isEmpty(), "a missing folder is an empty list");
CustomIconStore.Entry first = store.add(PNG, "Первый");
CustomIconStore.Entry second = store.add(PNG, " Second\ticon ");
List<CustomIconStore.Entry> entries = store.list();
check(entries.size() == 2, "two icons stored");
check("Первый".equals(entries.get(0).name) && "Second icon".equals(entries.get(1).name), "names survive a round trip, in order");
check(entries.get(0).id.equals(first.id) && first.image.isFile(), "the picture is on disk");
check(!first.id.equals(second.id), "ids are distinct");
check(!store.remove("zzzzzzzz") && !store.remove("../../x"), "unknown ids are refused");
check(store.remove(first.id), "removing a stored icon works");
check(!first.image.exists(), "its picture is deleted too");
check(store.list().size() == 1 && store.list().get(0).id.equals(second.id), "the other one stays");
try {
store.add(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9}, "not a png");
throw new AssertionError("a non-PNG must be refused");
} catch (IOException expected) {
// refused
}
try {
store.add(new byte[CustomIconStore.MAX_PNG_BYTES + 1], "huge");
throw new AssertionError("an oversized picture must be refused");
} catch (IOException expected) {
// refused
}
for (int i = 1; i < CustomIconStore.MAX_ICONS; i++) store.add(PNG, "n" + i);
check(store.list().size() == CustomIconStore.MAX_ICONS, "the folder fills up to the cap");
try {
store.add(PNG, "one too many");
throw new AssertionError("the cap must hold");
} catch (IOException expected) {
// refused
}
check(store.list().size() == CustomIconStore.MAX_ICONS, "a refused add changes nothing");
}
private static void damaged(File dir) throws IOException {
CustomIconStore store = new CustomIconStore(dir);
CustomIconStore.Entry kept = store.add(PNG, "kept");
File lost = store.add(PNG, "lost").image;
check(lost.delete(), "simulate a picture that vanished");
String index = "no tab here\n"
+ "../../evil\tbad id\n"
+ "0a1b2c3d\tpicture missing\n"
+ kept.id + "\tkept\n"
+ "\n";
Files.write(new File(dir, "index.txt").toPath(), index.getBytes(StandardCharsets.UTF_8));
List<CustomIconStore.Entry> entries = store.list();
check(entries.size() == 1 && entries.get(0).id.equals(kept.id), "only the sound entry survives: " + entries.size());
check(store.add(PNG, "after damage").image.isFile(), "the store keeps working after damage");
}
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
private static void delete(File file) {
File[] children = file.listFiles();
if (children != null) {
for (File child : children) delete(child);
}
file.delete();
}
}

View File

@@ -0,0 +1,369 @@
package lihont.two.settings;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
/** Behavioral tests without Android framework mocks or third-party test dependencies. */
public final class SettingsModelTest {
private interface Action { void run() throws Exception; }
public static void main(String[] args) throws Exception {
if (args.length != 1) throw new AssertionError("Expected a test scenario");
switch (args[0]) {
case "defaults": defaults(); break;
case "countries": countries(); break;
case "validation": validation(); break;
case "restart": restart(); break;
case "codec": codec(); break;
case "invalid": invalid(); break;
case "persistence": persistence(); break;
case "snapshot": snapshot(); break;
case "reset": reset(); break;
case "atomicImport": atomicImport(); break;
case "ioFailures": ioFailures(); break;
case "corruptStorage": corruptStorage(); break;
default: throw new AssertionError("Unknown test scenario");
}
System.out.println("PASS " + args[0]);
}
private static void defaults() {
ModSettings settings = ModSettings.defaults();
equal("JP", settings.region(), "Japan is the initial selection");
equal("jp", settings.regionIso(), "Runtime country code uses lowercase");
check(settings.profileBanners(), "Banners default enabled");
check(settings.commentReposts(), "Comment reposts default enabled");
check(settings.md3(), "MD3 interface default enabled");
check(settings.noWatermark(), "Watermark-free downloads default enabled");
check(settings.hideAds(), "Ad removal default enabled");
check(settings.hideLive(), "Live removal default enabled");
check(!settings.hideShop(), "Shop tab removal default disabled");
equal(ModSettings.Theme.SYSTEM, settings.theme(), "Follow system theme initially");
check(!settings.needsBannerWarning(), "Japan requires no banner warning");
check(settings.withRegion("AL").needsBannerWarning(), "Albania warns about banners");
check(!settings.withRegion("AL").withRegion("JP").needsBannerWarning(),
"Returning to Japan clears warning");
}
private static void countries() throws Exception {
equal(249, ModSettings.COUNTRY_CODES.size(), "Full ISO 3166-1 list");
equal(new HashSet<String>(Arrays.asList(Locale.getISOCountries())),
ModSettings.COUNTRY_CODES, "Country set matches JDK ISO registry");
Locale previous = Locale.getDefault();
try {
Locale.setDefault(new Locale("tr", "TR"));
for (String country : ModSettings.COUNTRY_CODES) {
ModSettings value = ModSettings.defaults().withRegion(country);
equal(country.toLowerCase(Locale.ROOT), value.regionIso(),
"Country lowercasing is independent of device language");
equal(value, SettingsCodec.decode(SettingsCodec.encode(value)),
"Every supported country survives export/import");
equal(!"JP".equals(country), value.needsBannerWarning(),
"Only Japan suppresses region warning");
}
} finally {
Locale.setDefault(previous);
}
expect(UnsupportedOperationException.class, () -> ModSettings.COUNTRY_CODES.add("ZZ"));
}
private static void validation() throws Exception {
for (String country : new String[] {null, "", "jp", " JP", "JP ", "JPN", "ZZ", "XK"}) {
expect(IllegalArgumentException.class, () -> ModSettings.defaults().withRegion(country));
}
expect(IllegalArgumentException.class, () -> ModSettings.defaults().withTheme(null));
expect(IllegalArgumentException.class, () -> ModSettings.Theme.fromValue("DARK"));
expect(IllegalArgumentException.class, () -> ModSettings.Theme.fromValue(null));
expect(IllegalArgumentException.class, () -> SettingsCodec.encode(null));
expect(IllegalArgumentException.class, () -> new SettingsRepository(null));
}
private static void restart() throws Exception {
ModSettings active = ModSettings.defaults();
check(!active.requiresRestartComparedTo(active), "Unchanged settings need no restart");
check(!active.withTheme(ModSettings.Theme.DARK).requiresRestartComparedTo(active),
"Theme is available without process restart");
check(active.withRegion("AL").requiresRestartComparedTo(active), "Region requires restart");
check(active.withProfileBanners(false).requiresRestartComparedTo(active),
"Banner feature state requires restart");
check(active.withCommentReposts(false).requiresRestartComparedTo(active),
"Comment feature state requires restart");
check(active.withMd3(false).requiresRestartComparedTo(active),
"MD3 interface state requires restart");
check(active.withNoWatermark(false).requiresRestartComparedTo(active),
"Download source requires restart");
check(active.withHideAds(false).requiresRestartComparedTo(active), "Ad removal requires restart");
check(active.withHideLive(false).requiresRestartComparedTo(active), "Live removal requires restart");
check(active.withHideShop(true).requiresRestartComparedTo(active), "Shop tab removal requires restart");
expect(IllegalArgumentException.class, () -> active.requiresRestartComparedTo(null));
}
private static void codec() {
String compact = document("US", false, true, "dark");
ModSettings expected = new ModSettings("US", false, true, ModSettings.Theme.DARK);
equal(expected, SettingsCodec.decode(compact), "Portable compact JSON is accepted");
String reordered = "{\"values\":{\"theme\":\"light\",\"commentReposts\":false,"
+ "\"region\":\"\\u004A\\u0050\",\"profileBanners\":true},"
+ "\"schemaVersion\":1,\"format\":\"lihont.settings\"}";
equal(new ModSettings("JP", true, false, ModSettings.Theme.LIGHT),
SettingsCodec.decode(reordered), "Object order and JSON unicode escapes are supported");
for (ModSettings.Theme theme : ModSettings.Theme.values()) {
for (boolean banners : new boolean[] {true, false}) {
for (boolean reposts : new boolean[] {true, false}) {
ModSettings value = new ModSettings("AL", banners, reposts, theme);
equal(value, SettingsCodec.decode(SettingsCodec.encode(value)),
"Typed combination survives roundtrip");
}
}
}
// files written before the MD3 switch existed have four values and mean "on"
check(SettingsCodec.decode(compact).md3(), "A file without md3 means on");
ModSettings off = expected.withMd3(false);
equal(off, SettingsCodec.decode(SettingsCodec.encode(off)), "MD3 off survives roundtrip");
check(!SettingsCodec.decode(SettingsCodec.encode(off)).md3(), "and stays off");
check(SettingsCodec.encode(off).contains("\"md3\": false"), "Export carries the MD3 value");
check(!SettingsCodec.encode(off).contains("bottomBar"), "Export no longer uses the old key");
// the switch was called bottomBar when it only restyled the bottom bar: such files still decode
String legacy = compact.replace("\"theme\":", "\"bottomBar\":false,\"theme\":");
check(!SettingsCodec.decode(legacy).md3(), "The old bottomBar key still sets the MD3 switch");
boolean both = false;
try {
SettingsCodec.decode(compact.replace("\"theme\":", "\"md3\":true,\"bottomBar\":true,\"theme\":"));
} catch (IllegalArgumentException rejected) {
both = true;
}
check(both, "A file with both md3 and bottomBar is rejected");
check(SettingsCodec.decode(compact).noWatermark(), "A file without noWatermark means on");
ModSettings feedOff = expected.withHideAds(false).withHideLive(false).withHideShop(false);
check(SettingsCodec.decode(compact).hideAds() && SettingsCodec.decode(compact).hideLive()
&& !SettingsCodec.decode(compact).hideShop(),
"A file without feed switches means ads and live off the feed, Shop tab kept");
equal(feedOff, SettingsCodec.decode(SettingsCodec.encode(feedOff)), "Feed switches off survive roundtrip");
ModSettings shopOn = expected.withHideShop(true);
equal(shopOn, SettingsCodec.decode(SettingsCodec.encode(shopOn)), "Shop tab removal on survives roundtrip");
check(!SettingsCodec.decode(SettingsCodec.encode(feedOff)).hideLive(), "and stay off");
check(SettingsCodec.encode(feedOff).contains("\"hideAds\": false")
&& SettingsCodec.encode(feedOff).contains("\"hideLive\": false")
&& SettingsCodec.encode(feedOff).contains("\"hideShop\": false"), "Export carries the feed switches");
ModSettings plain = expected.withNoWatermark(false);
equal(plain, SettingsCodec.decode(SettingsCodec.encode(plain)), "Watermark switch off survives roundtrip");
check(!SettingsCodec.decode(SettingsCodec.encode(plain)).noWatermark(), "and stays off");
check(plain.withMd3(false).equals(SettingsCodec.decode(SettingsCodec.encode(plain.withMd3(false)))),
"Both optional switches together survive roundtrip");
String exported = SettingsCodec.encode(expected);
check(exported.contains("\"schemaVersion\": 1"), "Export carries schema version");
check(!exported.contains("uuid") && !exported.contains("token"),
"Export contains preferences, without account identifiers");
}
private static String[] invalidDocuments() {
String good = document("JP", true, true, "system");
return new String[] {
null, "", " ", "{}", "[]", "null", "true", "1",
good + "{}", good + " trailing", good.substring(0, good.length() - 1),
good.replace("lihont.settings", "another.application"),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":0"),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":2"),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":\"1\""),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":true"),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":1.0"),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":1e0"),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":01"),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":2147483648"),
good.replace("\"schemaVersion\":1", "\"schemaVersion\":-1"),
good.replace("\"schemaVersion\":1,", ""),
good.replace("\"values\":{", "\"unknown\":true,\"values\":{"),
good.replace("\"values\":{", "\"values\":null,\"unused\":{"),
good.replace("\"region\":\"JP\"", "\"region\":\"ZZ\""),
good.replace("\"region\":\"JP\"", "\"region\":\"jp\""),
good.replace("\"region\":\"JP\"", "\"region\":null"),
good.replace("\"region\":\"JP\"", "\"region\":[\"JP\"]"),
good.replace("\"region\":\"JP\",", ""),
good.replace("\"region\":\"JP\",", "\"region\":\"JP\",\"unknown\":true,"),
good.replace("\"region\":\"JP\",", "\"region\":\"JP\",\"region\":\"US\","),
good.replace("\"region\":\"JP\",", "\"region\":\"JP\",\"\\u0072egion\":\"US\","),
good.replace("\"profileBanners\":true", "\"profileBanners\":\"true\""),
good.replace("\"commentReposts\":true", "\"commentReposts\":1"),
good.replace("\"theme\":\"system\"", "\"bottomBar\":1,\"theme\":\"system\""),
good.replace("\"theme\":\"system\"", "\"md3\":1,\"theme\":\"system\""),
good.replace("\"theme\":\"system\"", "\"hideAds\":1,\"theme\":\"system\""),
good.replace("\"theme\":\"system\"", "\"hideLive\":\"no\",\"theme\":\"system\""),
good.replace("\"theme\":\"system\"", "\"hideShop\":null,\"theme\":\"system\""),
good.replace("\"theme\":\"system\"", "\"md3\":true,\"md3\":false,\"theme\":\"system\""),
good.replace("\"theme\":\"system\"", "\"noWatermark\":\"yes\",\"theme\":\"system\""),
good.replace("\"theme\":\"system\"", "\"bottomBar\":true,\"bottomBar\":false,\"theme\":\"system\""),
good.replace("\"theme\":\"system\"", "\"theme\":\"automatic\""),
good.replace("\"theme\":\"system\"", "\"theme\":{}"),
good.replace("JP", "J\\qP"), good.replace("JP", "J\\uGGGGP"),
good.replace("JP", "J\nP"), good.replace("JP", "J\\u00"),
good.replace("\"theme\":\"system\"}", "\"theme\":\"system\",}"),
good.substring(0, good.length() - 1) + ",}",
"{\"format\":\"lihont.settings\",\"format\":\"lihont.settings\","
+ good.substring(1 + "\"format\":\"lihont.settings\",".length()),
"{\"a\":{\"b\":{\"c\":{\"d\":true}}}}",
good + " ".repeat(SettingsCodec.MAX_DOCUMENT_LENGTH),
};
}
private static void invalid() throws Exception {
for (String document : invalidDocuments()) {
expect(IllegalArgumentException.class, () -> SettingsCodec.decode(document));
}
String good = document("JP", true, true, "system");
String boundary = good + " ".repeat(SettingsCodec.MAX_DOCUMENT_LENGTH - good.length());
equal(ModSettings.defaults(), SettingsCodec.decode(boundary),
"Maximum-size valid input is accepted");
}
private static void persistence() throws Exception {
MemoryStorage storage = new MemoryStorage(null);
SettingsRepository first = new SettingsRepository(storage);
equal(1, storage.writes, "First launch creates persistent defaults");
equal(ModSettings.defaults(), first.current(), "First launch starts with defaults");
ModSettings selected = new ModSettings("AL", false, true, ModSettings.Theme.DARK);
first.save(selected);
equal(selected, SettingsCodec.decode(storage.document), "Save reaches persistent storage");
SettingsRepository restarted = new SettingsRepository(storage);
equal(selected, restarted.current(), "Next process loads saved values");
equal(selected, restarted.active(), "Saved values become active in the next process");
check(!restarted.restartRequired(), "Fresh process needs no additional restart");
equal(2, storage.writes, "Loading valid stored settings performs no write");
}
private static void snapshot() throws Exception {
MemoryStorage storage = new MemoryStorage(null);
SettingsRepository repository = new SettingsRepository(storage);
ModSettings initial = repository.active();
repository.save(ModSettings.defaults());
equal(1, storage.writes, "No-op save does not touch storage");
repository.save(initial.withTheme(ModSettings.Theme.LIGHT));
check(!repository.restartRequired(), "Theme alone needs no restart");
ModSettings other = initial.withRegion("US");
repository.save(other);
equal(initial, repository.active(), "Changing selection leaves process-start snapshot intact");
equal(other, repository.current(), "UI reads the newly saved selection");
equal(other, SettingsCodec.decode(repository.exportJson()),
"Export reflects the selected settings, not the startup snapshot");
check(repository.restartRequired(), "Changed region requests restart");
repository.save(initial);
check(!repository.restartRequired(), "Reverting selection removes restart requirement");
expect(IllegalArgumentException.class, () -> repository.save(null));
}
private static void reset() throws Exception {
ModSettings old = new ModSettings("US", false, false, ModSettings.Theme.DARK);
MemoryStorage storage = new MemoryStorage(SettingsCodec.encode(old));
SettingsRepository repository = new SettingsRepository(storage);
repository.reset();
equal(ModSettings.defaults(), repository.current(), "Reset restores every default");
equal(ModSettings.defaults(), SettingsCodec.decode(storage.document), "Reset is persistent");
equal(old, repository.active(), "Reset keeps active process snapshot unchanged");
check(repository.restartRequired(), "Reset requests restart when runtime fields changed");
repository.reset();
equal(1, storage.writes, "Repeated reset avoids redundant writes");
check(!new SettingsRepository(storage).restartRequired(), "Restart completes the reset");
}
private static void atomicImport() throws Exception {
ModSettings initial = new ModSettings("AL", false, true, ModSettings.Theme.LIGHT);
MemoryStorage storage = new MemoryStorage(SettingsCodec.encode(initial));
SettingsRepository repository = new SettingsRepository(storage);
String before = storage.document;
for (String document : invalidDocuments()) {
expect(IllegalArgumentException.class, () -> repository.importJson(document));
equal(initial, repository.current(), "Rejected import preserves memory");
equal(before, storage.document, "Rejected import preserves stored document");
equal(0, storage.writes, "Validation finishes before any persistence attempt");
}
repository.importJson(document("US", true, false, "dark"));
equal(new ModSettings("US", true, false, ModSettings.Theme.DARK), repository.current(),
"Valid import applies every value");
equal(1, storage.writes, "Valid import is a single full-document transaction");
}
private static void ioFailures() throws Exception {
ModSettings initial = ModSettings.defaults();
MemoryStorage storage = new MemoryStorage(SettingsCodec.encode(initial));
SettingsRepository repository = new SettingsRepository(storage);
String original = storage.document;
storage.failWrites = true;
expect(IOException.class, () -> repository.save(initial.withRegion("AL")));
equal(initial, repository.current(), "Failed save preserves memory");
equal(original, storage.document, "Failed save preserves disk");
check(!repository.restartRequired(), "Failed save cannot create pending runtime state");
expect(IOException.class, () -> repository.importJson(document("US", false, false, "dark")));
equal(initial, repository.current(), "Failed import write preserves memory");
storage.failWrites = false;
repository.save(initial.withRegion("AL"));
original = storage.document;
storage.failWrites = true;
expect(IOException.class, repository::reset);
equal("AL", repository.current().region(), "Failed reset preserves current values");
equal(original, storage.document, "Failed reset preserves stored values");
storage.failReads = true;
expect(IOException.class, () -> new SettingsRepository(storage));
equal(original, storage.document, "Read error never overwrites stored values");
MemoryStorage empty = new MemoryStorage(null);
empty.failWrites = true;
expect(IOException.class, () -> new SettingsRepository(empty));
equal(null, empty.document, "Failed first-launch write is surfaced");
}
private static void corruptStorage() throws Exception {
for (String document : new String[] {"not JSON", document("JP", true, true, "system")
.replace("\"schemaVersion\":1", "\"schemaVersion\":2")}) {
MemoryStorage storage = new MemoryStorage(document);
expect(IllegalArgumentException.class, () -> new SettingsRepository(storage));
equal(document, storage.document, "Unrecognized saved data is kept for recovery");
equal(0, storage.writes, "No silent downgrade or replacement of corrupt data");
}
}
private static String document(String region, boolean banners, boolean reposts, String theme) {
return "{\"format\":\"lihont.settings\",\"schemaVersion\":1,\"values\":{"
+ "\"region\":\"" + region + "\",\"profileBanners\":" + banners
+ ",\"commentReposts\":" + reposts + ",\"theme\":\"" + theme + "\"}}";
}
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
private static void equal(Object expected, Object actual, String message) {
if (expected == null ? actual != null : !expected.equals(actual)) {
throw new AssertionError(message + "; expected=" + expected + "; actual=" + actual);
}
}
private static void expect(Class<? extends Throwable> expected, Action action) throws Exception {
try {
action.run();
} catch (Throwable thrown) {
if (expected.isInstance(thrown)) return;
throw new AssertionError("Expected " + expected.getName() + ", got " + thrown, thrown);
}
throw new AssertionError("Expected " + expected.getName());
}
private static final class MemoryStorage implements SettingsRepository.Storage {
String document;
int writes;
boolean failReads;
boolean failWrites;
MemoryStorage(String document) { this.document = document; }
@Override public String read() throws IOException {
if (failReads) throw new IOException("Injected read failure");
return document;
}
@Override public void write(String replacement) throws IOException {
if (failWrites) throw new IOException("Injected atomic write failure");
document = replacement;
writes++;
}
}
}

172
tests/test_app_icons.py Normal file
View File

@@ -0,0 +1,172 @@
"""Launcher icon aliases and the dead drawables that carry their pictures."""
import struct
import tempfile
import unittest
import zlib
from pathlib import Path
from lihont import app_icons
from lihont.app_icons import Icon, IconError
from lihont.apkzip import Apk
from lihont.arsc import Arsc
from lihont.axml import Axml
from lihont.manifest import entry_icon, launcher_elements
ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / "src" / "icons"
BASE = ROOT / "OriginalAPKtiktok" / (
"com.zhiliaoapp.musically_47.1.3-2024701030_minAPI23(arm64-v8a)(nodpi)_apkmirror.com.apk")
def png(width, height):
"""The smallest thing png_side will accept: signature and an IHDR chunk."""
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
chunk = b"IHDR" + header
return (app_icons.PNG_SIGNATURE + struct.pack(">I", len(header)) + chunk
+ struct.pack(">I", zlib.crc32(chunk) & 0xFFFFFFFF) + b"\0" * 8)
class CatalogTest(unittest.TestCase):
def test_the_shipped_catalog_is_valid(self):
icons = app_icons.load_catalog(CATALOG)
self.assertEqual(["tolstyj", "elmacho", "pushistik", "original"], [i.slug for i in icons])
self.assertEqual(["Эй толстый", "Эль мачо", "Пушистик", "Оригинал"], [i.title for i in icons])
for icon in icons:
self.assertEqual(256, app_icons.png_side(icon.png))
def test_pictures_must_be_small_square_pngs(self):
self.assertEqual(256, app_icons.png_side(png(256, 256)))
for bad in (b"", b"not a png at all, not at all, not at all!!", png(256, 200), png(64, 64), png(2048, 2048)):
with self.assertRaises(IconError):
app_icons.png_side(bad)
def test_catalog_errors_are_reported(self):
with tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
(directory / "ok.png").write_bytes(png(256, 256))
for text in ("a\tonly two\n", "Bad Slug\tTitle\tok.png\n", "dup\tT\tok.png\ndup\tU\tok.png\n",
"fine\t\tok.png\n", "fine\t" + "x" * 33 + "\tok.png\n"):
(directory / "catalog.tsv").write_text(text, encoding="utf-8")
with self.assertRaises(IconError, msg=text):
app_icons.load_catalog(directory)
(directory / "catalog.tsv").write_text("# comment\n\nfine\tTitle\tok.png\n", encoding="utf-8")
self.assertEqual(["fine"], [i.slug for i in app_icons.load_catalog(directory)])
def test_new_path_keeps_the_encoded_length(self):
for _, old in app_icons.SLOTS:
for index in range(len(app_icons.SLOTS)):
new = app_icons.slot_path(old, index)
self.assertEqual(len(old), len(new))
self.assertEqual(old.rpartition("/")[0], new.rpartition("/")[0])
self.assertTrue(new.endswith(".png"))
self.assertEqual("res/c/li0.png", app_icons.slot_path("res/c/a0g.png", 0))
with self.assertRaises(IconError):
app_icons.slot_path("res/c/a.png", 0)
def test_slots_are_distinct(self):
self.assertEqual(len(app_icons.SLOTS), len({r for r, _ in app_icons.SLOTS}))
self.assertEqual(len(app_icons.SLOTS), len({p for _, p in app_icons.SLOTS}))
class OnTheRealApkTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not BASE.exists():
raise unittest.SkipTest("clean TikTok base APK is not available")
def setUp(self):
self.apk = Apk(str(BASE))
self.addCleanup(self.apk.close)
def test_every_pinned_slot_is_what_the_table_says(self):
arsc = Arsc(self.apk.read("resources.arsc"))
for res_id, path in app_icons.SLOTS:
app_icons.verify_slot(arsc, res_id, path)
self.assertTrue(self.apk.has(path), path)
def test_a_slot_that_moved_is_refused(self):
arsc = Arsc(self.apk.read("resources.arsc"))
with self.assertRaises(IconError):
app_icons.verify_slot(arsc, app_icons.SLOTS[0][0], "res/c/zzz.png")
with self.assertRaises(IconError):
app_icons.verify_slot(arsc, 0x7F010000, "res/c/a0g.png")
def test_aliases_are_added_disabled_and_carry_their_pictures(self):
xml = Axml.parse(self.apk.read("AndroidManifest.xml"))
before = len(launcher_elements(xml))
icons = app_icons.load_catalog(CATALOG)
report = app_icons.apply(self.apk, xml, icons)
self.assertEqual(4, report["icons"])
self.assertEqual(app_icons.ORIGINAL_ENTRY, report["original_entry"])
reparsed = Axml.parse(self.apk.read("AndroidManifest.xml"))
self.assertEqual(before + 4, len(launcher_elements(reparsed)))
aliases = {reparsed.attr_string(n, "name"): n for n in reparsed.elements("activity-alias")}
arsc = Arsc(self.apk.read("resources.arsc"))
for index, icon in enumerate(icons):
name = app_icons.alias_name(icon.slug)
node = aliases[name]
res_id, old_path = app_icons.SLOTS[index]
self.assertEqual(0, reparsed.attr(node, "enabled").data, "starts disabled")
self.assertEqual(0xFFFFFFFF, reparsed.attr(node, "exported").data)
self.assertEqual(report["target"], reparsed.attr_string(node, "targetActivity"))
self.assertEqual("TikTok", reparsed.attr_string(node, "label"))
self.assertEqual(res_id, reparsed.attr(node, "icon").data)
# the table now sends that id to our file, and the file is in the zip
new_path = app_icons.slot_path(old_path, index)
self.assertEqual(new_path, arsc.file_path(arsc.values(res_id)[0]))
self.assertEqual(icon.png, self.apk.read(new_path))
self.assertFalse(self.apk.has(old_path))
titles = [reparsed.attr_string(n, "value") for n in reparsed.elements("meta-data")
if reparsed.attr_string(n, "name") == "lihont.title"]
self.assertEqual([app_icons.STOCK_TITLE] + [i.title for i in icons], titles, "stock icon first, then the catalog")
def test_the_original_entry_stays_callable_but_leaves_the_home_screen(self):
xml = Axml.parse(self.apk.read("AndroidManifest.xml"))
original_icon = entry_icon(xml, app_icons.ORIGINAL_ENTRY)
app_icons.apply(self.apk, xml, app_icons.load_catalog(CATALOG))
reparsed = Axml.parse(self.apk.read("AndroidManifest.xml"))
original = next(n for n in reparsed.elements("activity-alias")
if reparsed.attr_string(n, "name") == app_icons.ORIGINAL_ENTRY)
enabled = reparsed.attr(original, "enabled")
self.assertTrue(enabled is None or enabled.data != 0, "TikTok starts it by name, so it must stay on")
launchers = [reparsed.attr_string(n, "name") for n in launcher_elements(reparsed)]
self.assertNotIn(app_icons.ORIGINAL_ENTRY, launchers)
self.assertEqual(5, len(launchers))
stock = next(n for n in reparsed.elements("activity-alias")
if reparsed.attr_string(n, "name") == app_icons.alias_name(app_icons.STOCK_SLUG))
self.assertEqual(0xFFFFFFFF, reparsed.attr(stock, "enabled").data, "the stock icon is on by default")
self.assertEqual(original_icon, reparsed.attr(stock, "icon").data, "and it is TikTok's own picture")
def test_exactly_one_icon_is_on(self):
xml = Axml.parse(self.apk.read("AndroidManifest.xml"))
app_icons.apply(self.apk, xml, app_icons.load_catalog(CATALOG))
reparsed = Axml.parse(self.apk.read("AndroidManifest.xml"))
on = [reparsed.attr_string(n, "name") for n in launcher_elements(reparsed)
if reparsed.attr(n, "enabled") is None or reparsed.attr(n, "enabled").data != 0]
self.assertEqual([app_icons.alias_name(app_icons.STOCK_SLUG)], on)
def test_a_catalog_icon_cannot_take_the_stock_slug(self):
with tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
(directory / "p.png").write_bytes(png(256, 256))
(directory / "catalog.tsv").write_text("stock\tTikTok\tp.png\n", encoding="utf-8")
with self.assertRaises(IconError):
app_icons.load_catalog(directory)
def test_more_icons_than_slots_are_refused(self):
icons = [Icon("i%02d" % n, "T", png(256, 256)) for n in range(len(app_icons.SLOTS) + 1)]
xml = Axml.parse(self.apk.read("AndroidManifest.xml"))
with tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
(directory / "p.png").write_bytes(png(256, 256))
(directory / "catalog.tsv").write_text(
"".join("i%02d\tT\tp.png\n" % n for n in range(len(icons))), encoding="utf-8")
with self.assertRaises(IconError):
app_icons.load_catalog(directory)
if __name__ == "__main__":
unittest.main()

80
tests/test_badge_patch.py Normal file
View File

@@ -0,0 +1,80 @@
import unittest
from pathlib import Path
from lihont import badge_patch as bp
USER = """\
.class public Lcom/ss/android/ugc/aweme/profile/model/User;
.super Ljava/lang/Object;
.method public getNickname()Ljava/lang/String;
.registers 2
iget-object v0, p0, Lx;->nickname:Ljava/lang/String;
return-object v0
.end method
.method public getUniqueId()Ljava/lang/String;
.registers 2
iget-object v0, p0, Lx;->uniqueId:Ljava/lang/String;
return-object v0
.end method
.method public getUserDisplayName()Ljava/lang/String;
.registers 2
iget-object v0, p0, Lx;->d:Ljava/lang/String;
return-object v0
.end method
"""
TUX = """\
.class public Lcom/bytedance/tux/input/TuxTextView;
.super Landroid/widget/TextView;
.method public setText(Ljava/lang/CharSequence;Landroid/widget/TextView$BufferType;)V
.registers 10
return-void
.end method
"""
class BadgePatchTest(unittest.TestCase):
def test_user_getters_remember_their_names(self):
patched, hits = bp.rewrite_user(USER)
self.assertEqual(3, len(hits))
self.assertEqual(3, patched.count(bp.REMEMBER))
self.assertLess(patched.index(bp.REMEMBER), patched.index("return-object v0"))
def test_text_view_hook_runs_first_and_replaces_the_text(self):
patched, _ = bp.rewrite_text_view(TUX)
self.assertLess(patched.index(".registers 10"), patched.index(bp.PREPARE))
self.assertLess(patched.index("move-result-object p1"), patched.index("return-void"))
def test_second_pass_and_changed_shapes_are_refused(self):
with self.assertRaises(ValueError):
bp.rewrite_user(bp.rewrite_user(USER)[0])
with self.assertRaises(ValueError):
bp.rewrite_user(USER.replace(".registers 2", ".registers 3", 1))
with self.assertRaises(ValueError):
bp.rewrite_text_view(TUX.replace("setText", "setLabel"))
def test_every_target_has_a_dex_and_the_runtime_calls_exist(self):
self.assertEqual(set(bp.REWRITES), set(bp.DEXES))
self.assertEqual(4, len(bp.runtime_targets()))
def test_the_bundled_pictures_are_there(self):
# the pictures are third-party artwork: a checkout without them still builds, without those two badges
for name in ("supporter.png", "developer.png"):
path = Path("src/compat", name)
if path.is_file():
self.assertTrue(path.stat().st_size > 500)
if __name__ == "__main__":
unittest.main()

71
tests/test_bar_patch.py Normal file
View File

@@ -0,0 +1,71 @@
import unittest
from lihont import bar_patch
ON_RESUME = """\
.class public Lcom/ss/android/ugc/aweme/main/MainActivity;
.super LX/0tPX;
.method public onResume()V
.registers 5
const v0, 0x117de
invoke-static {v0}, Lcom/bytedance/pumbaa/utility/method_id/MethodIDManager;->push(I)Z
move-result v3
const/4 v0, 0x1
const-string v2, "com.ss.android.ugc.aweme.main.MainActivity"
const-string v1, "onResume"
invoke-static {v2, v1, v0}, Lcom/bytedance/apm/agent/v2/instrumentation/ActivityAgent;->onTrace(Ljava/lang/String;Ljava/lang/String;Z)V
invoke-super {p0}, LX/1EKk;->onResume()V
const/4 v0, 0x0
invoke-static {v2, v1, v0}, Lcom/bytedance/apm/agent/v2/instrumentation/ActivityAgent;->onTrace(Ljava/lang/String;Ljava/lang/String;Z)V
return-void
.end method
.method public onStart()V
.registers 4
invoke-super {p0}, LX/1EKk;->onStart()V
return-void
.end method
"""
class BarPatchTest(unittest.TestCase):
def test_hook_follows_the_super_call_and_nothing_else_changes(self):
patched = bar_patch.rewrite_main_activity(ON_RESUME)
call = " invoke-static {p0}, " + bar_patch.HOOK
self.assertEqual(1, patched.count(call))
self.assertLess(patched.index("->onResume()V"), patched.index(call))
self.assertLess(patched.index(call), patched.index("const/4 v0, 0x0"))
# onStart's own super call is left alone and the rest of the file is byte for byte the same
self.assertEqual(ON_RESUME, patched.replace("\n" + call + "\n", "", 1))
def test_it_refuses_to_patch_twice(self):
patched = bar_patch.rewrite_main_activity(ON_RESUME)
with self.assertRaises(ValueError):
bar_patch.rewrite_main_activity(patched)
def test_it_refuses_a_method_it_does_not_recognise(self):
with self.assertRaises(ValueError):
bar_patch.rewrite_main_activity(ON_RESUME.replace("->onResume()V", "->onPause()V"))
with self.assertRaises(ValueError):
bar_patch.rewrite_main_activity(".class public Lx;\n.super Ljava/lang/Object;\n")
def test_runtime_target_is_the_hook(self):
self.assertEqual([bar_patch.HOOK], bar_patch.runtime_targets())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,67 @@
"""The custom launcher icon store and the picture-crop geometry.
Both are plain java.io / arithmetic, so they run on a desktop JVM; the Android side (picking a photo,
pinning the shortcut) is checked on the phone.
"""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
PACKAGE = ROOT / "src" / "lihont" / "two" / "settings"
HARNESS = ROOT / "tests" / "java" / "lihont" / "two" / "settings" / "IconStoreTest.java"
class CustomIconTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.javac = shutil.which("javac")
cls.java = shutil.which("java")
if not cls.javac or not cls.java:
raise RuntimeError("These tests require a JDK with java and javac")
cls.temporary = tempfile.TemporaryDirectory(prefix="lihont-custom-icon-tests-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.classes = Path(cls.temporary.name)
cls.environment = dict(os.environ)
cls.environment.pop("JAVA_TOOL_OPTIONS", None)
cls.run_command([
cls.javac, "--release", "17", "-encoding", "UTF-8", "-nowarn", "-Xlint:-options",
"-d", str(cls.classes),
str(PACKAGE / "IconCrop.java"), str(PACKAGE / "CustomIconStore.java"), str(HARNESS),
])
@classmethod
def run_command(cls, command):
result = subprocess.run(
command, capture_output=True, text=True, encoding="utf-8",
env=cls.environment, timeout=60,
)
if result.returncode:
raise AssertionError(
f"Command failed with exit {result.returncode}: {command[0]}\n" + result.stdout + result.stderr)
return result
def run_scenario(self, name):
result = self.run_command([
self.java, "-cp", str(self.classes), "lihont.two.settings.IconStoreTest", name])
self.assertEqual(result.stdout.strip(), "PASS " + name)
def test_the_photo_is_cut_to_the_centre_square(self):
self.run_scenario("crop")
def test_names_and_ids_are_cleaned(self):
self.run_scenario("names")
def test_icons_are_stored_listed_capped_and_removed(self):
self.run_scenario("store")
def test_a_damaged_index_is_survived(self):
self.run_scenario("damaged")
if __name__ == "__main__":
unittest.main()

210
tests/test_feed_patch.py Normal file
View File

@@ -0,0 +1,210 @@
import unittest
from lihont import feed_patch as fp
AWEME = "Lcom/ss/android/ugc/aweme/feed/model/Aweme;"
FEED_PAGE = """\
.class public Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;
.super Ljava/lang/Object;
.method public getItems()Ljava/util/List;
.registers 4
.annotation system Ldalvik/annotation/Signature;
value = {
"()",
"Ljava/util/List<",
"Lcom/ss/android/ugc/aweme/feed/model/Aweme;",
">;"
}
.end annotation
.prologue
.line 1
iget-object v0, p0, Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;->items:Ljava/util/List;
return-object v0
.end method
.method public getPreloadAds()Ljava/util/List;
.registers 2
iget-object v0, p0, Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;->preloadAds:Ljava/util/List;
return-object v0
.end method
.method public isHasAd()Z
.registers 2
iget-boolean v0, p0, Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;->hasAd:Z
return v0
.end method
.method public setItems(Ljava/util/List;)V
.registers 4
iput-object p1, p0, Lcom/ss/android/ugc/aweme/feed/model/FeedItemList;->items:Ljava/util/List;
return-void
.end method
"""
MID_AD = """\
.class public final Lcom/ss/android/ugc/aweme/commercialize/feed/assem/midad/MidAdResponse;
.super Ljava/lang/Object;
.method public final getAweme()Lcom/ss/android/ugc/aweme/feed/model/Aweme;
.registers 2
iget-object v0, p0, Lcom/ss/android/ugc/aweme/commercialize/feed/assem/midad/MidAdResponse;->aweme:Lcom/ss/android/ugc/aweme/feed/model/Aweme;
return-object v0
.end method
"""
CANDIDATES = """\
.class public final Lcom/ss/android/ugc/aweme/commercialize/feed/candidate/CandidateRequestImpl;
.super Ljava/lang/Object;
.method public final LIZ(Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V
.registers 4
invoke-interface {v0}, Lcom/ss/android/ugc/aweme/commercialize/feed/candidate/CandidateAdRequestApi;->request(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)LX/12b3;
return-void
.end method
"""
TOP_VIEW = """\
.class public final Lcom/bytedance/ies/ugc/aweme/commercialize/splash/topview/TopViewJsonManager;
.super Ljava/lang/Object;
.method public final LIZIZ(Ljava/lang/String;)Lcom/ss/android/ugc/aweme/feed/model/Aweme;
.registers 5
invoke-virtual {p0, p1}, Lcom/bytedance/ies/ugc/aweme/commercialize/splash/topview/TopViewJsonManager;->LIZLLL(Ljava/lang/String;)Ljava/util/Map;
const/4 v0, 0x0
return-object v0
.end method
.method public final declared-synchronized LJ()Ljava/util/Map;
.registers 2
.annotation system Ldalvik/annotation/Signature;
value = {
"()",
"Ljava/util/Map<",
"Ljava/lang/String;",
"LX/0cWK;",
">;"
}
.end annotation
monitor-enter p0
iget-object v0, p0, Lcom/bytedance/ies/ugc/aweme/commercialize/splash/topview/TopViewJsonManager;->LJ:Ljava/util/concurrent/ConcurrentHashMap;
monitor-exit p0
return-object v0
.end method
"""
class HelperTest(unittest.TestCase):
def test_parameter_registers_count_this_and_wide_arguments(self):
self.assertEqual(1, fp._parameter_registers("public final x()V"))
self.assertEqual(0, fp._parameter_registers("public static x()V"))
self.assertEqual(4, fp._parameter_registers("public x(IJ)V"))
self.assertEqual(4, fp._parameter_registers("public x(Ljava/lang/String;[ILjava/util/List;)V"))
self.assertEqual(3, fp._parameter_registers("public static x(DZ)V"))
def test_a_local_is_made_when_the_method_has_none(self):
self.assertIn(".registers 3", fp._with_local("public x(I)V", " .registers 2\n return-void\n"))
self.assertIn(".registers 4", fp._with_local("public x(I)V", " .registers 4\n return-void\n"))
def test_the_first_instruction_comes_after_annotations_and_debug_lines(self):
body = (" .registers 4\n .annotation system Lx/Y;\n value = {\n }\n .end annotation\n\n"
" .prologue\n .line 3\n const/4 v0, 0x1\n")
self.assertEqual(" const/4 v0, 0x1\n", body[fp._first_instruction(body):])
class RewriteTest(unittest.TestCase):
def test_the_feed_page_is_cleaned_when_read_or_written(self):
patched, hits = fp.rewrite_feed_page(FEED_PAGE)
self.assertEqual({"items_read", "items_write", "preload_read", "ad_flag"}, set(hits))
self.assertEqual(4, patched.count(fp.MARKER))
self.assertEqual(4, patched.count(fp.CLEAN))
self.assertEqual(1, patched.count(fp.FILTER))
# the call goes after the annotation block and before the method's own code
items = patched.split(".method public getItems()")[1].split(".end method")[0]
self.assertLess(items.index(".end annotation"), items.index(fp.CLEAN))
self.assertLess(items.index(fp.CLEAN), items.index("iget-object v0"))
self.assertEqual(FEED_PAGE.count(".method "), patched.count(".method "))
def test_the_filtered_list_replaces_the_argument_of_set_items(self):
patched, _ = fp.rewrite_feed_page(FEED_PAGE)
setter = patched.split(".method public setItems(")[1]
self.assertLess(setter.index("move-result-object p1"), setter.index("iput-object p1"))
def test_the_mid_ad_response_ends_early_while_ads_are_off(self):
patched, hits = fp.rewrite_mid_ad(MID_AD)
self.assertEqual({"mid_ad": 1}, hits)
self.assertLess(patched.index(fp.ADS), patched.index("\n :lihont_keep\n"))
self.assertIn("const/4 v0, 0x0\n\n return-object v0", patched)
self.assertIn("iget-object v0, p0, Lcom/ss/android/ugc/aweme/commercialize/feed/assem/midad/MidAdResponse;->aweme", patched)
def test_the_candidate_request_is_not_sent_while_ads_are_off(self):
patched, hits = fp.rewrite_candidates(CANDIDATES)
self.assertEqual({"candidate_request": 1}, hits)
label = "\n :lihont_keep\n"
self.assertLess(patched.index("return-void"), patched.index(label))
self.assertLess(patched.index(label), patched.index("CandidateAdRequestApi"))
def test_the_top_view_store_is_empty_while_ads_are_off(self):
patched, hits = fp.rewrite_top_view(TOP_VIEW)
self.assertEqual({"top_view", "top_view_store"}, set(hits))
store = patched.split("declared-synchronized LJ()")[1]
self.assertLess(store.index(fp.ADS), store.index("monitor-enter p0"))
self.assertIn("new-instance v0, Ljava/util/HashMap;", store)
# LIZIZ(String) has p0 and p1 (two registers) and five in all: v0 is already a local there
self.assertIn(".registers 5", patched)
def test_a_method_without_a_local_gets_one(self):
tight = MID_AD.replace(".registers 2", ".registers 1").replace("iget-object v0, p0,", "iget-object p0, p0,").replace("return-object v0", "return-object p0")
patched, _ = fp.rewrite_mid_ad(tight)
self.assertIn(".registers 2", patched)
def test_a_second_pass_is_refused(self):
for rewrite, text in ((fp.rewrite_feed_page, FEED_PAGE), (fp.rewrite_mid_ad, MID_AD),
(fp.rewrite_candidates, CANDIDATES), (fp.rewrite_top_view, TOP_VIEW)):
with self.assertRaises(ValueError):
rewrite(rewrite(text)[0])
def test_a_class_that_changed_is_refused(self):
with self.assertRaises(ValueError):
fp.rewrite_feed_page(FEED_PAGE.replace("->items:Ljava/util/List;", "->list:Ljava/util/List;"))
with self.assertRaises(ValueError):
fp.rewrite_feed_page(FEED_PAGE.replace("isHasAd", "hasAnAd"))
with self.assertRaises(ValueError):
fp.rewrite_mid_ad(MID_AD.replace("->aweme:", "->other:"))
with self.assertRaises(ValueError):
fp.rewrite_candidates(CANDIDATES.replace("CandidateAdRequestApi", "Other"))
with self.assertRaises(ValueError):
fp.rewrite_top_view(TOP_VIEW.replace("->LJ:Ljava/util/concurrent/ConcurrentHashMap;", "->LJ:Ljava/util/Map;"))
with self.assertRaises(ValueError):
fp.rewrite_feed_page(MID_AD)
def test_every_rewrite_has_its_dex_and_runtime_calls(self):
self.assertEqual(set(fp.REWRITES), set(fp.DEXES))
both = "".join(fp.REWRITES[owner](text)[0] for owner, text in (
(fp.FEED_PAGE, FEED_PAGE), (fp.MID_AD, MID_AD), (fp.CANDIDATES, CANDIDATES), (fp.TOP_VIEW, TOP_VIEW)))
for target in fp.runtime_targets():
self.assertIn(target, both)
if __name__ == "__main__":
unittest.main()

70
tests/test_icons.py Normal file
View File

@@ -0,0 +1,70 @@
"""The mod's icons are standard Material glyphs parsed from SVG path data.
The parser and the icon table run on a desktop JVM; Android is only on the classpath.
To look at the icons, render them with the harness:
java -cp <classes>;tools/android.jar lihont.two.settings.IconRenderTest render out.png
"""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
PACKAGE = ROOT / "src" / "lihont" / "two" / "settings"
HARNESS = ROOT / "tests" / "java" / "lihont" / "two" / "settings" / "IconRenderTest.java"
class IconTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.javac = shutil.which("javac")
cls.java = shutil.which("java")
if not cls.javac or not cls.java:
raise RuntimeError("Icon tests require a JDK with java and javac")
cls.android_jar = ROOT / "tools" / "android.jar"
if not cls.android_jar.is_file():
raise RuntimeError("tools/android.jar must be available")
cls.temporary = tempfile.TemporaryDirectory(prefix="lihont-icon-tests-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.classes = Path(cls.temporary.name)
cls.environment = dict(os.environ)
cls.environment.pop("JAVA_TOOL_OPTIONS", None)
cls.run_command([
cls.javac, "--release", "17", "-encoding", "UTF-8", "-nowarn", "-Xlint:-options",
"-cp", str(cls.android_jar), "-d", str(cls.classes),
str(PACKAGE / "SvgPath.java"), str(PACKAGE / "Md3Icon.java"), str(HARNESS),
])
@classmethod
def run_command(cls, command):
result = subprocess.run(
command, capture_output=True, text=True, encoding="utf-8",
env=cls.environment, timeout=60,
)
if result.returncode:
raise AssertionError(
f"Command failed with exit {result.returncode}: {command[0]}\n"
+ result.stdout + result.stderr
)
return result
def run_scenario(self, name):
result = self.run_command([
self.java, "-Djava.awt.headless=true",
"-cp", str(self.classes) + os.pathsep + str(self.android_jar),
"lihont.two.settings.IconRenderTest", name,
])
self.assertEqual(result.stdout.strip(), "PASS " + name)
def test_every_icon_parses_inside_the_grid_and_is_legible(self):
self.run_scenario("bounds")
def test_path_data_shortcuts_and_arcs_are_understood(self):
self.run_scenario("tokens")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,46 @@
from pathlib import Path
import unittest
from lihont.axml import Axml
from lihont.manifest import ManifestError, add_activity, has_activity
class ManifestActivityTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.apk = Path('OriginalAPKtiktok/com.zhiliaoapp.musically_47.1.3-2024701030_minAPI23(arm64-v8a)(nodpi)_apkmirror.com.apk')
if not cls.apk.exists():
raise unittest.SkipTest('clean TikTok base APK is not available')
def _manifest(self):
from lihont.apkzip import Apk
apk = Apk(str(self.apk))
try:
return Axml.parse(apk.read('AndroidManifest.xml'))
finally:
apk.close()
def test_private_settings_activity_is_non_exported(self):
xml = self._manifest()
name = 'lihont.two.settings.LihonTSettingsActivity'
add_activity(xml, name, 'LihonT', 0x7F120364)
self.assertTrue(has_activity(xml, name))
node = next(n for n in xml.elements('activity') if xml.attr_string(n, 'name') == name)
exported = xml.attr(node, 'exported')
self.assertIsNotNone(exported)
self.assertEqual(0, exported.data)
self.assertEqual(0x7F120364, xml.attr(node, 'theme').data)
# A second parse verifies the binary XML remains valid after appending
# framework attributes and a private activity subtree.
reparsed = Axml.parse(xml.build())
self.assertTrue(has_activity(reparsed, name))
def test_launcher_activity_must_explicitly_be_exported(self):
xml = self._manifest()
with self.assertRaises(ManifestError):
add_activity(xml, 'lihont.two.TestLauncher', 'LihonT', 0x7F120364,
affinity='lihont.test', launcher=True)
if __name__ == '__main__':
unittest.main()

147
tests/test_media_patch.py Normal file
View File

@@ -0,0 +1,147 @@
import unittest
from pathlib import Path
from lihont import media_patch
ROOT = Path(__file__).resolve().parents[1]
SMALI24 = ROOT / "work" / "tabs" / "smali24" / "X"
REAL_SOURCE = SMALI24 / "0qvH.2.smali"
REAL_CONTEXT = SMALI24 / "0qwE.2.smali"
AWEME = media_patch.AWEME
SOURCE = """\
.class public final LX/0qvH;
.super Ljava/lang/Object;
.method public final LIZ(%(aweme)sZ)V
.registers 7
return-void
.end method
.method public final LIZIZ(%(aweme)s)V
.registers 11
.prologue
.line 16777216
iget-object v1, p0, LX/0qvH;->LJ:Ljava/lang/String;
return-void
.end method
""" % {"aweme": AWEME}
CONTEXT = """\
.class public final LX/0qwE;
.super LX/0qzh;
.method public final LJFF()Z
.registers 2
iget-boolean v0, p0, LX/0qwE;->LJIJI:Z
return v0
.end method
.method public final LJIIIZ()Z
.registers 2
.prologue
.line 0
iget-boolean v0, p0, LX/0qwE;->LJIJ:Z
.line 1
return v0
.end method
.method public final LJIIJ()Z
.registers 2
iget-boolean v0, p0, LX/0qwE;->LJIIZILJ:Z
return v0
.end method
"""
class SourceRewriteTest(unittest.TestCase):
def test_the_hook_goes_in_front_of_the_original_body(self):
patched = media_patch.rewrite_source(SOURCE)
head, tail = patched.split(".method public final LIZIZ(", 1)
self.assertIn(" .registers 11\n\n" + media_patch.PICK_HOOK, tail)
self.assertTrue(tail.index(media_patch.PICK_HOOK) < tail.index("iget-object v1, p0, LX/0qvH;->LJ"))
self.assertEqual(SOURCE.split(".method public final LIZIZ(", 1)[0], head)
def test_it_hands_over_to_the_plain_pick_and_returns(self):
hook = media_patch.PICK_HOOK
self.assertIn("invoke-virtual {p0, p1, v0}, LX/0qvH;->LIZ(%sZ)V" % AWEME, hook)
self.assertLess(hook.index("LIZ(" + AWEME), hook.index("return-void"))
self.assertEqual(1, sum(1 for line in hook.splitlines() if line.strip() == ":lihont_normal"))
self.assertEqual(1, hook.count("if-eqz v0, :lihont_normal"))
# only v0 is touched, which is a local in a method with eleven registers
for register in ("v1", "v2", "v9", "v10"):
self.assertNotIn(register, hook)
def test_it_refuses_a_second_pass(self):
with self.assertRaises(ValueError):
media_patch.rewrite_source(media_patch.rewrite_source(SOURCE))
def test_it_refuses_a_class_without_the_methods(self):
with self.assertRaises(ValueError):
media_patch.rewrite_source(".class public Lx;\n.super Ljava/lang/Object;\n")
with self.assertRaises(ValueError):
media_patch.rewrite_source(SOURCE.replace(".method public final LIZ(" + AWEME + "Z)V", ".method public final LIZ(I)V"))
@unittest.skipUnless(REAL_SOURCE.exists(), "the disassembled X.0qvH is not in work/")
def test_the_real_class_is_rewritten(self):
text = REAL_SOURCE.read_text(encoding="utf-8").replace(chr(13) + chr(10), chr(10))
patched = media_patch.rewrite_source(text)
self.assertEqual(1, patched.count(media_patch.SWITCH))
self.assertEqual(text.count(".method "), patched.count(".method "), "no method added or lost")
class ContextRewriteTest(unittest.TestCase):
def test_only_the_logo_getter_changes(self):
patched = media_patch.rewrite_context(CONTEXT)
self.assertIn(media_patch.LOGO, patched)
self.assertIn("iget-boolean v0, p0, LX/0qwE;->LJIJ:Z\n\n invoke-static {v0}, " + media_patch.LOGO, patched)
# the other flags, including the AI-content ones, keep TikTok's own answers
self.assertIn("iget-boolean v0, p0, LX/0qwE;->LJIJI:Z\n\n return v0", patched)
self.assertIn("iget-boolean v0, p0, LX/0qwE;->LJIIZILJ:Z\n\n return v0", patched)
self.assertEqual(CONTEXT.count(".method "), patched.count(".method "))
def test_registers_cover_what_the_body_uses(self):
body = media_patch.LOGO_BODY
self.assertIn(".registers 2", body)
for register in ("v1", "v2"):
self.assertNotIn(register, body)
def test_it_refuses_a_second_pass(self):
with self.assertRaises(ValueError):
media_patch.rewrite_context(media_patch.rewrite_context(CONTEXT))
def test_it_refuses_a_getter_that_is_not_the_plain_one(self):
changed = CONTEXT.replace(" return v0\n.end method\n\n.method public final LJIIJ()Z",
" invoke-static {v0}, Lx/Y;->z(Z)V\n return v0\n.end method\n\n.method public final LJIIJ()Z")
with self.assertRaises(ValueError):
media_patch.rewrite_context(changed)
with self.assertRaises(ValueError):
media_patch.rewrite_context(".class public Lx;\n.super Ljava/lang/Object;\n")
@unittest.skipUnless(REAL_CONTEXT.exists(), "the disassembled X.0qwE is not in work/")
def test_the_real_class_is_rewritten(self):
text = REAL_CONTEXT.read_text(encoding="utf-8").replace(chr(13) + chr(10), chr(10))
patched = media_patch.rewrite_context(text)
self.assertEqual(1, patched.count(media_patch.LOGO))
self.assertEqual(text.count(".method "), patched.count(".method "), "no method added or lost")
class RuntimeTest(unittest.TestCase):
def test_runtime_targets_are_what_the_rewrites_call(self):
both = media_patch.rewrite_source(SOURCE) + media_patch.rewrite_context(CONTEXT)
for target in media_patch.runtime_targets():
self.assertIn(target, both)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,73 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
SPEC = importlib.util.spec_from_file_location("members_server", Path(__file__).resolve().parents[1] / "services" / "members" / "server.py")
server = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(server)
UID = "6812345678901234"
class ClaimTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.now = 1_790_000_000
self.bio = ""
self.found = UID
self.registry = server.Registry(Path(self.tmp.name) / "r.sqlite3", clock=lambda: self.now, fetcher=self.fetch)
def fetch(self, unique_id):
return (self.found, self.bio)
def test_code_is_stable_and_per_account(self):
code = self.registry.code(UID)
self.assertEqual(code, self.registry.code(UID))
self.assertNotEqual(code, self.registry.code("6812345678901235"))
self.assertRegex(code, r"^LT-[A-Z2-7]{8}$")
def test_bio_with_the_code_gets_the_badge(self):
self.assertFalse(self.registry.lookup([UID])["niche"][UID])
self.assertEqual("code_missing", self.registry.claim(UID, "someone")["reason"])
self.bio = "hello " + self.registry.code(UID) + " bye"
self.assertTrue(self.registry.claim(UID, "someone")["granted"])
self.assertTrue(self.registry.lookup([UID])["niche"][UID])
def test_someone_elses_profile_is_refused(self):
self.found = "999999999"
self.bio = self.registry.code(UID)
with self.assertRaises(ValueError):
self.registry.claim(UID, "someone")
def test_nothing_is_issued_after_the_deadline_but_holders_stay(self):
self.bio = self.registry.code(UID)
self.registry.claim(UID, "someone")
self.now = server.CLAIM_DEADLINE + 1
other = "6812345678901299"
self.found, self.bio = other, self.registry.code(other)
with self.assertRaises(ValueError):
self.registry.claim(other, "other")
self.assertTrue(self.registry.lookup([UID, other])["niche"][UID])
self.assertFalse(self.registry.lookup([UID, other])["niche"][other])
def test_blocked_accounts_get_nothing(self):
with self.registry.connect() as db:
db.execute("INSERT INTO blocked(uid) VALUES(?)", (UID,))
self.bio = self.registry.code(UID)
with self.assertRaises(ValueError):
self.registry.claim(UID, "someone")
def test_bad_input_and_unreadable_profiles(self):
with self.assertRaises(ValueError):
self.registry.claim("abc", "someone")
with self.assertRaises(ValueError):
self.registry.claim(UID, "bad name!")
self.registry.fetcher = lambda u: (_ for _ in ()).throw(OSError("blocked"))
with self.assertRaises(ValueError):
self.registry.claim(UID, "someone")
if __name__ == "__main__":
unittest.main()

201
tests/test_portal.py Normal file
View File

@@ -0,0 +1,201 @@
"""The private /lihont2 portal: real requests against a server on a throw-away database.
The credentials are random per run and live only in this process. The pages are checked for
the content each role must (and must not) see, for escaping of user-controlled text, and for
icon ids that exist in the shared sprite.
"""
import gc
import http.cookiejar
import importlib.util
import re
import secrets
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SERVER = ROOT / "src" / "web" / "server.py"
SPRITE = ROOT / "src" / "web" / "assets" / "site" / "icons.svg"
def load_server():
spec = importlib.util.spec_from_file_location("lihont_portal_server", SERVER)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
class PortalTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.portal = load_server()
cls.directory = tempfile.TemporaryDirectory(prefix="lihont-portal-test-", ignore_cleanup_errors=True)
base = Path(cls.directory.name)
cls.apk = base / "lihont2.apk"
cls.apk.write_bytes(b"PK\x03\x04" + b"0" * 2048)
cls.admin_user = "admin_" + secrets.token_hex(4)
cls.admin_password = secrets.token_urlsafe(18)
cls.portal.ADMIN_USERNAME = cls.admin_user
cls.portal.ADMIN_PASSWORD = cls.admin_password
db = str(base / "data.db")
cls.portal.init_db(db)
handler = cls.portal.LihonTHandler
handler.db_path = db
handler.apk_path = str(cls.apk)
handler._cached_stat = (0.0, 0, "")
class Server(cls.portal.socketserver.ThreadingMixIn, cls.portal.http.server.HTTPServer):
daemon_threads = True
cls.httpd = Server(("127.0.0.1", 0), handler)
cls.base_url = "http://127.0.0.1:%d%s" % (cls.httpd.server_address[1], cls.portal.BASE_PATH)
threading.Thread(target=cls.httpd.serve_forever, daemon=True).start()
cls.sprite_ids = set(re.findall(r'<symbol id="([^"]+)"', SPRITE.read_text(encoding="utf-8")))
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
gc.collect() # the portal leaves sqlite connections to the collector; Windows will not delete an open file
cls.directory.cleanup()
def session(self, follow=True):
jar = http.cookiejar.CookieJar()
handlers = [urllib.request.HTTPCookieProcessor(jar)]
if not follow:
handlers.append(NoRedirect())
return urllib.request.build_opener(*handlers)
def fetch(self, opener, path, data=None):
body = urllib.parse.urlencode(data).encode() if data is not None else None
try:
with opener.open(self.base_url + path, body) as response:
return response.status, response.read().decode("utf-8"), response
except urllib.error.HTTPError as error:
return error.code, error.read().decode("utf-8"), error
def login(self, user, password):
opener = self.session()
status, page, _ = self.fetch(opener, "/login", {"username": user, "password": password})
return opener, status, page
def assert_icons_exist(self, page):
used = set(re.findall(r'icons\.svg#([a-z0-9-]+)', page))
self.assertTrue(used, "page uses no icons at all")
self.assertEqual(used - self.sprite_ids, set())
def test_login_page_is_material(self):
status, page, _ = self.fetch(self.session(), "/")
self.assertEqual(status, 200)
self.assertIn('/assets/site/md3.css', page)
self.assertIn('class="field"', page)
self.assertIn('type="password"', page)
self.assertNotIn("user-scalable=no", page)
self.assert_icons_exist(page)
def test_wrong_password_shows_error_banner(self):
status, page, _ = self.fetch(self.session(), "/login", {"username": self.admin_user, "password": "wrong"})
self.assertEqual(status, 401)
self.assertIn("Неверный логин или пароль", page)
self.assertIn("alert--error", page)
def test_download_needs_a_session(self):
opener = self.session(follow=False)
status, _, response = self.fetch(opener, "/download")
self.assertEqual(status, 303)
self.assertEqual(response.headers["Location"], self.portal.BASE_PATH + "/")
def test_admin_flow(self):
opener, status, page = self.login(self.admin_user, self.admin_password)
self.assertEqual(status, 200)
self.assertIn("Панель управления", page)
self.assertIn(self.admin_user, page)
self.assertIn(hash_prefix(self.apk), page)
self.assert_icons_exist(page)
tester, tester_password = "tester_" + secrets.token_hex(3), secrets.token_urlsafe(12)
status, page, _ = self.fetch(opener, "/admin/create-user", {"username": tester, "password": tester_password, "note": "QA"})
self.assertEqual(status, 200)
self.assertIn(tester, page)
self.assertIn("alert--success", page)
self.assertIn("data-confirm", page)
self.assertNotIn("onsubmit", page)
status, page, _ = self.fetch(opener, "/admin/create-user", {"username": tester, "password": "x"})
self.assertEqual(status, 400)
self.assertIn("уже существует", page)
status, page, _ = self.fetch(opener, "/admin/create-user", {"username": "", "password": ""})
self.assertEqual(status, 400)
self.assertIn("alert--error", page)
user_opener, status, user_page = self.login(tester, tester_password)
self.assertEqual(status, 200)
self.assertIn("Release v2.0 Native", user_page)
self.assertIn("Скачать lihont2.apk", user_page)
self.assertNotIn("Панель управления", user_page)
self.assertIn("data-copy", user_page)
self.assert_icons_exist(user_page)
status, body, response = self.fetch(user_opener, "/download")
self.assertEqual(status, 200)
self.assertEqual(response.headers["Content-Disposition"], 'attachment; filename="lihont2.apk"')
self.assertEqual(response.headers["Content-Length"], str(self.apk.stat().st_size))
user_id = re.search(r'name="user_id" value="(\d+)"', page).group(1)
status, page, _ = self.fetch(opener, "/admin/delete-user", {"user_id": user_id})
self.assertEqual(status, 200)
self.assertIn("Пользователь удален.", page)
self.assertNotIn(tester, page)
def test_admin_cannot_delete_self(self):
opener, _, page = self.login(self.admin_user, self.admin_password)
status, page, _ = self.fetch(opener, "/admin/delete-user", {"user_id": "1"})
self.assertIn(self.admin_user, page)
def test_user_text_is_escaped(self):
opener, _, _ = self.login(self.admin_user, self.admin_password)
nasty = "<img src=x onerror=alert(1)>'\""
status, page, _ = self.fetch(opener, "/admin/create-user", {"username": nasty, "password": "x", "note": "<b>note</b>"})
self.assertEqual(status, 200)
self.assertNotIn("<img src=x", page)
self.assertNotIn("<b>note</b>", page)
self.assertIn("&lt;img src=x", page)
user_id = re.search(r'name="user_id" value="(\d+)"', page).group(1)
self.fetch(opener, "/admin/delete-user", {"user_id": user_id})
def test_missing_page_is_styled(self):
status, page, _ = self.fetch(self.session(), "/nope")
self.assertEqual(status, 404)
self.assertIn("Страница не найдена", page)
self.assertIn("/assets/site/md3.css", page)
self.assert_icons_exist(page)
def test_pages_use_only_shared_assets(self):
_, login_page, _ = self.fetch(self.session(), "/")
self.assertNotIn("<style", login_page)
for href in re.findall(r'(?:href|src)="(/[^"]+)"', login_page):
if href.startswith(self.portal.BASE_PATH):
continue
self.assertTrue(href.startswith("/assets/site/") or href == "/logo_single_note.svg", href)
def hash_prefix(path):
import hashlib
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
if __name__ == "__main__":
unittest.main()

203
tests/test_profile_flags.py Normal file
View File

@@ -0,0 +1,203 @@
"""Native flag reads are routed through LihonTFlags, and LihonTFlags obeys the settings.
The smali rewriter is exercised on small synthetic methods. The runtime class is
compiled and run on a real JVM against the production settings classes; Android
itself is only on the classpath, as in test_settings_model.
"""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from lihont import profile_features
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src" / "lihont" / "two"
HARNESS = ROOT / "tests" / "java" / "lihont" / "two" / "FlagsTest.java"
HOOK = "Llihont/two/LihonTFlags;"
ALL_FLAGS = {**profile_features.PROFILE_FLAGS, **profile_features.REPOST_FLAGS}
INT_READ = """\
.class public Lcom/example/Reader;
.super Ljava/lang/Object;
.method public static read()I
.registers 3
const-string v0, "profile_left_align"
const/4 v1, 0x0
invoke-static {v0, v1}, Lcom/bytedance/ies/abmock/SettingsManager;->LIZ(Ljava/lang/String;I)I
move-result v2
return v2
.end method
"""
BOOL_READ = INT_READ.replace("(Ljava/lang/String;I)I", "(Ljava/lang/String;Z)Z").replace(
"read()I", "read()Z")
PARAMETER_READ = """\
.class public Lcom/example/Reader;
.super Ljava/lang/Object;
.method public run(I)I
.registers 5
const-string v0, "repost_support_digg"
invoke-static {v0, p1}, LX/0abc;->LIZ(Ljava/lang/String;I)I
move-result p1
return p1
.end method
"""
LEGACY = """\
.class public Lcom/example/Reader;
.super Ljava/lang/Object;
.method public static read()I
.registers 3
const-string v0, "profile_left_align"
const/4 v1, 0x0
invoke-static {v0, v1}, Lcom/bytedance/ies/abmock/SettingsManager;->LIZ(Ljava/lang/String;I)I
move-result v2
# lihont feature flag: profile_left_align
const/16 v2, 0x1
return v2
.end method
"""
class RewriteTest(unittest.TestCase):
def test_int_read_goes_through_the_hook(self):
text, hits = profile_features.rewrite_smali(INT_READ, ALL_FLAGS)
self.assertEqual(hits, {"profile_left_align": 1})
self.assertIn(
" move-result v2\n"
" # lihont feature flag: profile_left_align\n"
" invoke-static/range {v2 .. v2}, %s->profile_left_align(I)I\n"
" move-result v2\n" % HOOK, text)
self.assertNotIn("const/16", text)
self.assertIn("SettingsManager;->LIZ(Ljava/lang/String;I)I", text)
self.assertIn(" return v2\n", text)
def test_boolean_read_uses_the_boolean_overload(self):
text, hits = profile_features.rewrite_smali(BOOL_READ, ALL_FLAGS)
self.assertEqual(hits, {"profile_left_align": 1})
self.assertIn("%s->profile_left_align(Z)Z" % HOOK, text)
def test_parameter_register_is_kept(self):
text, hits = profile_features.rewrite_smali(PARAMETER_READ, ALL_FLAGS)
self.assertEqual(hits, {"repost_support_digg": 1})
self.assertIn("invoke-static/range {p1 .. p1}, %s->repost_support_digg(I)I" % HOOK, text)
self.assertIn(" move-result p1\n\n return p1\n", text)
def test_unknown_keys_are_left_alone(self):
text, hits = profile_features.rewrite_smali(
INT_READ.replace("profile_left_align", "some_other_flag"), ALL_FLAGS)
self.assertEqual(hits, {})
self.assertNotIn("LihonTFlags", text)
def test_rewriting_twice_changes_nothing(self):
once, _ = profile_features.rewrite_smali(INT_READ, ALL_FLAGS)
twice, hits = profile_features.rewrite_smali(once, ALL_FLAGS)
self.assertEqual(hits, {})
self.assertEqual(once, twice)
def test_old_const_override_is_replaced(self):
text, hits = profile_features.rewrite_smali(LEGACY, ALL_FLAGS)
self.assertEqual(hits, {"profile_left_align": 1})
self.assertNotIn("const/16", text)
self.assertEqual(text.count("# lihont feature flag"), 1)
self.assertIn("(I)I\n move-result v2\n\n return v2\n", text)
def test_marker_without_a_known_block_does_not_eat_code(self):
stray = INT_READ.replace(
" move-result v2\n",
" move-result v2\n # lihont feature flag: profile_left_align\n")
text, hits = profile_features.rewrite_smali(stray, ALL_FLAGS)
self.assertEqual(hits, {"profile_left_align": 1})
self.assertIn(" return v2\n", text)
class RuntimeTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.javac = shutil.which("javac")
cls.java = shutil.which("java")
if not cls.javac or not cls.java:
raise RuntimeError("Flag runtime tests require a JDK with java and javac")
cls.android_jar = ROOT / "tools" / "android.jar"
if not cls.android_jar.is_file():
raise RuntimeError("tools/android.jar must be available")
cls.temporary = tempfile.TemporaryDirectory(prefix="lihont-flags-tests-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.classes = Path(cls.temporary.name) / "classes"
cls.classes.mkdir()
cls.environment = dict(os.environ)
cls.environment.pop("JAVA_TOOL_OPTIONS", None)
settings = SRC / "settings"
cls.run_command([
cls.javac, "--release", "17", "-encoding", "UTF-8",
"-cp", str(cls.android_jar), "-d", str(cls.classes),
str(settings / "ModSettings.java"), str(settings / "SettingsCodec.java"),
str(settings / "SettingsRepository.java"), str(settings / "AndroidSettingsStorage.java"),
str(settings / "SettingsHost.java"), str(SRC / "LihonTFlags.java"), str(HARNESS),
])
@classmethod
def run_command(cls, command):
result = subprocess.run(
command, capture_output=True, text=True, encoding="utf-8",
env=cls.environment, timeout=60,
)
if result.returncode:
raise AssertionError(
f"Command failed with exit {result.returncode}: {command[0]}\n"
+ result.stdout + result.stderr
)
return result
@staticmethod
def entries():
return (["banners:%s:%d" % item for item in profile_features.PROFILE_FLAGS.items()]
+ ["reposts:%s:%d" % item for item in profile_features.REPOST_FLAGS.items()])
def run_scenario(self, name):
result = self.run_command([
self.java, "-cp", str(self.classes) + os.pathsep + str(self.android_jar),
"lihont.two.FlagsTest", name,
] + self.entries())
return result.stdout.strip()
def test_each_switch_controls_only_its_own_flags(self):
self.assertEqual(self.run_scenario("matrix"), "PASS matrix")
def test_saved_change_applies_to_the_next_process(self):
self.assertEqual(self.run_scenario("snapshot"), "PASS snapshot")
def test_defaults_apply_while_settings_cannot_be_read(self):
self.assertEqual(self.run_scenario("unavailable"), "PASS unavailable")
def test_runtime_offers_exactly_what_the_patcher_calls(self):
offered = set(self.run_scenario("surface").splitlines())
called = {target.split("->", 1)[1] for target in profile_features.runtime_targets()}
self.assertEqual(offered, called)
if __name__ == "__main__":
unittest.main()

98
tests/test_region.py Normal file
View File

@@ -0,0 +1,98 @@
import unittest
from lihont import region
def wrap(body: str, header: str) -> str:
return header + "\n" + body + ".end method\n"
BPEA_HEADER = ".method public static LIZIZ(Landroid/telephony/TelephonyManager;Lcom/bytedance/bpea/basics/Cert;)Ljava/lang/String;"
BPEA_BODY = """
.registers 6
const-string v1, "TelephonyManager_getNetworkCountryIso"
const v0, 0x18a39
invoke-static {v0, v1, p1}, LX/1DHU;->LIZ(ILjava/lang/String;Lcom/bytedance/bpea/basics/Cert;)LX/0kmX;
return-object v0
"""
DELEGATE_HEADER = ".method public static LIZJ(Landroid/telephony/TelephonyManager;)Ljava/lang/String;"
DELEGATE_BODY = """
.registers 3
invoke-static {p0, v0}, LX/1DHU;->LIZIZ(Landroid/telephony/TelephonyManager;Lcom/bytedance/bpea/basics/Cert;)Ljava/lang/String;
move-result-object v0
return-object v0
"""
MONITOR_HEADER = ".method public static LLIIIL(Landroid/telephony/TelephonyManager;LX/0yw4;)Ljava/lang/String;"
MONITOR_BODY = """
.registers 19
const/16 v4, 0x2866
const-string v14, "getSimCountryIso"
invoke-virtual {v9}, Landroid/telephony/TelephonyManager;->getSimCountryIso()Ljava/lang/String;
return-object v7
"""
LAMBDA_HEADER = ".class public final LX/0kmK;\n.super LX/0MXs;\n.method public final invoke(Ljava/lang/Object;)Ljava/lang/Object;"
LAMBDA_BODY = """
.registers 3
iget-object v0, p0, LX/0kmK;->LL:Landroid/telephony/TelephonyManager;
invoke-virtual {v0}, Landroid/telephony/TelephonyManager;->getNetworkOperator()Ljava/lang/String;
move-result-object v0
return-object v0
"""
class RegionPatchTest(unittest.TestCase):
def test_bpea_wrapper_is_replaced_with_constant(self):
text = wrap(BPEA_BODY, BPEA_HEADER)
changed, hits = region.rewrite(text, "LX/1DHU;")
self.assertEqual(hits, {"bpea_LIZIZ": 1})
self.assertIn("LihonTRegion;->iso()", changed)
self.assertNotIn("TelephonyManager_getNetworkCountryIso", changed)
self.assertEqual(region.rewrite(changed, "LX/1DHU;"), (changed, {}))
def test_bpea_delegate_is_replaced(self):
text = wrap(DELEGATE_BODY, DELEGATE_HEADER)
changed, hits = region.rewrite(text, "LX/1DHU;")
self.assertEqual(hits, {"bpea_delegate_LIZJ": 1})
self.assertIn("LihonTRegion;->iso()", changed)
def test_bpea_wrapper_drift_raises(self):
text = wrap(BPEA_BODY.replace("getNetworkCountryIso", "getSomethingElse").replace("TelephonyManager_", "TelephonyManager_getSomething"), BPEA_HEADER)
with self.assertRaises(ValueError):
region.rewrite(text, "LX/1DHU;")
def test_monitor_body_replaced_by_marker(self):
text = wrap(MONITOR_BODY, MONITOR_HEADER)
changed, hits = region.rewrite_monitor(text)
self.assertEqual(hits, {"monitor_LLIIIL": 1})
self.assertIn("LihonTRegion;->iso()", changed)
def test_monitor_other_methods_untouched(self):
text = wrap(MONITOR_BODY.replace("LLIIIL", "LLIIJL").replace("0x2866", "0x2999"), MONITOR_HEADER.replace("LLIIIL", "LLIIJL"))
changed, hits = region.rewrite_monitor(text)
self.assertEqual(hits, {})
self.assertEqual(changed, text)
def test_lambda_is_replaced(self):
text = LAMBDA_HEADER + "\n" + LAMBDA_BODY + ".end method\n"
changed, hits = region.rewrite_lambda(text, "LX/0kmK;")
self.assertEqual(hits, {"lambda_LX/0kmK;": 1})
self.assertIn("LihonTRegion;->operator()", changed)
self.assertNotIn("getNetworkOperator", changed)
def test_expected_hits_cover_every_audited_point(self):
expected = region.expected_hits()
self.assertEqual(len(expected), 6 + 5 + 5 + 2)
self.assertIn("bpea_LJIIJJI", expected)
self.assertIn("monitor_LLIIJI", expected)
self.assertIn("lambda_LX/0kmL;", expected)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,35 @@
"""The whole LihonT runtime (settings screen, hooks, region, flags) compiles against Android.
Nothing here is executed: screens need a device. This catches a broken edit in the UI code
before a 350 MB build does.
"""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class RuntimeCompileTest(unittest.TestCase):
def test_runtime_sources_compile_against_android_jar(self):
javac = shutil.which("javac")
android_jar = ROOT / "tools" / "android.jar"
if not javac:
raise RuntimeError("This test requires a JDK with javac")
self.assertTrue(android_jar.is_file(), "tools/android.jar must be available")
sources = sorted(str(path) for path in (ROOT / "src" / "lihont" / "two").rglob("*.java"))
self.assertTrue(sources)
with tempfile.TemporaryDirectory(prefix="lihont-runtime-compile-") as out:
result = subprocess.run(
[javac, "--release", "17", "-nowarn", "-Xlint:-options", "-encoding", "UTF-8",
"-cp", str(android_jar), "-d", out] + sources,
capture_output=True, text=True, encoding="utf-8", timeout=120,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,108 @@
"""Run the platform-independent settings contract on a real JVM.
The Android file adapter is compiled against android.jar, not executed under
desktop stubs. Device/storage integration belongs to the later APK stage.
"""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
PACKAGE = ROOT / "src" / "lihont" / "two" / "settings"
HARNESS = ROOT / "tests" / "java" / "lihont" / "two" / "settings" / "SettingsModelTest.java"
class SettingsModelTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.javac = shutil.which("javac")
cls.java = shutil.which("java")
if not cls.javac or not cls.java:
raise RuntimeError("Settings model tests require a JDK with java and javac")
cls.temporary = tempfile.TemporaryDirectory(prefix="lihont-settings-tests-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.classes = Path(cls.temporary.name) / "classes"
cls.classes.mkdir()
cls.environment = dict(os.environ)
cls.environment.pop("JAVA_TOOL_OPTIONS", None)
cls.run_command([
cls.javac, "--release", "17", "-encoding", "UTF-8", "-d", str(cls.classes),
str(PACKAGE / "ModSettings.java"),
str(PACKAGE / "SettingsCodec.java"),
str(PACKAGE / "SettingsRepository.java"),
str(HARNESS),
])
@classmethod
def run_command(cls, command):
result = subprocess.run(
command, capture_output=True, text=True, encoding="utf-8",
env=cls.environment, timeout=60,
)
if result.returncode:
raise AssertionError(
f"Command failed with exit {result.returncode}: {command[0]}\n"
+ result.stdout + result.stderr
)
return result
def run_scenario(self, name):
result = self.run_command([
self.java, "-cp", str(self.classes),
"lihont.two.settings.SettingsModelTest", name,
])
self.assertEqual(result.stdout.strip(), "PASS " + name)
def test_defaults_and_region_warning(self):
self.run_scenario("defaults")
def test_all_iso_countries_roundtrip(self):
self.run_scenario("countries")
def test_typed_inputs_reject_invalid_values(self):
self.run_scenario("validation")
def test_restart_policy(self):
self.run_scenario("restart")
def test_valid_json_and_portable_roundtrip(self):
self.run_scenario("codec")
def test_invalid_documents_are_rejected(self):
self.run_scenario("invalid")
def test_settings_persist_across_repository_instances(self):
self.run_scenario("persistence")
def test_active_snapshot_and_noop_write(self):
self.run_scenario("snapshot")
def test_reset_restores_defaults_and_persists_them(self):
self.run_scenario("reset")
def test_invalid_imports_preserve_memory_and_disk(self):
self.run_scenario("atomicImport")
def test_io_failures_are_visible_and_preserve_previous_settings(self):
self.run_scenario("ioFailures")
def test_corrupt_or_future_stored_data_is_not_overwritten(self):
self.run_scenario("corruptStorage")
def test_android_adapter_compiles_against_android_api(self):
android_jar = ROOT / "tools" / "android.jar"
self.assertTrue(android_jar.is_file(), "tools/android.jar must be available")
self.run_command([
self.javac, "--release", "17", "-encoding", "UTF-8",
"-cp", str(android_jar) + os.pathsep + str(self.classes),
"-d", str(self.classes), str(PACKAGE / "AndroidSettingsStorage.java"),
])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,152 @@
import unittest
from lihont import settings_patch
DVK_SAMPLE = """
.class public final LX/1DvK;
.super Ljava/lang/Object;
.method public static final LIZ(LX/0CWi;ZZLX/0LYA;I)V
.registers 40
move-object/from16 v5, p0
invoke-virtual {v6}, LX/0MNM;->LJJJJZ()LX/0MN8;
move-result-object v1
if-eqz v1, :cond_5a
new-instance v0, Lkotlin/jvm/internal/AwS2S0121000_35;
.line 83886157
.line 83886158
const/16 v11, 0x23
.line 83886159
.line 83886160
move-object v6, v0
.line 83886161
move-object v7, v5
.line 83886162
move v8, v4
.line 83886163
move v9, v3
.line 83886164
move v10, v2
.line 83886165
invoke-direct/range {v6 .. v11}, Lkotlin/jvm/internal/AwS2S0121000_35;-><init>(LX/0CWi;ZZII)V
iput-object v0, v1, LX/0MN8;->LIZLLL:Lkotlin/jvm/functions/Function2;
:cond_5a
return-void
iget-object v0, v5, LX/0CWi;->LLJJIJIIJIL:Ljava/lang/Integer;
.line 83886180
.line 83886181
const/4 v12, 0x0
.line 83886182
if-eqz v0, :cond_e5
.line 83886183
.line 83886184
invoke-virtual {v0}, Ljava/lang/Integer;->intValue()I
.line 83886185
.line 83886186
.line 83886187
move-result v0
.line 83886188
:goto_6c
invoke-static {v0, v6}, LX/0KwU;->LIZIZ(ILX/0LYA;)Ljava/lang/String;
.line 83886189
.line 83886190
.line 83886191
move-result-object v11
.line 83886192
const/4 v10, 0x0
.line 83886193
iget-object v0, v5, LX/0CWi;->LLJJIJI:LX/0LGU;
.line 83886194
.line 83886195
if-nez v0, :cond_77
.line 83886196
.line 83886197
sget-object v0, LX/0L1b;->LIZ:LX/0LGU;
.line 83886198
.line 83886199
:cond_77
invoke-static {v0, v6}, LX/0Lis;->LJFF(LX/0LGU;LX/0LYA;)LX/0MIo;
:cond_e5
const/4 v0, 0x0
.line 83886310
goto :goto_6c
.end method
"""
DUR_SAMPLE = """
.class public final LX/1DuR;
.super Ljava/lang/Object;
.method public static final LJIIIZ(LX/1Dgo;ZZLX/0LYA;I)V
const/4 v14, 0x4
.line 83886338
move-object v12, v6
.line 83886339
invoke-static/range {v9 .. v14}, LX/1Dvf;->LIZIZ(ZZLcom/ss/android/ugc/aweme/setting/ui/rvmpcompose/group/top/cell/ManagePostsVM;LX/0LYA;II)V
.end method
"""
LDM_SAMPLE = """
.class public final LX/0LDm;
.super Ljava/lang/Object;
.method public static final LIZ(LX/0M1d;Ljava/lang/String;ILjava/lang/String;ZZLX/0LDk;ZLX/0LRh;ZLX/0LDv;ZZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;LX/1Dw4;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function2;LX/0LYA;IIII)V
.registers 78
.prologue
.line 503316480
move-object/from16 p20, p20
return-void
.end method
"""
class SettingsPatchTest(unittest.TestCase):
def test_rewrite_1dvk_injects_lihont_method(self):
patched = settings_patch.rewrite_1dvk(DVK_SAMPLE)
self.assertIn(".method public static final lihont(", patched)
self.assertIn("SettingsCell;->title()Ljava/lang/String;", patched)
self.assertIn("LX/0LB8;->LLLLIIL:LX/0LGU;", patched)
self.assertIn("SettingsCell;->again(", patched)
self.assertNotIn(":cond_e5", patched.split("lihont(")[1])
with self.assertRaises(ValueError):
settings_patch.rewrite_1dvk(patched)
def test_rewrite_1dur_injects_draw_call(self):
patched = settings_patch.rewrite_1dur(DUR_SAMPLE)
self.assertIn("invoke-static {v9, v6}, Llihont/two/settings/SettingsCell;->draw(ZLjava/lang/Object;)Z", patched)
self.assertIn("move-result v9", patched)
with self.assertRaises(ValueError):
settings_patch.rewrite_1dur(patched)
def test_rewrite_0ldm_injects_transform_icon(self):
patched = settings_patch.rewrite_0ldm(LDM_SAMPLE)
self.assertIn("SettingsCell;->transformIcon(Ljava/lang/Object;Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object;", patched)
self.assertIn("move-object/from16 p8, v0", patched)
with self.assertRaises(ValueError):
settings_patch.rewrite_0ldm(patched)

259
tests/test_theme_patch.py Normal file
View File

@@ -0,0 +1,259 @@
import unittest
from lihont import theme_patch
ACCESSOR = """\
.class public final LX/0LAr;
.super Ljava/lang/Object;
.source "SourceFile"
.method public static LIZ(LX/0LYA;)LX/0LFj;
.registers 2
sget-object v0, LX/0LAs;->LIZ:LX/0MO7;
invoke-interface {p0, v0}, LX/0LYA;->LJJIZ(LX/0MNv;)Ljava/lang/Object;
move-result-object v0
check-cast v0, LX/0LFj;
return-object v0
.end method
.method public static LIZIZ(LX/0LYA;)LX/1MiX;
.registers 2
.prologue
sget-object v0, LX/1MiY;->LIZ:LX/0MO7;
invoke-interface {p0, v0}, LX/0LYA;->LJJIZ(LX/0MNv;)Ljava/lang/Object;
move-result-object v0
check-cast v0, LX/1MiX;
return-object v0
.end method
"""
LABEL = """.class public final LX/0Lis;
.super Ljava/lang/Object;
.method public static final LIZIZ(LX/0DMR;LX/0LYA;I)V
.registers 20
invoke-static {v0}, LX/0LAr;->LIZIZ(LX/0LYA;)LX/1MiX;
move-result-object v1
iget-wide v14, v1, LX/1MiX;->d4:J
return-void
.end method
.method public static final LIZJ(Ljava/lang/String;ZZZLkotlin/jvm/functions/Function0;LX/0LYA;II)V
.registers 40
invoke-static {v0}, LX/0LAr;->LIZIZ(LX/0LYA;)LX/1MiX;
move-result-object v11
iget-wide v12, v11, LX/1MiX;->d4:J
invoke-static {v0}, LX/0LAr;->LJ(LX/0LYA;)LX/0LAb;
.line 83886186
move-result-object v3
.line 83886187
iget-wide v8, v3, LX/1MiX;->d4:J
:goto_6d
return-void
.end method
"""
ROW = """\
.class public final LX/0LDf;
.super Ljava/lang/Object;
.method public final invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
.registers 52
invoke-interface {v0}, LX/0LYA;->LJ()V
.line 33555008
.line 33555009
.line 33555010
move-object/from16 v9, v17
.line 33555011
.line 33555012
invoke-static {v9, v1, v13}, LX/0M8m;->LIZIZ(LX/0M1d;ZLkotlin/jvm/functions/Function1;)LX/0M1d;
.line 33555013
move-result-object v1
invoke-interface {v4, v0}, LX/0LDi;->LJIIJJI(LX/0LYA;)J
move-result-wide v4
.line 33555175
invoke-static {v8, v8, v7, v7}, LX/0Kzn;->LIZJ(FFFF)LX/0LQf;
.line 33555176
.line 33555177
move-result-object v7
move-object/from16 v6, v17
return-object v1
.end method
"""
CARD = """.class public final LX/0Lk9;
.super Ljava/lang/Object;
.method public static final LIZ(LX/0CX2;ZZLcom/ss/android/ugc/aweme/setting/ui/rvmpcompose/group/content/cell/LanguageVM;LX/0LYA;I)V
.registers 40
invoke-static {v15}, LX/0Kzn;->LIZIZ(F)LX/0LQf;
move-result-object v15
invoke-static {v11, v0, v1, v15}, LX/0L5k;->LIZIZ(LX/0M1d;JLX/0LQx;)LX/0M1d;
.line 33555200
.line 33555201
move-result-object v0
sget-object v1, LX/0L0L;->LIZ:LX/0L0J;
return-void
.end method
"""
class ColoursAccessorTest(unittest.TestCase):
def test_hook_sits_between_the_read_and_the_cast(self):
text = theme_patch.rewrite_colors_accessor(ACCESSOR)
self.assertIn(
" move-result-object v0\n\n"
" invoke-static {v0, p0}, %s\n\n"
" move-result-object v0\n\n"
" check-cast v0, LX/1MiX;\n" % theme_patch.HOOK, text)
def test_other_methods_are_untouched(self):
text = theme_patch.rewrite_colors_accessor(ACCESSOR)
self.assertEqual(text.count("NativeTheme"), 1)
self.assertIn("check-cast v0, LX/0LFj;\n\n return-object v0\n", text)
def test_a_second_pass_is_refused(self):
once = theme_patch.rewrite_colors_accessor(ACCESSOR)
with self.assertRaises(ValueError):
theme_patch.rewrite_colors_accessor(once)
def test_a_missing_method_is_refused(self):
with self.assertRaises(ValueError):
theme_patch.rewrite_colors_accessor(ACCESSOR.replace("LIZIZ", "LIZZZ"))
def test_an_unexpected_register_layout_is_refused(self):
with self.assertRaises(ValueError):
theme_patch.rewrite_colors_accessor(
ACCESSOR.replace(".registers 2\n .prologue", ".registers 3\n .prologue"))
def test_runtime_target_matches_the_hook(self):
self.assertEqual(theme_patch.runtime_targets(), [theme_patch.HOOK])
class SectionLabelTest(unittest.TestCase):
def test_both_reads_in_the_group_title_are_rewritten(self):
text = theme_patch.rewrite_section_label(LABEL)
self.assertIn(
" invoke-static/range {v11 .. v11}, %s\n\n move-result-wide v12\n"
% theme_patch.SECTION_HOOK, text)
self.assertIn(
" invoke-static/range {v3 .. v3}, %s\n\n move-result-wide v8\n"
% theme_patch.SECTION_HOOK, text)
self.assertEqual(text.count("sectionLabel"), 2)
# The other composable keeps reading the token itself.
self.assertIn("iget-wide v14, v1, LX/1MiX;->d4:J", text)
def test_a_second_pass_is_refused(self):
once = theme_patch.rewrite_section_label(LABEL)
with self.assertRaises(ValueError):
theme_patch.rewrite_section_label(once)
def test_a_missing_composable_is_refused(self):
with self.assertRaises(ValueError):
theme_patch.rewrite_section_label(
LABEL.replace(".method public static final LIZJ(", ".method public static final LIZZ("))
def test_a_title_with_a_different_number_of_reads_is_refused(self):
with self.assertRaises(ValueError):
theme_patch.rewrite_section_label(
LABEL.replace("v8, v3, LX/1MiX;->d4:J", "v8, v3, LX/1MiX;->a4:J"))
def test_section_target_is_the_hook(self):
self.assertEqual(theme_patch.section_targets(), [theme_patch.SECTION_HOOK])
class RowLambdaTest(unittest.TestCase):
def test_modifier_and_shape_are_routed_through_the_hooks(self):
text = theme_patch.rewrite_row_lambda(ROW)
self.assertIn(
" move-object/from16 v9, v17\n"
" invoke-static {v9, v0}, %s\n\n"
" move-result-object v9\n\n"
" check-cast v9, LX/0M1d;\n" % theme_patch.ROW_MODIFIER_HOOK, text)
self.assertIn("invoke-static {v8, v8, v7, v7, v0}, %s\n" % theme_patch.ROW_SHAPE_HOOK, text)
self.assertIn(" move-result-object v7\n\n check-cast v7, LX/0LQf;\n", text)
self.assertNotIn("LX/0Kzn;->LIZJ", text)
# The original semantics call still follows the padded modifier.
self.assertIn("invoke-static {v9, v1, v13}, LX/0M8m;->LIZIZ", text)
def test_a_second_pass_is_refused(self):
once = theme_patch.rewrite_row_lambda(ROW)
with self.assertRaises(ValueError):
theme_patch.rewrite_row_lambda(once)
def test_a_missing_shape_call_is_refused(self):
with self.assertRaises(ValueError):
theme_patch.rewrite_row_lambda(ROW.replace("LIZJ(FFFF)", "LIZJ(FF)"))
def test_a_missing_modifier_start_is_refused(self):
with self.assertRaises(ValueError):
theme_patch.rewrite_row_lambda(
ROW.replace("move-object/from16 v9, v17", "move-object/from16 v9, v18"))
def test_row_targets_are_the_hooks(self):
self.assertEqual(theme_patch.row_targets(),
[theme_patch.ROW_MODIFIER_HOOK, theme_patch.ROW_SHAPE_HOOK])
class LanguageCardTest(unittest.TestCase):
def test_the_card_background_call_goes_through_the_hook(self):
text = theme_patch.rewrite_language_card(CARD)
self.assertIn(" invoke-static {v11, v0, v1, v15, v3}, %s\n" % theme_patch.CARD_HOOK, text)
self.assertIn(" move-result-object v0\n\n check-cast v0, LX/0M1d;\n", text)
self.assertNotIn("LX/0L5k;->LIZIZ", text)
# The rounded shape built just before it is TikTok's own call and stays.
self.assertIn("LX/0Kzn;->LIZIZ(F)LX/0LQf;", text)
def test_a_second_pass_is_refused(self):
once = theme_patch.rewrite_language_card(CARD)
with self.assertRaises(ValueError):
theme_patch.rewrite_language_card(once)
def test_a_missing_background_call_is_refused(self):
with self.assertRaises(ValueError):
theme_patch.rewrite_language_card(CARD.replace("v11, v0, v1, v15}", "v10, v0, v1, v15}"))
def test_card_target_is_the_hook(self):
self.assertEqual(theme_patch.card_targets(), [theme_patch.CARD_HOOK])
if __name__ == "__main__":
unittest.main()