"""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()