From 7ad39a41c289746c2aa17039cb3c36a39730b724 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Thu, 10 Sep 2026 23:07:21 +0000 Subject: [PATCH 1/2] feat(mobile): the dev app carries its own label, launcher icon and version code The dev and prod shells already install side by side (distinct applicationId) but looked identical on the home screen. The label and launcher icon are now build inputs next to the id: - VOGT_ANDROID_APP_NAME (the variable capacitor.config.ts already reads for appName) generates app_name/title_activity_main via resValue, so the two files cannot disagree; the static strings leave strings.xml. - VOGT_ANDROID_APP_ICON selects the launcher set through manifest placeholders: default (shipped icon) or dev (amber background, dark V, DEV ribbon; adaptive for API 26+, flattened layer-list for minSdk 23). Any other value fails the build. - release-mobile-dev.yml sets 'Vogt Dev' + dev, and computes a monotonic dev versionCode ( x 10000 + run number) with versionName -dev., so a dev re-upload is never refused for a code Play has already seen. release.yml sets nothing and prod is unchanged. tests/test_mobile_identity.py asserts the dev workflow builds under its own label and icon and that the generated strings are not also static. Verified locally: assembleDebug with both variants; merged manifest shows the dev label/icon and the default one is byte-identical to before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YG9Nsxqc6CREb56Uisnmmy --- .github/workflows/release-mobile-dev.yml | 23 ++++++++++- docs/mobile-release-validation.md | 19 +++++++++ mobile/android/app/build.gradle | 29 ++++++++++++++ .../android/app/src/main/AndroidManifest.xml | 4 +- .../drawable/ic_launcher_dev_foreground.xml | 40 +++++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher_dev.xml | 5 +++ .../ic_launcher_dev_round.xml | 5 +++ .../res/mipmap-anydpi/ic_launcher_dev.xml | 11 +++++ .../mipmap-anydpi/ic_launcher_dev_round.xml | 11 +++++ mobile/android/app/src/main/res/raw/keep.xml | 2 +- .../res/values/ic_launcher_dev_background.xml | 6 +++ .../app/src/main/res/values/strings.xml | 4 +- tests/test_mobile_identity.py | 35 ++++++++++++++++ 13 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 mobile/android/app/src/main/res/drawable/ic_launcher_dev_foreground.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev_round.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher_dev.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher_dev_round.xml create mode 100644 mobile/android/app/src/main/res/values/ic_launcher_dev_background.xml diff --git a/.github/workflows/release-mobile-dev.yml b/.github/workflows/release-mobile-dev.yml index 93b44b40..12489b04 100644 --- a/.github/workflows/release-mobile-dev.yml +++ b/.github/workflows/release-mobile-dev.yml @@ -20,7 +20,7 @@ on: workflow_dispatch: inputs: version_name: - description: Informational only — the AAB version comes from the product version in build.gradle + description: Informational only — the AAB versionName is -dev., computed in the job required: false type: string @@ -111,16 +111,37 @@ jobs: fi python3 -c 'import json,sys; d=json.load(open("mobile/android/app/google-services.json")); pkgs={c.get("client_info",{}).get("android_client_info",{}).get("package_name") for c in d.get("client",[])}; sys.exit(0 if "com.thedancingdeveloper.vogt.dev" in pkgs else "Firebase config has no Android client for com.thedancingdeveloper.vogt.dev")' + # The dev app is a separate Play record, so its versionCode only has to + # climb among dev uploads: product semver code × 10000 + run number + # (0.6.2 → 60020000+N). Play refuses a re-upload of a code it has + # already seen, and the product version alone moves too rarely for a + # dev channel that republishes between releases. + - name: dev version code + working-directory: mobile + run: | + set -euo pipefail + version="$(python3 -c 'import json; print(json.load(open("package.json"))["version"])')" + IFS=. read -r major minor patch <<<"${version%%-*}" + code=$(( (major * 1000000 + minor * 1000 + patch) * 10000 + GITHUB_RUN_NUMBER )) + { + echo "VOGT_ANDROID_VERSION_CODE=$code" + echo "VOGT_ANDROID_VERSION_NAME=${version}-dev.${GITHUB_RUN_NUMBER}" + } >> "$GITHUB_ENV" + echo "::notice::dev AAB versionCode=$code versionName=${version}-dev.${GITHUB_RUN_NUMBER}" + - name: capacitor sync working-directory: mobile env: VOGT_ANDROID_APP_ID: com.thedancingdeveloper.vogt.dev + VOGT_ANDROID_APP_NAME: Vogt Dev run: npx cap sync android - name: bundle and sign the dev AAB working-directory: mobile/android env: VOGT_ANDROID_APP_ID: com.thedancingdeveloper.vogt.dev + VOGT_ANDROID_APP_NAME: Vogt Dev + VOGT_ANDROID_APP_ICON: dev run: | set -euo pipefail # keystore path + creds come from $GITHUB_ENV (set by the resolve step). diff --git a/docs/mobile-release-validation.md b/docs/mobile-release-validation.md index c11ab510..0c8ffd54 100644 --- a/docs/mobile-release-validation.md +++ b/docs/mobile-release-validation.md @@ -56,6 +56,25 @@ for the version being shipped. (The deterministic native half — clean re-registration, no stale service — is covered by `VoiceServiceProcessReclaimTest`.) +### Dev app identity (side-by-side with prod) + +The dev shell is a separate Play record and a separate app on the device, so +it must be telling apart on the home screen, not only by package name. Three +build inputs carry its identity; `release-mobile-dev.yml` sets all three and +`release.yml` sets none (prod takes the defaults): + +| Variable | Read by | Default | Dev workflow | +|---|---|---|---| +| `VOGT_ANDROID_APP_ID` | `build.gradle`, `capacitor.config.ts` | `com.thedancingdeveloper.vogt` | `com.thedancingdeveloper.vogt.dev` | +| `VOGT_ANDROID_APP_NAME` | `build.gradle` (generates `app_name`), `capacitor.config.ts` | `Vogt` | `Vogt Dev` | +| `VOGT_ANDROID_APP_ICON` | `build.gradle` (manifest `icon`/`roundIcon`) | `default` | `dev` (amber launcher set, `res/mipmap-*/ic_launcher_dev*`) | + +The dev AAB's `versionCode` is ` × 10000 + ` +and its `versionName` is `-dev.`, computed in the +workflow, so a dev re-upload between product releases is never refused for a +version code Play has already seen. `tests/test_mobile_identity.py` holds +the three inputs and the workflow to this. + ### Play internal-track / pre-launch report The Play Console pre-launch report and internal-track validation are an external diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 5dbc171b..2266a6cc 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -51,6 +51,25 @@ def androidVersionCode = parseVersionCodeOverride( def androidApplicationId = ( System.getenv('VOGT_ANDROID_APP_ID') ?: 'com.thedancingdeveloper.vogt' ).trim() +// Label and launcher icon are build inputs too, so a dev build is telling +// apart from prod on the home screen and not only by its applicationId. +// VOGT_ANDROID_APP_NAME is the same variable capacitor.config.ts reads for +// `appName`; the string resources are generated here (resValue) rather than +// kept in strings.xml so the two can never disagree. VOGT_ANDROID_APP_ICON +// picks the launcher icon set: `default` (the shipped icon) or `dev` (amber +// variant, res/mipmap-*/ic_launcher_dev*). Anything else fails the build. +def androidAppName = (System.getenv('VOGT_ANDROID_APP_NAME') ?: 'Vogt').trim() +if (androidAppName.isEmpty()) { + throw new GradleException("VOGT_ANDROID_APP_NAME must not be blank") +} +def androidAppIcon = (System.getenv('VOGT_ANDROID_APP_ICON') ?: 'default').trim() +def androidLauncherIcons = [ + 'default': ['@mipmap/ic_launcher', '@mipmap/ic_launcher_round'], + 'dev': ['@mipmap/ic_launcher_dev', '@mipmap/ic_launcher_dev_round'], +] +if (!androidLauncherIcons.containsKey(androidAppIcon)) { + throw new GradleException("VOGT_ANDROID_APP_ICON must be one of ${androidLauncherIcons.keySet()}, got '${androidAppIcon}'") +} def releaseKeystorePath = System.getenv('VOGT_ANDROID_KEYSTORE_PATH') def releaseKeystorePassword = System.getenv('VOGT_ANDROID_KEYSTORE_PASSWORD') @@ -66,12 +85,22 @@ def hasReleaseSigning = [ android { namespace "com.thedancingdeveloper.vogt" compileSdk rootProject.ext.compileSdkVersion + buildFeatures { + // Off by default since AGP 9; the label strings above are resValues. + resValues true + } defaultConfig { applicationId androidApplicationId minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode androidVersionCode versionName androidVersionName + resValue "string", "app_name", androidAppName + resValue "string", "title_activity_main", androidAppName + manifestPlaceholders = [ + appIcon: androidLauncherIcons[androidAppIcon][0], + appRoundIcon: androidLauncherIcons[androidAppIcon][1], + ] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 42e76f43..bf683e12 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -6,9 +6,9 @@ android:allowBackup="false" android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules" - android:icon="@mipmap/ic_launcher" + android:icon="${appIcon}" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" + android:roundIcon="${appRoundIcon}" android:supportsRtl="true" android:theme="@style/AppTheme"> diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_dev_foreground.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_dev_foreground.xml new file mode 100644 index 00000000..3dd94518 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_dev_foreground.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev.xml new file mode 100644 index 00000000..7a1b47c7 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev_round.xml new file mode 100644 index 00000000..7a1b47c7 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher_dev.xml b/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher_dev.xml new file mode 100644 index 00000000..b14247c6 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher_dev.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher_dev_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher_dev_round.xml new file mode 100644 index 00000000..b14247c6 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher_dev_round.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/raw/keep.xml b/mobile/android/app/src/main/res/raw/keep.xml index 6f15f0c0..fbab1692 100644 --- a/mobile/android/app/src/main/res/raw/keep.xml +++ b/mobile/android/app/src/main/res/raw/keep.xml @@ -18,4 +18,4 @@ gains a dynamic reference is kept rather than stripped. --> + tools:keep="@mipmap/ic_launcher,@mipmap/ic_launcher_round,@mipmap/ic_launcher_dev,@mipmap/ic_launcher_dev_round,@drawable/splash,@color/ic_launcher_background,@color/ic_launcher_dev_background" /> diff --git a/mobile/android/app/src/main/res/values/ic_launcher_dev_background.xml b/mobile/android/app/src/main/res/values/ic_launcher_dev_background.xml new file mode 100644 index 00000000..712e98e0 --- /dev/null +++ b/mobile/android/app/src/main/res/values/ic_launcher_dev_background.xml @@ -0,0 +1,6 @@ + + + + #B45309 + diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml index 0eb72a01..83347e7c 100644 --- a/mobile/android/app/src/main/res/values/strings.xml +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - Vogt - Vogt + com.thedancingdeveloper.vogt com.thedancingdeveloper.vogt diff --git a/tests/test_mobile_identity.py b/tests/test_mobile_identity.py index 3a486cae..465915ba 100644 --- a/tests/test_mobile_identity.py +++ b/tests/test_mobile_identity.py @@ -308,3 +308,38 @@ def test_the_android_shell_does_not_zoom_the_page() -> None: assert config.count("zoomEnabled: false") == 2, ( "both the top-level and the android block must turn page zoom off" ) + + +#: The label under the icon and the icon itself are build inputs alongside +#: the id, read by the same two files: `build.gradle` generates the string +#: resources from ``VOGT_ANDROID_APP_NAME`` (resValue) and picks the launcher +#: set from ``VOGT_ANDROID_APP_ICON``; `capacitor.config.ts` reads the name +#: for ``appName``. +APP_NAME_VAR = "VOGT_ANDROID_APP_NAME" +APP_ICON_VAR = "VOGT_ANDROID_APP_ICON" +DEV_WORKFLOW = WORKFLOWS / "release-mobile-dev.yml" + + +def test_the_dev_app_is_telling_apart_on_the_home_screen() -> None: + """Two apps with the same name and icon are the same app to a person. + + The dev record must build under its own label and launcher icon, or the + operator cannot tell which one is open — the failure reported from a + device carrying both. Asserted on the workflow because that is where the + dev stream is defined, and on the build files because both must read the + variable for the label to reach the manifest and the Capacitor config. + """ + gradle = GRADLE.read_text(encoding="utf-8") + capacitor = CAPACITOR.read_text(encoding="utf-8") + assert APP_NAME_VAR in gradle and APP_NAME_VAR in capacitor + assert APP_ICON_VAR in gradle + workflow = DEV_WORKFLOW.read_text(encoding="utf-8") + names = set(re.findall(rf"{APP_NAME_VAR}:\s*\"?([^\"\n]+?)\"?\s*$", workflow, re.M)) + assert names and names != {"Vogt"}, "the dev workflow builds under the prod label" + assert re.search(rf"{APP_ICON_VAR}:\s*dev\s*$", workflow, re.M), ( + "the dev workflow builds with the prod launcher icon" + ) + # The generated strings must not also be declared statically, or aapt + # rejects the duplicate — and a static copy would silently win a merge. + strings = (MOBILE / "android/app/src/main/res/values/strings.xml").read_text(encoding="utf-8") + assert 'name="app_name"' not in strings and 'name="title_activity_main"' not in strings From f0a6a49ad77f009824365dc38af9fd13af4af1f3 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Thu, 10 Sep 2026 23:20:35 +0000 Subject: [PATCH 2/2] test(mobile): wrap two long lines for ruff E501 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YG9Nsxqc6CREb56Uisnmmy --- tests/test_mobile_identity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_mobile_identity.py b/tests/test_mobile_identity.py index 465915ba..62584e51 100644 --- a/tests/test_mobile_identity.py +++ b/tests/test_mobile_identity.py @@ -341,5 +341,7 @@ def test_the_dev_app_is_telling_apart_on_the_home_screen() -> None: ) # The generated strings must not also be declared statically, or aapt # rejects the duplicate — and a static copy would silently win a merge. - strings = (MOBILE / "android/app/src/main/res/values/strings.xml").read_text(encoding="utf-8") - assert 'name="app_name"' not in strings and 'name="title_activity_main"' not in strings + strings_xml = MOBILE / "android/app/src/main/res/values/strings.xml" + strings = strings_xml.read_text(encoding="utf-8") + assert 'name="app_name"' not in strings + assert 'name="title_activity_main"' not in strings