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:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""LihonT 2 Python test package."""
|
||||
116
tests/java/lihont/two/FlagsTest.java
Normal file
116
tests/java/lihont/two/FlagsTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
183
tests/java/lihont/two/settings/IconRenderTest.java
Normal file
183
tests/java/lihont/two/settings/IconRenderTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
129
tests/java/lihont/two/settings/IconStoreTest.java
Normal file
129
tests/java/lihont/two/settings/IconStoreTest.java
Normal 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();
|
||||
}
|
||||
}
|
||||
369
tests/java/lihont/two/settings/SettingsModelTest.java
Normal file
369
tests/java/lihont/two/settings/SettingsModelTest.java
Normal 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
172
tests/test_app_icons.py
Normal 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
80
tests/test_badge_patch.py
Normal 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
71
tests/test_bar_patch.py
Normal 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()
|
||||
67
tests/test_custom_icons.py
Normal file
67
tests/test_custom_icons.py
Normal 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
210
tests/test_feed_patch.py
Normal 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
70
tests/test_icons.py
Normal 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()
|
||||
46
tests/test_manifest_activity.py
Normal file
46
tests/test_manifest_activity.py
Normal 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
147
tests/test_media_patch.py
Normal 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()
|
||||
73
tests/test_members_server.py
Normal file
73
tests/test_members_server.py
Normal 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
201
tests/test_portal.py
Normal 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("<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
203
tests/test_profile_flags.py
Normal 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
98
tests/test_region.py
Normal 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()
|
||||
35
tests/test_runtime_compiles.py
Normal file
35
tests/test_runtime_compiles.py
Normal 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()
|
||||
108
tests/test_settings_model.py
Normal file
108
tests/test_settings_model.py
Normal 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()
|
||||
152
tests/test_settings_patch.py
Normal file
152
tests/test_settings_patch.py
Normal 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
259
tests/test_theme_patch.py
Normal 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()
|
||||
Reference in New Issue
Block a user