Separate Android deployment identities

This commit is contained in:
SimpleTest 2026-07-23 23:39:56 +03:00
parent 591384f2b7
commit e5829bcaf2
32 changed files with 1029 additions and 151 deletions

View File

@ -72,7 +72,7 @@ PHX_CHECK_ORIGINS=
# Android debug builds compile this origin into BuildConfig. The Docker # Android debug builds compile this origin into BuildConfig. The Docker
# emulator uses adb reverse to expose the local Compose proxy on loopback. # emulator uses adb reverse to expose the local Compose proxy on loopback.
WNH_DEBUG_BASE_URL=http://localhost:4010 WNH_DEBUG_BASE_URL=http://localhost:4010
# Staging/release builds require a public HTTPS origin. Keep the value # Development/staging/release builds require a public HTTPS origin. Keep the value
# environment-specific; scripts/ensure-local-public-origin.sh can derive it # environment-specific; scripts/ensure-local-public-origin.sh can derive it
# from the three PHX_* values in the ignored .env. # from the three PHX_* values in the ignored .env.
WNH_BASE_URL= WNH_BASE_URL=
@ -91,8 +91,9 @@ WNH_FIREBASE_API_KEY=
WNH_FIREBASE_PROJECT_ID= WNH_FIREBASE_PROJECT_ID=
WNH_FIREBASE_GCM_SENDER_ID= WNH_FIREBASE_GCM_SENDER_ID=
# Verified Android App Links are configured by the web deployment rather than # Verified Android App Links are configured by the web deployment rather than
# embedded as secrets in the application. Use org.whoneedhelp.mobile.staging # embedded as secrets in the application. Use
# with the staging signing certificate on the dev checkout and # org.whoneedhelp.mobile.development with the development certificate on DEV,
# org.whoneedhelp.mobile.staging with the staging certificate on test, and
# org.whoneedhelp.mobile with every active Play signing certificate on # org.whoneedhelp.mobile with every active Play signing certificate on
# production. Keep both empty until the matching signed APK/AAB is available. # production. Keep both empty until the matching signed APK/AAB is available.
ANDROID_APP_LINKS_PACKAGE_NAME= ANDROID_APP_LINKS_PACKAGE_NAME=
@ -101,7 +102,10 @@ ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=
# keystore and its randomized password live outside the repository under # keystore and its randomized password live outside the repository under
# ~/.config/who_need_help/android-release/. # ~/.config/who_need_help/android-release/.
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload
# The dev-domain staging APK uses a different stable signing identity under # The DEV-domain APK uses a stable signing identity under
# ~/.config/who_need_help/android-development/.
WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS=who-need-help-development
# The test-domain staging APK uses a different stable signing identity under
# ~/.config/who_need_help/android-staging/. This keeps App Link verification # ~/.config/who_need_help/android-staging/. This keeps App Link verification
# reproducible without reusing the future production upload key. # reproducible without reusing the future production upload key.
WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS=who-need-help-staging WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS=who-need-help-staging

View File

@ -30,3 +30,6 @@ jobs:
- name: Exercise signed APK and Play AAB release pipeline - name: Exercise signed APK and Play AAB release pipeline
run: ./scripts/android-release-ci.sh run: ./scripts/android-release-ci.sh
- name: Exercise isolated signed development APK pipeline
run: ./scripts/android-public-ci.sh

View File

@ -61,3 +61,6 @@ jobs:
- name: Exercise signed APK and Play AAB release pipeline - name: Exercise signed APK and Play AAB release pipeline
run: ./scripts/android-release-ci.sh run: ./scripts/android-release-ci.sh
- name: Exercise isolated signed development APK pipeline
run: ./scripts/android-public-ci.sh

View File

@ -517,14 +517,21 @@ Results and failure diagnostics are retained by API under ignored
`output/android-instrumentation/`; the exact emulator container and one-run `output/android-instrumentation/`; the exact emulator container and one-run
image are removed automatically. image are removed automatically.
The public-staging variant and its instrumentation APK use the explicit HTTPS The public development and staging variants and their instrumentation APKs use
origin from the ignored `.env`. The smoke probe checks rendered WebView DOM on the explicit HTTPS origin from each checkout's ignored `.env`. Development uses
`org.whoneedhelp.mobile.development`; the independent test checkout uses
`org.whoneedhelp.mobile.staging`. The smoke probe checks rendered WebView DOM on
the home and Safety routes. The cross-client probe uses run-scoped users and a the home and Safety routes. The cross-client probe uses run-scoped users and a
matched medicine request to verify Android login, private chat in both matched medicine request to verify Android login, private chat in both
directions, foreground location sharing, browser marker appearance and directions, foreground location sharing, browser marker appearance and removal,
removal, and exact database cleanup: and exact database cleanup:
```bash ```bash
./scripts/android-development-build.sh
./scripts/android-development-smoke.sh
./scripts/android-browser-development-e2e.sh
# Run these only from the independent test checkout.
./scripts/android-staging-build.sh ./scripts/android-staging-build.sh
./scripts/android-staging-smoke.sh ./scripts/android-staging-smoke.sh
./scripts/android-browser-staging-e2e.sh ./scripts/android-browser-staging-e2e.sh

View File

@ -146,7 +146,7 @@ COPY --from=android-sdk \
/workspace/android/app/build/reports/lint-results-debug.html \ /workspace/android/app/build/reports/lint-results-debug.html \
/lint-results-debug.html /lint-results-debug.html
FROM android-base AS android-staging-sdk FROM android-base AS android-public-sdk
USER gradle USER gradle
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
@ -163,14 +163,22 @@ ARG WNH_FIREBASE_APPLICATION_ID
ARG WNH_FIREBASE_CLIENT_VALUE ARG WNH_FIREBASE_CLIENT_VALUE
ARG WNH_FIREBASE_PROJECT_ID ARG WNH_FIREBASE_PROJECT_ID
ARG WNH_FIREBASE_GCM_SENDER_ID ARG WNH_FIREBASE_GCM_SENDER_ID
ARG WNH_PUBLIC_BUILD_TYPE
ARG WNH_EXPECTED_APPLICATION_ID
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \ --mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
--mount=type=secret,id=android_staging_keystore,required=true,uid=1000,gid=1000,mode=0400 \ --mount=type=secret,id=android_nonproduction_keystore,required=true,uid=1000,gid=1000,mode=0400 \
--mount=type=secret,id=android_staging_password,required=true,uid=1000,gid=1000,mode=0400 \ --mount=type=secret,id=android_nonproduction_password,required=true,uid=1000,gid=1000,mode=0400 \
--mount=type=secret,id=android_staging_alias,required=true,env=WNH_ANDROID_SIGNING_KEY_ALIAS \ --mount=type=secret,id=android_nonproduction_alias,required=true,env=WNH_ANDROID_SIGNING_KEY_ALIAS \
WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_staging_keystore \ case "${WNH_PUBLIC_BUILD_TYPE}" in \
WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_staging_password \ development) task_name=Development ;; \
staging) task_name=Staging ;; \
*) echo "WNH_PUBLIC_BUILD_TYPE must be development or staging" >&2; exit 1 ;; \
esac \
&& test -n "${WNH_EXPECTED_APPLICATION_ID}" \
&& WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_nonproduction_keystore \
WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_nonproduction_password \
gradle --no-daemon \ gradle --no-daemon \
"-PWNH_BASE_URL=${WNH_BASE_URL}" \ "-PWNH_BASE_URL=${WNH_BASE_URL}" \
"-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \ "-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \
@ -182,38 +190,38 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
"-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \ "-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \
"-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \ "-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \
"-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \ "-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \
"-PWNH_TEST_BUILD_TYPE=staging" \ "-PWNH_TEST_BUILD_TYPE=${WNH_PUBLIC_BUILD_TYPE}" \
testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest \ "test${task_name}UnitTest" \
"lint${task_name}" \
"assemble${task_name}" \
"assemble${task_name}AndroidTest" \
&& "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \ && "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \
verify --verbose --print-certs \ verify --verbose --print-certs \
app/build/outputs/apk/staging/app-staging.apk \ "app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \
>app/build/outputs/apk/staging/signing-certificate.txt \ >"/tmp/signing-certificate.txt" \
&& "${ANDROID_HOME}/build-tools/37.0.0/aapt2" dump badging \ && "${ANDROID_HOME}/build-tools/37.0.0/aapt2" dump badging \
app/build/outputs/apk/staging/app-staging.apk \ "app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \
| sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" \ | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" \
>app/build/outputs/apk/staging/package-name.txt \ >"/tmp/package-name.txt" \
&& grep -Fx "org.whoneedhelp.mobile.staging" \ && grep -Fx "${WNH_EXPECTED_APPLICATION_ID}" "/tmp/package-name.txt" \
app/build/outputs/apk/staging/package-name.txt && mkdir -p /workspace/export \
&& cp \
"app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \
"/workspace/export/who-need-help-${WNH_PUBLIC_BUILD_TYPE}.apk" \
&& cp \
"app/build/outputs/apk/androidTest/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}-androidTest.apk" \
"/workspace/export/who-need-help-${WNH_PUBLIC_BUILD_TYPE}-androidTest.apk" \
&& cp \
"app/build/reports/lint-results-${WNH_PUBLIC_BUILD_TYPE}.html" \
"/workspace/export/lint-results-${WNH_PUBLIC_BUILD_TYPE}.html" \
&& cp /tmp/signing-certificate.txt /workspace/export/signing-certificate.txt \
&& cp /tmp/package-name.txt /workspace/export/package-name.txt
FROM scratch AS staging-artifact FROM scratch AS public-artifact
USER 65532:65532 USER 65532:65532
COPY --from=android-staging-sdk \ COPY --from=android-public-sdk /workspace/export/ /
/workspace/android/app/build/outputs/apk/staging/app-staging.apk \
/who-need-help-staging.apk
COPY --from=android-staging-sdk \
/workspace/android/app/build/outputs/apk/androidTest/staging/app-staging-androidTest.apk \
/who-need-help-staging-androidTest.apk
COPY --from=android-staging-sdk \
/workspace/android/app/build/reports/lint-results-staging.html \
/lint-results-staging.html
COPY --from=android-staging-sdk \
/workspace/android/app/build/outputs/apk/staging/signing-certificate.txt \
/signing-certificate.txt
COPY --from=android-staging-sdk \
/workspace/android/app/build/outputs/apk/staging/package-name.txt \
/package-name.txt
FROM android-base AS android-release-base FROM android-base AS android-release-base

View File

@ -87,8 +87,11 @@ build.
Google/Firebase setup is environment-specific as well: Google/Firebase setup is environment-specific as well:
- dev/staging uses package `org.whoneedhelp.mobile.staging`, its stable staging - development uses package `org.whoneedhelp.mobile.development`, its stable
signing certificate, the dev Web OAuth client, and the dev Firebase project; development certificate, the development Web OAuth client, and the
development Firebase project;
- test/staging uses package `org.whoneedhelp.mobile.staging`, its independent
staging certificate, and the test environment's provider configuration;
- production uses package `org.whoneedhelp.mobile`, the Play-distributed signing - production uses package `org.whoneedhelp.mobile`, the Play-distributed signing
certificate, the production Web OAuth client, and the production Firebase certificate, the production Web OAuth client, and the production Firebase
project; project;
@ -119,38 +122,50 @@ separate app-signing key used for distributed APKs:
- <https://support.google.com/googleplay/android-developer/answer/9859152> - <https://support.google.com/googleplay/android-developer/answer/9859152>
- <https://docs.docker.com/build/building/secrets/> - <https://docs.docker.com/build/building/secrets/>
## Public staging build ## Public development and staging builds
The installable `staging` build type uses the explicit public HTTPS The installable `development` and `staging` build types use the explicit public
`WNH_BASE_URL`, disables cleartext traffic, and has its own HTTPS `WNH_BASE_URL` and disable cleartext traffic. Development is the
`org.whoneedhelp.mobile.staging` application ID. Configure a missing local value `org.whoneedhelp.mobile.development` application connected to the development
from the existing `PHX_HOST`, `PHX_SCHEME`, and `PHX_URL_PORT`, then build: origin. Generate its dedicated signing identity once, synchronize the public
identity into the checkout's single ignored `.env`, and build:
```sh ```sh
./scripts/ensure-local-public-origin.sh ./scripts/init-android-development-signing.sh
./scripts/configure-android-development-env.sh
./scripts/android-development-build.sh
sha256sum android/dist-development/who-need-help-development.apk
```
The separate test checkout uses `org.whoneedhelp.mobile.staging`, its own
staging key, and the same workflow with test-specific inputs:
```sh
./scripts/init-android-staging-signing.sh
./scripts/android-staging-build.sh ./scripts/android-staging-build.sh
sha256sum android/dist-staging/who-need-help-staging.apk sha256sum android/dist-staging/who-need-help-staging.apk
``` ```
This variant uses the dedicated stable staging key below The two identities live below
`~/.config/who_need_help/android-staging/`. It is not the production upload `~/.config/who_need_help/android-development/` and
identity and must not be published as a production release. The manifest `~/.config/who_need_help/android-staging/`. Neither is the production upload
accepts same-origin HTTPS deep links, while verified Android App Links require identity or suitable for publication as the production application. Each
this staging certificate fingerprint in the dev deployment's deployment's `/.well-known/assetlinks.json` must contain the package and
`/.well-known/assetlinks.json`. certificate fingerprint of the APK connected to that exact origin.
With the temporary public origin reachable, run the API 37 emulator smoke test: With the matching public origin reachable, run the API 37 emulator smoke test:
```sh ```sh
./scripts/android-development-smoke.sh
./scripts/android-staging-smoke.sh ./scripts/android-staging-smoke.sh
``` ```
The script installs the exported staging APK into a fresh project-scoped Each script installs the corresponding APK into a fresh project-scoped emulator
emulator container, loads the configured HTTPS home page, follows a container, loads the configured HTTPS home page, follows a
same-origin `/safety` deep link, verifies that the package does not claim an same-origin `/safety` deep link, verifies that the package does not claim an
external HTTPS origin, and retains UI dumps, screenshots, package metadata, external HTTPS origin, and retains UI dumps, screenshots, package metadata,
and logcat diagnostics under ignored `output/android-staging-smoke/`. The and logcat diagnostics under its ignored `output/android-*-smoke/` directory.
one-run container and image are removed on success or failure. The one-run container and image are removed on success or failure.
## Reproducible Docker build ## Reproducible Docker build

View File

@ -161,15 +161,34 @@ android {
"\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" "\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
) )
manifestPlaceholders["usesCleartextTraffic"] = "true" manifestPlaceholders["usesCleartextTraffic"] = "true"
manifestPlaceholders["deepLinkScheme"] = debugManifestOrigin?.scheme ?: "https"
manifestPlaceholders["deepLinkHost"] = manifestPlaceholders["deepLinkHost"] =
debugManifestOrigin?.host ?: "invalid.whoneedhelp.local" debugManifestOrigin?.host ?: "invalid.whoneedhelp.local"
} }
create("staging") { create("development") {
initWith(getByName("debug")) initWith(getByName("debug"))
applicationIdSuffix = ".staging" applicationIdSuffix = ".development"
versionNameSuffix = "-staging" versionNameSuffix = "-development"
isDebuggable = false
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
buildConfigField(
"String",
"BASE_URL",
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
manifestPlaceholders["usesCleartextTraffic"] = "false"
manifestPlaceholders["deepLinkHost"] =
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
matchingFallbacks += listOf("debug")
}
create("staging") {
initWith(getByName("debug"))
applicationIdSuffix = ".staging"
versionNameSuffix = "-staging"
isDebuggable = false
if (releaseSigningConfigured) { if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
@ -179,7 +198,6 @@ android {
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" "\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
) )
manifestPlaceholders["usesCleartextTraffic"] = "false" manifestPlaceholders["usesCleartextTraffic"] = "false"
manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https"
manifestPlaceholders["deepLinkHost"] = manifestPlaceholders["deepLinkHost"] =
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local" releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
matchingFallbacks += listOf("debug") matchingFallbacks += listOf("debug")
@ -197,7 +215,6 @@ android {
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" "\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
) )
manifestPlaceholders["usesCleartextTraffic"] = "false" manifestPlaceholders["usesCleartextTraffic"] = "false"
manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https"
manifestPlaceholders["deepLinkHost"] = manifestPlaceholders["deepLinkHost"] =
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local" releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
proguardFiles( proguardFiles(
@ -221,7 +238,11 @@ android {
} }
} }
tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach { tasks.matching {
it.name == "preDevelopmentBuild" ||
it.name == "preStagingBuild" ||
it.name == "preReleaseBuild"
}.configureEach {
doFirst { doFirst {
validateFirebaseConfiguration() validateFirebaseConfiguration()
if (!releaseSigningConfigured) { if (!releaseSigningConfigured) {

View File

@ -40,9 +40,9 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="${deepLinkHost}" <data android:scheme="https" />
android:scheme="${deepLinkScheme}" /> <data android:host="${deepLinkHost}" />
</intent-filter> </intent-filter>
</activity> </activity>
<service <service

View File

@ -25,8 +25,11 @@ final class LaunchUrlResolver {
String debugInitialUrl, String debugInitialUrl,
boolean debugBuild boolean debugBuild
) { ) {
if (deepLinkUrl != null && trustedOrigin.matches(deepLinkUrl)) { if (deepLinkUrl != null) {
return deepLinkUrl; String appLink = trustedOrigin.canonicalAppLink(deepLinkUrl);
if (appLink != null) {
return appLink;
}
} }
if ( if (

View File

@ -86,6 +86,41 @@ final class TrustedOrigin {
} }
} }
String canonicalAppLink(String value) {
if (matches(value)) {
return value;
}
try {
URI candidate = new URI(value);
if (
!"https".equals(normalized(base.getScheme())) ||
!"http".equals(normalized(candidate.getScheme())) ||
!normalized(base.getHost()).equals(normalized(candidate.getHost())) ||
candidate.getUserInfo() != null ||
(candidate.getPort() != -1 && candidate.getPort() != 80)
) {
return null;
}
StringBuilder canonical = new StringBuilder(originRule());
if (candidate.getRawPath() != null) {
canonical.append(candidate.getRawPath());
}
if (candidate.getRawQuery() != null) {
canonical.append('?').append(candidate.getRawQuery());
}
if (candidate.getRawFragment() != null) {
canonical.append('#').append(candidate.getRawFragment());
}
return canonical.toString();
} catch (URISyntaxException exception) {
return null;
}
}
private static boolean pathIsOrigin(String path) { private static boolean pathIsOrigin(String path) {
return path == null || path.isEmpty() || "/".equals(path); return path == null || path.isEmpty() || "/".equals(path);
} }

View File

@ -43,6 +43,27 @@ public final class LaunchUrlResolverTest {
); );
} }
@Test
public void releaseUpgradesAssociatedHttpLinkToTrustedHttpsOrigin() {
assertEquals(
"https://help.example/requests/123?from=email#handover",
LaunchUrlResolver.incomingUrl(
origin,
"http://help.example/requests/123?from=email#handover",
null,
false
)
);
assertNull(
LaunchUrlResolver.incomingUrl(
origin,
"http://attacker.example/requests/123",
null,
false
)
);
}
@Test @Test
public void debugCanUseValidatedInitialUrlExtra() { public void debugCanUseValidatedInitialUrlExtra() {
assertEquals( assertEquals(

View File

@ -2,6 +2,7 @@ package org.whoneedhelp.mobile;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@ -18,6 +19,13 @@ public final class TrustedOriginTest {
assertFalse(origin.matches("http://help.example/")); assertFalse(origin.matches("http://help.example/"));
assertFalse(origin.matches("https://help.example:444/")); assertFalse(origin.matches("https://help.example:444/"));
assertEquals("https://help.example", origin.originRule()); assertEquals("https://help.example", origin.originRule());
assertEquals(
"https://help.example/requests/123?from=web#message",
origin.canonicalAppLink(
"http://help.example/requests/123?from=web#message"
)
);
assertNull(origin.canonicalAppLink("http://help.example.evil.test/"));
} }
@Test @Test

View File

@ -126,24 +126,33 @@ checkout they target. Do not create `.env.android-release`,
WNH_BASE_URL=https://dev.example.com WNH_BASE_URL=https://dev.example.com
WNH_ANDROID_VERSION_CODE=1 WNH_ANDROID_VERSION_CODE=1
WNH_ANDROID_VERSION_NAME=0.1.0 WNH_ANDROID_VERSION_NAME=0.1.0
WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS=who-need-help-staging WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS=who-need-help-development
ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.development
ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=AA:BB:... ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=AA:BB:...
``` ```
The dev checkout uses package `org.whoneedhelp.mobile.staging` and a dedicated The development checkout uses package
stable key under `~/.config/who_need_help/android-staging/`. Generate it once: `org.whoneedhelp.mobile.development` and a dedicated stable key under
`~/.config/who_need_help/android-development/`. Generate it once and place only
its public identity into the existing ignored `.env`:
```bash ```bash
./scripts/init-android-staging-signing.sh ./scripts/init-android-development-signing.sh
./scripts/android-staging-build.sh ./scripts/configure-android-development-env.sh
./scripts/android-development-build.sh
``` ```
The build exports the package and certificate reports, verifies that both match The build exports the package and certificate reports, verifies that both match
the checkout `.env`, and checks the HTTPS the checkout `.env`, and checks the HTTPS
`/.well-known/assetlinks.json` response. Losing this key changes the staging `/.well-known/assetlinks.json` response. Losing this key changes the development
certificate and breaks previously installed App Links, so back it up. certificate and breaks previously installed App Links, so back it up.
The independent test checkout uses package
`org.whoneedhelp.mobile.staging`, its own key under
`~/.config/who_need_help/android-staging/`, and
`scripts/android-staging-build.sh`. Do not copy either ignored `.env` or signing
material between development and test.
The production checkout instead uses package `org.whoneedhelp.mobile`, the The production checkout instead uses package `org.whoneedhelp.mobile`, the
separate upload material under separate upload material under
`~/.config/who_need_help/android-release/`, and its own `.env`: `~/.config/who_need_help/android-release/`, and its own `.env`:
@ -187,7 +196,9 @@ For Android Google sign-in, each environment's `GOOGLE_OAUTH_CLIENT_ID` is also
the public server client ID supplied at runtime to Credential Manager. the public server client ID supplied at runtime to Credential Manager.
`GOOGLE_OAUTH_CLIENT_SECRET` never leaves Phoenix. The Android package and `GOOGLE_OAUTH_CLIENT_SECRET` never leaves Phoenix. The Android package and
signing-certificate identity must be registered in the matching Google project: signing-certificate identity must be registered in the matching Google project:
`org.whoneedhelp.mobile.staging` plus the stable staging certificate for dev, `org.whoneedhelp.mobile.development` plus the stable development certificate
for development,
`org.whoneedhelp.mobile.staging` plus the stable staging certificate for test,
and `org.whoneedhelp.mobile` plus the Play-distributed certificate for and `org.whoneedhelp.mobile` plus the Play-distributed certificate for
production. Do not add a second Google secret file or Gradle property. production. Do not add a second Google secret file or Gradle property.

View File

@ -191,7 +191,7 @@ this audit.
| Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. | | Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. |
| Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms create separate audited queues; private email status links verify public contacts; authenticated submissions use the account email; moderator-only operations can update status and notify verified contacts. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. | | Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms create separate audited queues; private email status links verify public contacts; authenticated submissions use the account email; moderator-only operations can update status and notify verified contacts. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. |
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |
| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Existing lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests remain in place. A stable staging certificate now signs the dev APK, the HTTPS deployment publishes the matching App Links statement, and the build checks its package and SHA-256 certificate. A separate upload key produces a signed production APK and Play AAB. | Play registration/App Signing, on-device domain-verification observation, physical-device FCM delivery, unattended/background-permission tracking, and iOS are not complete. | | Android client | Local, development, test/staging, and production build identities implemented | The native packages `org.whoneedhelp.mobile.debug`, `org.whoneedhelp.mobile.development`, `org.whoneedhelp.mobile.staging`, and `org.whoneedhelp.mobile` are separated by build type and signing identity. Lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests remain in place. Ephemeral signed development and production pipelines verify package IDs, certificates, unit tests, lint, APKs and instrumentation artifacts; production also verifies the signed AAB with Bundletool. | The new development package still needs an online App Links/device observation after the controlled development rebuild. Play registration/App Signing, physical-device FCM delivery, unattended/background-permission tracking, and iOS are not complete. |
| Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | | Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
| Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. | | Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. |
| Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. | | Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. |
@ -1365,6 +1365,34 @@ None of the observations below describe the current delivery path.
unsubscribe removal remain unverified until the provider enables them and a unsubscribe removal remain unverified until the provider enables them and a
new delivered MIME is inspected. new delivered MIME is inspected.
## Development Android and provider isolation on 2026-07-23
- The development checkout now owns
`org.whoneedhelp.mobile.development`, an independent mode-`0600` signing
identity outside the repository, and matching App Links values in its one
ignored mode-`0600` `.env`. The test/staging package remains
`org.whoneedhelp.mobile.staging`; production remains
`org.whoneedhelp.mobile`.
- The signed ephemeral development pipeline passed development unit tests,
Android lint, APK and instrumentation-APK assembly, package/certificate
verification, and offline App Links identity validation. The signed
production pipeline separately passed release tests, lint, R8/resource
shrinking, APK/AAB signing checks, Bundletool validation, and production App
Links identity validation.
- The complete isolated quality/security gate passed with 352 ExUnit tests,
ShellCheck, Hadolint, actionlint, Compose/Helm validation, format/compiler,
xref, Credo, Sobelow, Dialyzer, Hex/npm audits, source scanning, and all
configured runtime image scans. Its unique Compose project, volume, and
temporary image tags were absent after cleanup.
- The development Google Web OAuth client is configured for the exact
`https://whoneedhelp.imalto.site` origin and callback, and its ID/secret are
present only in the ignored development `.env`. The currently running
development containers have not yet been rebuilt with that configuration.
- Firebase Android and server FCM credentials are intentionally still absent.
Adding Firebase to the existing Google Cloud development project is pending
explicit acceptance of the separate Firebase terms. No test or production
provider configuration was changed.
## Known work before a public production launch ## Known work before a public production launch
- Promote the tested release from `test.whoneedhelp.com` to the independent - Promote the tested release from `test.whoneedhelp.com` to the independent
@ -1373,13 +1401,14 @@ None of the observations below describe the current delivery path.
after that promotion; the current test origin still depends on its configured after that promotion; the current test origin still depends on its configured
workstation/VPN/gateway path. workstation/VPN/gateway path.
- The final Android application ID is `org.whoneedhelp.mobile`. The application - The final Android application ID is `org.whoneedhelp.mobile`. The application
now publishes environment-specific `/.well-known/assetlinks.json`, and the publishes environment-specific `/.well-known/assetlinks.json`. The previous
stable-signed dev APK was checked against its HTTPS response. Before a Play staging identity was checked against its HTTPS response; repeat that online
release, register the application, add the Play App Signing certificate check for the new `org.whoneedhelp.mobile.development` identity after the
fingerprint alongside any sideload/upload fingerprint, repeat Android's controlled development rebuild. Before a Play release, register the
domain verification on a device, and complete store policy/release work. A application, add the Play App Signing certificate fingerprint alongside any
dedicated upload key and signed APK/AAB exist, but no Play application has sideload/upload fingerprint, repeat Android's domain verification on a
been registered. device, and complete store policy/release work. A dedicated upload key and
signed APK/AAB exist, but no Play application has been registered.
- Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring, - Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring,
and the availability model selected for real usage. and the availability model selected for real usage.
- The development Brevo SMTP transport and sender have completed an external - The development Brevo SMTP transport and sender have completed an external
@ -1391,8 +1420,9 @@ None of the observations below describe the current delivery path.
Google OAuth client on its exact HTTPS callback origin after the tested Google OAuth client on its exact HTTPS callback origin after the tested
release is explicitly promoted. The test client and callback have already release is explicitly promoted. The test client and callback have already
completed real registration and returning-user login. completed real registration and returning-user login.
- Configure environment-specific VAPID and Firebase credentials, then verify a - Development VAPID is configured. Configure isolated Firebase/FCM credentials,
real browser subscription and Android device against each deployed origin. then verify a real browser subscription and Android device against each
deployed origin.
Direct Web Push/FCM adapters, registration lifecycle, private payload shape, Direct Web Push/FCM adapters, registration lifecycle, private payload shape,
retries, invalid-device cleanup, and Android deep-link handling are retries, invalid-device cleanup, and Android deep-link handling are
implemented and locally tested; real provider/device delivery is not yet implemented and locally tested; real provider/device delivery is not yet

View File

@ -0,0 +1,7 @@
#!/bin/sh
set -eu
WNH_ANDROID_PUBLIC_VARIANT=development
export WNH_ANDROID_PUBLIC_VARIANT
exec "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)/android-browser-staging-e2e.sh" "$@"

View File

@ -6,8 +6,27 @@ ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT" cd "$ROOT"
ENV_FILE="$ROOT/.env" ENV_FILE="$ROOT/.env"
APK="$ROOT/android/dist-staging/who-need-help-staging.apk" variant=${WNH_ANDROID_PUBLIC_VARIANT:-staging}
TEST_APK="$ROOT/android/dist-staging/who-need-help-staging-androidTest.apk"
case "$variant" in
development)
package=org.whoneedhelp.mobile.development
expected_deployment_env=development
build_script=scripts/android-development-build.sh
;;
staging)
package=org.whoneedhelp.mobile.staging
expected_deployment_env="test"
build_script=scripts/android-staging-build.sh
;;
*)
echo "WNH_ANDROID_PUBLIC_VARIANT must be development or staging." >&2
exit 2
;;
esac
APK="$ROOT/android/dist-$variant/who-need-help-$variant.apk"
TEST_APK="$ROOT/android/dist-$variant/who-need-help-$variant-androidTest.apk"
remote_target= remote_target=
remote_host= remote_host=
remote_root= remote_root=
@ -15,13 +34,13 @@ remote_output_dir=
remote_tools_image= remote_tools_image=
ssh_tunnel_pid= ssh_tunnel_pid=
run_id="$(date -u +%Y%m%d%H%M%S)-$$" run_id="$(date -u +%Y%m%d%H%M%S)-$$"
output_dir="$ROOT/output/android-browser-staging-e2e/$run_id" output_root="$ROOT/output/android-browser-$variant-e2e"
output_dir="$output_root/$run_id"
tools_image="who-need-help:android-e2e-tools-$run_id" tools_image="who-need-help:android-e2e-tools-$run_id"
browser_image="who-need-help-e2e-tests:android-$run_id" browser_image="who-need-help-e2e-tests:android-$run_id"
android_image="who-need-help-android:cross-client-$run_id" android_image="who-need-help-android:$variant-cross-client-$run_id"
container="who-need-help-android-cross-client-$run_id" container="who-need-help-android-$variant-cross-client-$run_id"
avd_volume="who-need-help-android-avd-cross-client-$run_id" avd_volume="who-need-help-android-avd-$variant-cross-client-$run_id"
package=org.whoneedhelp.mobile.staging
service_class=org.whoneedhelp.mobile.TrackingService service_class=org.whoneedhelp.mobile.TrackingService
fixture_password="$(openssl rand -hex 24)" fixture_password="$(openssl rand -hex 24)"
android_message="android-$run_id" android_message="android-$run_id"
@ -46,6 +65,11 @@ case "$#" in
esac esac
if [[ -n "$remote_target" ]]; then if [[ -n "$remote_target" ]]; then
if [[ "$variant" != staging ]]; then
echo "Remote cross-client E2E is restricted to the isolated test/staging deployment." >&2
exit 1
fi
case "$remote_target" in case "$remote_target" in
*:/*) *:/*)
remote_host=${remote_target%%:*} remote_host=${remote_target%%:*}
@ -85,13 +109,17 @@ if [[ ! -f "$ENV_FILE" ]]; then
exit 1 exit 1
fi fi
"$ROOT/scripts/validate-android-environment.sh" \
"$ENV_FILE" \
"$expected_deployment_env"
if [[ ! -s "$APK" ]]; then if [[ ! -s "$APK" ]]; then
echo "Missing staging APK: $APK. Run scripts/android-staging-build.sh first." >&2 echo "Missing $variant APK: $APK. Run $build_script first." >&2
exit 1 exit 1
fi fi
if [[ ! -s "$TEST_APK" ]]; then if [[ ! -s "$TEST_APK" ]]; then
echo "Missing staging test APK: $TEST_APK. Run scripts/android-staging-build.sh first." >&2 echo "Missing $variant test APK: $TEST_APK. Run $build_script first." >&2
exit 1 exit 1
fi fi
@ -100,6 +128,11 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
if [[ -z "$remote_target" && "${DEPLOYMENT_ENV:-}" != "$expected_deployment_env" ]]; then
echo "The $variant cross-client E2E requires DEPLOYMENT_ENV=$expected_deployment_env." >&2
exit 1
fi
if [[ -z "$remote_target" && "${DATABASE_MODE:-container}" != container ]]; then if [[ -z "$remote_target" && "${DATABASE_MODE:-container}" != container ]]; then
echo "This rollback-based Android/browser drill requires DATABASE_MODE=container." >&2 echo "This rollback-based Android/browser drill requires DATABASE_MODE=container." >&2
exit 1 exit 1
@ -202,7 +235,7 @@ esac
mailpit_url="http://${mailpit_host}:${MAILPIT_PORT}" mailpit_url="http://${mailpit_host}:${MAILPIT_PORT}"
mkdir -p "$output_dir" mkdir -p "$output_dir"
chmod 700 "$ROOT/output" "$ROOT/output/android-browser-staging-e2e" "$output_dir" chmod 700 "$ROOT/output" "$output_root" "$output_dir"
if [[ -n "$remote_target" ]]; then if [[ -n "$remote_target" ]]; then
remote_runtime=$( remote_runtime=$(
@ -424,7 +457,7 @@ start_android_phase() {
-e request_path "$request_path" \ -e request_path "$request_path" \
-e android_message "$android_message" \ -e android_message "$android_message" \
-e browser_reply "$browser_reply" \ -e browser_reply "$browser_reply" \
org.whoneedhelp.mobile.staging.test/androidx.test.runner.AndroidJUnitRunner \ "${package}.test/androidx.test.runner.AndroidJUnitRunner" \
>"$android_runner_output" 2>&1 & >"$android_runner_output" 2>&1 &
android_runner_pid=$! android_runner_pid=$!
} }
@ -462,7 +495,7 @@ cleanup() {
if [[ "$prepared" -eq 1 ]]; then if [[ "$prepared" -eq 1 ]]; then
if ! run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then if ! run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then
echo "Exact Android/browser staging cleanup failed; inspect $output_dir." >&2 echo "Exact Android/browser $variant cleanup failed; inspect $output_dir." >&2
status=1 status=1
else else
snapshot_database "$output_dir/database-after.txt" snapshot_database "$output_dir/database-after.txt"
@ -670,10 +703,10 @@ adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0 adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0 adb shell settings put global animator_duration_scale 0
adb shell cmd location set-location-enabled true adb shell cmd location set-location-enabled true
docker cp "$APK" "$container:/tmp/who-need-help-staging.apk" docker cp "$APK" "$container:/tmp/who-need-help-public.apk"
docker cp "$TEST_APK" "$container:/tmp/who-need-help-staging-androidTest.apk" docker cp "$TEST_APK" "$container:/tmp/who-need-help-public-androidTest.apk"
adb install -r /tmp/who-need-help-staging.apk >"$output_dir/install.txt" adb install -r /tmp/who-need-help-public.apk >"$output_dir/install.txt"
adb install -r /tmp/who-need-help-staging-androidTest.apk \ adb install -r /tmp/who-need-help-public-androidTest.apk \
>"$output_dir/test-install.txt" >"$output_dir/test-install.txt"
adb shell pm list instrumentation >"$output_dir/instrumentation.txt" adb shell pm list instrumentation >"$output_dir/instrumentation.txt"
adb shell pm grant "$package" android.permission.ACCESS_FINE_LOCATION adb shell pm grant "$package" android.permission.ACCESS_FINE_LOCATION
@ -691,7 +724,7 @@ for _attempt in $(seq 1 45); do
done done
if [[ "$network_ready" -ne 1 ]]; then if [[ "$network_ready" -ne 1 ]]; then
echo "The Android emulator could not resolve and reach the staging host." >&2 echo "The Android emulator could not resolve and reach the $variant host." >&2
exit 1 exit 1
fi fi
@ -787,5 +820,5 @@ fi
printf 'load_tls_browser_or_fatal_errors=0\n' printf 'load_tls_browser_or_fatal_errors=0\n'
} >"$output_dir/summary.txt" } >"$output_dir/summary.txt"
echo "Android/browser public staging E2E passed." echo "Android/browser public $variant E2E passed."
echo "Evidence: $output_dir" echo "Evidence: $output_dir"

View File

@ -0,0 +1,64 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env"
config_home=${XDG_CONFIG_HOME:-"$HOME/.config"}
SIGNING_DIR=${WNH_ANDROID_DEVELOPMENT_SIGNING_DIR:-"$config_home/who_need_help/android-development"}
KEYSTORE="$SIGNING_DIR/who-need-help-development.p12"
PASSWORD_FILE="$SIGNING_DIR/who-need-help-development.password"
"$ROOT/scripts/ensure-local-public-origin.sh"
"$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" development
set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a
: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}"
: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}"
: "${WNH_ANDROID_VERSION_CODE:?Set WNH_ANDROID_VERSION_CODE in .env}"
: "${WNH_ANDROID_VERSION_NAME:?Set WNH_ANDROID_VERSION_NAME in .env}"
: "${WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS:?Set WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS in .env}"
for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do
if [ ! -f "$secret_file" ]; then
echo "Missing Android development signing file: $secret_file" >&2
echo "Run scripts/init-android-development-signing.sh once." >&2
exit 1
fi
mode=$(stat -c '%a' "$secret_file")
case "$mode" in
400|600) ;;
*)
echo "Android development signing file must have mode 0400 or 0600: $secret_file" >&2
exit 1
;;
esac
done
docker build \
--secret "id=android_nonproduction_keystore,src=$KEYSTORE" \
--secret "id=android_nonproduction_password,src=$PASSWORD_FILE" \
--secret "id=android_nonproduction_alias,env=WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS" \
--build-arg "WNH_BASE_URL=$WNH_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
--build-arg "WNH_ANDROID_VERSION_CODE=$WNH_ANDROID_VERSION_CODE" \
--build-arg "WNH_ANDROID_VERSION_NAME=$WNH_ANDROID_VERSION_NAME" \
--build-arg "WNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID:-}" \
--build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \
--build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \
--build-arg "WNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID:-}" \
--build-arg "WNH_PUBLIC_BUILD_TYPE=development" \
--build-arg "WNH_EXPECTED_APPLICATION_ID=org.whoneedhelp.mobile.development" \
--target public-artifact \
--output "type=local,dest=$ROOT/android/dist-development" \
"$ROOT/android"
"$ROOT/scripts/android-app-links-verify.sh" \
"$ENV_FILE" \
"$ROOT/android/dist-development" \
--online

View File

@ -0,0 +1,7 @@
#!/bin/sh
set -eu
WNH_ANDROID_PUBLIC_VARIANT=development
export WNH_ANDROID_PUBLIC_VARIANT
exec "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)/android-staging-smoke.sh" "$@"

78
scripts/android-public-ci.sh Executable file
View File

@ -0,0 +1,78 @@
#!/bin/sh
set -eu
umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
workspace=$(mktemp -d "${TMPDIR:-/tmp}/wnh-android-public-ci.XXXXXX")
signing_dir="$workspace/signing"
environment_file="$workspace/development.env"
output_dir="$workspace/output"
key_alias=who-need-help-ci-development
base_url=https://android-development-ci.invalid
WNH_ANDROID_SIGNING_KEY_ALIAS=$key_alias
export WNH_ANDROID_SIGNING_KEY_ALIAS
cleanup() {
rm -rf "$workspace"
}
trap cleanup EXIT HUP INT TERM
WNH_ANDROID_SIGNING_DIR="$signing_dir" \
WNH_ANDROID_SIGNING_BASENAME=who-need-help-development \
WNH_ANDROID_SIGNING_KEY_ALIAS="$key_alias" \
WNH_ANDROID_SIGNING_SUBJECT="CN=Who Need Help CI development key" \
"$ROOT/scripts/init-android-release-signing.sh" >/dev/null
fingerprint=$(
"$ROOT/scripts/android-signing-fingerprint.sh" \
"$signing_dir/who-need-help-development.p12" \
"$signing_dir/who-need-help-development.password" \
"$key_alias"
)
printf '%s\n' \
'DEPLOYMENT_ENV=development' \
'PHX_HOST=android-development-ci.invalid' \
'PHX_SCHEME=https' \
'PHX_URL_PORT=443' \
"WNH_BASE_URL=$base_url" \
"WNH_DEBUG_BASE_URL=$base_url" \
'WNH_TRACKING_MIN_TIME_MS=5000' \
'WNH_TRACKING_HTTP_TIMEOUT_MS=15000' \
'WNH_ANDROID_VERSION_CODE=1' \
'WNH_ANDROID_VERSION_NAME=0.1.0-ci' \
"WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS=$key_alias" \
'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.development' \
"ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$fingerprint" \
>"$environment_file"
chmod 600 "$environment_file"
"$ROOT/scripts/validate-android-environment.sh" \
"$environment_file" \
development
docker build \
--secret "id=android_nonproduction_keystore,src=$signing_dir/who-need-help-development.p12" \
--secret "id=android_nonproduction_password,src=$signing_dir/who-need-help-development.password" \
--secret "id=android_nonproduction_alias,env=WNH_ANDROID_SIGNING_KEY_ALIAS" \
--build-arg "WNH_BASE_URL=$base_url" \
--build-arg WNH_TRACKING_MIN_TIME_MS=5000 \
--build-arg WNH_TRACKING_HTTP_TIMEOUT_MS=15000 \
--build-arg WNH_ANDROID_VERSION_CODE=1 \
--build-arg WNH_ANDROID_VERSION_NAME=0.1.0-ci \
--build-arg WNH_PUBLIC_BUILD_TYPE=development \
--build-arg WNH_EXPECTED_APPLICATION_ID=org.whoneedhelp.mobile.development \
--target public-artifact \
--output "type=local,dest=$output_dir" \
"$ROOT/android"
"$ROOT/scripts/android-app-links-verify.sh" \
"$environment_file" \
"$output_dir"
test -s "$output_dir/who-need-help-development.apk"
test -s "$output_dir/who-need-help-development-androidTest.apk"
test -s "$output_dir/lint-results-development.html"
echo "Ephemeral signed Android development pipeline passed."

View File

@ -24,6 +24,8 @@ set -a
. "$ENV_FILE" . "$ENV_FILE"
set +a set +a
"$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" production
: "${WNH_BASE_URL:?Set WNH_BASE_URL in the selected environment file}" : "${WNH_BASE_URL:?Set WNH_BASE_URL in the selected environment file}"
: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in the selected environment file}" : "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in the selected environment file}"
: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in the selected environment file}" : "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in the selected environment file}"
@ -84,10 +86,7 @@ for artifact in \
fi fi
done done
if [ -n "${ANDROID_APP_LINKS_PACKAGE_NAME:-}" ] || "$ROOT/scripts/android-app-links-verify.sh" "$ENV_FILE" "$OUTPUT_DIR"
[ -n "${ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}" ]; then
"$ROOT/scripts/android-app-links-verify.sh" "$ENV_FILE" "$OUTPUT_DIR"
fi
sha256sum \ sha256sum \
"$OUTPUT_DIR/who-need-help-release.apk" \ "$OUTPUT_DIR/who-need-help-release.apk" \

View File

@ -8,26 +8,42 @@ workspace=$(mktemp -d "${TMPDIR:-/tmp}/wnh-android-release-ci.XXXXXX")
signing_dir="$workspace/signing" signing_dir="$workspace/signing"
environment_file="$workspace/release.env" environment_file="$workspace/release.env"
output_dir="$workspace/output" output_dir="$workspace/output"
key_alias=who-need-help-ci-upload
base_url=https://android-release-ci.invalid
cleanup() { cleanup() {
rm -rf "$workspace" rm -rf "$workspace"
} }
trap cleanup EXIT HUP INT TERM trap cleanup EXIT HUP INT TERM
WNH_ANDROID_SIGNING_DIR="$signing_dir" \
WNH_ANDROID_SIGNING_KEY_ALIAS="$key_alias" \
"$ROOT/scripts/init-android-release-signing.sh" >/dev/null
fingerprint=$(
"$ROOT/scripts/android-signing-fingerprint.sh" \
"$signing_dir/who-need-help-upload.p12" \
"$signing_dir/who-need-help-upload.password" \
"$key_alias"
)
printf '%s\n' \ printf '%s\n' \
'WNH_BASE_URL=https://android-release-ci.invalid' \ 'DEPLOYMENT_ENV=production' \
'PHX_HOST=android-release-ci.invalid' \
'PHX_SCHEME=https' \
'PHX_URL_PORT=443' \
"WNH_BASE_URL=$base_url" \
"WNH_DEBUG_BASE_URL=$base_url" \
'WNH_TRACKING_MIN_TIME_MS=5000' \ 'WNH_TRACKING_MIN_TIME_MS=5000' \
'WNH_TRACKING_HTTP_TIMEOUT_MS=15000' \ 'WNH_TRACKING_HTTP_TIMEOUT_MS=15000' \
'WNH_ANDROID_VERSION_CODE=1' \ 'WNH_ANDROID_VERSION_CODE=1' \
'WNH_ANDROID_VERSION_NAME=0.1.0-ci' \ 'WNH_ANDROID_VERSION_NAME=0.1.0-ci' \
'WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-ci-upload' \ "WNH_ANDROID_SIGNING_KEY_ALIAS=$key_alias" \
'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile' \
"ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$fingerprint" \
>"$environment_file" >"$environment_file"
chmod 600 "$environment_file" chmod 600 "$environment_file"
WNH_ANDROID_SIGNING_DIR="$signing_dir" \
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-ci-upload \
"$ROOT/scripts/init-android-release-signing.sh" >/dev/null
WNH_ANDROID_SIGNING_DIR="$signing_dir" \ WNH_ANDROID_SIGNING_DIR="$signing_dir" \
WNH_ANDROID_RELEASE_OUTPUT_DIR="$output_dir" \ WNH_ANDROID_RELEASE_OUTPUT_DIR="$output_dir" \
WNH_ENV_FILE="$environment_file" \ WNH_ENV_FILE="$environment_file" \

View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
keystore=${1:-}
password_file=${2:-}
key_alias=${3:-}
key_image="gradle:9.6.1-jdk17@sha256:7364ce528f33bb6038672bcef990d524f1ad8fbc292935819c235db886d0fae7"
if [[ -z "$keystore" || -z "$password_file" || -z "$key_alias" ]]; then
echo "Usage: $0 KEYSTORE PASSWORD_FILE KEY_ALIAS" >&2
exit 2
fi
keystore=$(realpath "$keystore")
password_file=$(realpath "$password_file")
keystore_dir=$(dirname "$keystore")
password_dir=$(dirname "$password_file")
for secret_file in "$keystore" "$password_file"; do
[[ -f "$secret_file" && ! -L "$secret_file" ]] || {
echo "Android signing input must be a regular non-symlink file: $secret_file" >&2
exit 1
}
case "$(stat -c '%a' "$secret_file")" in
400 | 600) ;;
*)
echo "Android signing input must have mode 0400 or 0600: $secret_file" >&2
exit 1
;;
esac
done
case "$key_alias" in
'' | *[!A-Za-z0-9._-]*)
echo "Android signing alias contains unsupported characters." >&2
exit 1
;;
esac
mounts=(
--mount "type=bind,src=$keystore_dir,dst=/keystore,readonly"
)
password_container_dir=/password
if [[ "$password_dir" == "$keystore_dir" ]]; then
password_container_dir=/keystore
else
mounts+=(--mount "type=bind,src=$password_dir,dst=/password,readonly")
fi
report=$(
docker run --rm \
--user "$(id -u):$(id -g)" \
"${mounts[@]}" \
--entrypoint keytool \
"$key_image" \
-list -v \
-keystore "/keystore/$(basename "$keystore")" \
-storetype PKCS12 \
-storepass:file "$password_container_dir/$(basename "$password_file")" \
-alias "$key_alias"
)
fingerprint=$(
awk -F': ' '
/^[[:space:]]*SHA256:/ {
print $2
found = 1
exit
}
END { if (!found) exit 1 }
' <<<"$report"
) || {
echo "The signing certificate SHA-256 fingerprint was not found." >&2
exit 1
}
[[ "${fingerprint//:/}" =~ ^[0-9A-Fa-f]{64}$ ]] || {
echo "The signing certificate SHA-256 fingerprint is malformed." >&2
exit 1
}
printf '%s\n' "$(tr '[:lower:]' '[:upper:]' <<<"$fingerprint")"

View File

@ -9,6 +9,7 @@ KEYSTORE="$SIGNING_DIR/who-need-help-staging.p12"
PASSWORD_FILE="$SIGNING_DIR/who-need-help-staging.password" PASSWORD_FILE="$SIGNING_DIR/who-need-help-staging.password"
"$ROOT/scripts/ensure-local-public-origin.sh" "$ROOT/scripts/ensure-local-public-origin.sh"
"$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" test
set -a set -a
# shellcheck source=/dev/null # shellcheck source=/dev/null
@ -40,9 +41,9 @@ for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do
done done
docker build \ docker build \
--secret "id=android_staging_keystore,src=$KEYSTORE" \ --secret "id=android_nonproduction_keystore,src=$KEYSTORE" \
--secret "id=android_staging_password,src=$PASSWORD_FILE" \ --secret "id=android_nonproduction_password,src=$PASSWORD_FILE" \
--secret "id=android_staging_alias,env=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS" \ --secret "id=android_nonproduction_alias,env=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS" \
--build-arg "WNH_BASE_URL=$WNH_BASE_URL" \ --build-arg "WNH_BASE_URL=$WNH_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \
--build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \ --build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \
@ -52,7 +53,9 @@ docker build \
--build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \ --build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \
--build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \ --build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \
--build-arg "WNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID:-}" \ --build-arg "WNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID:-}" \
--target staging-artifact \ --build-arg "WNH_PUBLIC_BUILD_TYPE=staging" \
--build-arg "WNH_EXPECTED_APPLICATION_ID=org.whoneedhelp.mobile.staging" \
--target public-artifact \
--output "type=local,dest=$ROOT/android/dist-staging" \ --output "type=local,dest=$ROOT/android/dist-staging" \
"$ROOT/android" "$ROOT/android"

View File

@ -4,14 +4,33 @@ umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env" ENV_FILE="$ROOT/.env"
APK="$ROOT/android/dist-staging/who-need-help-staging.apk" variant=${WNH_ANDROID_PUBLIC_VARIANT:-staging}
TEST_APK="$ROOT/android/dist-staging/who-need-help-staging-androidTest.apk"
case "$variant" in
development)
package=org.whoneedhelp.mobile.development
validation_environment=development
build_script=scripts/android-development-build.sh
;;
staging)
package=org.whoneedhelp.mobile.staging
validation_environment="test"
build_script=scripts/android-staging-build.sh
;;
*)
echo "WNH_ANDROID_PUBLIC_VARIANT must be development or staging." >&2
exit 2
;;
esac
APK="$ROOT/android/dist-$variant/who-need-help-$variant.apk"
TEST_APK="$ROOT/android/dist-$variant/who-need-help-$variant-androidTest.apk"
run_id=$(date -u +%Y%m%d%H%M%S)-$$ run_id=$(date -u +%Y%m%d%H%M%S)-$$
image="who-need-help-android:staging-smoke-$run_id" image="who-need-help-android:$variant-smoke-$run_id"
container="who-need-help-android-staging-smoke-$run_id" container="who-need-help-android-$variant-smoke-$run_id"
avd_volume="who-need-help-android-avd-staging-smoke-$run_id" avd_volume="who-need-help-android-avd-$variant-smoke-$run_id"
output="$ROOT/output/android-staging-smoke/$run_id" output_root="$ROOT/output/android-$variant-smoke"
package=org.whoneedhelp.mobile.staging output="$output_root/$run_id"
activity=org.whoneedhelp.mobile.MainActivity activity=org.whoneedhelp.mobile.MainActivity
if [ ! -e /dev/kvm ]; then if [ ! -e /dev/kvm ]; then
@ -24,13 +43,17 @@ if [ ! -f "$ENV_FILE" ]; then
exit 1 exit 1
fi fi
"$ROOT/scripts/validate-android-environment.sh" \
"$ENV_FILE" \
"$validation_environment"
if [ ! -s "$APK" ]; then if [ ! -s "$APK" ]; then
echo "Missing staging APK: $APK. Run scripts/android-staging-build.sh first." >&2 echo "Missing $variant APK: $APK. Run $build_script first." >&2
exit 1 exit 1
fi fi
if [ ! -s "$TEST_APK" ]; then if [ ! -s "$TEST_APK" ]; then
echo "Missing staging test APK: $TEST_APK. Run scripts/android-staging-build.sh first." >&2 echo "Missing $variant test APK: $TEST_APK. Run $build_script first." >&2
exit 1 exit 1
fi fi
@ -42,6 +65,7 @@ set +a
: "${WNH_BASE_URL:?Set WNH_BASE_URL in .env}" : "${WNH_BASE_URL:?Set WNH_BASE_URL in .env}"
: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}" : "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}"
: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}" : "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}"
: "${WNH_ANDROID_VERSION_NAME:?Set WNH_ANDROID_VERSION_NAME in .env}"
case "$WNH_BASE_URL" in case "$WNH_BASE_URL" in
https://*/* | https://*) ;; https://*/* | https://*) ;;
@ -63,7 +87,7 @@ esac
expected_host=${origin_without_scheme%%:*} expected_host=${origin_without_scheme%%:*}
mkdir -p "$output" mkdir -p "$output"
chmod 700 "$ROOT/output" "$ROOT/output/android-staging-smoke" "$output" chmod 700 "$ROOT/output" "$output_root" "$output"
cleanup() { cleanup() {
status=$? status=$?
@ -133,7 +157,7 @@ docker run -d \
if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != "true" ]; then if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != "true" ]; then
docker logs "$container" >"$output/emulator.log" 2>&1 || true docker logs "$container" >"$output/emulator.log" 2>&1 || true
echo "Android staging emulator exited before ADB became available; inspect $output/emulator.log." >&2 echo "Android $variant emulator exited before ADB became available; inspect $output/emulator.log." >&2
exit 1 exit 1
fi fi
@ -156,7 +180,7 @@ while [ "$attempt" -lt 90 ]; do
done done
if [ "$booted" != "1" ]; then if [ "$booted" != "1" ]; then
echo "Android staging emulator did not finish booting." >&2 echo "Android $variant emulator did not finish booting." >&2
exit 1 exit 1
fi fi
@ -164,11 +188,11 @@ docker exec "$container" adb shell input keyevent 82
docker exec "$container" adb shell settings put global window_animation_scale 0 docker exec "$container" adb shell settings put global window_animation_scale 0
docker exec "$container" adb shell settings put global transition_animation_scale 0 docker exec "$container" adb shell settings put global transition_animation_scale 0
docker exec "$container" adb shell settings put global animator_duration_scale 0 docker exec "$container" adb shell settings put global animator_duration_scale 0
docker cp "$APK" "$container:/tmp/who-need-help-staging.apk" docker cp "$APK" "$container:/tmp/who-need-help-public.apk"
docker cp "$TEST_APK" "$container:/tmp/who-need-help-staging-androidTest.apk" docker cp "$TEST_APK" "$container:/tmp/who-need-help-public-androidTest.apk"
docker exec "$container" adb install -r /tmp/who-need-help-staging.apk \ docker exec "$container" adb install -r /tmp/who-need-help-public.apk \
>"$output/install.txt" >"$output/install.txt"
docker exec "$container" adb install -r /tmp/who-need-help-staging-androidTest.apk \ docker exec "$container" adb install -r /tmp/who-need-help-public-androidTest.apk \
>"$output/test-install.txt" >"$output/test-install.txt"
docker exec "$container" adb shell pm list instrumentation \ docker exec "$container" adb shell pm list instrumentation \
>"$output/instrumentation.txt" >"$output/instrumentation.txt"
@ -189,12 +213,12 @@ while [ "$attempt" -lt 45 ]; do
done done
if [ "$network_ready" != true ]; then if [ "$network_ready" != true ]; then
echo "The Android emulator could not resolve and reach the staging host." >&2 echo "The Android emulator could not resolve and reach the $variant host." >&2
exit 1 exit 1
fi fi
if ! grep -Fq "versionName=0.1.0-staging" "$output/package.txt"; then if ! grep -Fq "versionName=$WNH_ANDROID_VERSION_NAME-$variant" "$output/package.txt"; then
echo "The installed package is not the expected staging variant." >&2 echo "The installed package is not the expected $variant variant." >&2
exit 1 exit 1
fi fi
@ -202,12 +226,12 @@ same_origin="$WNH_BASE_URL/safety"
external_origin=https://example.com/ external_origin=https://example.com/
if ! grep -Fq "Authority: \"$expected_host\"" "$output/package.txt"; then if ! grep -Fq "Authority: \"$expected_host\"" "$output/package.txt"; then
echo "The staging APK does not declare its exact HTTPS host." >&2 echo "The $variant APK does not declare its exact HTTPS host." >&2
exit 1 exit 1
fi fi
if grep -Fq 'Authority: "example.com"' "$output/package.txt"; then if grep -Fq 'Authority: "example.com"' "$output/package.txt"; then
echo "The staging APK incorrectly declares an external HTTPS host." >&2 echo "The $variant APK incorrectly declares an external HTTPS host." >&2
exit 1 exit 1
fi fi
@ -222,7 +246,7 @@ docker exec "$container" adb shell cmd package resolve-activity --brief \
-d "$external_origin" >"$output/external-origin-resolver.txt" -d "$external_origin" >"$output/external-origin-resolver.txt"
if grep -Fq "$package/" "$output/external-origin-resolver.txt"; then if grep -Fq "$package/" "$output/external-origin-resolver.txt"; then
echo "The staging APK incorrectly claimed an external HTTPS origin." >&2 echo "The $variant APK incorrectly claimed an external HTTPS origin." >&2
exit 1 exit 1
fi fi
@ -249,7 +273,7 @@ while [ "$attempt" -lt 45 ]; do
done done
if [ "$home_loaded" != true ]; then if [ "$home_loaded" != true ]; then
echo "The staging WebView did not finish loading the public home page." >&2 echo "The $variant WebView did not finish loading the public home page." >&2
exit 1 exit 1
fi fi
@ -294,7 +318,7 @@ docker exec "$container" adb exec-out screencap -p >"$output/safety.png"
set +e set +e
docker exec "$container" adb shell am instrument -w -r \ docker exec "$container" adb shell am instrument -w -r \
-e class org.whoneedhelp.mobile.PublicStagingInstrumentedTest \ -e class org.whoneedhelp.mobile.PublicStagingInstrumentedTest \
org.whoneedhelp.mobile.staging.test/androidx.test.runner.AndroidJUnitRunner \ "${package}.test/androidx.test.runner.AndroidJUnitRunner" \
>"$output/dom-results.txt" 2>&1 >"$output/dom-results.txt" 2>&1
dom_status=$? dom_status=$?
set -e set -e
@ -304,7 +328,7 @@ if [ "$dom_status" -ne 0 ] \
|| grep -Eq 'FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed' \ || grep -Eq 'FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed' \
"$output/dom-results.txt"; then "$output/dom-results.txt"; then
cat "$output/dom-results.txt" >&2 cat "$output/dom-results.txt" >&2
echo "The staging WebView DOM assertions failed." >&2 echo "The $variant WebView DOM assertions failed." >&2
exit 1 exit 1
fi fi
@ -316,12 +340,12 @@ docker exec "$container" adb shell dumpsys activity activities \
if grep -Eqi \ if grep -Eqi \
'Main-frame load failed|net::ERR_|ERR_CERT|SSL handshake failed|chromium.*crash' \ 'Main-frame load failed|net::ERR_|ERR_CERT|SSL handshake failed|chromium.*crash' \
"$output/webview-after-dom.txt"; then "$output/webview-after-dom.txt"; then
echo "Android staging logcat contains a public-page load or TLS failure." >&2 echo "Android $variant logcat contains a public-page load or TLS failure." >&2
exit 1 exit 1
fi fi
if ! grep -Fq 'INSTRUMENTATION_CODE: -1' "$output/dom-results.txt"; then if ! grep -Fq 'INSTRUMENTATION_CODE: -1' "$output/dom-results.txt"; then
echo "The staging DOM runner did not finish normally." >&2 echo "The $variant DOM runner did not finish normally." >&2
exit 1 exit 1
fi fi
@ -339,5 +363,5 @@ fi
printf 'load_or_tls_errors=0\n' printf 'load_or_tls_errors=0\n'
} >"$output/summary.txt" } >"$output/summary.txt"
echo "Android public staging smoke passed." echo "Android public $variant smoke passed."
echo "Evidence: $output" echo "Evidence: $output"

View File

@ -210,11 +210,30 @@ else
partial "Android App Links" "package and signing fingerprints must be configured together" partial "Android App Links" "package and signing fingerprints must be configured together"
fi fi
if all_set WNH_ANDROID_VERSION_CODE WNH_ANDROID_VERSION_NAME \ android_signing_alias=
WNH_ANDROID_SIGNING_KEY_ALIAS WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS; then android_signing_label=
ready "Android release inputs" "version and separate production/staging signing aliases are present" case "$deployment_env" in
development)
android_signing_alias=WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS
android_signing_label=development
;;
test)
android_signing_alias=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS
android_signing_label=staging
;;
production)
android_signing_alias=WNH_ANDROID_SIGNING_KEY_ALIAS
android_signing_label=production
;;
esac
if [[ -n "$android_signing_alias" ]] &&
all_set WNH_ANDROID_VERSION_CODE WNH_ANDROID_VERSION_NAME "$android_signing_alias"; then
ready "Android release inputs" \
"version and $android_signing_label signing alias are present"
else else
missing "Android release inputs" "version code/name and both signing aliases" missing "Android release inputs" \
"version code/name and the signing alias for DEPLOYMENT_ENV=$deployment_env"
fi fi
printf '\nEnvironment readiness: %d blocking item(s), %d local-only warning(s).\n' \ printf '\nEnvironment readiness: %d blocking item(s), %d local-only warning(s).\n' \

View File

@ -0,0 +1,50 @@
#!/bin/sh
set -eu
umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env"}
config_home=${XDG_CONFIG_HOME:-"$HOME/.config"}
signing_dir=${WNH_ANDROID_DEVELOPMENT_SIGNING_DIR:-"$config_home/who_need_help/android-development"}
key_alias=${WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS:-who-need-help-development}
keystore="$signing_dir/who-need-help-development.p12"
password_file="$signing_dir/who-need-help-development.password"
values_file=$(mktemp "${TMPDIR:-/tmp}/wnh-android-development-env.XXXXXX")
cleanup() {
if [ -e "$values_file" ]; then
unlink "$values_file"
fi
}
trap cleanup EXIT HUP INT TERM
case "$env_file" in
/*) ;;
*) env_file="$ROOT/$env_file" ;;
esac
if [ ! -f "$env_file" ]; then
echo "Development environment file does not exist: $env_file" >&2
exit 1
fi
fingerprint=$(
"$ROOT/scripts/android-signing-fingerprint.sh" \
"$keystore" \
"$password_file" \
"$key_alias"
)
printf '%s\n' \
'DEPLOYMENT_ENV=development' \
'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.development' \
"ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$fingerprint" \
"WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS=$key_alias" \
>"$values_file"
chmod 600 "$values_file"
"$ROOT/scripts/set-env-values.sh" "$env_file" "$values_file" >/dev/null
"$ROOT/scripts/validate-android-environment.sh" "$env_file" development
echo "Configured the ignored development environment with the public Android identity."

View File

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
config_home=${XDG_CONFIG_HOME:-"$HOME/.config"}
WNH_ANDROID_SIGNING_DIR=${WNH_ANDROID_DEVELOPMENT_SIGNING_DIR:-"$config_home/who_need_help/android-development"} \
WNH_ANDROID_SIGNING_BASENAME=who-need-help-development \
WNH_ANDROID_SIGNING_KEY_ALIAS=${WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS:-who-need-help-development} \
WNH_ANDROID_SIGNING_SUBJECT="CN=Who Need Help development key" \
exec "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)/init-android-release-signing.sh"

View File

@ -322,6 +322,9 @@ TEST_UPSTREAM_VALUE=$test_upstream \
replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"] replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"]
replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"] replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"]
replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"] replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"]
replacement["WNH_ANDROID_SIGNING_KEY_ALIAS"] = "who-need-help-upload"
replacement["WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS"] = ""
replacement["WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS"] = ""
replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
} }
{ {

View File

@ -263,6 +263,9 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"] replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"]
replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"] replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"]
replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"] replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"]
replacement["WNH_ANDROID_SIGNING_KEY_ALIAS"] = ""
replacement["WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS"] = ""
replacement["WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS"] = "who-need-help-staging"
replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"]
} }
{ {

View File

@ -127,6 +127,51 @@ if ./scripts/set-env-values.sh \
exit 1 exit 1
fi fi
echo "Checking single-file environment template synchronization"
sync_template="$scan_dir/sync-template.env.example"
sync_env="$scan_dir/sync.env"
printf '%s\n' \
'# Template comment' \
'FIRST_VALUE=template-default' \
'SECOND_VALUE=' \
>"$sync_template"
printf '%s\n' \
'SECOND_VALUE=preserve-this-value' \
'LOCAL_ONLY_VALUE=preserve-local-key' \
'FIRST_VALUE=preserve-first-value' \
>"$sync_env"
chmod 600 "$sync_env"
sync_output=$(
./scripts/sync-env-template.sh "$sync_env" "$sync_template"
)
if printf '%s' "$sync_output" | grep -F 'preserve-this-value' >/dev/null; then
echo "Environment synchronizer printed an environment value." >&2
exit 1
fi
test "$(stat -c '%a' "$sync_env")" = 600
grep -Fx '# Template comment' "$sync_env" >/dev/null
grep -Fx 'FIRST_VALUE=preserve-first-value' "$sync_env" >/dev/null
grep -Fx 'SECOND_VALUE=preserve-this-value' "$sync_env" >/dev/null
grep -Fx 'LOCAL_ONLY_VALUE=preserve-local-key' "$sync_env" >/dev/null
test "$(grep -Fc 'FIRST_VALUE=' "$sync_env")" = 1
test "$(grep -Fc 'SECOND_VALUE=' "$sync_env")" = 1
test "$(grep -Fc 'LOCAL_ONLY_VALUE=' "$sync_env")" = 1
sync_hash=$(sha256sum "$sync_env" | awk '{print $1}')
./scripts/sync-env-template.sh "$sync_env" "$sync_template" >/dev/null
test "$(sha256sum "$sync_env" | awk '{print $1}')" = "$sync_hash"
sync_duplicate="$scan_dir/sync-duplicate.env"
printf '%s\n' \
'FIRST_VALUE=one' \
'FIRST_VALUE=two' \
>"$sync_duplicate"
chmod 600 "$sync_duplicate"
if ./scripts/sync-env-template.sh \
"$sync_duplicate" "$sync_template" >/dev/null 2>&1; then
echo "Environment synchronizer accepted duplicate source keys." >&2
exit 1
fi
google_client="$scan_dir/google-oauth-client.json" google_client="$scan_dir/google-oauth-client.json"
printf '%s\n' \ printf '%s\n' \
'{"web":{"client_id":"quality-google-client","project_id":"quality-development","client_secret":"quality-google-secret","redirect_uris":["https://dev.help.test/auth/google/callback"]}}' \ '{"web":{"client_id":"quality-google-client","project_id":"quality-development","client_secret":"quality-google-secret","redirect_uris":["https://dev.help.test/auth/google/callback"]}}' \
@ -157,6 +202,42 @@ grep -Fx 'WNH_FIREBASE_API_KEY=quality-firebase-api-key' "$credential_env" >/dev
grep -Fx 'WNH_FIREBASE_PROJECT_ID=quality-development' "$credential_env" >/dev/null grep -Fx 'WNH_FIREBASE_PROJECT_ID=quality-development' "$credential_env" >/dev/null
grep -Fx 'WNH_FIREBASE_GCM_SENDER_ID=123456789' "$credential_env" >/dev/null grep -Fx 'WNH_FIREBASE_GCM_SENDER_ID=123456789' "$credential_env" >/dev/null
echo "Checking Android environment isolation"
android_fingerprint=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF
android_env="$scan_dir/android-development.env"
printf '%s\n' \
'DEPLOYMENT_ENV=development' \
'PHX_HOST=dev.help.test' \
'PHX_SCHEME=https' \
'PHX_URL_PORT=443' \
'WNH_BASE_URL=https://dev.help.test' \
'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.development' \
"ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$android_fingerprint" \
>"$android_env"
chmod 600 "$android_env"
./scripts/validate-android-environment.sh \
"$android_env" development >/dev/null
sed -i \
's|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging|' \
"$android_env"
if ./scripts/validate-android-environment.sh \
"$android_env" development >/dev/null 2>&1; then
echo "Development Android validation accepted the test package." >&2
exit 1
fi
sed -i \
's|^DEPLOYMENT_ENV=.*|DEPLOYMENT_ENV=test|; s|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging|' \
"$android_env"
./scripts/validate-android-environment.sh "$android_env" test >/dev/null
sed -i \
's|^DEPLOYMENT_ENV=.*|DEPLOYMENT_ENV=production|; s|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile|' \
"$android_env"
./scripts/validate-android-environment.sh \
"$android_env" production >/dev/null
fcm_service_account="$scan_dir/fcm-service-account.json" fcm_service_account="$scan_dir/fcm-service-account.json"
printf '%s\n' \ printf '%s\n' \
'{"type":"service_account","project_id":"quality-development","client_email":"quality-fcm@quality-development.iam.gserviceaccount.com","private_key":"quality-private-key"}' \ '{"type":"service_account","project_id":"quality-development","client_email":"quality-fcm@quality-development.iam.gserviceaccount.com","private_key":"quality-private-key"}' \

120
scripts/sync-env-template.sh Executable file
View File

@ -0,0 +1,120 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env"}
template_file=${2:-"$ROOT/.env.example"}
case "$env_file" in
/*) ;;
*) env_file="$ROOT/$env_file" ;;
esac
case "$template_file" in
/*) ;;
*) template_file="$ROOT/$template_file" ;;
esac
if [ ! -f "$env_file" ]; then
echo "Environment file does not exist: $env_file" >&2
exit 1
fi
if [ ! -f "$template_file" ]; then
echo "Environment template does not exist: $template_file" >&2
exit 1
fi
if [ "$(stat -c '%a' "$env_file")" != 600 ]; then
echo "Environment file must have mode 0600: $env_file" >&2
exit 1
fi
env_dir=$(CDPATH='' cd -- "$(dirname -- "$env_file")" && pwd)
env_name=$(basename -- "$env_file")
temporary_env=$(mktemp "$env_dir/$env_name.tmp.XXXXXX")
trap 'rm -f "$temporary_env"' EXIT HUP INT TERM
chmod 600 "$temporary_env"
if ! awk '
BEGIN {
current_file = 1
}
FNR == 1 && NR != 1 {
current_file = 0
}
current_file {
separator = index($0, "=")
if (separator > 1) {
key = substr($0, 1, separator - 1)
if (key ~ /^[A-Z][A-Z0-9_]*$/) {
if (key in current) {
exit 40
}
current[key] = substr($0, separator + 1)
current_order[++current_count] = key
}
}
next
}
{
separator = index($0, "=")
if (separator > 1) {
key = substr($0, 1, separator - 1)
if (key ~ /^[A-Z][A-Z0-9_]*$/) {
if (key in template_seen) {
exit 41
}
template_seen[key] = 1
if (key in current) {
print key "=" current[key]
emitted[key] = 1
next
}
}
}
print
}
END {
unknown_count = 0
for (position = 1; position <= current_count; position++) {
key = current_order[position]
if (!(key in emitted) && !(key in template_seen)) {
unknown[++unknown_count] = key
}
}
if (unknown_count > 0) {
print ""
print "# Local keys not present in the current template."
for (position = 1; position <= unknown_count; position++) {
key = unknown[position]
print key "=" current[key]
}
}
}
' "$env_file" "$template_file" >"$temporary_env"; then
echo "Refusing to synchronize the environment: invalid or duplicate keys were found." >&2
exit 1
fi
mv "$temporary_env" "$env_file"
trap - EXIT HUP INT TERM
echo "Environment synchronized with the template without printing values: $env_file"

View File

@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
env_file=${1:-"$ROOT/.env"}
expected_environment=${2:-}
if [[ "$env_file" != /* ]]; then
env_file="$ROOT/$env_file"
fi
case "$expected_environment" in
development) expected_package=org.whoneedhelp.mobile.development ;;
test) expected_package=org.whoneedhelp.mobile.staging ;;
production) expected_package=org.whoneedhelp.mobile ;;
*)
echo "Usage: $0 ENV_FILE development|test|production" >&2
exit 2
;;
esac
[[ -f "$env_file" ]] || {
echo "Android build environment does not exist: $env_file" >&2
exit 1
}
[[ "$(stat -c '%a' "$env_file")" == 600 ]] || {
echo "Android build environment must have mode 0600: $env_file" >&2
exit 1
}
read_unique() {
local key=$1
local output
output=$(
awk -v key="$key" '
index($0, key "=") == 1 {
count += 1
value = substr($0, length(key) + 2)
}
END {
if (count != 1) exit 1
if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) {
value = substr(value, 2, length(value) - 2)
}
print value
}
' "$env_file"
) || {
echo "$key must occur exactly once in $env_file." >&2
exit 1
}
[[ -n "$output" ]] || {
echo "$key must not be empty in $env_file." >&2
exit 1
}
printf '%s' "$output"
}
deployment_environment=$(read_unique DEPLOYMENT_ENV)
phx_host=$(read_unique PHX_HOST)
phx_scheme=$(read_unique PHX_SCHEME)
phx_port=$(read_unique PHX_URL_PORT)
base_url=$(read_unique WNH_BASE_URL)
app_links_package=$(read_unique ANDROID_APP_LINKS_PACKAGE_NAME)
app_links_fingerprints=$(read_unique ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS)
[[ "$deployment_environment" == "$expected_environment" ]] || {
echo "Android build requires DEPLOYMENT_ENV=$expected_environment." >&2
exit 1
}
[[ "$app_links_package" == "$expected_package" ]] || {
echo "Android build for $expected_environment requires package $expected_package." >&2
exit 1
}
[[ "$phx_scheme" == https ]] || {
echo "Public Android builds require PHX_SCHEME=https." >&2
exit 1
}
[[ "$phx_host" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || {
echo "PHX_HOST must be a lowercase public DNS hostname." >&2
exit 1
}
if ! [[ "$phx_port" =~ ^[1-9][0-9]{0,4}$ ]] ||
((phx_port > 65535)); then
echo "PHX_URL_PORT must be a valid TCP port." >&2
exit 1
fi
expected_origin="https://$phx_host"
if [[ "$phx_port" != 443 ]]; then
expected_origin="$expected_origin:$phx_port"
fi
[[ "$base_url" == "$expected_origin" ]] || {
echo "WNH_BASE_URL must equal the canonical PHX origin: $expected_origin" >&2
exit 1
}
IFS=',' read -r -a fingerprints <<<"$app_links_fingerprints"
for fingerprint in "${fingerprints[@]}"; do
compact=${fingerprint//:/}
compact=${compact//[[:space:]]/}
[[ "$compact" =~ ^[0-9A-Fa-f]{64}$ ]] || {
echo "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS contains an invalid value." >&2
exit 1
}
done
echo "Verified $expected_environment Android origin, application ID, and App Links identity."