From 3ed66c82931ed81186bb262fa61a510414b6a00b Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Fri, 15 May 2026 17:08:28 +0200 Subject: [PATCH 1/8] Generate neutral stealth Android icons --- Makefile | 9 + android/app/build.gradle | 21 +++ android/app/src/main/AndroidManifest.xml | 4 +- docs/stealth-android-icons.md | 45 +++++ scripts/stealth/generate_android_icons.py | 163 ++++++++++++++++++ .../stealth/generate_android_icons_test.py | 58 +++++++ 6 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 docs/stealth-android-icons.md create mode 100644 scripts/stealth/generate_android_icons.py create mode 100644 scripts/stealth/generate_android_icons_test.py diff --git a/Makefile b/Makefile index 1c964e7dbd..3932f94a2c 100644 --- a/Makefile +++ b/Makefile @@ -173,6 +173,9 @@ get-command = $(shell which="$$(which $(1) 2> /dev/null)" && if [[ ! -z "$$which APPDMG := $(call get-command,appdmg) DART_DEFINES := --dart-define=BUILD_TYPE=$(BUILD_TYPE) $(if $(VERSION),--dart-define=VERSION=$(VERSION),) +STEALTH_ICON_SEED ?= +STEALTH_ICON_RES_DIR ?= android/app/build/generated/stealth-icons/res +export STEALTH_ICON_SEED INSTALLER_RESOURCES := installer-resources @@ -183,6 +186,12 @@ guard-%: check-gomobile: @command -v gomobile >/dev/null || (echo "gomobile not found. Run 'make install-android-deps'" && exit 1) +.PHONY: stealth-android-icons +stealth-android-icons: guard-STEALTH_ICON_SEED + python3 scripts/stealth/generate_android_icons.py \ + --seed "$(STEALTH_ICON_SEED)" \ + --output-res-dir "$(STEALTH_ICON_RES_DIR)" + .PHONY: require-appdmg require-appdmg: diff --git a/android/app/build.gradle b/android/app/build.gradle index 57bb8ad393..2f6357dbdf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -41,6 +41,10 @@ def generateSideloadManifest = tasks.register("generateSideloadManifest") { def start = new Date(2015, 1, 1).getTime() def now = System.currentTimeMillis() def code = (int)((now - start) / 1000) +def stealthIconSeed = (findProperty("stealthIconSeed") ?: System.getenv("STEALTH_ICON_SEED"))?.toString()?.trim() +def generatedStealthIconResDir = layout.buildDirectory.dir("generated/stealth-icons/res") +def launcherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher" : "@mipmap/ic_launcher" +def roundLauncherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher_round" : "@mipmap/ic_launcher_round" android { namespace = "org.getlantern.lantern" @@ -57,6 +61,7 @@ android { } jniLibs.srcDirs = ['libs'] jniLibs.srcDirs += ['src/main/jniLibs'] + res.srcDirs += [generatedStealthIconResDir.get().asFile] } } @@ -115,6 +120,10 @@ android { versionCode = code versionName = flutter.versionName buildConfigField "String", "SIDELOAD_SIGNING_CERTIFICATE_SHA256", "\"${sideloadSigningCertificateSha256}\"" + manifestPlaceholders += [ + launcherIcon : launcherIconResource, + roundLauncherIcon: roundLauncherIconResource, + ] ndk { // arm64 only (APK + AAB) — armeabi-v7a dropped, see the comment @@ -175,6 +184,18 @@ if (sideloadUpdates) { .configureEach { dependsOn(generateSideloadManifest) } } +tasks.register("generateStealthAndroidIcons", Exec) { + onlyIf { stealthIconSeed } + inputs.property("stealthIconSeed", stealthIconSeed ?: "") + outputs.dir(generatedStealthIconResDir) + commandLine "python3", + "${rootProject.projectDir.parentFile}/scripts/stealth/generate_android_icons.py", + "--seed", stealthIconSeed ?: "", + "--output-res-dir", generatedStealthIconResDir.get().asFile.absolutePath +} + +preBuild.dependsOn("generateStealthAndroidIcons") + flutter { source = "../.." } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1948926bc3..cf2e40f5a7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,10 +18,10 @@ + android:roundIcon="${roundLauncherIcon}"> bytes: + return hashlib.sha256(seed.encode("utf-8")).digest() + + +def color_from_hash(data: bytes, offset: int, sat: float, light: float) -> str: + hue = int.from_bytes(data[offset : offset + 2], "big") / 65535 + red, green, blue = colorsys.hls_to_rgb(hue, light, sat) + return "#{:02X}{:02X}{:02X}".format( + round(red * 255), + round(green * 255), + round(blue * 255), + ) + + +def shape_paths(data: bytes, primary: str, secondary: str, accent: str) -> str: + template = data[4] % 4 + if template == 0: + return f""" + + + +""" + if template == 1: + return f""" + + + +""" + if template == 2: + return f""" + + + +""" + return f""" + + + +""" + + +def write_text(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value.strip() + "\n", encoding="utf-8") + + +def generate(seed: str, output_res_dir: Path) -> dict[str, str]: + data = hash_bytes(seed) + background = color_from_hash(data, 0, 0.46, 0.30) + primary = color_from_hash(data, 6, 0.42, 0.74) + secondary = color_from_hash(data, 10, 0.36, 0.58) + accent = color_from_hash(data, 14, 0.56, 0.82) + + values_dir = output_res_dir / "values" + drawable_dir = output_res_dir / "drawable" + mipmap_dir = output_res_dir / "mipmap-anydpi-v26" + + write_text( + values_dir / "stealth_icon_colors.xml", + f""" + + {background} + +""", + ) + + write_text( + drawable_dir / "stealth_launcher_foreground.xml", + f""" + +{shape_paths(data, primary, secondary, accent)} + +""", + ) + + write_text( + drawable_dir / "stealth_notification_icon.xml", + """ + + + + +""", + ) + + adaptive_icon = """ + + + + + +""" + write_text(mipmap_dir / "stealth_ic_launcher.xml", adaptive_icon) + write_text(mipmap_dir / "stealth_ic_launcher_round.xml", adaptive_icon) + + metadata = { + "seedSha256": hashlib.sha256(seed.encode("utf-8")).hexdigest(), + "background": background, + "primary": primary, + "secondary": secondary, + "accent": accent, + "launcherIcon": "@mipmap/stealth_ic_launcher", + "roundLauncherIcon": "@mipmap/stealth_ic_launcher_round", + "notificationIcon": "@drawable/stealth_notification_icon", + } + write_text( + output_res_dir / "stealth-icon-metadata.json", + json.dumps(metadata, indent=2, sort_keys=True), + ) + return metadata + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--seed", + default="", + help="private per-variant seed; random if omitted", + ) + parser.add_argument( + "--output-res-dir", + type=Path, + required=True, + help="Android generated resource directory", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + seed = args.seed or secrets.token_urlsafe(24) + metadata = generate(seed, args.output_res_dir) + print( + "Generated stealth Android icons:", + args.output_res_dir, + metadata["seedSha256"], + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py new file mode 100644 index 0000000000..3f17efe765 --- /dev/null +++ b/scripts/stealth/generate_android_icons_test.py @@ -0,0 +1,58 @@ +import tempfile +import unittest +from pathlib import Path + +import generate_android_icons + + +class GenerateAndroidIconsTest(unittest.TestCase): + def test_generates_expected_resource_files(self): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "res" + + metadata = generate_android_icons.generate("variant-a", out) + + self.assertEqual(metadata["launcherIcon"], "@mipmap/stealth_ic_launcher") + self.assertTrue((out / "values/stealth_icon_colors.xml").exists()) + self.assertTrue((out / "drawable/stealth_launcher_foreground.xml").exists()) + self.assertTrue((out / "drawable/stealth_notification_icon.xml").exists()) + self.assertTrue((out / "mipmap-anydpi-v26/stealth_ic_launcher.xml").exists()) + self.assertTrue( + (out / "mipmap-anydpi-v26/stealth_ic_launcher_round.xml").exists() + ) + self.assertTrue((out / "stealth-icon-metadata.json").exists()) + + def test_generation_is_deterministic_for_same_seed(self): + with tempfile.TemporaryDirectory() as tmp: + first = Path(tmp) / "first" + second = Path(tmp) / "second" + + first_metadata = generate_android_icons.generate("variant-a", first) + second_metadata = generate_android_icons.generate("variant-a", second) + + self.assertEqual(first_metadata, second_metadata) + self.assertEqual( + (first / "drawable/stealth_launcher_foreground.xml").read_text(), + (second / "drawable/stealth_launcher_foreground.xml").read_text(), + ) + + def test_generation_changes_by_seed(self): + with tempfile.TemporaryDirectory() as tmp: + first = Path(tmp) / "first" + second = Path(tmp) / "second" + + first_metadata = generate_android_icons.generate("variant-a", first) + second_metadata = generate_android_icons.generate("variant-b", second) + + self.assertNotEqual( + first_metadata["seedSha256"], + second_metadata["seedSha256"], + ) + self.assertNotEqual( + (first / "drawable/stealth_launcher_foreground.xml").read_text(), + (second / "drawable/stealth_launcher_foreground.xml").read_text(), + ) + + +if __name__ == "__main__": + unittest.main() From 1432ceafbae6aac897ca3bcb0b0e6f7015ef0b37 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Fri, 15 May 2026 17:44:05 +0200 Subject: [PATCH 2/8] Address review feedback for neutral icons --- android/app/build.gradle | 2 ++ docs/stealth-android-icons.md | 7 +++++-- scripts/stealth/generate_android_icons.py | 16 +++++++++++++++- scripts/stealth/generate_android_icons_test.py | 7 ++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f6357dbdf..f7933a8383 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -43,6 +43,7 @@ def now = System.currentTimeMillis() def code = (int)((now - start) / 1000) def stealthIconSeed = (findProperty("stealthIconSeed") ?: System.getenv("STEALTH_ICON_SEED"))?.toString()?.trim() def generatedStealthIconResDir = layout.buildDirectory.dir("generated/stealth-icons/res") +def generatedStealthIconMetadata = layout.buildDirectory.file("generated/stealth-icons/stealth-icon-metadata.json") def launcherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher" : "@mipmap/ic_launcher" def roundLauncherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher_round" : "@mipmap/ic_launcher_round" @@ -188,6 +189,7 @@ tasks.register("generateStealthAndroidIcons", Exec) { onlyIf { stealthIconSeed } inputs.property("stealthIconSeed", stealthIconSeed ?: "") outputs.dir(generatedStealthIconResDir) + outputs.file(generatedStealthIconMetadata) commandLine "python3", "${rootProject.projectDir.parentFile}/scripts/stealth/generate_android_icons.py", "--seed", stealthIconSeed ?: "", diff --git a/docs/stealth-android-icons.md b/docs/stealth-android-icons.md index 29909a2fdc..605638acfc 100644 --- a/docs/stealth-android-icons.md +++ b/docs/stealth-android-icons.md @@ -14,11 +14,14 @@ This writes Android resources under - adaptive launcher icon: `@mipmap/stealth_ic_launcher` - adaptive round launcher icon: `@mipmap/stealth_ic_launcher_round` +- pre-26 fallback launcher icons in `mipmap-anydpi` - foreground vector: `@drawable/stealth_launcher_foreground` - notification icon candidate: `@drawable/stealth_notification_icon` -- private metadata: `stealth-icon-metadata.json` -The metadata stores only the seed hash, not the raw seed. +Private metadata is written outside the resource tree at +`android/app/build/generated/stealth-icons/stealth-icon-metadata.json`. It +stores only the seed hash, not the raw seed, and is not packaged as an Android +resource. ## Build Wiring diff --git a/scripts/stealth/generate_android_icons.py b/scripts/stealth/generate_android_icons.py index 40061712e3..8eace04354 100644 --- a/scripts/stealth/generate_android_icons.py +++ b/scripts/stealth/generate_android_icons.py @@ -66,6 +66,7 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: values_dir = output_res_dir / "values" drawable_dir = output_res_dir / "drawable" + legacy_mipmap_dir = output_res_dir / "mipmap-anydpi" mipmap_dir = output_res_dir / "mipmap-anydpi-v26" write_text( @@ -114,6 +115,19 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: write_text(mipmap_dir / "stealth_ic_launcher.xml", adaptive_icon) write_text(mipmap_dir / "stealth_ic_launcher_round.xml", adaptive_icon) + legacy_icon = f""" + + +{shape_paths(data, primary, secondary, accent)} + +""" + write_text(legacy_mipmap_dir / "stealth_ic_launcher.xml", legacy_icon) + write_text(legacy_mipmap_dir / "stealth_ic_launcher_round.xml", legacy_icon) + metadata = { "seedSha256": hashlib.sha256(seed.encode("utf-8")).hexdigest(), "background": background, @@ -125,7 +139,7 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: "notificationIcon": "@drawable/stealth_notification_icon", } write_text( - output_res_dir / "stealth-icon-metadata.json", + output_res_dir.parent / "stealth-icon-metadata.json", json.dumps(metadata, indent=2, sort_keys=True), ) return metadata diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py index 3f17efe765..58afc5ddf1 100644 --- a/scripts/stealth/generate_android_icons_test.py +++ b/scripts/stealth/generate_android_icons_test.py @@ -16,11 +16,16 @@ def test_generates_expected_resource_files(self): self.assertTrue((out / "values/stealth_icon_colors.xml").exists()) self.assertTrue((out / "drawable/stealth_launcher_foreground.xml").exists()) self.assertTrue((out / "drawable/stealth_notification_icon.xml").exists()) + self.assertTrue((out / "mipmap-anydpi/stealth_ic_launcher.xml").exists()) + self.assertTrue( + (out / "mipmap-anydpi/stealth_ic_launcher_round.xml").exists() + ) self.assertTrue((out / "mipmap-anydpi-v26/stealth_ic_launcher.xml").exists()) self.assertTrue( (out / "mipmap-anydpi-v26/stealth_ic_launcher_round.xml").exists() ) - self.assertTrue((out / "stealth-icon-metadata.json").exists()) + self.assertFalse((out / "stealth-icon-metadata.json").exists()) + self.assertTrue((out.parent / "stealth-icon-metadata.json").exists()) def test_generation_is_deterministic_for_same_seed(self): with tempfile.TemporaryDirectory() as tmp: From 7c295af059ceaf38cb0b49d006bc750b95613299 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Fri, 15 May 2026 18:15:32 +0200 Subject: [PATCH 3/8] Avoid stale generated stealth icons --- android/app/build.gradle | 4 +++- scripts/stealth/generate_android_icons.py | 1 - scripts/stealth/generate_android_icons_test.py | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index f7933a8383..db05975d9d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -62,7 +62,9 @@ android { } jniLibs.srcDirs = ['libs'] jniLibs.srcDirs += ['src/main/jniLibs'] - res.srcDirs += [generatedStealthIconResDir.get().asFile] + if (stealthIconSeed) { + res.srcDirs += [generatedStealthIconResDir.get().asFile] + } } } diff --git a/scripts/stealth/generate_android_icons.py b/scripts/stealth/generate_android_icons.py index 8eace04354..728ca4876b 100644 --- a/scripts/stealth/generate_android_icons.py +++ b/scripts/stealth/generate_android_icons.py @@ -100,7 +100,6 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: android:viewportWidth="24" android:viewportHeight="24"> - """, ) diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py index 58afc5ddf1..b3a22d2c23 100644 --- a/scripts/stealth/generate_android_icons_test.py +++ b/scripts/stealth/generate_android_icons_test.py @@ -16,6 +16,10 @@ def test_generates_expected_resource_files(self): self.assertTrue((out / "values/stealth_icon_colors.xml").exists()) self.assertTrue((out / "drawable/stealth_launcher_foreground.xml").exists()) self.assertTrue((out / "drawable/stealth_notification_icon.xml").exists()) + self.assertNotIn( + "#00000000", + (out / "drawable/stealth_notification_icon.xml").read_text(), + ) self.assertTrue((out / "mipmap-anydpi/stealth_ic_launcher.xml").exists()) self.assertTrue( (out / "mipmap-anydpi/stealth_ic_launcher_round.xml").exists() From 24b2c5fb2b78be3b10229cd7087ccd8d3d371c1c Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Fri, 15 May 2026 18:44:10 +0200 Subject: [PATCH 4/8] Keep stealth icon seed out of argv --- Makefile | 1 - android/app/build.gradle | 11 +++++++++-- scripts/stealth/generate_android_icons.py | 13 +++++++------ scripts/stealth/generate_android_icons_test.py | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 3932f94a2c..bef9403936 100644 --- a/Makefile +++ b/Makefile @@ -189,7 +189,6 @@ check-gomobile: .PHONY: stealth-android-icons stealth-android-icons: guard-STEALTH_ICON_SEED python3 scripts/stealth/generate_android_icons.py \ - --seed "$(STEALTH_ICON_SEED)" \ --output-res-dir "$(STEALTH_ICON_RES_DIR)" diff --git a/android/app/build.gradle b/android/app/build.gradle index db05975d9d..819d763785 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -42,6 +42,13 @@ def start = new Date(2015, 1, 1).getTime() def now = System.currentTimeMillis() def code = (int)((now - start) / 1000) def stealthIconSeed = (findProperty("stealthIconSeed") ?: System.getenv("STEALTH_ICON_SEED"))?.toString()?.trim() +def sha256Hex = { value -> + java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes("UTF-8")) + .collect { String.format("%02x", it) } + .join() +} +def stealthIconSeedSha256 = stealthIconSeed ? sha256Hex(stealthIconSeed) : "" def generatedStealthIconResDir = layout.buildDirectory.dir("generated/stealth-icons/res") def generatedStealthIconMetadata = layout.buildDirectory.file("generated/stealth-icons/stealth-icon-metadata.json") def launcherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher" : "@mipmap/ic_launcher" @@ -189,12 +196,12 @@ if (sideloadUpdates) { tasks.register("generateStealthAndroidIcons", Exec) { onlyIf { stealthIconSeed } - inputs.property("stealthIconSeed", stealthIconSeed ?: "") + inputs.property("stealthIconSeedSha256", stealthIconSeedSha256) outputs.dir(generatedStealthIconResDir) outputs.file(generatedStealthIconMetadata) + environment "STEALTH_ICON_SEED", stealthIconSeed ?: "" commandLine "python3", "${rootProject.projectDir.parentFile}/scripts/stealth/generate_android_icons.py", - "--seed", stealthIconSeed ?: "", "--output-res-dir", generatedStealthIconResDir.get().asFile.absolutePath } diff --git a/scripts/stealth/generate_android_icons.py b/scripts/stealth/generate_android_icons.py index 728ca4876b..0cd62c5589 100644 --- a/scripts/stealth/generate_android_icons.py +++ b/scripts/stealth/generate_android_icons.py @@ -7,6 +7,7 @@ import colorsys import hashlib import json +import os import secrets from pathlib import Path @@ -144,12 +145,12 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: return metadata -def parse_args() -> argparse.Namespace: +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--seed", default="", - help="private per-variant seed; random if omitted", + help="private per-variant seed; defaults to STEALTH_ICON_SEED; random if omitted", ) parser.add_argument( "--output-res-dir", @@ -157,12 +158,12 @@ def parse_args() -> argparse.Namespace: required=True, help="Android generated resource directory", ) - return parser.parse_args() + return parser.parse_args(argv) -def main() -> int: - args = parse_args() - seed = args.seed or secrets.token_urlsafe(24) +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + seed = args.seed or os.environ.get("STEALTH_ICON_SEED", "") or secrets.token_urlsafe(24) metadata = generate(seed, args.output_res_dir) print( "Generated stealth Android icons:", diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py index b3a22d2c23..8c6e46956a 100644 --- a/scripts/stealth/generate_android_icons_test.py +++ b/scripts/stealth/generate_android_icons_test.py @@ -1,6 +1,9 @@ import tempfile import unittest +from contextlib import redirect_stdout +from io import StringIO from pathlib import Path +from unittest.mock import patch import generate_android_icons @@ -62,6 +65,21 @@ def test_generation_changes_by_seed(self): (second / "drawable/stealth_launcher_foreground.xml").read_text(), ) + def test_main_reads_seed_from_environment(self): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "res" + + with patch.dict("os.environ", {"STEALTH_ICON_SEED": "variant-env"}): + with redirect_stdout(StringIO()): + exit_code = generate_android_icons.main( + ["--output-res-dir", str(out)] + ) + + expected = generate_android_icons.generate("variant-env", Path(tmp) / "expected") + metadata = (out.parent / "stealth-icon-metadata.json").read_text() + self.assertEqual(exit_code, 0) + self.assertIn(expected["seedSha256"], metadata) + if __name__ == "__main__": unittest.main() From d6f6459f0422b2c9d6c5c14283af028cbd816fc0 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Sat, 16 May 2026 12:54:43 +0200 Subject: [PATCH 5/8] fix: harden generated icon metadata --- android/app/build.gradle | 2 +- scripts/stealth/generate_android_icons.py | 2 +- scripts/stealth/generate_android_icons_test.py | 11 ++++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 819d763785..8e281ab6ce 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -45,7 +45,7 @@ def stealthIconSeed = (findProperty("stealthIconSeed") ?: System.getenv("STEALTH def sha256Hex = { value -> java.security.MessageDigest.getInstance("SHA-256") .digest(value.getBytes("UTF-8")) - .collect { String.format("%02x", it) } + .collect { String.format("%02x", it & 0xff) } .join() } def stealthIconSeedSha256 = stealthIconSeed ? sha256Hex(stealthIconSeed) : "" diff --git a/scripts/stealth/generate_android_icons.py b/scripts/stealth/generate_android_icons.py index 0cd62c5589..017b82642a 100644 --- a/scripts/stealth/generate_android_icons.py +++ b/scripts/stealth/generate_android_icons.py @@ -17,7 +17,7 @@ def hash_bytes(seed: str) -> bytes: def color_from_hash(data: bytes, offset: int, sat: float, light: float) -> str: - hue = int.from_bytes(data[offset : offset + 2], "big") / 65535 + hue = int.from_bytes(data[offset : offset + 2], "big") / 65536.0 red, green, blue = colorsys.hls_to_rgb(hue, light, sat) return "#{:02X}{:02X}{:02X}".format( round(red * 255), diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py index 8c6e46956a..5a37ba95e4 100644 --- a/scripts/stealth/generate_android_icons_test.py +++ b/scripts/stealth/generate_android_icons_test.py @@ -1,3 +1,4 @@ +import sys import tempfile import unittest from contextlib import redirect_stdout @@ -5,6 +6,7 @@ from pathlib import Path from unittest.mock import patch +sys.path.insert(0, str(Path(__file__).resolve().parent)) import generate_android_icons @@ -21,7 +23,11 @@ def test_generates_expected_resource_files(self): self.assertTrue((out / "drawable/stealth_notification_icon.xml").exists()) self.assertNotIn( "#00000000", - (out / "drawable/stealth_notification_icon.xml").read_text(), + self.read(out, "drawable/stealth_launcher_foreground.xml"), + ) + self.assertNotIn( + "#00000000", + self.read(out, "drawable/stealth_notification_icon.xml"), ) self.assertTrue((out / "mipmap-anydpi/stealth_ic_launcher.xml").exists()) self.assertTrue( @@ -80,6 +86,9 @@ def test_main_reads_seed_from_environment(self): self.assertEqual(exit_code, 0) self.assertIn(expected["seedSha256"], metadata) + def read(self, root: Path, relative_path: str) -> str: + return (root / relative_path).read_text(encoding="utf-8") + if __name__ == "__main__": unittest.main() From 1e3ef0dd9864cc5eda589e5185bf4f07152d8971 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Sat, 16 May 2026 13:16:14 +0200 Subject: [PATCH 6/8] fix: generate adaptive monochrome icons --- android/app/build.gradle | 4 +++- scripts/stealth/generate_android_icons.py | 20 ++++++++++++++++--- .../stealth/generate_android_icons_test.py | 5 +++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8e281ab6ce..66aabe64bf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -51,6 +51,7 @@ def sha256Hex = { value -> def stealthIconSeedSha256 = stealthIconSeed ? sha256Hex(stealthIconSeed) : "" def generatedStealthIconResDir = layout.buildDirectory.dir("generated/stealth-icons/res") def generatedStealthIconMetadata = layout.buildDirectory.file("generated/stealth-icons/stealth-icon-metadata.json") +def generatedStealthIconScript = file("${rootProject.projectDir.parentFile}/scripts/stealth/generate_android_icons.py") def launcherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher" : "@mipmap/ic_launcher" def roundLauncherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher_round" : "@mipmap/ic_launcher_round" @@ -197,11 +198,12 @@ if (sideloadUpdates) { tasks.register("generateStealthAndroidIcons", Exec) { onlyIf { stealthIconSeed } inputs.property("stealthIconSeedSha256", stealthIconSeedSha256) + inputs.file(generatedStealthIconScript) outputs.dir(generatedStealthIconResDir) outputs.file(generatedStealthIconMetadata) environment "STEALTH_ICON_SEED", stealthIconSeed ?: "" commandLine "python3", - "${rootProject.projectDir.parentFile}/scripts/stealth/generate_android_icons.py", + generatedStealthIconScript.absolutePath, "--output-res-dir", generatedStealthIconResDir.get().asFile.absolutePath } diff --git a/scripts/stealth/generate_android_icons.py b/scripts/stealth/generate_android_icons.py index 017b82642a..726f0c16c4 100644 --- a/scripts/stealth/generate_android_icons.py +++ b/scripts/stealth/generate_android_icons.py @@ -30,7 +30,7 @@ def shape_paths(data: bytes, primary: str, secondary: str, accent: str) -> str: template = data[4] % 4 if template == 0: return f""" - + """ @@ -42,7 +42,7 @@ def shape_paths(data: bytes, primary: str, secondary: str, accent: str) -> str: """ if template == 2: return f""" - + """ @@ -105,11 +105,24 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: """, ) + write_text( + drawable_dir / "stealth_launcher_monochrome.xml", + f""" + +{shape_paths(data, "#FFFFFFFF", "#FFFFFFFF", "#FFFFFFFF")} + +""", + ) + adaptive_icon = """ - + """ write_text(mipmap_dir / "stealth_ic_launcher.xml", adaptive_icon) @@ -136,6 +149,7 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: "accent": accent, "launcherIcon": "@mipmap/stealth_ic_launcher", "roundLauncherIcon": "@mipmap/stealth_ic_launcher_round", + "monochromeIcon": "@drawable/stealth_launcher_monochrome", "notificationIcon": "@drawable/stealth_notification_icon", } write_text( diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py index 5a37ba95e4..1a96fec64b 100644 --- a/scripts/stealth/generate_android_icons_test.py +++ b/scripts/stealth/generate_android_icons_test.py @@ -20,11 +20,16 @@ def test_generates_expected_resource_files(self): self.assertEqual(metadata["launcherIcon"], "@mipmap/stealth_ic_launcher") self.assertTrue((out / "values/stealth_icon_colors.xml").exists()) self.assertTrue((out / "drawable/stealth_launcher_foreground.xml").exists()) + self.assertTrue((out / "drawable/stealth_launcher_monochrome.xml").exists()) self.assertTrue((out / "drawable/stealth_notification_icon.xml").exists()) self.assertNotIn( "#00000000", self.read(out, "drawable/stealth_launcher_foreground.xml"), ) + self.assertIn( + '@drawable/stealth_launcher_monochrome', + self.read(out, "mipmap-anydpi-v26/stealth_ic_launcher.xml"), + ) self.assertNotIn( "#00000000", self.read(out, "drawable/stealth_notification_icon.xml"), From 9fd5af1e55e213691a2aeb299239570020446a06 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Sat, 16 May 2026 13:29:58 +0200 Subject: [PATCH 7/8] test: isolate icon metadata assertion --- scripts/stealth/generate_android_icons_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py index 1a96fec64b..1a00578e09 100644 --- a/scripts/stealth/generate_android_icons_test.py +++ b/scripts/stealth/generate_android_icons_test.py @@ -1,3 +1,4 @@ +import hashlib import sys import tempfile import unittest @@ -86,10 +87,10 @@ def test_main_reads_seed_from_environment(self): ["--output-res-dir", str(out)] ) - expected = generate_android_icons.generate("variant-env", Path(tmp) / "expected") + expected_sha = hashlib.sha256(b"variant-env").hexdigest() metadata = (out.parent / "stealth-icon-metadata.json").read_text() self.assertEqual(exit_code, 0) - self.assertIn(expected["seedSha256"], metadata) + self.assertIn(expected_sha, metadata) def read(self, root: Path, relative_path: str) -> str: return (root / relative_path).read_text(encoding="utf-8") From 7f3f33293813d06759cb90e81bc016bd9f6b5724 Mon Sep 17 00:00:00 2001 From: Ilya Yakelzon Date: Thu, 11 Jun 2026 12:23:11 +0200 Subject: [PATCH 8/8] fix: drop stealth_ prefix from generated Android resource names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resource entry names are retained in resources.arsc even with shrinkResources=true, making stealth_ic_launcher etc. a visible artifact fingerprint. Replace every stealth_* resource name with a neutral _alt-suffixed name: stealth_ic_launcher → ic_launcher_alt stealth_ic_launcher_round → ic_launcher_alt_round stealth_launcher_foreground → launcher_foreground_alt stealth_launcher_monochrome → launcher_monochrome_alt stealth_notification_icon → ic_notification_alt stealth_launcher_background → launcher_background_alt stealth_icon_colors.xml → icon_colors_alt.xml Update build.gradle placeholder targets, metadata values, and test assertions accordingly. Add a test guard that asserts no "stealth" token appears in any generated resource filename. Co-Authored-By: Claude Opus 4.8 --- android/app/build.gradle | 4 +- scripts/stealth/generate_android_icons.py | 32 ++++++++-------- .../stealth/generate_android_icons_test.py | 37 ++++++++++--------- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 66aabe64bf..f6a1ce92e8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -52,8 +52,8 @@ def stealthIconSeedSha256 = stealthIconSeed ? sha256Hex(stealthIconSeed) : "" def generatedStealthIconResDir = layout.buildDirectory.dir("generated/stealth-icons/res") def generatedStealthIconMetadata = layout.buildDirectory.file("generated/stealth-icons/stealth-icon-metadata.json") def generatedStealthIconScript = file("${rootProject.projectDir.parentFile}/scripts/stealth/generate_android_icons.py") -def launcherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher" : "@mipmap/ic_launcher" -def roundLauncherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher_round" : "@mipmap/ic_launcher_round" +def launcherIconResource = stealthIconSeed ? "@mipmap/ic_launcher_alt" : "@mipmap/ic_launcher" +def roundLauncherIconResource = stealthIconSeed ? "@mipmap/ic_launcher_alt_round" : "@mipmap/ic_launcher_round" android { namespace = "org.getlantern.lantern" diff --git a/scripts/stealth/generate_android_icons.py b/scripts/stealth/generate_android_icons.py index 726f0c16c4..c111795ecc 100644 --- a/scripts/stealth/generate_android_icons.py +++ b/scripts/stealth/generate_android_icons.py @@ -71,16 +71,16 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: mipmap_dir = output_res_dir / "mipmap-anydpi-v26" write_text( - values_dir / "stealth_icon_colors.xml", + values_dir / "icon_colors_alt.xml", f""" - {background} + {background} """, ) write_text( - drawable_dir / "stealth_launcher_foreground.xml", + drawable_dir / "launcher_foreground_alt.xml", f""" dict[str, str]: ) write_text( - drawable_dir / "stealth_notification_icon.xml", + drawable_dir / "ic_notification_alt.xml", """ dict[str, str]: ) write_text( - drawable_dir / "stealth_launcher_monochrome.xml", + drawable_dir / "launcher_monochrome_alt.xml", f""" dict[str, str]: adaptive_icon = """ - - - + + + """ - write_text(mipmap_dir / "stealth_ic_launcher.xml", adaptive_icon) - write_text(mipmap_dir / "stealth_ic_launcher_round.xml", adaptive_icon) + write_text(mipmap_dir / "ic_launcher_alt.xml", adaptive_icon) + write_text(mipmap_dir / "ic_launcher_alt_round.xml", adaptive_icon) legacy_icon = f""" dict[str, str]: {shape_paths(data, primary, secondary, accent)} """ - write_text(legacy_mipmap_dir / "stealth_ic_launcher.xml", legacy_icon) - write_text(legacy_mipmap_dir / "stealth_ic_launcher_round.xml", legacy_icon) + write_text(legacy_mipmap_dir / "ic_launcher_alt.xml", legacy_icon) + write_text(legacy_mipmap_dir / "ic_launcher_alt_round.xml", legacy_icon) metadata = { "seedSha256": hashlib.sha256(seed.encode("utf-8")).hexdigest(), @@ -147,10 +147,10 @@ def generate(seed: str, output_res_dir: Path) -> dict[str, str]: "primary": primary, "secondary": secondary, "accent": accent, - "launcherIcon": "@mipmap/stealth_ic_launcher", - "roundLauncherIcon": "@mipmap/stealth_ic_launcher_round", - "monochromeIcon": "@drawable/stealth_launcher_monochrome", - "notificationIcon": "@drawable/stealth_notification_icon", + "launcherIcon": "@mipmap/ic_launcher_alt", + "roundLauncherIcon": "@mipmap/ic_launcher_alt_round", + "monochromeIcon": "@drawable/launcher_monochrome_alt", + "notificationIcon": "@drawable/ic_notification_alt", } write_text( output_res_dir.parent / "stealth-icon-metadata.json", diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py index 1a00578e09..a5eee5055d 100644 --- a/scripts/stealth/generate_android_icons_test.py +++ b/scripts/stealth/generate_android_icons_test.py @@ -18,30 +18,33 @@ def test_generates_expected_resource_files(self): metadata = generate_android_icons.generate("variant-a", out) - self.assertEqual(metadata["launcherIcon"], "@mipmap/stealth_ic_launcher") - self.assertTrue((out / "values/stealth_icon_colors.xml").exists()) - self.assertTrue((out / "drawable/stealth_launcher_foreground.xml").exists()) - self.assertTrue((out / "drawable/stealth_launcher_monochrome.xml").exists()) - self.assertTrue((out / "drawable/stealth_notification_icon.xml").exists()) + self.assertEqual(metadata["launcherIcon"], "@mipmap/ic_launcher_alt") + self.assertTrue((out / "values/icon_colors_alt.xml").exists()) + self.assertTrue((out / "drawable/launcher_foreground_alt.xml").exists()) + self.assertTrue((out / "drawable/launcher_monochrome_alt.xml").exists()) + self.assertTrue((out / "drawable/ic_notification_alt.xml").exists()) self.assertNotIn( "#00000000", - self.read(out, "drawable/stealth_launcher_foreground.xml"), + self.read(out, "drawable/launcher_foreground_alt.xml"), ) self.assertIn( - '@drawable/stealth_launcher_monochrome', - self.read(out, "mipmap-anydpi-v26/stealth_ic_launcher.xml"), + '@drawable/launcher_monochrome_alt', + self.read(out, "mipmap-anydpi-v26/ic_launcher_alt.xml"), ) self.assertNotIn( "#00000000", - self.read(out, "drawable/stealth_notification_icon.xml"), + self.read(out, "drawable/ic_notification_alt.xml"), ) - self.assertTrue((out / "mipmap-anydpi/stealth_ic_launcher.xml").exists()) + # No "stealth" token in any generated resource name + for f in out.rglob("*"): + self.assertNotIn("stealth", f.name.lower()) + self.assertTrue((out / "mipmap-anydpi/ic_launcher_alt.xml").exists()) self.assertTrue( - (out / "mipmap-anydpi/stealth_ic_launcher_round.xml").exists() + (out / "mipmap-anydpi/ic_launcher_alt_round.xml").exists() ) - self.assertTrue((out / "mipmap-anydpi-v26/stealth_ic_launcher.xml").exists()) + self.assertTrue((out / "mipmap-anydpi-v26/ic_launcher_alt.xml").exists()) self.assertTrue( - (out / "mipmap-anydpi-v26/stealth_ic_launcher_round.xml").exists() + (out / "mipmap-anydpi-v26/ic_launcher_alt_round.xml").exists() ) self.assertFalse((out / "stealth-icon-metadata.json").exists()) self.assertTrue((out.parent / "stealth-icon-metadata.json").exists()) @@ -56,8 +59,8 @@ def test_generation_is_deterministic_for_same_seed(self): self.assertEqual(first_metadata, second_metadata) self.assertEqual( - (first / "drawable/stealth_launcher_foreground.xml").read_text(), - (second / "drawable/stealth_launcher_foreground.xml").read_text(), + (first / "drawable/launcher_foreground_alt.xml").read_text(), + (second / "drawable/launcher_foreground_alt.xml").read_text(), ) def test_generation_changes_by_seed(self): @@ -73,8 +76,8 @@ def test_generation_changes_by_seed(self): second_metadata["seedSha256"], ) self.assertNotEqual( - (first / "drawable/stealth_launcher_foreground.xml").read_text(), - (second / "drawable/stealth_launcher_foreground.xml").read_text(), + (first / "drawable/launcher_foreground_alt.xml").read_text(), + (second / "drawable/launcher_foreground_alt.xml").read_text(), ) def test_main_reads_seed_from_environment(self):