diff --git a/.env.example b/.env.example
index 5ec9c55..d28d034 100644
--- a/.env.example
+++ b/.env.example
@@ -11,8 +11,10 @@ PHX_URL_PORT=4010
# Android debug builds compile this origin into BuildConfig. The Docker
# emulator uses adb reverse to expose the local Compose proxy on loopback.
WNH_DEBUG_BASE_URL=http://localhost:4010
-# Release builds require a public HTTPS origin.
-WNH_BASE_URL=https://whoneedhelp.imalto.site
+# Staging/release builds require a public HTTPS origin. Keep the value
+# 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
# and fifteen-second request timeout. They are build inputs, not measured
# production capacity recommendations.
diff --git a/README.md b/README.md
index 79a35e3..9312a7b 100644
--- a/README.md
+++ b/README.md
@@ -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,
private chat, and a user-started location foreground service. Its persistent
notification exposes Stop, it continues while the Activity is minimized, and
- it retains only the current point. The reproducible Docker target currently
- exports a debug APK; production signing and store publication are not
- configured.
+ it retains only the current point. Reproducible Docker targets export
+ distinct local and public-staging debug APKs; production signing and store
+ publication are not configured.
- 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.
- One immutable release image with `web`, `worker`, and one-shot `migrate`
diff --git a/android/Dockerfile b/android/Dockerfile
index 27fa25b..94d16e8 100644
--- a/android/Dockerfile
+++ b/android/Dockerfile
@@ -111,3 +111,32 @@ COPY --from=android-sdk \
COPY --from=android-sdk \
/workspace/android/app/build/reports/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
diff --git a/android/README.md b/android/README.md
index 65dc98b..82b6b2d 100644
--- a/android/README.md
+++ b/android/README.md
@@ -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
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
From the repository root:
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 8e84fc2..0afae54 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -11,6 +11,16 @@ val trackingMinTimeMs = providers.gradleProperty("WNH_TRACKING_MIN_TIME_MS").orE
val trackingHttpTimeoutMs =
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 {
namespace = "org.whoneedhelp.mobile"
compileSdk = 37
@@ -42,6 +52,25 @@ android {
"\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
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 {
@@ -53,6 +82,9 @@ android {
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
manifestPlaceholders["usesCleartextTraffic"] = "false"
+ manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https"
+ manifestPlaceholders["deepLinkHost"] =
+ releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -74,7 +106,7 @@ android {
}
}
-tasks.matching { it.name == "preReleaseBuild" }.configureEach {
+tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach {
doFirst {
val value = releaseBaseUrl.orNull.orEmpty()
val uri = runCatching { URI(value) }.getOrNull()
@@ -88,7 +120,8 @@ tasks.matching { it.name == "preReleaseBuild" }.configureEach {
uri.fragment != null
) {
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"
)
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 51c5258..c0e7148 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -20,11 +20,20 @@
+ android:exported="true"
+ android:launchMode="singleTop">
+
+
+
+
+
+
assign_positions(positions)
+ |> assign_assignment_evidence(evidence)}
end
def handle_info({:tracking_stopped, user_id}, socket) do
@@ -389,6 +393,20 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|> assign(:markers, Jason.encode!(markers))
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
request.category
|> WhoNeedHelp.Catalog.structured_fields()
diff --git a/scripts/android-staging-build.sh b/scripts/android-staging-build.sh
new file mode 100755
index 0000000..5ef2261
--- /dev/null
+++ b/scripts/android-staging-build.sh
@@ -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"
diff --git a/scripts/ensure-local-public-origin.sh b/scripts/ensure-local-public-origin.sh
new file mode 100755
index 0000000..59e801e
--- /dev/null
+++ b/scripts/ensure-local-public-origin.sh
@@ -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."
diff --git a/test/who_need_help/trust_safety_test.exs b/test/who_need_help/trust_safety_test.exs
index 9edd1cf..8d6c817 100644
--- a/test/who_need_help/trust_safety_test.exs
+++ b/test/who_need_help/trust_safety_test.exs
@@ -59,6 +59,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
test "tracking derives movement and proximity from browser accuracy envelopes", context do
{:ok, request} = Help.create_request(context.requester_scope, context.attrs)
{: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.requester_scope, assignment)
@@ -69,6 +70,11 @@ defmodule WhoNeedHelp.TrustSafetyTest do
"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, _} =
Tracking.update_position(context.helper_scope, assignment, %{
"latitude" => 50.4510,
@@ -76,6 +82,12 @@ defmodule WhoNeedHelp.TrustSafetyTest do
"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, _} =
Tracking.update_position(context.requester_scope, assignment, %{
"latitude" => 50.4511,
diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs
index e8ed78b..9e0a2bf 100644
--- a/test/who_need_help_web/live/mutual_aid_live_test.exs
+++ b/test/who_need_help_web/live/mutual_aid_live_test.exs
@@ -208,11 +208,17 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
send(
requester_view.pid,
{: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) =~ "30.526"
+ assert render(requester_view) =~ "helper movement supported"
+ refute render(requester_view) =~ "no movement evidence"
send(requester_view.pid, {:tracking_stopped, helper.id})