feat: add public Android staging flow

This commit is contained in:
SimpleTest 2026-07-18 22:26:58 +03:00
parent 4d5efea419
commit 3a35af6782
17 changed files with 444 additions and 34 deletions

View File

@ -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.

View File

@ -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`

View File

@ -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

View File

@ -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:

View File

@ -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"
) )
} }

View File

@ -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"

View File

@ -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;
}
}

View File

@ -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(),
if (candidate != null && trustedOrigin.matches(candidate)) { getIntent().getStringExtra("initial_url"),
return candidate; BuildConfig.DEBUG
} );
} }
return trustedOrigin.startUrl(); @Override
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);
}
} }
private static String safeLogPath(Uri uri) { private static String safeLogPath(Uri uri) {

View File

@ -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
)
);
}
}

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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()

View 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"

View 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."

View File

@ -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,

View File

@ -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})