diff --git a/Makefile b/Makefile index 1c964e7dbd..bef9403936 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,11 @@ 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 \ + --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..f6a1ce92e8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -41,6 +41,19 @@ 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 sha256Hex = { value -> + java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes("UTF-8")) + .collect { String.format("%02x", it & 0xff) } + .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 generatedStealthIconScript = file("${rootProject.projectDir.parentFile}/scripts/stealth/generate_android_icons.py") +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" @@ -57,6 +70,9 @@ android { } jniLibs.srcDirs = ['libs'] jniLibs.srcDirs += ['src/main/jniLibs'] + if (stealthIconSeed) { + res.srcDirs += [generatedStealthIconResDir.get().asFile] + } } } @@ -115,6 +131,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 +195,20 @@ if (sideloadUpdates) { .configureEach { dependsOn(generateSideloadManifest) } } +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", + generatedStealthIconScript.absolutePath, + "--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") / 65536.0 + 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" + legacy_mipmap_dir = output_res_dir / "mipmap-anydpi" + mipmap_dir = output_res_dir / "mipmap-anydpi-v26" + + write_text( + values_dir / "icon_colors_alt.xml", + f""" + + {background} + +""", + ) + + write_text( + drawable_dir / "launcher_foreground_alt.xml", + f""" + +{shape_paths(data, primary, secondary, accent)} + +""", + ) + + write_text( + drawable_dir / "ic_notification_alt.xml", + """ + + + +""", + ) + + write_text( + drawable_dir / "launcher_monochrome_alt.xml", + f""" + +{shape_paths(data, "#FFFFFFFF", "#FFFFFFFF", "#FFFFFFFF")} + +""", + ) + + 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""" + + +{shape_paths(data, primary, secondary, accent)} + +""" + 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(), + "background": background, + "primary": primary, + "secondary": secondary, + "accent": accent, + "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", + json.dumps(metadata, indent=2, sort_keys=True), + ) + return metadata + + +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; defaults to STEALTH_ICON_SEED; random if omitted", + ) + parser.add_argument( + "--output-res-dir", + type=Path, + required=True, + help="Android generated resource directory", + ) + return parser.parse_args(argv) + + +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:", + 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..a5eee5055d --- /dev/null +++ b/scripts/stealth/generate_android_icons_test.py @@ -0,0 +1,103 @@ +import hashlib +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +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/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/launcher_foreground_alt.xml"), + ) + self.assertIn( + '@drawable/launcher_monochrome_alt', + self.read(out, "mipmap-anydpi-v26/ic_launcher_alt.xml"), + ) + self.assertNotIn( + "#00000000", + self.read(out, "drawable/ic_notification_alt.xml"), + ) + # 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/ic_launcher_alt_round.xml").exists() + ) + self.assertTrue((out / "mipmap-anydpi-v26/ic_launcher_alt.xml").exists()) + self.assertTrue( + (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()) + + 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/launcher_foreground_alt.xml").read_text(), + (second / "drawable/launcher_foreground_alt.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/launcher_foreground_alt.xml").read_text(), + (second / "drawable/launcher_foreground_alt.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_sha = hashlib.sha256(b"variant-env").hexdigest() + metadata = (out.parent / "stealth-icon-metadata.json").read_text() + self.assertEqual(exit_code, 0) + self.assertIn(expected_sha, metadata) + + def read(self, root: Path, relative_path: str) -> str: + return (root / relative_path).read_text(encoding="utf-8") + + +if __name__ == "__main__": + unittest.main()