Verify Play physical location lifecycle

This commit is contained in:
SimpleTest 2026-08-09 06:15:01 +03:00
parent 53c6099c36
commit 5df616a1e3
3 changed files with 461 additions and 12 deletions

View File

@ -15,15 +15,18 @@
- [x] Accept Play App Signing. - [x] Accept Play App Signing.
- [x] Record the upload-certificate SHA-1 and SHA-256 with the source-bound - [x] Record the upload-certificate SHA-1 and SHA-256 with the source-bound
candidate. candidate.
- [ ] Record every Play App Signing SHA-1 and SHA-256 displayed by Play after - [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 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 before any tester rollout. Do not assume that a new app has only one
Play certificate: current Play quantum-ready hybrid signing can expose Play certificate: current Play quantum-ready hybrid signing can expose
multiple classical/post-quantum identities for different Android multiple classical/post-quantum identities for different Android
generations. generations. The protected identity document records all three Play
- [ ] Add every applicable Play App Signing SHA-256 to production identities shown for version `0.1.0 (1)` plus the independent upload
Google/Firebase Android configuration. identity; no certificate values are stored in this public checklist.
- [ ] Publish and verify - [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 `https://whoneedhelp.com/.well-known/assetlinks.json` for the Play
certificate identities used to sign delivered APKs. certificate identities used to sign delivered APKs.
Use `scripts/import-play-android-config.sh` in plan mode first, then Use `scripts/import-play-android-config.sh` in plan mode first, then
@ -42,9 +45,11 @@
lint, package name, version code/name, target SDK, and production origin. 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 - [x] Install the release APK generated from the same source-bound build on the authorized
physical phone and run the release smoke test. physical phone and run the release smoke test.
- [ ] Save and publish the source-bound AAB currently uploaded to the internal - [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 testing draft. Do not mark this complete until Play Console shows one
accepted artifact and the internal release is available to testers. 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.
## Production capability gate ## Production capability gate
@ -53,10 +58,10 @@
checks reported `READY`, with zero blocking items and zero local-only checks reported `READY`, with zero blocking items and zero local-only
warnings. The validator did not print secret values and did not modify warnings. The validator did not print secret values and did not modify
production. production.
- [ ] Pass the stricter `--require-release` gate. Its only remaining blocking - [x] Pass the stricter `--require-release` gate. On 2026-08-09 the live
item on 2026-08-08 is the not-yet-generated set of Play App Signing production `.env` reported all twelve capabilities `READY`, with zero
SHA-256 fingerprints; blocking items and zero local-only warnings after the Play identities
every other capability reported `READY`. were imported.
## Store presence ## Store presence
@ -100,12 +105,21 @@
## Testing ## Testing
- [ ] Internal track smoke test passed. - [x] Internal track smoke test passed.
Before exercising product flows, run Before exercising product flows, run
`scripts/verify-play-installed-android.sh` with the protected Play `scripts/verify-play-installed-android.sh` with the protected Play
identity document, physical-device serial, and exact expected version. identity document, physical-device serial, and exact expected version.
It must confirm the Google Play installer, Play signing identity, It must confirm the Google Play installer, Play signing identity,
verified production App Link, and `MainActivity` resolution. verified production App Link, and `MainActivity` resolution.
The 2026-08-09 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. This verifies observed app behavior; it does not complete the
separate Play Console policy declarations above.
- [ ] Closed track created and opt-in link tested. - [ ] Closed track created and opt-in link tested.
- [ ] At least 12 testers continuously opted in for 14 days. - [ ] At least 12 testers continuously opted in for 14 days.
- [ ] Tester feedback and fixes documented. - [ ] Tester feedback and fixes documented.

View File

@ -2113,3 +2113,82 @@ promoted.
changed and no release was saved, submitted, or published. The Play App changed and no release was saved, submitted, or published. The Play App
Signing certificate set and Play-delivered device flow remain unverified Signing certificate set and Play-delivered device flow remain unverified
gates. gates.
# 2026-08-09 Google Play internal-track and production-provider verification
- Google Play accepted the source-bound AAB with SHA-256
`03d39a9a08e9ca7569caccf1c7bd75e9349f7655935e7bbf23d1998cd37b3837`
as version `0.1.0 (1)`. The release named `0.1.0 internal verification` is
active only on the Internal testing track. No Closed or Production rollout
was created or started.
- Every Play App Signing identity displayed for the accepted artifact was
recorded in a mode-`0600`, ignored provider document. The production
Firebase Android app and Google OAuth clients were reconciled with all Play
SHA-1 identities while preserving the independent upload identity. A fresh
production Android client configuration contained four Android OAuth
clients and one Web OAuth client and passed the repository validator. Secret
provider material is intentionally not reproduced in this document.
- The production application was recreated with the reconciled provider
configuration while retaining its exact application image
`who-need-help:production-0ad9a5430e1a`. A subsequent read-only observation
reported zero container restarts, healthy Docker state, and the expected
`{"status":"ready"}` response both locally on the server and through the
public HTTPS endpoint. The frozen hackathon test containers remained healthy
and were not changed.
- The public Android association response contained one statement and four
unique SHA-256 signing identities. On the connected physical phone,
`scripts/verify-play-installed-android.sh` confirmed package
`org.whoneedhelp.mobile`, version `0.1.0 (1)`, installer Google Play, a member
of the recorded Play App Signing set, verified `whoneedhelp.com` domain
state, and production App Link resolution to `MainActivity`.
- The Play-delivered build completed production Google sign-in without a
secondary ownership email, opened `/requests` through the verified App Link,
registered its Android FCM device after the system notification permission
was granted, received one scoped production FCM notification, and routed a
tap to the in-app notifications inbox. The notification displayed the
privacy-safe localized title and body rather than the internal event copy.
- The exact scoped notification and its two related Oban jobs were deleted
after the delivery proof. A follow-up query found zero remaining
notifications for its unique idempotency key. This cleanup did not delete
any other notification or background job.
- The strict live production readiness replay reported all twelve capability
groups `READY`, zero blocking items, and zero local-only warnings. The
structural production environment validator also passed without printing
secrets. These checks verify configuration and the observed flows above;
they do not establish closed-test completion, public Production-track
approval, or capacity limits.
## Play-delivered physical foreground-location replay
- A run-scoped production fixture linked one synthetic requester and one
synthetic medicine-pickup request to the already authenticated physical
tester. Before starting, the phone reported both fine and coarse location
permissions denied and production reported no active tracking session for
that tester.
- The Play-delivered `0.1.0 (1)` build opened the exact request through the
verified production App Link. Starting sharing displayed the native
prominent disclosure first, then Android's foreground-location permission.
After consent, Android reported the `TrackingService` foreground service and
an ongoing `Sharing live location` notification with a `Stop sharing`
action.
- Production observed three samples and one retained current position while
the app was visible. After Home minimized the app, the same session remained
active, its ongoing notification remained present, and the observed sample
count increased to eleven. No raw coordinate was printed into verification
output or retained in this document.
- Invoking `Stop sharing` from the system notification removed both the
foreground service and ongoing notification. Production then observed one
inactive ended session with forty summary samples and zero retained raw
positions.
- With airplane mode enabled, a cold application start showed the native
offline screen and explicitly stated that private pages are not stored on
the device. Airplane mode was restored to its original disabled state; the
exact authenticated request returned without a new login. A subsequent
application force-stop and verified App Link restart also restored the
authenticated stopped state while leaving both the tracking service and
tracking notification absent.
- Cleanup removed exactly one fixture request, assignment, tracking session,
and synthetic requester. A follow-up production query found zero fixture
users and zero fixture requests while the pre-existing tester and its one
push device remained present. The frozen hackathon test deployment and the
public Git remote were not changed.

View File

@ -0,0 +1,356 @@
defmodule WhoNeedHelp.ProductionPlayPhysicalFixture do
import Ecto.Query
alias Oban.Job
alias WhoNeedHelp.Accounts.{Scope, User, UserToken}
alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message
alias WhoNeedHelp.Notifications.Notification
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
alias WhoNeedHelp.Trust.{AuditEvent, RateLimitBucket, Report, Review}
@allowed_actions ~w(prepare verify-active verify-stopped cleanup)
def run(action, options) when is_binary(action) and is_map(options) do
unless action in @allowed_actions, do: raise("unsupported fixture action")
context = verified_context(action, options)
case action do
"prepare" -> prepare(context)
"verify-active" -> verify_active(context)
"verify-stopped" -> verify_stopped(context)
"cleanup" -> cleanup(context)
end
end
defp verified_context(action, options) do
run_id = required_option!(options, :run_id)
expected_database = required_option!(options, :expected_database)
helper_email = required_option!(options, :helper_email) |> String.downcase()
manifest_path = required_option!(options, :manifest_path)
unless Regex.match?(~r/^[a-z0-9-]+$/, run_id), do: raise("invalid run id")
unless String.starts_with?(manifest_path, "/tmp/wnh-play-physical-") do
raise "invalid manifest path"
end
%Postgrex.Result{rows: [[actual_database]]} =
Repo.query!("SELECT current_database()", [], log: false)
unless actual_database == expected_database, do: raise("database identity mismatch")
%{
action: action,
run_id: run_id,
database: actual_database,
helper_email: helper_email,
requester_email: "wnh-play-physical-#{run_id}-requester@example.invalid",
manifest_path: manifest_path
}
end
defp prepare(context) do
if File.exists?(context.manifest_path), do: raise("manifest already exists")
assert_requester_absent!(context)
helper = Repo.get_by(User, email: context.helper_email)
unless match?(%User{moderation_status: :active, confirmed_at: %DateTime{}}, helper) do
raise "helper is missing, unconfirmed, or inactive"
end
if Repo.exists?(from(s in TrackingSession, where: s.user_id == ^helper.id and s.active)) do
raise "helper already has an active tracking session"
end
now = DateTime.utc_now(:second)
category = Repo.get_by!(Category, slug: "medicine-pickup", active: true)
{:ok, fixture} =
Repo.transaction(fn ->
requester =
%User{}
|> User.registration_changeset(%{
"email" => context.requester_email,
"display_name" => "Play physical smoke requester",
"locale" => "en",
"terms_accepted" => true
})
|> Ecto.Changeset.put_change(:confirmed_at, now)
|> Repo.insert!()
request =
%HelpRequest{requester_id: requester.id}
|> HelpRequest.create_changeset(%{
"title" => "Play physical location smoke #{context.run_id}",
"description" => "Run-scoped synthetic request for foreground location verification.",
"structured_data" => %{"pickup_status" => "reserved"},
"location_label" => "Synthetic Play verification area",
"latitude" => "50.4501",
"longitude" => "30.5234",
"location_radius_meters" => 500,
"urgency" => "now",
"location_visibility" => "exact_for_active_match",
"expires_at" => DateTime.add(now, 2 * 60 * 60, :second),
"category_id" => category.id,
"safety_confirmed" => true
})
|> Ecto.Changeset.put_change(:status, :matched)
|> Repo.insert!()
assignment =
%Assignment{}
|> Assignment.changeset(%{
request_id: request.id,
helper_id: helper.id,
status: :accepted,
accepted_at: now,
handover_code_hash: :crypto.hash(:sha256, context.run_id)
})
|> Repo.insert!()
%{requester: requester, helper: helper, request: request, assignment: assignment}
end)
manifest = %{
"schema_version" => 1,
"run_id" => context.run_id,
"database" => context.database,
"requester" => %{"id" => fixture.requester.id, "email" => fixture.requester.email},
"helper" => %{"id" => fixture.helper.id, "email" => fixture.helper.email},
"request" => %{
"id" => fixture.request.id,
"path" => "/requests/#{fixture.request.id}"
},
"assignment" => %{"id" => fixture.assignment.id}
}
File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
File.chmod!(context.manifest_path, 0o600)
IO.puts("fixture_prepared=true")
IO.puts("request_path=#{manifest["request"]["path"]}")
end
defp verify_active(context) do
fixture = context |> load_and_validate_manifest!() |> validate_fixture_links!()
sessions = fixture_sessions(fixture.assignment.id, fixture.helper.id)
positions = fixture_position_count(sessions)
unless match?([%TrackingSession{active: true, sample_count: n}] when n >= 1, sessions) and
positions == 1 do
raise "expected one active sampled session and one current raw position"
end
IO.puts("active_tracking_verified=true")
IO.puts("sample_count=#{hd(sessions).sample_count}")
IO.puts("retained_position_count=#{positions}")
end
defp verify_stopped(context) do
fixture = context |> load_and_validate_manifest!() |> validate_fixture_links!()
sessions = fixture_sessions(fixture.assignment.id, fixture.helper.id)
positions = fixture_position_count(sessions)
unless match?(
[%TrackingSession{active: false, ended_at: %DateTime{}, sample_count: n}]
when n >= 1,
sessions
) and positions == 0 do
raise "expected one stopped sampled session and zero retained raw positions"
end
IO.puts("stopped_tracking_verified=true")
IO.puts("sample_count=#{hd(sessions).sample_count}")
IO.puts("retained_position_count=#{positions}")
end
defp cleanup(context) do
fixture = context |> load_and_validate_manifest!() |> validate_fixture_links!()
if Repo.exists?(
from(s in TrackingSession,
where:
s.assignment_id == ^fixture.assignment.id and s.user_id == ^fixture.helper.id and
s.active
)
) do
case Tracking.stop_session(Scope.for_user(fixture.helper), fixture.assignment) do
{:ok, _} -> :ok
{:error, reason} -> raise "could not stop fixture tracking: #{inspect(reason)}"
end
end
sessions = fixture_sessions(fixture.assignment.id, fixture.helper.id)
session_ids = Enum.map(sessions, & &1.id)
message_ids = exact_ids(Message, :assignment_id, fixture.assignment.id)
notification_ids =
Notification
|> where(
[n],
fragment("?->>'assignment_id' = ?", n.data, ^fixture.assignment.id) or
fragment("?->>'request_id' = ?", n.data, ^fixture.request.id)
)
|> select([n], n.id)
|> Repo.all()
job_ids = fixture_job_ids(fixture, notification_ids)
target_ids = [fixture.requester.id, fixture.request.id, fixture.assignment.id] ++ message_ids
requester_scope_hash = :crypto.hash(:sha256, fixture.requester.id)
{:ok, deleted} =
Repo.transaction(fn ->
%{
jobs: delete_ids(Job, job_ids),
notifications: delete_ids(Notification, notification_ids),
audit_events: AuditEvent |> where([e], e.target_id in ^target_ids) |> delete_count(),
reports:
Report
|> where(
[r],
r.request_id == ^fixture.request.id or r.assignment_id == ^fixture.assignment.id or
r.message_id in ^message_ids
)
|> delete_count(),
reviews:
Review
|> where([r], r.assignment_id == ^fixture.assignment.id)
|> delete_count(),
positions:
Position
|> where([p], p.tracking_session_id in ^session_ids)
|> delete_count(),
sessions: delete_ids(TrackingSession, session_ids),
messages: delete_ids(Message, message_ids),
assignment: delete_ids(Assignment, [fixture.assignment.id]),
request: delete_ids(HelpRequest, [fixture.request.id]),
requester_tokens:
UserToken
|> where([t], t.user_id == ^fixture.requester.id)
|> delete_count(),
requester_rate_limits:
RateLimitBucket
|> where([b], b.scope_hash == ^requester_scope_hash)
|> delete_count(),
requester: delete_ids(User, [fixture.requester.id])
}
end)
unless deleted.assignment == 1 and deleted.request == 1 and deleted.requester == 1 do
raise "cleanup did not remove the exact fixture"
end
assert_requester_absent!(context)
File.rm!(context.manifest_path)
IO.puts("fixture_cleanup_verified=true")
IO.puts("deleted=#{inspect(deleted, limit: :infinity)}")
end
defp load_and_validate_manifest!(context) do
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
valid? =
manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
manifest["database"] == context.database and
manifest["requester"]["email"] == context.requester_email and
manifest["helper"]["email"] == context.helper_email and
uuid?(manifest["requester"]["id"]) and uuid?(manifest["helper"]["id"]) and
uuid?(manifest["request"]["id"]) and uuid?(manifest["assignment"]["id"]) and
manifest["request"]["path"] == "/requests/#{manifest["request"]["id"]}"
unless valid?, do: raise("manifest does not match this exact run")
manifest
end
defp validate_fixture_links!(manifest) do
requester = Repo.get(User, manifest["requester"]["id"])
helper = Repo.get(User, manifest["helper"]["id"])
request = Repo.get(HelpRequest, manifest["request"]["id"])
assignment = Repo.get(Assignment, manifest["assignment"]["id"])
valid? =
match?(%User{}, requester) and match?(%User{}, helper) and
match?(%HelpRequest{}, request) and match?(%Assignment{}, assignment) and
requester.email == manifest["requester"]["email"] and
helper.email == manifest["helper"]["email"] and request.requester_id == requester.id and
assignment.request_id == request.id and assignment.helper_id == helper.id and
assignment.status in [:accepted, :in_progress] and assignment.active
unless valid?, do: raise("fixture links no longer match the manifest")
if Repo.exists?(
from(r in HelpRequest,
where: r.requester_id == ^requester.id and r.id != ^request.id
)
) do
raise "synthetic requester owns unexpected requests"
end
%{requester: requester, helper: helper, request: request, assignment: assignment}
end
defp fixture_sessions(assignment_id, helper_id) do
TrackingSession
|> where([s], s.assignment_id == ^assignment_id and s.user_id == ^helper_id)
|> order_by([s], asc: s.inserted_at)
|> Repo.all()
end
defp fixture_position_count([]), do: 0
defp fixture_position_count(sessions) do
ids = Enum.map(sessions, & &1.id)
Position |> where([p], p.tracking_session_id in ^ids) |> Repo.aggregate(:count)
end
defp fixture_job_ids(fixture, notification_ids) do
notification_ids = MapSet.new(notification_ids)
Job
|> where(
[job],
job.worker in [
"WhoNeedHelp.Push.DeliveryWorker",
"WhoNeedHelp.Push.NotificationDispatchWorker",
"WhoNeedHelp.Push.DeviceDeliveryWorker",
"WhoNeedHelp.Push.NotificationEmailWorker"
]
)
|> select([job], {job.id, job.args})
|> Repo.all()
|> Enum.filter(fn {_id, args} ->
args["assignment_id"] == fixture.assignment.id or
args["request_id"] == fixture.request.id or
MapSet.member?(notification_ids, args["notification_id"])
end)
|> Enum.map(&elem(&1, 0))
end
defp exact_ids(schema, name, value) do
schema |> where([row], field(row, ^name) == ^value) |> select([row], row.id) |> Repo.all()
end
defp delete_ids(_schema, []), do: 0
defp delete_ids(schema, ids), do: schema |> where([row], row.id in ^ids) |> delete_count()
defp delete_count(query), do: query |> Repo.delete_all() |> elem(0)
defp assert_requester_absent!(context) do
if Repo.exists?(from(u in User, where: u.email == ^context.requester_email)) do
raise "run-scoped requester already exists"
end
end
defp required_option!(options, name) do
case Map.get(options, name) do
value when is_binary(value) and value != "" -> value
_ -> raise "#{name} is required"
end
end
defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value))
defp uuid?(_), do: false
end