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..62584e51 100644 --- a/tests/test_mobile_identity.py +++ b/tests/test_mobile_identity.py @@ -308,3 +308,40 @@ 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_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