From 341cdb0e61f28b3581752608c01efe723d087e59 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:56:44 +0200 Subject: [PATCH] feat: build a Google Play bundle alongside the GitHub APK The app now has two product flavors. `github` is the sideloaded APK as before, with the same package name and its in-app updater. `play` is a bundle for Google Play as `org.bulwarkmail.mobile`, without the updater or the installer permission, since Play forbids apps updating themselves. - `release-android.yml` builds the Play bundle in a second job, checks its signer, package name and permissions, and keeps it as an artifact. With `PLAY_SERVICE_ACCOUNT_JSON` set it uploads it to internal testing. - The Play flavor uses the new Firebase project; the GitHub flavor keeps the old one until the app re-registers its token when it changes. - Widget clicks and the device-sync account type follow the package name. - Photos come from the system picker without asking for media access, and the unused microphone permission from expo-camera is removed. - A manual workflow run without a tag no longer replaces the APK of the latest release. --- .github/workflows/release-android.yml | 163 +++++++++++++++++- android/app/build.gradle | 42 ++++- android/app/google-services.json | 16 +- android/app/src/github/AndroidManifest.xml | 6 + android/app/src/github/google-services.json | 48 ++++++ android/app/src/main/AndroidManifest.xml | 72 ++++---- .../bulwarkmobile/BulwarkBuildModule.kt | 18 ++ .../bulwarkmobile/BulwarkFcmPackage.kt | 1 + docs/android-release.md | 44 ++++- .../__tests__/platform-capabilities.test.ts | 35 ++++ src/lib/platform-capabilities.ts | 23 ++- src/screens/ComposeScreen.tsx | 20 +-- src/screens/ContactFormScreen.tsx | 10 +- 13 files changed, 405 insertions(+), 93 deletions(-) create mode 100644 android/app/src/github/AndroidManifest.xml create mode 100644 android/app/src/github/google-services.json create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkBuildModule.kt create mode 100644 src/lib/__tests__/platform-capabilities.test.ts diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml index 50348c7f..2e9e6a44 100644 --- a/.github/workflows/release-android.yml +++ b/.github/workflows/release-android.yml @@ -1,4 +1,4 @@ -name: Release Android APK +name: Release Android on: release: @@ -137,7 +137,7 @@ jobs: ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x ./gradlew - ./gradlew assembleRelease --no-daemon + ./gradlew assembleGithubRelease --no-daemon - name: Put apksigner on the PATH run: | @@ -188,7 +188,7 @@ jobs: # Oldest signer first: apksigner uses it for v2, and the rotated key # for v3 from API 28. Without --rotation-min-sdk-version it would # only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key. - APK=android/app/build/outputs/apk/release/app-release.apk + APK=android/app/build/outputs/apk/github/release/app-github-release.apk apksigner sign \ --v4-signing-enabled false \ --ks android/app/debug.keystore --ks-key-alias androiddebugkey \ @@ -210,7 +210,7 @@ jobs: ROTATED: ${{ steps.rotation.outputs.rotated }} run: | set -euo pipefail - APK=android/app/build/outputs/apk/release/app-release.apk + APK=android/app/build/outputs/apk/github/release/app-github-release.apk apksigner verify --verbose --print-certs "$APK" # build-tools 36 prints "Signer #1 certificate SHA-256 digest: …", # 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …". @@ -248,12 +248,12 @@ jobs: run: | set -euo pipefail VERSION=$(cat VERSION | tr -d '[:space:]') - SRC=android/app/build/outputs/apk/release/app-release.apk + SRC=android/app/build/outputs/apk/github/release/app-github-release.apk DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk" cp "$SRC" "$DEST" sha256sum "$DEST" > "$DEST.sha256" - MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt + MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt MAPPING="" if [ -f "$MAPPING_SRC" ]; then MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt" @@ -299,12 +299,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # A manual run with no tag only builds: it must not replace the APK + # of the latest release with one from another commit. if [ "${{ github.event_name }}" = "release" ]; then TAG="${{ github.event.release.tag_name }}" - elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then - TAG="${{ github.event.inputs.attach_to_release_tag }}" else - TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true) + TAG="${{ github.event.inputs.attach_to_release_tag }}" fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" if [ -n "$TAG" ]; then @@ -319,3 +319,148 @@ jobs: with: tag_name: ${{ steps.target_tag.outputs.tag }} files: ${{ steps.artifact.outputs.files }} + + # The Google Play bundle (docs/android-release.md, "Google Play"). It is + # signed with the release key, which Play knows as the upload key; Play + # re-signs what it delivers with its own app signing key. A separate job so + # the GitHub APK isn't held up by the three-ABI native build. + play-bundle: + runs-on: ubuntu-latest + timeout-minutes: 75 + permissions: + contents: read + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + steps: + - name: Require the release signing secrets + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + missing="" + [ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64" + [ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD" + [ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS" + [ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD" + if [ -n "$missing" ]; then + echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md." + exit 1 + fi + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + + - name: Install JS dependencies + run: npm ci + + - name: Decode and check the upload key + id: key + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + run: | + set -euo pipefail + KEYSTORE="$RUNNER_TEMP/release.keystore" + CERT="$RUNNER_TEMP/release-cert.der" + echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE" + keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \ + -storepass:env KEYSTORE_PASSWORD -file "$CERT" + CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1) + if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then + echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key." + exit 1 + fi + echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT" + echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT" + + # Play splits a bundle per ABI, so the extra architectures cost a phone + # nothing and let 32-bit ARM phones and x86_64 Chromebooks install it. + - name: Build the Play bundle + working-directory: android + env: + ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }} + ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + chmod +x ./gradlew + ./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64 + + # Play rejects a bundle signed with another key than the registered + # upload key, and its policies reject a self-updater and the media + # permission. Catch all three here instead of in the Play Console. + - name: Check the bundle + env: + RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }} + run: | + set -euo pipefail + AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab + SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f') + echo "Bundle signer SHA-256: $SIGNER" + if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then + echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'." + exit 1 + fi + MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n') + if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then + echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile." + exit 1 + fi + for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do + if grep -q "android.permission.$permission" <<< "$MANIFEST"; then + echo "::error title=Permission not allowed on Play::The Play bundle declares $permission." + exit 1 + fi + done + + - name: Name the bundle and R8 mapping with version + id: artifact + run: | + set -euo pipefail + VERSION=$(cat VERSION | tr -d '[:space:]') + AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab" + MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt" + cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB" + cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING" + echo "aab=$AAB" >> "$GITHUB_OUTPUT" + echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.aab }} + path: | + ${{ steps.artifact.outputs.aab }} + ${{ steps.artifact.outputs.mapping }} + if-no-files-found: error + + # Runs once PLAY_SERVICE_ACCOUNT_JSON is set. Play only accepts API + # uploads after the first bundle was uploaded by hand in the Console, and + # releases of an app that was never published must be drafts; switch + # `status` to `completed` after the first production release. + - name: Upload to Google Play (internal testing, draft) + if: env.PLAY_SERVICE_ACCOUNT_JSON != '' + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: org.bulwarkmail.mobile + releaseFiles: ${{ steps.artifact.outputs.aab }} + mappingFile: ${{ steps.artifact.outputs.mapping }} + track: internal + status: draft diff --git a/android/app/build.gradle b/android/app/build.gradle index ad476faf..1c101acd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ react { /* Variants */ // The list of variants to that are debuggable. For those we're going to // skip the bundling of the JS bundle and the assets. By default is just 'debug'. - // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. - // debuggableVariants = ["liteDebug", "prodDebug"] + // The distribution flavors (github, play) need their debug variants listed. + debuggableVariants = ["githubDebug", "playDebug"] /* Bundling */ // A list containing the node command and its flags. Default is just 'node'. @@ -154,13 +154,39 @@ android { versionCode appVersionCode.toInteger() versionName appVersionName - // Android account type of device sync (#34), derived from the package - // name so a later rename needs no other change. The XML in res/xml and - // the Kotlin in sync/ read it as @string/device_sync_account_type. - resValue "string", "device_sync_account_type", "${applicationId}.account" - buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } + + /** + * Where the build is distributed. The two are separate apps on a phone: + * + * - github: the APK on GitHub Releases, sideloaded, updated by the in-app + * updater. Keeps the original package name so existing installs update. + * - play: the Google Play bundle. Play forbids an app updating itself + * outside Play, so this flavor has no installer permission and the JS + * hides the updater (BuildConfig.DISTRIBUTION, lib/platform-capabilities). + * + * See docs/android-release.md. + */ + flavorDimensions = ["distribution"] + productFlavors { + github { + dimension "distribution" + applicationId 'com.anonymous.bulwarkmobile' + buildConfigField "String", "DISTRIBUTION", '"github"' + } + play { + dimension "distribution" + applicationId 'org.bulwarkmail.mobile' + buildConfigField "String", "DISTRIBUTION", '"play"' + } + } + applicationVariants.all { variant -> + // Android account type of device sync (#34), derived from the package + // name of each flavor. The XML in res/xml and the Kotlin in sync/ read + // it as @string/device_sync_account_type. + variant.resValue "string", "device_sync_account_type", "${variant.applicationId}.account" + } signingConfigs { debug { storeFile file('debug.keystore') @@ -245,7 +271,7 @@ def checkReleaseSigning = tasks.register('checkReleaseSigning') { } } tasks.configureEach { task -> - if (task.name == 'preReleaseBuild') { + if (task.name ==~ /pre\w*ReleaseBuild/) { task.dependsOn(checkReleaseSigning) } } diff --git a/android/app/google-services.json b/android/app/google-services.json index 4517c7e1..63a45bc8 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -1,13 +1,13 @@ { "project_info": { - "project_number": "504486613721", - "project_id": "bulwark-mobile", - "storage_bucket": "bulwark-mobile.firebasestorage.app" + "project_number": "752109678859", + "project_id": "bulwark-20c72", + "storage_bucket": "bulwark-20c72.firebasestorage.app" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8", + "mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e", "android_client_info": { "package_name": "com.anonymous.bulwarkmobile" } @@ -15,7 +15,7 @@ "oauth_client": [], "api_key": [ { - "current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" + "current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4" } ], "services": { @@ -26,15 +26,15 @@ }, { "client_info": { - "mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8", + "mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e", "android_client_info": { - "package_name": "com.bulwark.bulwarkmobile" + "package_name": "org.bulwarkmail.mobile" } }, "oauth_client": [], "api_key": [ { - "current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" + "current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4" } ], "services": { diff --git a/android/app/src/github/AndroidManifest.xml b/android/app/src/github/AndroidManifest.xml new file mode 100644 index 00000000..747e48e7 --- /dev/null +++ b/android/app/src/github/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/github/google-services.json b/android/app/src/github/google-services.json new file mode 100644 index 00000000..4517c7e1 --- /dev/null +++ b/android/app/src/github/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "504486613721", + "project_id": "bulwark-mobile", + "storage_bucket": "bulwark-mobile.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8", + "android_client_info": { + "package_name": "com.anonymous.bulwarkmobile" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8", + "android_client_info": { + "package_name": "com.bulwark.bulwarkmobile" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5d0f74d7..71324ad2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,9 +2,13 @@ - + - + + + + @@ -110,224 +114,224 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkBuildModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkBuildModule.kt new file mode 100644 index 00000000..f6cbde75 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkBuildModule.kt @@ -0,0 +1,18 @@ +package com.anonymous.bulwarkmobile + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule + +/** + * Facts about this build that JS can't see on its own. `distribution` is the + * product flavor (app/build.gradle): "github" for the sideloaded APK with its + * own updater, "play" for the Google Play bundle, which must not update + * itself (lib/platform-capabilities). + */ +class BulwarkBuildModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName() = "BulwarkBuild" + + override fun getConstants(): Map = mapOf( + "distribution" to BuildConfig.DISTRIBUTION, + ) +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt index 71cb797a..73b9ded0 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt @@ -16,6 +16,7 @@ class BulwarkFcmPackage : ReactPackage { add(BulwarkDeviceSyncModule(reactContext)) add(BulwarkWidgetsModule(reactContext)) add(BulwarkPdfModule(reactContext)) + add(BulwarkBuildModule(reactContext)) // The bridge registers React Native's own module under this name. if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { add(BulwarkHeadlessJsTaskSupportModule(reactContext)) diff --git a/docs/android-release.md b/docs/android-release.md index 3e5769e3..802e5ae2 100644 --- a/docs/android-release.md +++ b/docs/android-release.md @@ -3,7 +3,8 @@ The release APK is built by [.github/workflows/release-android.yml](../.github/workflows/release-android.yml) on an Ubuntu runner whenever a GitHub release is published, and attached to that -release. Installed apps pick it up through the in-app updater. +release. Installed apps pick it up through the in-app updater. The same workflow +builds the Google Play bundle; see [Google Play](#google-play). Every APK up to and including 0.1.62 was signed with `android/app/debug.keystore`, the debug key that ships with the React Native template. Its password is @@ -199,6 +200,42 @@ Once you delete it, every APK is signed with the release key alone: the update. Those users have to uninstall, reinstall and sign in again, and they lose local data. Afterwards their devices trust only the release key. +## Google Play + +The app has two product flavors (`android/app/build.gradle`). They install +side by side as separate apps: + +| Flavor | Package | Output | Updated by | Firebase project | +|---|---|---|---|---| +| `github` | `com.anonymous.bulwarkmobile` | APK on GitHub Releases | in-app updater | `bulwark-mobile` (`src/github/google-services.json`) | +| `play` | `org.bulwarkmail.mobile` | AAB for Google Play | Google Play | `bulwark-20c72` (`google-services.json`) | + +Play forbids an app updating itself outside Play, so the `play` flavor has no +`REQUEST_INSTALL_PACKAGES` (only `src/github/AndroidManifest.xml` declares it) +and `BuildConfig.DISTRIBUTION` is `"play"`, which the `BulwarkBuild` native +module hands to JS. `supportsSideloadUpdates` in `src/lib/platform-capabilities.ts` +is false there, which hides the updater. Neither flavor declares +`READ_MEDIA_IMAGES`: photos come from the system photo picker, and Play's photo +and video policy rejects the permission for that use. `RECORD_AUDIO`, which +expo-camera adds, is removed as well: QR scanning never records audio. + +Play App Signing holds the key that signs what Play delivers (generated by +Google, certificate SHA-256 `7e:e1:fd:35:…:91:fa:e3`). The workflow signs the +bundle with our release key, which Play registered as the upload key with the +first upload. The two flavors therefore have different signers too. + +The `play-bundle` job builds `bundlePlayRelease` for arm64-v8a, armeabi-v7a and +x86_64 (Play splits the bundle per ABI), checks the signer, the package name and +the permissions, and keeps the AAB and its R8 mapping as a workflow artifact. +With the `PLAY_SERVICE_ACCOUNT_JSON` secret set (vault: "Google Play – service +account bulwark-google-play-api") it also uploads the bundle to the internal +testing track as a draft. Play only accepts API uploads once a first bundle has +been uploaded by hand in the Play Console. + +The `github` flavor stays on the old Firebase project until the app registers +its new FCM token with the relay when the token changes; moving it before that +would silence push for every updated install. + ## Local release builds CI signs releases, so you normally don't need the release key on your machine. @@ -206,7 +243,8 @@ For a local release build, for profiling or checking R8, opt in to the debug key ```bash cd android -./gradlew assembleRelease -PallowDebugSignedRelease=true +./gradlew assembleGithubRelease -PallowDebugSignedRelease=true +./gradlew bundlePlayRelease -PallowDebugSignedRelease=true ``` ```powershell @@ -252,7 +290,7 @@ retrace bulwark-mobile-0.1.63-abc1234-mapping.txt stacktrace.txt `retrace` is in the Android SDK command-line tools (`cmdline-tools/latest/bin`). A local build writes its mapping to -`android/app/build/outputs/mapping/release/mapping.txt`. +`android/app/build/outputs/mapping/Release/mapping.txt`. ## versionCode diff --git a/src/lib/__tests__/platform-capabilities.test.ts b/src/lib/__tests__/platform-capabilities.test.ts new file mode 100644 index 00000000..bc739df4 --- /dev/null +++ b/src/lib/__tests__/platform-capabilities.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +async function load(os: string, nativeModules: Record) { + vi.resetModules(); + vi.doMock('react-native', () => ({ Platform: { OS: os }, NativeModules: nativeModules })); + return import('../platform-capabilities'); +} + +afterEach(() => { + vi.doUnmock('react-native'); +}); + +describe('supportsSideloadUpdates', () => { + it('is on for the GitHub build', async () => { + const caps = await load('android', { BulwarkBuild: { distribution: 'github' } }); + expect(caps.androidDistribution).toBe('github'); + expect(caps.supportsSideloadUpdates).toBe(true); + }); + + it('is off for the Play build, which Play updates', async () => { + const caps = await load('android', { BulwarkBuild: { distribution: 'play' } }); + expect(caps.androidDistribution).toBe('play'); + expect(caps.supportsSideloadUpdates).toBe(false); + }); + + it('treats a build without the module as the GitHub build', async () => { + const caps = await load('android', {}); + expect(caps.supportsSideloadUpdates).toBe(true); + }); + + it('is off on iOS', async () => { + const caps = await load('ios', {}); + expect(caps.supportsSideloadUpdates).toBe(false); + }); +}); diff --git a/src/lib/platform-capabilities.ts b/src/lib/platform-capabilities.ts index 60489183..5d548809 100644 --- a/src/lib/platform-capabilities.ts +++ b/src/lib/platform-capabilities.ts @@ -1,19 +1,28 @@ // Single source of truth for "this feature only exists on some platforms", so // the UI, the stores and the native bridges all agree on what is reachable. -import { Platform } from 'react-native'; +import { NativeModules, Platform } from 'react-native'; + +/** + * Where this Android build is distributed: the product flavor in + * android/app/build.gradle, read from the BulwarkBuild native module. A build + * without the module (older native code) is the sideloaded GitHub APK. + */ +export const androidDistribution: 'github' | 'play' = + Platform.OS === 'android' && NativeModules.BulwarkBuild?.distribution === 'play' ? 'play' : 'github'; /** * In-app sideload updates: download the release APK from GitHub and hand it to * the system package installer (see `lib/install-update`). * - * Android only. App Store Review guideline 2.5.2 forbids an iOS app from - * downloading and installing executable code, and there is no iOS equivalent of - * the installer intent anyway - iOS builds are updated through TestFlight or - * the App Store. The whole updater surface (banner, settings pane, launch-time - * check) is hidden when this is false. + * Only the GitHub build on Android. App Store Review guideline 2.5.2 forbids an + * iOS app from downloading and installing executable code, and Google Play + * forbids an app updating itself outside Play; the Play build also lacks the + * installer permission. iOS builds are updated through TestFlight or the App + * Store, the Play build through Play. The whole updater surface (banner, + * settings pane, launch-time check) is hidden when this is false. */ -export const supportsSideloadUpdates = Platform.OS === 'android'; +export const supportsSideloadUpdates = Platform.OS === 'android' && androidDistribution === 'github'; /** * Device sync: address books and calendars in Android's Contacts and Calendar diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index a122c7f1..51085d7c 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -1625,14 +1625,8 @@ export default function ComposeScreen({ route, navigation }: Props) { }, []); const pickPhotoAttachments = async () => { - const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (!perm.granted) { - Alert.alert( - t('email_composer.attach', 'Attach'), - t('email_composer.permission_photos', 'Photo library permission is required to attach images.'), - ); - return; - } + // The system photo picker needs no media permission (and Play rejects + // READ_MEDIA_IMAGES for picking a photo now and then). const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images', 'videos'], allowsMultipleSelection: true, @@ -1729,14 +1723,8 @@ export default function ComposeScreen({ route, navigation }: Props) { }; const insertInlineImage = async () => { - const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (!perm.granted) { - Alert.alert( - t('email_composer.attach', 'Attach'), - t('email_composer.permission_photos', 'Photo library permission is required to attach images.'), - ); - return; - } + // The system photo picker needs no media permission (and Play rejects + // READ_MEDIA_IMAGES for picking a photo now and then). const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsMultipleSelection: true, diff --git a/src/screens/ContactFormScreen.tsx b/src/screens/ContactFormScreen.tsx index 5e28c384..bd2586b3 100644 --- a/src/screens/ContactFormScreen.tsx +++ b/src/screens/ContactFormScreen.tsx @@ -889,14 +889,8 @@ export default function ContactFormScreen() { : [form.given, form.surname].filter(Boolean).join(' ').trim() || form.full.trim(); const pickPhoto = async () => { - const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (!perm.granted) { - Alert.alert( - t('contacts.form.photo_permission_title', 'Photo access needed'), - t('contacts.form.photo_permission_message', 'Grant photo library permission to pick a contact photo.'), - ); - return; - } + // The system photo picker needs no media permission (and Play rejects + // READ_MEDIA_IMAGES for picking a photo now and then). const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsEditing: true,