Compare commits
334 Commits
921e04b360
...
319cdb970b
| Author | SHA1 | Date | |
|---|---|---|---|
| 319cdb970b | |||
| 45ccdd494a | |||
| a9b78ec374 | |||
| 72fd99e09a | |||
| b7a18b0649 | |||
| 89ac07ec20 | |||
| 5576b815df | |||
| 4197cb8626 | |||
| abc1bed140 | |||
| 4d09caf130 | |||
| 86260cead1 | |||
| 01971f1bc4 | |||
| 05dc9372e6 | |||
| adffed2965 | |||
| f08bdd04ce | |||
| 6d53436fc8 | |||
| e4c7ec8154 | |||
| ad87539789 | |||
| f5e0c991ea | |||
| c193cccf26 | |||
| d94c89e844 | |||
| c91ce502cb | |||
| 5a6f99b2aa | |||
| 0d5130fcd9 | |||
| acccb140b8 | |||
| 033718b9f4 | |||
| 92e6ee8d70 | |||
| 70baebb385 | |||
| 3daeceab1f | |||
| a47339cb51 | |||
| 5bc02efa7c | |||
| ea099b58ad | |||
| e799e5e791 | |||
| 18ce9c174b | |||
| 305bdebdd1 | |||
| dcac2fa0bc | |||
| e20bfca423 | |||
| 256c168a42 | |||
| e596af24d9 | |||
| 14d14bf4a0 | |||
| 3238a9feb3 | |||
| 85039b76ef | |||
| 9cdd31c72e | |||
| 7094f21394 | |||
| 4f81c38717 | |||
| 15c976213a | |||
| f15f5578e0 | |||
| e5351c000b | |||
| 711da82e08 | |||
| a3d13bf95b | |||
| 01855f5d9a | |||
| 6090af0f54 | |||
| 8b9dadba39 | |||
| e618ec80dd | |||
| ccbef9cd4f | |||
| cd9a21af85 | |||
| bb7eb58c8f | |||
| b2addbbe3e | |||
| 5efb2c319c | |||
| 1f9c5d74d5 | |||
| 8e5073a7a5 | |||
| bad9ca5b62 | |||
| 23776e296b | |||
| f1947f2264 | |||
| d742c27b9a | |||
| d281db42a8 | |||
| 805a762f89 | |||
| dd10e2d2d0 | |||
| dab96a9ed1 | |||
| ba92a7e62e | |||
| dbfb019598 | |||
| 02ccebb5b9 | |||
| 9aa371105f | |||
| d79e4e436b | |||
| 3c8c0b3cf7 | |||
| df6396df8a | |||
| 7908189e40 | |||
| 1eea74f454 | |||
| 67fd3feba7 | |||
| 9a2270e3db | |||
| 7130be3b69 | |||
| 17c93fa2f4 | |||
| 39ba37194f | |||
| d17637d3d3 | |||
| 2aa246dcfc | |||
| bd1ccae6ab | |||
| af90a64afc | |||
| c233f3ba34 | |||
| 9c482603af | |||
| 0393cadcad | |||
| 9c4367e822 | |||
| 6b4d238e51 | |||
| 6ed3ea4ab7 | |||
| 49a188023a | |||
| 6c1fe594f6 | |||
| 205c6df730 | |||
| 300e6d8311 | |||
| d2fb194d56 | |||
| 346b03695f | |||
| 0a575a6b33 | |||
| 7c812526d0 | |||
| 009ed6dafa | |||
| 3ba3de6eff | |||
| 128ae0ea85 | |||
| ecd5ef3c4f | |||
| dafcdb36cb | |||
| 4f1453feb8 | |||
| ed4d85dcab | |||
| 8449ee696f | |||
| 2a17a23f95 | |||
| 2ed9cae5df | |||
| ce7ce40fc3 | |||
| d76c321033 | |||
| 1fe06eab86 | |||
| abbcf24984 | |||
| 7c2b55917d | |||
| f672e9cc90 | |||
| 360c7a567e | |||
| d2cba218f7 | |||
| beb5de5eda | |||
| d97adefac1 | |||
| cd154766a0 | |||
| ee0650bf32 | |||
| 16956c62e5 | |||
| 95caa5d38e | |||
| ce6101df29 | |||
| 246800a3fd | |||
| f0cb936854 | |||
| a638088322 | |||
| bc49f09544 | |||
| 510ad2898c | |||
| c97744a777 | |||
| 029373aa80 | |||
| 1fa5b365ca | |||
| ff50a80e48 | |||
| d8177f38bd | |||
| 5990a1935e | |||
| 3c731b5aaf | |||
| 8ab30ce7f5 | |||
| e779188e8e | |||
| a65d7d126f | |||
| 94d4e6dae1 | |||
| 5df616a1e3 | |||
| 53c6099c36 | |||
| 9405e00baa | |||
| f3b5e63222 | |||
| 38e297f83b | |||
| 897a943815 | |||
| 67990ee47f | |||
| 0ad9a5430e | |||
| d1f2c4fc10 | |||
| c31532ebbc | |||
| de60e6e54c | |||
| 1f0c271951 | |||
| 95049b4a43 | |||
| 67747813cb | |||
| 9e0d41eadf | |||
| 07f96a6070 | |||
| da19893b36 | |||
| 645ff3eed9 | |||
| 8584308aba | |||
| c3f6905624 | |||
| 4fd340d705 | |||
| c56c2cebd3 | |||
| 39c07ea0ff | |||
| 4d321b92f5 | |||
| 0965523dc1 | |||
| 19bbfcaa4c | |||
| c4a9dbe2c2 | |||
| 578c77d7c5 | |||
| b80bf6e9a5 | |||
| 0aac86bda0 | |||
| 4ca0baa8fd | |||
| 5be7b6186e | |||
| 9f359da60a | |||
| f4a4831f12 | |||
| d3dc882549 | |||
| 4c52c8bf3d | |||
| cf283db350 | |||
| 6cee86833e | |||
| 0975b5dec8 | |||
| 87d6b94bf7 | |||
| 5e5478389d | |||
| ead8a67d0f | |||
| c085620762 | |||
| 9b79f7b8de | |||
| ea2a1b6f5e | |||
| 741359e476 | |||
| 7be5fcf478 | |||
| accc0845bd | |||
| f5c0d1d9b2 | |||
| 41b37ccf37 | |||
| 3f7484e798 | |||
| afc0040a65 | |||
| d3114e211b | |||
| e499ed7553 | |||
| 76e1ab7fd8 | |||
| 9279835243 | |||
| e558486256 | |||
| a540165da4 | |||
| d28a71bc6b | |||
| 040889de1a | |||
| af922b015f | |||
| ee6a1f7e70 | |||
| 762b72100d | |||
| 61ad7923ca | |||
| 4138d7bfef | |||
| 227c0bdc0c | |||
| 4490280e0e | |||
| ebaaeaf517 | |||
| b639db157c | |||
| cd7f6ea45c | |||
| b519f902e8 | |||
| 042cabe2a8 | |||
| 3d6f9c5004 | |||
| c8e95b8c77 | |||
| 3f67df1733 | |||
| c863b47ed1 | |||
| cbb13d6888 | |||
| a0e45707a3 | |||
| ca715e2876 | |||
| 6c14531da3 | |||
| 41011fe65c | |||
| 882df25aa9 | |||
| 695f6ebc0f | |||
| 7a54477a30 | |||
| 9875fd7d05 | |||
| 28940f9621 | |||
| 13d0495446 | |||
| 93bf508bdb | |||
| 539076c163 | |||
| 769be81ee3 | |||
| 02af8d88fa | |||
| c89dc78381 | |||
| 4f2ab7d205 | |||
| 62f1c4d9fc | |||
| 6302fd4045 | |||
| 7581283b47 | |||
| 13fbeec958 | |||
| ae663a745b | |||
| 144167beae | |||
| 51de2d829b | |||
| bcbe417a79 | |||
| 51d55fa73f | |||
| 22e83a420b | |||
| 615aee3d7a | |||
| 8e4656a725 | |||
| cba544ffda | |||
| 8adfe77c00 | |||
| 4f64eab796 | |||
| 7e0bc76640 | |||
| f69bb9c342 | |||
| ba872dda63 | |||
| a753af688a | |||
| 50be881601 | |||
| 504c0af867 | |||
| 4f8677927a | |||
| 04eb0fe506 | |||
| 39b7d0c6a3 | |||
| a9984738cc | |||
| fa7dcb18be | |||
| a95ebf71c6 | |||
| c952f3cc61 | |||
| a35b7cb8ba | |||
| 6c3d3ec16d | |||
| 28fc9559b3 | |||
| 8e74f8601e | |||
| 07cf978415 | |||
| b742a98979 | |||
| f3cfc4e215 | |||
| e7286f5608 | |||
| ac354f8073 | |||
| 7cdf5ab4eb | |||
| 7b09becc09 | |||
| cbb5efb62a | |||
| 5129e1253a | |||
| 0b9589f4fa | |||
| 89851097fd | |||
| 17478f6303 | |||
| ef9ac148e5 | |||
| 671d056d6d | |||
| c76bc4f5f1 | |||
| 96b676d036 | |||
| b556ee7e46 | |||
| 902079a9ad | |||
| 32cb7945e5 | |||
| 08c59a565a | |||
| 194ac181e7 | |||
| 6e5406c40b | |||
| fdd92b51b5 | |||
| e5829bcaf2 | |||
| 591384f2b7 | |||
| 71510a2ab4 | |||
| 6030c75288 | |||
| 1793258f97 | |||
| 564897cd3a | |||
| 0d880fc21e | |||
| 27bcb7188e | |||
| ca766dc1d3 | |||
| 6582b61dc4 | |||
| a7f076514c | |||
| e7cfddb587 | |||
| d18470f77a | |||
| 777bd779ef | |||
| f18e76b201 | |||
| 0dab9873b3 | |||
| 14584ccb8a | |||
| bf1eeee8f0 | |||
| a167d2ba97 | |||
| 0e81ee7f36 | |||
| 4a1af1b658 | |||
| 9c3456ad56 | |||
| 0717054851 | |||
| 7ce052d0a1 | |||
| f0512fab84 | |||
| 2159e6cda7 | |||
| e2bc8acf97 | |||
| 1db67b1048 | |||
| 3224d7d697 | |||
| b992b12e4b | |||
| c32fd99c73 | |||
| 3a3ce39258 | |||
| 29fe7319a2 | |||
| 6f22657731 | |||
| 3dfdf7a360 | |||
| b0abe5248c | |||
| 3d320ce182 | |||
| c634c136c9 | |||
| f97b00bda8 | |||
| 9acc52ef78 | |||
| fb79adffb2 | |||
| 81b64deced | |||
| 0880b25e73 | |||
| e5e6b501ea |
|
|
@ -14,14 +14,15 @@ TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
|
|||
PHX_HOST=proxy
|
||||
PHX_SCHEME=https
|
||||
PHX_URL_PORT=443
|
||||
MAP_TILE_URL=https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}
|
||||
MAP_TILE_URL=/__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
|
||||
SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-e2e
|
||||
POSTGIS_IMAGE=who-need-help:postgis-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_TOPOLOGY=split
|
||||
DATABASE_MODE=container
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ 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
|
||||
|
|
|
|||
109
.env.example
|
|
@ -31,10 +31,14 @@ 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
|
||||
# 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.
|
||||
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.
|
||||
EDGE_COMPOSE_PROJECT_NAME=who_need_help_edge
|
||||
CADDY_IMAGE=who-need-help:caddy-local
|
||||
EDGE_BIND_ADDRESS=0.0.0.0
|
||||
|
|
@ -51,6 +55,10 @@ 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
|
||||
|
|
@ -60,10 +68,15 @@ 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
|
||||
# Staging/release builds require a public HTTPS origin. Keep the value
|
||||
# Development/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=
|
||||
|
|
@ -74,10 +87,37 @@ 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
|
||||
|
|
@ -98,10 +138,15 @@ 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. Its callback URL must be:
|
||||
# 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:
|
||||
# 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=
|
||||
|
|
@ -119,9 +164,29 @@ 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
|
||||
|
|
@ -134,6 +199,7 @@ 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).
|
||||
|
|
@ -156,10 +222,37 @@ SMTP_TLS=never
|
|||
SMTP_SSL=false
|
||||
EMAIL_FROM_NAME="Who Need Help"
|
||||
EMAIL_FROM_ADDRESS=contact@example.com
|
||||
# Optional monitored inbox. It receives new-case alerts and is used as Reply-To.
|
||||
# Optional monitored inbox used as Reply-To for support and legal correspondence.
|
||||
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
|
||||
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.
|
||||
# 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.
|
||||
# Shape: {"action_name":{"limit":POSITIVE_INTEGER,"window_seconds":POSITIVE_INTEGER}}
|
||||
RATE_LIMIT_POLICIES_JSON={}
|
||||
# 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}}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ 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
|
||||
|
|
@ -36,6 +37,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -30,3 +30,6 @@ 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
|
||||
|
|
|
|||
3
.github/workflows/quality.yml
vendored
|
|
@ -61,3 +61,6 @@ 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
|
|
@ -37,6 +37,7 @@ who_need_help-*.tar
|
|||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
/e2e/node_modules/
|
||||
/e2e/output/
|
||||
/.tools/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
15
Dockerfile
|
|
@ -91,14 +91,23 @@ 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 \
|
||||
openssl=3.5.6-1~deb13u2 \
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the locale
|
||||
|
|
@ -133,6 +142,7 @@ 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/*
|
||||
|
||||
|
|
@ -152,6 +162,9 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,38 @@
|
|||
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS restic
|
||||
FROM golang:1.26.7-alpine3.23@sha256:b17af760035fc2f338eed92d448a6c67f2d45438844fc6c60678fa5f99e44b57 AS restic
|
||||
|
||||
RUN CGO_ENABLED=0 go install github.com/restic/restic/cmd/restic@v0.19.1
|
||||
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
|
||||
|
||||
FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates=20260611-r0 \
|
||||
libcrypto3=3.5.7-r0 \
|
||||
libssl3=3.5.7-r0 \
|
||||
libcrypto3=3.5.8-r0 \
|
||||
libssl3=3.5.8-r0 \
|
||||
musl=1.2.5-r23 \
|
||||
musl-utils=1.2.5-r23 \
|
||||
postgresql18-client=18.4-r0 \
|
||||
postgresql18-client=18.6-r0 \
|
||||
zlib=1.3.2-r0
|
||||
|
||||
COPY --from=restic /go/bin/restic /usr/local/bin/restic
|
||||
COPY --from=restic /out/restic /usr/local/bin/restic
|
||||
|
||||
ENTRYPOINT []
|
||||
USER 10001:10001
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
# syntax=docker/dockerfile:1.20.0
|
||||
|
||||
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder
|
||||
FROM golang:1.26.7-alpine3.23@sha256:b17af760035fc2f338eed92d448a6c67f2d45438844fc6c60678fa5f99e44b57 AS builder
|
||||
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOTOOLCHAIN=local
|
||||
|
||||
RUN GOBIN=/out go install \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -X github.com/caddyserver/caddy/v2.CustomVersion=v2.11.4" \
|
||||
github.com/caddyserver/caddy/v2/cmd/caddy@v2.11.4
|
||||
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 \
|
||||
-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
|
||||
|
||||
RUN mkdir -p /rootfs/data/caddy /rootfs/config/caddy /rootfs/tmp \
|
||||
&& chown -R 1000:1000 /rootfs
|
||||
|
|
|
|||
64
Dockerfile.mailpit
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# 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"]
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS minio_builder
|
||||
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
|
||||
|
||||
ADD --checksum=sha256:45521908307306e925c98d629e1c17d78c8b72b6ee242b1bfb1409f7d8ee5841 \
|
||||
https://github.com/minio/minio/archive/9e49d5e7a648f00e26f2246f4dc28e6b07f8c84a.tar.gz \
|
||||
|
|
@ -12,9 +16,14 @@ 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@v0.52.0 \
|
||||
golang.org/x/net@v0.55.0 \
|
||||
google.golang.org/grpc@v1.81.1 \
|
||||
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}" \
|
||||
&& CGO_ENABLED=0 go build \
|
||||
-mod=mod \
|
||||
-trimpath \
|
||||
|
|
@ -27,7 +36,11 @@ RUN tar --extract --gzip --file /tmp/minio.tar.gz \
|
|||
-X github.com/minio/minio/cmd.ShortCommitID=9e49d5e7a648" \
|
||||
-o /out/minio .
|
||||
|
||||
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS mc_builder
|
||||
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
|
||||
|
||||
ADD --checksum=sha256:95cd293c7119f16921a6dc515a1fb74a2227f19fd994b9c8b770a154e802ac44 \
|
||||
https://github.com/minio/mc/archive/7394ce0dd2a80935aded936b09fa12cbb3cb8096.tar.gz \
|
||||
|
|
@ -39,10 +52,15 @@ 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@v0.52.0 \
|
||||
golang.org/x/net@v0.55.0 \
|
||||
google.golang.org/grpc@v1.81.1 \
|
||||
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 \
|
||||
&& 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 \
|
||||
|
|
@ -58,8 +76,8 @@ FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f
|
|||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates=20260611-r0 \
|
||||
libcrypto3=3.5.7-r0 \
|
||||
libssl3=3.5.7-r0 \
|
||||
libcrypto3=3.5.8-r0 \
|
||||
libssl3=3.5.8-r0 \
|
||||
musl=1.2.5-r23 \
|
||||
musl-utils=1.2.5-r23 \
|
||||
zlib=1.3.2-r0
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ 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 rm /usr/local/bin/gosu
|
||||
RUN apk add --no-cache \
|
||||
libcrypto3=3.5.8-r0 \
|
||||
libssl3=3.5.8-r0 \
|
||||
&& rm /usr/local/bin/gosu
|
||||
|
||||
USER postgres
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ 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
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --no-cache \
|
||||
libcrypto3=3.5.8-r0 \
|
||||
libssl3=3.5.8-r0
|
||||
|
||||
USER haproxy
|
||||
|
|
|
|||
47
Dockerfile.traefik
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# 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
|
|
@ -1,14 +1,17 @@
|
|||
# Who Need Help
|
||||
|
||||
Who Need Help is a working mutual-aid MVP for urgent, local, voluntary help.
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## OpenAI Build Week 2026
|
||||
|
||||
|
|
@ -43,20 +46,44 @@ 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`, plus cancel
|
||||
and expiry paths.
|
||||
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
|
||||
PubSub/Presence, and optional consent-driven live location sharing.
|
||||
- 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.
|
||||
- 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.
|
||||
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.
|
||||
- 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, operator alerts, and audited staff queues.
|
||||
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.
|
||||
- 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
|
||||
|
|
@ -66,11 +93,15 @@ 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, and a user-started location foreground service. Its persistent
|
||||
private chat, consent-based FCM registration/deep links, 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 local and public-staging debug APKs; production signing and store
|
||||
publication are not configured.
|
||||
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.
|
||||
- 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
|
||||
|
|
@ -79,16 +110,19 @@ local Codex CLI authenticated with their ChatGPT subscription.
|
|||
replicas by default.
|
||||
|
||||
Additional social providers, background PWA or unattended location tracking,
|
||||
platform payments, production Android signing/store publication, iOS,
|
||||
platform payments, Android 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 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
|
||||
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
|
||||
[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.
|
||||
|
||||
|
|
@ -106,6 +140,13 @@ 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:
|
||||
|
|
@ -185,7 +226,7 @@ load project and then run:
|
|||
```
|
||||
|
||||
The command generates MinIO and Restic secrets only in ignored mode-`0600`
|
||||
`.env.load`, streams `pg_dump` directly into an encrypted Restic repository,
|
||||
`output/runtime/load.env`, 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.
|
||||
|
|
@ -208,7 +249,9 @@ 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).
|
||||
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.
|
||||
|
||||
Local defaults are intentionally limited to local development. Copy
|
||||
`.env.example` to `.env` and replace every secret before any public deployment.
|
||||
|
|
@ -235,9 +278,14 @@ 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 new-case alerts and make replies return to the support team; the
|
||||
database queues continue to work when it is empty. See
|
||||
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
|
||||
[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
|
||||
|
|
@ -322,6 +370,12 @@ 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
|
||||
|
|
@ -332,6 +386,15 @@ 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
|
||||
|
|
@ -400,12 +463,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:
|
||||
measurements. The lifecycle wrapper is preferred because it removes only its
|
||||
unique containers, networks, volumes, environment, and image tags on success,
|
||||
failure, or interruption:
|
||||
|
||||
```bash
|
||||
./scripts/load-stack-up.sh
|
||||
./scripts/load-run.sh local-load
|
||||
./scripts/load-stack-stop.sh
|
||||
./scripts/load-cycle.sh local-load load
|
||||
```
|
||||
|
||||
The profile has its own generated mode-`0600` environment, Compose project,
|
||||
|
|
@ -414,6 +477,24 @@ 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:
|
||||
|
||||
|
|
@ -436,8 +517,8 @@ two worker replicas:
|
|||
./scripts/e2e-run.sh
|
||||
```
|
||||
|
||||
On its first run it generates `.env.e2e` with independent random local secrets
|
||||
and mode `0600`. Browser traffic uses the isolated Traefik HTTPS entrypoint;
|
||||
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;
|
||||
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
|
||||
|
|
@ -445,9 +526,13 @@ 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; and the Activity flow through join approval, private
|
||||
double-blind reviews; 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.
|
||||
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.
|
||||
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
|
||||
|
|
@ -475,8 +560,8 @@ probe or the complete minimum/current API 24/30/34/37 matrix:
|
|||
./scripts/android-matrix-test.sh
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
|
@ -485,14 +570,21 @@ 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-staging variant and its instrumentation APK use the explicit HTTPS
|
||||
origin from the ignored `.env`. The smoke probe checks rendered WebView DOM on
|
||||
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 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
|
||||
|
|
@ -509,6 +601,24 @@ 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:
|
||||
|
|
@ -518,14 +628,16 @@ 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 roles in `/moderation`; the last
|
||||
administrator cannot demote themselves. For kind, append `kind`:
|
||||
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`:
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap-admin.sh you@example.com --confirm kind
|
||||
```
|
||||
|
||||
## Optional shared action limits
|
||||
## Shared action limits
|
||||
|
||||
`RATE_LIMIT_POLICIES_JSON` configures atomic PostgreSQL counters shared by every
|
||||
web replica. Its shape is:
|
||||
|
|
@ -535,8 +647,25 @@ web replica. Its shape is:
|
|||
```
|
||||
|
||||
The strings above describe the required types and are not a runnable policy.
|
||||
Keep `{}` until numeric limits have been approved from policy and observed
|
||||
traffic. Supported actions are listed in `docs/trust-safety.md`.
|
||||
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.
|
||||
|
||||
## Local Kubernetes verification
|
||||
|
||||
|
|
@ -575,6 +704,14 @@ 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:
|
||||
|
||||
|
|
@ -604,7 +741,8 @@ 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` `.env.load`.
|
||||
The generated Grafana password remains only in mode-`0600`
|
||||
`output/runtime/load.env`.
|
||||
|
||||
Stop only the monitoring services while leaving their local metric volumes and
|
||||
the load application running:
|
||||
|
|
@ -650,6 +788,7 @@ 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)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ 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 \
|
||||
|
|
@ -54,6 +58,10 @@ 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
|
||||
|
|
@ -61,7 +69,7 @@ FROM android-base AS emulator
|
|||
USER root
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
ARG ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.0/google_apis_ps16k/x86_64
|
||||
ARG ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.1/google_apis_ps16k/x86_64
|
||||
|
||||
RUN android --no-metrics --sdk="${ANDROID_HOME}" sdk install \
|
||||
"emulator" \
|
||||
|
|
@ -134,13 +142,17 @@ 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-staging-sdk
|
||||
FROM android-base AS android-public-sdk
|
||||
|
||||
USER gradle
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
WORKDIR /workspace/android
|
||||
|
||||
COPY --chown=gradle:gradle . .
|
||||
|
|
@ -150,9 +162,28 @@ 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}" \
|
||||
|
|
@ -160,22 +191,42 @@ 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_TEST_BUILD_TYPE=staging" \
|
||||
testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest
|
||||
"-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
|
||||
|
||||
FROM scratch AS staging-artifact
|
||||
FROM scratch AS public-artifact
|
||||
|
||||
USER 65532:65532
|
||||
|
||||
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
|
||||
COPY --from=android-public-sdk /workspace/export/ /
|
||||
|
||||
FROM android-base AS android-release-base
|
||||
|
||||
|
|
@ -196,6 +247,7 @@ 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 . .
|
||||
|
|
@ -205,13 +257,19 @@ 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 \
|
||||
WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_upload_keystore \
|
||||
test -n "${WNH_SIGNING_CERT_SHA256}" \
|
||||
&& 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}" \
|
||||
|
|
@ -219,11 +277,21 @@ 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 \
|
||||
|
|
@ -231,7 +299,33 @@ 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
|
||||
>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
|
||||
|
||||
FROM scratch AS release-artifact
|
||||
|
||||
|
|
@ -243,9 +337,24 @@ 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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,24 @@ 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
|
||||
|
|
@ -55,10 +73,37 @@ 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
|
||||
WNH_ENV_FILE=.env.production ./scripts/android-release-build.sh
|
||||
./scripts/android-release-build.sh
|
||||
```
|
||||
|
||||
The build passes the private files with Docker BuildKit secret mounts, runs
|
||||
|
|
@ -77,37 +122,57 @@ separate app-signing key used for distributed APKs:
|
|||
- <https://support.google.com/googleplay/android-developer/answer/9859152>
|
||||
- <https://docs.docker.com/build/building/secrets/>
|
||||
|
||||
## Public staging build
|
||||
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.
|
||||
|
||||
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:
|
||||
## 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:
|
||||
|
||||
```sh
|
||||
./scripts/ensure-local-public-origin.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/android-staging-build.sh
|
||||
sha256sum android/dist-staging/who-need-help-staging.apk
|
||||
```
|
||||
|
||||
This variant uses Android's generic debug signing key so it can be installed
|
||||
for staging verification. It is not a production-signed artifact and must not
|
||||
be published as a release. The manifest accepts same-origin HTTPS deep links,
|
||||
but verified Android App Links additionally require the final signing
|
||||
certificate fingerprint in the deployment's `/.well-known/assetlinks.json`.
|
||||
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.
|
||||
|
||||
With the temporary public origin reachable, run the API 37 emulator smoke test:
|
||||
With the matching public origin reachable, run the API 37 emulator smoke test:
|
||||
|
||||
```sh
|
||||
./scripts/android-development-smoke.sh
|
||||
./scripts/android-staging-smoke.sh
|
||||
```
|
||||
|
||||
The script installs the exported staging APK into a fresh project-scoped
|
||||
emulator container, loads the configured HTTPS home page, follows a
|
||||
Each script installs the corresponding 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 ignored `output/android-staging-smoke/`. The
|
||||
one-run container and image are removed on success or failure.
|
||||
and logcat diagnostics under its ignored `output/android-*-smoke/` directory.
|
||||
The one-run container and image are removed on success or failure.
|
||||
|
||||
## Reproducible Docker build
|
||||
|
||||
|
|
@ -132,10 +197,11 @@ repository root:
|
|||
./scripts/android-matrix-test.sh
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
|
@ -152,7 +218,7 @@ in either outcome.
|
|||
|
||||
## Emulator verification
|
||||
|
||||
The optional `emulator` target contains the API 37.0 Google APIs x86_64 system
|
||||
The optional `emulator` target contains the API 37.1 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`:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ 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 =
|
||||
|
|
@ -25,6 +29,29 @@ 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),
|
||||
|
|
@ -87,6 +114,24 @@ 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"
|
||||
}
|
||||
|
|
@ -117,22 +162,45 @@ 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")
|
||||
|
|
@ -150,7 +218,6 @@ android {
|
|||
"\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||
)
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https"
|
||||
manifestPlaceholders["deepLinkHost"] =
|
||||
releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local"
|
||||
proguardFiles(
|
||||
|
|
@ -174,14 +241,19 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach {
|
||||
tasks.matching {
|
||||
it.name == "preDevelopmentBuild" ||
|
||||
it.name == "preStagingBuild" ||
|
||||
it.name == "preReleaseBuild"
|
||||
}.configureEach {
|
||||
doFirst {
|
||||
if (name == "preReleaseBuild" && !releaseSigningConfigured) {
|
||||
validateFirebaseConfiguration()
|
||||
if (!releaseSigningConfigured) {
|
||||
val detail =
|
||||
if (releaseSigningPartiallyConfigured) {
|
||||
"Release signing is only partially configured"
|
||||
"Android signing is only partially configured"
|
||||
} else {
|
||||
"Release signing is not configured"
|
||||
"Android signing is not configured"
|
||||
}
|
||||
throw GradleException(
|
||||
"$detail; set WNH_ANDROID_SIGNING_STORE_FILE, "
|
||||
|
|
@ -214,6 +286,7 @@ tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.
|
|||
|
||||
tasks.matching { it.name == "preDebugBuild" }.configureEach {
|
||||
doFirst {
|
||||
validateFirebaseConfiguration()
|
||||
val value = debugBaseUrl.orNull.orEmpty()
|
||||
val uri = runCatching { URI(value) }.getOrNull()
|
||||
|
||||
|
|
@ -258,7 +331,14 @@ 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")
|
||||
|
|
@ -267,4 +347,7 @@ 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"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
4
android/app/proguard-rules.pro
vendored
|
|
@ -1 +1,3 @@
|
|||
# The app uses only Android framework APIs. Keep rules are intentionally empty.
|
||||
# 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 { *; }
|
||||
|
|
|
|||
|
|
@ -14,13 +14,19 @@ 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;
|
||||
|
|
@ -40,6 +46,7 @@ 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)
|
||||
|
|
@ -66,7 +73,38 @@ public final class AndroidClientInstrumentedTest {
|
|||
context.stopService(new Intent(context, TrackingService.class));
|
||||
context.getSystemService(NotificationManager.class).cancelAll();
|
||||
device.pressBack();
|
||||
server.close();
|
||||
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
|
||||
|
|
@ -107,66 +145,183 @@ public final class AndroidClientInstrumentedTest {
|
|||
.withElement(findElement(Locator.ID, "location"))
|
||||
.perform(webClick());
|
||||
|
||||
UiObject2 allow = null;
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
"com.android.packageinstaller",
|
||||
"permission_allow_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
"com.google.android.packageinstaller",
|
||||
"permission_allow_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
LOCATION_PERMISSION_CONTROLLER,
|
||||
"permission_allow_foreground_only_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(By.textContains("While using the app")),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
assertNotNull("Android location permission prompt was not shown", allow);
|
||||
allow.click();
|
||||
|
||||
waitForLocationPermission();
|
||||
UiObject2 locationButton = device.wait(
|
||||
Until.findObject(
|
||||
By.res(context.getPackageName(), "location_button")
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
assertNotNull(
|
||||
"WebView geolocation did not report a granted position",
|
||||
server.awaitRequestEndingWith(
|
||||
"/geolocation-granted",
|
||||
15,
|
||||
TimeUnit.SECONDS
|
||||
"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) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
"com.android.packageinstaller",
|
||||
"permission_allow_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
"com.google.android.packageinstaller",
|
||||
"permission_allow_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(
|
||||
By.res(
|
||||
LOCATION_PERMISSION_CONTROLLER,
|
||||
"permission_allow_foreground_only_button"
|
||||
)
|
||||
),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (allow == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
allow = device.wait(
|
||||
Until.findObject(By.textContains("While using the app")),
|
||||
UI_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
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))
|
||||
)
|
||||
);
|
||||
|
||||
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("location-granted")));
|
||||
.check(webMatches(getText(), containsString("tracking-cancelled")));
|
||||
assertFalse(
|
||||
"Cancelling live-location disclosure was reported as a technical error",
|
||||
device.hasObject(By.text("tracking-error"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +350,70 @@ 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();
|
||||
|
|
@ -275,33 +494,31 @@ public final class AndroidClientInstrumentedTest {
|
|||
server.awaitRequestEndingWith(positionPath, 15, TimeUnit.SECONDS)
|
||||
);
|
||||
|
||||
int foregroundCount = server.requestCountEndingWith(positionPath);
|
||||
device.pressHome();
|
||||
|
||||
assertNotNull(
|
||||
"Tracking stopped after the Activity left the foreground",
|
||||
server.awaitRequestAfterCount(
|
||||
positionPath,
|
||||
foregroundCount,
|
||||
15,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
);
|
||||
SystemClock.sleep(1_000);
|
||||
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();
|
||||
|
||||
assertNotNull(
|
||||
"Tracking stopped after the Activity was destroyed",
|
||||
server.awaitRequestAfterCount(
|
||||
positionPath,
|
||||
stoppedActivityCount,
|
||||
15,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
);
|
||||
SystemClock.sleep(1_000);
|
||||
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(
|
||||
|
|
@ -350,12 +567,19 @@ public final class AndroidClientInstrumentedTest {
|
|||
}
|
||||
|
||||
private ActivityScenario<MainActivity> launch(String path) {
|
||||
Intent intent = new Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse(BuildConfig.BASE_URL + path),
|
||||
context,
|
||||
MainActivity.class
|
||||
);
|
||||
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.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
return ActivityScenario.launch(intent);
|
||||
}
|
||||
|
|
@ -388,18 +612,21 @@ 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
|
||||
);
|
||||
|
||||
assertNotNull("Foreground tracking notification had no Stop action", stop);
|
||||
stop.click();
|
||||
if (stop != null) {
|
||||
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() {
|
||||
|
|
@ -462,20 +689,29 @@ public final class AndroidClientInstrumentedTest {
|
|||
}
|
||||
|
||||
private void grantTrackingPermissions() throws Exception {
|
||||
String packageName = context.getPackageName();
|
||||
device.executeShellCommand(
|
||||
"pm grant " + packageName + " " + Manifest.permission.ACCESS_FINE_LOCATION
|
||||
);
|
||||
grantLocationPermission();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
device.executeShellCommand(
|
||||
"pm grant " + packageName + " " + Manifest.permission.POST_NOTIFICATIONS
|
||||
"pm grant "
|
||||
+ context.getPackageName()
|
||||
+ " "
|
||||
+ 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);
|
||||
|
||||
|
|
@ -501,4 +737,22 @@ 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,19 +8,26 @@ 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;
|
||||
|
|
@ -29,7 +36,7 @@ import java.util.concurrent.TimeUnit;
|
|||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class CrossClientStagingInstrumentedTest {
|
||||
private static final long PAGE_TIMEOUT_SECONDS = 45;
|
||||
private static final long PAGE_TIMEOUT_SECONDS = 120;
|
||||
|
||||
private final Context context = ApplicationProvider.getApplicationContext();
|
||||
private final UiDevice device =
|
||||
|
|
@ -44,12 +51,26 @@ 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(
|
||||
"main-content",
|
||||
"Help can be closer than you think."
|
||||
"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."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -60,8 +81,13 @@ 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);
|
||||
}
|
||||
|
|
@ -97,6 +123,13 @@ 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);
|
||||
|
|
@ -111,6 +144,11 @@ 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);
|
||||
|
|
@ -120,7 +158,7 @@ public final class CrossClientStagingInstrumentedTest {
|
|||
try {
|
||||
onWebView()
|
||||
.withElement(findElement(Locator.CSS_SELECTOR, selector))
|
||||
.check(webMatches(getText(), containsString("")));
|
||||
.check(webMatches(getText(), containsString(expected)));
|
||||
return;
|
||||
} catch (AssertionError | RuntimeException failure) {
|
||||
lastFailure = failure;
|
||||
|
|
@ -129,7 +167,10 @@ public final class CrossClientStagingInstrumentedTest {
|
|||
}
|
||||
|
||||
AssertionError timeout = new AssertionError(
|
||||
"WebView selector did not appear: " + selector
|
||||
"WebView selector did not contain expected text: "
|
||||
+ selector
|
||||
+ " => "
|
||||
+ expected
|
||||
);
|
||||
|
||||
if (lastFailure != null) {
|
||||
|
|
@ -139,6 +180,38 @@ 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 =
|
||||
|
|
@ -168,6 +241,31 @@ 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ 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);
|
||||
|
|
@ -69,6 +71,11 @@ 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);
|
||||
|
|
@ -88,6 +95,25 @@ 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,
|
||||
|
|
@ -189,6 +215,19 @@ 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(),
|
||||
|
|
@ -220,13 +259,32 @@ 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')},"
|
||||
+ "()=>{document.getElementById('marker').textContent='location-denied';"
|
||||
+ "fetch('/geolocation-denied')},"
|
||||
+ "{enableHighAccuracy:true,maximumAge:0,timeout:10000})\">"
|
||||
+ "(error)=>{document.getElementById('marker').textContent="
|
||||
+ "'location-denied-'+error.code;"
|
||||
+ "fetch('/geolocation-denied-'+error.code)},"
|
||||
+ "{enableHighAccuracy:false,maximumAge:3600000,timeout:30000})\">"
|
||||
+ "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>";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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;
|
||||
|
|
@ -28,10 +29,15 @@ 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",
|
||||
"Help can be closer than you think."
|
||||
"Need help nearby? Ask the community."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -3,21 +3,31 @@
|
|||
<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"
|
||||
|
|
@ -27,13 +37,25 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="false">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:host="${deepLinkHost}"
|
||||
android:scheme="${deepLinkScheme}" />
|
||||
<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" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
|
|
@ -41,5 +63,12 @@
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,8 +25,11 @@ final class LaunchUrlResolver {
|
|||
String debugInitialUrl,
|
||||
boolean debugBuild
|
||||
) {
|
||||
if (deepLinkUrl != null && trustedOrigin.matches(deepLinkUrl)) {
|
||||
return deepLinkUrl;
|
||||
if (deepLinkUrl != null) {
|
||||
String appLink = trustedOrigin.canonicalAppLink(deepLinkUrl);
|
||||
if (appLink != null && AppLinkRoutePolicy.allows(appLink)) {
|
||||
return appLink;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -39,4 +42,8 @@ final class LaunchUrlResolver {
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
static boolean shouldLoadIncomingUrl(String currentUrl, String candidateUrl) {
|
||||
return candidateUrl != null && !candidateUrl.equals(currentUrl);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
private static final int NOTIFICATION_ID = 4101;
|
||||
static final int NOTIFICATION_ID = 4101;
|
||||
|
||||
private final Object pendingLocationLock = new Object();
|
||||
private final AtomicBoolean uploadRunning = new AtomicBoolean(false);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,41 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
59
android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?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>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?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>
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 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">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 16 KiB |
33
android/app/src/main/res/values-ru/strings.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?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>
|
||||
33
android/app/src/main/res/values-uk/strings.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?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>
|
||||
|
|
@ -2,4 +2,7 @@
|
|||
<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>
|
||||
|
|
|
|||
4
android/app/src/main/res/values/ids.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="main_content" type="id" />
|
||||
</resources>
|
||||
|
|
@ -4,8 +4,10 @@
|
|||
<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>
|
||||
|
|
@ -19,4 +21,13 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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">false</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
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;
|
||||
|
||||
|
|
@ -28,6 +30,14 @@ public final class LaunchUrlResolverTest {
|
|||
false
|
||||
)
|
||||
);
|
||||
assertNull(
|
||||
LaunchUrlResolver.incomingUrl(
|
||||
origin,
|
||||
"https://help.example/auth/google/callback?code=one-time&state=browser",
|
||||
null,
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -43,6 +53,27 @@ 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(
|
||||
|
|
@ -55,4 +86,26 @@ 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ 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;
|
||||
|
||||
|
|
@ -18,6 +19,13 @@ 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
|
||||
|
|
|
|||
113
android/play-store/closed-test.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# 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
|
||||
177
android/play-store/data-safety.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# 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
|
||||
73
android/play-store/internal-release-v1.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# 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.
|
||||
|
||||
33
android/play-store/internal-release-v2.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# 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.
|
||||
105
android/play-store/internal-release-v3.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# 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.
|
||||
68
android/play-store/internal-release-v4.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# 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.
|
||||
241
android/play-store/location-and-fgs-declaration.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# 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
|
||||
245
android/play-store/release-checklist.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# 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
|
||||
118
android/play-store/review-access.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# 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.
|
||||
48
android/play-store/store-listing-en-US.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# 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
|
||||
38
android/play-store/store-listing-ru-RU.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 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
|
||||
38
android/play-store/store-listing-uk-UA.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 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
|
||||
171
android/play-store/store-presence-runbook.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# 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
|
||||
51
android/store-assets/README.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# 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
|
||||
BIN
android/store-assets/feature-graphic-1024x500.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
72
android/store-assets/feature-graphic.svg
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
BIN
android/store-assets/screenshots/phone/01-home.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
android/store-assets/screenshots/phone/02-request-form.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
android/store-assets/screenshots/phone/03-location-privacy.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
android/store-assets/screenshots/phone/04-category-proposals.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
1064
assets/css/app.css
131
assets/js/app.js
|
|
@ -25,6 +25,7 @@ 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"
|
||||
|
|
@ -58,13 +59,6 @@ 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"),
|
||||
|
|
@ -74,6 +68,16 @@ 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")
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -83,8 +87,26 @@ 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()
|
||||
}
|
||||
|
|
@ -121,6 +143,101 @@ 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()
|
||||
|
||||
|
|
|
|||
2425
assets/js/hooks.js
143
assets/js/map_cluster_features.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
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
|
||||
})
|
||||
165
assets/js/map_cluster_features.test.mjs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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]
|
||||
)
|
||||
})
|
||||
13
assets/js/map_interactions.test.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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)
|
||||
})
|
||||
25
assets/js/map_viewport_sync.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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
|
||||
}
|
||||
66
assets/js/map_viewport_sync.test.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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)
|
||||
})
|
||||
20
assets/js/native_push_state.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
72
assets/js/native_push_state.test.mjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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"
|
||||
}
|
||||
)
|
||||
})
|
||||
26
assets/js/protected_token_fragment.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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
|
||||
}
|
||||
66
assets/js/protected_token_fragment.test.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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)
|
||||
})
|
||||
156
assets/js/service_worker.test.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"name": "who-need-help-assets",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test js/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "5.24.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,32 @@
|
|||
# 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.
|
||||
# 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.
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
services:
|
||||
migrate:
|
||||
image: who-need-help:e2e
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
|
||||
pull_policy: never
|
||||
build:
|
||||
args:
|
||||
WNH_E2E_ROUTES: "true"
|
||||
|
||||
web:
|
||||
image: who-need-help:e2e
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
|
||||
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: who-need-help:e2e
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
|
||||
pull_policy: never
|
||||
|
||||
e2e:
|
||||
image: who-need-help-e2e-tests:local
|
||||
image: ${E2E_TEST_IMAGE:?Set E2E_TEST_IMAGE for the isolated E2E run}
|
||||
build:
|
||||
context: e2e
|
||||
init: true
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ services:
|
|||
ingress: {}
|
||||
|
||||
migrate:
|
||||
image: who-need-help:load
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
|
||||
|
||||
web:
|
||||
image: who-need-help:load
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
|
||||
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: who-need-help:load
|
||||
image: ${APP_IMAGE:?Set APP_IMAGE in the isolated load environment}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
services:
|
||||
alert-receiver:
|
||||
image: python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4
|
||||
image: ${WNH_PYTHON_RUNTIME_IMAGE:-who-need-help:python-runtime}
|
||||
build:
|
||||
context: ops/external-boundaries
|
||||
target: python_runtime
|
||||
command: ["python", "/opt/who-need-help/alert-receiver.py"]
|
||||
volumes:
|
||||
- ./scripts/alert-receiver.py:/opt/who-need-help/alert-receiver.py:ro
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
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
|
||||
|
|
|
|||
12
compose.scale.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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
|
||||
37
compose.yaml
|
|
@ -1,4 +1,5 @@
|
|||
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:-}
|
||||
|
|
@ -15,6 +16,7 @@ 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}
|
||||
|
|
@ -27,8 +29,14 @@ 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}
|
||||
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
|
||||
# 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}
|
||||
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:-}
|
||||
|
|
@ -40,6 +48,7 @@ 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:-}
|
||||
|
|
@ -49,8 +58,17 @@ 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:
|
||||
|
|
@ -82,9 +100,13 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
proxy:
|
||||
image: traefik:v3.7.8@sha256:4299bbed850421258fc5448c2e0e6ad350981d4d335a68de11b92448aedbefe5
|
||||
image: who-need-help:traefik-v3.7.10-grpc1.82.1-xmod0.40.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.traefik
|
||||
command:
|
||||
- --api.dashboard=false
|
||||
- --api.dashboard=${TRAEFIK_API_INSECURE:-false}
|
||||
- --api.insecure=${TRAEFIK_API_INSECURE:-false}
|
||||
- --providers.docker=true
|
||||
- --providers.docker.endpoint=tcp://docker-api-proxy:2375
|
||||
- --providers.docker.exposedbydefault=false
|
||||
|
|
@ -135,7 +157,10 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.4@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6
|
||||
image: who-need-help:mailpit-v1.30.7-go1.26.7-xmod0.40.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.mailpit
|
||||
user: "65534:65534"
|
||||
ports:
|
||||
- "${MAILPIT_BIND_ADDRESS:-127.0.0.1}:${MAILPIT_PORT:-8027}:8025"
|
||||
|
|
@ -231,7 +256,11 @@ 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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ 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: [
|
||||
|
|
@ -27,14 +29,42 @@ 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,
|
||||
rate_limit_policies: %{},
|
||||
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
# 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: []
|
||||
}
|
||||
|
||||
config :who_need_help, WhoNeedHelp.Repo, types: WhoNeedHelp.PostgrexTypes
|
||||
|
||||
|
|
@ -45,7 +75,7 @@ config :phoenix,
|
|||
|
||||
config :who_need_help, Oban,
|
||||
repo: WhoNeedHelp.Repo,
|
||||
queues: [maintenance: 2, push: 1],
|
||||
queues: [maintenance: 2, push: 1, mail: 1],
|
||||
plugins: [
|
||||
{Oban.Plugins.Pruner, max_age: 86_400},
|
||||
{Oban.Plugins.Cron, crontab: [{"* * * * *", WhoNeedHelp.Workers.ExpireRequests}]}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
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
|
||||
|
|
@ -12,7 +27,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
|
||||
|
|
@ -46,6 +61,7 @@ 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:
|
||||
|
|
@ -82,11 +98,18 @@ 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)
|
||||
push: positive_integer_with_default.("OBAN_PUSH_CONCURRENCY", 1),
|
||||
mail: positive_integer_with_default.("OBAN_MAIL_CONCURRENCY", 1)
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -203,6 +226,12 @@ 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")}
|
||||
|
|
@ -272,6 +301,160 @@ 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") ||
|
||||
|
|
@ -402,6 +585,44 @@ 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 =
|
||||
|
|
@ -506,9 +727,16 @@ 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: System.get_env("EMAIL_FROM_NAME", "Who Need Help"),
|
||||
address: System.get_env("EMAIL_FROM_ADDRESS", "contact@example.com")
|
||||
name: email_from_name,
|
||||
address: email_from_address
|
||||
|
||||
support_inbox_address =
|
||||
case System.get_env("SUPPORT_INBOX_ADDRESS") do
|
||||
|
|
@ -516,17 +744,55 @@ if config_env() == :prod do
|
|||
value -> value
|
||||
end
|
||||
|
||||
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."
|
||||
if support_inbox_address && not WhoNeedHelp.EmailAddress.valid?(support_inbox_address) do
|
||||
raise "SUPPORT_INBOX_ADDRESS must be a single SMTP-safe mailbox address."
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
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
|
||||
|
|
@ -11,13 +17,14 @@ 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", "postgres"),
|
||||
password: System.get_env("DB_PASSWORD"),
|
||||
username: System.get_env("DB_USER", System.get_env("POSTGRES_USER", "postgres")),
|
||||
password: System.get_env("DB_PASSWORD", System.get_env("POSTGRES_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"))
|
||||
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"))
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
|
|
@ -32,6 +39,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ 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
|
||||
|
|
@ -91,6 +95,8 @@ 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
|
||||
|
|
@ -101,6 +107,10 @@ 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
|
||||
|
|
|
|||