59 commits. A shipping run should still cover the whole checklist, but these areas changed and carry the release risk. Everything below has a dedicated section; sections marked NEW did not exist for the last release.
Area What changed Where Google Sign-In Migrated to google_sign_inv7 with a native branded button;firebase_ui_oauth_googleremoved. Fixes theSignInHubActivityNPE crash and the iOS launch-screen hang (init moved off the boot path).§1.3 ID token freshness Token force-refreshed after email verification and after an in-place anonymous→Google link. §1.2, §1.4 Shareable signal links https://link.helpapaw.org/signal/<id>App Links / Universal Links, hosted fallback page (signalLinkfunction), warm-start routing, Android deferred deep link via Play Install Referrer.§15 NEW Background location Native significant-change monitoring (iOS CLLocationManager, AndroidFusedLocationProvider+PendingIntent), boot/app-update re-arm, 6-hour WorkManager reconcile.§7.2 Arrival catch-up NearbySignalCheckernotifies about signals you travel into range of; headless isolate on Android; cross-channel FCM dedupe.§7.3 In-app inbox "My Notifications" page, server-side persistence, drawer unread badge, real iOS app-icon badge count. §6.6 Fan-out efficiency Geohash-bucketed recipient selection instead of scanning every enabled user. §6.7 Security rules Signal/comment creates bound to the caller + content bounds; reporter-only signal edits with a status-only volunteer path; publicProfilesnon-enumerable + name validation; feedback anti-spoof + rate limit; Storage rules for avatars and test-mode photos.§11.5–§11.9 NEW Photo uploads Avatar upload was always denied; test-mode signal photos were denied; a failed upload was reported as success. All three fixed. §1.6, §3.1 Map Info window now survives the recluster after tapping a distant pin. §2.2 Filters "Deselect all signal types" now means none, not all. §4.5 Client-side input caps Title/description/comment/name fields now cap at the rules' limits as you type. §3.1, §3.4, §1.5, §1.6 Account lifecycle Weekly cleanupAnonymousUsersreaper;deleteAccountalso clearsuserCounters+ notifications.§1.8, §16.4
help-a-paw-devis production. Verified against the live project, not assumed:
cd firestore-tests && npm test— 91/91 pass, Firestore + Storage (§16.1)- Firestore rules deployed and current — the live ruleset contains every new construct:
allow list: if falseonpublicProfiles, theusers/{uid}/notificationsanduserCountersblocks,isStatusOnlyUpdate, and the feedbackuserIdpin- Indexes deployed — all 8, including
notifications (testMode ASC, createdAt DESC)and(status, location.geohash, createdAt)on bothsignalsandsignals_test- Storage rules deployed and current —
profile_photosblock present (avatars were previously default-denied), andisSignalReportercheckssignalsandsignals_test- Functions deployed — all 13 present incl.
signalLink,cleanupAnonymousUsers,deleteAccount, and the test-collection triggers- Hosting live —
assetlinks.jsonandapple-app-site-associationboth200 application/json;/signal/**rewrite serves; anfbclidquery301s to the canonical URL (cache-fragmentation guard working)- TTL policy ACTIVE on
notifications.expiresAt— confirmed viagcloud firestore fields ttls list- App Links verified on-device — Play build 126 signature matches the published fingerprint;
pm get-app-linksreportslink.helpapaw.org: verified(§15.1)- Version bumped to
6.0.2+126; both Android devices confirmed running it from Play, iOS via TestFlightNothing further needs deploying. Remaining pre-test setup is device/account state — see §16.0.
- Account deletion implemented. In-app deletion (Profile → "Delete Account") via the
deleteAccountCloud Function: anonymizes authored signals (strips phone numbers), tombstones the user doc as "Deleted user", deletes the notifications subcollection +userCounters+ profile photo, then deletes the Auth user. See §1.8.- "My Notifications" page — BUILT 2026-08-04. Cloud Functions persist a document per push, the rules grant the owner access, a drawer entry with an unread badge reaches the page, and rows render localized. See §6.6. The iOS badge now sends a real count (it previously always sent
1) — see the §6.6 watch-out about sticky badges on older builds.- iOS background relaunch is UNVERIFIED. The whole premise of §7.2 — iOS waking a terminated app on a significant location change — has never been observed on a device. It cannot be tested on a debug build. See §7.2 and
BACKGROUND_LOCATION_PENDING_TESTS.md§1.- M-1 is only half done. Firestore rules still allow anonymous users to create signals/comments; the server-side
email_verifiedgate is held back until the token-refresh fix (633da3b) ships in a released build. Client-side guards are the only thing enforcing it today. Tracking:HelpAPaw/Flutter#67. See §11.5.- Edit Signal & Delete Signal flows exist in code (author-only AppBar actions on Signal Details) but were previously untested. Covered in §3.6 and §3.7.
- Light theme only. No dark theme is defined; verify rendering under OS dark mode. See §14.1.
- Debug assetlinks fingerprint is still published.
hosting/public/.well-known/assetlinks.jsonlists a machine-local debug keystore alongside the release one. Decide whether to strip it before the release deploy (§15.1).
- App auto-creates an anonymous Firebase account on first launch
- Anonymous user can view the map, browse signals, and see signal details
- Anonymous user cannot create signals, change status, or post comments (requires authenticated account)
- Anonymous user can access Notification Settings (anonymous account created automatically if needed)
- Anonymous user sees "Sign In" in the navigation drawer
- User can register with email and password from Sign In page
- After registration, a verification email is sent automatically
- User is redirected to
/verify_emailscreen - Verification screen polls every 3 seconds for email verification status
- "Resend Email" button works with a 60-second cooldown
- Manual "Check Verification" button works
- On successful verification, user is redirected to Profile Completion
- Skip button available (in debug builds) to bypass verification
- ID token is force-refreshed on verification (
getIdToken(true)): immediately after the screen advances, the user can create a signal and post a comment with noPERMISSION_DENIED— do not wait an hour, and do not sign out and back in first
The
firebase_ui_oauth_googleprovider button is gone. The Google button is now asign_in_buttonin the auth screen'sfooterBuilder, callingGoogleSignIn.instance.authenticate()directly. This is the fix for theSignInHubActivityNPE (Crashlyticsadd2187e) and for the iOS launch-screen hang.
- "Sign in with Google" button appears below the email/password form, with Google branding and the localized label
- Button is hidden on platforms where
supportsAuthenticate()is false (should be visible on both iOS and Android) - No
SignInHubActivitycrash on Android — run the flow repeatedly (5+ times), including immediately after a cold start; check Crashlytics foradd2187erecurrence on the new build - No launch-screen hang on iOS — cold launch reaches the map; the Google init runs in the background and never blocks the first frame
- Tapping while the flow is in progress does nothing (re-entrancy guard); a spinner replaces the button
- Cancelling the Google account chooser shows no error snackbar (cancel is not an error)
- A genuine failure shows the "Google sign-in failed" snackbar (localized) and leaves the user signed in as before
- Google OAuth users skip email verification (auto-verified)
- Google display name pre-fills Profile Completion form
- Works on iOS with the platform-specific client ID; Android derives its client from
google-services.json - Sign-out clears the cached Google session: after Sign Out, tapping Google again shows the account chooser rather than silently reusing the last account
- Signing in with Google on a fresh (never-signed-in) account routes to Profile Completion
- Signing in with Google on an existing account routes straight into the app
- When an anonymous user signs in, the anonymous account is linked to the new credential (same UID preserved)
- Anonymous → Google is an in-place link (
linkWithCredential): same UID, signals/prefs/subscriptions/FCM token all retained, no orphaned anonymous doc - Anonymous → an already-used Google account surfaces
credential-already-in-useand merges into the existing account (FCM tokens merged) rather than erroring out - Data (tokens, notification preferences, signal subscriptions) transfers correctly on account upgrade
- ID token is force-refreshed after the in-place link: immediately after linking, creating a signal/comment succeeds with no
PERMISSION_DENIED(the anonymous-minted token'semail_verified: falseclaim would otherwise persist ~1h) - Live location is not transferred (it lives in
userLocations/{uid}and self-heals on the next GPS update); the anonymous account'suserLocationsdoc is cleaned up - In-app inbox entries written while anonymous stay with the same UID after an in-place link (§6.6)
- After first sign-in, user sees Profile Completion screen
- Full Name field is required (min 2 characters)
- Phone Number field is optional (min 8 characters if provided)
- Name pre-fills from Google OAuth or existing display name
- "Skip" option saves
profileCompleted: truebut skips data entry - Successful completion navigates back through auth stack to original screen
- Name field is capped at 100 characters as you type and rejects newlines (single-line formatter) — mirrors the
publicProfilesrules, so no opaquePERMISSION_DENIED - A whitespace-only or empty name never produces a
publicProfileswrite (it is a no-op, not an error) - A name containing an emoji at the 100-char boundary is truncated cleanly (no half-emoji / mojibake)
- Shows avatar (or fallback icon), display name, email, phone
- Shows email verification status ("Yes"/"No") with "Verify" button if unverified
- Shows "Member Since" date
- Shows statistics: total signals created, total comments made
- Edit mode toggle via AppBar button
- Can edit display name and phone number
- Name field capped at 100 chars, single-line (same as §1.5)
- Avatar upload actually succeeds — this was previously denied by default-deny in
storage.rulesfor every user, with an opaque error. Upload from camera and from gallery, on a fresh account and an existing one. - Photo resized to 512x512 at 80% quality, uploaded with an explicit
image/jpegcontent type, stored atprofile_photos/{uid}.jpg - Uploaded avatar renders immediately, survives an app restart, and appears in the drawer header
- Re-uploading replaces the previous avatar (same path)
- Unauthenticated user sees "Please sign in" message with Sign In button
- Sign Out button visible in drawer for authenticated users
- Sign out removes device FCM token from Firestore
- User returns to home screen as anonymous after sign-out
- Drawer updates to show "Sign In" instead of profile info
- "Delete Account" action present in Profile (Danger Zone, red), authenticated users only
- Confirmation dialog warns the action is permanent and explains signals are kept but unlinked
- Cancel aborts with no changes
- On confirm:
deleteAccountCloud Function runs, then the app signs out and returns to a fresh anonymous state at home - Auth record is deleted (user can no longer sign in with the same credentials)
- User doc is tombstoned: name shows "Deleted user", PII removed (fcmTokens, notificationPreferences, signalSubscriptions, phone, photo)
- Authored signals remain on the map but show "Deleted user" as reporter and have phone numbers stripped (
contactPhone/phoneNumberempty) - Comments by the user resolve to "Deleted user"
-
users/{uid}/notificationssubcollection removed; profile photo deleted from Storage -
userCounters/{uid}(the unread/badge counter) is removed -
publicProfiles/{uid}is tombstoned so the name resolves to "Deleted user" everywhere, and the tombstone cannot be cleared by a client (rules restrict client writes toname) - Stored live location removed:
userLocations/{uid}document is deleted (location PII no longer lives on the user doc) - Background location monitoring stops on that device (no further
userLocationswrites after deletion) - Device no longer receives push notifications meant for the deleted account
- Works for email/password, Google, and anonymous accounts
- Multi-device: deletion removes the account globally (other signed-in devices lose access)
- Error path: function failure shows an error snackbar and leaves the user signed in
- Google Maps loads on app start
- Map initially centers on user's location (if permission granted)
- Falls back to Sofia, Bulgaria (42.6977, 23.3219) if no location permission
- "My Location" blue dot appears when location permission granted
- Zoom controls work (pinch, buttons)
- Map pan/scroll works smoothly
- Signal markers appear on the map based on geo-query (100km radius from center)
- Marker colors correspond to status: Red (Help Needed), Orange (On The Way), Green (Solved)
- Marker clustering works for nearby signals
- Tapping a marker shows an info window (title + description snippet)
- Tapping the info window navigates to Signal Details screen
- Tapping the map dismisses open info windows
Distant-pin info window (regression fix — recurring bug)
Tapping a far pin makes the SDK auto-pan to centre it; that pan crosses the re-query threshold, the marker set rebuilds, the native
ClusterManagerreclusters, and the open InfoWindow is torn down. The fix re-asserts the window (immediately + a 350 ms follow-up).
- Tap a pin near the edge of the visible map (far enough that the map auto-pans): the info window stays open after the pan settles
- The re-asserted window is still tappable and navigates to the correct signal
- No re-query loop / flicker: the map settles and does not keep panning or repeatedly rebuilding markers
- Near-pin taps are unaffected (window opens once, no double-show flicker)
- Changing a filter while an info window is open re-asserts the window if the signal still matches, and dismisses it cleanly if it no longer does
- A signal deleted by another device while its window is open dismisses cleanly — no
PlatformException: Invalid markerId, and the ghost tap target does not navigate (§10.2)
- New signals from other users appear on the map in real time (Firestore stream)
- Status changes to signals update marker color in real time
- Stream updates when map center changes significantly or filter changes
- FAB (add button) opens the new signal form
- GPS crosshair appears at map center to indicate signal location
- Title field is required (text capitalization enabled)
- Description field is required (text capitalization enabled)
- Phone Number field is optional (numeric keyboard)
- Signal Type dropdown with 7 options: Emergency, Lost/Found, Blood Donation, Homeless, Unneutered Animals, Wild Animals, Other
- Photo attachment: camera icon opens bottom sheet with "Take Photo" / "Choose from Gallery"
- Photo preview shows with delete (X) button
- Images compressed to max 1920x1920 at 85% quality
- Only 1 photo can be attached during creation
- Submit button disabled if title or description empty
- Loading state shown during submission
- On success: form clears, map centers on new signal, info window auto-shows
- Creator is automatically subscribed to the signal
- Requires authenticated (non-anonymous) user — shows sign-in prompt if anonymous
- Signal location uses map center coordinates at time of submission
Input bounds (mirror the Firestore rules — new)
- Title stops accepting input at 300 characters; the write is never rejected with
PERMISSION_DENIED - Description stops accepting input at 10 000 characters
- Paste of an over-long string is truncated rather than rejected
Photo upload failure reporting (regression fix — new)
uploadSignalImagereports failure by returning a result, not by throwing. That return was previously ignored, so a denied upload fell through to full success and the photo was silently dropped — which is exactly how the Storage rule denying every test-mode upload went unnoticed.
- Test-mode signal photos now upload successfully (this was denied for every test-mode signal before the Storage rules fix)
- Photos are uploaded with an explicit
image/jpegcontent type - Partial success state: signal created but photo upload failed → the warning is actually shown, the form clears, and the signal still opens
- Force a denial (e.g. sign out mid-upload / airplane mode at the right moment) and confirm the warning appears rather than a silent success
- The failure is logged to Crashlytics ("Created without photo (upload failed)")
- An oversized image (>5 MB after re-encode) is rejected with a message, not a silent drop
- Displays: title, description, signal type name, creation date/time, reporter name, contact phone
- Photo carousel: swipeable PageView with photo counter (e.g., "1/5")
- Tap photo for full-screen gallery (PhotoView)
- Creator can delete individual photos (X button in corner)
- Creator can add photos (up to 5 total): "Add Photo" / "Add Another Photo" page appears
- Navigate button: opens external navigation app (geo: URI or Google Maps fallback)
- Call button: initiates phone call to signal's contact phone
- Share button: shares signal type, description, a
https://link.helpapaw.org/signal/<id>link and the location via the native share sheet - The signal link comes before the Google Maps link in the shared text — link-preview scrapers unfurl the first URL, and putting maps first made shares preview as a map pin instead of the animal's photo (§15.3)
- Sharing works for a signal with no coordinates (the location line is simply omitted)
- The shared link round-trips: sending it to another device opens that signal (§15.2)
- Navigating from this signal to another (e.g. via a shared link) shows the new signal's data — photos, comments and reporter must not be carried over from the previous one
- Status dropdown visible on details screen (authenticated users only)
- 3 options with colored icons: Help Needed (red), Somebody On The Way (orange), Solved (green)
- Changing status updates the Firestore document
- Status change logs a special comment (orange background) showing "X changed status to Y"
- Status change records
lastUpdatedByuser reference - User auto-subscribed to signal on status change
- Cannot change to same status (no-op)
- Text input field at bottom of details screen with send button
- Comments display: author name, text content, timestamp
- Regular comments have white border and left padding
- Status-change comments have orange background/border with status icon
- Comments ordered by
createdAtascending - Real-time updates via StreamBuilder
- Posting a comment auto-subscribes user to signal
- Requires authenticated user
- Comment input stops at 2000 characters (matches the rules' cap)
- A whitespace-only comment is dropped client-side — send button does nothing, no
PERMISSION_DENIED, no blank comment row - Leading/trailing whitespace is trimmed before the write
- A status-change system comment (which carries no
text) is still created successfully — the rules' text bounds must not reject it (§3.3)
- Lists all signals created by current user
- Ordered by creation date (newest first)
- Card-based layout with status color indicator and signal type icon
- Tap card navigates to Signal Details
- Requires authentication — shows sign-in prompt if not logged in
- Empty state message when no signals exist
- Edit (pencil) icon appears in Signal Details AppBar only for the signal's creator
- Edit icon is hidden for non-authors and anonymous users
- Tapping it opens the Edit Signal screen (
/edit_signal/:signalId) - Editable fields: Title (required), Description (required), Signal Type (dropdown), Contact Phone (optional, phone keyboard)
- Title/Description validation prevents saving when empty
- Non-author opening the route directly is bounced back (guarded by reporter check)
- Save shows loading state, success snackbar, and returns to details
- Save failure shows an error snackbar and stays on the edit screen
- Photos are not edited here (managed on the Details screen — see §3.2)
- Edited fields reflect immediately on the Details screen and on the map marker
- Delete (trash) icon appears in Signal Details AppBar only for the signal's creator
- Tapping it shows a confirmation dialog before deleting
- Cancel aborts; confirm deletes the signal document
- After deletion, user is navigated away from the (now-gone) details screen
- Deleted signal's marker disappears on the map for all users (real-time)
- Verify cascade behavior: comments subcollection, Storage photos, and subscriber records are cleaned up (or document expected orphaning)
- Filter icon in AppBar opens filter bottom sheet
- Red dot indicator on filter icon when non-default filters are active
- Last 24 hours
- Last 7 days
- Last 30 days (default)
- All time
- Toggle: Help Needed (status 0)
- Toggle: Somebody On The Way (status 1)
- Toggle: Solved (status 2)
- All enabled by default
- Toggle each of 7 types independently: Emergency, Lost/Found, Blood Donation, Homeless, Unneutered Animals, Wild Animals, Other
- All enabled by default
- "Select All" button enables all statuses and types
- "Clear All" button hides all signals
- "Deselect all signal types" deselects every type (it previously selected all of them — inverted behaviour, fixed in
814af47). After tapping it, zero signals render on the map. - Re-selecting a single type after a deselect-all shows only that type
- Filter changes update markers on map in real time
- An open info window is reconciled correctly when a filter change hides its signal (§2.2)
- Hospital icon in AppBar toggles vet clinic mode on/off
- Active state indicated by darker background on toggle
- Vet clinics shown as blue hospital pin markers
- "Search This Area" button appears when map panned >2km or zoom changes >2 levels
- Button auto-hides after search and has 1-second debounce
- Search radius calculated from zoom level (formula-based, 1-100km)
- Results accumulated across searches (keyed by Place ID, no duplicates)
- Maximum 20 results per search
- Shows: name (large/bold), rating with star, address, phone, opening hours
- "View Reviews" link to Google Maps reviews
- "Call" button to phone clinic
- Google Maps and directions buttons
- Error state with "return to map" button if data fails
- Calls the
searchVetClinicscallable (onCall) function, invoked over HTTPS POST to thecloudfunctions.netURL (clinic details usegetVetClinicDetails) - Requires Firebase Auth token and App Check token (verify the onCall envelope is accepted)
- 15-second timeout on requests
- Rate limit handling (429 → "Too many searches" message)
- Server-side cache: repeating a search of the same area (same ~5km cell + radius) returns results without a new Places API call — served from
vetClinicCache(check function logs for "cache hit") - Details cache: opening the same clinic's details twice calls the Places details API once, then serves from
vetClinicDetails - Cached entries refresh after the 30-day TTL (stale entries trigger a fresh Places call)
- Results are unchanged from the user's perspective whether served fresh or cached
- Modal bottom sheet on first app launch explaining location tracking benefits
- "Enable" button triggers permission request flow
- "Maybe Later" dismisses but shows persistent button on map
- Completing onboarding hides all prompts permanently
Cloud Functions emit FCM messages with
typevalues:new_signal,status_change,new_comment(functions/src/index.ts). Each is also persisted to the recipient's in-app inbox (see §6.6).
- New signal nearby (
new_signal): Received when a signal is created within the user's configured radius/region and matches their type preferences - Status change (
status_change): Received when a subscribed signal's status changes - New comment (
new_comment): Received when a comment is posted on a subscribed signal (body truncated to 50 chars)
- Foreground: Shows local notification (high priority Android, sound+badge iOS)
- Background: Device processes push notification
- Terminated:
getInitialMessage()checks for notification on app launch - Tapping any notification deep-links to signal details (
/signal_details/:signalId) via the sharedSignalNavigatorseam - After dismissing details, map focuses on that signal with info window shown
- Regression — tap handling was previously clobbered. The local-notification plugin is a singleton and every
initialize()re-registers the tap callback; a second caller passing no callback silently disabled tap handling app-wide. Verify taps work on both an FCM notification and a catch-up (local) notification, in foreground, background and terminated states. - Launching the app from a local (catch-up) notification while terminated opens that signal
- The background FCM handler runs even when the app is terminated (it records the signal for dedupe — §7.3)
- Toggle notifications enable/disable
- Notification radius slider (1-50 km, default 10 km)
- Signal type filter checkboxes (7 types)
- Region of interest: map-based selection with adjustable radius
- Settings persist in Firestore
notificationPreferences - Preferences are read through the typed
NotificationPreferencesmodel — a user doc with missing or malformed preference fields falls back to defaults rather than throwing (covered bytest/models/notification_preferences_test.dart)
Location tracking toggle — three distinct outcomes (rewritten)
The toggle now reports what the native monitors actually started, not what the permission enum implies. Enum-derived guesses disagreed with reality on Android 9 and when "Always" was granted out-of-band via Settings.
- Permission denied → red/neutral snackbar "location permission required", toggle returns to off, nothing starts
- "While Using the App" granted → tracking is enabled, and a 6-second snackbar explains that background monitoring needs "Always" (
locationAlwaysPermissionRequired). Confirm the wording is localized in Bulgarian too. - "Always" granted → enabled with no snackbar; native background monitoring starts (verify the
BackgroundLocation: monitoring startedlog) - Granting "Always" out-of-band (OS Settings, app never re-toggled) and relaunching: tracking restores to full background mode, not foreground-only
- The permission dialog appears only from this user-initiated toggle — never during app startup (§7.1)
- Turning the toggle off stops both foreground and background tracking, clears the native enabled flag, and deletes
userLocations/{uid}(§7.2)
- Token saved to Firestore on registration (
arrayUnionfor multi-device) - Token removed on sign-out (
arrayRemove) -
tokenLastSavedtimestamp tracked - Cloud Function deduplicates tokens across user documents (prevents orphaned anonymous accounts from receiving notifications)
- Token refresh handled automatically via
onTokenRefreshlistener - Retry logic with exponential backoff (3 retries, up to 30 seconds)
- Moving the device (location updates every 500m) writes to
userLocations/{uid}and does not rewrite the user doc — confirmonUserTokensWrittenis not invoked by location updates (check function logs/invocation count) - Token dedup (
onUserTokensWritten) still fires on genuine token registration and removes the token from other user docs
Built 2026-08-04. Canonical
typevocabulary is nownew_signal/status_change/new_comment/nearby_signal— the page moved to match the wire, sincedata.typeis a contract with every installed build.Deploy order matters (see
docs/SPECIFICATION.md§7.13):
firestore.rules+firestore.indexes.json— the inbox query needs thenotificationscomposite index (testMode ASC, createdAt DESC) and is dead without it.functions— starts persisting entries; push payload otherwise unchanged.- TTL policy (
gcloud firestore fields ttls update expiresAt --collection-group=notifications) — not carried byfirebase deploy.- App release.
iOS badge sends a real count (
badge: NfromuserCounters).⚠️ Accepted consequence: iOS badges are sticky, so on builds predating this release the number climbs and never clears. Expect that when testing against an older build.
Entry point & list
- Drawer shows a "Notifications" item (bell icon, between "My Signals" and "Notification Settings") — visible to anonymous users too
- The icon carries an unread count badge that clears as items are read
- List shows newest-first, max 50, with icon/color per type
- Unread items are bold with an orange dot; tapping marks read and deep-links to the signal
- Swipe-to-delete removes a single notification; a failed delete now shows an error (previously silent)
- "Mark all as read" and "Clear all" work, including with more than 50 notifications
- Empty state and unauthenticated ("please sign in") states render correctly
Server persistence
- Creating a signal writes a
new_signalentry to every nearby recipient's inbox - A status change writes
status_changeto subscribers; a comment writesnew_comment - The actor (reporter / status-changer / comment author) gets no entry for their own action
- A user with notifications disabled or no FCM token still receives inbox entries (the recipient split — this is the main reason the inbox exists)
- Entries are not duplicated if a trigger retries (deterministic ids
sig_/st_/cmt_/nb_)
Localization
- On a Bulgarian device, rows render in Bulgarian — signal type name, status label and all titles — even though the push text stored on the document is English
- An unknown/legacy
typefalls back to the storedtitle/bodyrather than rendering blank
Arrival catch-up
- Travelling into range of an existing signal produces both the local notification and a
nearby_signalinbox entry (Android: written from the headless isolate) - A signal the server already pushed does not also produce a
nearby_signalentry (dedupe inherited fromNotifiedSignalsStore)
Test-mode isolation
- Entries created in test mode do not appear in the production inbox, and vice versa
- Turning test mode off and back on shows the right set each time
Account lifecycle
-
deleteAccountremoves the notifications subcollection anduserCounters/{uid} - The scheduled anonymous cleanup does the same for reaped anonymous users
Entry point details (new)
- The drawer's unread badge is driven by a cached Firestore stream — opening/closing the drawer and selecting other tiles repeatedly must not open a new listener each time (watch read counts in the Firebase console during ~10 drawer opens)
- Switching accounts (sign out → sign in as someone else) resets the badge to the new user's count, not the previous user's
- Badge count is capped at the 50-item page size and clears to hidden at zero
iOS badge
Sent as a real
badge: NfromuserCounters, applied by the OS. The app clears/repairs it via a nativeorg.helpapaw.helpapaw/app_badgechannel on resume and at the end of startup (after sign-in — not ininitState, where there was no uid and the call was a silent no-op).
- Badge count matches the unread count (not stuck at 1)
- Opening the app zeroes the badge and repairs the stored counter (
userCounters/{uid}.unread) - Receiving several notifications while backgrounded increments the badge correctly
- Resuming from background (not a cold launch) also reconciles the badge
- A cold launch on a signed-out/anonymous install does not throw — badge sync runs after the anonymous sign-in completes
- Android: the badge call is a harmless no-op (
MissingPluginExceptionbranch); the launcher badge follows the notification shade -
⚠️ Expected, not a bug: against builds predating this release, the iOS badge climbs and never clears (old builds always sent1and have no clear path)
handleSignalCreatednow selects recipients via geohash range queries (geofire-common) over theuserLocationscollection (live-location path) andusersregion-of-interest geohash (region path), instead of scanning every enabled user. Bounded byMAX_LOCATION_RADIUS_KM=50/MAX_REGION_RADIUS_KM=100. The precise per-user radius is still enforced by a Haversine check. SeeCOST_ANALYSIS.md.
- Location path: a user with location tracking on, within their
locationRadiusKmof a new signal, receives thenew_signalpush - Region path: a user whose region of interest covers the new signal receives the push (even with no live location tracking)
- Just-out-of-range: a user just beyond both their location radius and region radius does not receive the push (Haversine boundary, not just the geohash bucket)
- Far-away user (different city/country, beyond the 50/100 km query bounds) is excluded — no notification and no wasted read
- Signal type filter still applies (notified only for subscribed signal types)
- Signal reporter is not notified about their own signal
- Users with notifications enabled but no live location and no region of interest receive no
new_signalpushes (nothing to match on) - Test-mode isolation still holds:
testModeusers only receivesignals_testnotifications, prod users onlysignals - Multiple recipients in one area all receive the push (union of location + region paths, deduped by uid — a user matching both paths is notified once)
- Geohash compatibility: a known-near user stored by the client (
geoflutterfire_plus) is matched by the server'sgeofire-commonbounds (precision mismatch does not cause misses)
Thresholds that govern every test in this section. Getting these wrong is the most common reason a "failure" here isn't real:
What Threshold Source Location write (foreground + Android displacement filter) 500 m distanceFilterMeters/MIN_DISPLACEMENT_METERSCatch-up check gate >3 km moved AND ≥30 min NearbySignalChecker.minDisplacementKm/minIntervalSignal eligibility for catch-up unsolved (status 0/1), created within 7 days eligibilityWindowNotification radius user preference, default 10 km notificationPreferences.locationRadiusKm500 m writes a location but runs no signal check. You need a >3 km trip. Full procedures, device state and setup scripts:
BACKGROUND_LOCATION_PENDING_TESTS.md.
- Checks location services are enabled
- Requests "While In Use" permission first
- Can upgrade to "Always" (background) from the Notification Settings toggle only
- Handles denied / permanently denied states gracefully
- Startup NEVER prompts for location permission. With tracking enabled but permission revoked in OS Settings, a cold launch logs
Location tracking is enabled but permission is …; not restoringand shows no dialog. - Denying location still leaves the map usable (falls back to Sofia — §2.1)
geolocatorcannot do background significant-change on either platform, so the background path is native and runs alongside the foreground stream. iOS:CLLocationManager.startMonitoringSignificantLocationChanges, and the Firestore write happens in Dart (a nativeFirestore.firestore()call aborts the app at launch). Android:FusedLocationProviderClient→PendingIntent→LocationUpdateReceiver, which writes Firestore natively (no Flutter engine in that process) and deliberately runs with no foreground service.
Core behaviour (both platforms)
- Medium accuracy, 500 m distance filter
- Writes
userLocations/{uid}withgeopointand a precision-9geohash(kept off the user doc so location writes don't triggeronUserTokensWritten) - Confirm in function logs that a location update does not invoke
onUserTokensWritten(§6.5) - Tracking auto-restores on every app launch when the preference is on — this was dead code before; a user who enabled tracking previously stopped reporting after the next restart
- Turning tracking off: foreground stream cancelled, native monitor stopped, native enabled flag cleared (so nothing re-arms on the next launch or reboot),
userLocations/{uid}deleted,locationTrackingEnabled = false - With tracking OFF but location permission granted, backgrounding/foregrounding the app several times produces no
userLocations/{uid}write (the lifecycle observer is registered only while tracking is on) - Resuming the app with tracking on takes a fresh fix and runs a catch-up check (the geolocator stream only fires on movement)
Android specifics
- No persistent notification in the shade while tracking is on
- Location is written with the app force-stopped/swiped away (native write, no Dart engine in the process)
- Re-arm after app update:
adb install -r→ logcat showsre-armed location updates after android.intent.action.MY_PACKAGE_REPLACED - Re-arm after a real reboot
⚠️ not yet verified: power-cycle the phone, do not open the app, confirmre-armed location updates after android.intent.action.BOOT_COMPLETED - WorkManager reconcile (
LocationReconcileWorker, 6-hour period,KEEPpolicy): fires on schedule and re-arms registrations.⚠️ only its first immediate run has been observed — confirm a later scheduled run, ideally on an OEM with an aggressive battery manager (Samsung/Xiaomi/Huawei). - Reconcile cancels itself once tracking is disabled (no rescheduling loop)
- Native state is read from native-owned prefs, never from
shared_preferences(a Dart double is stored there as a prefixed string) — verify by flipping settings and confirming native logs agree
iOS specifics
- Requires Always authorization. With "While Using",
start()returns false, the UI explains why (§6.4), and nothing is silently broken. -
BackgroundLocation: monitoring startedappears inios syslogafter enabling with Always -
⚠️ UNVERIFIED — the headline test. Terminate the app (swipe away), travel a few km between distinct Wi-Fi/cell environments, and confirmBackgroundLocation: relaunched by a significant location changeinios syslog.monitoring startedalone is not a pass (it also appears on a normal foreground launch).- Must be a profile/release build — a debug build cannot be relaunched by iOS at all (
ptrace(PT_TRACE_ME)) ios setlocation(go-ios) silently does nothing on this setup — do not trust it
- Must be a profile/release build — a debug build cannot be relaunched by iOS at all (
- No launch crash. The iOS Firestore write is done in Dart, not Swift. Verify the app launches cleanly (no SIGABRT) on a profile/release build with background location enabled — this is the
6066a46fix and it has never shipped. - Buffered-update path: seeing neither
buffering update until Dart is readynorreplaying update buffered before Dart was readyis the expected result (the handler is installed inmain()beforerunApp); seeing them is also a pass and proves the buffer works.
Geohash parity (silent-failure guard)
- Dart (
geoflutterfire_plus) and Kotlin encoders produce byte-identical base32 precision-9 hashes — a drift silently stops the fan-out's range query from matching users, with no error anywhere -
android/app/src/test/.../GeohashTest.ktpasses -
ios/RunnerTests/GeohashTest.swiftpasses (orGeohash.swift+ its test are deleted together — the Swift encoder is no longer used in production) -
⚠️ Running the Swift tests poisonsbuild/native_assets/ios/objective_c.frameworkfor device builds — check withvtool -show-build-version …(platform IOS, notIOSSIMULATOR) or budget a fullflutter clean
NearbySignalCheckercovers people who travel into range after a signal was created (the server fan-out only reaches people near it at creation time). Runs headless on Android (HeadlessNearbyCheckboots a Flutter engine), in the normal isolate on iOS, and on app resume on both.
Setup for every test here (see BACKGROUND_LOCATION_PENDING_TESTS.md §4)
- Plant the signal near the destination, before setting off
- Reporter must be a different account (own signals are excluded)
- Status must be unsolved (0 or 1)
- Device in test mode so writes go to
signals_test - Write a correctly computed precision-9 geohash — never hand-written
Behaviour
- Travelling >3 km into range of a qualifying signal produces a local notification
- Signals older than the 7-day eligibility window are ignored (note: this is not 24 hours)
- Resolved (status 2) signals are filtered out — pushed into the query via
whereInon open status codes - The user's own signals are excluded
- Signal type preferences are honoured; a signal with no stored type still gets through (the filter exists to honour opt-outs, not to reject malformed data)
-
strictModeclipping: a signal inside the geohash rectangle but outside the real radius does not notify - 2–3 qualifying signals produce separate notifications, each with its own tap target opening its own signal — grouped in the shade under one group key, not collapsed into one
- Tapping a catch-up notification opens the correct signal, and backing out focuses that pin on the map
Gate & dedupe
- Moving 500 m writes the location but runs no check (below the 3 km gate)
- Two checks within 30 minutes run only once, regardless of distance
- Permanent per-signal dedupe: a daily commuter is notified about a given signal exactly once, including across a force-quit / process death
- Cross-channel dedupe (highest-value test). A signal delivered as a real FCM push must never later produce a duplicate local catch-up notification. Run it the way it actually happens: background or terminate the app before the push lands, and do not tap the notification — swipe it away, then move away and back.
- A data-only push (no
notificationblock) must not suppress a later catch-up — it displays nothing, so it must not count as announced - Android: a headless check's dedupe records are not erased by the next foreground push (cross-isolate write safety)
- Concurrent checks are coalesced — a resume that coincides with a position delivery announces each signal once, not twice
- Android: the native pre-filter gate is mirrored from Dart, so fewer headless engine boots is the expected new behaviour, not a regression
Test-mode isolation in the background
- Logs show
NearbySignalChecker: querying signals_test …when test mode is on, andsignalswhen off — the only way to confirm isolation held in a headless isolate - Flipping test mode no longer requires clearing app data on Android: the native gate is keyed by mode, so a flip lands on a clean gate and flipping back restores the real one
- Flipping test mode pushes the new mode to native immediately (
syncTestMode), and re-pushes on every launch so a failed push self-heals
Failure visibility
- A failing geo query (missing composite index, Firestore settings conflict) is reported to Crashlytics rather than silently reading as "no signals nearby"
-
test/firestore_settings_guard_test.dartpasses — it fails the build if any Dart code assigns Firestore.settings, which would silently break the Android headless isolate's query
Actual order in
home_route_drawer.dart: header logo → Profile/Sign In → Sign Out (immediately under Profile, authenticated only) → My Signals → Notifications → Notification Settings → FAQs → Feedback → Privacy Policy → Our Site → About → Share App.
- Profile (authenticated) / Sign In (anonymous)
- Sign Out (authenticated only) — appears directly beneath the Profile tile at the top, not at the bottom
- My Signals — user's created signals
- Notifications — in-app inbox, with an unread count badge on the icon (§6.6)
- Notification Settings — full notification configuration
- FAQs — 5 sections, 17 questions with answers
- Feedback — type dropdown, message, optional email, optional device info
- Privacy Policy — in-app + external website link
- Our Site — opens website (shows a launch status message)
- About — version, build, logo, description, links (Website, Facebook, GitHub), Open Source Licenses
- Share App — shares iOS App Store + Android Play Store links + website
- Shows app version and build number (from
package_info_plus) - Logo and description
- Links: Website, Facebook, GitHub
- Open Source Licenses button
- Copyright year
- Type dropdown: General, Bug, Feature, Other
- Message field (required)
- Optional email
- Optional device info checkbox (platform, OS version, app version, build)
- Submitted via Cloud Function that sends SMTP email
- Malformed email is caught client-side with "Please enter a valid email address" (localized) — no write is attempted
- A valid email is accepted and arrives as the email's
replyTo - Empty email is still allowed (the field is optional)
- Message longer than 1000 characters is rejected by the rules — confirm the user sees a failure rather than a silent drop
- Rate limit: submitting more than 5 feedback items within an hour from one account still writes the documents but suppresses further emails; the function logs
rate-limited; email suppressed - Rate limit resets after the 1-hour window
- Anti-spoof: a submission cannot claim another user's
userId(rules pin it torequest.auth.uid) — see §11.8 - Unauthenticated writes are rejected (the app always has at least an anonymous session)
- HTML injection: submit a message containing
<script>alert(1)</script>and<b>x</b>— the received email renders them as literal text, not markup
- English (en) fully translated
- Bulgarian (bg) fully translated (key parity with
enconfirmed; remaining diffs are ICU placeholder names, not missing strings) - All UI strings use
AppLocalizations(no hardcoded strings) - Date/time formatting is locale-aware
- Signal type names, status labels, error messages, FAQ content all localized
New strings this release — verify in Bulgarian on a bg device
-
signInWithGoogle,googleSignInFailed(§1.3) -
pleaseEnterValidEmail(§8.3) -
locationAlwaysPermissionRequired(§6.4) — long string, check it doesn't overflow the snackbar -
signalNearbyNotificationTitle— the catch-up notification title, rendered from a headless isolate with noBuildContext(it looks up the device locale directly). Verify a Bulgarian device gets a Bulgarian notification. - Inbox row strings:
notificationNewSignalTitle/Body,notificationStatusChangeTitle/Body,notificationNewCommentTitle,notificationNearbySignalBody— rendered from structured fields, not from the Englishtitle/bodystored on the document (§6.6) - An unsupported device locale falls back to English rather than crashing
- The hosted share page localizes client-side (§15.3) — its Bulgarian signal-type names are a third copy of the type list; confirm they match the app's
- Same user account receives notifications on ALL registered devices
- FCM tokens stored as array — each device adds its token
- Sign-out removes only that device's token (other devices continue receiving)
- Cloud Function deduplicates: if a device registers under a new user, its token is removed from the old user document
- Signal creation on Device A visible on Device B in real time (Firestore streams)
- Signal edits (title/description/type/phone) on Device A propagate to Device B's details view and map marker
- Signal deletion on Device A removes the marker and closes the details view on Device B
- Signal deletion on Device A while its info window/overlay is open on the map on Device B: the marker disappears and the open info window + invisible tap target are dismissed cleanly — no crash (
PlatformException: Invalid markerId) and tapping where the window was does not navigate to the deleted signal - Status changes propagate across all devices viewing the same signal
- Comments appear in real time on all devices viewing the signal
- Profile edits (name, phone, photo) sync via Firestore across devices
- Notification preferences sync across devices
- Signal subscriptions shared across devices
- User A creates signal → User B (within radius, matching preferences) receives push notification
- User B changes status → User A (signal creator, auto-subscribed) receives notification
- User C comments → User A and User B (both subscribed) receive notifications; User C does not
- User D subscribes (by commenting or changing status) → User D receives future notifications for that signal
- Status change by any user updates marker color for all users viewing the map
- Multiple users can change status of the same signal — last write wins
- User starts anonymous on Device A
- Subscribes to signals, sets notification preferences
- Signs in on Device A → data transfers from anonymous account to authenticated account
- Anonymous account's FCM token moved to authenticated account
- If user was already authenticated on Device B, tokens merge (both devices receive notifications)
- Debug providers in debug builds (both platforms)
- Production: Play Integrity (Android), App Attest with Device Check fallback (iOS)
- Enforced on Cloud Functions (vet clinic search, clinic details)
- Data modifications (create signal, comment, status change) require authenticated (non-anonymous) user
- Unverified email users redirected to verification screen
- Profile page shows sign-in prompt for unauthenticated users
- Network errors display appropriate messages
- Photo upload failures show partial success state
- Vet clinic search timeout (15s) shows error
- Rate limiting (429) shows "too many searches"
- Invalid arguments handled gracefully
-
launchUrlfailures show fallback messages for calls and navigation
- All Flutter errors captured via
FlutterError.onError - Async errors captured via
PlatformDispatcher.instance.onError - User identifier set (UID only, no PII)
- Manual logging throughout codebase for key events
- Regression watch on the new build — confirm these known issues do not recur:
add2187e(SignInHubActivityNPE, §1.3),d98a1898/6fb193a6("Bad state: No element" at bootstrap, fixed by71a45ae),a8072678(forgot-passwordsetStateafter dispose — still open,firebase_ui_auth3.0.1)
Run the emulator suite first (§16.1). These are the on-device confirmations that the deployed rules behave.
- Create binding: a signal's
reportermust equal the caller's user doc — a forged reporter is denied - Content bounds on create: title required and ≤300 chars, description ≤10 000,
signalTypean int in 0–6. Out-of-range values are denied. - Reporter-only edits: only the signal's author can change title/description/type/phone/photos (§3.6)
- Status-only volunteer path: another signed-in user can change only
status(0–2) and must self-stamplastUpdatedBy— attempting to change any other field in the same write is denied -
lastUpdatedBycannot be spoofed to a different user - Delete is reporter-only (§3.7); a non-author's delete is denied
- Comment create binding:
authormust equal the caller; text (when present) 1–2000 chars - A
status_changecomment (notextfield) is accepted - A signal's reporter can delete comments on their own signal (enables the delete cascade); a third party cannot
- All of the above hold identically for
signals_test -
⚠️ Known gap: anonymous users are not blocked server-side yet (M-1 partial). Confirm the client guards still prevent it in the UI (§1.1, §11.2), and record that the rules-level block is deferred.
- Enumeration is blocked: a
list/collection query onpublicProfilesis denied (only single-documentgetis allowed). This is the fix — do not widengetback toread. - Single-document
getstill works for any signed-in user (incl. anonymous), so reporter and comment-author names resolve - Name validation: >100 chars denied; empty or whitespace-only denied; control characters (newline, NUL) denied
- A user can only write their own profile, and only the
namefield - A client cannot clear the
deleted/deletedAttombstone written bydeleteAccount(§1.8) - Client-side caps mean users never actually hit these denials in normal use (§1.5, §1.6)
- Names still render everywhere after the tightening: signal details reporter, comment authors, My Signals
-
users/{uid}/notifications/*is readable only by its owner (rules do not cascade fromusers/{uid}— this needed its own match, and its absence was the original inbox bug) - A client can only create
type == 'nearby_signal'entries — fabricating astatus_changeornew_commententry is denied - A client-created entry must carry
read == false, anexpiresAttimestamp, and stay within the size caps (title ≤300, body ≤1000, signalId ≤200) - Update is diff-limited to
read— a client cannot rewrite a delivered notification's title/body/signalId after the fact - Owner can delete their own entries; nobody else can read or write them
-
userLocations/{uid}is owner-only for read and write -
userCounters/{uid}accepts only{unread, updatedAt}withunread >= 0, owner-only - TTL policy is live:
expiresAton thenotificationscollection group is configured (entries expire after 90 days) — this is not deployed byfirebase deploy
- Unauthenticated create is denied
-
userIdmust equalrequest.auth.uid— spoofing another user is denied -
messagerequired, 1–1000 chars;typerestricted togeneral|bug|feature|other -
email, when present, must be syntactically valid and ≤254 chars;null/absent is allowed - Read/update/delete are denied to all clients (Admin SDK / Console only)
- Signal photos: only the signal's reporter can upload/replace/delete; a third party is denied
- Test-mode signal photos work — the rule checks
signalsandsignals_test. Checking onlysignalsmade every test-mode upload fail hard (a null dereference, not afalse). - Signal photos are publicly readable
- Upload is bounded at 5 MB and must be
image/*(the client setsimage/jpegexplicitly) - Avatars:
profile_photos/{uid}.jpg— a user can write only their own file;profile_photos/<someone-else>.jpgis denied - Avatar upload bounded at 2 MB,
image/*required - Avatars are publicly readable
- Comment-photo paths remain read-only (feature doesn't exist; default-deny is intentional)
-
⚠️ Emulator caveat: the Storage emulator project-prefixes cross-service DocumentReferences, so the realstorage.rulesdeny every upload there. That is an emulator artefact, not a prod bug — seefirestore-tests/storage.rules.test.js.
12. Special / Hidden Features
- Activated by tapping app title 7 times, each tap within 2 seconds of the last
- Toggles between
signalsandsignals_testFirestore collections - Cloud Functions have separate test triggers
- Users marked with
testMode: truein Firestore - Useful for QA without polluting production data
-
⚠️ Test mode isolates data collections only — rules, functions, auth and storage are shared with prod
New in this release
- Flipping test mode immediately pushes the mode to native (
syncTestMode), so Android's background pre-filter gate switches with it (§7.3) - The mode is re-pushed on every launch, so a push that failed while the app was being killed self-heals
- The catch-up dedupe store and gate are namespaced per mode — flipping to test mode lands on a clean gate; flipping back restores the real one rather than destroying it
- The in-app inbox is mode-filtered: test-mode entries never appear in the production inbox and vice versa (§6.6)
- Signal photos upload correctly in test mode (§11.9)
- The public share page resolves ids in both
signalsandsignals_test(§15.3)
- Package name:
org.helpapaw.helpapaw(release) /org.helpapaw.helpapaw.debug(debug) - Min SDK 24, Target SDK 36, Compile SDK 36 (corrected 2026-08-05 — the previous "23 / 35 / 35" was stale;
minSdkVersionnow tracksflutter.minSdkVersion, and build 126 reportsminSdk=24 targetSdk=36on device) - Notification channel: "help_a_paw_signals" (high importance) — defined in one place and shared by FCM and catch-up notifications (Android ignores a redefinition after first creation)
- Google Services configured via
google-services.json
New manifest surface (NEW)
-
launchModeissingleTask(wassingleTop): opening a link from another app (e.g. a note-taking app) brings the existing task forward rather than cloningMainActivityinto the caller's task with a second Flutter engine and split state -
flutter_deeplinking_enabledmeta-data present — VIEW intents reach go_router - App Link filter is scoped to
https://link.helpapaw.org/signal/viapathPrefix— the bare domain and.well-knownfiles must NOT open the app onto a "page not found" screen -
helpapaw://custom-scheme filter present (used by the hosted page's "Open in app" button) -
LocationUpdateReceiverregistered, not exported -
BootReceiverregistered forBOOT_COMPLETED,MY_PACKAGE_REPLACED,QUICKBOOT_POWERON
- Bundle ID:
com.helpapaw.helpapaw(release) /com.helpapaw.helpapaw.debug(debug) - APNs token acquisition with retry logic
- GoogleService-Info plist selected by build config (debug vs release scripts)
- Google Sign-In uses the platform-specific iOS client ID plus the web
serverClientId
New Info.plist / entitlements surface (NEW)
-
FlutterDeepLinkingEnabledistrue -
helpapawURL scheme registered inCFBundleURLTypes -
com.apple.developer.associated-domainscontainsapplinks:link.helpapaw.orgin both Debug and Release entitlements -
UIBackgroundModesincludeslocationandremote-notification -
NSLocationAlwaysAndWhenInUseUsageDescriptionpresent and accurate — iOS rejects background location without it - Both
.debugand release app IDs are listed in the publishedapple-app-site-association(§15.1) -
⚠️ Profile/Release builds use the PRODUCTION bundle idcom.helpapaw.helpapaw— installing one to a test device overwrites the real app and wipes its data. Reinstall from TestFlight/App Store afterwards.
- App uses a single light theme (
ThemeData(primarySwatch: orange, useMaterial3: true)) — no dark theme defined - Verify all screens render correctly when the OS is in dark mode (native dialogs, keyboards, system pickers, share sheet)
- Text remains legible and contrast acceptable throughout
- Camera denied → photo capture (signal & profile) degrades gracefully with a message
- Photo library denied → gallery picker degrades gracefully
- Notifications denied (after onboarding) → app remains usable; settings reflect disabled state
- Location denied / permanently denied → falls back to Sofia, prompts handled (see §7.1)
- Permission re-request routes user to OS settings where appropriate
- Cold launch with no network: map, cached signals, and UI don't crash
- Firestore offline cache serves previously loaded data
- Writes (signal/comment/status) queue or fail with a clear message when offline
- Recovery when connectivity returns (queued writes flush, streams resume)
- Vet clinic search and feedback show clear errors when offline
- Backgrounding during photo upload (signal creation §3.1) resumes or reports partial success
- Backgrounding mid signal creation preserves or cleanly discards form state
- Incoming call / app switch during navigation does not corrupt state
- Returning from background refreshes auth/notification state correctly
- Interactive elements expose
Semanticslabels (FAB, AppBar actions, drawer, map controls) - Screen reader (VoiceOver / TalkBack) can navigate primary flows: view signal, create signal, comment, change status
- Tap targets are adequately sized; dynamic/large font sizes don't break layouts
- Color is not the only status indicator (marker/status colors paired with labels/icons)
Several launch-path crashes and hangs were fixed this cycle. All of them present as "the app doesn't start", so test cold launches deliberately and repeatedly.
- Cold launch reaches the map on both platforms, from a fully terminated state, 5+ times in a row
- No launch-screen hang — nothing on the boot path awaits
google_sign_ininitialization (this caused the v7 splash hang) - No "Bad state: No element" crash —
GoRouter.stateis never read before the Router has built (71a45ae). Exercise: cold launch straight into a deep link, and cold launch with a pending notification. - No iOS SIGABRT at launch with background location enabled (the native-Firestore/plugin-settings conflict,
6066a46— never shipped, so this is first contact with real users) - Launching with no network does not hang: the anonymous sign-in is time-boxed and the first frame still renders
- Launching signed out (first install) completes bootstrap in order: router attached → deep-link listener → anonymous sign-in → notifications + location restore → badge sync
- A failure in notification init does not prevent background-location restore, and vice versa (each has its own catch)
Shared links are
https://link.helpapaw.org/signal/<id>. When the app is installed the OS intercepts the verified link and opens the signal natively; otherwise Hosting rewrites/signal/**to thesignalLinkCloud Function, which renders a public fallback page. Every external "open this signal" — notification tap, shared link, deferred install hand-off — routes through the singleSignalNavigatorseam.
-
https://link.helpapaw.org/.well-known/assetlinks.jsonreachable,200 application/json✅ verified 2026-08-05 -
https://link.helpapaw.org/.well-known/apple-app-site-associationreachable,200 application/json— the Hosting header override works ✅ verified 2026-08-05 - The release SHA-256 fingerprint matches the build under test ✅ RESOLVED — the Play-installed build 126 reports signature
91:82:E5:7B:…, byte-identical to the firstassetlinks.jsonentry. The published value is the Play App Signing certificate, so App Links work for real Play users. The earlier concern is closed. - Android App Link verification succeeds on-device —
pm get-app-linkson the Android 14 device reportslink.helpapaw.org: verified✅ verified 2026-08-05 - iOS
appIDslist bothNJRZ88GCVL.com.helpapaw.helpapawand….debug— file content confirmed; on-device Universal Link resolution still needs a real tap test (§15.2), since iOS gives nopm get-app-linksequivalent - Decide whether the debug keystore fingerprint stays in the published file for the release (see blockers)
- Cold launch from a tapped link opens the signal directly — no flash of the map first (the built-in handler applies it as the initial route)
- Warm start (app already running, in background) opens the signal — this is the gap Flutter's built-in deep linking misses and
app_linkscloses; verify by tapping a link saved in another app - Foreground (app open on the map) — tapping a link navigates to the signal
- Tapping the same link while already on that signal does nothing (no duplicate page stacked)
- Tapping a link for a different signal while viewing one shows the new signal's data (the route is keyed by id, so state is not reused)
- Backing out of a link-opened signal returns to the map, which focuses that pin and shows its info window
- Android: opening a link from another app does not create a second task/instance — check the recents screen shows one Help a Paw entry
- iOS: a Universal Link tapped in Messages/Mail/Safari opens the app (not Safari)
- The
helpapaw:///signal/<id>custom scheme also opens the app (note the triple slash —helpapaw://signal/<id>would parsesignalas the host and not match)
- Desktop browser: page renders the signal's title, description, type name and photo
- A QR code is shown; scanning it on a phone opens the same link (and thus the app or store)
- Phone browser without the app: attempts the app, then falls back to the correct store after a short delay
- Android fallback goes to Play with the
referrer=signal=<id>parameter attached - iOS shows the Smart App Banner (
apple-itunes-app), which reads "OPEN" post-install and deep-links viaapp-argument - Unknown / deleted signal id renders the "Signal not found" page, not an error or a blank page
- Malformed id (path traversal, HTML, >128 chars) is rejected: the page renders safely, nothing is interpolated raw, and
og:url/ the QR do not advertise a dead URL - Ids resolve from both
signalsandsignals_test - Open Graph preview: pasting the link into Facebook/Messenger/Slack unfurls our page (photo + title), not a Google Maps pin — the signal link must come first in the shared text (§3.2)
- Localization is client-side: a Bulgarian visitor sees Bulgarian text even though the CDN serves one cached copy. Verify a
bgvisitor and anenvisitor in quick succession both see their own language (this is the CDNAccept-Languagetrap). -
?lang=bgpins Bulgarian server-side;?lang=enpins English - Tracking parameters (
fbclid,utm_*) 301-redirect to the canonical URL so the CDN cache is not fragmented - Repeat visits are served from cache (no fresh function invocation / Firestore read per viewer)
- A deleted or anonymized signal stops being served within ~1 hour (cache TTL)
- Tap a shared link on a device without the app → Play Store → install → first launch opens that signal
- It runs at most once per install: force-quit and relaunch does not re-open the signal
- It is marked spent even if the referrer lookup fails, so a failure never becomes a retry that re-opens a stale signal later
- An organic install (referrer is
utm_source=google-play&utm_medium=organic) is the normal case and produces no navigation and no error - A launch that came from an actual link does not also fire the hand-off (it only marks it spent)
- A malformed/oversized id in the referrer is ignored
- iOS is deliberately not covered — confirm no "Allow Paste" prompt appears for a new user on first launch
-
/signal_details/<junk>(junk id: symbols, path traversal, >128 chars) redirects home — it never becomes a Firestore lookup -
/signal/<id>redirects to the canonical/signal_details/<id> - A link to a valid-shaped but non-existent id shows the details screen's not-found/error state, not a crash
- Notification taps, shared links and the install hand-off all set the pending map focus consistently (they previously disagreed on
pushvsgo)
A. Release-mode smoke test — DO THIS FIRST, it can invalidate everything else
These are the first release builds tested in a while. Release mode swaps App Check from debug providers to Play Integrity / App Attest, and uses the release API keys. A missing API on the release key silently breaks Auth, Firestore and FCM with no useful error — historically very hard to diagnose. Ten minutes here saves days. ✅ RUN 2026-08-05 on both Android devices — PASS. Full log:
docs/ai/SMOKE_TEST_126.md. The failure mode this test exists to catch (a release API key missing an API) did not occur; release-mode App Check attestation works. iOS still needs a manual pass.
- App launches and reaches the map — ✅ both Android devices
- Anonymous sign-in succeeds (no 403) — ✅
requestIntegrityToken() finishedon both, so Play Integrity attestation works in release - Map renders Google Maps tiles — ✅ both
- Signals load from Firestore — ✅ same "Solved" marker renders on both
- An FCM token is written to
users/{uid}.fcmTokens— ✅ tablet on launch 1; 🟡 Android 10 only on launch 2 (see SMOKE-01) - Sign in with email/password on one device — proves Token Service + Identity Toolkit (not yet run — needs the test accounts from §16.0-C)
- iOS/iPad: repeat all of the above manually
- If any of the above fails, check the release API key restrictions before proceeding: Token Service, Firebase Installations, FCM, Identity Toolkit, Firebase App Check, Maps SDK
🟡 SMOKE-01 — first launch after install may not register an FCM token (NOT a blocker)
Investigated 2026-08-05. The identity concern is disproved: across 3 further cold launches the uid, the
users/doccreateTimeand the FCM token string were all unchanged. No forked account, no orphaned data. The timeout did not reproduce once the install was warm — it is specific to the first launch after an install/update.
- Identity stability verified — same uid and token across 4 cold launches ✅
- Remaining real issue: on a slow device's first launch,
signInAnonymously()can exceed the 15s time box, bootstrap proceeds withcurrentUser == null, and_saveFcmTokenToFirestorehits its silentif (user == null) return. That launch registers no token, so a brand-new user receives no pushes until their second app open. Self-healing, silent, and it lands exactly on the new-user path. - Verify the self-heal explicitly on the clean-install device: first launch → check
users/{uid}.fcmTokens; if absent, relaunch and confirm it appears - Decide whether to fix now or defer — see the analysis in
docs/ai/SMOKE_TEST_126.md. NoteNotificationService.onUserLogin()already exists but would not fix this, because it early-returns unless the account already hasnotificationPreferences.enabled == true, which a brand-new user does not.
B. Baselines to capture before the first test action
- Record the current Crashlytics state for build 126 so any new crash is attributable to the run
- Note the starting Firestore document counts for
signals_test, and for the accounts you'll use - Confirm the feedback recipient inbox is reachable (still outstanding — §8.3 can't be fully verified without it)
C. Account setup
- Create the 4 app accounts needed for §10.3 (A creates → B status → C comments → D subscribes)
-
helpapaw.qa@gmail.comis on the iPad but not yet used to sign into the app — keep it that way until §1.3, so the new-user Google path is genuinely new - A second real Google account is still needed for the
credential-already-in-usemerge path (§1.4) — a+aliaswill not work - Confirm inbox access to
helpapaw.qa@gmail.comfor verification emails (§1.2)
D. Device state
- Enable test mode on every device (tap the title 7×, each tap within 2s) before creating any signal —
help-a-paw-devis production - Grant notifications on all three; on the Android 14 device this is a real runtime prompt (§14.2)
- Grant location Always on the iPad (iOS never offers Always on the first prompt — Settings → Privacy & Security → Location Services → Help a Paw → Always)
- Decide which Android device is the clean-install device for §16.4 and uninstall there
-
⚠️ The 125→126 in-place upgrade has already happened unobserved on both Android devices (both showfirstInstallTimein June,lastUpdateTimetoday, installercom.android.vending). To test §16.4's upgrade path deliberately, roll one device back to the previous closed-track release first, set up state, then update to 126.
-
flutter analyze lib— clean -
flutter test— all pass, including the newtest/deep_link_parsing_test.dart,test/models/notification_preferences_test.dart,test/viewmodels/map_view_model_test.dart -
test/firestore_settings_guard_test.dartpasses — treat a failure as a release blocker: assigning custom FirestoreSettingsin Dart silently breaks the Android headless isolate's geo query, and the failure reports as "no signals nearby" -
cd firestore-tests && npm test— Firestore and Storage rules suites pass. Required before every rules deploy; device testing cannot validate undeployed rules becausehelp-a-paw-devis production. - Kotlin:
android/app/src/test/.../GeohashTest.ktpasses (guards the Dart↔Kotlin geohash parity the fan-out depends on) - Swift:
ios/RunnerTests/GeohashTest.swiftpasses — and afterwards, restore/verifybuild/native_assets/ios/objective_c.frameworkbefore any device build -
cd functions && npm run build— TypeScript compiles
- Function logs show the geohash fan-out selecting recipients (not a full scan) — compare read counts against
COST_ANALYSIS.mdexpectations - Inbox entries appear in
users/{uid}/notificationswith deterministic ids (sig_/st_/cmt_/nb_) and anexpiresAt -
userCounters/{uid}.unreadincrements server-side and is repaired by the client on resume -
signalLinkresponds for a valid id, an unknown id, and a malformed id (§15.3) - Places caches (
vetClinicCache,vetClinicDetails) still show cache hits (§5.3) -
onCommentCreateddoes not throw on a status-change comment (which carries no.text) — this was R3-002; re-verify on the deployed build - No new unhandled errors in the functions log after 24 h of live traffic
-
cleanupAnonymousUsersis deployed and scheduled (Sun 03:00 UTC), andANON_CLEANUP_DRY_RUNis false for the real run - Trigger it manually once and read the summary log:
scanned N, found M stale anonymous (> 90d inactive), deleted K - It deletes only accounts with no linked providers and >90 days since last token refresh — verify an active anonymous session survives
- For each reaped uid it removes:
users/{uid}, thenotificationssubcollection,userLocations/{uid},userCounters/{uid} - Auth deletion happens only after the Firestore cleanup succeeded (a failed cleanup is left for the next run)
- The notifications TTL policy actually expires entries older than 90 days
Most of this release's risk lives here. An existing install carries prefs, an auth session, an FCM token and a dedupe store written by a build that knew nothing about the inbox, background location, deep links or the v7 Google SDK. A clean install exercises none of that.
⚠️ This must be run on a device that is still on 125. The two attached Android devices already auto-updated to 126 from Play, so the transition happened unobserved and cannot be replayed on them without a rollback. Use the dedicated observation device.
- Install 6.0.1+125 (previous closed-track release, or sideload that APK)
- Confirm
versionName=6.0.1 versionCode=125viaadb shell dumpsys package org.helpapaw.helpapaw | grep version - Sign in — use a Google account, so the v6→v7 SDK migration is exercised
- Enable notifications; confirm a token lands in
users/{uid}.fcmTokens - Enable location tracking; confirm
userLocations/{uid}exists - Set a non-default notification radius (e.g. 25 km) and deselect two signal types
- Enable test mode (title ×7)
- Create one signal and post one comment, so there is authored content
- Receive at least one push, and leave it unread (iOS: leaves a badge from the old
badge: 1behaviour) - Record all of it — uid, exact radius, which types are deselected, token value, badge number, signal id. This is the comparison baseline; without it "survived the upgrade" is unfalsifiable.
- Start
adb logcatbefore the upgrade and keep it running —MY_PACKAGE_REPLACEDfires during install and is easy to miss - Update via the Play closed track (preferred — matches what real users get) or
adb install -r - Confirm
versionCode=126, and thatfirstInstallTimeis unchanged whilelastUpdateTimemoves — that proves it was an upgrade, not a reinstall
-
MY_PACKAGE_REPLACEDre-arms background location: logcat showsre-armed location updates after android.intent.action.MY_PACKAGE_REPLACEDwithout the app being launched (§7.2) - No crash on the receiver path in logcat
- Launches cleanly to the map; no crash, no launch-screen hang (§14.6)
- Still signed in — no forced re-auth, same uid as recorded. This is the Google v6→v7 check: the Firebase session must survive the SDK swap.
-
⚠️ The user is NOT dumped into a random signal. The deferred deep-link one-shot has never run on this install (deferred_link_checkeddidn't exist in 125), so it will execute on this first launch and read the original Play install referrer. An organic referrer must be a silent no-op. Landing on a signal here is a bug. - Notification radius still reads 25 km; the same two signal types are still deselected — proves the new typed
NotificationPreferencesmodel reads 125's data correctly - Test mode is still on and the map still reads
signals_test - Authored signal and comment still present, reporter name resolves (not "Unknown")
- Location tracking restored on first launch —
userLocations/{uid}updates without the user touching the toggle. Note this is genuinely new behaviour:LocationService.initialize()was dead code in 125, so tracking silently stopped after every restart. Confirm it now self-starts. - FCM token unchanged and still in
users/{uid}.fcmTokens— a rotated token here would mean a delivery gap for real users - A push sent now arrives exactly once (no duplicate from a stale token)
- Drawer shows the new Notifications entry; the inbox opens and renders (empty is fine — 125 wrote no entries)
-
userCounters/{uid}did not exist under 125; confirm the badge sync creates it rather than erroring
- The app-icon badge left stuck by 125's
badge: 1behaviour is cleared on first resume of 126 - Universal Links begin working after the upgrade (125 had no associated-domains entitlement) — tap a shared link
- Google sign-out then sign-in uses the new v7 flow and shows the account chooser
- Create a signal, comment, change status — all succeed with no
PERMISSION_DENIED(the rules tightened in this release; a 125-era session must still satisfy them) - The catch-up dedupe store from 125 (
notified_signals) doesn't cause a missed or duplicated notification - Fresh-install path tested separately on a different device — both must pass independently