From 3a35af6782b97e2007152ff26e494cb859485946 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 18 Jul 2026 22:26:58 +0300 Subject: [PATCH] feat: add public Android staging flow --- .env.example | 6 +- README.md | 6 +- android/Dockerfile | 29 +++++++ android/README.md | 19 ++++ android/app/build.gradle.kts | 37 +++++++- android/app/src/main/AndroidManifest.xml | 11 ++- .../whoneedhelp/mobile/LaunchUrlResolver.java | 42 +++++++++ .../org/whoneedhelp/mobile/MainActivity.java | 28 ++++-- .../mobile/LaunchUrlResolverTest.java | 58 +++++++++++++ docs/product-spec.md | 6 +- docs/verification.md | 86 ++++++++++++++++--- lib/who_need_help/tracking.ex | 14 ++- .../live/request_live/show.ex | 22 ++++- scripts/android-staging-build.sh | 23 +++++ scripts/ensure-local-public-origin.sh | 71 +++++++++++++++ test/who_need_help/trust_safety_test.exs | 12 +++ .../live/mutual_aid_live_test.exs | 8 +- 17 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 android/app/src/main/java/org/whoneedhelp/mobile/LaunchUrlResolver.java create mode 100644 android/app/src/test/java/org/whoneedhelp/mobile/LaunchUrlResolverTest.java create mode 100755 scripts/android-staging-build.sh create mode 100755 scripts/ensure-local-public-origin.sh 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})