Compare commits

..

No commits in common. "319cdb970b02ddaf176db4b4f9600e870bc42d2b" and "921e04b3608007675e22e7e26e0beb3975dbba58" have entirely different histories.

480 changed files with 8876 additions and 87750 deletions

View File

@ -14,15 +14,14 @@ TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
PHX_HOST=proxy
PHX_SCHEME=https
PHX_URL_PORT=443
MAP_TILE_URL=/__e2e__/map-tile.png?z={z}&x={x}&y={y}
MAP_TILE_URL=https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}
DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development
COMPOSE_PROJECT_NAME=who_need_help_e2e
APP_IMAGE=who-need-help:e2e-GENERATED_UNIQUE_E2E_RUN
SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-e2e-GENERATED_UNIQUE_E2E_RUN
POSTGIS_IMAGE=who-need-help:postgis-e2e-GENERATED_UNIQUE_E2E_RUN
E2E_TEST_IMAGE=who-need-help-e2e-tests:GENERATED_UNIQUE_E2E_RUN
APP_IMAGE=who-need-help:e2e
SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-e2e
POSTGIS_IMAGE=who-need-help:postgis-e2e
APP_TOPOLOGY=split
DATABASE_MODE=container
@ -36,7 +35,6 @@ MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1
OBAN_MAIL_CONCURRENCY=1
WEB_REPLICAS=2
WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536

View File

@ -31,14 +31,10 @@ HTTP_BIND_ADDRESS=0.0.0.0
# network. Keep disabled for ordinary local development.
PUBLIC_EDGE_ENABLED=false
PUBLIC_EDGE_NETWORK=who_need_help_public_edge
PUBLIC_ROUTE_ID=who_need_help
PUBLIC_UPSTREAM_NAME=who-need-help-local
PUBLIC_UPSTREAM_PORT=4000
PUBLIC_HEALTH_PATH=/healthz/ready
PUBLIC_WWW_REDIRECT=false
# Compatibility settings for the separately managed shared edge. Application
# releases do not build or restart Caddy; route changes belong to the
# independent server_edge project.
# The public Caddy edge is managed from the production checkout with the same
# production .env. The test checkout joins PUBLIC_EDGE_NETWORK but never owns
# or restarts Caddy.
EDGE_COMPOSE_PROJECT_NAME=who_need_help_edge
CADDY_IMAGE=who-need-help:caddy-local
EDGE_BIND_ADDRESS=0.0.0.0
@ -55,10 +51,6 @@ DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
# accepts. Keep loopback locally; set the exact VPN proxy address for staging.
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_RETRY_ATTEMPTS=3
# Keep false for ordinary deployments. The isolated load profile enables the
# unbound port-8080 API only inside its private Compose networks so its rolling
# drill can observe when a drained backend leaves service.
TRAEFIK_API_INSECURE=false
# Docker-provider isolation and names. A second Compose project must use its
# own project constraint, router/service name, Docker network, and Host rule.
TRAEFIK_PROJECT_CONSTRAINT=who_need_help
@ -68,15 +60,10 @@ TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
PHX_HOST=localhost
PHX_SCHEME=http
PHX_URL_PORT=4010
# Optional comma-separated additional browser origins for Phoenix sockets.
# Keep this empty when the site is reached only through PHX_HOST. For a local
# Compose instance also exposed through an HTTPS tunnel, list both exact
# origins, for example: https://dev.example.com,http://localhost:4010
PHX_CHECK_ORIGINS=
# 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
# Development/staging/release builds require a public HTTPS origin. Keep the value
# 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=
@ -87,37 +74,10 @@ WNH_TRACKING_MIN_TIME_MS=5000
WNH_TRACKING_HTTP_TIMEOUT_MS=15000
WNH_ANDROID_VERSION_CODE=1
WNH_ANDROID_VERSION_NAME=0.1.0
# Public Firebase Android client configuration. These values are embedded in
# the APK and are not service-account credentials. Set all four per environment
# to enable native FCM registration, or leave all four empty to disable it.
WNH_FIREBASE_APPLICATION_ID=
WNH_FIREBASE_API_KEY=
WNH_FIREBASE_PROJECT_ID=
WNH_FIREBASE_GCM_SENDER_ID=
# Verified Android App Links are configured by the web deployment rather than
# embedded as secrets in the application. Use
# org.whoneedhelp.mobile.development with the development certificate on DEV,
# org.whoneedhelp.mobile.staging with the staging certificate on test, and
# org.whoneedhelp.mobile with every active Play signing certificate on
# production. Keep both empty until the matching signed APK/AAB is available.
ANDROID_APP_LINKS_PACKAGE_NAME=
ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=
# Production-only evidence from Google Play Console. This must contain the
# Play App Signing certificate fingerprint(s), not the local upload key, and
# every value must also appear in ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS.
# Development and test leave this empty.
ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=
# Public identifier of the locally held Google Play upload key. The private
# keystore and its randomized password live outside the repository under
# ~/.config/who_need_help/android-release/.
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload
# The DEV-domain APK uses a stable signing identity under
# ~/.config/who_need_help/android-development/.
WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS=who-need-help-development
# The test-domain staging APK uses a different stable signing identity under
# ~/.config/who_need_help/android-staging/. This keeps App Link verification
# reproducible without reusing the future production upload key.
WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS=who-need-help-staging
WNH_ANDROID_TEST_API_MATRIX="24 30 34 37.0"
WNH_ANDROID_TEST_DATA_PARTITION_SIZE=1G
# Public raster tile template used by MapLibre. Use a provider whose policy and
@ -138,15 +98,10 @@ GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS=
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS=
# Optional Google OpenID Connect registration and sign-in. Leave both empty
# until a Google OAuth Web client exists. Android Credential Manager obtains
# the public client ID from Phoenix at runtime; never copy the secret into the
# APK or Gradle configuration. The browser callback URL must be:
# until a Google OAuth Web client exists. Its callback URL must be:
# https://YOUR_PHX_HOST/auth/google/callback
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
# Comma-separated Android OAuth client IDs allowed as the verified azp claim
# for Credential Manager cross-client ID tokens. Keep environments isolated.
GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=
# Leave endpoint and timeout overrides empty for Google's discovery endpoint
# and Req defaults. The base URL override exists for isolated protocol tests.
GOOGLE_OAUTH_BASE_URL=
@ -164,29 +119,9 @@ PUSH_HTTP_RECEIVE_TIMEOUT_MS=
PUSH_HTTP_CONNECT_TIMEOUT_MS=
PUSH_HTTP_RETRY_DELAY_MS=
# Direct browser Web Push. Generate one VAPID key pair per environment with
# scripts/generate-vapid-env.sh and keep the private key only in that
# environment's .env. The subject must be a mailto: or HTTPS contact owned by
# the operator.
WEB_PUSH_VAPID_PUBLIC_KEY=
WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=
# Native Android push through Firebase Cloud Messaging. Either mount the
# service-account JSON read-only and set its absolute in-container path, or put
# standard Base64 of that JSON in the single environment file. Never set both.
# Leave all three values empty to disable FCM.
FCM_PROJECT_ID=
FCM_SERVICE_ACCOUNT_FILE=
FCM_SERVICE_ACCOUNT_JSON_BASE64=
POSTGRES_DB=who_need_help
POSTGRES_USER=postgres
POSTGRES_PASSWORD=replace-with-a-local-or-deployment-secret
# Required only by scripts/dev-scale-seed.sh for the local synthetic viewer.
# Generate a distinct value; never reuse a real user or deployment password.
DEV_SCALE_VIEWER_PASSWORD=replace-with-a-random-local-fixture-password
DATABASE_URL=ecto://postgres:replace-with-url-encoded-password@db/who_need_help
# Optional absolute host directory containing PostgreSQL Unix sockets. When it
# is set in external mode, Compose mounts it read-only and Ecto uses it instead
@ -199,7 +134,6 @@ MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1
OBAN_MAIL_CONCURRENCY=1
WEB_REPLICAS=2
WORKER_REPLICAS=2
# Maximum simultaneously existing Erlang ports (files, sockets and drivers).
@ -222,37 +156,10 @@ SMTP_TLS=never
SMTP_SSL=false
EMAIL_FROM_NAME="Who Need Help"
EMAIL_FROM_ADDRESS=contact@example.com
# Optional monitored inbox used as Reply-To for support and legal correspondence.
# Optional monitored inbox. It receives new-case alerts and is used as Reply-To.
SUPPORT_INBOX_ADDRESS=
# Optional inbound email intake. Both values are required together. Configure
# the same receiving address/domain and Bearer token in Brevo's inbound parser.
# Inbound email still requires confirmation of its From address before the case
# becomes visible to staff. Attachments are not downloaded by this integration.
SUPPORT_INBOUND_RECIPIENT=
SUPPORT_INBOUND_WEBHOOK_TOKEN=
# Operator email alerts are disabled by default because the permission-scoped
# staff workspace is the canonical queue. Set immediate only when a monitored
# mailbox should receive one metadata-only alert for each newly verified case.
# Ongoing conversation stays in the staff workspace and in-app inbox. Both
# operator alerts and user-facing support updates use the separate Oban mail queue.
SUPPORT_OPERATOR_EMAIL_MODE=disabled
# Pilot policy: an anonymous support/removal email must be confirmed within one
# day. Confirmed case links remain usable for one year. Both values are seconds
# and can be changed without rebuilding the release.
PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS=86400
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS=31536000
CODEX_SESSION_ID=copy-the-main-local-codex-session-id
# Shared PostgreSQL-backed pilot policies. This explicit value makes an
# operator's effective policy reviewable without inspecting the image. Remove
# the value to use the same compiled pilot default; set exactly {} only to
# disable every counter in an isolated benchmark/test environment.
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.
# Shape: {"action_name":{"limit":POSITIVE_INTEGER,"window_seconds":POSITIVE_INTEGER}}
# Authentication delivery uses paired email/IP actions:
# registration_email + registration_ip, magic_link_email + magic_link_ip,
# password_login_email + password_login_ip, email_change_email + email_change_ip.
# Public support intake uses support_request (account/email scope) together with
# support_request_ip (trusted client-IP scope). IP ceilings are intentionally
# higher than account/email ceilings so shared networks are not treated as one
# person. These are initial pilot product limits, not universal recommendations.
RATE_LIMIT_POLICIES_JSON={"registration_email":{"limit":4,"window_seconds":3600},"registration_ip":{"limit":120,"window_seconds":3600},"magic_link_email":{"limit":4,"window_seconds":3600},"magic_link_ip":{"limit":120,"window_seconds":3600},"password_login_email":{"limit":10,"window_seconds":900},"password_login_ip":{"limit":300,"window_seconds":900},"email_change_email":{"limit":3,"window_seconds":86400},"email_change_ip":{"limit":60,"window_seconds":3600},"support_request":{"limit":5,"window_seconds":86400},"support_request_ip":{"limit":120,"window_seconds":3600},"content_removal_notice":{"limit":20,"window_seconds":86400},"content_removal_notice_ip":{"limit":120,"window_seconds":3600}}
RATE_LIMIT_POLICIES_JSON={}

View File

@ -21,7 +21,6 @@ PHX_SCHEME=https
PHX_URL_PORT=443
TRAEFIK_TRUSTED_IPS=127.0.0.1/32
TRAEFIK_RETRY_ATTEMPTS=3
TRAEFIK_API_INSECURE=true
TRAEFIK_PROJECT_CONSTRAINT=who_need_help_load
TRAEFIK_APP_NAME=who-need-help-load
TRAEFIK_DOCKER_NETWORK=who_need_help_load_ingress
@ -37,7 +36,6 @@ MIGRATE_POOL_SIZE=2
COMBINED_POOL_SIZE=4
OBAN_MAINTENANCE_CONCURRENCY=2
OBAN_PUSH_CONCURRENCY=1
OBAN_MAIL_CONCURRENCY=1
WEB_REPLICAS=2
WORKER_REPLICAS=2
ERLANG_PORT_LIMIT=65536

View File

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

View File

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

1
.gitignore vendored
View File

@ -37,7 +37,6 @@ who_need_help-*.tar
npm-debug.log
/assets/node_modules/
/e2e/node_modules/
/e2e/output/
/.tools/
__pycache__/
*.py[cod]

View File

@ -91,23 +91,14 @@ FROM ${RUNNER_IMAGE} AS final
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bsdutils=1:2.41.5-0+deb13u1 \
ca-certificates=20250419 \
curl=8.14.1-2+deb13u4 \
iproute2=6.15.0-1 \
libblkid1=2.41.5-0+deb13u1 \
liblastlog2-2=2.41.5-0+deb13u1 \
libmount1=2.41.5-0+deb13u1 \
libncurses6=6.5+20250216-2 \
libsmartcols1=2.41.5-0+deb13u1 \
libsctp1=1.0.21+dfsg-1 \
libstdc++6=14.2.0-19 \
libuuid1=2.41.5-0+deb13u1 \
locales=2.41-12+deb13u3 \
login=1:4.16.0-2+really2.41.5-0+deb13u1 \
mount=2.41.5-0+deb13u1 \
openssl=3.5.7-1~deb13u2 \
util-linux=2.41.5-0+deb13u1 \
openssl=3.5.6-1~deb13u2 \
&& rm -rf /var/lib/apt/lists/*
# Set the locale
@ -142,7 +133,6 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential=12.12 \
ca-certificates=20250419 \
cmake=3.31.6-2 \
git=1:2.47.3-0+deb13u1 \
&& rm -rf /var/lib/apt/lists/*
@ -162,9 +152,6 @@ COPY .dialyzer_ignore.exs .formatter.exs ./
COPY priv priv
COPY lib lib
COPY test test
COPY scripts/production-play-physical-fixture.exs scripts/production-play-physical-fixture.exs
COPY scripts/production-android-fcm-smoke.exs scripts/production-android-fcm-smoke.exs
COPY scripts/production-web-push-smoke.exs scripts/production-web-push-smoke.exs
RUN mix compile

View File

@ -1,38 +1,19 @@
FROM golang:1.26.7-alpine3.23@sha256:b17af760035fc2f338eed92d448a6c67f2d45438844fc6c60678fa5f99e44b57 AS restic
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS restic
ARG X_TEXT_VERSION=v0.40.0
ARG X_NET_VERSION=v0.57.0
ENV GOTOOLCHAIN=local
WORKDIR /src
RUN go mod init who-need-help/restic-build \
&& go get github.com/restic/restic/cmd/restic@v0.19.1 \
&& go get google.golang.org/grpc@v1.82.1 \
&& go get "golang.org/x/net@${X_NET_VERSION}" \
&& go get "golang.org/x/text@${X_TEXT_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/net)" = "${X_NET_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \
&& CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w" \
-o /out/restic \
github.com/restic/restic/cmd/restic
RUN CGO_ENABLED=0 go install github.com/restic/restic/cmd/restic@v0.19.1
FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
RUN apk add --no-cache \
ca-certificates=20260611-r0 \
libcrypto3=3.5.8-r0 \
libssl3=3.5.8-r0 \
libcrypto3=3.5.7-r0 \
libssl3=3.5.7-r0 \
musl=1.2.5-r23 \
musl-utils=1.2.5-r23 \
postgresql18-client=18.6-r0 \
postgresql18-client=18.4-r0 \
zlib=1.3.2-r0
COPY --from=restic /out/restic /usr/local/bin/restic
COPY --from=restic /go/bin/restic /usr/local/bin/restic
ENTRYPOINT []
USER 10001:10001

View File

@ -1,27 +1,14 @@
# syntax=docker/dockerfile:1.20.0
FROM golang:1.26.7-alpine3.23@sha256:b17af760035fc2f338eed92d448a6c67f2d45438844fc6c60678fa5f99e44b57 AS builder
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=local
ARG CADDY_VERSION=v2.11.4
ARG GRPC_GO_VERSION=v1.82.1
ARG X_TEXT_VERSION=v0.40.0
WORKDIR /src
RUN go mod init who-need-help/caddy-build \
&& go get "github.com/caddyserver/caddy/v2/cmd/caddy@${CADDY_VERSION}" \
&& go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \
&& go get "golang.org/x/text@${X_TEXT_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "$GRPC_GO_VERSION" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "$X_TEXT_VERSION" \
&& go build \
RUN GOBIN=/out go install \
-trimpath \
-ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=${CADDY_VERSION}-wnh-go1.26.7-grpc1.82.1-xtext0.40.0" \
-o /out/caddy \
github.com/caddyserver/caddy/v2/cmd/caddy
-ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=v2.11.4" \
github.com/caddyserver/caddy/v2/cmd/caddy@v2.11.4
RUN mkdir -p /rootfs/data/caddy /rootfs/config/caddy /rootfs/tmp \
&& chown -R 1000:1000 /rootfs

View File

@ -1,64 +0,0 @@
# syntax=docker/dockerfile:1.20.0
FROM docker.io/node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d AS ui
ARG MAILPIT_VERSION=v1.30.7
ARG MAILPIT_SOURCE_SHA256=19366f9b6fb3c8dd8f9c97b2e894133c6fbac2c2fee9657975874a0deab71777
ADD --checksum=sha256:19366f9b6fb3c8dd8f9c97b2e894133c6fbac2c2fee9657975874a0deab71777 \
https://github.com/axllent/mailpit/archive/refs/tags/v1.30.7.tar.gz \
/tmp/mailpit.tar.gz
WORKDIR /src
RUN test "$MAILPIT_SOURCE_SHA256" = "19366f9b6fb3c8dd8f9c97b2e894133c6fbac2c2fee9657975874a0deab71777" \
&& tar -xzf /tmp/mailpit.tar.gz --strip-components=1 \
&& npm ci \
&& npm run package
FROM golang:1.26.7-alpine3.23@sha256:b17af760035fc2f338eed92d448a6c67f2d45438844fc6c60678fa5f99e44b57 AS builder
ARG MAILPIT_VERSION=v1.30.7
ARG X_MOD_VERSION=v0.40.0
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=local
COPY --from=ui /src /src
WORKDIR /src
RUN go get "golang.org/x/mod@${X_MOD_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/mod)" = "$X_MOD_VERSION" \
&& go build \
-trimpath \
-ldflags "-s -w -X github.com/axllent/mailpit/config.Version=${MAILPIT_VERSION}-wnh-go1.26.7-xmod0.40.0" \
-o /out/mailpit
FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS runtime_files
RUN apk add --no-cache \
ca-certificates=20260611-r0 \
tzdata=2026c-r0
FROM scratch
LABEL org.opencontainers.image.title="Mailpit" \
org.opencontainers.image.description="An email and SMTP testing tool for Who Need Help development" \
org.opencontainers.image.source="https://github.com/axllent/mailpit" \
org.opencontainers.image.url="https://mailpit.axllent.org" \
org.opencontainers.image.documentation="https://mailpit.axllent.org/docs/" \
org.opencontainers.image.licenses="MIT"
ENV HOME=/tmp
COPY --from=runtime_files /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=runtime_files /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /out/mailpit /mailpit
USER 65534:65534
EXPOSE 1025/tcp 1110/tcp 8025/tcp
HEALTHCHECK --interval=15s --start-period=10s --start-interval=1s CMD ["/mailpit", "readyz"]
ENTRYPOINT ["/mailpit"]

View File

@ -1,8 +1,4 @@
FROM golang:1.26.7-alpine3.23@sha256:b17af760035fc2f338eed92d448a6c67f2d45438844fc6c60678fa5f99e44b57 AS minio_builder
ARG X_TEXT_VERSION=v0.40.0
ARG X_CRYPTO_VERSION=v0.54.0
ARG X_NET_VERSION=v0.57.0
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS minio_builder
ADD --checksum=sha256:45521908307306e925c98d629e1c17d78c8b72b6ee242b1bfb1409f7d8ee5841 \
https://github.com/minio/minio/archive/9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a.tar.gz \
@ -16,14 +12,9 @@ RUN tar --extract --gzip --file /tmp/minio.tar.gz \
github.com/apache/thrift@v0.23.0 \
github.com/buger/jsonparser@v1.1.2 \
github.com/prometheus/prometheus@v0.311.3 \
golang.org/x/crypto@${X_CRYPTO_VERSION} \
golang.org/x/net@${X_NET_VERSION} \
golang.org/x/text@${X_TEXT_VERSION} \
google.golang.org/grpc@v1.82.1 \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/crypto)" = "${X_CRYPTO_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/net)" = "${X_NET_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \
golang.org/x/crypto@v0.52.0 \
golang.org/x/net@v0.55.0 \
google.golang.org/grpc@v1.81.1 \
&& CGO_ENABLED=0 go build \
-mod=mod \
-trimpath \
@ -36,11 +27,7 @@ RUN tar --extract --gzip --file /tmp/minio.tar.gz \
-X github.com/minio/minio/cmd.ShortCommitID=9e49d5e7a648" \
-o /out/minio .
FROM golang:1.26.7-alpine3.23@sha256:b17af760035fc2f338eed92d448a6c67f2d45438844fc6c60678fa5f99e44b57 AS mc_builder
ARG X_TEXT_VERSION=v0.40.0
ARG X_CRYPTO_VERSION=v0.54.0
ARG X_NET_VERSION=v0.57.0
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS mc_builder
ADD --checksum=sha256:95cd293c7119f16921a6dc515a1fb74a2227f19fd994b9c8b770a154e802ac44 \
https://github.com/minio/mc/archive/7394ce0dd2a80935aded936b09fa12cbb3cb8096.tar.gz \
@ -52,15 +39,10 @@ RUN tar --extract --gzip --file /tmp/mc.tar.gz \
--directory . --strip-components=1 \
&& go get \
github.com/prometheus/prometheus@v0.311.3 \
golang.org/x/crypto@${X_CRYPTO_VERSION} \
golang.org/x/net@${X_NET_VERSION} \
golang.org/x/text@${X_TEXT_VERSION} \
google.golang.org/grpc@v1.82.1 \
golang.org/x/crypto@v0.52.0 \
golang.org/x/net@v0.55.0 \
google.golang.org/grpc@v1.81.1 \
&& go mod tidy \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/crypto)" = "${X_CRYPTO_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/net)" = "${X_NET_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "v1.82.1" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/text)" = "${X_TEXT_VERSION}" \
&& CGO_ENABLED=0 go build \
-trimpath \
-tags kqueue \
@ -76,8 +58,8 @@ FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f
RUN apk add --no-cache \
ca-certificates=20260611-r0 \
libcrypto3=3.5.8-r0 \
libssl3=3.5.8-r0 \
libcrypto3=3.5.7-r0 \
libssl3=3.5.7-r0 \
musl=1.2.5-r23 \
musl-utils=1.2.5-r23 \
zlib=1.3.2-r0

View File

@ -4,9 +4,6 @@ FROM postgis/postgis:18-3.6-alpine@sha256:05d68c7f0f19b9aa0bf7c4a2049b2e8b38b44a
# PostgreSQL 18's image-owned volume path is already writable by this account.
# Starting non-root removes the entrypoint's need for the bundled gosu binary.
RUN apk add --no-cache \
libcrypto3=3.5.8-r0 \
libssl3=3.5.8-r0 \
&& rm /usr/local/bin/gosu
RUN rm /usr/local/bin/gosu
USER postgres

View File

@ -6,9 +6,6 @@ USER root
# The upstream release image is immutable but its Alpine packages predate
# currently available security fixes. Keep the reviewed proxy implementation
# and apply the repository's current fixes; scripts/quality.sh scans the result.
RUN apk upgrade --no-cache \
&& apk add --no-cache \
libcrypto3=3.5.8-r0 \
libssl3=3.5.8-r0
RUN apk upgrade --no-cache
USER haproxy

View File

@ -1,47 +0,0 @@
# syntax=docker/dockerfile:1.20.0
FROM golang:1.26.7-alpine3.23@sha256:b17af760035fc2f338eed92d448a6c67f2d45438844fc6c60678fa5f99e44b57 AS builder
ARG TRAEFIK_VERSION=v3.7.10
ARG TRAEFIK_SOURCE_SHA256=31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6
ARG TRAEFIK_BUILD_DATE=2026-07-31_12:49:21PM
ARG GRPC_GO_VERSION=v1.82.1
ARG X_MOD_VERSION=v0.40.0
ADD --checksum=sha256:31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6 \
https://github.com/traefik/traefik/releases/download/v3.7.10/traefik-v3.7.10.src.tar.gz \
/tmp/traefik.tar.gz
WORKDIR /src
RUN test "$TRAEFIK_SOURCE_SHA256" = "31e0e2fbdccd3170b3bc5c3d233a08585bcbc5ede8f753d12a5999d69c21cdd6" \
&& tar -xzf /tmp/traefik.tar.gz --strip-components=1 \
&& go get "google.golang.org/grpc@${GRPC_GO_VERSION}" \
&& go get "golang.org/x/mod@${X_MOD_VERSION}" \
&& test "$(go list -m -f '{{.Version}}' google.golang.org/grpc)" = "$GRPC_GO_VERSION" \
&& test "$(go list -m -f '{{.Version}}' golang.org/x/mod)" = "$X_MOD_VERSION" \
&& CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-s -w -X github.com/traefik/traefik/v3/pkg/version.Version=${TRAEFIK_VERSION}-wnh-grpc1.82.1-xmod0.40.0 -X github.com/traefik/traefik/v3/pkg/version.BuildDate=${TRAEFIK_BUILD_DATE}" \
-installsuffix nocgo \
-o /out/traefik \
./cmd/traefik
FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS certificates
RUN apk add --no-cache \
ca-certificates=20260611-r0 \
tzdata=2026c-r0
FROM scratch
COPY --from=certificates /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=certificates /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /out/traefik /traefik
EXPOSE 80
VOLUME ["/tmp"]
USER 65532:65532
ENTRYPOINT ["/traefik"]

229
README.md
View File

@ -1,17 +1,14 @@
# Who Need Help
Who Need Help is a working mutual-aid MVP for urgent, local, voluntary help.
The first priority is asking whether a nearby volunteer is willing to purchase,
pick up, and deliver lawful medicine. Accepting never obliges a helper to spend
money; purchase and reimbursement arrangements remain directly between the
matched people. The same urgent-help flow also supports safe, non-emergency fuel
delivery, wheel help, bicycle and motorcycle problems, vehicle breakdowns, and
practical support after a secured road incident.
The first priority is pickup and delivery of a legal medicine that has already
been purchased or reserved. The same urgent-help flow also supports safe,
non-emergency fuel delivery, wheel help, bicycle and motorcycle problems,
vehicle breakdowns, and practical support after a secured road incident.
It is not an emergency, medical, pharmacy, or payment service, does not
prescribe or sell medicine, and does not guarantee reimbursement. A helper may
publish an optional external thank-you link; money goes directly between users
outside the platform.
It is not an emergency or medical service, does not prescribe or sell medicine,
and does not process payments. A helper may publish an optional external
thank-you link; money goes directly between users outside the platform.
## OpenAI Build Week 2026
@ -46,44 +43,20 @@ local Codex CLI authenticated with their ChatGPT subscription.
exact coordinates visible only to approved participants. Activities never
affect urgent-helper reputation; Activity and Activity-message reports expose
only the linked evidence to audited moderators.
- Request lifecycle: `open → matched → in_progress → completed`, with explicit
start, arrival, handover, both-party confirmation, requester cancellation,
helper withdrawal/reopening, replacement-helper, and expiry paths.
- PostgreSQL/PostGIS locations, viewport-scoped request discovery, server-side
map clustering, MapLibre map, private matched chat, Phoenix PubSub/Presence,
and optional consent-driven live location sharing. The browser loads only
the chosen map area; exact points, privacy areas, and coordinate-free
requests retain their distinct disclosure rules.
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
and expiry paths.
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
PubSub/Presence, and optional consent-driven live location sharing.
- Handover code plus both-party confirmation before verified completion.
- Double-blind reviews, public trust summaries, and a helper leaderboard that
prioritizes unique location-supported and handover-verified counterparts
before raw totals.
- A private notification inbox, category/radius/urgency/availability-based
nearby-help subscriptions, quiet hours, per-channel preferences, browser Web
Push registrations, and Android FCM device registrations. Nearby matches use
the inbox and optional real-time push; immediate nearby email is disabled and
a batched email digest is not implemented yet.
Remote payloads contain navigation metadata and generic text, never chat
bodies or exact coordinates; Oban retries transient delivery failures and
disables rejected device registrations.
- Bidirectional discovery blocks, scoped reports, account/request/category
moderation, abuse-signal review, and audited moderator access to only the
conversation linked by a report. A unified staff workspace uses combinable
support, moderator, legal, analyst, and administrator roles; administrators
manage users and staff access while the last active administrator is
protected.
conversation linked by a report.
- Separate public support and content-removal intake, including moderation
appeals, account deletion/data requests, a URL-only TAKE IT DOWN form,
verified-contact status links, verification-gated support alerts, and audited
staff queues. Support sends one initial response email and later public-status
changes through the dedicated Oban `mail` queue; ordinary conversation
messages stay in the private inbox and optional push. Content-removal
confirmation, receipt, and public decision updates use the same bounded queue,
while internal assignment-only changes do not email the submitter.
Authenticated users can download an allow-listed JSON data export that omits
password/session/push credentials and counterpart message bodies. A
moderator-only deletion preflight reports active workflows without performing
an unapproved destructive action.
verified-contact status links, operator alerts, and audited staff queues.
- Optional GPS evidence derived from browser accuracy envelopes. Raw current
positions are deleted on stop, terminal match state, or participant block.
- PostgreSQL-backed cross-replica action-limit policies configured by the
@ -93,15 +66,11 @@ local Codex CLI authenticated with their ChatGPT subscription.
tokens are not stored.
- EN/UK/RU UI foundation and installable PWA metadata/service worker.
- Native Android WebView client with the same authenticated LiveView, map,
private chat, consent-based FCM registration/deep links, 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
it retains only the current point. Reproducible Docker targets export
distinct debug and stable-signed staging APKs plus a production-signed APK
and Play AAB. The web app publishes environment-specific verified Android
App Links metadata and the build verifies package/certificate agreement.
Play registration, Play App Signing identity, store review, and physical
device FCM delivery are still external release steps.
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`, combined `app`, and
@ -110,19 +79,16 @@ local Codex CLI authenticated with their ChatGPT subscription.
replicas by default.
Additional social providers, background PWA or unattended location tracking,
platform payments, Android store publication, iOS,
platform payments, production Android signing/store publication, iOS,
automatic punitive fraud decisions, and jurisdiction-specific public-launch
policies are deliberately not claimed as complete.
## Fast start with Docker Compose
The public single-server path uses the compact application topology plus an
independent server-level Caddy edge. Production and test run as isolated
Compose projects with distinct checkouts, configuration, images, databases,
volumes, OAuth clients, and email credentials while sharing only a Docker
network used for HTTPS reverse proxying. A candidate is committed and pushed,
released and verified on the public test site, and only that exact verified
commit SHA is then eligible for production promotion. See the
The public single-server path uses the compact application topology plus a
separately managed Caddy edge. Production and test can run as isolated
Compose projects with distinct PostGIS volumes and secrets while sharing only a
Docker network used for HTTPS reverse proxying. See the
[operations runbook](docs/operations.md#two-independent-checkouts-and-one-env-in-each)
for the verified order of operations. Redis is not a project dependency.
@ -140,13 +106,6 @@ Open:
Compose starts Traefik, PostGIS, Mailpit, a migration runner, 2 web replicas,
and 2 Oban worker replicas. It waits for readiness and verifies a PubSub message
broadcast from a different BEAM node. Registration emails appear in Mailpit.
The Traefik image is reproducibly built from the checksum-pinned upstream
`v3.7.10` source with `grpc-go 1.82.1`. The Caddy edge, backup image, and optional
MinIO images build upstream Caddy `v2.11.4` (with `grpc-go 1.82.1` and
`golang.org/x/text 0.40.0`), restic `v0.19.1`, MinIO, and `mc` with the same
dependency pin. Those upstream dependency graphs still contain
older `grpc-go` versions affected by
[GHSA-hrxh-6v49-42gf](https://github.com/advisories/GHSA-hrxh-6v49-42gf).
Four deployment settings in the ignored environment select the runtime without
editing Compose files:
@ -226,7 +185,7 @@ load project and then run:
```
The command generates MinIO and Restic secrets only in ignored mode-`0600`
`output/runtime/load.env`, streams `pg_dump` directly into an encrypted Restic repository,
`.env.load`, streams `pg_dump` directly into an encrypted Restic repository,
restores it into a new temporary database, checks corruption and interruption
failure paths, removes those temporary buckets, and retains the successful
encrypted bucket in local MinIO. It never writes a plaintext dump to the host.
@ -249,9 +208,7 @@ presented as an FCM or APNs implementation; an external provider must resolve
the stable user recipient to registered devices.
The commands, boundaries, and unclaimed production properties are documented
in [the operations runbook](docs/operations.md). Use the
[public launch checklist](docs/public-launch-checklist.md) to keep automated
evidence separate from provider, staffing, and jurisdiction-specific approvals.
in [the operations runbook](docs/operations.md).
Local defaults are intentionally limited to local development. Copy
`.env.example` to `.env` and replace every secret before any public deployment.
@ -278,14 +235,9 @@ SMTP adapter and requires the relay's `SMTP_*` credentials. Email registration
and magic-link login are unusable for real
recipients until the selected provider and its accepted sender are configured. Set
the optional `SUPPORT_INBOX_ADDRESS` to a monitored mailbox to receive
metadata-only alerts for authenticated or email-verified support cases and make
replies return to the support team; unverified public support stays outside the
operator queue. The database queues continue to work when it is empty. See
metadata-only new-case alerts and make replies return to the support team; the
database queues continue to work when it is empty. See
[the support and content-removal runbook](docs/support-and-content-removal.md).
That address is not proof of inbound delivery. Optional Brevo inbound parsing
uses the paired `SUPPORT_INBOUND_RECIPIENT` and
`SUPPORT_INBOUND_WEBHOOK_TOKEN` settings; it deduplicates provider messages and
still requires the sender to confirm the mailbox before staff can see the case.
Then validate the file structure and the production Compose render:
```bash
@ -370,12 +322,6 @@ If both values are empty, the Google buttons remain visible but disabled with
an explanation. A partial pair is rejected at startup and by the production
environment validator.
For Android Credential Manager, set `GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS` to the
comma-separated OAuth client IDs registered for the Android package/signing
certificates in that same environment. Phoenix still requires the Web client
ID as the token audience; the Android client ID is accepted only as the
verified `azp` (authorized party) claim.
The flow requests `openid email profile`, verifies the provider email claim,
uses state, nonce, and PKCE, and discards provider tokens. A new Google identity
continues to a safe registration-completion page and creates a confirmed local
@ -386,15 +332,6 @@ Google from the sudo-protected account settings page instead. Leave
`GOOGLE_OAUTH_BASE_URL` and the Google HTTP timeout variables empty outside the
isolated protocol drill.
The Android app does not open Google OAuth inside the WebView. Its visible
Google button uses Android Credential Manager, asks this same server for a
session-bound one-time nonce, and sends the resulting ID token directly back to
the server. The server verifies the signature, algorithm, issuer, Web audience,
allowlisted Android authorized party, expiration, issued-at time, verified
email, and nonce before it reuses the ordinary login, registration, or
account-linking rules. `GOOGLE_OAUTH_CLIENT_SECRET` remains server-only; neither
it nor the ID token is exposed to WebView JavaScript or compiled into the APK.
### Optional verified GitHub linking
Create a GitHub OAuth App with this exact callback URL for the active public
@ -463,12 +400,12 @@ The same external-service protocol drill used by CI can be run independently:
Run the isolated HTTP/WebSocket/authenticated chat/tracking load profile with
resource, PostgreSQL statement, database-connection, and Ecto pool-wait
measurements. The lifecycle wrapper is preferred because it removes only its
unique containers, networks, volumes, environment, and image tags on success,
failure, or interruption:
measurements:
```bash
./scripts/load-cycle.sh local-load load
./scripts/load-stack-up.sh
./scripts/load-run.sh local-load
./scripts/load-stack-stop.sh
```
The profile has its own generated mode-`0600` environment, Compose project,
@ -477,24 +414,6 @@ database. Exact inputs and threshold-free evidence are retained below
`output/performance/local-load/`; see
[Performance measurement](docs/performance.md) for scope and interpretation.
A separate production probe is limited to a compiled allowlist of public GET
pages and Phoenix heartbeat frames. First inspect a read-only plan with every
experiment input supplied explicitly:
```bash
WNH_PRODUCTION_LOAD_HTTP_VUS=<chosen-count> \
WNH_PRODUCTION_LOAD_WS_VUS=<chosen-count> \
WNH_PRODUCTION_LOAD_DURATION=<chosen-duration> \
WNH_PRODUCTION_LOAD_HTTP_THINK_SECONDS=<chosen-seconds> \
WNH_PRODUCTION_LOAD_WS_HOLD_MS=<chosen-milliseconds> \
WNH_PRODUCTION_LOAD_WS_CONNECT_TIMEOUT_MS=<chosen-milliseconds> \
./scripts/production-readonly-load.sh plan
```
It does not start load in `plan` mode. The separately approved `run` mode
requires the exact confirmation printed by that plan and still cannot test
authenticated writes or establish a production capacity limit.
The cursor-pagination database benchmark also creates a one-run Compose
project, random database credentials, and a separate PostgreSQL volume:
@ -517,8 +436,8 @@ two worker replicas:
./scripts/e2e-run.sh
```
On its first run it generates `output/runtime/e2e.env` with independent random
local secrets and mode `0600`. Browser traffic uses the isolated Traefik HTTPS entrypoint;
On its first run it generates `.env.e2e` with independent random local secrets
and mode `0600`. Browser traffic uses the isolated Traefik HTTPS entrypoint;
Playwright accepts only that one-run proxy's generated certificate. E2E-only
fixture and failure-injection routes are enabled by a compile-time flag that is
disabled in the ordinary production image. The suite registers users through
@ -526,13 +445,9 @@ real Mailpit messages and uses the audited one-time bootstrap command inside
only the isolated E2E database to create its moderator. It covers
public/authentication boundaries;
the urgent medicine flow through matching, realtime chat, handover, and
double-blind reviews; the Activity flow through join approval, private
double-blind reviews; and the Activity flow through join approval, private
group chat, message-scoped reporting, blocking, privacy defaults, an unverified
social link, category moderation, report resolution, and account restriction;
and the support/legal flow through authenticated support, operator reply and
resolution, private requester status, general content removal, dedicated TAKE
IT DOWN intake, legal-queue processing, and decision email delivery through
the isolated Mailpit server.
social link, category moderation, report resolution, and account restriction.
The same gate checks keyboard skip navigation, WCAG violations and contrast on
four public pages in both themes, horizontal overflow at three viewport widths,
an actual locally served raster map tile, LiveView offline/reconnect UI, public
@ -560,8 +475,8 @@ probe or the complete minimum/current API 24/30/34/37 matrix:
./scripts/android-matrix-test.sh
```
On first run it generates ignored `output/runtime/android-test.env` with a
randomized device-loopback origin and mode `0600`. The suite covers denied and granted
On first run it generates the ignored `.env.android-test` with a randomized
device-loopback origin and mode `0600`. The suite covers denied and granted
location permission, same-origin deep links, Activity recreation, foreground
location upload while the Activity is backgrounded and destroyed, the
persistent notification Stop action, a disconnected Stop request with visible
@ -570,21 +485,14 @@ Results and failure diagnostics are retained by API under ignored
`output/android-instrumentation/`; the exact emulator container and one-run
image are removed automatically.
The public development and staging variants and their instrumentation APKs use
the explicit HTTPS origin from each checkout's ignored `.env`. Development uses
`org.whoneedhelp.mobile.development`; the independent test checkout uses
`org.whoneedhelp.mobile.staging`. The smoke probe checks rendered WebView DOM on
The public-staging variant and its instrumentation APK use the explicit HTTPS
origin from the ignored `.env`. The smoke probe checks rendered WebView DOM on
the home and Safety routes. The cross-client probe uses run-scoped users and a
matched medicine request to verify Android login, private chat in both
directions, foreground location sharing, browser marker appearance and removal,
and exact database cleanup:
directions, foreground location sharing, browser marker appearance and
removal, and exact database cleanup:
```bash
./scripts/android-development-build.sh
./scripts/android-development-smoke.sh
./scripts/android-browser-development-e2e.sh
# Run these only from the independent test checkout.
./scripts/android-staging-build.sh
./scripts/android-staging-smoke.sh
./scripts/android-browser-staging-e2e.sh
@ -601,24 +509,6 @@ run-scoped loopback SSH tunnel, and executes fixture preparation, verification,
and exact cleanup next to the remote test database. It does not delete
unrelated records.
The exact production foreground-service recording flow has a separate
run-scoped operator fixture. It creates one temporary browser account and one
matched request for an already confirmed physical-device helper, retains its
exact IDs in ignored mode-`0600` state, verifies the active and stopped location
states, and performs exact cleanup:
```bash
# Run only from /srv/who_need_help-production after reading the Play declaration.
./scripts/production-play-physical-fixture.sh \
plan HELPER_EMAIL --check-only whoneedhelp.com .env
```
The mutating `prepare`, `verify-active`, `verify-stopped`, and `cleanup`
commands are documented in
`android/play-store/location-and-fgs-declaration.md`. The wrapper refuses the
independent hackathon test checkout and any unexpected production root, origin,
Compose project, image, health state, or database.
## First administrator
Register and confirm the first account, then explicitly bootstrap it:
@ -628,16 +518,14 @@ Register and confirm the first account, then explicitly bootstrap it:
```
This succeeds only while no administrator exists and writes an audit event.
After bootstrap, an administrator can manage multiple staff roles in
`/admin/users`; the last active administrator cannot remove their own admin
access or be restricted. The complete role matrix and operator workflow are in
[`docs/staff-operations.md`](docs/staff-operations.md). For kind, append `kind`:
After bootstrap, an administrator can manage roles in `/moderation`; the last
administrator cannot demote themselves. For kind, append `kind`:
```bash
./scripts/bootstrap-admin.sh you@example.com --confirm kind
```
## Shared action limits
## Optional shared action limits
`RATE_LIMIT_POLICIES_JSON` configures atomic PostgreSQL counters shared by every
web replica. Its shape is:
@ -647,25 +535,8 @@ web replica. Its shape is:
```
The strings above describe the required types and are not a runnable policy.
The shipped pilot policy covers public authentication and anonymous
support/content-removal intake. Account/email ceilings are lower than IP
ceilings so a shared network is not treated as one person. The complete
effective JSON is kept in `.env`; an absent value uses the compiled pilot
default, while an explicit `{}` disables all counters for isolated load/E2E
runs. These values are an initial product policy, not universal security or
capacity thresholds. Supported actions are listed in `docs/trust-safety.md`.
Measure the limiter on a disposable PostgreSQL database and a one-CPU
application container by supplying the workload explicitly:
```bash
./scripts/rate-limit-benchmark.sh ATTEMPTS CONCURRENCY DISTRIBUTED_SCOPES
```
The script measures a single contended bucket and a distributed-scope profile,
writes JSON under ignored `output/rate-limit/`, and removes only its uniquely
named Compose project, volume, containers, and images on success, failure, or
interrupt. It does not infer a production limit from the result.
Keep `{}` until numeric limits have been approved from policy and observed
traffic. Supported actions are listed in `docs/trust-safety.md`.
## Local Kubernetes verification
@ -704,14 +575,6 @@ that dump. After a successful rollout it also removes the obsolete chart Secret
and only the local Helm history revisions that stored the former inline
credential fields.
When the ignored mode-`0600` `.env` exists, every `kind-up.sh` run also
reconciles a fixed allowlist of development provider settings into that same
Secret: Google/GitHub sign-in, browser push, FCM delivery, Android App Links,
sender identity, and support routing. It never prints their values and does not
replace the independently generated database or application secrets. Empty
allowlisted values remove stale provider settings so `.env` remains the single
development source of truth.
Exercise the verified local rolling-update path without recreating PostGIS or
the Secret:
@ -741,8 +604,7 @@ Grafana datasource and dashboard, discovers each current web container as a
separate target, and exercises a firing/resolved alert by stopping and
recovering exactly one verified load replica. It prints the loopback-only
random ports and retains non-secret evidence below `output/observability/`.
The generated Grafana password remains only in mode-`0600`
`output/runtime/load.env`.
The generated Grafana password remains only in mode-`0600` `.env.load`.
Stop only the monitoring services while leaving their local metric volumes and
the load application running:
@ -788,7 +650,6 @@ and no fallback provider. Recommendations require a human moderator action.
- [Architecture](docs/architecture.md)
- [Trust and safety](docs/trust-safety.md)
- [Operations runbook](docs/operations.md)
- [Google provider inventory for Dev, Test, and Prod](docs/google-provider-inventory.md)
- [Performance measurement](docs/performance.md)
- [Implementation verification and known limits](docs/verification.md)
- [Verified dependency baseline](docs/dependency-baseline.md)

View File

@ -45,10 +45,6 @@ ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE=1
ARG WNH_ANDROID_VERSION_NAME=0.1.0
ARG WNH_FIREBASE_APPLICATION_ID
ARG WNH_FIREBASE_CLIENT_VALUE
ARG WNH_FIREBASE_PROJECT_ID
ARG WNH_FIREBASE_GCM_SENDER_ID
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
@ -58,10 +54,6 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \
"-PWNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID}" \
"-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \
"-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \
"-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \
testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest
FROM android-base AS emulator
@ -69,7 +61,7 @@ FROM android-base AS emulator
USER root
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ARG ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.1/google_apis_ps16k/x86_64
ARG ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.0/google_apis_ps16k/x86_64
RUN android --no-metrics --sdk="${ANDROID_HOME}" sdk install \
"emulator" \
@ -142,17 +134,13 @@ USER 65532:65532
COPY --from=android-sdk \
/workspace/android/app/build/outputs/apk/debug/app-debug.apk \
/who-need-help-debug.apk
COPY --from=android-sdk \
/workspace/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
/who-need-help-debug-androidTest.apk
COPY --from=android-sdk \
/workspace/android/app/build/reports/lint-results-debug.html \
/lint-results-debug.html
FROM android-base AS android-public-sdk
FROM android-base AS android-staging-sdk
USER gradle
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /workspace/android
COPY --chown=gradle:gradle . .
@ -162,28 +150,9 @@ ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE=1
ARG WNH_ANDROID_VERSION_NAME=0.1.0
ARG WNH_FIREBASE_APPLICATION_ID
ARG WNH_FIREBASE_CLIENT_VALUE
ARG WNH_FIREBASE_PROJECT_ID
ARG WNH_FIREBASE_GCM_SENDER_ID
ARG WNH_PUBLIC_BUILD_TYPE
ARG WNH_EXPECTED_APPLICATION_ID
ARG WNH_SIGNING_CERT_SHA256
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
--mount=type=secret,id=android_nonproduction_keystore,required=true,uid=1000,gid=1000,mode=0400 \
--mount=type=secret,id=android_nonproduction_password,required=true,uid=1000,gid=1000,mode=0400 \
--mount=type=secret,id=android_nonproduction_alias,required=true,env=WNH_ANDROID_SIGNING_KEY_ALIAS \
case "${WNH_PUBLIC_BUILD_TYPE}" in \
development) task_name=Development ;; \
staging) task_name=Staging ;; \
*) echo "WNH_PUBLIC_BUILD_TYPE must be development or staging" >&2; exit 1 ;; \
esac \
&& test -n "${WNH_EXPECTED_APPLICATION_ID}" \
&& test -n "${WNH_SIGNING_CERT_SHA256}" \
&& WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_nonproduction_keystore \
WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_nonproduction_password \
gradle --no-daemon \
"-PWNH_BASE_URL=${WNH_BASE_URL}" \
"-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \
@ -191,42 +160,22 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \
"-PWNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID}" \
"-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \
"-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \
"-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \
"-PWNH_TEST_BUILD_TYPE=${WNH_PUBLIC_BUILD_TYPE}" \
"test${task_name}UnitTest" \
"lint${task_name}" \
"assemble${task_name}" \
"assemble${task_name}AndroidTest" \
&& "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \
verify --verbose --print-certs \
"app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \
>"/tmp/signing-certificate.txt" \
&& "${ANDROID_HOME}/build-tools/37.0.0/aapt2" dump badging \
"app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \
| sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" \
>"/tmp/package-name.txt" \
&& grep -Fx "${WNH_EXPECTED_APPLICATION_ID}" "/tmp/package-name.txt" \
&& mkdir -p /workspace/export \
&& cp \
"app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \
"/workspace/export/who-need-help-${WNH_PUBLIC_BUILD_TYPE}.apk" \
&& cp \
"app/build/outputs/apk/androidTest/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}-androidTest.apk" \
"/workspace/export/who-need-help-${WNH_PUBLIC_BUILD_TYPE}-androidTest.apk" \
&& cp \
"app/build/reports/lint-results-${WNH_PUBLIC_BUILD_TYPE}.html" \
"/workspace/export/lint-results-${WNH_PUBLIC_BUILD_TYPE}.html" \
&& cp /tmp/signing-certificate.txt /workspace/export/signing-certificate.txt \
&& cp /tmp/package-name.txt /workspace/export/package-name.txt
"-PWNH_TEST_BUILD_TYPE=staging" \
testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest
FROM scratch AS public-artifact
FROM scratch AS staging-artifact
USER 65532:65532
COPY --from=android-public-sdk /workspace/export/ /
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/outputs/apk/androidTest/staging/app-staging-androidTest.apk \
/who-need-help-staging-androidTest.apk
COPY --from=android-staging-sdk \
/workspace/android/app/build/reports/lint-results-staging.html \
/lint-results-staging.html
FROM android-base AS android-release-base
@ -247,7 +196,6 @@ USER gradle
FROM android-release-base AS android-release-sdk
USER gradle
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /workspace/android
COPY --chown=gradle:gradle . .
@ -257,19 +205,13 @@ ARG WNH_TRACKING_MIN_TIME_MS
ARG WNH_TRACKING_HTTP_TIMEOUT_MS
ARG WNH_ANDROID_VERSION_CODE
ARG WNH_ANDROID_VERSION_NAME
ARG WNH_FIREBASE_APPLICATION_ID
ARG WNH_FIREBASE_CLIENT_VALUE
ARG WNH_FIREBASE_PROJECT_ID
ARG WNH_FIREBASE_GCM_SENDER_ID
ARG WNH_SIGNING_CERT_SHA256
RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
--mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \
--mount=type=secret,id=android_upload_keystore,required=true,uid=1000,gid=1000,mode=0400 \
--mount=type=secret,id=android_upload_password,required=true,uid=1000,gid=1000,mode=0400 \
--mount=type=secret,id=android_upload_alias,required=true,env=WNH_ANDROID_SIGNING_KEY_ALIAS \
test -n "${WNH_SIGNING_CERT_SHA256}" \
&& WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_upload_keystore \
WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_upload_keystore \
WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_upload_password \
gradle --no-daemon \
"-PWNH_BASE_URL=${WNH_BASE_URL}" \
@ -277,21 +219,11 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
"-PWNH_ANDROID_VERSION_CODE=${WNH_ANDROID_VERSION_CODE}" \
"-PWNH_ANDROID_VERSION_NAME=${WNH_ANDROID_VERSION_NAME}" \
"-PWNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID}" \
"-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \
"-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \
"-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \
testReleaseUnitTest lintRelease assembleRelease bundleRelease \
&& "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \
verify --verbose --print-certs \
app/build/outputs/apk/release/app-release.apk \
>app/build/outputs/apk/release/signing-certificate.txt \
&& "${ANDROID_HOME}/build-tools/37.0.0/aapt2" dump badging \
app/build/outputs/apk/release/app-release.apk \
| sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" \
>app/build/outputs/apk/release/package-name.txt \
&& grep -Fx "org.whoneedhelp.mobile" \
app/build/outputs/apk/release/package-name.txt \
&& LC_ALL=C jarsigner -verify -verbose -certs \
app/build/outputs/bundle/release/app-release.aab \
>app/build/outputs/bundle/release/signing-verification.txt \
@ -299,33 +231,7 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
app/build/outputs/bundle/release/signing-verification.txt \
&& java -jar /opt/bundletool.jar validate \
--bundle=app/build/outputs/bundle/release/app-release.aab \
>app/build/outputs/bundle/release/bundletool-validation.txt \
&& java -jar /opt/bundletool.jar build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=app/build/outputs/bundle/release/app-release-universal.apks \
--mode=universal \
--ks=/run/secrets/android_upload_keystore \
--ks-pass=file:/run/secrets/android_upload_password \
--ks-key-alias="${WNH_ANDROID_SIGNING_KEY_ALIAS}" \
--key-pass=file:/run/secrets/android_upload_password \
--overwrite \
&& unzip -p \
app/build/outputs/bundle/release/app-release-universal.apks \
universal.apk \
>app/build/outputs/bundle/release/app-release-universal.apk \
&& "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \
verify --verbose --print-certs \
app/build/outputs/bundle/release/app-release-universal.apk \
>app/build/outputs/bundle/release/universal-signing-certificate.txt \
&& grep -Fqi \
"certificate SHA-256 digest: $(printf '%s' "${WNH_SIGNING_CERT_SHA256}" | tr -d ':')" \
app/build/outputs/bundle/release/universal-signing-certificate.txt \
&& "${ANDROID_HOME}/build-tools/37.0.0/aapt2" dump badging \
app/build/outputs/bundle/release/app-release-universal.apk \
| sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" \
>app/build/outputs/bundle/release/universal-package-name.txt \
&& grep -Fx "org.whoneedhelp.mobile" \
app/build/outputs/bundle/release/universal-package-name.txt
>app/build/outputs/bundle/release/bundletool-validation.txt
FROM scratch AS release-artifact
@ -337,24 +243,9 @@ COPY --from=android-release-sdk \
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/app-release.aab \
/who-need-help-release.aab
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/app-release-universal.apks \
/who-need-help-release-universal.apks
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/app-release-universal.apk \
/who-need-help-release-universal.apk
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/apk/release/signing-certificate.txt \
/signing-certificate.txt
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/universal-signing-certificate.txt \
/universal-signing-certificate.txt
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/universal-package-name.txt \
/universal-package-name.txt
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/apk/release/package-name.txt \
/package-name.txt
COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/signing-verification.txt \
/bundle-signing-verification.txt

View File

@ -11,24 +11,6 @@ The native tracking bridge uses `WebViewCompat.addWebMessageListener` with the
exact configured origin and rejects messages outside the main frame. It does
not expose a legacy `addJavascriptInterface` object to every frame.
Remote notifications are opt-in. The Android bridge requests the Android 13+
notification permission, enables Firebase Messaging only after consent, and
registers the Firebase Installation ID through the authenticated same-origin
`/mobile/push-devices` endpoint. Data-only FCM messages are rendered by the app
and may deep-link only to a validated relative path on the configured Who Need
Help origin. Notification payloads do not contain chat text or exact location.
Disabling the current device removes its server registration and unregisters
the Firebase Installation; registration can be enabled again explicitly.
Google authentication uses Android Credential Manager rather than an embedded
OAuth user agent. The web page asks the native bridge to begin only after the
user presses the visible Google button. Native code obtains a server-bound
Google ID token with a one-time nonce and posts it directly to the same trusted
Phoenix origin; the token is never returned to WebView JavaScript. The server
client ID is obtained at runtime from the authenticated environment endpoint,
so no Google client secret is compiled into any APK. Signing out also clears
Credential Manager state.
## Verified build configuration
- Android Gradle Plugin 9.3.0
@ -73,37 +55,10 @@ WNH_BASE_URL=https://your-final-origin.example
WNH_ANDROID_VERSION_CODE=1
WNH_ANDROID_VERSION_NAME=0.1.0
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload
WNH_FIREBASE_APPLICATION_ID=1:123456789:android:example
WNH_FIREBASE_API_KEY=the-public-firebase-android-client-key
WNH_FIREBASE_PROJECT_ID=your-firebase-project
WNH_FIREBASE_GCM_SENDER_ID=123456789
```
The four Firebase Android client values are public application configuration,
not the server credential. They must be either all present or all empty. Server
delivery separately requires `FCM_PROJECT_ID` and exactly one service-account
source in the Phoenix environment; never put that private JSON in the Android
build.
Google/Firebase setup is environment-specific as well:
- development uses package `org.whoneedhelp.mobile.development`, its stable
development certificate, the development Web OAuth client, and the
development Firebase project;
- test/staging uses package `org.whoneedhelp.mobile.staging`, its independent
staging certificate, and the test environment's provider configuration;
- production uses package `org.whoneedhelp.mobile`, the Play-distributed signing
certificate, the production Web OAuth client, and the production Firebase
project;
- `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` stay in that
checkout's single ignored `.env`; only the public client ID is returned at
runtime to Credential Manager;
- the server Web client ID is the audience requested by Credential Manager.
Register the matching Android package/signing certificate in the same Google
project before a real-device sign-in test.
```sh
./scripts/android-release-build.sh
WNH_ENV_FILE=.env.production ./scripts/android-release-build.sh
```
The build passes the private files with Docker BuildKit secret mounts, runs
@ -122,57 +77,37 @@ separate app-signing key used for distributed APKs:
- <https://support.google.com/googleplay/android-developer/answer/9859152>
- <https://docs.docker.com/build/building/secrets/>
The production checkout therefore keeps the locally measured upload
certificate and the Play Console app-signing certificate as distinct evidence.
`ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS` publishes every active identity,
while `ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS` must contain the
Play-delivered identity as a verified subset. A release is not marked ready
from the upload certificate alone.
## Public staging build
## Public development and staging builds
The installable `development` and `staging` build types use the explicit public
HTTPS `WNH_BASE_URL` and disable cleartext traffic. Development is the
`org.whoneedhelp.mobile.development` application connected to the development
origin. Generate its dedicated signing identity once, synchronize the public
identity into the checkout's single ignored `.env`, and 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/init-android-development-signing.sh
./scripts/configure-android-development-env.sh
./scripts/android-development-build.sh
sha256sum android/dist-development/who-need-help-development.apk
```
The separate test checkout uses `org.whoneedhelp.mobile.staging`, its own
staging key, and the same workflow with test-specific inputs:
```sh
./scripts/init-android-staging-signing.sh
./scripts/ensure-local-public-origin.sh
./scripts/android-staging-build.sh
sha256sum android/dist-staging/who-need-help-staging.apk
```
The two identities live below
`~/.config/who_need_help/android-development/` and
`~/.config/who_need_help/android-staging/`. Neither is the production upload
identity or suitable for publication as the production application. Each
deployment's `/.well-known/assetlinks.json` must contain the package and
certificate fingerprint of the APK connected to that exact origin.
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`.
With the matching public origin reachable, run the API 37 emulator smoke test:
With the temporary public origin reachable, run the API 37 emulator smoke test:
```sh
./scripts/android-development-smoke.sh
./scripts/android-staging-smoke.sh
```
Each script installs the corresponding APK into a fresh project-scoped emulator
container, loads the configured HTTPS home page, follows a
The script installs the exported staging APK into a fresh project-scoped
emulator container, loads the configured HTTPS home page, follows a
same-origin `/safety` deep link, verifies that the package does not claim an
external HTTPS origin, and retains UI dumps, screenshots, package metadata,
and logcat diagnostics under its ignored `output/android-*-smoke/` directory.
The one-run container and image are removed on success or failure.
and logcat diagnostics under ignored `output/android-staging-smoke/`. The
one-run container and image are removed on success or failure.
## Reproducible Docker build
@ -197,11 +132,10 @@ repository root:
./scripts/android-matrix-test.sh
```
The command requires `/dev/kvm`. It generates ignored
`output/runtime/android-test.env` once with a randomized
`http://127.0.0.1:PORT` origin and mode `0600`; the application and its
in-process fixture server both derive the origin from that file. It then
builds both APKs, boots a fresh selected emulator
The command requires `/dev/kvm`. It generates an ignored
`.env.android-test` once with a randomized `http://127.0.0.1:PORT` origin and
mode `0600`; the application and its in-process fixture server both derive the
origin from that file. It then builds both APKs, boots a fresh selected emulator
container without external networking, injects emulator coordinates, and runs
`AndroidJUnitRunner`. `WNH_ANDROID_TEST_API` selects one supported API, while
`WNH_ANDROID_TEST_API_MATRIX` controls the matrix command.
@ -218,7 +152,7 @@ in either outcome.
## Emulator verification
The optional `emulator` target contains the API 37.1 Google APIs x86_64 system
The optional `emulator` target contains the API 37.0 Google APIs x86_64 system
image. Manual verification against the local Compose application requires KVM
and host networking. Pass the same `.env` value as a build argument, then
expose the Compose proxy to Android with `adb reverse`:

View File

@ -16,10 +16,6 @@ val instrumentationBuildType =
providers.gradleProperty("WNH_TEST_BUILD_TYPE").orElse("debug")
val androidVersionCode = providers.gradleProperty("WNH_ANDROID_VERSION_CODE").orElse("1")
val androidVersionName = providers.gradleProperty("WNH_ANDROID_VERSION_NAME").orElse("0.1.0")
val firebaseApplicationId = providers.gradleProperty("WNH_FIREBASE_APPLICATION_ID").orElse("")
val firebaseApiKey = providers.gradleProperty("WNH_FIREBASE_API_KEY").orElse("")
val firebaseProjectId = providers.gradleProperty("WNH_FIREBASE_PROJECT_ID").orElse("")
val firebaseSenderId = providers.gradleProperty("WNH_FIREBASE_GCM_SENDER_ID").orElse("")
val releaseSigningStoreFile =
providers.environmentVariable("WNH_ANDROID_SIGNING_STORE_FILE").orNull
val releaseSigningPasswordFile =
@ -29,29 +25,6 @@ val releaseSigningKeyAlias =
fun nonBlank(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty)
fun quotedBuildConfig(value: String): String =
"\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
val firebaseInputs =
listOf(
firebaseApplicationId.get(),
firebaseApiKey.get(),
firebaseProjectId.get(),
firebaseSenderId.get()
)
val firebaseConfigured = firebaseInputs.all { it.isNotBlank() }
val firebasePartiallyConfigured = firebaseInputs.any { it.isNotBlank() }
fun validateFirebaseConfiguration() {
if (firebasePartiallyConfigured && !firebaseConfigured) {
throw GradleException(
"WNH_FIREBASE_APPLICATION_ID, WNH_FIREBASE_API_KEY, "
+ "WNH_FIREBASE_PROJECT_ID, and WNH_FIREBASE_GCM_SENDER_ID "
+ "must either all be set or all be empty"
)
}
}
val releaseSigningInputs =
listOf(
nonBlank(releaseSigningStoreFile),
@ -114,24 +87,6 @@ android {
"TRACKING_HTTP_TIMEOUT_MS",
"${trackingHttpTimeoutMs.get()}L"
)
buildConfigField("boolean", "FIREBASE_CONFIGURED", firebaseConfigured.toString())
buildConfigField(
"String",
"FIREBASE_APPLICATION_ID",
quotedBuildConfig(firebaseApplicationId.get())
)
buildConfigField("String", "FIREBASE_API_KEY", quotedBuildConfig(firebaseApiKey.get()))
buildConfigField(
"String",
"FIREBASE_PROJECT_ID",
quotedBuildConfig(firebaseProjectId.get())
)
buildConfigField(
"String",
"FIREBASE_GCM_SENDER_ID",
quotedBuildConfig(firebaseSenderId.get())
)
buildConfigField("boolean", "SAFE_PAGE_LOAD_DIAGNOSTICS", "false")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@ -162,45 +117,22 @@ android {
"\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
manifestPlaceholders["usesCleartextTraffic"] = "true"
manifestPlaceholders["deepLinkScheme"] = debugManifestOrigin?.scheme ?: "https"
manifestPlaceholders["deepLinkHost"] =
debugManifestOrigin?.host ?: "invalid.whoneedhelp.local"
}
create("development") {
initWith(getByName("debug"))
applicationIdSuffix = ".development"
versionNameSuffix = "-development"
isDebuggable = false
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
buildConfigField(
"String",
"BASE_URL",
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
buildConfigField("boolean", "SAFE_PAGE_LOAD_DIAGNOSTICS", "true")
manifestPlaceholders["usesCleartextTraffic"] = "false"
manifestPlaceholders["deepLinkHost"] =
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
matchingFallbacks += listOf("debug")
}
create("staging") {
initWith(getByName("debug"))
applicationIdSuffix = ".staging"
versionNameSuffix = "-staging"
isDebuggable = false
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
buildConfigField(
"String",
"BASE_URL",
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
buildConfigField("boolean", "SAFE_PAGE_LOAD_DIAGNOSTICS", "true")
manifestPlaceholders["usesCleartextTraffic"] = "false"
manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https"
manifestPlaceholders["deepLinkHost"] =
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
matchingFallbacks += listOf("debug")
@ -218,6 +150,7 @@ android {
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
)
manifestPlaceholders["usesCleartextTraffic"] = "false"
manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https"
manifestPlaceholders["deepLinkHost"] =
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
proguardFiles(
@ -241,19 +174,14 @@ android {
}
}
tasks.matching {
it.name == "preDevelopmentBuild" ||
it.name == "preStagingBuild" ||
it.name == "preReleaseBuild"
}.configureEach {
tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach {
doFirst {
validateFirebaseConfiguration()
if (!releaseSigningConfigured) {
if (name == "preReleaseBuild" && !releaseSigningConfigured) {
val detail =
if (releaseSigningPartiallyConfigured) {
"Android signing is only partially configured"
"Release signing is only partially configured"
} else {
"Android signing is not configured"
"Release signing is not configured"
}
throw GradleException(
"$detail; set WNH_ANDROID_SIGNING_STORE_FILE, "
@ -286,7 +214,6 @@ tasks.matching {
tasks.matching { it.name == "preDebugBuild" }.configureEach {
doFirst {
validateFirebaseConfiguration()
val value = debugBaseUrl.orNull.orEmpty()
val uri = runCatching { URI(value) }.getOrNull()
@ -331,14 +258,7 @@ tasks.withType<JavaCompile>().configureEach {
dependencies {
implementation("androidx.activity:activity:1.13.0")
implementation("androidx.core.locationbutton:locationbutton:1.0.0-alpha01")
implementation("androidx.credentials:credentials:1.6.0")
implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
implementation("androidx.fragment:fragment:1.8.9")
implementation("androidx.webkit:webkit:1.16.0")
implementation("com.google.android.libraries.identity.googleid:googleid:1.2.0")
implementation(platform("com.google.firebase:firebase-bom:34.17.0"))
implementation("com.google.firebase:firebase-messaging")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:core:1.7.0")
androidTestImplementation("androidx.test:runner:1.7.0")
@ -347,7 +267,4 @@ dependencies {
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.test.espresso:espresso-web:3.7.0")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.4.0")
androidTestImplementation(
"androidx.core.locationbutton:locationbutton-testing:1.0.0-alpha01"
)
}

View File

@ -1,3 +1 @@
# Firebase Messaging is consumed through a manifest-declared service. The
# Firebase libraries provide their own consumer rules; keep only our service.
-keep class org.whoneedhelp.mobile.WhoNeedHelpMessagingService { *; }
# The app uses only Android framework APIs. Keep rules are intentionally empty.

View File

@ -14,19 +14,13 @@ import android.Manifest;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.permissionui.LocationButtonRequest;
import android.app.permissionui.LocationButtonSession;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.SystemClock;
import android.service.notification.StatusBarNotification;
import android.view.ViewGroup;
import androidx.core.locationbutton.LocationButton;
import androidx.core.locationbutton.LocationButtonCompat;
import androidx.core.locationbutton.testing.TestLocationButtonProvider;
import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@ -46,7 +40,6 @@ import org.junit.runners.MethodSorters;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
@RunWith(AndroidJUnit4.class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@ -73,39 +66,8 @@ public final class AndroidClientInstrumentedTest {
context.stopService(new Intent(context, TrackingService.class));
context.getSystemService(NotificationManager.class).cancelAll();
device.pressBack();
if (server != null) {
server.close();
}
}
@Test
public void test00ColdStartShowsLoadingStateUntilFirstPageIsVisible() throws Exception {
server.delayNextGet("/delayed-start", 1_500);
try (ActivityScenario<MainActivity> scenario = launch("/delayed-start")) {
assertTrue(
"Cold start did not expose a native loading state",
device.wait(
Until.hasObject(
By.res(context.getPackageName(), "page_loading")
),
1_000
)
);
assertTrue(
"Native loading state remained after the first page became visible",
device.wait(
Until.gone(
By.res(context.getPackageName(), "page_loading")
),
UI_TIMEOUT_MS
)
);
onWebView()
.withElement(findElement(Locator.ID, "marker"))
.check(webMatches(getText(), containsString("loaded:/delayed-start")));
}
}
@Test
public void test01TrackingWithoutLocationPermissionStopsLocally() throws Exception {
@ -145,99 +107,6 @@ public final class AndroidClientInstrumentedTest {
.withElement(findElement(Locator.ID, "location"))
.perform(webClick());
UiObject2 locationButton = device.wait(
Until.findObject(
By.res(context.getPackageName(), "location_button")
),
UI_TIMEOUT_MS
);
assertNotNull(
"The native Android location explanation was not shown",
locationButton
);
assertTrue(
device.hasObject(
By.text(context.getString(R.string.location_button_title))
)
);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
AtomicReference<LocationButtonSession> session = new AtomicReference<>();
TestLocationButtonProvider provider = new TestLocationButtonProvider(context) {
@Override
protected void onSessionRequestReceived(
LocationButtonRequest request,
LocationButtonSession openedSession
) {
session.set(openedSession);
}
};
TestLocationButtonProvider finalProvider = provider;
scenario.onActivity(activity -> {
LocationButton button = activity.locationButtonForTesting();
if (button == null) {
throw new AssertionError(
"The native location button was not attached to its dialog"
);
}
ViewGroup parent = (ViewGroup) button.getParent();
int index = parent.indexOfChild(button);
ViewGroup.LayoutParams layoutParams = button.getLayoutParams();
parent.removeViewAt(index);
LocationButtonCompat.setLocationButtonProvider(button, finalProvider);
parent.addView(button, index, layoutParams);
});
LocationButtonSession openedSession = waitForLocationButtonSession(session);
grantLocationPermission();
provider.notifyPermissionResult(openedSession, true);
} else {
locationButton.click();
clickLegacyLocationPermissionAllow();
}
waitForLocationPermission();
FixtureHttpServer.RecordedRequest callback =
server.awaitRequestContaining(
"/geolocation-",
35,
TimeUnit.SECONDS
);
assertNotNull(
"WebView geolocation did not return a callback",
callback
);
assertFalse(
"WebView treated the granted Android permission as denied",
callback.path.endsWith("/geolocation-denied-1")
);
assertTrue("Android location permission was not retained", hasLocationPermission());
}
}
private LocationButtonSession waitForLocationButtonSession(
AtomicReference<LocationButtonSession> session
) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(UI_TIMEOUT_MS);
while (System.nanoTime() < deadline) {
LocationButtonSession openedSession = session.get();
if (openedSession != null) {
return openedSession;
}
Thread.sleep(50);
}
throw new AssertionError("The Android 17 test location session did not open");
}
private void clickLegacyLocationPermissionAllow() {
UiObject2 allow = null;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@ -285,43 +154,19 @@ public final class AndroidClientInstrumentedTest {
assertNotNull("Android location permission prompt was not shown", allow);
allow.click();
}
@Test
public void test02bTrackingRequiresExplicitNativeDisclosure() throws Exception {
try (ActivityScenario<MainActivity> scenario = launch("/tracking-disclosure")) {
onWebView()
.withElement(findElement(Locator.ID, "tracking"))
.perform(webClick());
UiObject2 disclosure = device.wait(
Until.findObject(
By.text(context.getString(R.string.tracking_disclosure_title))
),
UI_TIMEOUT_MS
);
assertNotNull("The live-location disclosure was not shown", disclosure);
assertTrue(
device.hasObject(
By.text(context.getString(R.string.tracking_disclosure_detail))
waitForLocationPermission();
assertNotNull(
"WebView geolocation did not report a granted position",
server.awaitRequestEndingWith(
"/geolocation-granted",
15,
TimeUnit.SECONDS
)
);
UiObject2 cancel = device.wait(
Until.findObject(By.res("android", "button2")),
UI_TIMEOUT_MS
);
assertNotNull("The live-location disclosure had no Cancel action", cancel);
cancel.click();
waitForServiceState(false);
onWebView()
.withElement(findElement(Locator.ID, "marker"))
.check(webMatches(getText(), containsString("tracking-cancelled")));
assertFalse(
"Cancelling live-location disclosure was reported as a technical error",
device.hasObject(By.text("tracking-error"))
);
.check(webMatches(getText(), containsString("location-granted")));
}
}
@ -350,70 +195,6 @@ public final class AndroidClientInstrumentedTest {
}
}
@Test
public void test03aNativePushStateRestoresRegisteredDevice() {
PushTokenStore.clearRegistration(context);
PushTokenStore.setRequested(context, true);
PushTokenStore.markRegistered(context, "fixture-device-id");
try (ActivityScenario<MainActivity> scenario = launch("/push-state")) {
onWebView()
.withElement(findElement(Locator.ID, "push-state"))
.perform(webClick());
onWebView()
.withElement(findElement(Locator.ID, "marker"))
.check(
webMatches(
getText(),
containsString("push-state:true:fixture-device-id")
)
);
} finally {
PushTokenStore.clearRegistration(context);
}
}
@Test
public void test03bSameOriginDeepLinkUpdatesRunningActivity() {
ActivityScenario<MainActivity> scenario =
launch("/notifications?section=settings");
try {
onWebView()
.withElement(findElement(Locator.ID, "marker"))
.check(
webMatches(
getText(),
containsString("loaded:/notifications?section=settings")
)
);
Intent notificationIntent = new Intent(
Intent.ACTION_VIEW,
Uri.parse(BuildConfig.BASE_URL + "/notifications"),
context,
MainActivity.class
);
scenario.onActivity(activity -> activity.onNewIntent(notificationIntent));
onWebView()
.withElement(findElement(Locator.ID, "marker"))
.check(
webMatches(
getText(),
containsString("loaded:/notifications")
)
);
} finally {
// Calling onNewIntent directly is deliberate: this regression test
// exercises the already-running Activity branch without depending
// on an OEM task switcher. ActivityScenario.close() can wait
// indefinitely after that synthetic lifecycle callback on HyperOS,
// so finish the test Activity explicitly instead.
scenario.onActivity(MainActivity::finish);
}
}
@Test
public void test04ForegroundTrackingPostsLocationAndNotificationStop() throws Exception {
grantTrackingPermissions();
@ -494,31 +275,33 @@ public final class AndroidClientInstrumentedTest {
server.awaitRequestEndingWith(positionPath, 15, TimeUnit.SECONDS)
);
int foregroundCount = server.requestCountEndingWith(positionPath);
device.pressHome();
SystemClock.sleep(1_000);
assertNotNull(
"Tracking stopped after the Activity left the foreground",
server.awaitRequestAfterCount(
positionPath,
foregroundCount,
15,
TimeUnit.SECONDS
)
);
assertTrue(serviceIsRunning());
assertTrue(
"Location updates were unregistered after the Activity left the foreground",
locationUpdatesAreRegistered()
);
assertTrue(
"The foreground tracking notification disappeared after pressing Home",
trackingNotificationIsActive()
);
int stoppedActivityCount = server.requestCountEndingWith(positionPath);
scenario.close();
SystemClock.sleep(1_000);
assertNotNull(
"Tracking stopped after the Activity was destroyed",
server.awaitRequestAfterCount(
positionPath,
stoppedActivityCount,
15,
TimeUnit.SECONDS
)
);
assertTrue(serviceIsRunning());
assertTrue(
"Location updates were unregistered after the Activity was destroyed",
locationUpdatesAreRegistered()
);
assertTrue(
"The foreground tracking notification disappeared after Activity destruction",
trackingNotificationIsActive()
);
openNotificationAndClickStop();
assertNotNull(
@ -567,19 +350,12 @@ public final class AndroidClientInstrumentedTest {
}
private ActivityScenario<MainActivity> launch(String path) {
String targetUrl = BuildConfig.BASE_URL + path;
Intent intent = new Intent(context, MainActivity.class);
if (AppLinkRoutePolicy.allows(targetUrl)) {
intent.setAction(Intent.ACTION_VIEW);
intent.setData(Uri.parse(targetUrl));
} else {
// Instrumentation-only fixture routes are intentionally outside the
// production App Link allowlist. Debug builds expose a same-origin
// initial URL extra specifically for these isolated test pages.
intent.putExtra("initial_url", targetUrl);
}
Intent intent = new Intent(
Intent.ACTION_VIEW,
Uri.parse(BuildConfig.BASE_URL + path),
context,
MainActivity.class
);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return ActivityScenario.launch(intent);
}
@ -612,21 +388,18 @@ public final class AndroidClientInstrumentedTest {
);
assertNotNull("Foreground tracking notification was not visible", active);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
sendStopActionFromActiveNotification();
return;
}
UiObject2 stop = device.wait(
Until.findObject(By.text(context.getString(R.string.tracking_stop_action))),
UI_TIMEOUT_MS
);
if (stop != null) {
assertNotNull("Foreground tracking notification had no Stop action", stop);
stop.click();
return;
}
// Some OEM notification shades keep foreground-service actions collapsed
// even after the notification is opened. Validate and invoke the actual
// action exposed by Android instead of treating that UI choice as an
// application failure.
sendStopActionFromActiveNotification();
}
private void sendStopActionFromActiveNotification() {
@ -689,29 +462,20 @@ public final class AndroidClientInstrumentedTest {
}
private void grantTrackingPermissions() throws Exception {
grantLocationPermission();
String packageName = context.getPackageName();
device.executeShellCommand(
"pm grant " + packageName + " " + Manifest.permission.ACCESS_FINE_LOCATION
);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
device.executeShellCommand(
"pm grant "
+ context.getPackageName()
+ " "
+ Manifest.permission.POST_NOTIFICATIONS
"pm grant " + packageName + " " + Manifest.permission.POST_NOTIFICATIONS
);
}
assertTrue(hasLocationPermission());
}
private void grantLocationPermission() throws Exception {
device.executeShellCommand(
"pm grant "
+ context.getPackageName()
+ " "
+ Manifest.permission.ACCESS_FINE_LOCATION
);
}
private void waitForServiceState(boolean expected) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
@ -737,22 +501,4 @@ public final class AndroidClientInstrumentedTest {
.contains(TrackingService.class.getName());
}
private boolean locationUpdatesAreRegistered() throws Exception {
return device
.executeShellCommand("dumpsys location")
.contains(context.getPackageName());
}
private boolean trackingNotificationIsActive() {
NotificationManager manager = context.getSystemService(NotificationManager.class);
for (StatusBarNotification notification : manager.getActiveNotifications()) {
if (notification.getId() == TrackingService.NOTIFICATION_ID) {
return true;
}
}
return false;
}
}

View File

@ -8,26 +8,19 @@ import static androidx.test.espresso.web.webdriver.DriverAtoms.getText;
import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick;
import static androidx.test.espresso.web.webdriver.DriverAtoms.webKeys;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.web.webdriver.Locator;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.By;
import androidx.test.uiautomator.UiDevice;
import androidx.test.uiautomator.UiObject2;
import androidx.test.uiautomator.Until;
import org.junit.Test;
import org.junit.runner.RunWith;
@ -36,7 +29,7 @@ import java.util.concurrent.TimeUnit;
@RunWith(AndroidJUnit4.class)
public final class CrossClientStagingInstrumentedTest {
private static final long PAGE_TIMEOUT_SECONDS = 120;
private static final long PAGE_TIMEOUT_SECONDS = 45;
private final Context context = ApplicationProvider.getApplicationContext();
private final UiDevice device =
@ -51,26 +44,12 @@ public final class CrossClientStagingInstrumentedTest {
String androidMessage = requiredArgument("android_message");
String browserReply = requiredArgument("browser_reply");
context.getSystemService(NotificationManager.class).cancelAll();
try (ActivityScenario<MainActivity> scenario = launch(loginPath)) {
waitForElement("remember-login-button");
click("remember-login-button");
waitForElementText(
"home-title",
"Need help nearby? Ask the community."
);
}
try (
ActivityScenario<MainActivity> scenario =
launch("/notifications?section=settings")
) {
waitForSelector(".phx-connected");
clickSelector("[data-enable-push]:not([disabled])");
waitForSelectorText(
"[data-push-status]",
"Push notifications are enabled on this Android device."
"main-content",
"Help can be closer than you think."
);
}
@ -81,13 +60,8 @@ public final class CrossClientStagingInstrumentedTest {
click("send-message-button");
waitForElementText("messages", androidMessage);
click("share-location-button");
confirmTrackingDisclosure();
waitForServiceState(true);
waitForElementText("messages", browserReply);
waitForNotification(
"New message",
"Open Who Need Help to read the conversation."
);
click("stop-location-button");
waitForServiceState(false);
}
@ -123,13 +97,6 @@ public final class CrossClientStagingInstrumentedTest {
.perform(webClick());
}
private static void clickSelector(String selector) throws InterruptedException {
waitForSelector(selector);
onWebView()
.withElement(findElement(Locator.CSS_SELECTOR, selector))
.perform(webClick());
}
private static void replaceText(String id, String value)
throws InterruptedException {
waitForElement(id);
@ -144,11 +111,6 @@ public final class CrossClientStagingInstrumentedTest {
}
private static void waitForSelector(String selector)
throws InterruptedException {
waitForSelectorText(selector, "");
}
private static void waitForSelectorText(String selector, String expected)
throws InterruptedException {
long deadline =
System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS);
@ -158,7 +120,7 @@ public final class CrossClientStagingInstrumentedTest {
try {
onWebView()
.withElement(findElement(Locator.CSS_SELECTOR, selector))
.check(webMatches(getText(), containsString(expected)));
.check(webMatches(getText(), containsString("")));
return;
} catch (AssertionError | RuntimeException failure) {
lastFailure = failure;
@ -167,10 +129,7 @@ public final class CrossClientStagingInstrumentedTest {
}
AssertionError timeout = new AssertionError(
"WebView selector did not contain expected text: "
+ selector
+ " => "
+ expected
"WebView selector did not appear: " + selector
);
if (lastFailure != null) {
@ -180,38 +139,6 @@ public final class CrossClientStagingInstrumentedTest {
throw timeout;
}
private void waitForNotification(String expectedTitle, String expectedBody)
throws InterruptedException {
NotificationManager manager =
context.getSystemService(NotificationManager.class);
long deadline =
System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS);
while (System.nanoTime() < deadline) {
for (StatusBarNotification active : manager.getActiveNotifications()) {
Notification notification = active.getNotification();
CharSequence title =
notification.extras.getCharSequence(Notification.EXTRA_TITLE);
CharSequence body =
notification.extras.getCharSequence(Notification.EXTRA_TEXT);
if (
expectedTitle.contentEquals(title == null ? "" : title)
&& expectedBody.contentEquals(body == null ? "" : body)
) {
return;
}
}
Thread.sleep(250);
}
throw new AssertionError(
"Android did not receive the expected FCM notification: "
+ expectedTitle
);
}
private static void waitForElementText(String id, String expected)
throws InterruptedException {
long deadline =
@ -241,31 +168,6 @@ public final class CrossClientStagingInstrumentedTest {
throw timeout;
}
private void confirmTrackingDisclosure() {
UiObject2 disclosure = device.wait(
Until.findObject(
By.text(context.getString(R.string.tracking_disclosure_title))
),
TimeUnit.SECONDS.toMillis(PAGE_TIMEOUT_SECONDS)
);
assertNotNull("The live-location disclosure was not shown", disclosure);
assertTrue(
device.hasObject(
By.text(context.getString(R.string.tracking_disclosure_detail))
)
);
UiObject2 continueButton = device.wait(
Until.findObject(By.res("android", "button1")),
TimeUnit.SECONDS.toMillis(PAGE_TIMEOUT_SECONDS)
);
assertNotNull(
"The live-location disclosure had no Continue action",
continueButton
);
continueButton.click();
}
private void waitForServiceState(boolean expected) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15);

View File

@ -39,8 +39,6 @@ final class FixtureHttpServer implements Closeable {
private volatile boolean running = true;
private volatile boolean disconnectStopRequest;
private volatile String disconnectNextGetPath;
private volatile String delayedNextGetPath;
private volatile long delayedNextGetMillis;
FixtureHttpServer() throws IOException {
URI base = URI.create(BuildConfig.BASE_URL);
@ -71,11 +69,6 @@ final class FixtureHttpServer implements Closeable {
disconnectNextGetPath = path;
}
void delayNextGet(String path, long delayMillis) {
delayedNextGetPath = path;
delayedNextGetMillis = delayMillis;
}
RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit)
throws InterruptedException {
return awaitRequestAfterCount(suffix, 0, timeout, unit);
@ -95,25 +88,6 @@ final class FixtureHttpServer implements Closeable {
return count;
}
RecordedRequest awaitRequestContaining(String value, long timeout, TimeUnit unit)
throws InterruptedException {
long deadline = System.nanoTime() + unit.toNanos(timeout);
while (System.nanoTime() < deadline) {
synchronized (requests) {
for (RecordedRequest request : requests) {
if (request.path.contains(value)) {
return request;
}
}
}
Thread.sleep(50);
}
return null;
}
RecordedRequest awaitRequestAfterCount(
String suffix,
int previousCount,
@ -215,19 +189,6 @@ final class FixtureHttpServer implements Closeable {
return;
}
if ("GET".equals(request.method) && request.path.equals(delayedNextGetPath)) {
long delayMillis = delayedNextGetMillis;
delayedNextGetPath = null;
delayedNextGetMillis = 0;
try {
Thread.sleep(delayMillis);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return;
}
}
if ("GET".equals(request.method)) {
writeResponse(
connection.getOutputStream(),
@ -259,32 +220,13 @@ final class FixtureHttpServer implements Closeable {
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<title>Who Need Help Android fixture</title></head><body>"
+ "<h1 id=\"marker\">loaded:" + escapedPath + "</h1>"
+ "<script>"
+ "window.addEventListener('wnh:native-tracking-cancelled',()=>{"
+ "document.getElementById('marker').textContent='tracking-cancelled'});"
+ "window.addEventListener('wnh:native-tracking-error',()=>{"
+ "document.getElementById('marker').textContent='tracking-error'});"
+ "window.addEventListener('wnh:native-push-state',(event)=>{"
+ "document.getElementById('marker').textContent='push-state:'"
+ "+event.detail.requested+':'"
+ "+event.detail.server_device_id});"
+ "</script>"
+ "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition("
+ "()=>{document.getElementById('marker').textContent='location-granted';"
+ "fetch('/geolocation-granted')},"
+ "(error)=>{document.getElementById('marker').textContent="
+ "'location-denied-'+error.code;"
+ "fetch('/geolocation-denied-'+error.code)},"
+ "{enableHighAccuracy:false,maximumAge:3600000,timeout:30000})\">"
+ "()=>{document.getElementById('marker').textContent='location-denied';"
+ "fetch('/geolocation-denied')},"
+ "{enableHighAccuracy:true,maximumAge:0,timeout:10000})\">"
+ "Request location</button>"
+ "<button id=\"tracking\" onclick=\"window.WhoNeedHelpAndroid.postMessage("
+ "JSON.stringify({action:'start',"
+ "assignment_id:'00000000-0000-4000-8000-000000000001',"
+ "csrf_token:'fixture-csrf'}))\">"
+ "Start live tracking</button>"
+ "<button id=\"push-state\" onclick=\"window.WhoNeedHelpAndroid.postMessage("
+ "JSON.stringify({action:'push_token_request'}))\">"
+ "Read push state</button>"
+ "</body></html>";
}

View File

@ -4,7 +4,6 @@ import static androidx.test.espresso.web.assertion.WebViewAssertions.webMatches;
import static androidx.test.espresso.web.sugar.Web.onWebView;
import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement;
import static androidx.test.espresso.web.webdriver.DriverAtoms.getText;
import static org.junit.Assert.assertTrue;
import static org.hamcrest.Matchers.containsString;
import android.content.Context;
@ -29,15 +28,10 @@ public final class PublicStagingInstrumentedTest {
@Test
public void publicHomeAndSafetyDeepLinkRenderExpectedDom() throws Exception {
assertTrue(
"Public non-production smoke builds must expose redacted page-load diagnostics",
BuildConfig.SAFE_PAGE_LOAD_DIAGNOSTICS
);
try (ActivityScenario<MainActivity> scenario = launch("/")) {
waitForElementText(
"main-content",
"Need help nearby? Ask the community."
"Help can be closer than you think."
);
}

View File

@ -1,42 +0,0 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.os.Build;
import android.view.View;
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class SystemBarInsetsInstrumentedTest {
@Rule
public final ActivityScenarioRule<MainActivity> activityRule =
new ActivityScenarioRule<>(MainActivity.class);
@Test
public void edgeToEdgeContentUsesSystemBarInsets() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
return;
}
activityRule.getScenario().onActivity(activity -> {
View contentView = activity.findViewById(R.id.main_content);
assertNotNull(contentView);
assertTrue(
"The app content must start below the status bar",
contentView.getPaddingTop() > 0
);
assertTrue(
"The app content must end above the navigation bar",
contentView.getPaddingBottom() > 0
);
});
}
}

View File

@ -3,31 +3,21 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.USE_LOCATION_BUTTON" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application
android:name=".WhoNeedHelpApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.WhoNeedHelp"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="firebase_messaging_installation_id_enabled"
android:value="true" />
<activity
android:name=".MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
@ -37,25 +27,13 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<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:scheme="http" />
<data android:scheme="https" />
<data android:host="${deepLinkHost}" />
<data android:path="/requests" />
<data android:pathPrefix="/requests/" />
<data android:path="/activities" />
<data android:pathPrefix="/activities/" />
<data android:path="/notifications" />
<data android:path="/reports" />
<data android:path="/users/log-in" />
<data android:path="/safety" />
<data android:path="/profile" />
<data android:pathPrefix="/people/" />
<data android:path="/leaderboard" />
<data android:path="/categories/proposals" />
<data
android:host="${deepLinkHost}"
android:scheme="${deepLinkScheme}" />
</intent-filter>
</activity>
<service
@ -63,12 +41,5 @@
android:exported="false"
android:foregroundServiceType="location"
android:stopWithTask="false" />
<service
android:name=".WhoNeedHelpMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -1,36 +0,0 @@
package org.whoneedhelp.mobile;
import java.net.URI;
import java.net.URISyntaxException;
final class AppLinkRoutePolicy {
private AppLinkRoutePolicy() {}
static boolean allows(String value) {
if (value == null) {
return false;
}
try {
String path = new URI(value).getPath();
if (path == null) {
return false;
}
return path.equals("/requests")
|| path.startsWith("/requests/")
|| path.equals("/activities")
|| path.startsWith("/activities/")
|| path.equals("/notifications")
|| path.equals("/reports")
|| path.equals("/users/log-in")
|| path.equals("/safety")
|| path.equals("/profile")
|| path.startsWith("/people/")
|| path.equals("/leaderboard")
|| path.equals("/categories/proposals");
} catch (URISyntaxException exception) {
return false;
}
}
}

View File

@ -25,11 +25,8 @@ final class LaunchUrlResolver {
String debugInitialUrl,
boolean debugBuild
) {
if (deepLinkUrl != null) {
String appLink = trustedOrigin.canonicalAppLink(deepLinkUrl);
if (appLink != null && AppLinkRoutePolicy.allows(appLink)) {
return appLink;
}
if (deepLinkUrl != null && trustedOrigin.matches(deepLinkUrl)) {
return deepLinkUrl;
}
if (
@ -42,8 +39,4 @@ final class LaunchUrlResolver {
return null;
}
static boolean shouldLoadIncomingUrl(String currentUrl, String candidateUrl) {
return candidateUrl != null && !candidateUrl.equals(currentUrl);
}
}

View File

@ -1,267 +0,0 @@
package org.whoneedhelp.mobile;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.json.JSONException;
import org.json.JSONObject;
final class NativeGoogleAuthClient {
private NativeGoogleAuthClient() {
}
static Preparation prepare(
String baseUrl,
String cookie,
String csrfToken,
String flow,
String locale,
int timeoutMilliseconds
) throws IOException, JSONException {
JSONObject body = new JSONObject()
.put("flow", flow)
.put("locale", locale == null ? "" : locale);
Response response = post(
baseUrl,
"/mobile/auth/google/prepare",
cookie,
csrfToken,
body,
timeoutMilliseconds
);
if (response.status != HttpURLConnection.HTTP_OK) {
throw new IOException("Native Google preparation failed with HTTP " + response.status);
}
JSONObject payload = new JSONObject(response.body);
String nonce = payload.optString("nonce", "");
String serverClientId = payload.optString("server_client_id", "");
if (nonce.isBlank() || serverClientId.isBlank()) {
throw new IOException("Native Google preparation response is incomplete");
}
return new Preparation(
nonce,
serverClientId,
mergeCookieHeader(cookie, response.setCookies),
response.setCookies
);
}
static Completion complete(
String baseUrl,
String cookie,
String csrfToken,
String idToken,
int timeoutMilliseconds
) throws IOException, JSONException {
JSONObject body = new JSONObject().put("id_token", idToken);
Response response = post(
baseUrl,
"/mobile/auth/google/complete",
cookie,
csrfToken,
body,
timeoutMilliseconds
);
if (
response.status != HttpURLConnection.HTTP_MOVED_TEMP
&& response.status != HttpURLConnection.HTTP_SEE_OTHER
) {
throw new IOException("Native Google completion failed with HTTP " + response.status);
}
if (response.location == null || response.location.isBlank()) {
throw new IOException("Native Google completion did not return a redirect");
}
return new Completion(response.location, response.setCookies);
}
static String mergeCookieHeader(String original, List<String> setCookies) {
LinkedHashMap<String, String> values = new LinkedHashMap<>();
addCookiePairs(values, original, false);
for (String setCookie : setCookies) {
addCookiePairs(values, setCookie, true);
}
StringBuilder merged = new StringBuilder();
for (Map.Entry<String, String> entry : values.entrySet()) {
if (merged.length() > 0) {
merged.append("; ");
}
merged.append(entry.getKey()).append('=').append(entry.getValue());
}
return merged.toString();
}
private static void addCookiePairs(
LinkedHashMap<String, String> destination,
String raw,
boolean firstOnly
) {
if (raw == null || raw.isBlank()) {
return;
}
String[] parts = raw.split(";");
int limit = firstOnly ? Math.min(parts.length, 1) : parts.length;
for (int index = 0; index < limit; index++) {
String part = parts[index].trim();
int separator = part.indexOf('=');
if (separator <= 0) {
continue;
}
String name = part.substring(0, separator).trim();
String value = part.substring(separator + 1).trim();
if (
!name.isEmpty()
&& name.indexOf('\r') < 0
&& name.indexOf('\n') < 0
&& value.indexOf('\r') < 0
&& value.indexOf('\n') < 0
) {
destination.put(name, value);
}
}
}
private static Response post(
String baseUrl,
String path,
String cookie,
String csrfToken,
JSONObject body,
int timeoutMilliseconds
) throws IOException {
HttpURLConnection connection = null;
try {
URL url = URI.create(baseUrl).resolve(path).toURL();
connection = (HttpURLConnection) url.openConnection();
connection.setInstanceFollowRedirects(false);
connection.setRequestMethod("POST");
connection.setConnectTimeout(timeoutMilliseconds);
connection.setReadTimeout(timeoutMilliseconds);
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("X-CSRF-Token", csrfToken);
connection.setRequestProperty("Cookie", cookie);
connection.setDoOutput(true);
byte[] encoded = body.toString().getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(encoded.length);
try (OutputStream output = connection.getOutputStream()) {
output.write(encoded);
}
int status = connection.getResponseCode();
InputStream responseStream =
status >= 400 ? connection.getErrorStream() : connection.getInputStream();
String responseBody = "";
if (responseStream != null) {
try (InputStream input = responseStream) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[4_096];
int count;
while ((count = input.read(buffer)) != -1) {
output.write(buffer, 0, count);
}
responseBody = output.toString(StandardCharsets.UTF_8.name());
}
}
return new Response(
status,
responseBody,
connection.getHeaderField("Location"),
setCookieHeaders(connection)
);
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private static List<String> setCookieHeaders(HttpURLConnection connection) {
ArrayList<String> values = new ArrayList<>();
for (Map.Entry<String, List<String>> header : connection.getHeaderFields().entrySet()) {
if (
header.getKey() != null
&& header.getKey().toLowerCase(Locale.ROOT).equals("set-cookie")
&& header.getValue() != null
) {
values.addAll(header.getValue());
}
}
return values;
}
static final class Preparation {
final String nonce;
final String serverClientId;
final String cookie;
final List<String> setCookies;
Preparation(
String nonce,
String serverClientId,
String cookie,
List<String> setCookies
) {
this.nonce = nonce;
this.serverClientId = serverClientId;
this.cookie = cookie;
this.setCookies = List.copyOf(setCookies);
}
}
static final class Completion {
final String location;
final List<String> setCookies;
Completion(String location, List<String> setCookies) {
this.location = location;
this.setCookies = List.copyOf(setCookies);
}
}
private static final class Response {
final int status;
final String body;
final String location;
final List<String> setCookies;
Response(int status, String body, String location, List<String> setCookies) {
this.status = status;
this.body = body;
this.location = location;
this.setCookies = setCookies;
}
}
}

View File

@ -1,30 +0,0 @@
package org.whoneedhelp.mobile;
import java.net.URI;
import java.net.URISyntaxException;
final class PushRoute {
private PushRoute() {}
static String resolve(String baseUrl, String path, boolean debugBuild) {
if (
path == null
|| path.isBlank()
|| !path.startsWith("/")
|| path.startsWith("//")
|| path.contains("\\")
) {
return null;
}
try {
TrustedOrigin origin = TrustedOrigin.parse(baseUrl, debugBuild);
URI base = new URI(origin.startUrl() + "/");
URI resolved = base.resolve(path);
String candidate = resolved.toString();
return origin.matches(candidate) ? candidate : null;
} catch (IllegalArgumentException | URISyntaxException exception) {
return null;
}
}
}

View File

@ -1,92 +0,0 @@
package org.whoneedhelp.mobile;
import android.content.Context;
import android.content.SharedPreferences;
import java.util.UUID;
final class PushTokenStore {
private static final String PREFERENCES = "who_need_help_push";
private static final String INSTALLATION_ID = "installation_id";
private static final String TOKEN = "fcm_token";
private static final String REQUESTED = "requested";
private static final String DIRTY = "registration_dirty";
private static final String SERVER_DEVICE_ID = "server_device_id";
private PushTokenStore() {}
static synchronized String installationId(Context context) {
SharedPreferences preferences = preferences(context);
String existing = preferences.getString(INSTALLATION_ID, null);
if (existing != null && !existing.isBlank()) {
return existing;
}
String generated = UUID.randomUUID().toString();
preferences.edit().putString(INSTALLATION_ID, generated).apply();
return generated;
}
static void setRequested(Context context, boolean value) {
preferences(context).edit().putBoolean(REQUESTED, value).apply();
}
static boolean requested(Context context) {
return preferences(context).getBoolean(REQUESTED, false);
}
static void storeToken(Context context, String value) {
if (value == null || value.isBlank()) {
return;
}
preferences(context)
.edit()
.putString(TOKEN, value)
.putBoolean(DIRTY, true)
.apply();
}
static String pendingToken(Context context) {
SharedPreferences preferences = preferences(context);
if (!requested(context) || !preferences.getBoolean(DIRTY, false)) {
return null;
}
return preferences.getString(TOKEN, null);
}
static void markRegistered(Context context, String deviceId) {
SharedPreferences.Editor editor = preferences(context)
.edit()
.putBoolean(DIRTY, false);
if (deviceId == null || deviceId.isBlank()) {
editor.remove(SERVER_DEVICE_ID);
} else {
editor.putString(SERVER_DEVICE_ID, deviceId);
}
editor.apply();
}
static String serverDeviceId(Context context) {
return preferences(context).getString(SERVER_DEVICE_ID, null);
}
static void clearRegistration(Context context) {
preferences(context)
.edit()
.putBoolean(REQUESTED, false)
.putBoolean(DIRTY, false)
.remove(TOKEN)
.remove(SERVER_DEVICE_ID)
.apply();
}
private static SharedPreferences preferences(Context context) {
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
}
}

View File

@ -39,7 +39,7 @@ public final class TrackingService extends Service implements LocationListener {
private static final String EXTRA_CSRF_TOKEN = "csrf_token";
private static final String EXTRA_COOKIE = "cookie";
private static final String CHANNEL_ID = "active_help_tracking";
static final int NOTIFICATION_ID = 4101;
private static final int NOTIFICATION_ID = 4101;
private final Object pendingLocationLock = new Object();
private final AtomicBoolean uploadRunning = new AtomicBoolean(false);

View File

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

View File

@ -1,41 +0,0 @@
package org.whoneedhelp.mobile;
import android.app.Application;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class WhoNeedHelpApplication extends Application {
private final ExecutorService firebaseExecutor =
Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "wnh-firebase");
thread.setPriority(Thread.NORM_PRIORITY);
return thread;
});
@Override
public void onCreate() {
super.onCreate();
if (!BuildConfig.FIREBASE_CONFIGURED) {
return;
}
if (FirebaseApp.getApps(this).isEmpty()) {
FirebaseOptions options = new FirebaseOptions.Builder()
.setApplicationId(BuildConfig.FIREBASE_APPLICATION_ID)
.setApiKey(BuildConfig.FIREBASE_API_KEY)
.setProjectId(BuildConfig.FIREBASE_PROJECT_ID)
.setGcmSenderId(BuildConfig.FIREBASE_GCM_SENDER_ID)
.build();
FirebaseApp.initializeApp(this, options);
}
}
void runFirebaseTask(Runnable task) {
firebaseExecutor.execute(task);
}
}

View File

@ -1,96 +0,0 @@
package org.whoneedhelp.mobile;
import android.annotation.SuppressLint;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import androidx.annotation.NonNull;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import java.util.Map;
// The current Firebase Installation ID API invokes onRegistered; onNewToken is deprecated.
@SuppressLint("MissingFirebaseInstanceTokenRefresh")
public final class WhoNeedHelpMessagingService extends FirebaseMessagingService {
private static final String CHANNEL_ID = "who_need_help_updates";
@Override
public void onRegistered(@NonNull String installationId) {
PushTokenStore.storeToken(this, installationId);
}
@Override
public void onUnregistered(@NonNull String installationId) {
PushTokenStore.clearRegistration(this);
}
@Override
public void onMessageReceived(RemoteMessage message) {
Map<String, String> data = message.getData();
String route = PushRoute.resolve(
BuildConfig.BASE_URL,
data.get("path"),
BuildConfig.DEBUG
);
if (route == null) {
return;
}
String title = limited(data.get("title"), getString(R.string.app_name), 120);
String body = limited(data.get("body"), getString(R.string.updates_default_body), 240);
String notificationId = limited(data.get("notification_id"), route, 160);
createChannel();
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(route), this, MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
this,
notificationId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setStyle(new NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
getSystemService(NotificationManager.class)
.notify(notificationId.hashCode(), builder.build());
}
private void createChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
getString(R.string.updates_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
);
channel.setDescription(getString(R.string.updates_channel_description));
getSystemService(NotificationManager.class).createNotificationChannel(channel);
}
private static String limited(String value, String fallback, int maximum) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
normalized = fallback;
}
return normalized.length() <= maximum ? normalized : normalized.substring(0, maximum);
}
}

View File

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@id/main_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="MergeRootFrame">
<WebView
android:id="@+id/web_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="invisible" />
<LinearLayout
android:id="@+id/page_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/app_surface"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp">
<ImageView
android:layout_width="96dp"
android:layout_height="96dp"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:src="@drawable/ic_launcher_foreground" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="sans-serif-medium"
android:text="@string/app_name"
android:textColor="@color/app_text"
android:textSize="24sp" />
<ProgressBar
android:id="@+id/page_loading_progress"
style="?android:attr/progressBarStyle"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="28dp"
android:contentDescription="@string/loading_page"
android:indeterminate="true"
android:indeterminateTint="@color/brand_green" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:text="@string/loading_page"
android:textColor="@color/app_text_secondary"
android:textSize="15sp" />
</LinearLayout>
</FrameLayout>

View File

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingTop="12dp"
android:paddingEnd="20dp"
android:paddingBottom="8dp">
<androidx.core.locationbutton.LocationButton
android:id="@+id/location_button"
android:layout_width="match_parent"
android:layout_height="56dp"
android:textColor="@android:color/white"
app:backgroundColor="@color/brand_green"
app:cornerRadius="16dp"
app:locationButtonTextType="use_precise_location" />
</FrameLayout>

View File

@ -1,8 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- API 33+ supplies the themed monochrome layer in mipmap-anydpi-v33. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="MonochromeLauncherIcon">
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Who Need Help</string>
<string name="no_link_handler">Нет приложения, которое может открыть эту ссылку.</string>
<string name="page_load_failed_title">Страница недоступна</string>
<string name="page_load_failed">Не удалось загрузить Who Need Help. Проверьте подключение и повторите попытку.</string>
<string name="loading_page">Безопасная загрузка…</string>
<string name="retry">Повторить</string>
<string name="close">Закрыть</string>
<string name="cancel">Отмена</string>
<string name="secure_connection_failed">Безопасное подключение было отклонено.</string>
<string name="unsupported_link">Этот тип ссылки не поддерживается.</string>
<string name="tracking_channel_name">Геолокация активной помощи</string>
<string name="tracking_channel_description">Видимая передача геолокации для активной принятой заявки.</string>
<string name="tracking_active">Передача текущей геолокации</string>
<string name="tracking_active_detail">Who Need Help может продолжать передачу, когда приложение свёрнуто.</string>
<string name="tracking_stop_action">Остановить передачу</string>
<string name="tracking_stopping">Остановка передачи геолокации</string>
<string name="tracking_stopping_detail">Ожидаем подтверждения удаления текущей позиции с сервера.</string>
<string name="tracking_stop_failed">Не удалось подтвердить остановку</string>
<string name="tracking_stop_failed_detail">Новые обновления приостановлены. Нажмите «Остановить передачу», чтобы повторить удаление текущей позиции.</string>
<string name="tracking_location_unavailable">Геолокация недоступна</string>
<string name="tracking_location_unavailable_detail">Включите геолокацию устройства, вернитесь к заявке и повторите попытку.</string>
<string name="location_button_title">Использовать точную геолокацию один раз</string>
<string name="location_button_detail">Who Need Help использует вашу точную геолокацию только для этого действия, чтобы разместить выбранную точку или область. Это не запускает передачу геолокации в реальном времени.</string>
<string name="location_button_failed">Android не смог показать защищённую кнопку геолокации. Геолокация не передавалась.</string>
<string name="tracking_disclosure_title">Передавать текущую геолокацию участнику?</string>
<string name="tracking_disclosure_detail">Who Need Help получает и отправляет вашу точную геолокацию назначенному заказчику или помощнику для передачи геопозиции в реальном времени, в том числе в фоновом режиме, когда приложение свёрнуто или не используется. Передача начинается только после нажатия «Продолжить». Постоянное уведомление останется видимым. Нажмите «Остановить передачу» в приложении или уведомлении, чтобы остановить её; текущая необработанная позиция после этого удаляется. Сводные данные безопасности могут храниться в соответствии с Политикой конфиденциальности.</string>
<string name="tracking_disclosure_continue">Продолжить и передавать</string>
<string name="updates_channel_name">Обновления заявок о помощи</string>
<string name="updates_channel_description">Приватные уведомления о заявках, сообщениях и помощи поблизости.</string>
<string name="updates_default_body">Откройте Who Need Help, чтобы просмотреть обновление.</string>
</resources>

View File

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Who Need Help</string>
<string name="no_link_handler">Немає застосунку, який може відкрити це посилання.</string>
<string name="page_load_failed_title">Сторінка недоступна</string>
<string name="page_load_failed">Не вдалося завантажити Who Need Help. Перевірте з’єднання та повторіть спробу.</string>
<string name="loading_page">Безпечне завантаження…</string>
<string name="retry">Повторити</string>
<string name="close">Закрити</string>
<string name="cancel">Скасувати</string>
<string name="secure_connection_failed">Захищене з’єднання було відхилено.</string>
<string name="unsupported_link">Цей тип посилання не підтримується.</string>
<string name="tracking_channel_name">Геолокація активної допомоги</string>
<string name="tracking_channel_description">Видима передача геолокації для активної прийнятої заявки.</string>
<string name="tracking_active">Передача поточної геолокації</string>
<string name="tracking_active_detail">Who Need Help може продовжувати передачу, коли застосунок згорнуто.</string>
<string name="tracking_stop_action">Зупинити передачу</string>
<string name="tracking_stopping">Зупинення передачі геолокації</string>
<string name="tracking_stopping_detail">Очікуємо підтвердження видалення поточної позиції із сервера.</string>
<string name="tracking_stop_failed">Не вдалося підтвердити зупинення</string>
<string name="tracking_stop_failed_detail">Нові оновлення призупинено. Натисніть «Зупинити передачу», щоб повторити видалення поточної позиції.</string>
<string name="tracking_location_unavailable">Геолокація недоступна</string>
<string name="tracking_location_unavailable_detail">Увімкніть геолокацію пристрою, поверніться до заявки та повторіть спробу.</string>
<string name="location_button_title">Використати точну геолокацію один раз</string>
<string name="location_button_detail">Who Need Help використає вашу точну геолокацію лише для цієї дії, щоб розмістити вибрану точку або область. Це не запускає передавання геолокації в реальному часі.</string>
<string name="location_button_failed">Android не зміг показати захищену кнопку геолокації. Геолокація не передавалася.</string>
<string name="tracking_disclosure_title">Передавати поточну геолокацію учаснику?</string>
<string name="tracking_disclosure_detail">Who Need Help отримує й надсилає вашу точну геолокацію призначеному замовнику або помічнику для передавання геопозиції в реальному часі, зокрема у фоновому режимі, коли застосунок згорнуто або він не використовується. Передавання починається лише після натискання «Продовжити». Постійне сповіщення залишатиметься видимим. Натисніть «Зупинити передавання» в застосунку або сповіщенні, щоб зупинити його; поточна необроблена позиція після цього видаляється. Зведені дані безпеки можуть зберігатися відповідно до Політики конфіденційності.</string>
<string name="tracking_disclosure_continue">Продовжити й передавати</string>
<string name="updates_channel_name">Оновлення заявок про допомогу</string>
<string name="updates_channel_description">Приватні сповіщення про заявки, повідомлення та допомогу поблизу.</string>
<string name="updates_default_body">Відкрийте Who Need Help, щоб переглянути оновлення.</string>
</resources>

View File

@ -2,7 +2,4 @@
<resources>
<color name="brand_green">#14532D</color>
<color name="brand_green_dark">#0A2F1A</color>
<color name="app_surface">#FAFAFA</color>
<color name="app_text">#202124</color>
<color name="app_text_secondary">#5F6368</color>
</resources>

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="main_content" type="id" />
</resources>

View File

@ -4,10 +4,8 @@
<string name="no_link_handler">No app can open this link.</string>
<string name="page_load_failed_title">Page unavailable</string>
<string name="page_load_failed">Could not load Who Need Help. Check your connection and retry.</string>
<string name="loading_page">Loading securely…</string>
<string name="retry">Retry</string>
<string name="close">Close</string>
<string name="cancel">Cancel</string>
<string name="secure_connection_failed">The secure connection was rejected.</string>
<string name="unsupported_link">This link type is not supported.</string>
<string name="tracking_channel_name">Active help location</string>
@ -21,13 +19,4 @@
<string name="tracking_stop_failed_detail">New updates are paused. Tap Stop sharing to retry deleting the current position.</string>
<string name="tracking_location_unavailable">Location is unavailable</string>
<string name="tracking_location_unavailable_detail">Enable device location, then return to the request and try again.</string>
<string name="location_button_title">Use your precise location once</string>
<string name="location_button_detail">Who Need Help will use your precise location only for this action to place the point or area you selected. This does not start live tracking.</string>
<string name="location_button_failed">Android could not provide the secure location button. No location was shared.</string>
<string name="tracking_disclosure_title">Share live location with your match?</string>
<string name="tracking_disclosure_detail">Who Need Help collects and sends your precise location to the matched requester or helper for live location sharing, including in the background when the app is minimized or not in use. Sharing starts only after you continue. A persistent notification remains visible. Use Stop sharing in the app or notification to stop; the current raw position is then deleted. Summary safety evidence may be retained as described in the Privacy Policy.</string>
<string name="tracking_disclosure_continue">Continue and share</string>
<string name="updates_channel_name">Help request updates</string>
<string name="updates_channel_description">Private request, message, and nearby-help notifications.</string>
<string name="updates_default_body">Open Who Need Help to view the update.</string>
</resources>

View File

@ -6,6 +6,6 @@
<item name="android:navigationBarColor">@color/brand_green_dark</item>
<item name="android:statusBarColor">@color/brand_green</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@ -1,26 +0,0 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class AppLinkRoutePolicyTest {
@Test
public void acceptsOnlyIntentionalApplicationRoutes() {
assertTrue(AppLinkRoutePolicy.allows("https://help.example/requests/123#messages"));
assertTrue(AppLinkRoutePolicy.allows("https://help.example/activities/456"));
assertTrue(AppLinkRoutePolicy.allows("https://help.example/users/log-in#token=secret"));
assertTrue(AppLinkRoutePolicy.allows("https://help.example/safety"));
assertTrue(AppLinkRoutePolicy.allows("https://help.example/people/123"));
assertFalse(
AppLinkRoutePolicy.allows(
"https://help.example/auth/google/callback?code=one-time&state=browser"
)
);
assertFalse(AppLinkRoutePolicy.allows("https://help.example/auth/social/google/callback"));
assertFalse(AppLinkRoutePolicy.allows("https://help.example/"));
assertFalse(AppLinkRoutePolicy.allows("not a URI"));
}
}

View File

@ -1,16 +0,0 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public final class BuildTypeConfigurationTest {
@Test
public void safePageLoadDiagnosticsAreLimitedToPublicNonProductionBuilds() {
boolean expected =
BuildConfig.APPLICATION_ID.endsWith(".development")
|| BuildConfig.APPLICATION_ID.endsWith(".staging");
assertEquals(expected, BuildConfig.SAFE_PAGE_LOAD_DIAGNOSTICS);
}
}

View File

@ -1,9 +1,7 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
@ -30,14 +28,6 @@ public final class LaunchUrlResolverTest {
false
)
);
assertNull(
LaunchUrlResolver.incomingUrl(
origin,
"https://help.example/auth/google/callback?code=one-time&state=browser",
null,
false
)
);
}
@Test
@ -53,27 +43,6 @@ public final class LaunchUrlResolverTest {
);
}
@Test
public void releaseUpgradesAssociatedHttpLinkToTrustedHttpsOrigin() {
assertEquals(
"https://help.example/requests/123?from=email#handover",
LaunchUrlResolver.incomingUrl(
origin,
"http://help.example/requests/123?from=email#handover",
null,
false
)
);
assertNull(
LaunchUrlResolver.incomingUrl(
origin,
"http://attacker.example/requests/123",
null,
false
)
);
}
@Test
public void debugCanUseValidatedInitialUrlExtra() {
assertEquals(
@ -86,26 +55,4 @@ public final class LaunchUrlResolverTest {
)
);
}
@Test
public void incomingIntentDoesNotReloadThePageAlreadyShown() {
assertFalse(
LaunchUrlResolver.shouldLoadIncomingUrl(
"https://help.example/requests/123",
"https://help.example/requests/123"
)
);
assertTrue(
LaunchUrlResolver.shouldLoadIncomingUrl(
"https://help.example/requests/123",
"https://help.example/activities/456"
)
);
assertFalse(
LaunchUrlResolver.shouldLoadIncomingUrl(
"https://help.example/requests/123",
null
)
);
}
}

View File

@ -1,49 +0,0 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertEquals;
import java.util.List;
import org.junit.Test;
public final class NativeGoogleAuthClientTest {
@Test
public void mergesRotatedSessionCookieAndPreservesOtherCookies() {
String merged = NativeGoogleAuthClient.mergeCookieHeader(
"_who_need_help_key=old-session; locale=en; theme=dark",
List.of(
"_who_need_help_key=new-session; Path=/; HttpOnly; SameSite=Lax",
"locale=uk; Path=/"
)
);
assertEquals(
"_who_need_help_key=new-session; locale=uk; theme=dark",
merged
);
}
@Test
public void ignoresCookieAttributesAndMalformedHeaderValues() {
String merged = NativeGoogleAuthClient.mergeCookieHeader(
"session=original; invalid; =missing-name",
List.of(
"session=rotated; Path=/; Secure",
"bad\r\nheader=value; Path=/",
"second=present; SameSite=Strict"
)
);
assertEquals("session=rotated; second=present", merged);
}
@Test
public void acceptsAnEmptyInitialCookieHeader() {
String merged = NativeGoogleAuthClient.mergeCookieHeader(
null,
List.of("session=created; Path=/; HttpOnly")
);
assertEquals("session=created", merged);
}
}

View File

@ -1,23 +0,0 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import org.junit.Test;
public final class PushRouteTest {
@Test
public void acceptsOnlyRelativeSameOriginPaths() {
assertEquals(
"https://help.example/requests/123#messages",
PushRoute.resolve(
"https://help.example",
"/requests/123#messages",
false
)
);
assertNull(PushRoute.resolve("https://help.example", "https://evil.test", false));
assertNull(PushRoute.resolve("https://help.example", "//evil.test/requests", false));
assertNull(PushRoute.resolve("https://help.example", "/\\evil", false));
}
}

View File

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

View File

@ -1,113 +0,0 @@
# Google Play closed-test runbook
The developer account is a new personal account. Google currently requires at
least 12 testers to remain opted in to the closed test for 14 continuous days
before production access can be requested.
This requirement was rechecked against the official Play Console Help article
on 2026-08-26. The Console remains the source of truth for the account's actual
eligibility and the date on which production access can be requested.
## Before inviting testers
1. Complete Play developer identity and contact verification.
2. Create the Play app with package `org.whoneedhelp.mobile`.
3. Enable Play App Signing.
4. Record every signing-certificate fingerprint shown by Play, including all
identities shown for quantum-ready hybrid signing when present. Add every
applicable Play App Signing SHA-256 to production App Links and
Google/Firebase configuration, then verify the production association files.
5. Use the recorded production AAB already released only to Internal testing.
The current Internal release is `0.1.3 (4)` with SHA-256
`5147404e91aee6ef87014e19db93403fdb18a15e91b749737c494c372ea87371`;
its exact operator record is `internal-release-v4.md`.
6. Complete the store listing, App content, privacy, Data Safety, content rating,
ads, target-audience, and access declarations. Read-only Store settings
inspection on 2026-08-25 showed category **Social** and public contact email
`contact@whoneedhelp.com`; phone and website were empty.
7. Finish the internal test on the owner’s device, complete the foreground-
service declaration, then promote the verified build to the closed track.
As of the read-only recheck on 2026-08-26 the Internal release is active.
The 2026-08-28 Dashboard showed zero of five tasks complete: select countries
and regions, select testers, create a release, preview and confirm it, and
send it to Google for review.
After installing from the internal-track opt-in link, verify the delivery
boundary before testing authenticated flows:
```bash
./scripts/verify-play-installed-android.sh \
/secure/downloads/play-identities.json \
DEVICE_SERIAL \
4 \
0.1.3
```
The verifier is read-only. It requires the Google Play installer, one of the
recorded Play App Signing SHA-256 identities, the exact expected version, and a
verified `whoneedhelp.com` App Link that resolves to `MainActivity`. A locally
sideloaded APK intentionally fails this gate even if its UI and package name
look correct.
## Tester cohort
- Recruit at least 12 real people with Google or Google Workspace accounts.
- Keep at least the required 12 testers continuously opted in for the complete
14-day interval. If anyone opts out, replace them if needed and use the date
shown by Play Console rather than assuming the original interval still
qualifies.
- Testers may join on different dates, but production access cannot be
requested until at least 12 currently opted-in testers have each satisfied
the continuous 14-day requirement shown by Play Console.
- Do not publish tester email addresses in the repository.
- Give every tester the Play opt-in link and state clearly that they must remain
opted in for at least 14 continuous days.
- Record opt-in date, device/Android version, completed scenarios, and feedback
in a private operational sheet.
Do not invent a larger required cohort or a shorter testing interval. The
current official minimum is 12 continuously opted-in testers for 14 days; Play
Console is the source of truth for whether the account has satisfied it.
## Required scenarios for each cohort
- Install and open from the Play closed-test listing.
- Register or sign in with Google/email.
- Review privacy and location controls.
- Create or browse a request without exposing an exact public address.
- Accept/withdraw from a safe test request using two separate accounts.
- Send and receive private messages and push notifications.
- Start and stop live location on a clearly labelled synthetic test assignment.
- Request to join and withdraw from an activity.
- Block/report a synthetic account and locate support/account deletion.
- Confirm that rotation, background/foreground, offline/reconnect, and process
recreation do not lose critical state.
Never ask testers to simulate a real emergency, disclose prescriptions or
medical details, exchange money, or travel to meet an unknown person.
## Feedback questions
1. What task did you try to complete?
2. At which screen did you hesitate or stop?
3. Was approximate versus exact location visibility understandable?
4. Did notifications arrive, and did they open the expected screen?
5. Could you tell when live location was active and stop it?
6. Did leaving a request/activity immediately update your participation state?
7. What device and Android version did you use?
8. Did you encounter a crash, blank screen, inaccessible control, or misleading
status?
## Evidence for production-access application
- Closed-track name and release/version code.
- Dates covering at least the required continuous 14-day interval.
- Opted-in tester count shown by Play Console.
- Device/Android coverage.
- Issues found, fixes made, and how fixes were re-tested.
- Summary of feedback and why the app is ready for production.
Official references:
- https://support.google.com/googleplay/android-developer/answer/14151465
- https://support.google.com/googleplay/android-developer/answer/9845334

View File

@ -1,177 +0,0 @@
# Google Play Data Safety worksheet
This is the source-backed worksheet for the current Android build. Its answers
were entered into Google Play Console and saved as a draft on 2026-08-09. The
overall Publishing overview was deliberately **not** sent for review. Re-check
the worksheet against the exact release AAB and the current Play form before a
future review submission.
## Form-level answers
- Does the app collect or share required user data types? **Yes, collects and
shares.** The conservative sharing declaration is required by the current
direct OpenStreetMap tile integration described below.
- Is all user data encrypted in transit? **Yes.** Production app traffic uses
HTTPS; Firebase Cloud Messaging also uses encrypted transport.
- Can users request deletion? **Yes.**
- In-app path: account menu → account settings → delete-account request.
- External URL: `https://whoneedhelp.com/account/delete`.
- Does the app independently verify its security practices against a qualifying
standard? **No declaration.** Automated security checks are not an
independent certification.
- Does the app contain ads? **No.**
## Collected data types
| Play data type | Required or optional | Purposes | Current behavior/evidence |
| --- | --- | --- | --- |
| Personal info — Name | Required for an account | App functionality; account management; fraud prevention/security | Display name and profile are stored by the account system. |
| Personal info — Email address | Required for email accounts; supplied by Google for Google sign-in | App functionality; account management; security; support communications | Used for authentication, account notices, and support. |
| Personal info — User IDs | Required | App functionality; account management; fraud prevention/security | Internal account ID and connected identity identifiers. Provider access tokens are not persisted. |
| Personal info — Other info | Optional | App functionality; account management | Optional social-profile links and profile settings. |
| Location — Approximate location | Optional | App functionality; fraud prevention/security | Public request/activity location is rounded or hidden according to the user’s visibility choice. |
| Location — Precise location | Optional | App functionality; fraud prevention/security | Exact request/activity meeting point and opt-in live tracking. Exact points are restricted to relevant approved people. The current raw tracking point is deleted when sharing stops; derived evidence can remain. |
| Health and fitness — Health info | Optional, user-provided | App functionality | A medicine-help request can inherently reveal health-related context even though the UI prohibits prescriptions and unnecessary medical information. |
| App activity — App interactions | Required while using the service | App functionality; fraud prevention/security | Requests, matches, handovers, participation decisions, reviews, reports, moderation state, and safety evidence. |
| App activity — In-app search history | Optional; processed transiently | App functionality | Category, area, and map-viewport filters are sent to the server to return results and are not intentionally stored as a search-history profile. Select “processed ephemerally” if the current Play form offers it. |
| User-generated content — Other user-generated content | Optional | App functionality; fraud prevention/security; support | Request/activity descriptions, pickup instructions, private chat, reviews, category proposals, reports, and support messages. |
| Device or other IDs | Automatic for networked app functions | App functionality; fraud prevention/security | Firebase installation ID/FCM registration token, server session/security identifiers, and network identifiers exposed to the configured map-tile provider. No Google Analytics or Crashlytics SDK is included. |
## Data not collected by the current product
- Payment-card, bank-account, purchase-history, or payment-processing data.
Reimbursement happens outside the platform and sensitive payment data is
prohibited in messages.
- Contacts/address book.
- Email-message or mailbox content. The user’s authentication/contact address is
declared under **Personal info — Email address**; the app does not read or
import email messages.
- Photos, videos, audio, files, or documents.
- Advertising data.
- Google Analytics or Firebase Analytics events.
- Crashlytics crash reports.
## Sharing assessment
The current implementation sends data to:
1. The Who Need Help production server and its contracted infrastructure/service
providers to operate the product.
2. Google Firebase Cloud Messaging to deliver notifications.
3. The configured map-tile provider receives the client network request,
including its network identifier and requested tile coordinates.
4. Other users only through explicit product actions and visibility rules, such
as publishing an approximate area, accepting a match, approving an activity
participant, sending a message, or starting live sharing.
Google Play excludes some service-provider transfers and user-initiated sharing
from the “shared” declaration. The current default production configuration
loads raster tiles directly from `tile.openstreetmap.org`; OSMF is an independent
third party and there is no verified service-provider agreement under which it
processes data solely on behalf of Who Need Help. OSMF's current privacy policy
says that requests to its services produce records including IP address,
browser/device type, operating system, referrer, time, and requested pages.
At detailed zoom levels, requested tile coordinates can also describe an area
smaller than 3 km².
Until the release uses a separately verified provider relationship, answer the
top-level sharing question **Yes** and conservatively declare these current
direct tile transfers:
- **Approximate location — shared, optional, app functionality.**
- **Precise location — shared, optional, app functionality.** This applies when
a user opens a detailed map around an exact or live point.
- **Device or other IDs — shared, required while maps are used, app
functionality.** This is the conservative classification for the network and
browser/application identifiers recorded by OSMF.
This is a disclosure choice, not permission to send private request text,
messages, email, handover codes, or raw live-location API payloads to the tile
provider; the current tile requests must remain limited to standard tile
coordinates and ordinary HTTP request metadata.
## Source checks before every release
1. Compare this worksheet with `android/app/build.gradle.kts` and the resolved
release dependency report.
2. Confirm that Analytics, Crashlytics, ads, and delivery-metrics export remain
absent or update the declaration.
3. Confirm the final map-tile provider, provider agreement, request metadata,
and Play sharing classification. If the direct OSMF integration remains,
keep the conservative sharing declarations above.
4. Compare with `/privacy`, `/account/delete`, Android manifest permissions, and
the live-location prominent disclosure.
5. Confirm the external deletion URL loads without authentication and submits a
deletion request.
6. Update the worksheet if media uploads, avatars, payments, analytics, or any
new SDK is introduced.
## 2026-07-31 release-candidate verification
- `releaseRuntimeClasspath` contains Firebase Cloud Messaging 25.1.1 and its
Firebase Installations dependency. It does not contain the Firebase
Analytics, Crashlytics, Performance Monitoring, or advertising SDKs.
- The manifest keeps FCM auto-initialization and Firebase Analytics collection
disabled. Push registration is enabled only after the user requests it in the
product UI.
- No call enabling BigQuery message-delivery export was found in the Android
source.
- Firebase's current Android disclosure reference says FCM automatically
collects the app version and Firebase user agent, while Firebase
Installations generates and collects a per-installation FID. The device-ID
row above conservatively accounts for the installation identifier.
- The exact dependency report is generated locally during release validation
and intentionally is not treated as a permanent substitute for re-checking
the final AAB and current Google Play form.
## 2026-08-08 pre-submission re-check
- The current Android source fingerprint is still
`f2f281210d11465d8f7fda20a78d1ed2527660d2e8a10a29ed31bf031a29ce43`,
which exactly matches the source-bound release candidate in
`android/dist-release-20260803-162910/`.
- A fresh `releaseRuntimeClasspath` report resolves
`firebase-messaging:25.1.1` and `firebase-installations:19.1.2`. It does not
resolve Firebase Analytics, Crashlytics, Performance Monitoring, an ads SDK,
or another product-analytics SDK.
- `firebase-measurement-connector:19.0.0` is present only as a transitive
dependency of `firebase-messaging:25.1.1`; Gradle `dependencyInsight`
confirms that it was not added by an Analytics dependency.
- The public production pages `/privacy`, `/terms`, and `/account/delete`
returned HTTP 200 without authentication. The published Privacy Policy
describes FCM/Firebase Installations, direct OpenStreetMap tile requests,
foreground live-location sharing, retention, and account-deletion controls.
- `https://whoneedhelp.com/.well-known/assetlinks.json` currently publishes the
upload-certificate SHA-256 only. Re-run this worksheet after the separate
Google Play App Signing certificate is added and before submitting the Play
Data Safety form.
## 2026-08-09 Play Console draft
- The Console draft records that the app collects and shares data, encrypts
data in transit, supports account creation through password/other
authentication and OAuth, and accepts deletion requests at
`https://whoneedhelp.com/account/delete`.
- The saved draft contains all twelve selected data types documented in this
worksheet: four Personal info types, two Location types, Health info, Other
in-app messages, three App activity types, and Device or other IDs.
- Approximate location, Precise location, and Device or other IDs are the only
types conservatively marked as shared. The sharing purpose is App
functionality.
- In-app search history is marked as optional and processed ephemerally. The
other selected types use the collection, optionality, retention, and purpose
answers documented in the tables above.
- The Console preview showed the expected collected/shared categories,
encryption statement, deletion URL, and Privacy Policy URL. The final form
save succeeded and Play directed the operator to Publishing overview.
- The operator chose **Not now**. This records a saved declaration draft; it is
not evidence of Google review or approval.
## Official references
- https://support.google.com/googleplay/android-developer/answer/10787469
- https://support.google.com/googleplay/android-developer/answer/13327111
- https://firebase.google.com/docs/android/play-data-disclosure
- https://firebase.google.com/support/privacy/
- https://operations.osmfoundation.org/policies/tiles/
- https://osmfoundation.org/wiki/Privacy_Policy

View File

@ -1,73 +0,0 @@
# Internal release v1
This is the operator copy for the first Google Play internal-testing release.
It identifies the already uploaded, source-bound candidate and does not grant
permission to publish it.
## Release identity
- Track: Internal testing
- Release label: `0.1.0 internal verification`
- Package: `org.whoneedhelp.mobile`
- Version code: `1`
- Version name: `0.1.0`
- AAB: `android/dist-release-20260803-162910/who-need-help-release.aab`
- AAB SHA-256:
`03d39a9a08e9ca7569caccf1c7bd75e9349f7655935e7bbf23d1998cd37b3837`
- Source fingerprint:
`f2f281210d11465d8f7fda20a78d1ed2527660d2e8a10a29ed31bf031a29ce43`
Do not rebuild, replace, or re-upload the candidate while preparing this
release. Keep the accepted version-code `1` artifact and remove only the
failed duplicate-upload row if it is still present in the draft.
## Release notes
### English (United States)
First internal build for production verification. Test secure sign-in,
privacy-aware nearby requests and activities, private chat, notifications,
verified links, and consent-based live location sharing.
### Ukrainian
Перша внутрішня збірка для перевірки перед запуском. Перевірте безпечний вхід,
заявки й події поруч із захистом приватності, приватний чат, сповіщення,
перевірені посилання та добровільне передавання геолокації.
### Russian
Первая внутренняя сборка для проверки перед запуском. Проверьте безопасный
вход, заявки и события поблизости с защитой приватности, приватный чат,
уведомления, проверенные ссылки и добровольную передачу геолокации.
## Before publishing
Verify in Play Console that:
1. the draft is under the Who Need Help application with Play application ID
`4972430103169452589`;
2. the track is Internal testing;
3. exactly one accepted artifact with version code `1` remains in the release;
4. the package is `org.whoneedhelp.mobile`;
5. the release notes above contain no test URL, credential, email address,
private location, or prescription information;
6. no production or closed-track rollout is selected.
Saving or publishing the release is an external state change. Do not press the
final save, publish, or rollout control without the user's explicit permission
for this exact candidate and track.
## Immediately after publication
1. Record every Play App Signing SHA-1 and SHA-256 displayed by Play.
2. Import the applicable identities into the production Firebase Android
configuration and production App Links association using the protected
import workflow.
3. Install from the internal-testing opt-in link on the authorised physical
phone.
4. Run `scripts/verify-play-installed-android.sh` before testing product flows.
5. Exercise production sign-in, notifications, verified links, foreground and
background location sharing, and the Stop action from the Play-delivered
build.

View File

@ -1,33 +0,0 @@
# Internal testing release v2
## Play Console values
- Package: `org.whoneedhelp.mobile`
- Version: `0.1.1 (2)`
- Release name: `0.1.1 App Links routing fix`
- Track: Internal testing only
- Source commit: `510ad2898c2131b493659963a069a0c8b868c2fb`
- AAB: `android/dist-release-20260809-072143-v2/who-need-help-release.aab`
- AAB SHA-256: `e5a731ce2360f0ba907b4d23e9416cf0a1db8627642e1a11205de780d05dabf8`
## What changed
The application still opens supported public Who Need Help routes as verified
App Links. OAuth provider callbacks and other browser-only routes are excluded
from the Android claim, so Google can complete its browser callback before the
site returns control to the authenticated application.
## Observed result
The build was installed by Google Play on the physical test phone. The strict
installed-build verifier passed the version, installer, Play signing identity,
verified domain, supported App Link and excluded OAuth callback checks. Google
sign-in, FCM delivery and tap routing, consent-based foreground location,
background sampling, notification stop action, raw-position removal and the
native offline screen were then exercised successfully.
Detailed evidence and remaining gates are recorded in
`docs/google-play-release-candidate-2026-08-09-v2.md`.
Do not promote this build to Closed or Production solely because the Internal
testing verification passed.

View File

@ -1,105 +0,0 @@
# Internal testing release v3
This is the operator record for the current Google Play Internal testing
release. The source-bound artifact was uploaded, released only to Internal
testing, delivered by Google Play, and verified on the authorised physical
phone on 2026-08-10.
## Release identity
- Track: Internal testing only
- Release label: `0.1.2 Location consent clarity`
- Package: `org.whoneedhelp.mobile`
- Version code: `3`
- Version name: `0.1.2`
- Source commit: `cd154766a06bb1febb0598fb3f53db78628bd7f6`
- Source fingerprint:
`70529d3befcb0818f0b79f7869389b4fad432eb2911be08d52342529b7f22614`
- AAB: `android/dist-release-20260809-v3/who-need-help-release.aab`
- AAB SHA-256:
`5f1b63d02467ce63af795d16459a75693e415426478e0788b83f0f6d35dff922`
Do not upload an artifact with a different hash under this release record. A
code change requires a new version code, a fresh source-bound build, and a new
record.
## Release notes
### English (United States)
Improves live-location consent feedback. Cancelling the disclosure now leaves
sharing stopped without presenting a misleading technical error.
### Ukrainian
Покращено повідомлення про згоду на передавання геолокації. Скасування діалогу
тепер залишає передавання вимкненим і не показує помилкову технічну помилку.
### Russian
Улучшена обратная связь при согласии на передачу геолокации. Отмена диалога
теперь оставляет передачу выключенной и не показывает ложную техническую ошибку.
## Local verification
- Release unit tests, release lint, R8/resource shrinking, APK/AAB signing,
bundletool validation and the production App Links gate passed.
- The exported source fingerprint matches the current committed source.
- API 37 instrumentation passed 10/10 tests, including explicit native
disclosure cancellation and notification-based Stop behavior.
- The independent process-death probe observed the expected killed process and
verified that the non-sticky foreground service and notification did not
remain.
- The one-off emulator container, image and volume were removed by the test
harness.
Detailed evidence is recorded in
`docs/google-play-release-candidate-2026-08-09-v3.md`.
## Publication evidence
Play Console was re-read on 2026-08-10 and showed:
1. track **Internal testing** is active;
2. latest release is `0.1.2 Location consent clarity`;
3. the release is available to internal testers with one version code;
4. the release timestamp is 2026-08-10 11:02 AM in the Console UI;
5. the app is still unreviewed, so Internal testers see the temporary package
name until the application setup is reviewed;
6. no Closed or Production rollout was started by this release.
The App content overview was re-read on 2026-08-10 and showed exactly one item
under **Need attention**: the Foreground service permissions declaration. Its
Location form had no saved task selection and the Save control was disabled.
No incomplete declaration was saved or submitted during this inspection.
The Dashboard separately showed overall app setup at 9 of 11 tasks. The two
incomplete tasks were **Select an app category and provide contact details** and
**Set up your store listing**. Closed testing remained locked until those tasks
are complete and reported `0 testers currently opted-in`. No values were saved
and no Closed or Production release was created during this inspection.
The authorised physical phone then passed the strict installed-build verifier:
Google Play installer, Play App Signing identity, exact `0.1.2 (3)` version,
verified production App Link, and `MainActivity` resolution.
## Post-publication foreground-location evidence
An exact run-scoped production fixture was used only for the location replay.
The Play-delivered app showed the prominent disclosure, Android permission
prompt, active in-app state, and persistent notification while minimized. The
notification Stop action ended the session; server verification observed 41
samples and zero retained raw positions. Exact fixture cleanup deleted the
request, assignment, session, and synthetic requester, then both production
and frozen-test readiness endpoints returned `ready`.
The long evidence take is not the submission video: Android's recorder reached
its time limit before the Stop action was captured even though Stop and server
cleanup were verified immediately afterward. A separate 21.379802-second take
from this same Play-delivered version now includes the trigger, disclosure,
permission prompt, minimized persistent notification, notification Stop action,
and stopped state. Its path and SHA-256 are recorded in
`location-and-fgs-declaration.md`. It remains local evidence until it is hosted
as an unlisted video and its URL is saved with the foreground-service
declaration. Do not replace the Internal candidate before that declaration is
complete.

View File

@ -1,68 +0,0 @@
# Internal testing release v4
This operator record identifies the Android build currently available through
Google Play Internal testing. It records observed artifact, Console, and
physical-device evidence without authorizing a Closed or Production rollout.
## Release identity
- Track: Internal testing only
- Release label: `0.1.3 Reliable notifications`
- Package: `org.whoneedhelp.mobile`
- Version code: `4`
- Version name: `0.1.3`
- Source fingerprint:
`42b485964a8c3fdaeff94ca80dcd8f33533e1723c6c2d625a9483458c0ff0068`
- AAB: `android/dist-release-20260821-v4-precommit/who-need-help-release.aab`
- AAB SHA-256:
`5147404e91aee6ef87014e19db93403fdb18a15e91b749737c494c372ea87371`
- Companion APK SHA-256:
`58136ea5ddead7ee683f28f3ae12aa65334207c40ebfe2fdb34064b8d2710c3d`
- Upload-certificate SHA-256:
`a5742bae70c6d034e37544b62e37a375c0e005647450f40f29b2a984f9fdb8fb`
The artifact directory name is accurate: the build was produced before its
matching Android source state was committed. Historical checkout comparison
found `f15f5578e010df80a430f2dbfa4aca54face65f3` to be the first committed
revision whose Android build-input fingerprint exactly matches the retained
artifact. This is reproducibility evidence; it is not a claim that the
artifact was originally built by checking out that commit.
Do not upload a different artifact under this release record. Any Android
source or build-input change requires a new version code, a fresh source-bound
build, and a new operator record.
## Publication evidence
A read-only authenticated Play Console inspection on 2026-08-25, repeated on
2026-08-26, showed:
1. **Internal testing** is active;
2. the latest release is `0.1.3 Reliable notifications` with one version code;
3. the release is available to internal testers;
4. the Console displayed a release time of 2026-08-25 3:46 PM;
5. the app remains unreviewed, so Internal testers see the temporary package
name;
6. the existing **Closed testing - Alpha** track is inactive, has zero of four
setup tasks complete, has no countries or testers selected, and contains no
release;
7. no Production rollout was observed or started during the inspection.
## Play-delivered physical-device evidence
The authorised physical phone passed the strict delivery-boundary verifier:
```bash
./scripts/verify-play-installed-android.sh \
output/runtime/production-provider-setup/play-identities.json \
72551e60 \
4 \
0.1.3
```
The verifier observed package `org.whoneedhelp.mobile`, version `0.1.3 (4)`,
Google Play as installer, a signing identity in the protected Play App Signing
set, and a verified `whoneedhelp.com` App Link resolving to `MainActivity`. The
browser-only Google OAuth callback was not claimed by the Android application.
The verification was read-only and did not install, update, clear, or
reconfigure the app.

View File

@ -1,241 +0,0 @@
# Google Play location and foreground-service declaration
This file is the source copy for Play Console. It describes the exact current
Android behavior; it is not evidence that Google Play has approved the feature.
Reconcile every answer with the AAB selected for release.
## Manifest and permission facts
- Package: `org.whoneedhelp.mobile`
- Target SDK: `37`
- Foreground service type: `location`
- Foreground-service permissions: `FOREGROUND_SERVICE` and
`FOREGROUND_SERVICE_LOCATION`
- Runtime location permissions: `ACCESS_COARSE_LOCATION` and
`ACCESS_FINE_LOCATION`
- The app does not request `ACCESS_BACKGROUND_LOCATION`.
- The app also declares `USE_LOCATION_BUTTON` for the separate one-time
foreground action that places a request or activity point. It must not use
`onlyForLocationButton`, because the distinct live-location flow also needs
precise location after the user starts the foreground service.
## Foreground-service declaration copy
**Use case:** User-initiated location sharing.
**Feature using the service:**
> During an active mutual-aid assignment, an accepted requester or helper can
> explicitly start live location sharing with the matched participant. Who Need
> Help starts a location foreground service only after the user opens the active
> assignment, taps Share live location, reads the prominent disclosure, and
> grants Android location permission. A persistent notification remains visible
> for the full session and includes a Stop sharing action.
**Why the task must start immediately:**
> The participant starts sharing to coordinate an active, time-sensitive handoff.
> Deferring the first update would show the matched participant stale or missing
> position information at the moment the user deliberately requested sharing.
**Impact if Android interrupts the task:**
> New location updates stop. The app does not silently restart sharing. The user
> must return to the active assignment and start it again. The matched participant
> no longer receives a current position.
**How it ends:**
- The user taps Stop sharing in the app or in the persistent notification.
- Cancelling, withdrawing from, completing, or otherwise leaving the active
assignment stops the native service through the server-driven terminal state.
- The service also stops on an authorization or missing-assignment response.
- Stopping removes the current raw location from the server. Limited derived
safety evidence can remain as stated in the Privacy Policy.
- Sharing is never started from boot, a background receiver, a push notification,
or an unattended scheduled task.
## Play Console answers
Use these only when the current Console wording matches the stated fact:
- Foreground service type: **Location**
- Closest preset use case: **Background Location Updates — User-initiated
location sharing**
- Core user benefit: safe coordination between the two people already matched
for an active help request
- Persistent notification: shown after the explicit in-app start and prominent
disclosure, with a user-visible Stop action
- Background-location runtime permission declared: **No**
- Location foreground service declared: **Yes**
Google Play requires a foreground-service declaration for apps targeting
Android 14 or newer. Do not describe this feature as passive, continuous,
always-on, emergency, medical, or hidden tracking.
## Video evidence script
Record one short, unlisted video from the exact Play candidate. Keep the phone
screen readable and show the complete trigger path without cuts that hide a
permission or disclosure screen.
1. Start on an active synthetic request in which the signed-in reviewer is an
accepted requester or helper.
2. Scroll to live-location controls and tap **Share live location**.
3. Pause on the prominent disclosure long enough to read what is collected,
who receives it, minimized-app use, deletion, and the Stop action.
4. Tap **Continue and share** and grant the Android location permission.
5. Show the persistent **Sharing live location** system notification.
6. Press Home so the app is minimized; show that the notification remains
visible and that the matched browser receives a current synthetic position.
7. Tap **Stop sharing** in the notification.
8. Return to the request and show that sharing is stopped and the live marker is
no longer available.
Do not use a real home address, real medical information, chat text, email,
handover code, access token, or another person's location in the recording.
### Retained operator copy
Earlier draft recordings were removed after the final Play-delivered evidence
was approved. The retained operator copy is:
`/home/simple/Downloads/Who-Need-Help-Google-Play-FGS-location-review.mp4`
It is the verified 21.379802-second edit described below, not a separate draft.
### Reproducible production fixture
The production operator script creates one run-scoped requester with a temporary
password, one synthetic matched medicine request, and one accepted assignment
for an existing confirmed helper. It refuses any root, Compose project, public
origin, image, health state, or database other than the explicitly verified
production values. It stores the exact IDs and temporary credentials only in
ignored mode-`0600` runtime files so cleanup can be resumed after a container
restart.
Run the read-only plan first from `/srv/who_need_help-production`:
```bash
./scripts/production-play-physical-fixture.sh \
plan HELPER_EMAIL --check-only whoneedhelp.com .env
```
Prepare the recording fixture only when the helper is signed into the exact
Play-delivered build:
```bash
./scripts/production-play-physical-fixture.sh \
prepare HELPER_EMAIL --confirm whoneedhelp.com .env
```
Use the printed temporary requester email and password in the recipient browser.
Do not copy those credentials into documentation, Play Console, chat, email, or
the recording. After location sharing starts, verify the server-side active
state; after using the notification Stop action, verify deletion:
```bash
./scripts/production-play-physical-fixture.sh \
verify-active --confirm whoneedhelp.com .env
./scripts/production-play-physical-fixture.sh \
verify-stopped --confirm whoneedhelp.com .env
```
Always remove the fixture immediately after the recording, including after an
aborted take:
```bash
./scripts/production-play-physical-fixture.sh \
cleanup --confirm whoneedhelp.com .env
```
Cleanup stops an exact still-active fixture session before deleting its current
raw position, tracking session, messages, notifications/jobs, assignment,
request, temporary tokens/rate-limit buckets, and requester. It then verifies
that the three primary fixture records were deleted and removes the local
runtime state. Never delete the runtime manifest manually while its fixture may
still exist.
## Pre-submission evidence
- Run `./scripts/android-play-policy-check.sh`.
- Run the signed release build and retain its manifest/package/signing reports.
- Repeat start, Home/minimize, notification, Stop, and raw-position deletion on
a Play-delivered internal-test install after Play App Signing is available.
- Confirm the Privacy Policy, Data Safety form, store listing, disclosure, and
Play Console declaration all describe the same behavior.
Foreground-service references rechecked on 2026-08-09:
- https://support.google.com/googleplay/android-developer/answer/13392821
- https://developer.android.com/develop/background-work/services/fgs/service-types
The Play Help page was rechecked again on 2026-08-10. It still requires a video
link for each declared foreground-service feature and recommends keeping the
demonstration at 30 seconds or less. The final edit must therefore retain the
user trigger, prominent disclosure, runtime prompt, minimized persistent
notification, and notification Stop action while removing only idle time.
### Play-delivered evidence take on 2026-08-10
The exact Play-delivered `0.1.2 (3)` build produced a long evidence take at:
`/g/home/Downloads/Who-Need-Help-Play-Console-location-sharing-FINAL-v4.mp4`
Its SHA-256 is
`56608ca3e2e1aafd7a85c325109581c379d4cb714794ba4c6c414706d451ff96`.
The file is 177.864689 seconds, H.264, and 720×1600. Visual review confirms the
in-app trigger, prominent disclosure, Android runtime prompt, active in-app
state, Home/minimized operation, and persistent notification with the visible
Stop action. It contains no account email, fixture credentials, precise map,
medical information, chat, or handover code.
This take is retained only as source evidence. The recorder reached its time
limit before the notification Stop tap and stopped in-app state were captured,
so it must not be submitted to Play Console as the final demonstration. The
Stop action was performed immediately afterward: server verification observed
41 samples and zero retained raw positions, and exact fixture cleanup passed.
A new concise take must visibly include Stop and the stopped state.
### Final Play-delivered demonstration
The final concise demonstration was recorded on 2026-08-10 from the exact
Google Play Internal-testing install `0.1.2 (3)` and saved as:
`output/android/play-console/wnh-fgs-review-final-v5.mp4`
It is 21.379802 seconds, H.264, 720×1600, and its SHA-256 is
`3c5f52019bf8a42ff42e1d9232afc126b62627390d74eed75084d82ecb33ac56`.
Visual review confirms that it shows the in-app trigger and prominent
disclosure, Android location prompt, active sharing state, minimized-app
persistent notification, visible notification Stop action, and the returned
stopped state with **Share location** available again. It contains no account
email, fixture credentials, precise map, medical information, chat, or handover
code.
Server-side verification for the same take observed active tracking with a
retained current position, then stopped tracking with zero retained raw
positions. Exact cleanup deleted the run-scoped request, assignment, tracking
session, and temporary requester; the protected runtime state and prepare log
were removed. Production readiness remained healthy. The retained operator
copy has the same SHA-256 and is stored at
`/home/simple/Downloads/Who-Need-Help-Google-Play-FGS-location-review.mp4`.
The video was hosted as an unlisted YouTube Short at
`https://youtube.com/shorts/UZh_QBdlbBc`. The authenticated Play Console
foreground-service form was saved with **User-initiated location sharing** and
this URL. This records the submitted form state; it is not a claim that Google
has approved the declaration.
The remaining Play policy references were rechecked on 2026-08-10. The current
Play Help still requires a video demonstration for the declared permission and
accepts a YouTube link (preferred) or a cloud-hosted common video file. It also
states that an unresolved declaration for an active bundle can prevent
publishing other changes, including Store Presence. Refresh the pages again
immediately before submitting because Play Help can change independently of
the Android platform documentation:
- https://support.google.com/googleplay/android-developer/answer/9799150
- https://support.google.com/googleplay/android-developer/answer/16909972
- https://support.google.com/googleplay/android-developer/answer/9214102

View File

@ -1,245 +0,0 @@
# Google Play release checklist
## External account gate
- [x] Google Play developer identity verification approved.
- [x] Contact phone verification completed.
- [x] Play Console enables **Create app**.
## App identity and signing
- [x] Create Android app `Who Need Help` with package
`org.whoneedhelp.mobile`.
- [x] Default language: English (United States).
- [x] App: not a game; free; no ads.
- [x] Accept Play App Signing.
- [x] Record the upload-certificate SHA-1 and SHA-256 with the source-bound
candidate.
- [x] Record every Play App Signing SHA-1 and SHA-256 displayed by Play after
the first AAB upload makes Play generate its signing identities and
before any tester rollout. Do not assume that a new app has only one
Play certificate: current Play quantum-ready hybrid signing can expose
multiple classical/post-quantum identities for different Android
generations. The protected identity document records all three Play
identities shown for version `0.1.0 (1)` plus the independent upload
identity; no certificate values are stored in this public checklist.
- [x] Add every applicable Play App Signing SHA-1/SHA-256 identity to the
production Google/Firebase Android configuration. A freshly downloaded
production client configuration was validated after the import.
- [x] Publish and verify
`https://whoneedhelp.com/.well-known/assetlinks.json` for the Play
certificate identities used to sign delivered APKs.
Use `scripts/import-play-android-config.sh` in plan mode first, then
`--apply`; it must match every Play SHA-1 to the production Firebase
Android OAuth client and preserve the upload identity.
## Build
- [x] Freeze the exact internal-release identity and localized release notes in
`internal-release-v1.md`; final save/publish remains a separate explicit
external action.
- [x] Build from the exact committed candidate with the validated Android
allow-list read from the production checkout’s single `.env`.
- [x] Run `scripts/android-release-build.sh`.
- [x] Verify source fingerprint, signing certificate, bundletool validation,
lint, package name, version code/name, target SDK, and production origin.
- [x] Install the release APK generated from the same source-bound build on the authorized
physical phone and run the release smoke test.
- [x] Save and publish the source-bound AAB currently uploaded to the internal
testing draft. Do not mark this complete until Play Console shows one
accepted artifact and the internal release is available to testers. The
exact `0.1.0 (1)` release is active only on the Internal testing track;
no Closed or Production rollout was started.
- [x] Build and locally validate source-bound candidate `0.1.2 (3)` from
commit `cd15476`; release tests, lint, signing, bundle validation, App
Links validation and API 37 instrumentation passed. The candidate is
documented in `internal-release-v3.md`; its subsequent Internal-track
upload and Play-delivered verification are recorded in the next item.
- [x] Upload the exact recorded `0.1.2 (3)` AAB to Internal testing, install it
through Google Play and repeat the strict physical-device verification.
Play Console showed release `0.1.2 Location consent clarity` available to
internal testers on 2026-08-10 with one version code and no Closed or
Production rollout. The installed package was delivered by
`com.android.vending`; its Play signing identity, version, verified
production App Link, and `MainActivity` resolution passed the strict
verifier.
- [x] Record and verify Internal release `0.1.3 (4)`. Play Console showed
`0.1.3 Reliable notifications` available to internal testers on
2026-08-25. The retained AAB SHA-256, Android source fingerprint,
provenance limitation, and strict Play-delivered phone verification are
recorded in `internal-release-v4.md`. No Closed or Production release was
created by this verification. A fresh read-only Console inspection on
2026-08-26 again showed version code `4`, version name `0.1.3`, target SDK
37, and status **Available to internal testers** on the Internal testing
track.
## Production capability gate
- [x] On 2026-08-08, run the current candidate validator against the live
production `.env` with `--require-server-release`: all twelve capability
checks reported `READY`, with zero blocking items and zero local-only
warnings. The validator did not print secret values and did not modify
production.
- [x] Pass the stricter `--require-release` gate. On 2026-08-09 the live
production `.env` reported all twelve capabilities `READY`, with zero
blocking items and zero local-only warnings after the Play identities
were imported.
## Store presence
Complete the mandatory foreground-service declaration under **App content**
before attempting to publish Store Presence changes. The current Play Help
states that an unresolved permissions declaration for an active bundle can
block publishing changes, including Store Listing, Pricing, and Distribution.
- [x] 512×512 Play icon prepared.
- [x] 1024×500 24-bit PNG feature graphic prepared.
- [x] Four current 1080×1920 physical-phone screenshots captured and reviewed.
- [x] English, Ukrainian, and Russian listing copy prepared.
- [x] Alt-text copy (≤140 characters) prepared in `store-assets/README.md`.
- [x] Revalidate the exact prepared listing text and visual assets immediately
before Console entry. On 2026-08-10 the repository validator passed all
three localized text limits, the 512×512 icon, the 1024×500 RGB feature
graphic, and all four 1080×1920 RGB phone screenshots.
- [ ] Enter the prepared alt text if the current asset editor exposes that
field. The read-only Dashboard summary does not expose saved per-asset
alt text, so this detail remains unknown even though the overall Store
listing task is complete.
- [x] Choose category in the current Console options. **Social** was saved;
tags were deliberately left empty rather than adding an inaccurate tag.
- [x] Complete **Select an app category and provide contact details** in Play
Console. Read-only Store settings inspection on 2026-08-25 showed
category **Social** and public contact email `contact@whoneedhelp.com`;
phone and website were empty. A controlled inbound routing message sent
to that address was observed once in the operator Gmail Inbox with the
`Projects/WhoNeedHelp` label. This proves the tested inbound forwarding
route; it does not prove a standalone mailbox or reply-from identity.
- [x] Complete **Set up your store listing** in Play Console using the prepared
copy and assets. A read-only Dashboard inspection on 2026-08-14 showed
overall app setup at 10 of 11 tasks and marked this task complete.
- [ ] If the truthful category remains **Social**, publish and verify the
`/child-safety` standards, select a monitored child-safety point of
contact, verify that contact can access the urgent queue, and complete
the Play child-safety self-certification only from observed operational
evidence. Adult-only positioning does not remove this requirement for an
app declared as Social. A repeated public check on 2026-08-10 returned
`200` from `https://whoneedhelp.com/child-safety` after production was
observed on local revision `dafcdb36`. The frozen hackathon test remains
intentionally unchanged and returns `404`. The public page is only one
gate: do not self-certify until the monitored contact and real escalation
workflow are also verified. On 2026-08-26 the authenticated App content
overview showed exactly one item under **Need attention**: Child safety
standards. The declaration itself was still blank and its Save action was
disabled. A simultaneous public request returned `200` and displayed the
CSAE/CSAM standards and dedicated reporting path, but that technical
evidence does not authorise the separate legal-compliance attestation.
The official-requirement matrix and physical-device verification are
recorded in `docs/google-play-child-safety-audit-2026-08-26.md`.
## App content
- [x] Privacy policy URL saved as `https://whoneedhelp.com/privacy`.
- [x] Ads declaration saved: no ads.
- [x] Both App access accounts and both sides of the reviewer instructions
tested from a clean Play-delivered install.
- [x] Target audience/adult-only positioning saved as **18 and over**.
- [x] Content rating questionnaire completed and saved truthfully. The current
Console result is BR 12+, North America Teen, Europe parental guidance,
Germany 12+, and 12+ in the other displayed regions; this is a Console
result, not a product age-verification claim.
- [x] Local Data Safety worksheet reconciled with the source-bound candidate,
a fresh resolved release dependency report, and the published privacy and
account-deletion pages.
- [x] Enter and review those reconciled answers in the current Play Console
Data Safety form. The form was saved on 2026-08-09, and the overall
Publishing overview was deliberately not sent for review.
- [x] Account deletion questions and external URL completed.
- [x] Government, financial, and health declarations saved from actual app
behavior: not a government app, no financial features, and Healthcare
services and management only. The app is not described as a medical
service.
- [x] Complete the mandatory Play Console foreground-service declaration for
the `location` service used by the exact AAB. The authenticated form was
saved with **User-initiated location sharing**. A saved declaration is
submission evidence, not evidence of Google approval.
- [x] Upload the unlisted demonstration video showing the user-triggered start,
prominent disclosure, Android permission, persistent notification,
minimized-app operation, and Stop action. The final 21.379802-second
Play-delivered evidence file was visually and server-side verified,
hosted as an unlisted YouTube Short at
`https://youtube.com/shorts/UZh_QBdlbBc`, and saved in the declaration.
Its retained path and checksum are recorded in
`location-and-fgs-declaration.md`.
Immediately before upload on 2026-08-10 the exact final file was
revalidated as H.264, 720×1600, 21.379802 seconds, SHA-256
`3c5f52019bf8a42ff42e1d9232afc126b62627390d74eed75084d82ecb33ac56`;
a fresh contact-sheet review still showed the disclosure, Android prompt,
minimized persistent notification with Stop, and returned stopped state.
- [x] Reconcile the target-SDK-37 location declaration with the exact artifact.
The saved use case is user-initiated location sharing. The app uses a
user-started location foreground service and does not declare
`ACCESS_BACKGROUND_LOCATION`; the form was not answered as if it requests
the background-location runtime permission.
- [x] Use and verify the prepared declaration copy in
`location-and-fgs-declaration.md`.
## Testing
- [x] Internal track smoke test passed.
Before exercising product flows, run
`scripts/verify-play-installed-android.sh` with the protected Play
identity document, physical-device serial, and exact expected version.
It must confirm the Google Play installer, Play signing identity,
verified production App Link, and `MainActivity` resolution.
The 2026-08-09 v2 physical-device replay passed that verifier, Google
sign-in, production App Link routing, Android notification permission,
production FCM receipt and notification-tap routing. The same
Play-delivered build then passed the separate consent-driven foreground
location flow: explicit disclosure, Android permission, persistent
notification, minimized-app sampling, notification Stop cleanup,
offline/reconnect, and stopped-process recreation without sticky
tracking. On 2026-08-10 the Play-delivered v3 install passed the strict
delivery-boundary verifier and a new production fixture replay observed
the disclosure, Android runtime prompt, active in-app state, minimized
foreground-service notification, 41 server samples, notification Stop,
and zero retained raw positions after Stop. The run-scoped fixture was
then deleted and both production and frozen-test readiness remained
healthy. This verifies observed app behavior; it does not complete the
separate Play Console policy declarations or the final video above.
The strict verifier passed again on 2026-08-10 after the physical phone
was reconnected: Google Play installer, version `0.1.2 (3)`, Play signing
identity, verified production App Link, and `MainActivity` resolution all
matched the protected production identity record.
On 2026-08-25 the strict verifier also passed for the current
Play-delivered `0.1.3 (4)` install: Google Play installer, a protected
Play signing identity, verified production App Link, and `MainActivity`
resolution all matched. The exact artifact record is
`internal-release-v4.md`.
- [ ] Closed track created and opt-in link tested.
- [ ] At least 12 testers continuously opted in for 14 days.
- [ ] Tester feedback and fixes documented.
- [ ] Production-access questionnaire completed from actual evidence.
On 2026-08-28 the Dashboard showed zero of five Closed testing tasks complete:
select countries and regions, select testers, create a release, preview and
confirm it, and send it to Google for review. The current official requirement
for this personal developer account was rechecked as at least 12 testers
continuously opted in for at least 14 days before applying for Production
access. No Closed or Production release was created during this inspection.
## Publishing
- [ ] Managed publishing enabled if desired.
- [ ] Countries/regions and support contact reviewed.
- [ ] Production submission reviewed for accidental test URLs or credentials.
- [ ] Rollback and support/incident response are ready.
Official references:
- https://support.google.com/googleplay/android-developer/answer/9859152
- https://support.google.com/googleplay/android-developer/answer/9842756
- https://support.google.com/googleplay/android-developer/answer/9866151
- https://support.google.com/googleplay/android-developer/answer/9859455
- https://support.google.com/googleplay/android-developer/answer/10787469
- https://support.google.com/googleplay/android-developer/answer/13327111
- https://support.google.com/googleplay/android-developer/answer/14151465

View File

@ -1,118 +0,0 @@
# Google Play review access
The app has public pages, email/passwordless authentication, password
authentication, and Google sign-in.
Reviewers must be able to inspect restricted functionality without contacting a
real requester or sharing real personal/medical information.
## Recommended reviewer instructions
1. Open the app. The product home, Safety, Privacy, Terms, Support, content
reporting, and Account deletion pages are available without a reviewer
account. Request, activity, category-proposal, profile, notification, and
moderation LiveViews require authentication.
2. For authenticated functionality, use the two dedicated production review
accounts prepared immediately before submission. The requester/organizer
account exposes one side of the flows; the helper/participant account exposes
the other.
3. Expand **Use a password instead**, then enter one of the dedicated emails and
passwords supplied in Play Console. Do not use an email link or Google sign-in
for review: both supplied passwords must remain reusable, always available,
and independent of a developer mailbox or one-time code.
4. Use only the pre-created synthetic requests and activity. Their titles must
start with `Play review`.
5. Sign out and use the helper/participant credentials when checking acceptance,
participant state, the counterpart chat view, optional tracking, handover,
withdrawal, reviews, blocking, and reporting.
## Submission-time values
Do not store credentials here or in Git. Put them only in Play Console’s app
access field:
- Requester/organizer reviewer email and password: create at release time.
- Helper/participant reviewer email and password: create at release time.
- Store both credential pairs only in Play Console and the operator-controlled
password manager.
- Stable synthetic request URL: create at release time.
- Stable synthetic activity URL: create at release time.
- Public support contact: `contact@whoneedhelp.com`. On 2026-08-25 a controlled
message addressed to it was observed once in the operator Gmail Inbox with
the `Projects/WhoNeedHelp` label. This verifies the tested inbound forwarding
route, not a standalone mailbox or the ability to send replies from that
identity.
## Copy for Play Console App access
Use the following English instructions only after replacing all four bracketed
values with the two dedicated production credential pairs and after testing the
exact text from a clean Play-delivered installation. Never commit the completed
version.
```text
This app has public pages and authenticated product flows.
1. Open the app and tap Log in.
2. Expand "Use a password instead".
3. First enter the requester/organizer credentials below.
4. Open Requests and Activities to inspect the pre-created synthetic records,
requester/organizer controls, chat, location controls and reporting.
5. Sign out, return to Log in, expand "Use a password instead", and enter the
helper/participant credentials.
6. Open the same synthetic records to inspect the counterpart views, private
chat, acceptance/participation, tracking, handover, withdrawal, reviews,
blocking and reporting.
Requester/organizer email: [ENTER IN PLAY CONSOLE ONLY]
Requester/organizer password: [ENTER IN PLAY CONSOLE ONLY]
Helper/participant email: [ENTER IN PLAY CONSOLE ONLY]
Helper/participant password: [ENTER IN PLAY CONSOLE ONLY]
All records whose titles start with "Play review" are synthetic. No purchase,
payment, medicine, travel or real-world meeting is required. The credentials
are reusable, do not require a one-time code or developer mailbox, and work
independently of reviewer location.
Support: contact@whoneedhelp.com
```
After `scripts/prepare-play-review.sh ... --confirm` succeeds, append the exact
production request and activity URLs printed by the command. Do not use a dev,
test, localhost or expiring sign-in URL.
## Verification before submission
- Test the exact instructions in a clean Android install from the Play track.
- Confirm they do not depend on a developer browser session, VPN, localhost,
expiring fixture, or test/staging domain.
- Confirm neither review account has any staff role.
- Confirm all data is synthetic and no real user can be messaged or located.
- Confirm both passwords work from a clean Play-delivered install without a
second factor, one-time code, developer browser session, or location gate.
- Confirm the final Play Console instructions are in English and every route
they mention is reachable from the appropriate review account.
After both dedicated accounts have registered, confirmed their email, and set
their fixed passwords through the production UI, first run the read-only
readiness check from the production checkout:
```bash
./scripts/prepare-play-review.sh \
REVIEWER_EMAIL COUNTERPART_EMAIL \
--check-only whoneedhelp.com \
/srv/who_need_help-production/.env
```
Only after that check succeeds should an operator create the stable synthetic
records:
```bash
./scripts/prepare-play-review.sh \
REVIEWER_EMAIL COUNTERPART_EMAIL \
--confirm whoneedhelp.com \
/srv/who_need_help-production/.env
```
The command does not create or change credentials. It refuses missing,
unconfirmed, suspended, passwordless, staff, or duplicate accounts and is
idempotent for its one request and one activity.

View File

@ -1,48 +0,0 @@
# Google Play listing — English (United States)
## App name
Who Need Help
## Short description
Find nearby voluntary help for medicine pickup and urgent everyday needs
## Full description
Who Need Help connects people who need prompt, non-emergency assistance with nearby volunteers.
Medicine pickup is the first priority. You can ask someone nearby to collect medicine that is already purchased or reserved. If a volunteer agrees to purchase an eligible item, the people involved arrange the lawful purchase and any reimbursement directly in their private chat. Who Need Help does not prescribe, recommend, or sell medicine, process payments, or support controlled substances.
You can also ask for roadside help, such as fuel or wheel assistance, and find people for approved community activities.
Key features:
- Create a categorized request and show only an approximate public area, or hide the map point until you approve a participant.
- Accept a request and coordinate in private real-time chat.
- Share live location only when you choose, including in the background while the app is minimized or not in use, with a persistent Android notification and a visible stop action.
- Confirm a handover with a one-time code and completion from both people.
- Join moderated social activities and use participant group chat after approval.
- Build trust through completed-help history and double-blind reviews.
- Block users, report safety concerns, and contact support from the app.
Who Need Help is not an emergency, medical, pharmacy, taxi, or delivery service. If anyone is in immediate danger, contact local emergency services. Never share passwords, payment-card data, access codes, prescriptions, or unnecessary medical information.
Privacy controls limit public location precision. Exact request and activity meeting points are available only to the relevant approved people. Live tracking is optional, and the current raw position is deleted when sharing stops; limited summary evidence may be retained for safety and abuse prevention as explained in the Privacy Policy.
## Suggested category
Social
## Suggested tags
- Volunteering
- Local community
- Social
## Support and policy URLs
- Website: https://whoneedhelp.com/
- Support: https://whoneedhelp.com/support
- Privacy policy: https://whoneedhelp.com/privacy
- Account deletion: https://whoneedhelp.com/account/delete

View File

@ -1,38 +0,0 @@
# Google Play — русский
## Название
Who Need Help
## Краткое описание
Добровольная помощь рядом: лекарства и срочные бытовые задачи
## Полное описание
Who Need Help помогает людям, которым срочно нужна неэкстренная помощь, связаться с волонтёрами поблизости.
В первую очередь сервис предназначен для доставки лекарств. Можно попросить забрать уже купленное или зарезервированное лекарство. Если волонтёр согласен сам купить разрешённый товар, участники самостоятельно договариваются о законной покупке и возмещении расходов в приватном чате. Who Need Help не назначает, не рекомендует и не продаёт лекарства, не обрабатывает платежи и не допускает запросы на контролируемые вещества.
Также можно попросить о помощи в дороге, например с топливом или колесом, и найти людей для одобренных совместных мероприятий.
Основные возможности:
- Создание заявки по категории с примерной публичной областью или скрытой до одобрения точкой.
- Принятие заявки и координация в приватном чате в реальном времени.
- Добровольная трансляция геопозиции, в том числе в фоновом режиме, когда приложение свёрнуто или не используется, с постоянным уведомлением Android и заметной кнопкой остановки.
- Подтверждение передачи одноразовым кодом и завершение обеими сторонами.
- Участие в модерируемых мероприятиях и групповом чате после одобрения.
- История выполненной помощи и взаимные скрытые до завершения отзывы.
- Блокировка пользователей, жалобы на проблемы безопасности и обращение в поддержку.
Who Need Help не является экстренной, медицинской, аптечной, такси- или курьерской службой. При непосредственной опасности обратитесь в местные экстренные службы. Не публикуйте пароли, данные банковских карт, коды доступа, рецепты и лишнюю медицинскую информацию.
Настройки конфиденциальности ограничивают точность публичной карты. Точные точки заявок и мероприятий доступны только соответствующим одобренным участникам. Трансляция геопозиции необязательна; текущая точная позиция удаляется после остановки. Ограниченные сводные признаки могут храниться для безопасности и предотвращения злоупотреблений в соответствии с Политикой конфиденциальности.
## Ссылки
- Сайт: https://whoneedhelp.com/
- Поддержка: https://whoneedhelp.com/support
- Политика конфиденциальности: https://whoneedhelp.com/privacy
- Удаление аккаунта: https://whoneedhelp.com/account/delete

View File

@ -1,38 +0,0 @@
# Google Play — українська
## Назва
Who Need Help
## Короткий опис
Добровільна допомога поруч: ліки та термінові побутові завдання
## Повний опис
Who Need Help допомагає людям, яким терміново потрібна неекстрена допомога, зв’язатися з волонтерами поблизу.
Насамперед сервіс призначений для доставки ліків. Можна попросити забрати вже придбані або зарезервовані ліки. Якщо волонтер погоджується сам придбати дозволений товар, учасники самостійно домовляються про законну покупку та відшкодування витрат у приватному чаті. Who Need Help не призначає, не рекомендує і не продає ліки, не обробляє платежі та не дозволяє запити на контрольовані речовини.
Також можна попросити про допомогу в дорозі, наприклад із пальним або колесом, і знайти людей для схвалених спільних заходів.
Основні можливості:
- Створення заявки за категорією з приблизною публічною областю або прихованою до схвалення точкою.
- Прийняття заявки та координація у приватному чаті в реальному часі.
- Добровільна трансляція геопозиції, зокрема у фоновому режимі, коли застосунок згорнуто або він не використовується, з постійним сповіщенням Android і помітною дією зупинки.
- Підтвердження передачі одноразовим кодом і завершення обома сторонами.
- Участь у модерованих заходах і груповому чаті після схвалення.
- Історія виконаної допомоги та взаємні відгуки, приховані до завершення.
- Блокування користувачів, повідомлення про загрози безпеці та звернення до підтримки.
Who Need Help не є екстреною, медичною, аптечною, таксі- або кур’єрською службою. За безпосередньої небезпеки зверніться до місцевих екстрених служб. Не публікуйте паролі, дані банківських карток, коди доступу, рецепти та зайву медичну інформацію.
Налаштування конфіденційності обмежують точність публічної карти. Точні точки заявок і заходів доступні лише відповідним схваленим учасникам. Трансляція геопозиції необов’язкова; поточна точна позиція видаляється після зупинки. Обмежені зведені ознаки можуть зберігатися для безпеки та запобігання зловживанням відповідно до Політики конфіденційності.
## Посилання
- Сайт: https://whoneedhelp.com/
- Підтримка: https://whoneedhelp.com/support
- Політика конфіденційності: https://whoneedhelp.com/privacy
- Видалення облікового запису: https://whoneedhelp.com/account/delete

View File

@ -1,171 +0,0 @@
# Google Play store presence runbook
This runbook records the exact local inputs and the observed Console gaps. It
does not authorize saving fields in Play Console or publishing a release.
## Observed Console state on 2026-08-13
- App type is **App**.
- App category was subsequently saved as **Social**.
- Tags are not selected.
- Store-listing contact email, phone, and website are empty.
- The default English listing has no saved short description, full
description, app icon, feature graphic, or phone screenshots.
- The Dashboard initially showed 9 of 11 setup tasks complete. Category was
saved afterward; public contact details and Store-listing completion still
require a current Console recheck.
- Closed testing was still locked pending those setup tasks and reported zero
opted-in testers.
- After this initial observation, the foreground-service declaration was saved
with **User-initiated location sharing** and the unlisted demonstration URL
`https://youtube.com/shorts/UZh_QBdlbBc`. Recheck the current Console state
before any later release; this runbook does not treat a saved form as Google
approval.
## Read-only recheck on 2026-08-14
- The Dashboard now reports **10 of 11 complete**.
- **Set up your store listing** is marked complete.
- **Select an app category and provide contact details** is the only remaining
setup task. The category remains **Social**, while public email, phone and
website are still empty.
- Closed testing remains locked and reports `0 testers currently opted-in`.
- The foreground-service form still contains **User-initiated location
sharing**, the final unlisted video URL, and the saved-change confirmation.
This was a read-only observation. No Console value, track, release, production
deployment, Test deployment, or public Git remote was changed.
## Read-only recheck on 2026-08-20
- The Dashboard still reports **10 of 11 complete**.
- **Select an app category and provide contact details** remains the only
incomplete setup task. The category is **Social**. Public email, phone, and
website remain empty in Store settings.
- Closed testing remains locked until app setup is complete and still reports
`0 testers currently opted-in`.
- The production-access section currently requires a published closed-testing
release, at least 12 opted-in testers, and a closed test lasting at least 14
days.
This recheck was read-only. No Console field, release, track, deployment,
Test environment, or public Git remote was changed.
## Read-only recheck on 2026-08-25
- Store settings showed app category **Social** and public contact email
`contact@whoneedhelp.com`; phone and website were empty.
- Internal testing was active with release `0.1.3 Reliable notifications`, one
version code, and status available to internal testers.
- The existing **Closed testing - Alpha** track was inactive with zero of four
setup tasks complete. Countries and testers were not selected, and the track
contained no release.
- A controlled message addressed to `contact@whoneedhelp.com` was observed once
in the operator Gmail Inbox with the `Projects/WhoNeedHelp` label. The
message was not opened or marked read. This verifies the tested inbound
forwarding route, not a standalone mailbox or reply-from identity.
This recheck was read-only. No Console field, release, track, deployment,
Test environment, or public Git remote was changed.
## Read-only recheck on 2026-08-28
- Internal testing remained active with release `0.1.3 Reliable
notifications`. Production remained inactive and the app remained a draft.
- The Dashboard showed zero of five Closed testing tasks complete: select
countries and regions, select testers, create a release, preview and confirm
it, and send it to Google for review.
- The current opted-in tester count was zero. Production access required at
least 12 opted-in testers for at least 14 days.
- The Child safety declaration was empty. Its public standards URL and contact
were not selected, both attestations were unchecked, and Save was disabled.
This recheck was read-only. No Console field, release, track, billing setting,
Firebase setting, deployment, or public Git remote changed.
These are direct observations from the authenticated Play Console session on
that date. Recheck the Console before applying because its fields and policy
requirements can change.
## Prepared local inputs
- English listing: `android/play-store/store-listing-en-US.md`
- Ukrainian listing: `android/play-store/store-listing-uk-UA.md`
- Russian listing: `android/play-store/store-listing-ru-RU.md`
- Icon: `android/store-assets/icon-512.png`
- Feature graphic: `android/store-assets/feature-graphic-1024x500.png`
- Phone screenshots: `android/store-assets/screenshots/phone/`
- Screenshot alt text: `android/store-assets/README.md`
Run `./scripts/android-store-assets-validate.sh` immediately before upload.
The validator checks image dimensions/formats and listing-length constraints;
it does not prove Play policy approval or visual quality.
The validator passed again on 2026-08-10, and a fresh combined visual review
confirmed that the approved icon, feature graphic, and four phone screenshots
contain no visible email, private message, handover code, or real precise
location.
## Contact fields
- Public support email saved in Play Store settings: `contact@whoneedhelp.com`
- Website: `https://whoneedhelp.com/`
- Public phone: leave empty unless the operator deliberately chooses a number
that can be published and monitored.
Production sends through Brevo as `contact@whoneedhelp.com`. A controlled
inbound routing message sent to that address was observed once in the monitored
operator Gmail Inbox on 2026-08-25. This proves the tested forwarding path. It
does not prove a standalone mailbox, reply handling, or the ability to send
from that address through Gmail. Recheck inbound routing and operator access
before a public release, and separately test the chosen reply workflow.
## Category and child-safety gate
The prepared listing suggests **Social**, which accurately describes the
community and participant features. Google Play's current Child Safety
Standards policy applies to every app that declares itself as Social, including
adult-only apps. Before saving that category or self-certifying:
1. Deploy and open the globally accessible `/child-safety` standards.
2. Verify the in-app and public report paths for child-safety content.
3. Select a monitored child-safety point of contact who can access the urgent
restricted queue and act on reports.
4. Test inbound delivery to the selected contact.
5. Confirm the launch-jurisdiction escalation and authority-reporting process.
6. Record the evidence and only then complete Play's self-certification.
Do not choose a less accurate category merely to avoid a policy declaration.
Do not claim cross-jurisdiction legal compliance from the presence of a page,
queue, or automated test.
On 2026-08-10 a repeated public check observed HTTP `200` from
`https://whoneedhelp.com/child-safety` on production revision `dafcdb36`. The
frozen hackathon test was not changed and still returns `404`. This clears the
public-page deployment check only; it does not prove that the monitored
child-safety contact or jurisdiction-specific escalation process is ready.
## Suggested apply order after explicit permission
1. Recheck the current Console fields and policy warnings read-only.
2. Verify the production policy/support URLs and monitored email.
3. Host the verified foreground-location demonstration as an unlisted video,
then complete and save the mandatory foreground-service declaration. Reopen
**App content** and verify that the permission alert is cleared before
changing Store Presence. Google documents that an unresolved permissions
declaration for an active bundle can block publishing changes, including
store-listing changes.
4. Save category, tags, contact email, and website.
5. Create the default English (United States) listing with prepared text,
images, and alt text.
6. Add Ukrainian and Russian localizations from their prepared files.
7. Reopen the Dashboard and verify both setup tasks are marked complete.
8. Recheck App content; do not start Closed or Production rollout as part of
store-presence setup.
Official references rechecked on 2026-08-10:
- https://support.google.com/googleplay/android-developer/answer/9859454
- https://support.google.com/googleplay/android-developer/answer/14747720
- https://support.google.com/googleplay/android-developer/answer/9878809
- https://support.google.com/googleplay/android-developer/answer/9214102

View File

@ -1,51 +0,0 @@
# Google Play graphic assets
## Prepared assets
- `icon-512.png` — 512×512 RGBA Play icon, below the 1 MB limit.
- `feature-graphic.svg` — editable deterministic source.
- `feature-graphic-1024x500.png` — required 1024×500 24-bit RGB feature
graphic without alpha.
The feature graphic intentionally contains no localized text, ranking, price,
store badge, device frame, or third-party mark.
Suggested alt text:
> Two nearby people connected by a dotted route around the Who Need Help
> location mark.
## Screenshots
The four files in `screenshots/phone/` were captured on 2026-07-31 from the
development Android client on the authorised physical 1220×2712 device. The
captures were cropped to a 9:16 frame and proportionally scaled to 1080×1920.
They are 24-bit RGB PNG files without alpha and contain only public example
content or an unsubmitted empty form:
1. `01-home.png` — public product explanation and example medicine request.
2. `02-request-form.png` — the first step of the urgent-help request flow and
its safety notice.
3. `03-location-privacy.png` — an unsubmitted approximate-area selection with
a draggable privacy circle and radius controls.
4. `04-category-proposals.png` — the community category and subcategory
proposal flow.
The uncropped ADB source captures are intentionally kept outside Git. No real
email, real user message, notification, medical detail, or exact real-user
location is present in the approved outputs.
These screenshots remain valid only while the release UI matches the captured
development build. Recapture and re-review them after any relevant UI, copy,
privacy, or map behavior change.
Suggested English alt text:
1. `Who Need Help home screen explaining nearby voluntary assistance.`
2. `Urgent-help request form with clear steps and a non-emergency safety notice.`
3. `Approximate-area map with a movable privacy circle and selectable radius.`
4. `Community form for proposing a new help category or subcategory.`
Official asset requirements:
- https://support.google.com/googleplay/android-developer/answer/9866151

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

View File

@ -1,72 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 500" role="img" aria-labelledby="title desc">
<title id="title">Who Need Help feature graphic</title>
<desc id="desc">Two nearby people connected by a safe route around the Who Need Help location mark.</desc>
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#d8f2ec"/>
<stop offset="0.55" stop-color="#f4f5ef"/>
<stop offset="1" stop-color="#f6d8ca"/>
</linearGradient>
</defs>
<rect width="1024" height="500" fill="url(#background)"/>
<g fill="none" stroke="#047b68" stroke-linecap="round" opacity=".14">
<path d="M-30 92C130 78 198 153 332 137S548 59 1058 112" stroke-width="24"/>
<path d="M-28 430C169 332 244 392 362 354s240-148 700-107" stroke-width="14"/>
<path d="M151-20c-4 173 86 205 57 363-12 66-42 122-78 177" stroke-width="9"/>
<path d="M883-20c-88 138-63 243-24 333 22 51 31 119 24 207" stroke-width="9"/>
</g>
<g fill="none" stroke="#b83b12" stroke-linecap="round" opacity=".12">
<path d="M-20 253c177-3 287-78 418-55 126 22 224 142 646 105" stroke-width="18"/>
<path d="M712-30c-77 113-131 192-118 306 8 70 48 143 87 244" stroke-width="8"/>
</g>
<g fill="#fff" opacity=".74">
<circle cx="103" cy="92" r="10"/>
<circle cx="241" cy="367" r="8"/>
<circle cx="785" cy="118" r="9"/>
<circle cx="921" cy="371" r="12"/>
</g>
<path
d="M227 307C339 307 369 212 470 229c72 12 93 70 169 52 51-12 88-49 158-49"
fill="none"
stroke="#fff"
stroke-width="16"
stroke-linecap="round"
stroke-dasharray="1 31"
/>
<g>
<circle cx="212" cy="307" r="60" fill="#047b68"/>
<circle cx="212" cy="284" r="18" fill="#fff"/>
<path d="M173 334c12-28 65-28 78 0" fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round"/>
<circle cx="812" cy="232" r="60" fill="#b83b12"/>
<circle cx="812" cy="209" r="18" fill="#fff"/>
<path d="M773 259c12-28 65-28 78 0" fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round"/>
</g>
<g transform="translate(392 75) scale(.47)">
<path
fill="#047b68"
d="M256 12C122 12 22 112 22 236c0 117 96 207 234 270 138-63 234-153 234-270C490 112 390 12 256 12Z"
/>
<path
fill="#b83b12"
d="M425 176c42 91 10 189-57 250-32 29-70 56-112 80V282c62-11 123-51 169-106Z"
/>
<path
fill="#fff"
d="M256 55c-94 0-171 53-171 118 0 47 48 65 119 69 19 1 25 19 10 31-17 13-36 20-53 26-16 6-18 26-4 37 20 16 45 19 67 16l32 36 32-36c22 3 47 0 67-16 14-11 12-31-4-37-17-6-36-13-53-26-15-12-9-30 10-31 71-4 119-22 119-69 0-65-77-118-171-118Z"
/>
<circle cx="153" cy="170" r="34" fill="#047b68"/>
<circle cx="359" cy="170" r="34" fill="#b83b12"/>
<path
fill="#b83b12"
d="M256 252c5 20 16 31 36 36-20 5-31 16-36 36-5-20-16-31-36-36 20-5 31-16 36-36Z"
/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,6 @@ import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/who_need_help"
import topbar from "../vendor/topbar"
import {Hooks, mountStaticAidMaps} from "./hooks"
import {isProtectedFragmentTokenForForm} from "./protected_token_fragment.mjs"
const systemTheme = () =>
matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
@ -59,6 +58,13 @@ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const activateProtectedTokenFragment = () => {
if (!window.location.hash.startsWith("#token=")) return
const token = new URLSearchParams(window.location.hash.slice(1)).get("token")
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`)
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return
const candidates = [
{
form: document.getElementById("magic-link-fragment-form"),
@ -68,16 +74,6 @@ const activateProtectedTokenFragment = () => {
{
form: document.getElementById("email-change-fragment-form"),
input: document.getElementById("email-change-fragment-token")
},
{
form: document.getElementById("support-confirmation-fragment-form"),
input: document.getElementById("support-confirmation-fragment-token"),
invalidMessage: document.getElementById("support-confirmation-fragment-invalid")
},
{
form: document.getElementById("content-removal-confirmation-fragment-form"),
input: document.getElementById("content-removal-confirmation-fragment-token"),
invalidMessage: document.getElementById("content-removal-confirmation-fragment-invalid")
}
]
@ -87,26 +83,8 @@ const activateProtectedTokenFragment = () => {
if (!candidate) return
const hasTokenFragment = window.location.hash.startsWith("#token=")
const token = hasTokenFragment
? new URLSearchParams(window.location.hash.slice(1)).get("token")
: null
if (hasTokenFragment) {
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`)
}
if (!isProtectedFragmentTokenForForm(candidate.form.id, token)) {
if (candidate.invalidMessage instanceof HTMLElement) {
candidate.invalidMessage.hidden = false
}
return
}
candidate.input.value = token
candidate.form.hidden = false
if (candidate.invalidMessage instanceof HTMLElement) candidate.invalidMessage.hidden = true
if (candidate.options instanceof HTMLElement) candidate.options.hidden = true
candidate.form.querySelector("button")?.focus()
}
@ -143,101 +121,6 @@ window.addEventListener("phx:native-tracking-stop", () => {
window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "stop"}))
})
const configureNativeGoogleAuth = () => {
if (typeof window.WhoNeedHelpAndroid?.postMessage !== "function") return
const forms = document.querySelectorAll("form[data-native-google-flow]")
forms.forEach(form => {
if (!(form instanceof HTMLFormElement) || form.dataset.nativeGoogleBound === "true") return
form.dataset.nativeGoogleBound = "true"
form.addEventListener("submit", event => {
event.preventDefault()
if (form.dataset.nativeGooglePending === "true") return
const flow = form.dataset.nativeGoogleFlow
if (!["login", "register", "link"].includes(flow)) return
form.dataset.nativeGooglePending = "true"
form.querySelectorAll("button").forEach(button => {
button.disabled = true
})
let status = form.querySelector("[data-native-google-status]")
if (!(status instanceof HTMLElement)) {
status = document.createElement("p")
status.dataset.nativeGoogleStatus = "true"
status.className = "mt-2 text-center text-sm text-base-content/70"
status.setAttribute("role", "status")
status.setAttribute("aria-live", "polite")
form.append(status)
}
status.textContent = ""
const localeInput = form.querySelector("input[name='google_registration[locale]']")
const locale = localeInput instanceof HTMLInputElement ? localeInput.value : ""
window.WhoNeedHelpAndroid.postMessage(JSON.stringify({
action: "google_sign_in",
flow,
locale,
csrf_token: csrfToken
}))
})
})
}
configureNativeGoogleAuth()
window.addEventListener("wnh:native-google-error", () => {
document.querySelectorAll("form[data-native-google-flow]").forEach(form => {
if (!(form instanceof HTMLFormElement) || form.dataset.nativeGooglePending !== "true") return
delete form.dataset.nativeGooglePending
form.querySelectorAll("button").forEach(button => {
button.disabled = false
})
const status = form.querySelector("[data-native-google-status]")
if (status instanceof HTMLElement) {
status.textContent =
form.dataset.nativeGoogleError || document.documentElement.dataset.googleSignInError || ""
status.classList.add("text-error")
}
})
})
document.addEventListener("submit", event => {
const form = event.target
if (!(form instanceof HTMLFormElement)) return
const action = new URL(form.action, window.location.origin)
if (action.origin === window.location.origin && action.pathname === "/users/log-out") {
const deviceId = window.localStorage.getItem("wnh.push.server-device-id")
if (deviceId) {
const input = document.createElement("input")
input.type = "hidden"
input.name = "push_device_id"
input.value = deviceId
form.append(input)
window.localStorage.removeItem("wnh.push.server-device-id")
}
if (window.WhoNeedHelpAndroid?.postMessage) {
window.WhoNeedHelpAndroid.postMessage(JSON.stringify({action: "disable_push"}))
} else if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready
.then(registration => registration.pushManager.getSubscription())
.then(subscription => subscription?.unsubscribe())
.catch(error => console.warn("Browser push unsubscribe on logout failed", error))
}
window.WhoNeedHelpAndroid?.postMessage?.(JSON.stringify({action: "google_sign_out"}))
}
})
// connect if there are any LiveViews on the page
liveSocket.connect()

File diff suppressed because it is too large Load Diff

View File

@ -1,143 +0,0 @@
const finiteCount = (value, fallback) => {
const count = Number(value)
return Number.isFinite(count) && count >= 0 ? count : fallback
}
export const clusterSizeClass = count => {
if (count >= 100_000) return "is-xxl"
if (count >= 10_000) return "is-xl"
if (count >= 1_000) return "is-lg"
return "is-md"
}
export const clusterCountLabel = (count, locale = "en") => {
const value = finiteCount(count, 0)
if (value < 1_000) return new Intl.NumberFormat(locale).format(value)
return new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: value < 10_000 ? 1 : 0
}).format(value)
}
const clusterIconDiameter = count => {
if (count >= 100_000) return 60
if (count >= 10_000) return 56
if (count >= 1_000) return 52
return 44
}
const imageKeyPart = value =>
Array.from(value)
.map(character => character.codePointAt(0).toString(16))
.join("-")
export const clusterIconDescriptor = (point, locale = "en") => {
const count = finiteCount(point?.count, 1)
const urgent = finiteCount(point?.urgent_count, 0) > 0
const label = clusterCountLabel(count, locale)
const diameter = clusterIconDiameter(count)
return {
id: `wnh-cluster-${urgent ? "urgent" : "normal"}-${diameter}-${imageKeyPart(label)}`,
count,
urgent,
label,
diameter
}
}
export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom = 22) => {
const current = finiteCount(currentZoom, 0)
const requested = finiteCount(requestedZoom, current + 1)
const nearestIntegerZoom = Math.round(current)
const normalizedCurrent =
Math.abs(current - nearestIntegerZoom) < 0.001 ? nearestIntegerZoom : current
const nextIntegerZoom = Math.floor(normalizedCurrent) + 1
return Math.min(maximumZoom, Math.max(nextIntegerZoom, requested))
}
const normalizedLongitude = longitude => {
const wrapped = ((longitude + 180) % 360 + 360) % 360 - 180
return wrapped === -180 && longitude > 0 ? 180 : wrapped
}
export const clusterExpansionCenter = point => {
const fallback = [Number(point?.longitude), Number(point?.latitude)]
const bounds = point?.bounds
const west = Number(bounds?.west)
const south = Number(bounds?.south)
const east = Number(bounds?.east)
const north = Number(bounds?.north)
if (![west, south, east, north].every(Number.isFinite)) return fallback
const longitude =
west <= east
? (west + east) / 2
: normalizedLongitude((west + east + 360) / 2)
return [longitude, (south + north) / 2]
}
export const serverClusterDescriptor = point => {
if (
point?.type !== "cluster" ||
!point.id ||
!Number.isFinite(Number(point.longitude)) ||
!Number.isFinite(Number(point.latitude))
) {
return null
}
return {
key: `server:${point.id}`,
count: finiteCount(point.count, 1),
urgentCount: finiteCount(point.urgent_count, 0),
longitude: Number(point.longitude),
latitude: Number(point.latitude)
}
}
export const pointFeatureCollection = (points, locale = "en") => ({
type: "FeatureCollection",
features: points.map(point => {
const clusterIcon =
point.type === "cluster" ? clusterIconDescriptor(point, locale) : null
return {
type: "Feature",
id: point.id,
properties: {
id: point.id,
item_type: point.type || "request",
title: point.title || "",
location: point.location || "",
exact: point.exact === true,
radius_meters: Number.isFinite(point.radius_meters) ? point.radius_meters : 0,
weight: finiteCount(point.count, 1),
urgent_weight: finiteCount(point.urgent_count, 0),
server_cluster_id: point.cluster_id || "",
parent_cluster_id: point.parent_cluster_id || "",
hierarchy_level: finiteCount(point.hierarchy_level, 0),
expansion_zoom: finiteCount(point.expansion_zoom, 22),
count_label: clusterIcon?.label || "",
cluster_icon: clusterIcon?.id || ""
},
geometry: {
type: "Point",
coordinates: [Number(point.longitude), Number(point.latitude)]
}
}
})
})
export const pointSourceOptions = data => ({
type: "geojson",
data,
generateId: false,
maxzoom: 22,
cluster: false
})

View File

@ -1,165 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
clusterCountLabel,
clusterExpansionCenter,
clusterExpansionTarget,
clusterIconDescriptor,
clusterSizeClass,
pointFeatureCollection,
pointSourceOptions,
serverClusterDescriptor
} from "./map_cluster_features.mjs"
test("cluster icon descriptors are stable and encode visual state", () => {
assert.deepEqual(clusterIconDescriptor({count: 999, urgent_count: 0}, "en"), {
id: "wnh-cluster-normal-44-39-39-39",
count: 999,
urgent: false,
label: "999",
diameter: 44
})
assert.deepEqual(clusterIconDescriptor({count: 12_500, urgent_count: 2}, "en"), {
id: "wnh-cluster-urgent-56-31-33-4b",
count: 12_500,
urgent: true,
label: "13K",
diameter: 56
})
})
test("server hierarchy metadata survives GeoJSON conversion without becoming a native cluster", () => {
const feature = pointFeatureCollection([
{
id: "request:11:1197:1378",
type: "cluster",
cluster_id: "request:11:1197:1378",
parent_cluster_id: "request:10:598:689",
hierarchy_level: 11,
expansion_zoom: 13,
longitude: 30.52,
latitude: 50.45,
count: 27
}
]).features[0]
assert.equal(feature.properties.server_cluster_id, "request:11:1197:1378")
assert.equal(feature.properties.parent_cluster_id, "request:10:598:689")
assert.equal(feature.properties.hierarchy_level, 11)
assert.equal(feature.properties.expansion_zoom, 13)
assert.equal(feature.properties.weight, 27)
assert.equal(feature.properties.count_label, "27")
assert.equal(feature.properties.cluster_icon, "wnh-cluster-normal-44-32-37")
assert.equal("point_count" in feature.properties, false)
})
test("browser source leaves server hierarchy nodes unclustered", () => {
const collection = pointFeatureCollection([
{
id: "request:11:1197:1378",
type: "cluster",
longitude: 30.52,
latitude: 50.45,
count: 27
}
])
const source = pointSourceOptions(collection)
assert.equal(source.cluster, false)
assert.equal(source.data, collection)
})
test("cluster size classes remain stable for accessible count buttons", () => {
assert.equal(clusterSizeClass(999), "is-md")
assert.equal(clusterSizeClass(1_000), "is-lg")
assert.equal(clusterSizeClass(10_000), "is-xl")
assert.equal(clusterSizeClass(100_000), "is-xxl")
})
test("large visual counts are compact while exact values remain available to accessibility text", () => {
assert.equal(clusterCountLabel(999, "en"), "999")
assert.equal(clusterCountLabel(1_250, "en"), "1.3K")
assert.equal(clusterCountLabel(48_533, "en"), "49K")
assert.equal(clusterCountLabel(1_000_000, "en"), "1M")
})
test("server cluster count labels are compacted before rendering", () => {
const feature = pointFeatureCollection([
{
id: "request:12:2402:2406",
type: "cluster",
longitude: 31.3,
latitude: 30.2,
count: 48_533
}
], "en").features[0]
assert.equal(feature.properties.count_label, "49K")
})
test("only server hierarchy nodes become cluster markers", () => {
assert.deepEqual(
serverClusterDescriptor({
id: "request:12:2402:2406",
type: "cluster",
longitude: 31.3,
latitude: 30.2,
count: 1_727,
urgent_count: 8
}),
{
key: "server:request:12:2402:2406",
count: 1_727,
urgentCount: 8,
longitude: 31.3,
latitude: 30.2
}
)
assert.equal(
serverClusterDescriptor({
id: "request-1",
type: "request",
longitude: 31.3,
latitude: 30.2
}),
null
)
})
test("cluster expansion always advances at least one integer zoom level", () => {
assert.equal(clusterExpansionTarget(9, 9), 10)
assert.equal(clusterExpansionTarget(8.999_999_096_835_52, 9), 10)
assert.equal(clusterExpansionTarget(10.999_994_193_019_71, 11), 12)
assert.equal(clusterExpansionTarget(9.6, 10), 10)
assert.equal(clusterExpansionTarget(9.6, 13), 13)
assert.equal(clusterExpansionTarget(21.9, 24), 22)
})
test("cluster expansion centers on actual member bounds instead of the display cell", () => {
const [longitude, latitude] = clusterExpansionCenter({
longitude: 30.4,
latitude: 50.6,
bounds: {west: 30.51, south: 50.44, east: 30.53, north: 50.46}
})
assert.ok(Math.abs(longitude - 30.52) <= Number.EPSILON * 32)
assert.ok(Math.abs(latitude - 50.45) <= Number.EPSILON * 32)
assert.deepEqual(
clusterExpansionCenter({
longitude: 179,
latitude: 10,
bounds: {west: 179.5, south: 9, east: -179.5, north: 11}
}),
[180, 10]
)
})
test("cluster expansion falls back to the display point without valid bounds", () => {
assert.deepEqual(
clusterExpansionCenter({longitude: 30.52, latitude: 50.45}),
[30.52, 50.45]
)
})

View File

@ -1,13 +0,0 @@
import assert from "node:assert/strict"
import {readFile} from "node:fs/promises"
import test from "node:test"
const hooksSource = await readFile(new URL("./hooks.js", import.meta.url), "utf8")
test("every embedded MapLibre map uses cooperative gestures", () => {
const constructors = hooksSource.match(/new maplibregl\.Map\(\{/g) || []
const cooperativeOptions = hooksSource.match(/cooperativeGestures:\s*true/g) || []
assert.equal(constructors.length, 2)
assert.equal(cooperativeOptions.length, constructors.length)
})

View File

@ -1,25 +0,0 @@
const serializedCoordinateTolerance = 1e-6
export const shouldApplyInitialViewport = initializedViewport => !initializedViewport
export const viewportMatchesBounds = (viewport, bounds) => {
if (!viewport || !bounds) return false
return [
[viewport.west, bounds.getWest()],
[viewport.south, bounds.getSouth()],
[viewport.east, bounds.getEast()],
[viewport.north, bounds.getNorth()]
].every(
([saved, current]) =>
Number.isFinite(saved) &&
Number.isFinite(current) &&
Math.abs(saved - current) <= serializedCoordinateTolerance
)
}
export const mapSizeChanged = (previous, current) => {
if (!previous) return true
return previous.width !== current.width || previous.height !== current.height
}

View File

@ -1,66 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
mapSizeChanged,
shouldApplyInitialViewport,
viewportMatchesBounds
} from "./map_viewport_sync.mjs"
const bounds = ({west, south, east, north}) => ({
getWest: () => west,
getSouth: () => south,
getEast: () => east,
getNorth: () => north
})
test("a server acknowledgement rounded to six decimals does not move the map again", () => {
const viewport = {
west: 30.543427,
south: 50.42413,
east: 30.551643,
north: 50.429139
}
assert.equal(
viewportMatchesBounds(
viewport,
bounds({
west: 30.543426946507708,
south: 50.42413000000005,
east: 30.55164305349291,
north: 50.42913900000016
})
),
true
)
})
test("a materially different saved viewport still repositions the map", () => {
const viewport = {west: 30.5, south: 50.4, east: 30.6, north: 50.5}
assert.equal(
viewportMatchesBounds(
viewport,
bounds({west: 30.51, south: 50.41, east: 30.61, north: 50.51})
),
false
)
})
test("map resize is requested only when the rendered dimensions change", () => {
assert.equal(mapSizeChanged(null, {width: 767, height: 734}), true)
assert.equal(
mapSizeChanged({width: 767, height: 734}, {width: 767, height: 734}),
false
)
assert.equal(
mapSizeChanged({width: 767, height: 734}, {width: 768, height: 734}),
true
)
})
test("server viewport is never reapplied after the user camera is initialized", () => {
assert.equal(shouldApplyInitialViewport(false), true)
assert.equal(shouldApplyInitialViewport(true), false)
})

View File

@ -1,20 +0,0 @@
const normalizedDeviceId = value => String(value || "").trim()
export const resolveNativePushState = ({
configured,
requested,
serverDeviceId,
activeDeviceIds = []
}) => {
const deviceId = normalizedDeviceId(serverDeviceId)
const activeIds = new Set([...activeDeviceIds].map(normalizedDeviceId).filter(Boolean))
const belongsToCurrentAccount = deviceId !== "" && activeIds.has(deviceId)
return {
configured: configured === true,
registered: configured === true && requested === true && belongsToCurrentAccount,
stale: configured === true && requested === true && deviceId !== "" &&
!belongsToCurrentAccount,
serverDeviceId: deviceId
}
}

View File

@ -1,72 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import {resolveNativePushState} from "./native_push_state.mjs"
test("a configured registration is active only for the current account", () => {
assert.deepEqual(
resolveNativePushState({
configured: true,
requested: true,
serverDeviceId: "device-current",
activeDeviceIds: ["device-current", "device-other"]
}),
{
configured: true,
registered: true,
stale: false,
serverDeviceId: "device-current"
}
)
})
test("a device registered to another account is stale", () => {
assert.deepEqual(
resolveNativePushState({
configured: true,
requested: true,
serverDeviceId: "device-previous-account",
activeDeviceIds: ["device-current-account"]
}),
{
configured: true,
registered: false,
stale: true,
serverDeviceId: "device-previous-account"
}
)
})
test("a requested token without a server device remains eligible for registration", () => {
assert.deepEqual(
resolveNativePushState({
configured: true,
requested: true,
serverDeviceId: "",
activeDeviceIds: []
}),
{
configured: true,
registered: false,
stale: false,
serverDeviceId: ""
}
)
})
test("an unconfigured Android client is neither registered nor stale", () => {
assert.deepEqual(
resolveNativePushState({
configured: false,
requested: true,
serverDeviceId: "device-current",
activeDeviceIds: ["device-current"]
}),
{
configured: false,
registered: false,
stale: false,
serverDeviceId: "device-current"
}
)
})

View File

@ -1,26 +0,0 @@
const rawTokenPattern = /^[A-Za-z0-9_-]{43}$/
const phoenixSignedTokenPattern = /^SFMyNTY\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/
const phoenixSignedTokenForms = new Set([
"support-confirmation-fragment-form",
"content-removal-confirmation-fragment-form"
])
const rawTokenForms = new Set([
"magic-link-fragment-form",
"email-change-fragment-form"
])
export const isProtectedFragmentTokenForForm = (formID, token) => {
if (typeof token !== "string") return false
if (phoenixSignedTokenForms.has(formID)) {
return phoenixSignedTokenPattern.test(token)
}
if (rawTokenForms.has(formID)) {
return rawTokenPattern.test(token)
}
return false
}

View File

@ -1,66 +0,0 @@
import assert from "node:assert/strict"
import test from "node:test"
import {isProtectedFragmentTokenForForm} from "./protected_token_fragment.mjs"
const rawToken = "a".repeat(43)
const signedToken = `SFMyNTY.${"b".repeat(64)}.${"c".repeat(43)}`
test("raw account tokens remain limited to one 43-character segment", () => {
assert.equal(
isProtectedFragmentTokenForForm("magic-link-fragment-form", rawToken),
true
)
assert.equal(
isProtectedFragmentTokenForForm("email-change-fragment-form", rawToken),
true
)
assert.equal(
isProtectedFragmentTokenForForm("magic-link-fragment-form", signedToken),
false
)
})
test("support and removal forms accept Phoenix SHA-256 signed tokens", () => {
assert.equal(
isProtectedFragmentTokenForForm("support-confirmation-fragment-form", signedToken),
true
)
assert.equal(
isProtectedFragmentTokenForForm(
"content-removal-confirmation-fragment-form",
signedToken
),
true
)
assert.equal(
isProtectedFragmentTokenForForm("support-confirmation-fragment-form", rawToken),
false
)
})
test("malformed signed tokens are rejected", () => {
const candidates = [
`SFMyNTY.${"b".repeat(64)}.${"c".repeat(42)}`,
`SFMyNTY.${"b".repeat(64)}.${"c".repeat(44)}`,
`SFMyNTY.${"b".repeat(64)}.${"c".repeat(42)}+`,
`SFMyNTY..${"c".repeat(43)}`,
`SFMyNTY.${"b".repeat(64)}.${"c".repeat(43)}?extra=1`,
`token=${signedToken}`
]
for (const candidate of candidates) {
assert.equal(
isProtectedFragmentTokenForForm(
"support-confirmation-fragment-form",
candidate
),
false
)
}
})
test("unknown forms do not inherit a token format", () => {
assert.equal(isProtectedFragmentTokenForForm("unknown-form", rawToken), false)
assert.equal(isProtectedFragmentTokenForForm("unknown-form", signedToken), false)
})

View File

@ -1,156 +0,0 @@
import assert from "node:assert/strict"
import {after, before, beforeEach, test} from "node:test"
const originalSelf = globalThis.self
const listeners = new Map()
const shownNotifications = []
let windowClients = []
let openedWindow = null
before(async () => {
globalThis.self = {
addEventListener(type, handler) {
listeners.set(type, handler)
},
location: {origin: "https://whoneedhelp.com"},
registration: {
async showNotification(title, options) {
shownNotifications.push({title, options})
}
},
clients: {
async matchAll(options) {
assert.deepEqual(options, {type: "window", includeUncontrolled: true})
return windowClients
},
async openWindow(url) {
openedWindow = url
return {url}
}
}
}
await import("../../priv/static/sw.js")
})
beforeEach(() => {
shownNotifications.length = 0
windowClients = []
openedWindow = null
})
after(() => {
globalThis.self = originalSelf
})
test("push keeps a safe same-origin path in the operating-system notification", async () => {
const event = pushEvent({
title: "Request accepted",
body: "Open the private request chat.",
path: "/requests/7f84fc06-0fae-4e9d-b266-041ca87678d1?section=chat",
tag: "request-update"
})
listeners.get("push")(event)
await event.completion
assert.deepEqual(shownNotifications, [
{
title: "Request accepted",
options: {
body: "Open the private request chat.",
icon: "/images/pwa-192.png",
badge: "/images/favicon-48.png",
tag: "request-update",
data: {
path: "/requests/7f84fc06-0fae-4e9d-b266-041ca87678d1?section=chat"
}
}
}
])
})
test("push rejects an external path instead of leaving the application origin", async () => {
const event = pushEvent({
title: "Unsafe target",
path: "//attacker.example/redirect"
})
listeners.get("push")(event)
await event.completion
assert.equal(shownNotifications[0].options.data.path, "/notifications")
})
test("notification click navigates and focuses an existing application window", async () => {
const navigation = []
let focused = false
windowClients = [
{
url: "https://whoneedhelp.com/notifications?section=settings",
async navigate(url) {
navigation.push(url)
},
async focus() {
focused = true
}
},
{url: "https://example.com/"}
]
const event = clickEvent("/requests/request-id")
listeners.get("notificationclick")(event)
await event.completion
assert.equal(event.closed, true)
assert.deepEqual(navigation, ["https://whoneedhelp.com/requests/request-id"])
assert.equal(focused, true)
assert.equal(openedWindow, null)
})
test("notification click opens the application when no application window exists", async () => {
windowClients = [{url: "https://example.com/"}]
const event = clickEvent("/notifications")
listeners.get("notificationclick")(event)
await event.completion
assert.equal(event.closed, true)
assert.equal(openedWindow, "https://whoneedhelp.com/notifications")
})
function pushEvent(payload) {
return waitableEvent({
data: {
json() {
return payload
}
}
})
}
function clickEvent(path) {
const event = waitableEvent({
closed: false,
notification: {
data: {path},
close() {
event.closed = true
}
}
})
return event
}
function waitableEvent(properties) {
return {
...properties,
completion: Promise.resolve(),
waitUntil(completion) {
this.completion = completion
}
}
}

View File

@ -1,9 +1,6 @@
{
"name": "who-need-help-assets",
"private": true,
"scripts": {
"test": "node --test js/*.test.mjs"
},
"dependencies": {
"maplibre-gl": "5.24.0"
}

View File

@ -1,32 +1,14 @@
# Optional measurement-only override. Every service is pinned to the same
# explicit host CPU set so the isolated stack can reproduce contention on a
# small CPU budget without constraining the external load generator. The BEAM
# services additionally use explicit scheduler counts. Values are experiment
# inputs, not production recommendations or minimum requirements.
# Optional measurement-only override. It constrains CPU visibility for each
# long-running BEAM container so scheduler-count and memory behavior can be
# reproduced on a many-core development host. Values are experiment inputs,
# not production recommendations or minimum requirements.
services:
docker-api-proxy:
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
proxy:
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
db:
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
mailpit:
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
migrate:
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
web:
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
cpus: ${CPU_REPLAY_WEB_CPUS:?Set CPU_REPLAY_WEB_CPUS for this experiment}
environment:
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}:${CPU_REPLAY_WEB_SCHEDULERS:?Set CPU_REPLAY_WEB_SCHEDULERS}"
worker:
cpuset: ${CPU_REPLAY_CPUSET:?Set CPU_REPLAY_CPUSET for this experiment}
cpus: ${CPU_REPLAY_WORKER_CPUS:?Set CPU_REPLAY_WORKER_CPUS for this experiment}
environment:
ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536} +S ${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}:${CPU_REPLAY_WORKER_SCHEDULERS:?Set CPU_REPLAY_WORKER_SCHEDULERS}"

View File

@ -1,13 +1,13 @@
services:
migrate:
image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
image: who-need-help:e2e
pull_policy: never
build:
args:
WNH_E2E_ROUTES: "true"
web:
image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
image: who-need-help:e2e
pull_policy: never
labels:
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.rule=${TRAEFIK_ROUTER_RULE}
@ -17,11 +17,11 @@ services:
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true
worker:
image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
image: who-need-help:e2e
pull_policy: never
e2e:
image: ${E2E_TEST_IMAGE:?Set E2E_TEST_IMAGE for the isolated E2E run}
image: who-need-help-e2e-tests:local
build:
context: e2e
init: true

View File

@ -18,10 +18,10 @@ services:
ingress: {}
migrate:
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
image: who-need-help:load
web:
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
image: who-need-help:load
labels:
- traefik.http.routers.${TRAEFIK_APP_NAME}.middlewares=${TRAEFIK_APP_NAME}-forwarded,${TRAEFIK_APP_NAME}-retry
- traefik.http.middlewares.${TRAEFIK_APP_NAME}-forwarded.headers.customrequestheaders.X-Forwarded-Proto=https
@ -32,4 +32,4 @@ services:
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true
worker:
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
image: who-need-help:load

View File

@ -1,9 +1,6 @@
services:
alert-receiver:
image: ${WNH_PYTHON_RUNTIME_IMAGE:-who-need-help:python-runtime}
build:
context: ops/external-boundaries
target: python_runtime
image: python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4
command: ["python", "/opt/who-need-help/alert-receiver.py"]
volumes:
- ./scripts/alert-receiver.py:/opt/who-need-help/alert-receiver.py:ro

View File

@ -1,13 +1,4 @@
services:
docker-api-proxy:
image: ${PORTABILITY_SOCKET_PROXY_IMAGE:?Set PORTABILITY_SOCKET_PROXY_IMAGE for the isolated portability drill}
proxy:
image: ${PORTABILITY_TRAEFIK_IMAGE:?Set PORTABILITY_TRAEFIK_IMAGE for the isolated portability drill}
db:
image: ${PORTABILITY_POSTGIS_IMAGE:?Set PORTABILITY_POSTGIS_IMAGE for the isolated portability drill}
migrate:
image: ${PORTABILITY_IMAGE:?Set PORTABILITY_IMAGE for the isolated portability drill}
pull_policy: never

View File

@ -1,12 +0,0 @@
services:
proxy:
ports:
- "${SCALE_HTTPS_BIND_ADDRESS:-127.0.0.1}:${SCALE_HTTPS_PORT:-4121}:443"
web:
labels:
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.rule=${TRAEFIK_ROUTER_RULE}
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.entrypoints=websecure
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.service=${TRAEFIK_APP_NAME}
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.middlewares=${TRAEFIK_APP_NAME}-forwarded,${TRAEFIK_APP_NAME}-retry
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true

View File

@ -1,5 +1,4 @@
x-app-environment: &app-environment
DEPLOYMENT_ENV: ${DEPLOYMENT_ENV:?Set DEPLOYMENT_ENV in .env}
APP_ROLE: web
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env}
DATABASE_SOCKET_DIR: ${DATABASE_SOCKET_DIR:-}
@ -16,7 +15,6 @@ x-app-environment: &app-environment
PHX_HOST: ${PHX_HOST:?Set PHX_HOST in .env}
PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env}
PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env}
PHX_CHECK_ORIGINS: ${PHX_CHECK_ORIGINS:-}
PORT: "4000"
EMAIL_DELIVERY_PROVIDER: ${EMAIL_DELIVERY_PROVIDER:-smtp}
SMTP_RELAY: ${SMTP_RELAY:-mailpit}
@ -29,14 +27,8 @@ x-app-environment: &app-environment
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env}
EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env}
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
SUPPORT_INBOUND_RECIPIENT: ${SUPPORT_INBOUND_RECIPIENT:-}
SUPPORT_INBOUND_WEBHOOK_TOKEN: ${SUPPORT_INBOUND_WEBHOOK_TOKEN:-}
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
# Empty/unset uses the compiled pilot policy. Set exactly {} only for an
# isolated benchmark or test environment that must disable every counter.
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-}
PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS: ${PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS:-86400}
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS: ${PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS:-31536000}
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png}
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}
@ -48,7 +40,6 @@ x-app-environment: &app-environment
GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-}
GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-}
GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-}
GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS: ${GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS:-}
GOOGLE_OAUTH_BASE_URL: ${GOOGLE_OAUTH_BASE_URL:-}
GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-}
GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-}
@ -58,17 +49,8 @@ x-app-environment: &app-environment
PUSH_HTTP_RECEIVE_TIMEOUT_MS: ${PUSH_HTTP_RECEIVE_TIMEOUT_MS:-}
PUSH_HTTP_CONNECT_TIMEOUT_MS: ${PUSH_HTTP_CONNECT_TIMEOUT_MS:-}
PUSH_HTTP_RETRY_DELAY_MS: ${PUSH_HTTP_RETRY_DELAY_MS:-}
WEB_PUSH_VAPID_PUBLIC_KEY: ${WEB_PUSH_VAPID_PUBLIC_KEY:-}
WEB_PUSH_VAPID_PRIVATE_KEY: ${WEB_PUSH_VAPID_PRIVATE_KEY:-}
WEB_PUSH_VAPID_SUBJECT: ${WEB_PUSH_VAPID_SUBJECT:-}
FCM_PROJECT_ID: ${FCM_PROJECT_ID:-}
FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-}
FCM_SERVICE_ACCOUNT_JSON_BASE64: ${FCM_SERVICE_ACCOUNT_JSON_BASE64:-}
ANDROID_APP_LINKS_PACKAGE_NAME: ${ANDROID_APP_LINKS_PACKAGE_NAME:-}
ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS: ${ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}
OBAN_MAINTENANCE_CONCURRENCY: ${OBAN_MAINTENANCE_CONCURRENCY:-2}
OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1}
OBAN_MAIL_CONCURRENCY: ${OBAN_MAIL_CONCURRENCY:-1}
services:
docker-api-proxy:
@ -100,13 +82,9 @@ services:
restart: unless-stopped
proxy:
image: who-need-help:traefik-v3.7.10-grpc1.82.1-xmod0.40.0
build:
context: .
dockerfile: Dockerfile.traefik
image: traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5
command:
- --api.dashboard=${TRAEFIK_API_INSECURE:-false}
- --api.insecure=${TRAEFIK_API_INSECURE:-false}
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.endpoint=tcp://docker-api-proxy:2375
- --providers.docker.exposedbydefault=false
@ -157,10 +135,7 @@ services:
restart: unless-stopped
mailpit:
image: who-need-help:mailpit-v1.30.7-go1.26.7-xmod0.40.0
build:
context: .
dockerfile: Dockerfile.mailpit
image: axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6
user: "65534:65534"
ports:
- "${MAILPIT_BIND_ADDRESS:-127.0.0.1}:${MAILPIT_PORT:-8027}:8025"
@ -256,11 +231,7 @@ services:
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help}
- traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.middlewares=${TRAEFIK_APP_NAME:-who-need-help}-retry
- traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.attempts=${TRAEFIK_RETRY_ATTEMPTS:-3}
- traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.status=500-599
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.server.port=4000
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.path=/healthz/ready
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.interval=10s
- traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.timeout=3s
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"]
interval: 10s

View File

@ -12,8 +12,6 @@ config :who_need_help, :mailer_from,
address: "contact@example.com"
config :who_need_help, :support_inbox_address, nil
config :who_need_help, :support_inbound_recipient, nil
config :who_need_help, :support_inbound_webhook_token, nil
config :who_need_help, :scopes,
user: [
@ -29,42 +27,14 @@ config :who_need_help, :scopes,
]
config :who_need_help,
deployment_env: :development,
ecto_repos: [WhoNeedHelp.Repo],
generators: [timestamp_type: :utc_datetime, binary_id: true],
app_role: :web,
codex_session_id: "not-configured",
e2e_routes: false,
secure_cookies: false,
# Initial pilot policy for public authentication and anonymous intake. These
# values are product policy, not a claim about universal security thresholds
# or database capacity. Runtime JSON can replace the complete map, and an
# explicit `{}` disables every shared counter for isolated load/E2E runs.
rate_limit_policies: %{
"registration_email" => %{limit: 4, window_seconds: 3_600},
"registration_ip" => %{limit: 120, window_seconds: 3_600},
"magic_link_email" => %{limit: 4, window_seconds: 3_600},
"magic_link_ip" => %{limit: 120, window_seconds: 3_600},
"password_login_email" => %{limit: 10, window_seconds: 900},
"password_login_ip" => %{limit: 300, window_seconds: 900},
"email_change_email" => %{limit: 3, window_seconds: 86_400},
"email_change_ip" => %{limit: 60, window_seconds: 3_600},
"support_request" => %{limit: 5, window_seconds: 86_400},
"support_request_ip" => %{limit: 120, window_seconds: 3_600},
"content_removal_notice" => %{limit: 20, window_seconds: 86_400},
"content_removal_notice_ip" => %{limit: 120, window_seconds: 3_600}
},
public_contact_verification_max_age_seconds: 86_400,
public_case_access_max_age_seconds: 31_536_000,
tracking_presence_cleanup_grace_ms: 5_000,
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
android_app_links: nil,
web_push_public_key: nil,
fcm_goth_source: nil,
device_delivery_options: %{
web_push: [],
fcm: []
}
rate_limit_policies: %{},
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
config :who_need_help, WhoNeedHelp.Repo, types: WhoNeedHelp.PostgrexTypes
@ -75,7 +45,7 @@ config :phoenix,
config :who_need_help, Oban,
repo: WhoNeedHelp.Repo,
queues: [maintenance: 2, push: 1, mail: 1],
queues: [maintenance: 2, push: 1],
plugins: [
{Oban.Plugins.Pruner, max_age: 86_400},
{Oban.Plugins.Cron, crontab: [{"* * * * *", WhoNeedHelp.Workers.ExpireRequests}]}

View File

@ -1,20 +1,5 @@
import Config
deployment_env =
case System.get_env("DEPLOYMENT_ENV", "development") do
"development" ->
:development
"test" ->
:test
"production" ->
:production
other ->
raise "DEPLOYMENT_ENV must be development, test, or production; got #{inspect(other)}"
end
app_role =
case System.get_env("APP_ROLE", "web") do
"web" -> :web
@ -27,7 +12,7 @@ app_role =
rate_limit_policies =
case System.get_env("RATE_LIMIT_POLICIES_JSON") do
value when value in [nil, ""] ->
Application.fetch_env!(:who_need_help, :rate_limit_policies)
%{}
json ->
case Jason.decode(json) do
@ -61,7 +46,6 @@ rate_limit_policies =
end
config :who_need_help,
deployment_env: deployment_env,
app_role: app_role,
codex_session_id: System.get_env("CODEX_SESSION_ID", "not-configured"),
map_tile_url:
@ -98,18 +82,11 @@ positive_integer_with_default = fn name, default ->
optional_positive_integer.(name) || default
end
config :who_need_help,
public_contact_verification_max_age_seconds:
positive_integer_with_default.("PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS", 86_400),
public_case_access_max_age_seconds:
positive_integer_with_default.("PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS", 31_536_000)
if config_env() == :prod do
config :who_need_help, Oban,
queues: [
maintenance: positive_integer_with_default.("OBAN_MAINTENANCE_CONCURRENCY", 2),
push: positive_integer_with_default.("OBAN_PUSH_CONCURRENCY", 1),
mail: positive_integer_with_default.("OBAN_MAIL_CONCURRENCY", 1)
push: positive_integer_with_default.("OBAN_PUSH_CONCURRENCY", 1)
]
end
@ -226,12 +203,6 @@ config :who_need_help, :social_oauth, github_oauth
[
client_id: client_id,
client_secret: client_secret,
authorized_party_ids:
System.get_env("GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS", "")
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq(),
base_url: base_url,
authorization_params: [scope: "email profile"],
http_adapter: {Assent.HTTPAdapter.Req, oauth_http_options.("GOOGLE")}
@ -301,160 +272,6 @@ config :who_need_help,
),
push_delivery_options: Keyword.delete(push_configuration, :adapter)
web_push_configuration =
case {
System.get_env("WEB_PUSH_VAPID_PUBLIC_KEY"),
System.get_env("WEB_PUSH_VAPID_PRIVATE_KEY"),
System.get_env("WEB_PUSH_VAPID_SUBJECT")
} do
{public_key, private_key, subject}
when is_binary(public_key) and public_key != "" and is_binary(private_key) and
private_key != "" and is_binary(subject) and subject != "" ->
unless String.starts_with?(subject, ["mailto:", "https://"]) do
raise "WEB_PUSH_VAPID_SUBJECT must start with mailto: or https://."
end
[public_key: public_key, private_key: private_key, subject: subject]
{public_key, private_key, subject}
when public_key in [nil, ""] and private_key in [nil, ""] and subject in [nil, ""] ->
[]
_partial_configuration ->
raise """
WEB_PUSH_VAPID_PUBLIC_KEY, WEB_PUSH_VAPID_PRIVATE_KEY, and WEB_PUSH_VAPID_SUBJECT \
must either all be set or all be empty.
"""
end
if web_push_configuration != [] do
config :web_push_elixir,
vapid_public_key: Keyword.fetch!(web_push_configuration, :public_key),
vapid_private_key: Keyword.fetch!(web_push_configuration, :private_key),
vapid_subject: Keyword.fetch!(web_push_configuration, :subject)
end
fcm_credentials =
case {
System.get_env("FCM_SERVICE_ACCOUNT_FILE"),
System.get_env("FCM_SERVICE_ACCOUNT_JSON_BASE64")
} do
{credentials_file, encoded}
when is_binary(credentials_file) and credentials_file != "" and encoded in [nil, ""] ->
unless Path.type(credentials_file) == :absolute and File.regular?(credentials_file) do
raise "FCM_SERVICE_ACCOUNT_FILE must be an absolute path to a readable regular file."
end
credentials_file |> File.read!() |> Jason.decode!()
{credentials_file, encoded}
when credentials_file in [nil, ""] and is_binary(encoded) and encoded != "" ->
case Base.decode64(encoded) do
{:ok, json} -> Jason.decode!(json)
:error -> raise "FCM_SERVICE_ACCOUNT_JSON_BASE64 must contain standard Base64."
end
{credentials_file, encoded} when credentials_file in [nil, ""] and encoded in [nil, ""] ->
nil
_both_configured ->
raise "Set only one of FCM_SERVICE_ACCOUNT_FILE or FCM_SERVICE_ACCOUNT_JSON_BASE64."
end
if fcm_credentials &&
(fcm_credentials["type"] != "service_account" ||
Enum.any?(["project_id", "client_email", "private_key"], fn field ->
not is_binary(fcm_credentials[field]) or fcm_credentials[field] == ""
end)) do
raise "The configured FCM credentials must be a complete Google service-account document."
end
fcm_configuration =
case {System.get_env("FCM_PROJECT_ID"), fcm_credentials} do
{project_id, credentials}
when is_binary(project_id) and project_id != "" and is_map(credentials) ->
%{
project_id: project_id,
source:
{:service_account, credentials,
scopes: ["https://www.googleapis.com/auth/firebase.messaging"]}
}
{project_id, nil} when project_id in [nil, ""] ->
nil
_partial_configuration ->
raise "FCM_PROJECT_ID and one FCM credential source must be configured together."
end
config :who_need_help,
web_push_public_key: Keyword.get(web_push_configuration, :public_key),
fcm_goth_source: fcm_configuration && fcm_configuration.source,
device_delivery_options: %{
web_push: [],
fcm:
if(fcm_configuration,
do: [
project_id: fcm_configuration.project_id,
goth_name: WhoNeedHelp.Goth,
receive_timeout: 10_000,
connect_timeout: 5_000
],
else: []
)
}
android_app_links =
case {
System.get_env("ANDROID_APP_LINKS_PACKAGE_NAME"),
System.get_env("ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS")
} do
{package_name, fingerprints}
when is_binary(package_name) and package_name != "" and is_binary(fingerprints) and
fingerprints != "" ->
unless Regex.match?(
~r/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/,
package_name
) do
raise "ANDROID_APP_LINKS_PACKAGE_NAME must be a valid Android application ID."
end
normalized_fingerprints =
fingerprints
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.map(fn fingerprint ->
hex = fingerprint |> String.replace(":", "") |> String.upcase()
unless Regex.match?(~r/^[0-9A-F]{64}$/, hex) do
raise """
ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS must contain comma-separated \
SHA-256 certificate fingerprints.
"""
end
hex
|> String.graphemes()
|> Enum.chunk_every(2)
|> Enum.map_join(":", &Enum.join/1)
end)
|> Enum.uniq()
%{package_name: package_name, sha256_cert_fingerprints: normalized_fingerprints}
{package_name, fingerprints}
when package_name in [nil, ""] and fingerprints in [nil, ""] ->
nil
_partial_configuration ->
raise """
ANDROID_APP_LINKS_PACKAGE_NAME and \
ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS must either both be set or both be empty.
"""
end
config :who_need_help, :android_app_links, android_app_links
if config_env() == :prod and app_role in [:web, :worker, :combined] do
metrics_token =
System.get_env("METRICS_TOKEN") ||
@ -585,44 +402,6 @@ if config_env() == :prod do
default_url_port = if scheme == "https", do: "443", else: "80"
url_port = String.to_integer(System.get_env("PHX_URL_PORT", default_url_port))
check_origin =
case System.get_env("PHX_CHECK_ORIGINS") do
value when value in [nil, ""] ->
true
value ->
origins =
value
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
if origins == [] do
raise "PHX_CHECK_ORIGINS must contain at least one origin when configured."
end
Enum.each(origins, fn origin ->
case URI.parse(origin) do
%URI{
scheme: allowed_scheme,
host: allowed_host,
path: path,
query: nil,
fragment: nil,
userinfo: nil
}
when allowed_scheme in ["http", "https"] and is_binary(allowed_host) and
allowed_host != "" and path in [nil, ""] ->
:ok
_invalid ->
raise "PHX_CHECK_ORIGINS entries must be comma-separated HTTP(S) origins without paths, query strings, fragments, or credentials; got #{inspect(origin)}."
end
end)
origins
end
email_delivery_provider = System.get_env("EMAIL_DELIVERY_PROVIDER", "smtp")
mailer_config =
@ -727,16 +506,9 @@ if config_env() == :prod do
config :who_need_help, :handover_secret, handover_secret
email_from_name = System.get_env("EMAIL_FROM_NAME", "Who Need Help")
email_from_address = System.get_env("EMAIL_FROM_ADDRESS", "contact@example.com")
unless WhoNeedHelp.EmailAddress.valid?(email_from_address) do
raise "EMAIL_FROM_ADDRESS must be a single SMTP-safe mailbox address."
end
config :who_need_help, :mailer_from,
name: email_from_name,
address: email_from_address
name: System.get_env("EMAIL_FROM_NAME", "Who Need Help"),
address: System.get_env("EMAIL_FROM_ADDRESS", "contact@example.com")
support_inbox_address =
case System.get_env("SUPPORT_INBOX_ADDRESS") do
@ -744,55 +516,17 @@ if config_env() == :prod do
value -> value
end
if support_inbox_address && not WhoNeedHelp.EmailAddress.valid?(support_inbox_address) do
raise "SUPPORT_INBOX_ADDRESS must be a single SMTP-safe mailbox address."
if support_inbox_address &&
not Regex.match?(~r/^[^@,;\s]+@[^@,;\s]+$/, support_inbox_address) do
raise "SUPPORT_INBOX_ADDRESS must be a single email address without spaces."
end
config :who_need_help, :support_inbox_address, support_inbox_address
support_inbound_recipient =
case System.get_env("SUPPORT_INBOUND_RECIPIENT") do
value when value in [nil, ""] -> nil
value -> value
end
support_inbound_webhook_token =
case System.get_env("SUPPORT_INBOUND_WEBHOOK_TOKEN") do
value when value in [nil, ""] -> nil
value -> value
end
if support_inbound_recipient &&
not WhoNeedHelp.EmailAddress.valid?(support_inbound_recipient) do
raise "SUPPORT_INBOUND_RECIPIENT must be a single SMTP-safe mailbox address."
end
if support_inbound_recipient == nil != (support_inbound_webhook_token == nil) do
raise "SUPPORT_INBOUND_RECIPIENT and SUPPORT_INBOUND_WEBHOOK_TOKEN must be set together."
end
config :who_need_help, :support_inbound_recipient, support_inbound_recipient
config :who_need_help, :support_inbound_webhook_token, support_inbound_webhook_token
support_operator_email_mode =
case System.get_env("SUPPORT_OPERATOR_EMAIL_MODE", "disabled") do
"disabled" ->
:disabled
"immediate" ->
:immediate
other ->
raise "SUPPORT_OPERATOR_EMAIL_MODE must be disabled or immediate; got #{inspect(other)}"
end
config :who_need_help, :support_operator_email_mode, support_operator_email_mode
config :who_need_help, WhoNeedHelp.Mailer, mailer_config
config :who_need_help, WhoNeedHelpWeb.Endpoint,
url: [host: host, port: url_port, scheme: scheme],
check_origin: check_origin,
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.

View File

@ -1,12 +1,6 @@
import Config
config :who_need_help, :handover_secret, "isolated-test-handover-secret"
config :who_need_help, :deployment_env, :test
config :who_need_help, :tracking_presence_cleanup_grace_ms, 100
# Unit tests opt in to individual policies inside the relevant test. This keeps
# unrelated examples independent from shared counters and mirrors the explicit
# `{}` used by the isolated E2E/load environments.
config :who_need_help, :rate_limit_policies, %{}
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
@ -17,14 +11,13 @@ config :bcrypt_elixir, :log_rounds, 1
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :who_need_help, WhoNeedHelp.Repo,
username: System.get_env("DB_USER", System.get_env("POSTGRES_USER", "postgres")),
password: System.get_env("DB_PASSWORD", System.get_env("POSTGRES_PASSWORD")),
username: System.get_env("DB_USER", "postgres"),
password: System.get_env("DB_PASSWORD"),
hostname: System.get_env("DB_HOST", "localhost"),
port: String.to_integer(System.get_env("DB_PORT", "5432")),
database: "who_need_help_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: String.to_integer(System.get_env("TEST_POOL_SIZE", "10")),
ownership_timeout: String.to_integer(System.get_env("TEST_OWNERSHIP_TIMEOUT_MS", "120000"))
pool_size: String.to_integer(System.get_env("TEST_POOL_SIZE", "10"))
# We don't run a server during test. If one is required,
# you can enable the server option below.
@ -39,8 +32,6 @@ config :who_need_help, :social_oauth_adapter, WhoNeedHelp.SocialOAuthFake
config :who_need_help, :google_auth_adapter, WhoNeedHelp.GoogleAuthFake
config :who_need_help, :google_oauth_base_url, "https://accounts.google.example/"
config :who_need_help, :metrics_token, "test-metrics-token"
config :who_need_help, :support_inbound_recipient, "support@reply.whoneedhelp.test"
config :who_need_help, :support_inbound_webhook_token, "test-support-inbound-token"
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false

View File

@ -83,10 +83,6 @@ spec:
value: {{ $root.Values.app.scheme | quote }}
- name: PHX_URL_PORT
value: {{ $root.Values.app.urlPort | quote }}
{{- with $root.Values.app.checkOrigins }}
- name: PHX_CHECK_ORIGINS
value: {{ . | quote }}
{{- end }}
- name: PORT
value: {{ $root.Values.app.port | quote }}
- name: POOL_SIZE
@ -95,8 +91,6 @@ spec:
value: {{ $root.Values.worker.maintenanceConcurrency | quote }}
- name: OBAN_PUSH_CONCURRENCY
value: {{ $root.Values.worker.pushConcurrency | quote }}
- name: OBAN_MAIL_CONCURRENCY
value: {{ $root.Values.worker.mailConcurrency | quote }}
- name: EMAIL_DELIVERY_PROVIDER
value: {{ $root.Values.app.emailDeliveryProvider | quote }}
- name: SMTP_RELAY
@ -107,10 +101,6 @@ spec:
value: {{ $root.Values.app.codexSessionId | quote }}
- name: RATE_LIMIT_POLICIES_JSON
value: {{ $root.Values.app.rateLimitPoliciesJson | quote }}
- name: PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS
value: {{ $root.Values.app.publicContactVerificationMaxAgeSeconds | quote }}
- name: PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS
value: {{ $root.Values.app.publicCaseAccessMaxAgeSeconds | quote }}
- name: MAP_TILE_URL
value: {{ $root.Values.app.mapTileUrl | quote }}
- name: DNS_CLUSTER_QUERY

Some files were not shown because too many files have changed in this diff Show More