Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,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

Expand All @@ -170,6 +173,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:
Expand Down
34 changes: 34 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ def hasReleaseKeystore =
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/stealth_ic_launcher" : "@mipmap/ic_launcher"
def roundLauncherIconResource = stealthIconSeed ? "@mipmap/stealth_ic_launcher_round" : "@mipmap/ic_launcher_round"

Comment thread
reflog marked this conversation as resolved.
android {
namespace = "org.getlantern.lantern"
Expand All @@ -25,6 +38,9 @@ android {
main {
jniLibs.srcDirs = ['libs']
jniLibs.srcDirs += ['src/main/jniLibs']
if (stealthIconSeed) {
res.srcDirs += [generatedStealthIconResDir.get().asFile]
}
}
}

Expand Down Expand Up @@ -58,6 +74,10 @@ android {
targetSdk = 36
versionCode = code
versionName = flutter.versionName
manifestPlaceholders += [
launcherIcon : launcherIconResource,
roundLauncherIcon: roundLauncherIconResource,
]

ndk {
// Must match the ABIs Flutter actually builds (ANDROID_TARGET_PLATFORMS
Expand Down Expand Up @@ -115,6 +135,20 @@ android {

}

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
}
Comment thread
reflog marked this conversation as resolved.

preBuild.dependsOn("generateStealthAndroidIcons")

flutter {
source = "../.."
}
Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@

<application
android:name=".LanternApp"
android:icon="@mipmap/ic_launcher"
android:icon="${launcherIcon}"
android:label="Lantern"
android:usesCleartextTraffic="true"
android:roundIcon="@mipmap/ic_launcher_round">
android:roundIcon="${roundLauncherIcon}">

<activity
android:name=".MainActivity"
Expand Down
48 changes: 48 additions & 0 deletions docs/stealth-android-icons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Stealth Android icons

Stealth Android variants can generate a neutral launcher icon set from a
private per-variant seed. Normal builds keep the existing launcher icons.

## Generate Locally

```sh
make stealth-android-icons STEALTH_ICON_SEED="$PRIVATE_VARIANT_ICON_SEED"
```

This writes Android resources under
`android/app/build/generated/stealth-icons/res`:

- 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 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

Android Gradle reads `STEALTH_ICON_SEED` or `-PstealthIconSeed=...`. When a
seed is present, the manifest placeholders switch the app icon and round icon
to the generated resources:

```sh
STEALTH_ICON_SEED="$PRIVATE_VARIANT_ICON_SEED" make android-apk-release
```

Without a seed, the placeholders resolve to the existing `@mipmap/ic_launcher`
and `@mipmap/ic_launcher_round` resources.

## Release Policy

Use a different private icon seed for every distributed stealth variant. Keep
the seed and `stealth-icon-metadata.json` with the private build profile so
support can correlate a report with the shipped visual identity. Do not publish
the seed or metadata with public artifacts.

The generated icons are intentionally generic geometric utility icons. If design
provides a curated pool later, the same Gradle placeholder path can select
pre-rendered resources by variant ID instead of procedural output.
191 changes: 191 additions & 0 deletions scripts/stealth/generate_android_icons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Generate deterministic neutral Android icon resources for stealth variants."""

from __future__ import annotations

import argparse
import colorsys
import hashlib
import json
import os
import secrets
from pathlib import Path


def hash_bytes(seed: str) -> 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"""
<path android:fillColor="{primary}" android:pathData="M54,18 L90,54 L54,90 L18,54 Z"/>
<path android:fillColor="{secondary}" android:pathData="M54,28 L78,54 L54,80 L30,54 Z"/>
<path android:fillColor="{accent}" android:pathData="M47,47 L61,47 L61,61 L47,61 Z"/>
"""
if template == 1:
return f"""
<path android:fillColor="{primary}" android:pathData="M22,24 L86,24 L86,84 L22,84 Z"/>
<path android:fillColor="{secondary}" android:pathData="M34,36 L74,36 L74,72 L34,72 Z"/>
<path android:fillColor="{accent}" android:pathData="M42,46 L66,46 L66,62 L42,62 Z"/>
"""
if template == 2:
return f"""
<path android:fillColor="{primary}" android:pathData="M54,18 L76,26 L90,48 L84,72 L64,90 L38,86 L20,68 L20,40 L36,22 Z"/>
<path android:fillColor="{secondary}" android:pathData="M54,28 L70,36 L82,54 L74,72 L54,80 L34,72 L26,54 L38,36 Z"/>
<path android:fillColor="{accent}" android:pathData="M54,43 L65,54 L54,65 L43,54 Z"/>
"""
return f"""
<path android:fillColor="{primary}" android:pathData="M18,74 L42,22 L66,74 Z"/>
<path android:fillColor="{secondary}" android:pathData="M48,74 L66,34 L90,74 Z"/>
<path android:fillColor="{accent}" android:pathData="M44,74 L58,48 L72,74 Z"/>
"""


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"

Comment thread
reflog marked this conversation as resolved.
write_text(
values_dir / "stealth_icon_colors.xml",
f"""
<resources>
<color name="stealth_launcher_background">{background}</color>
</resources>
""",
)

write_text(
drawable_dir / "stealth_launcher_foreground.xml",
f"""
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
{shape_paths(data, primary, secondary, accent)}
</vector>
""",
)

write_text(
drawable_dir / "stealth_notification_icon.xml",
"""
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M12,3 L21,12 L12,21 L3,12 Z"/>
</vector>
""",
)

write_text(
drawable_dir / "stealth_launcher_monochrome.xml",
f"""
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
{shape_paths(data, "#FFFFFFFF", "#FFFFFFFF", "#FFFFFFFF")}
</vector>
""",
)

adaptive_icon = """
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/stealth_launcher_background"/>
<foreground android:drawable="@drawable/stealth_launcher_foreground"/>
<monochrome android:drawable="@drawable/stealth_launcher_monochrome"/>
</adaptive-icon>
Comment thread
reflog marked this conversation as resolved.
"""
write_text(mipmap_dir / "stealth_ic_launcher.xml", adaptive_icon)
write_text(mipmap_dir / "stealth_ic_launcher_round.xml", adaptive_icon)

legacy_icon = f"""
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="{background}" android:pathData="M0,0 L108,0 L108,108 L0,108 Z"/>
{shape_paths(data, primary, secondary, accent)}
</vector>
"""
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,
"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",
}
write_text(
output_res_dir.parent / "stealth-icon-metadata.json",
json.dumps(metadata, indent=2, sort_keys=True),
)
Comment thread
reflog marked this conversation as resolved.
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())
Loading
Loading