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,