feat: add public Android staging flow
This commit is contained in:
parent
4d5efea419
commit
3a35af6782
|
|
@ -11,8 +11,10 @@ PHX_URL_PORT=4010
|
||||||
# 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
|
||||||
# Release builds require a public HTTPS origin.
|
# Staging/release builds require a public HTTPS origin. Keep the value
|
||||||
WNH_BASE_URL=https://whoneedhelp.imalto.site
|
# environment-specific; scripts/ensure-local-public-origin.sh can derive it
|
||||||
|
# from the three PHX_* values in the ignored .env.
|
||||||
|
WNH_BASE_URL=
|
||||||
# These local values preserve the existing five-second client freshness window
|
# These local values preserve the existing five-second client freshness window
|
||||||
# and fifteen-second request timeout. They are build inputs, not measured
|
# and fifteen-second request timeout. They are build inputs, not measured
|
||||||
# production capacity recommendations.
|
# production capacity recommendations.
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,9 @@ thank-you link; money goes directly between users outside the platform.
|
||||||
- Native Android WebView client with the same authenticated LiveView, map,
|
- Native Android WebView client with the same authenticated LiveView, map,
|
||||||
private chat, and a user-started location foreground service. Its persistent
|
private chat, and a user-started location foreground service. Its persistent
|
||||||
notification exposes Stop, it continues while the Activity is minimized, and
|
notification exposes Stop, it continues while the Activity is minimized, and
|
||||||
it retains only the current point. The reproducible Docker target currently
|
it retains only the current point. Reproducible Docker targets export
|
||||||
exports a debug APK; production signing and store publication are not
|
distinct local and public-staging debug APKs; production signing and store
|
||||||
configured.
|
publication are not configured.
|
||||||
- Local, advisory Codex category review through the user's ChatGPT-authenticated
|
- Local, advisory Codex category review through the user's ChatGPT-authenticated
|
||||||
Codex CLI. It receives a PII-free export and never writes to the database.
|
Codex CLI. It receives a PII-free export and never writes to the database.
|
||||||
- One immutable release image with `web`, `worker`, and one-shot `migrate`
|
- One immutable release image with `web`, `worker`, and one-shot `migrate`
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,32 @@ COPY --from=android-sdk \
|
||||||
COPY --from=android-sdk \
|
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
|
||||||
|
|
||||||
|
USER gradle
|
||||||
|
WORKDIR /workspace/android
|
||||||
|
|
||||||
|
COPY --chown=gradle:gradle . .
|
||||||
|
|
||||||
|
ARG WNH_BASE_URL
|
||||||
|
ARG WNH_TRACKING_MIN_TIME_MS
|
||||||
|
ARG WNH_TRACKING_HTTP_TIMEOUT_MS
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
|
||||||
|
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
|
||||||
|
gradle --no-daemon \
|
||||||
|
"-PWNH_BASE_URL=${WNH_BASE_URL}" \
|
||||||
|
"-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \
|
||||||
|
"-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_MS}" \
|
||||||
|
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
|
||||||
|
testDebugUnitTest lintStaging assembleStaging
|
||||||
|
|
||||||
|
FROM scratch AS staging-artifact
|
||||||
|
|
||||||
|
COPY --from=android-staging-sdk \
|
||||||
|
/workspace/android/app/build/outputs/apk/staging/app-staging.apk \
|
||||||
|
/who-need-help-staging.apk
|
||||||
|
COPY --from=android-staging-sdk \
|
||||||
|
/workspace/android/app/build/reports/lint-results-staging.html \
|
||||||
|
/lint-results-staging.html
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,25 @@ Release builds do not have a default server. Supply the real HTTPS deployment:
|
||||||
The build rejects a missing, HTTP, credentialed, query-bearing, or
|
The build rejects a missing, HTTP, credentialed, query-bearing, or
|
||||||
fragment-bearing release URL.
|
fragment-bearing release URL.
|
||||||
|
|
||||||
|
## Public staging build
|
||||||
|
|
||||||
|
The installable `staging` build type uses the explicit public HTTPS
|
||||||
|
`WNH_BASE_URL`, disables cleartext traffic, and has its own
|
||||||
|
`org.whoneedhelp.mobile.staging` application ID. Configure a missing local value
|
||||||
|
from the existing `PHX_HOST`, `PHX_SCHEME`, and `PHX_URL_PORT`, then build:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/ensure-local-public-origin.sh
|
||||||
|
./scripts/android-staging-build.sh
|
||||||
|
sha256sum android/dist-staging/who-need-help-staging.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
This variant uses Android's generic debug signing key so it can be installed
|
||||||
|
for staging verification. It is not a production-signed artifact and must not
|
||||||
|
be published as a release. The manifest accepts same-origin HTTPS deep links,
|
||||||
|
but verified Android App Links additionally require the final signing
|
||||||
|
certificate fingerprint in the deployment's `/.well-known/assetlinks.json`.
|
||||||
|
|
||||||
## Reproducible Docker build
|
## Reproducible Docker build
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,16 @@ val trackingMinTimeMs = providers.gradleProperty("WNH_TRACKING_MIN_TIME_MS").orE
|
||||||
val trackingHttpTimeoutMs =
|
val trackingHttpTimeoutMs =
|
||||||
providers.gradleProperty("WNH_TRACKING_HTTP_TIMEOUT_MS").orElse("0")
|
providers.gradleProperty("WNH_TRACKING_HTTP_TIMEOUT_MS").orElse("0")
|
||||||
|
|
||||||
|
fun manifestOrigin(value: String): URI? =
|
||||||
|
runCatching { URI(value) }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeIf { uri ->
|
||||||
|
(uri.scheme == "http" || uri.scheme == "https") && !uri.host.isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
val debugManifestOrigin = manifestOrigin(debugBaseUrl.get())
|
||||||
|
val releaseManifestOrigin = manifestOrigin(releaseBaseUrl.get())
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "org.whoneedhelp.mobile"
|
namespace = "org.whoneedhelp.mobile"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
|
|
@ -42,6 +52,25 @@ android {
|
||||||
"\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
"\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||||
)
|
)
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
|
manifestPlaceholders["deepLinkScheme"] = debugManifestOrigin?.scheme ?: "https"
|
||||||
|
manifestPlaceholders["deepLinkHost"] =
|
||||||
|
debugManifestOrigin?.host ?: "invalid.whoneedhelp.local"
|
||||||
|
}
|
||||||
|
|
||||||
|
create("staging") {
|
||||||
|
initWith(getByName("debug"))
|
||||||
|
applicationIdSuffix = ".staging"
|
||||||
|
versionNameSuffix = "-staging"
|
||||||
|
buildConfigField(
|
||||||
|
"String",
|
||||||
|
"BASE_URL",
|
||||||
|
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||||
|
)
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https"
|
||||||
|
manifestPlaceholders["deepLinkHost"] =
|
||||||
|
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
|
||||||
|
matchingFallbacks += listOf("debug")
|
||||||
}
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
|
|
@ -53,6 +82,9 @@ android {
|
||||||
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||||
)
|
)
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https"
|
||||||
|
manifestPlaceholders["deepLinkHost"] =
|
||||||
|
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
|
|
@ -74,7 +106,7 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.matching { it.name == "preReleaseBuild" }.configureEach {
|
tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach {
|
||||||
doFirst {
|
doFirst {
|
||||||
val value = releaseBaseUrl.orNull.orEmpty()
|
val value = releaseBaseUrl.orNull.orEmpty()
|
||||||
val uri = runCatching { URI(value) }.getOrNull()
|
val uri = runCatching { URI(value) }.getOrNull()
|
||||||
|
|
@ -88,7 +120,8 @@ tasks.matching { it.name == "preReleaseBuild" }.configureEach {
|
||||||
uri.fragment != null
|
uri.fragment != null
|
||||||
) {
|
) {
|
||||||
throw GradleException(
|
throw GradleException(
|
||||||
"Release builds require -PWNH_BASE_URL=https://your-real-deployment.example"
|
"Staging and release builds require "
|
||||||
|
+ "-PWNH_BASE_URL=https://your-real-deployment.example"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,20 @@
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="false">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:host="${deepLinkHost}"
|
||||||
|
android:scheme="${deepLinkScheme}" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<service
|
<service
|
||||||
android:name=".TrackingService"
|
android:name=".TrackingService"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package org.whoneedhelp.mobile;
|
||||||
|
|
||||||
|
final class LaunchUrlResolver {
|
||||||
|
private LaunchUrlResolver() {}
|
||||||
|
|
||||||
|
static String initialUrl(
|
||||||
|
TrustedOrigin trustedOrigin,
|
||||||
|
String deepLinkUrl,
|
||||||
|
String debugInitialUrl,
|
||||||
|
boolean debugBuild
|
||||||
|
) {
|
||||||
|
String candidate = incomingUrl(
|
||||||
|
trustedOrigin,
|
||||||
|
deepLinkUrl,
|
||||||
|
debugInitialUrl,
|
||||||
|
debugBuild
|
||||||
|
);
|
||||||
|
|
||||||
|
return candidate == null ? trustedOrigin.startUrl() : candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String incomingUrl(
|
||||||
|
TrustedOrigin trustedOrigin,
|
||||||
|
String deepLinkUrl,
|
||||||
|
String debugInitialUrl,
|
||||||
|
boolean debugBuild
|
||||||
|
) {
|
||||||
|
if (deepLinkUrl != null && trustedOrigin.matches(deepLinkUrl)) {
|
||||||
|
return deepLinkUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
debugBuild &&
|
||||||
|
debugInitialUrl != null &&
|
||||||
|
trustedOrigin.matches(debugInitialUrl)
|
||||||
|
) {
|
||||||
|
return debugInitialUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -127,15 +127,29 @@ public final class MainActivity extends ComponentActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
private String initialUrl() {
|
private String initialUrl() {
|
||||||
if (BuildConfig.DEBUG) {
|
return LaunchUrlResolver.initialUrl(
|
||||||
String candidate = getIntent().getStringExtra("initial_url");
|
trustedOrigin,
|
||||||
|
getIntent().getDataString(),
|
||||||
|
getIntent().getStringExtra("initial_url"),
|
||||||
|
BuildConfig.DEBUG
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (candidate != null && trustedOrigin.matches(candidate)) {
|
@Override
|
||||||
return candidate;
|
protected void onNewIntent(Intent intent) {
|
||||||
}
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
|
||||||
|
String candidate = LaunchUrlResolver.incomingUrl(
|
||||||
|
trustedOrigin,
|
||||||
|
intent.getDataString(),
|
||||||
|
intent.getStringExtra("initial_url"),
|
||||||
|
BuildConfig.DEBUG
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidate != null && webView != null) {
|
||||||
|
webView.loadUrl(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return trustedOrigin.startUrl();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String safeLogPath(Uri uri) {
|
private static String safeLogPath(Uri uri) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package org.whoneedhelp.mobile;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public final class LaunchUrlResolverTest {
|
||||||
|
private final TrustedOrigin origin =
|
||||||
|
TrustedOrigin.parse("https://help.example", false);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void releaseAcceptsOnlySameOriginDeepLinks() {
|
||||||
|
assertEquals(
|
||||||
|
"https://help.example/requests/123",
|
||||||
|
LaunchUrlResolver.incomingUrl(
|
||||||
|
origin,
|
||||||
|
"https://help.example/requests/123",
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assertNull(
|
||||||
|
LaunchUrlResolver.incomingUrl(
|
||||||
|
origin,
|
||||||
|
"https://attacker.example/requests/123",
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void releaseIgnoresDebugInitialUrlExtra() {
|
||||||
|
assertEquals(
|
||||||
|
"https://help.example",
|
||||||
|
LaunchUrlResolver.initialUrl(
|
||||||
|
origin,
|
||||||
|
null,
|
||||||
|
"https://help.example/requests/123",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void debugCanUseValidatedInitialUrlExtra() {
|
||||||
|
assertEquals(
|
||||||
|
"https://help.example/requests/123",
|
||||||
|
LaunchUrlResolver.initialUrl(
|
||||||
|
origin,
|
||||||
|
null,
|
||||||
|
"https://help.example/requests/123",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -119,8 +119,10 @@ and a block prevents new discovery, joins, and messages.
|
||||||
## Success criteria for the hackathon
|
## Success criteria for the hackathon
|
||||||
|
|
||||||
- A new user can complete the primary journey locally in two browser sessions.
|
- A new user can complete the primary journey locally in two browser sessions.
|
||||||
- The debug Android client can authenticate against the local stack and use
|
- The debug Android client can authenticate against the local stack, while the
|
||||||
the request map, matched chat, and foreground sharing on an emulator.
|
separately installable staging variant can use an explicit public HTTPS
|
||||||
|
origin. Both use the request map, matched chat, and user-started foreground
|
||||||
|
sharing on an emulator.
|
||||||
- The same immutable image runs as both web and worker roles.
|
- The same immutable image runs as both web and worker roles.
|
||||||
- Two web replicas and two worker replicas run concurrently in normal
|
- Two web replicas and two worker replicas run concurrently in normal
|
||||||
development.
|
development.
|
||||||
|
|
|
||||||
|
|
@ -16,25 +16,28 @@ results from product limits and unknown production properties.
|
||||||
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
|
| Consent-driven live tracking | Implemented and cross-client verified | On API 37, Android started `TrackingService` as a location foreground service with a persistent Stop notification. After Home minimized the Activity, an emulator coordinate change reached PostGIS. Notification Stop removed the service, notification, active session, and raw position. | Browsers stop with the page. Android has no `ACCESS_BACKGROUND_LOCATION`, unattended start, or route history. |
|
||||||
| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. |
|
| Privacy settings | Implemented and browser-verified | The profile exposed hidden, approximate public, exact for active match, and explicit exact-public options. Blocking and current-position cleanup have automated tests. | Exact public location remains a user opt-in; legal privacy and retention text still requires jurisdiction-specific review before launch. |
|
||||||
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
|
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
|
||||||
| Social profiles | Manual links implemented; 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; 146 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. |
|
| Social profiles | Manual links implemented; 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; 149 tests pass, including callback replay/state checks. No access-token field exists and the controller receives only normalized identity attributes. | The staging operator has not supplied GitHub OAuth credentials, so the real external provider redirect/callback remains disabled and has not been browser-verified. Other providers remain manual/unverified. |
|
||||||
| 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 | Debug client implemented and emulator-verified | The native package `org.whoneedhelp.mobile.debug` launches the same authenticated LiveView app. Login, map, chat, permission prompts, minimized foreground-service location updates, notification Stop, and server cleanup were exercised on API 37. | Production signing, a public production origin, Play Store publication, unattended/background-permission tracking, and iOS are not implemented. |
|
| 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. Public HTTPS login, map, two-way chat, permission prompts, minimized foreground-service location updates, notification Stop, deep-link routing, and server cleanup were exercised on API 37. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. |
|
||||||
| Multiple web/worker instances | Implemented and locally verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | 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 verified | Docker Compose and kind each ran 2 web and 2 worker replicas. The project probes cross-node Phoenix PubSub using different BEAM nodes. Kubernetes web/worker pods were Ready with zero restarts at the final observation. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
|
||||||
|
|
||||||
## Reproducible checks
|
## Reproducible checks
|
||||||
|
|
||||||
- `./scripts/test.sh`: 146 tests, 0 failures after verified GitHub linking
|
- `./scripts/test.sh`: 149 tests, 0 failures after the Android public-staging and
|
||||||
|
realtime tracking-evidence changes
|
||||||
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
on Elixir 1.20.2 and Erlang/OTP 29.0.3.
|
||||||
- `mix compile --force --warnings-as-errors` and
|
- `mix compile --force --warnings-as-errors` and
|
||||||
`mix format --check-formatted`: passed after the OAuth change.
|
`mix format --check-formatted`: passed against the same final source.
|
||||||
- The generated Activity migration was rolled back by exactly one step and
|
- The generated Activity migration was rolled back by exactly one step and
|
||||||
migrated forward again against `who_need_help_test`; both directions passed.
|
migrated forward again against `who_need_help_test`; both directions passed.
|
||||||
- The Activity-report migration was also rolled back and migrated forward. The
|
- The Activity-report migration was also rolled back and migrated forward. The
|
||||||
observed database constraint changed from exactly one of 3 urgent-help
|
observed database constraint changed from exactly one of 3 urgent-help
|
||||||
targets to exactly one of 5 urgent-help/Activity targets.
|
targets to exactly one of 5 urgent-help/Activity targets.
|
||||||
- `mix format --check-formatted`: passed in the final run.
|
- `mix format --check-formatted`: passed in the final run.
|
||||||
- Android Docker build target: `testDebugUnitTest`, `lintDebug`, and
|
- Android local Docker build target: `testDebugUnitTest`, `lintDebug`, and
|
||||||
`assembleDebug` passed; the final lint report contains no errors or warnings.
|
`assembleDebug` passed. The public-staging target ran the unit tests plus
|
||||||
|
`lintStaging` and `assembleStaging`; both final lint reports contain no errors
|
||||||
|
or warnings.
|
||||||
- Release Android guard: a release build without `WNH_BASE_URL` failed as
|
- Release Android guard: a release build without `WNH_BASE_URL` failed as
|
||||||
intended; release configuration accepts only an explicit HTTPS origin.
|
intended; release configuration accepts only an explicit HTTPS origin.
|
||||||
- Helm lint, template rendering, server-side dry-run, rollout waits, readiness
|
- Helm lint, template rendering, server-side dry-run, rollout waits, readiness
|
||||||
|
|
@ -49,14 +52,23 @@ Local generated evidence (ignored by Git):
|
||||||
- `output/playwright/final-privacy-profile.png`
|
- `output/playwright/final-privacy-profile.png`
|
||||||
- `output/android/final-image-smoke.png`
|
- `output/android/final-image-smoke.png`
|
||||||
- `output/android/foreground-notification.png`
|
- `output/android/foreground-notification.png`
|
||||||
|
- `output/android/staging-deep-link-safety.png`
|
||||||
|
|
||||||
Android artifact:
|
Android artifact:
|
||||||
|
|
||||||
- `android/dist/who-need-help-debug.apk`
|
- `android/dist/who-need-help-debug.apk`
|
||||||
- Final post-upgrade SHA-256:
|
- SHA-256:
|
||||||
`063f3d8d877009ee229a403692e4b16517244dd9e7dddab4c2b3c202c5def4b8`
|
`4520aa0b50eaf53bb7052e7f4c096a73d456f45781380187586c79d7267c21c5`
|
||||||
- Observed manifest values: version `0.1.0-debug`, minimum SDK 24, target and
|
- `android/dist-staging/who-need-help-staging.apk`
|
||||||
compile SDK 37.
|
- SHA-256:
|
||||||
|
`08d990a9268a382052be5f2d3ba5afba2aa65a9ad5f9ae1ad4bff720f8999a6e`
|
||||||
|
- Observed staging manifest values: package
|
||||||
|
`org.whoneedhelp.mobile.staging`, version `0.1.0-staging`, minimum SDK 24,
|
||||||
|
target and compile SDK 37, cleartext traffic disabled, `singleTop`
|
||||||
|
`MainActivity`, and an exact HTTPS host of `whoneedhelp.imalto.site`.
|
||||||
|
- `apksigner verify` accepted the staging artifact's v2 generic debug
|
||||||
|
signature. That signing identity is only for installable staging verification
|
||||||
|
and is not a production release identity.
|
||||||
|
|
||||||
## Configuration finding
|
## Configuration finding
|
||||||
|
|
||||||
|
|
@ -174,6 +186,60 @@ Local ignored browser evidence:
|
||||||
|
|
||||||
- `.playwright-cli/page-2026-07-18T18-09-44-303Z.png`
|
- `.playwright-cli/page-2026-07-18T18-09-44-303Z.png`
|
||||||
|
|
||||||
|
## Public Android and cross-client observation
|
||||||
|
|
||||||
|
The public-staging APK was built with the explicit ignored `.env`
|
||||||
|
`WNH_BASE_URL`, installed on the API 37 emulator, and connected to
|
||||||
|
`https://whoneedhelp.imalto.site`. Android package inspection found exactly one
|
||||||
|
matching Activity for the configured HTTPS host and no matching Activity for
|
||||||
|
`attacker.example`. A standard implicit `ACTION_VIEW` launch of `/safety`
|
||||||
|
opened the native `MainActivity` and displayed the public Safety rules.
|
||||||
|
|
||||||
|
The manifest intentionally sets `android:autoVerify="false"`: this is a
|
||||||
|
same-origin HTTPS deep link, not a claimed verified Android App Link. Enabling
|
||||||
|
verification requires the final production application ID and signing
|
||||||
|
certificate fingerprint to be published in the deployment's
|
||||||
|
`/.well-known/assetlinks.json`.
|
||||||
|
|
||||||
|
A cross-client scenario then used headed Chrome as the requester and the
|
||||||
|
public-staging Android app as the helper. The observed behavior was:
|
||||||
|
|
||||||
|
- the Android app authenticated against the public HTTPS origin and rendered
|
||||||
|
the MapLibre request;
|
||||||
|
- chat messages travelled in both directions, including an Android-bound
|
||||||
|
browser message appearing without reload;
|
||||||
|
- Android granted foreground location permissions, started
|
||||||
|
`TrackingService`, and showed its persistent Stop notification;
|
||||||
|
- after Home minimized the Activity, an emulator location change from
|
||||||
|
`50.46009833, 30.5334` to `50.4611, 30.5344` reached PostGIS and produced
|
||||||
|
`121.97` metres of observed movement;
|
||||||
|
- the headed browser received the helper marker in real time and removed it
|
||||||
|
when tracking stopped;
|
||||||
|
- notification Stop removed the Android service and notification, ended the
|
||||||
|
exact tracking session, left zero active sessions, and deleted its raw current
|
||||||
|
position.
|
||||||
|
|
||||||
|
The browser scenario exposed one UI defect: movement evidence was persisted and
|
||||||
|
the marker updated, but the evidence badge remained stale until reload. The
|
||||||
|
tracking PubSub event now carries the already-derived movement/proximity
|
||||||
|
timestamps, and the LiveView updates its in-memory assignment from that same
|
||||||
|
event without an extra query. Domain and two-client LiveView regression tests
|
||||||
|
assert the event and badge change.
|
||||||
|
|
||||||
|
That fix was deployed with image digest
|
||||||
|
`sha256:1b991c1e07d98babb5151c010250cd0b9458195cf3de5a91972456ee72d75673`.
|
||||||
|
Both web and both worker containers used the digest, the cross-node PubSub probe
|
||||||
|
passed, local and public readiness returned HTTP 200, and no application
|
||||||
|
error/warning was found in the checked post-rollout logs. The before/after
|
||||||
|
database counts remained `2 users / 1 matched request / 7 messages / 14
|
||||||
|
categories / 1 assignment / 4 ended tracking sessions / 0 active tracking
|
||||||
|
sessions / 0 activities / 0 reports / 0 social identities`.
|
||||||
|
|
||||||
|
The exact temporary E2E users, tokens, request, assignment, messages, tracking
|
||||||
|
session, and audit records were removed in one scoped transaction. Queries by
|
||||||
|
their exact identifiers found no remaining rows and the original database
|
||||||
|
counts were restored. The headed browser windows and emulator were left open.
|
||||||
|
|
||||||
## Operations and metrics verification
|
## Operations and metrics verification
|
||||||
|
|
||||||
On 2026-07-18, the Compose database was archived with PostgreSQL 18
|
On 2026-07-18, the Compose database was archived with PostgreSQL 18
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,11 @@ defmodule WhoNeedHelp.Tracking do
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
with {:ok, position} <- result do
|
with {:ok, {position, evidence}} <- result do
|
||||||
Phoenix.PubSub.broadcast(
|
Phoenix.PubSub.broadcast(
|
||||||
WhoNeedHelp.PubSub,
|
WhoNeedHelp.PubSub,
|
||||||
"tracking:#{assignment.id}",
|
"tracking:#{assignment.id}",
|
||||||
{:position_updated, user.id, public_position(position)}
|
{:position_updated, user.id, public_position(position), evidence}
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, position}
|
{:ok, position}
|
||||||
|
|
@ -223,8 +223,14 @@ defmodule WhoNeedHelp.Tracking do
|
||||||
assignment =
|
assignment =
|
||||||
maybe_mark_helper_movement(assignment, user.id, session.movement_observed_at, now)
|
maybe_mark_helper_movement(assignment, user.id, session.movement_observed_at, now)
|
||||||
|
|
||||||
maybe_mark_proximity(assignment, session, position, now)
|
assignment = maybe_mark_proximity(assignment, session, position, now)
|
||||||
{:ok, position}
|
|
||||||
|
evidence = %{
|
||||||
|
helper_movement_observed_at: assignment.helper_movement_observed_at,
|
||||||
|
proximity_observed_at: assignment.proximity_observed_at
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, {position, evidence}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,13 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
load(socket, Help.get_request!(socket.assigns.request.id), socket.assigns.tracking_active)}
|
load(socket, Help.get_request!(socket.assigns.request.id), socket.assigns.tracking_active)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:position_updated, user_id, position}, socket) do
|
def handle_info({:position_updated, user_id, position, evidence}, socket) do
|
||||||
positions = Map.put(socket.assigns.positions, user_id, position)
|
positions = Map.put(socket.assigns.positions, user_id, position)
|
||||||
{:noreply, assign_positions(socket, positions)}
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign_positions(positions)
|
||||||
|
|> assign_assignment_evidence(evidence)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:tracking_stopped, user_id}, socket) do
|
def handle_info({:tracking_stopped, user_id}, socket) do
|
||||||
|
|
@ -389,6 +393,20 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
||||||
|> assign(:markers, Jason.encode!(markers))
|
|> assign(:markers, Jason.encode!(markers))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp assign_assignment_evidence(%{assigns: %{assignment: nil}} = socket, _evidence),
|
||||||
|
do: socket
|
||||||
|
|
||||||
|
defp assign_assignment_evidence(socket, evidence) do
|
||||||
|
assignment = socket.assigns.assignment
|
||||||
|
|
||||||
|
assign(socket, :assignment, %{
|
||||||
|
assignment
|
||||||
|
| helper_movement_observed_at:
|
||||||
|
evidence.helper_movement_observed_at || assignment.helper_movement_observed_at,
|
||||||
|
proximity_observed_at: evidence.proximity_observed_at || assignment.proximity_observed_at
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
defp structured_details(request, locale) do
|
defp structured_details(request, locale) do
|
||||||
request.category
|
request.category
|
||||||
|> WhoNeedHelp.Catalog.structured_fields()
|
|> WhoNeedHelp.Catalog.structured_fields()
|
||||||
|
|
|
||||||
23
scripts/android-staging-build.sh
Executable file
23
scripts/android-staging-build.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
ENV_FILE="$ROOT/.env"
|
||||||
|
|
||||||
|
"$ROOT/scripts/ensure-local-public-origin.sh"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${WNH_BASE_URL:?Set WNH_BASE_URL 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}"
|
||||||
|
|
||||||
|
exec docker build \
|
||||||
|
--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" \
|
||||||
|
--target staging-artifact \
|
||||||
|
--output "type=local,dest=$ROOT/android/dist-staging" \
|
||||||
|
"$ROOT/android"
|
||||||
71
scripts/ensure-local-public-origin.sh
Executable file
71
scripts/ensure-local-public-origin.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
ENV_FILE="$ROOT/.env"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Missing $ENV_FILE. Copy .env.example to .env first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v perl >/dev/null 2>&1; then
|
||||||
|
echo "Required command is unavailable: perl" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
if [ -n "${WNH_BASE_URL:-}" ]; then
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
echo "WNH_BASE_URL already exists; no configuration was changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${PHX_HOST:?PHX_HOST is missing from .env}"
|
||||||
|
: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}"
|
||||||
|
: "${PHX_URL_PORT:?PHX_URL_PORT is missing from .env}"
|
||||||
|
|
||||||
|
if [ "$PHX_SCHEME" != https ]; then
|
||||||
|
echo "A public Android origin requires PHX_SCHEME=https." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$PHX_HOST" in
|
||||||
|
"" | *[!A-Za-z0-9.-]*)
|
||||||
|
echo "PHX_HOST is not a supported public DNS hostname." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$PHX_URL_PORT" in
|
||||||
|
"" | *[!0-9]*)
|
||||||
|
echo "PHX_URL_PORT must be a numeric HTTPS port." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$PHX_URL_PORT" = 443 ]; then
|
||||||
|
public_origin="https://$PHX_HOST"
|
||||||
|
else
|
||||||
|
public_origin="https://$PHX_HOST:$PHX_URL_PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_env=$(mktemp "${ENV_FILE}.public-origin.XXXXXX")
|
||||||
|
trap 'rm -f "$tmp_env"' EXIT HUP INT TERM
|
||||||
|
chmod 600 "$tmp_env"
|
||||||
|
|
||||||
|
WNH_PUBLIC_ORIGIN=$public_origin perl -0pe '
|
||||||
|
END {
|
||||||
|
print "\nWNH_BASE_URL=$ENV{WNH_PUBLIC_ORIGIN}\n";
|
||||||
|
}
|
||||||
|
' "$ENV_FILE" >"$tmp_env"
|
||||||
|
|
||||||
|
mv "$tmp_env" "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
unset public_origin
|
||||||
|
|
||||||
|
echo "Configured WNH_BASE_URL from the existing HTTPS Phoenix origin."
|
||||||
|
|
@ -59,6 +59,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
test "tracking derives movement and proximity from browser accuracy envelopes", context do
|
test "tracking derives movement and proximity from browser accuracy envelopes", context do
|
||||||
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
|
||||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||||
|
:ok = Tracking.subscribe(assignment.id)
|
||||||
{:ok, _} = Tracking.start_session(context.helper_scope, assignment)
|
{:ok, _} = Tracking.start_session(context.helper_scope, assignment)
|
||||||
{:ok, _} = Tracking.start_session(context.requester_scope, assignment)
|
{:ok, _} = Tracking.start_session(context.requester_scope, assignment)
|
||||||
|
|
||||||
|
|
@ -69,6 +70,11 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
"accuracy_meters" => 5.0
|
"accuracy_meters" => 5.0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert_receive {:position_updated, helper_id, _position,
|
||||||
|
%{helper_movement_observed_at: nil, proximity_observed_at: nil}}
|
||||||
|
|
||||||
|
assert helper_id == context.helper.id
|
||||||
|
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
Tracking.update_position(context.helper_scope, assignment, %{
|
Tracking.update_position(context.helper_scope, assignment, %{
|
||||||
"latitude" => 50.4510,
|
"latitude" => 50.4510,
|
||||||
|
|
@ -76,6 +82,12 @@ defmodule WhoNeedHelp.TrustSafetyTest do
|
||||||
"accuracy_meters" => 5.0
|
"accuracy_meters" => 5.0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
assert_receive {:position_updated, helper_id, _position,
|
||||||
|
%{helper_movement_observed_at: movement_at, proximity_observed_at: nil}}
|
||||||
|
|
||||||
|
assert helper_id == context.helper.id
|
||||||
|
assert %DateTime{} = movement_at
|
||||||
|
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
Tracking.update_position(context.requester_scope, assignment, %{
|
Tracking.update_position(context.requester_scope, assignment, %{
|
||||||
"latitude" => 50.4511,
|
"latitude" => 50.4511,
|
||||||
|
|
|
||||||
|
|
@ -208,11 +208,17 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
send(
|
send(
|
||||||
requester_view.pid,
|
requester_view.pid,
|
||||||
{:position_updated, helper.id,
|
{:position_updated, helper.id,
|
||||||
%{latitude: 50.452, longitude: 30.526, accuracy: 6, captured_at: DateTime.utc_now()}}
|
%{latitude: 50.452, longitude: 30.526, accuracy: 6, captured_at: DateTime.utc_now()},
|
||||||
|
%{
|
||||||
|
helper_movement_observed_at: DateTime.utc_now(:second),
|
||||||
|
proximity_observed_at: nil
|
||||||
|
}}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert render(requester_view) =~ "50.452"
|
assert render(requester_view) =~ "50.452"
|
||||||
assert render(requester_view) =~ "30.526"
|
assert render(requester_view) =~ "30.526"
|
||||||
|
assert render(requester_view) =~ "helper movement supported"
|
||||||
|
refute render(requester_view) =~ "no movement evidence"
|
||||||
|
|
||||||
send(requester_view.pid, {:tracking_stopped, helper.id})
|
send(requester_view.pid, {:tracking_stopped, helper.id})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user