From 7353f8242fae03b2871ddae6f7c9b780a8a722dc Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Thu, 9 Jul 2026 08:58:43 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(v1.4):=20Trust=20&=20safety=20?= =?UTF-8?q?=E2=80=94=20Remote=20Config=20balance,=20GDPR=20+=20abuse=20fin?= =?UTF-8?q?ish,=20map=20friends=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements v1.4 "Trust & safety" from docs/plans/ROADMAP.md. Remote Config game balance (ship-gate pair): - New functions/src/_config.ts reads the RC server template (getServerTemplate + evaluate) with a 5-min cache and never throws; pure sanitisers enforce safety bounds (radius [10,100], points [1,50]) and fall back to the hard-coded constants. Wired into startScoring + _recomputeScores. - RemoteConfigService gains claimRadiusMeters / pointsForCipher / pointsByMonarch (defaults = current constants) + force-refetch on login; functional radius + points-display consumers route through them. cross_language_sync_test now pins the RC defaults == constants and client bounds == server bounds. GDPR account deletion (audit + finish): - Strip the new deviceIdHash on claim anonymisation; future-proof claims-photos/ prefix delete via the new testable storagePrefixesForUser; unit tests for deleteUserDocs + storagePrefixesForUser. Kept self-contained (no extension). Abuse detection (stays shadow mode): - New repeatedDeviceSignal + pure evaluateClaimSignals combiner; onClaimCreated counts distinct accounts per device hash over 24h (composite index added). Client sends a dependency-free persisted-random deviceIdHash (DeviceIdService). SHADOW_MODE stays true (enforcement is Phase 2). Scores-tab county map: - CountyHeatmap.friendsOnly (pure pickCountyLeaderEntry): the Map tab now honours the same Friends-only switch — off shows the global county leader. Gates: functions build + lint clean, 463 TS tests; flutter analyze clean, 439 Dart tests. Version 1.3.2+16 -> 1.4.0+17. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/ROADMAP.md | 37 ++++- firestore.indexes.json | 8 + functions/src/_abuseSignals.ts | 59 +++++++ functions/src/_accountDeletion.ts | 2 +- functions/src/_config.ts | 160 +++++++++++++++++++ functions/src/_recomputeScores.ts | 4 +- functions/src/abuse.ts | 57 ++++--- functions/src/onUserDeleted.ts | 19 ++- functions/src/startScoring.ts | 41 ++++- functions/src/test/test.index.ts | 229 ++++++++++++++++++++++++++- lib/claim.dart | 3 +- lib/county_heatmap.dart | 70 +++++--- lib/leaderboard_screen.dart | 62 ++++---- lib/main.dart | 4 + lib/nearby.dart | 4 +- lib/remote_config_service.dart | 80 ++++++++++ lib/route/live_route_screen.dart | 3 +- lib/services/device_id_service.dart | 52 ++++++ lib/settings_screen.dart | 5 +- lib/wear/wear_claim_page.dart | 11 +- lib/widgets/claim_quiz_sheet.dart | 18 ++- pubspec.yaml | 2 +- test/claim_quiz_sheet_test.dart | 5 + test/cross_language_sync_test.dart | 44 +++++ test/device_id_service_test.dart | 33 ++++ test/remote_config_service_test.dart | 93 +++++++++++ test/widget_test.dart | 29 ++++ 27 files changed, 1029 insertions(+), 105 deletions(-) create mode 100644 functions/src/_config.ts create mode 100644 lib/services/device_id_service.dart create mode 100644 test/device_id_service_test.dart diff --git a/docs/plans/ROADMAP.md b/docs/plans/ROADMAP.md index d098a2f3..8692c035 100644 --- a/docs/plans/ROADMAP.md +++ b/docs/plans/ROADMAP.md @@ -159,16 +159,35 @@ exceptions in key flows to non-fatals. --- -## v1.4 — Trust & safety (Queued) +## v1.4 — Trust & safety (In flight) **Theme**: harden the platform now that we can see what's happening (v1.3 observability). **Ship gate**: account-deletion flow tested end-to-end in staging; impossible-travel detector in shadow mode logging flags but not blocking; Remote Config drives `claim_radius_meters` and `points_by_monarch` end-to-end (client + Cloud Functions) with the safety bounds enforced. +> **Status** (branch `feat/v1.4-trust-safety`, `1.4.0+17`): all three workstreams +> implemented; `flutter analyze` + Dart tests + functions lint/tests green. Remaining +> before ship: the manual staging pass for account deletion and the Remote Config +> console setup (publish `claim_radius_meters` + `points_by_monarch` to the client AND +> server templates with values equal to the code defaults). Two roadmap divergences, +> both deliberate: (1) GDPR uses a **self-contained `onUserDeleted` function, not the +> Delete-User-Data extension** (the extension can only delete, not anonymise claims, +> which would break leaderboard integrity); (2) abuse **enforcement/voiding stays +> deferred (Phase 2)** — the ship gate requires shadow mode, so `SHADOW_MODE` remains +> `true`. Remote Config was scoped to the ship-gate pair only (no `quiz_required_streak`, +> other constants left hard-coded). + > **App Check enforcement moved to v1.7** (it's gated on iOS `AppleProvider` > wiring, which lands in v1.7's iOS work). See the App Check item under v1.7. ### Remote Config for game balance and copy (was #107, moved from v1.3) +> ✅ Implemented (ship-gate pair only). Client: `RemoteConfigService.claimRadiusMeters` +> + `pointsForCipher`/`pointsByMonarch` (bounds-checked, defaults = constants), force-refetch +> on login. Backend: `functions/src/_config.ts` (`getServerTemplate` + 5-min cache, never +> throws) wired into `startScoring` + `_recomputeScores`. Fallback constants stay canonical +> (guarded by `test/cross_language_sync_test.dart`). Other tunables + `quiz_required_streak` +> intentionally out of scope. + The Remote Config *infrastructure* (typed `RemoteConfigService`, fetch lifecycle, push updates, kill switches, `maintenance_mode`) already shipped in v1.3. What remains is the game-balance tuning layer: centralise tunable values so balance, @@ -184,6 +203,14 @@ and Cloud Functions read Remote Config. ### GDPR "Delete User Data" Firebase Extension (was #99) +> ✅ Implemented as a **self-contained `onUserDeleted` v1 Auth trigger** (europe-west2) +> + `functions/src/_accountDeletion.ts`, NOT the extension (the extension can't anonymise +> claims). Anonymises claims/reports (strips PII incl. the new `deviceIdHash`), hard-deletes +> user docs + `report_photos/` (and future-proof `claims-photos/` via +> `storagePrefixesForUser`), audit counter at `deletions/{day}`. Flutter re-auth + `User.delete()` +> in `lib/user_repository.dart`, Settings "Delete account" UI. Helpers unit-tested; full-flow +> e2e is the staging pass below. + Install the official extension. Configure paths: - Firestore: `users/{UID}`, `fcmTokens/{UID}`, `recaps/{UID}/periods/{DOC}`. @@ -201,6 +228,14 @@ Test in staging first: create a user with claims, photos, friends; delete; asser ### Abuse detection (impossible-travel and claim anomalies) (was #112) +> ✅ Implemented in **shadow mode** (`SHADOW_MODE = true`, stays that way per the ship gate). +> All four signals now fire: impossible-travel, out-of-window, coord-cluster, and the new +> **repeated-device** signal (`repeatedDeviceSignal` + `evaluateClaimSignals` in +> `_abuseSignals.ts`, wired in `onClaimCreated`; client sends a stable per-install +> `deviceIdHash` via `lib/services/device_id_service.dart`; composite index added). Flags → +> `moderationFlags`, trust decay → `trustScores`, admin review UI `admin_abuse_screen.dart`. +> Enforcement / claim-voiding remains the deferred **Phase 2** below. + `startScoring` already has a travel-speed anti-spoof check. Extend to: - **Impossible travel**: two claims < N minutes apart separated by distance requiring > 200 km/h (start with 180 km/h / 50 m/s). diff --git a/firestore.indexes.json b/firestore.indexes.json index 834f6346..c7c691b7 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -33,6 +33,14 @@ { "fieldPath": "timestamp", "order": "DESCENDING" } ] }, + { + "collectionGroup": "claims", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "deviceIdHash", "order": "ASCENDING" }, + { "fieldPath": "timestamp", "order": "DESCENDING" } + ] + }, { "collectionGroup": "postbox", "queryScope": "COLLECTION", diff --git a/functions/src/_abuseSignals.ts b/functions/src/_abuseSignals.ts index 6150aafc..a6912381 100644 --- a/functions/src/_abuseSignals.ts +++ b/functions/src/_abuseSignals.ts @@ -29,6 +29,10 @@ export const CLUSTER_DECIMALS = 6; * clustering signal (the current claim plus this many repeats). */ export const CLUSTER_MIN_REPEATS = 3; +/** Number of distinct accounts sharing one device hash within 24h that trips the + * repeated-device signal (multi-accounting from a single install). */ +export const REPEATED_DEVICE_MIN_ACCOUNTS = 3; + /** Trust score assigned to a user with no prior flags. */ export const DEFAULT_TRUST_SCORE = 100; @@ -89,6 +93,14 @@ export function coordClusterSignal(repeatCount: number): SignalResult { return { flagged: repeatCount >= CLUSTER_MIN_REPEATS, value: repeatCount }; } +/** Repeated-device signal: the number of DISTINCT accounts that have claimed + * from the same device hash within the recent window reaches + * REPEATED_DEVICE_MIN_ACCOUNTS. `distinctAccounts` is supplied by the caller + * from a Firestore query over recent claims sharing the deviceIdHash. */ +export function repeatedDeviceSignal(distinctAccounts: number): SignalResult { + return { flagged: distinctAccounts >= REPEATED_DEVICE_MIN_ACCOUNTS, value: distinctAccounts }; +} + function severityForCount(n: number): Severity { if (n >= 3) return "high"; if (n === 2) return "med"; @@ -103,6 +115,53 @@ export function summariseFlags(signals: NamedSignal[]): { reasons: string[]; sev return { reasons, severity: severityForCount(reasons.length) }; } +/** Inputs the onClaimCreated trigger gathers from the claim + Firestore, fed to + * the pure signal evaluator so the combination logic is unit-testable. */ +export interface ClaimSignalInputs { + travelSpeedMPerMin?: number; + serverTsMs: number; + clientTsMs?: number; + coordRepeatCount: number; + distinctDeviceAccounts: number; +} + +export interface ClaimSignalEvaluation { + reasons: string[]; + severity: Severity; + signals: { + travelSpeedMPerMin: number; + clockSkewMs: number; + coordRepeatCount: number; + deviceAccountCount: number; + }; +} + +/** Evaluate every shadow-mode anomaly signal for one claim and reduce to the + * fired-reason list, an overall severity, and the measured values for the flag + * doc. Pure — the trigger supplies the IO-derived inputs. */ +export function evaluateClaimSignals(inp: ClaimSignalInputs): ClaimSignalEvaluation { + const travel = impossibleTravelSignal(inp.travelSpeedMPerMin); + const window = outOfWindowSignal(inp.serverTsMs, inp.clientTsMs); + const cluster = coordClusterSignal(inp.coordRepeatCount); + const device = repeatedDeviceSignal(inp.distinctDeviceAccounts); + const { reasons, severity } = summariseFlags([ + { reason: "impossible_travel", flagged: travel.flagged }, + { reason: "out_of_window", flagged: window.flagged }, + { reason: "coord_cluster", flagged: cluster.flagged }, + { reason: "repeated_device", flagged: device.flagged }, + ]); + return { + reasons, + severity, + signals: { + travelSpeedMPerMin: travel.value, + clockSkewMs: window.value, + coordRepeatCount: cluster.value, + deviceAccountCount: device.value, + }, + }; +} + const DECAY_BY_SEVERITY: Record = { low: 5, med: 15, high: 30 }; /** Apply a trust-score penalty for a flag of the given severity, clamped to diff --git a/functions/src/_accountDeletion.ts b/functions/src/_accountDeletion.ts index 156e07b0..4d126d79 100644 --- a/functions/src/_accountDeletion.ts +++ b/functions/src/_accountDeletion.ts @@ -12,7 +12,7 @@ const WRITE_BATCH_SIZE = 400; /** Firestore field names on a claim that carry location/timing PII and must be * removed when a claim is anonymised. `points`/`monarch`/`dailyDate`/etc. are * kept so the global claim/postbox-history record stays intact. */ -const CLAIM_PII_FIELDS = ["userLat", "userLng", "coordKey6", "clientTsMs", "travelSpeed"] as const; +const CLAIM_PII_FIELDS = ["userLat", "userLng", "coordKey6", "clientTsMs", "travelSpeed", "deviceIdHash"] as const; /** Anonymise every claim by [uid]: rewrite `userid` to the deleted sentinel and * strip location PII. Returns the number of claims rewritten. Batched (400). */ diff --git a/functions/src/_config.ts b/functions/src/_config.ts new file mode 100644 index 00000000..7b5ce969 --- /dev/null +++ b/functions/src/_config.ts @@ -0,0 +1,160 @@ +// Server-side game-balance config, backed by Firebase Remote Config. +// +// v1.4 ship gate: `claim_radius_meters` and `points_by_monarch` are driven +// end-to-end (client + Cloud Functions) with safety bounds enforced. This +// module is the SERVER half — it reads the Remote Config *server* template, +// caches the evaluated values in-memory for 5 minutes (so hot callables pay +// nothing), and NEVER throws: on any fetch/parse failure it returns the +// hard-coded fallbacks so scoring degrades to today's behaviour. +// +// The hard-coded fallbacks (DEFAULT_CLAIM_RADIUS_METERS here, getPoints in +// _getPoints.ts) remain the canonical values guarded by +// test/cross_language_sync_test.dart. Remote Config is only an override on top. + +import "./adminInit"; +import * as admin from "firebase-admin"; +import { KNOWN_MONARCHS, pointsForMonarch } from "./_getPoints"; + +/** Remote Config parameter keys (must match the client + the RC templates). */ +export const KEY_CLAIM_RADIUS = "claim_radius_meters"; +export const KEY_POINTS_BY_MONARCH = "points_by_monarch"; + +/** Canonical claim radius (metres). Must match AppPreferences.claimRadiusMeters + * in lib/app_preferences.dart and CLAIM_RADIUS_METERS in startScoring.ts. */ +export const DEFAULT_CLAIM_RADIUS_METERS = 30; + +/** Safety band for a Remote-Config-supplied claim radius. A value outside this + * band is treated as a misconfiguration and ignored (fall back to default). */ +export const MIN_CLAIM_RADIUS_METERS = 10; +export const MAX_CLAIM_RADIUS_METERS = 100; + +/** Safety band for a Remote-Config-supplied per-monarch point value. */ +export const MIN_MONARCH_POINTS = 1; +export const MAX_MONARCH_POINTS = 50; + +/** How long an evaluated config is reused before the next fetch. */ +export const CONFIG_CACHE_TTL_MS = 5 * 60 * 1000; + +export interface GameConfig { + claimRadiusMeters: number; + /** Recognised-monarch -> points overrides, or null to use the hard-coded + * points wholesale (invalid / absent Remote Config). */ + pointsOverride: Record | null; +} + +const FALLBACK_CONFIG: GameConfig = { + claimRadiusMeters: DEFAULT_CLAIM_RADIUS_METERS, + pointsOverride: null, +}; + +/** Clamp a Remote-Config claim radius into the safety band, or fall back to the + * default when it is missing / non-finite / out of band. */ +export function sanitiseClaimRadius(raw: unknown): number { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return DEFAULT_CLAIM_RADIUS_METERS; + } + if (raw < MIN_CLAIM_RADIUS_METERS || raw > MAX_CLAIM_RADIUS_METERS) { + return DEFAULT_CLAIM_RADIUS_METERS; + } + return raw; +} + +/** Parse+validate the `points_by_monarch` JSON string. Returns a map of + * recognised monarch -> points (integers in [1,50]); unknown keys are dropped. + * Returns null (=> use the hard-coded points wholesale) if the string is + * absent, not a JSON object, or ANY value is a non-integer / out of range. */ +export function parsePointsOverride(raw: unknown): Record | null { + if (typeof raw !== "string" || raw.trim().length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + const known = new Set(KNOWN_MONARCHS); + const out: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (!known.has(key)) continue; // drop unknown keys + if (typeof value !== "number" || !Number.isInteger(value)) return null; + if (value < MIN_MONARCH_POINTS || value > MAX_MONARCH_POINTS) return null; + out[key] = value; + } + return out; +} + +/** Pure resolution: the override value for [monarch] if present, else the + * hard-coded fallback (pointsForMonarch). */ +export function resolvePointsForMonarch( + cfg: GameConfig, + monarch: string | null | undefined, +): number { + if ( + cfg.pointsOverride && + typeof monarch === "string" && + Object.prototype.hasOwnProperty.call(cfg.pointsOverride, monarch) + ) { + return cfg.pointsOverride[monarch]; + } + return pointsForMonarch(monarch); +} + +/** Build a GameConfig from raw Remote Config values via the pure sanitisers. */ +export function buildGameConfig(rawRadius: unknown, rawPoints: unknown): GameConfig { + return { + claimRadiusMeters: sanitiseClaimRadius(rawRadius), + pointsOverride: parsePointsOverride(rawPoints), + }; +} + +type ConfigLoader = () => Promise; + +/** Real loader: read + evaluate the Remote Config server template. Never throws + * — any failure yields the fallback config. */ +async function fetchGameConfigFromRemoteConfig(): Promise { + try { + const template = await admin.remoteConfig().getServerTemplate(); + const config = template.evaluate(); + const rawRadius = config.getValue(KEY_CLAIM_RADIUS).asNumber(); + const rawPoints = config.getValue(KEY_POINTS_BY_MONARCH).asString(); + return buildGameConfig(rawRadius, rawPoints); + } catch (e) { + console.warn("[_config] Remote Config fetch failed; using fallbacks:", e); + return FALLBACK_CONFIG; + } +} + +let loader: ConfigLoader = fetchGameConfigFromRemoteConfig; +let cache: { value: GameConfig; fetchedAtMs: number } | null = null; + +/** The evaluated game config, cached for CONFIG_CACHE_TTL_MS. `nowMs` is + * injectable for tests; production passes the default Date.now(). */ +export async function getGameConfig(nowMs: number = Date.now()): Promise { + if (cache && nowMs - cache.fetchedAtMs < CONFIG_CACHE_TTL_MS) { + return cache.value; + } + const value = await loader(); + cache = { value, fetchedAtMs: nowMs }; + return value; +} + +export async function getClaimRadiusMeters(): Promise { + return (await getGameConfig()).claimRadiusMeters; +} + +export async function getPointsForMonarch(monarch: string | null | undefined): Promise { + return resolvePointsForMonarch(await getGameConfig(), monarch); +} + +/** Test hook: swap the config loader (dependency injection for cache tests). */ +export function setConfigLoaderForTest(l: ConfigLoader): void { + loader = l; + cache = null; +} + +/** Test hook: clear the in-memory cache (leaves the loader as-is). */ +export function resetConfigCacheForTest(): void { + cache = null; +} diff --git a/functions/src/_recomputeScores.ts b/functions/src/_recomputeScores.ts index 02284c79..a1066fc7 100644 --- a/functions/src/_recomputeScores.ts +++ b/functions/src/_recomputeScores.ts @@ -1,6 +1,6 @@ import "./adminInit"; import * as admin from "firebase-admin"; -import { pointsForMonarch } from "./_getPoints"; +import { getPointsForMonarch } from "./_config"; import { getTodayLondon } from "./_dateUtils"; import { updateUserLeaderboards, @@ -50,7 +50,7 @@ export async function repointClaimsForPostbox( postboxKey: string, newMonarch: string | null ): Promise { - const newPts = pointsForMonarch(newMonarch); + const newPts = await getPointsForMonarch(newMonarch); const snap = await db .collection("claims") .where("postboxes", "==", `/postbox/${postboxKey}`) diff --git a/functions/src/abuse.ts b/functions/src/abuse.ts index 2e11bbd3..b1f7bc6d 100644 --- a/functions/src/abuse.ts +++ b/functions/src/abuse.ts @@ -4,13 +4,9 @@ import * as functions from "firebase-functions"; import { onDocumentCreated } from "firebase-functions/v2/firestore"; import { FIRESTORE_TRIGGER_REGION } from "./_region"; import { - impossibleTravelSignal, - outOfWindowSignal, - coordClusterSignal, - summariseFlags, + evaluateClaimSignals, applyTrustDecay, DEFAULT_TRUST_SCORE, - type NamedSignal, } from "./_abuseSignals"; // SHADOW MODE: the detector only records flags + decays an internal trust score. @@ -18,6 +14,11 @@ import { // verification, voiding) is a deferred follow-up gated behind this flag. const SHADOW_MODE = true; +/** Cap on the recent same-device claims read to count distinct accounts. A + * heuristic bound for the shadow-mode repeated-device signal — enough to + * surface multi-accounting without an unbounded scan. */ +const REPEATED_DEVICE_SCAN_LIMIT = 100; + const database = admin.firestore(); /** @@ -48,6 +49,7 @@ export const onClaimCreated = onDocumentCreated( const clientTsMs = typeof claim.clientTsMs === "number" ? (claim.clientTsMs as number) : undefined; const travelSpeed = typeof claim.travelSpeed === "number" ? (claim.travelSpeed as number) : undefined; const coordKey6 = typeof claim.coordKey6 === "string" ? (claim.coordKey6 as string) : undefined; + const deviceIdHash = typeof claim.deviceIdHash === "string" ? (claim.deviceIdHash as string) : undefined; // Coordinate clustering: how many of this user's claims sit at the same // rounded coordinate (this one included). Two equality filters are served @@ -63,22 +65,41 @@ export const onClaimCreated = onDocumentCreated( repeatCount = agg.data().count; } - const travel = impossibleTravelSignal(travelSpeed); - const window = outOfWindowSignal(serverTsMs, clientTsMs); - const cluster = coordClusterSignal(repeatCount); + // Repeated-device: distinct accounts that have claimed from this claim's + // device hash in the last 24h. Bounded read (equality + timestamp range — + // needs the composite index claims(deviceIdHash, timestamp)). + let distinctDeviceAccounts = 0; + if (deviceIdHash) { + const cutoff = admin.firestore.Timestamp.fromMillis(Date.now() - 24 * 60 * 60 * 1000); + const recent = await database + .collection("claims") + .where("deviceIdHash", "==", deviceIdHash) + .where("timestamp", ">=", cutoff) + .orderBy("timestamp", "desc") + .limit(REPEATED_DEVICE_SCAN_LIMIT) + .get(); + const uids = new Set(); + for (const d of recent.docs) { + const u = d.data().userid; + if (typeof u === "string") uids.add(u); + } + distinctDeviceAccounts = uids.size; + } - const named: NamedSignal[] = [ - { reason: "impossible_travel", flagged: travel.flagged }, - { reason: "out_of_window", flagged: window.flagged }, - { reason: "coord_cluster", flagged: cluster.flagged }, - ]; - const { reasons, severity } = summariseFlags(named); + const { reasons, severity, signals } = evaluateClaimSignals({ + travelSpeedMPerMin: travelSpeed, + serverTsMs, + clientTsMs, + coordRepeatCount: repeatCount, + distinctDeviceAccounts, + }); if (reasons.length === 0) return; // nothing anomalous // Internal visibility via Cloud Logging — values only, never exact coords. console.warn( `[abuse:shadow] uid=${uid} claim=${claimId} reasons=${reasons.join(",")} severity=${severity} ` + - `speedMPerMin=${travel.value.toFixed(1)} clockSkewMs=${window.value} coordRepeat=${cluster.value}`, + `speedMPerMin=${signals.travelSpeedMPerMin.toFixed(1)} clockSkewMs=${signals.clockSkewMs} ` + + `coordRepeat=${signals.coordRepeatCount} deviceAccounts=${signals.deviceAccountCount}`, ); // Surface the claim coordinate on the (admin-only) flag doc so the review @@ -93,11 +114,7 @@ export const onClaimCreated = onDocumentCreated( reasons, severity, shadow: SHADOW_MODE, - signals: { - travelSpeedMPerMin: travel.value, - clockSkewMs: window.value, - coordRepeatCount: cluster.value, - }, + signals, ...(flagLat !== undefined ? { lat: flagLat } : {}), ...(flagLng !== undefined ? { lng: flagLng } : {}), createdAt: admin.firestore.FieldValue.serverTimestamp(), diff --git a/functions/src/onUserDeleted.ts b/functions/src/onUserDeleted.ts index 28e91baa..5f5c5a19 100644 --- a/functions/src/onUserDeleted.ts +++ b/functions/src/onUserDeleted.ts @@ -49,7 +49,7 @@ export const onUserDeleted = functions ["anonymiseReports", anonymiseReportsForUser(db, uid)], ["removeFromLeaderboards", removeUserFromLeaderboards(db, uid, countySlugs)], ["removeFromFriends", removeUserFromFriends(db, uid)], - ["deleteReportPhotos", deleteReportPhotos(uid)], + ["deleteUserStorage", deleteUserStorage(uid)], ]; const results = await Promise.allSettled(phases.map(([, p]) => p)); results.forEach((r, i) => { @@ -79,7 +79,18 @@ export const onUserDeleted = functions } }); -/** Recursively delete the user's uploaded report photos from Storage. */ -async function deleteReportPhotos(uid: string): Promise { - await admin.storage().bucket().deleteFiles({ prefix: `report_photos/${uid}/` }); +/** Storage prefixes holding a user's uploaded media, deleted on erasure. + * `claims-photos/` is future-proofing for the planned v1.5 claim-photos feature + * — a harmless no-op until that path is ever written. Pure + exported so a unit + * test pins that erasure covers every per-user media prefix. */ +export function storagePrefixesForUser(uid: string): string[] { + return [`report_photos/${uid}/`, `claims-photos/${uid}/`]; +} + +/** Recursively delete the user's uploaded media from Storage. */ +async function deleteUserStorage(uid: string): Promise { + const bucket = admin.storage().bucket(); + await Promise.all( + storagePrefixesForUser(uid).map((prefix) => bucket.deleteFiles({ prefix })), + ); } diff --git a/functions/src/startScoring.ts b/functions/src/startScoring.ts index c379528e..1d71f399 100644 --- a/functions/src/startScoring.ts +++ b/functions/src/startScoring.ts @@ -1,7 +1,7 @@ import "./adminInit"; import * as admin from "firebase-admin"; import * as functions from "firebase-functions"; -import { getPoints } from "./_getPoints"; +import { getGameConfig, resolvePointsForMonarch, DEFAULT_CLAIM_RADIUS_METERS } from "./_config"; import { getTodayLondon } from "./_dateUtils"; import { lookupPostboxes, getLatLng } from "./_lookupPostboxes"; import { updateUserLeaderboards, mergeLifetimeEntries, LifetimeLeaderboardEntry, getWeekStart, getMonthStart, countySlug } from "./_leaderboardUtils"; @@ -12,10 +12,21 @@ import { coordKey } from "./_abuseSignals"; const database = admin.firestore(); -/** Radius (metres) within which a user must stand to claim a postbox. - * Must match AppPreferences.claimRadiusMeters in lib/app_preferences.dart. */ +/** Fallback radius (metres) within which a user must stand to claim a postbox. + * The live value comes from Remote Config via _config.ts (getClaimRadiusMeters); + * this constant is only the documented fallback. Must match + * AppPreferences.claimRadiusMeters in lib/app_preferences.dart (pinned by + * test/cross_language_sync_test.dart) and DEFAULT_CLAIM_RADIUS_METERS. */ const CLAIM_RADIUS_METERS = 30; +// The documented fallback here must equal _config's fallback, since the +// cross-language sync test pins THIS constant against the Dart client while the +// runtime radius actually resolves through _config. (Same load-time-invariant +// pattern as _abuseSignals.ts.) +if (CLAIM_RADIUS_METERS !== DEFAULT_CLAIM_RADIUS_METERS) { + throw new Error("CLAIM_RADIUS_METERS must equal DEFAULT_CLAIM_RADIUS_METERS in _config.ts"); +} + /** The patch merged onto a `postbox` doc when it's claimed: only the London * date it was last claimed (a "someone found this today" display hint). * @@ -37,15 +48,23 @@ interface StartScoringCallData { /** Client wall-clock at claim time (ms since epoch). Optional — legacy/web * clients omit it. Stored for the shadow-mode out-of-window anomaly signal. */ clientTsMs?: number; + /** SHA-256 hash of the client's Firebase installation id. Optional — legacy/ + * web clients omit it. Stored for the shadow-mode repeated-device signal + * (one install claiming across many accounts). Hashed client-side; treated + * as PII and stripped on account deletion (_accountDeletion.ts). */ + deviceIdHash?: string; } +/** Max stored length of a device-id hash (SHA-256 hex is 64 chars; allow slack). */ +const MAX_DEVICE_ID_HASH_LEN = 128; + export const startScoring = functions.https.onCall(async (request) => { const userid = request.auth?.uid; if (!userid) { throw new functions.https.HttpsError("unauthenticated", "Must be signed in to claim a postbox"); } - const { lat, lng, clientTsMs } = (request.data as StartScoringCallData) ?? {}; + const { lat, lng, clientTsMs, deviceIdHash } = (request.data as StartScoringCallData) ?? {}; if (lat === undefined || lat === null || lng === undefined || lng === null) { throw new functions.https.HttpsError("invalid-argument", "lat and lng are required"); } @@ -58,6 +77,10 @@ export const startScoring = functions.https.onCall(async (request) => { if (clientTsMs !== undefined && (typeof clientTsMs !== "number" || !Number.isFinite(clientTsMs))) { throw new functions.https.HttpsError("invalid-argument", "clientTsMs must be a finite number when provided"); } + if (deviceIdHash !== undefined && + (typeof deviceIdHash !== "string" || deviceIdHash.length === 0 || deviceIdHash.length > MAX_DEVICE_ID_HASH_LEN)) { + throw new functions.https.HttpsError("invalid-argument", "deviceIdHash must be a non-empty string when provided"); + } // Anti-spoof: reject claims whose implied travel speed from the user's // previous claim exceeds a liberal physical limit. Fail-open when the @@ -70,7 +93,12 @@ export const startScoring = functions.https.onCall(async (request) => { // Hoist date computation so all return paths include dailyDate for consistency. const todayLondon = getTodayLondon(); - const results = await lookupPostboxes(lat, lng, CLAIM_RADIUS_METERS); + // Fetch the Remote-Config-driven game balance once (5-min cached in _config). + // claim radius + per-monarch points both resolve from here, falling back to + // the hard-coded constants when Remote Config is absent/invalid. + const gameConfig = await getGameConfig(); + + const results = await lookupPostboxes(lat, lng, gameConfig.claimRadiusMeters); if (results.counts.total === 0) { return { found: false, claimed: 0, points: 0, allClaimedToday: false, dailyDate: todayLondon }; @@ -114,7 +142,7 @@ export const startScoring = functions.https.onCall(async (request) => { // the pre-fetch, then creates it atomically — preventing double-claims from // concurrent requests. const claimRef = database.collection('claims').doc(`${userid}_${key}_${todayLondon}`); - const pts = postbox.monarch !== undefined ? getPoints(postbox.monarch) : 2; + const pts = resolvePointsForMonarch(gameConfig, postbox.monarch); return database.runTransaction(async (tx) => { const claimSnap = await tx.get(claimRef); @@ -141,6 +169,7 @@ export const startScoring = functions.https.onCall(async (request) => { // never write `undefined` to Firestore). if (clientTsMs !== undefined) claimData.clientTsMs = clientTsMs; if (travelSpeed !== undefined) claimData.travelSpeed = travelSpeed; + if (deviceIdHash !== undefined) claimData.deviceIdHash = deviceIdHash; tx.set(claimRef, claimData); // Keep dailyClaim on the postbox doc for display purposes (shows // "someone found this today" in future UI); does not gate claiming. diff --git a/functions/src/test/test.index.ts b/functions/src/test/test.index.ts index 9905946c..ff1349e6 100644 --- a/functions/src/test/test.index.ts +++ b/functions/src/test/test.index.ts @@ -3,6 +3,28 @@ import test from "firebase-functions-test"; import * as myFunctions from "../index"; import { filterToCorridor, filterToEllipse, beamSearchOrienteering, metresBetween } from "../_routePlanner"; import { getPoints } from "../_getPoints"; +import { + sanitiseClaimRadius, + parsePointsOverride, + resolvePointsForMonarch, + getGameConfig, + getClaimRadiusMeters, + getPointsForMonarch, + setConfigLoaderForTest, + DEFAULT_CLAIM_RADIUS_METERS, + type GameConfig, +} from "../_config"; + +// Keep every test offline: the real _config loader reads the Remote Config +// server template over the network. Install a deterministic fallback loader for +// the whole run so startScoring / _recomputeScores paths resolve to the +// hard-coded points+radius without a network call; the _config caching tests +// override it per-test and restore this in afterEach. +const fallbackGameConfig = async (): Promise => ({ + claimRadiusMeters: DEFAULT_CLAIM_RADIUS_METERS, + pointsOverride: null, +}); +before(() => setConfigLoaderForTest(fallbackGameConfig)); import { getTodayLondon, previousDay, getLondonHourMinute } from "../_dateUtils"; import { decideFire, @@ -39,6 +61,88 @@ describe("getPoints", () => { it("returns 2 (default) for empty string", () => assert.strictEqual(getPoints(""), 2)); }); +describe("_config: sanitiseClaimRadius", () => { + it("accepts an in-band value", () => assert.strictEqual(sanitiseClaimRadius(45), 45)); + it("accepts the lower bound 10", () => assert.strictEqual(sanitiseClaimRadius(10), 10)); + it("accepts the upper bound 100", () => assert.strictEqual(sanitiseClaimRadius(100), 100)); + it("falls back below the lower bound", () => + assert.strictEqual(sanitiseClaimRadius(5), DEFAULT_CLAIM_RADIUS_METERS)); + it("falls back above the upper bound", () => + assert.strictEqual(sanitiseClaimRadius(500), DEFAULT_CLAIM_RADIUS_METERS)); + it("falls back for non-numbers", () => + assert.strictEqual(sanitiseClaimRadius("30"), DEFAULT_CLAIM_RADIUS_METERS)); + it("falls back for NaN", () => + assert.strictEqual(sanitiseClaimRadius(NaN), DEFAULT_CLAIM_RADIUS_METERS)); +}); + +describe("_config: parsePointsOverride", () => { + it("parses a valid partial override", () => + assert.deepStrictEqual(parsePointsOverride('{"VR":8}'), { VR: 8 })); + it("drops unknown monarch keys", () => + assert.deepStrictEqual(parsePointsOverride('{"VR":8,"BOGUS":3}'), { VR: 8 })); + it("rejects wholesale when a value is out of range", () => + assert.strictEqual(parsePointsOverride('{"VR":999}'), null)); + it("rejects wholesale when a value is below 1", () => + assert.strictEqual(parsePointsOverride('{"VR":0}'), null)); + it("rejects wholesale for non-integer values", () => + assert.strictEqual(parsePointsOverride('{"VR":7.5}'), null)); + it("returns null for invalid JSON", () => + assert.strictEqual(parsePointsOverride("not json"), null)); + it("returns null for an empty string", () => + assert.strictEqual(parsePointsOverride(""), null)); + it("returns null for a JSON array", () => + assert.strictEqual(parsePointsOverride("[1,2,3]"), null)); + it("returns an empty map for an empty object", () => + assert.deepStrictEqual(parsePointsOverride("{}"), {})); +}); + +describe("_config: resolvePointsForMonarch", () => { + const cfg = (pointsOverride: Record | null): GameConfig => ({ + claimRadiusMeters: 30, + pointsOverride, + }); + it("uses the override when present for the monarch", () => + assert.strictEqual(resolvePointsForMonarch(cfg({ VR: 8 }), "VR"), 8)); + it("falls back to hard-coded when the monarch is not overridden", () => + assert.strictEqual(resolvePointsForMonarch(cfg({ EIIR: 3 }), "VR"), 7)); + it("falls back to hard-coded when there is no override map", () => + assert.strictEqual(resolvePointsForMonarch(cfg(null), "VR"), 7)); + it("returns the default 2 for an unknown monarch with no override", () => + assert.strictEqual(resolvePointsForMonarch(cfg(null), "ZZZ"), 2)); + it("returns 2 for a null monarch", () => + assert.strictEqual(resolvePointsForMonarch(cfg(null), null), 2)); +}); + +describe("_config: getGameConfig caching", () => { + afterEach(() => setConfigLoaderForTest(fallbackGameConfig)); + + it("caches within the TTL and refetches after it", async () => { + let calls = 0; + setConfigLoaderForTest(async () => { + calls++; + return { claimRadiusMeters: 40 + calls, pointsOverride: null }; + }); + const t0 = 1_000_000; + const a = await getGameConfig(t0); + const b = await getGameConfig(t0 + 60_000); // within the 5-min TTL + assert.strictEqual(calls, 1); + assert.strictEqual(a.claimRadiusMeters, b.claimRadiusMeters); + const c = await getGameConfig(t0 + 5 * 60 * 1000 + 1); // past the TTL + assert.strictEqual(calls, 2); + assert.strictEqual(c.claimRadiusMeters, 42); + }); + + it("getClaimRadiusMeters / getPointsForMonarch read the cached config", async () => { + setConfigLoaderForTest(async () => ({ + claimRadiusMeters: 55, + pointsOverride: { VR: 8 }, + })); + assert.strictEqual(await getClaimRadiusMeters(), 55); + assert.strictEqual(await getPointsForMonarch("VR"), 8); + assert.strictEqual(await getPointsForMonarch("EIIR"), 2); + }); +}); + describe("diffSnapshots (migration verification)", () => { const snap = ( roots: Record, @@ -3398,11 +3502,14 @@ import { impossibleTravelSignal, outOfWindowSignal, coordClusterSignal, + repeatedDeviceSignal, + evaluateClaimSignals, summariseFlags, applyTrustDecay, SHADOW_TRAVEL_FLAG_M_PER_MIN, OUT_OF_WINDOW_MS, CLUSTER_MIN_REPEATS, + REPEATED_DEVICE_MIN_ACCOUNTS, DEFAULT_TRUST_SCORE, } from "../_abuseSignals"; @@ -3456,6 +3563,61 @@ describe("abuse signals: coordClusterSignal", () => { }); }); +describe("abuse signals: repeatedDeviceSignal", () => { + it("flags once distinct accounts reach the minimum", () => { + assert.strictEqual(repeatedDeviceSignal(REPEATED_DEVICE_MIN_ACCOUNTS).flagged, true); + }); + it("does not flag below the minimum", () => { + assert.strictEqual(repeatedDeviceSignal(REPEATED_DEVICE_MIN_ACCOUNTS - 1).flagged, false); + }); + it("reports the distinct-account count as the value", () => { + assert.strictEqual(repeatedDeviceSignal(5).value, 5); + }); +}); + +describe("abuse signals: evaluateClaimSignals", () => { + const base = { serverTsMs: 1_000_000_000_000, coordRepeatCount: 0, distinctDeviceAccounts: 0 }; + + it("flags nothing for a clean claim", () => { + const r = evaluateClaimSignals(base); + assert.deepStrictEqual(r.reasons, []); + assert.strictEqual(r.severity, "low"); + }); + + it("flags repeated_device once distinct accounts reach the minimum", () => { + const r = evaluateClaimSignals({ ...base, distinctDeviceAccounts: REPEATED_DEVICE_MIN_ACCOUNTS }); + assert.deepStrictEqual(r.reasons, ["repeated_device"]); + assert.strictEqual(r.severity, "low"); + assert.strictEqual(r.signals.deviceAccountCount, REPEATED_DEVICE_MIN_ACCOUNTS); + }); + + it("escalates severity as signals co-occur (device + travel = med)", () => { + const r = evaluateClaimSignals({ + ...base, + travelSpeedMPerMin: SHADOW_TRAVEL_FLAG_M_PER_MIN, + distinctDeviceAccounts: REPEATED_DEVICE_MIN_ACCOUNTS, + }); + assert.strictEqual(r.reasons.length, 2); + assert.ok(r.reasons.includes("repeated_device")); + assert.ok(r.reasons.includes("impossible_travel")); + assert.strictEqual(r.severity, "med"); + }); + + it("reports all four signals with high severity when everything fires", () => { + const r = evaluateClaimSignals({ + travelSpeedMPerMin: SHADOW_TRAVEL_FLAG_M_PER_MIN, + serverTsMs: base.serverTsMs, + clientTsMs: base.serverTsMs - (OUT_OF_WINDOW_MS + 1), + coordRepeatCount: CLUSTER_MIN_REPEATS, + distinctDeviceAccounts: REPEATED_DEVICE_MIN_ACCOUNTS, + }); + assert.strictEqual(r.reasons.length, 4); + assert.strictEqual(r.severity, "high"); + assert.strictEqual(r.signals.coordRepeatCount, CLUSTER_MIN_REPEATS); + assert.strictEqual(r.signals.deviceAccountCount, REPEATED_DEVICE_MIN_ACCOUNTS); + }); +}); + describe("abuse signals: summariseFlags", () => { it("collects only the fired reasons", () => { const r = summariseFlags([ @@ -3515,7 +3677,72 @@ import { anonymiseReportsForUser, removeUserFromFriends, removeUserFromLeaderboards, + deleteUserDocs, } from "../_accountDeletion"; +import { storagePrefixesForUser } from "../onUserDeleted"; + +describe("account deletion: storagePrefixesForUser", () => { + it("covers report photos and (future-proof) claim photos for the uid", () => { + assert.deepStrictEqual(storagePrefixesForUser("u1"), [ + "report_photos/u1/", + "claims-photos/u1/", + ]); + }); +}); + +describe("account deletion: deleteUserDocs (mock Firestore)", () => { + it("deletes the profile, per-uid docs, countyStats, and the user's flags", async () => { + const deleted: string[] = []; + const makeCollDoc = (path: string) => ({ + // A DocumentReference stand-in; deleteUserDocs only reads .id via batch. + id: path, + _path: path, + }); + const countyDocs = [makeCollDoc("users/u1/countyStats/avon")]; + const flagDocs = [makeCollDoc("moderationFlags/f1"), makeCollDoc("moderationFlags/f2")]; + + const userRef = { + _path: "users/u1", + collection: (name: string) => { + if (name !== "countyStats") throw new Error(`unexpected subcollection ${name}`); + return { async get() { return { docs: countyDocs.map((d) => ({ ref: d })) }; } }; + }, + }; + const db = { + collection: (name: string) => { + if (name === "users") return { doc: () => userRef }; + if (name === "moderationFlags") { + return { + where(field: string, op: string, val: unknown) { + assert.strictEqual(field, "uid"); + assert.strictEqual(op, "=="); + assert.strictEqual(val, "u1"); + return { async get() { return { docs: flagDocs.map((d) => ({ ref: d })) }; } }; + }, + }; + } + // fcmTokens / reportQuotas / trustScores + return { doc: () => makeCollDoc(`${name}/u1`) }; + }, + batch() { + return { + delete(ref: { _path: string }) { deleted.push(ref._path); }, + async commit() {}, + }; + }, + }; + + await deleteUserDocs(db as unknown as import("firebase-admin").firestore.Firestore, "u1"); + + assert.ok(deleted.includes("users/u1"), "profile doc deleted"); + assert.ok(deleted.includes("users/u1/countyStats/avon"), "countyStats deleted"); + assert.ok(deleted.includes("moderationFlags/f1") && deleted.includes("moderationFlags/f2"), + "user's moderation flags deleted"); + assert.ok(deleted.includes("fcmTokens/u1"), "fcm token deleted"); + assert.ok(deleted.includes("reportQuotas/u1"), "report quota deleted"); + assert.ok(deleted.includes("trustScores/u1"), "trust score deleted"); + }); +}); describe("account deletion: anonymiseClaimsForUser (mock Firestore)", () => { type Doc = { id: string; userid?: string; points?: number }; @@ -3567,7 +3794,7 @@ describe("account deletion: anonymiseClaimsForUser (mock Firestore)", () => { // points is preserved (not part of the update). assert.strictEqual("points" in w.update, false); // Location fields are FieldValue.delete() sentinels (objects, not values). - for (const f of ["userLat", "userLng", "coordKey6", "clientTsMs", "travelSpeed"]) { + for (const f of ["userLat", "userLng", "coordKey6", "clientTsMs", "travelSpeed", "deviceIdHash"]) { assert.ok(f in w.update, `${f} should be in the update`); assert.notStrictEqual(typeof w.update[f], "number"); assert.notStrictEqual(typeof w.update[f], "string"); diff --git a/lib/claim.dart b/lib/claim.dart index 5f803abf..f178c116 100644 --- a/lib/claim.dart +++ b/lib/claim.dart @@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:postbox_game/analytics_service.dart'; import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/remote_config_service.dart'; import 'package:postbox_game/james_controller.dart'; import 'package:postbox_game/james_messages.dart'; import 'package:postbox_game/location_service.dart'; @@ -228,7 +229,7 @@ class _ClaimState extends State { textAlign: landscape ? TextAlign.start : TextAlign.center, ); final subtitle = Text( - 'Stand within ${AppPreferences.formatShortDistance(AppPreferences.claimRadiusMeters, _distanceUnit)} of a postbox, then tap below to check if you can claim it.', + 'Stand within ${AppPreferences.formatShortDistance(RemoteConfigService.instance.claimRadiusMeters, _distanceUnit)} of a postbox, then tap below to check if you can claim it.', style: Theme.of(context) .textTheme .bodyMedium diff --git a/lib/county_heatmap.dart b/lib/county_heatmap.dart index ff546fcd..5d623101 100644 --- a/lib/county_heatmap.dart +++ b/lib/county_heatmap.dart @@ -68,8 +68,30 @@ Color mapColourFor({ /// /// The map intentionally has no tile layer — the heatmap is the content, a /// base map would distract and incur OSM tile traffic for no benefit. +/// Picks the leading leaderboard entry for a county. Friends-only: the top +/// entry whose uid is in [candidates] (entries are server-sorted, so the first +/// match is the highest-ranked friend). Global: the top entry regardless. +/// Returns null when no eligible entry exists. +@visibleForTesting +Map? pickCountyLeaderEntry( + List entries, Set candidates, bool friendsOnly) { + for (final e in entries) { + if (e is! Map) continue; + final entryUid = e['uid'] as String?; + if (entryUid == null) continue; + if (friendsOnly && !candidates.contains(entryUid)) continue; + return Map.from(e); + } + return null; +} + class CountyHeatmap extends StatefulWidget { - const CountyHeatmap({super.key}); + const CountyHeatmap({super.key, this.friendsOnly = true}); + + /// When true (default), each county is coloured by the leader among {me + my + /// friends}; when false, by the global leader. Mirrors the "Friends only" + /// toggle on the other leaderboard tabs. + final bool friendsOnly; @override State createState() => _CountyHeatmapState(); @@ -84,6 +106,15 @@ class _CountyHeatmapState extends State { _future = _load(); } + @override + void didUpdateWidget(CountyHeatmap oldWidget) { + super.didUpdateWidget(oldWidget); + // The toggle flips friendsOnly; reload so the county leaders recompute. + if (oldWidget.friendsOnly != widget.friendsOnly) { + setState(() => _future = _load()); + } + } + Future<_HeatmapData> _load() async { final uid = FirebaseAuth.instance.currentUser?.uid; if (uid == null) { @@ -217,21 +248,16 @@ class _CountyHeatmapState extends State { final lb = lbBySlug[slug]; if (lb == null) continue; final entries = (lb['entries'] as List?) ?? const []; - for (final e in entries) { - if (e is! Map) continue; - final entryUid = e['uid'] as String?; - if (entryUid == null || !candidates.contains(entryUid)) continue; - leaderBySlug[slug] = _CountyLeader( - uid: entryUid, - displayName: (e['displayName'] as String?) ?? - names[entryUid] ?? - 'Unknown', - uniqueBoxes: - (e['uniquePostboxesClaimed'] as num?)?.toInt() ?? 0, - totalPoints: (e['totalPoints'] as num?)?.toInt() ?? 0, - ); - break; - } + final e = pickCountyLeaderEntry(entries, candidates, widget.friendsOnly); + if (e == null) continue; + final entryUid = e['uid'] as String; + leaderBySlug[slug] = _CountyLeader( + uid: entryUid, + displayName: + (e['displayName'] as String?) ?? names[entryUid] ?? 'Unknown', + uniqueBoxes: (e['uniquePostboxesClaimed'] as num?)?.toInt() ?? 0, + totalPoints: (e['totalPoints'] as num?)?.toInt() ?? 0, + ); } return _HeatmapData( @@ -241,6 +267,7 @@ class _CountyHeatmapState extends State { leaderBySlug: leaderBySlug, displayNames: names, chosenColours: chosenColours, + friendsOnly: widget.friendsOnly, ); } @@ -339,10 +366,11 @@ class _HeatmapViewState extends State<_HeatmapView> { const SizedBox(height: AppSpacing.sm), if (leader == null) Text( - // The candidate set is (you + your friends), so a missing - // leader means neither you nor any friend has claimed here - // yet. Wording works whether or not the user has friends. - 'No claims here yet — go grab the lead!', + // Friends-only: a missing leader means neither you nor any + // friend has claimed here. Global: nobody has at all. + data.friendsOnly + ? 'No claims here from you or your friends yet. Go grab the lead!' + : 'No claims here yet. Be the first!', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -645,6 +673,7 @@ class _HeatmapData { final Map leaderBySlug; final Map displayNames; final Map chosenColours; + final bool friendsOnly; const _HeatmapData({ required this.myUid, @@ -653,6 +682,7 @@ class _HeatmapData { required this.leaderBySlug, required this.displayNames, required this.chosenColours, + required this.friendsOnly, }); } diff --git a/lib/leaderboard_screen.dart b/lib/leaderboard_screen.dart index 8d3c1381..dce1748f 100644 --- a/lib/leaderboard_screen.dart +++ b/lib/leaderboard_screen.dart @@ -49,9 +49,6 @@ class _LeaderboardScreenState extends State void _onTabChanged() { if (_tabController.indexIsChanging) return; if (!mounted) return; - // Rebuild so the Friends-only toggle row hides on the Map tab (where it - // doesn't apply) and reappears on the period tabs. - setState(() {}); final period = _periods[_tabController.index]; if (period == 'lifetime') { JamesController.of(context) @@ -64,7 +61,6 @@ class _LeaderboardScreenState extends State @override Widget build(BuildContext context) { - final isMapTab = _periods[_tabController.index] == 'counties'; return Column( children: [ Container( @@ -78,42 +74,40 @@ class _LeaderboardScreenState extends State .toList(), ), ), - // Friends-only toggle row — hidden on the Map tab where the heatmap is - // inherently friend-scoped (you + your friends) and the toggle has no - // effect, so showing it is misleading and wastes vertical space. - if (!isMapTab) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, vertical: AppSpacing.xs), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Friends only', - style: Theme.of(context).textTheme.bodyMedium), - Switch( - value: _friendsOnly, - activeThumbColor: postalRed, - onChanged: (v) { - setState(() => _friendsOnly = v); - if (v) { - JamesController.of(context) - ?.show(JamesMessages.navFriendsLeaderboard.resolve()); - } - }, - ), - ], - ), + // Friends-only toggle row. Applies to every tab, including the Map + // (county heatmap): on = leader among you + your friends, off = global. + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Friends only', + style: Theme.of(context).textTheme.bodyMedium), + Switch( + value: _friendsOnly, + activeThumbColor: postalRed, + onChanged: (v) { + setState(() => _friendsOnly = v); + if (v) { + JamesController.of(context) + ?.show(JamesMessages.navFriendsLeaderboard.resolve()); + } + }, + ), + ], ), - const Divider(height: 1), - ], + ), + const Divider(height: 1), Expanded( child: TabBarView( controller: _tabController, children: _periods.map((period) { if (period == 'counties') { - // County heatmap is inherently friend-scoped (uses your - // friends list); the friends-only toggle does not apply. - return const CountyHeatmap(); + // The heatmap honours the same friends-only toggle: on colours + // each county by the leader among you + your friends, off by the + // global leader. + return CountyHeatmap(friendsOnly: _friendsOnly); } if (_friendsOnly) { return _FriendsPeriodList( diff --git a/lib/main.dart b/lib/main.dart index 78dca5ef..5be2bd70 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -233,6 +233,10 @@ class _PostboxGameState extends State with WidgetsBindingObserver { if (state is Authenticated) { unawaited(NotificationService.init()); unawaited(_homeWidgetService.refresh()); + // Force a fresh Remote Config fetch on login (bypassing the + // 1-hour throttle) so a stale client cache can't hide a + // corrected game-balance value (claim radius / monarch points). + unawaited(RemoteConfigService.instance.forceRefresh()); final user = FirebaseAuth.instance.currentUser; unawaited(CrashlyticsHelper.setContext( CrashlyticsHelper.keyAuthState, _authStateLabel(user))); diff --git a/lib/nearby.dart b/lib/nearby.dart index 76fc7c6d..011d7942 100644 --- a/lib/nearby.dart +++ b/lib/nearby.dart @@ -780,8 +780,8 @@ class _NearbyState extends State { allClaimed ? '$code · claimed today' : claimed > 0 - ? '$code · ${MonarchInfo.getPoints(code)} pts · $available of $count available' - : '$code · ${MonarchInfo.getPoints(code)} pts each', + ? '$code · ${RemoteConfigService.instance.pointsForCipher(code)} pts · $available of $count available' + : '$code · ${RemoteConfigService.instance.pointsForCipher(code)} pts each', style: allClaimed ? TextStyle( color: Theme.of(context) diff --git a/lib/remote_config_service.dart b/lib/remote_config_service.dart index a3ab612a..ff016f92 100644 --- a/lib/remote_config_service.dart +++ b/lib/remote_config_service.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:convert'; import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/foundation.dart'; +import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/monarch_info.dart'; import 'package:postbox_game/services/crashlytics_helper.dart'; /// Read-only wrapper around Firebase Remote Config. @@ -55,10 +58,28 @@ class RemoteConfigService { static const String keyJamesWelcomeVariant = 'james_welcome_variant'; static const String keyMaintenanceMode = 'maintenance_mode'; static const String keyMaintenanceMessage = 'maintenance_message'; + static const String keyClaimRadiusMeters = 'claim_radius_meters'; + static const String keyPointsByMonarch = 'points_by_monarch'; static const String welcomeVariantClassic = 'classic'; static const String welcomeVariantCheeky = 'cheeky'; + /// Safety band for a Remote-Config-supplied claim radius (metres). Values + /// outside this band are treated as misconfiguration and ignored (fall back + /// to [AppPreferences.claimRadiusMeters]). Mirrors _config.ts on the server. + static const double minClaimRadiusMeters = 10.0; + static const double maxClaimRadiusMeters = 100.0; + + /// Safety band for a Remote-Config-supplied per-monarch point value. + static const int minMonarchPoints = 1; + static const int maxMonarchPoints = 50; + + /// Default `points_by_monarch` JSON. MUST equal [MonarchInfo.points] — pinned + /// by test/cross_language_sync_test.dart. Remote Config only overrides on top. + static const String defaultPointsByMonarchJson = + '{"EIIR":2,"CIIIR":9,"GR":4,"GVR":4,"GVIR":4,"VR":7,"EVIIR":9,' + '"EVIIIR":12,"SCOTTISH_CROWN":4}'; + /// Default banner copy when no remote string is set. James voice, no em-dash. static const String defaultMaintenanceMessage = "The Royal Mail van's pulled in for a service. " @@ -73,6 +94,8 @@ class RemoteConfigService { keyJamesWelcomeVariant: welcomeVariantClassic, keyMaintenanceMode: false, keyMaintenanceMessage: defaultMaintenanceMessage, + keyClaimRadiusMeters: AppPreferences.claimRadiusMeters, + keyPointsByMonarch: defaultPointsByMonarchJson, }; static const Duration _fetchTimeout = Duration(seconds: 10); @@ -140,6 +163,63 @@ class RemoteConfigService { return raw.isEmpty ? defaultMaintenanceMessage : raw; } + /// Claim radius (metres), Remote-Config-driven with a safety band. An unset, + /// non-finite, or out-of-band remote value falls back to the hard-coded + /// [AppPreferences.claimRadiusMeters]. + double get claimRadiusMeters { + final raw = _rc.getDouble(keyClaimRadiusMeters); + if (!raw.isFinite || + raw < minClaimRadiusMeters || + raw > maxClaimRadiusMeters) { + return AppPreferences.claimRadiusMeters; + } + return raw; + } + + /// Points for [cipher]: the Remote-Config override if valid + present, else + /// the hard-coded [MonarchInfo.getPoints] fallback. + int pointsForCipher(String cipher) { + final override = _parsePointsOverride(_rc.getString(keyPointsByMonarch)); + final v = override?[cipher]; + return v ?? MonarchInfo.getPoints(cipher); + } + + /// The full per-monarch point map: [MonarchInfo.points] with any valid + /// Remote-Config overrides layered on top. + Map get pointsByMonarch { + final override = _parsePointsOverride(_rc.getString(keyPointsByMonarch)); + if (override == null) return Map.from(MonarchInfo.points); + return {...MonarchInfo.points, ...override}; + } + + /// Parse+validate the `points_by_monarch` JSON. Returns a map of recognised + /// monarch -> points (integers in [[minMonarchPoints], [maxMonarchPoints]]); + /// unknown keys are dropped. Returns null (=> use hard-coded points wholesale) + /// if the string is absent, not a JSON object, or ANY value is a non-integer + /// / out of range. Mirrors parsePointsOverride in functions/src/_config.ts. + Map? _parsePointsOverride(String raw) { + if (raw.trim().isEmpty) return null; + Object? decoded; + try { + decoded = jsonDecode(raw); + } catch (_) { + return null; + } + if (decoded is! Map) return null; + final out = {}; + for (final entry in decoded.entries) { + final key = entry.key; + if (key is! String || !MonarchInfo.points.containsKey(key)) { + continue; // drop unknown keys + } + final value = entry.value; + if (value is! int) return null; // wholesale reject non-integers + if (value < minMonarchPoints || value > maxMonarchPoints) return null; + out[key] = value; + } + return out; + } + /// UI subscribes to this to rebuild when the maintenance flag flips /// (via push, force-refresh, or a fresh fetch). ValueListenable get maintenanceModeListenable => _maintenanceMode; diff --git a/lib/route/live_route_screen.dart b/lib/route/live_route_screen.dart index 37b738b2..28c5455d 100644 --- a/lib/route/live_route_screen.dart +++ b/lib/route/live_route_screen.dart @@ -8,6 +8,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:postbox_game/analytics_service.dart'; import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/remote_config_service.dart'; import 'package:postbox_game/fuzzy_compass.dart'; import 'package:postbox_game/james_controller.dart'; import 'package:postbox_game/james_messages.dart'; @@ -436,7 +437,7 @@ class _LiveRouteScreenState extends State { final dist = rawDist is num ? rawDist.toDouble() : double.infinity; if (claimedToday) continue; - if (dist > AppPreferences.claimRadiusMeters) continue; + if (dist > RemoteConfigService.instance.claimRadiusMeters) continue; if (_recentDismissals.containsKey(id)) continue; if (dist < bestDist) { diff --git a/lib/services/device_id_service.dart b/lib/services/device_id_service.dart new file mode 100644 index 00000000..ae7e207e --- /dev/null +++ b/lib/services/device_id_service.dart @@ -0,0 +1,52 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// A stable, privacy-preserving per-install identifier for the shadow-mode +/// repeated-device abuse signal (one install claiming across many accounts — +/// see functions/src/abuse.ts / _abuseSignals.repeatedDeviceSignal). +/// +/// Deliberately NOT derived from hardware: a random 256-bit value generated +/// once and persisted in SharedPreferences. It is app-global, so it survives +/// account switches (multi-accounting on one install shares it) and resets only +/// when the user clears app data — the same reset semantics as a Firebase +/// installation id, without adding a native Firebase plugin (which would risk +/// the firebase_core version ceiling). Sent to startScoring as `deviceIdHash`; +/// being random, it already reveals nothing about the device. +class DeviceIdService { + DeviceIdService._(); + + static const String _key = 'device_install_id'; + static String? _cached; + + /// The persisted install id, generating + storing one on first use. The id is + /// 64 lowercase-hex chars (matches the server's deviceIdHash length bound). + /// + /// Best-effort: returns null if SharedPreferences is unavailable so the claim + /// path never breaks — the abuse signal is optional and fails open. + static Future get() async { + if (_cached != null) return _cached; + try { + final prefs = await SharedPreferences.getInstance(); + var id = prefs.getString(_key); + if (id == null || id.length != 64) { + id = _generate(); + await prefs.setString(_key, id); + } + _cached = id; + return id; + } catch (_) { + return null; + } + } + + static String _generate() { + final rnd = Random.secure(); + final bytes = List.generate(32, (_) => rnd.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + @visibleForTesting + static void resetForTest() => _cached = null; +} diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index b073e138..2d9ffd48 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -9,6 +9,7 @@ import 'package:latlong2/latlong.dart'; import 'package:postbox_game/analytics_service.dart'; import 'package:postbox_game/analytics_user_properties.dart'; import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/remote_config_service.dart'; import 'package:postbox_game/authentication_bloc/bloc.dart'; import 'package:postbox_game/county_heatmap.dart'; import 'package:postbox_game/intro.dart'; @@ -834,7 +835,7 @@ class _SettingsScreenState extends State { postalRed.withValues(alpha: 0.6), 'Claim range', AppPreferences.formatShortDistance( - AppPreferences.claimRadiusMeters, _distanceUnit), + RemoteConfigService.instance.claimRadiusMeters, _distanceUnit), ), ], ), @@ -854,7 +855,7 @@ class _SettingsScreenState extends State { ), CircleMarker( point: center, - radius: AppPreferences.claimRadiusMeters, + radius: RemoteConfigService.instance.claimRadiusMeters, useRadiusInMeter: true, color: postalRed.withValues(alpha: 0.25), borderColor: postalRed.withValues(alpha: 0.9), diff --git a/lib/wear/wear_claim_page.dart b/lib/wear/wear_claim_page.dart index 31a9f2a9..2fc74dc2 100644 --- a/lib/wear/wear_claim_page.dart +++ b/lib/wear/wear_claim_page.dart @@ -5,7 +5,8 @@ import 'package:postbox_game/firebase_functions_eu.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:postbox_game/analytics_service.dart'; -import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/remote_config_service.dart'; +import 'package:postbox_game/services/device_id_service.dart'; import 'package:postbox_game/location_service.dart'; import 'package:postbox_game/monarch_info.dart'; import 'package:postbox_game/streak_service.dart'; @@ -75,7 +76,7 @@ class _WearClaimPageState extends State { final result = await _nearbyCallable.call({ 'lat': position.latitude, 'lng': position.longitude, - 'meters': AppPreferences.claimRadiusMeters, + 'meters': RemoteConfigService.instance.claimRadiusMeters, }); if (!mounted) return; final counts = result.data['counts'] ?? {}; @@ -189,11 +190,15 @@ class _WearClaimPageState extends State { }); try { final position = await getPosition(forceLocationManager: true); + final deviceIdHash = await DeviceIdService.get(); final result = await _claimCallable.call({ 'lat': position.latitude, 'lng': position.longitude, // Client wall-clock for the shadow-mode out-of-window anomaly signal. 'clientTsMs': DateTime.now().millisecondsSinceEpoch, + // Stable per-install id for the shadow-mode repeated-device signal + // (omitted when unavailable so the server never sees a null). + if (deviceIdHash != null) 'deviceIdHash': deviceIdHash, }); final found = result.data?['found'] == true; final allClaimedToday = result.data?['allClaimedToday'] == true; @@ -351,7 +356,7 @@ class _WearClaimPageState extends State { ), const SizedBox(height: WearSpacing.sm), Text( - 'Within ${AppPreferences.claimRadiusMeters}m', + 'Within ${RemoteConfigService.instance.claimRadiusMeters}m', style: Theme.of(context).textTheme.bodySmall, ), ], diff --git a/lib/widgets/claim_quiz_sheet.dart b/lib/widgets/claim_quiz_sheet.dart index 04cb785f..56c926f0 100644 --- a/lib/widgets/claim_quiz_sheet.dart +++ b/lib/widgets/claim_quiz_sheet.dart @@ -21,6 +21,7 @@ import 'package:postbox_game/monarch_info.dart'; import 'package:postbox_game/remote_config_service.dart'; import 'package:postbox_game/reports/report_missing_postbox_screen.dart'; import 'package:postbox_game/services/crashlytics_helper.dart'; +import 'package:postbox_game/services/device_id_service.dart'; import 'package:postbox_game/services/home_widget_service.dart'; import 'package:postbox_game/services/perf_service.dart'; import 'package:postbox_game/theme.dart'; @@ -381,7 +382,7 @@ class _ClaimQuizSheetState extends State final result = await _nearbyCallable({ 'lat': scanPos.latitude, 'lng': scanPos.longitude, - 'meters': AppPreferences.claimRadiusMeters, + 'meters': RemoteConfigService.instance.claimRadiusMeters, }); if (!mounted) return; @@ -498,6 +499,10 @@ class _ClaimQuizSheetState extends State HapticFeedback.mediumImpact(); try { final position = await _positionProvider(); + // Stable per-install id for the shadow-mode repeated-device signal. Null + // (best-effort) when unavailable — the key is then omitted so the server + // never sees a null deviceIdHash. + final deviceIdHash = await DeviceIdService.get(); final result = await PerfService.traceAsync( PerfTraces.callableStartScoring, (trace) async { @@ -508,6 +513,7 @@ class _ClaimQuizSheetState extends State // Client wall-clock for the shadow-mode out-of-window anomaly // signal (server compares it against its own claim timestamp). 'clientTsMs': DateTime.now().millisecondsSinceEpoch, + if (deviceIdHash != null) 'deviceIdHash': deviceIdHash, }); trace.putAttribute(PerfTraces.attrOutcome, 'ok'); return r; @@ -792,7 +798,7 @@ class _ClaimQuizSheetState extends State circleMarkers: [ CircleMarker( point: center, - radius: AppPreferences.claimRadiusMeters, + radius: RemoteConfigService.instance.claimRadiusMeters, useRadiusInMeter: true, color: postalRed.withValues(alpha: 0.1), borderColor: postalRed.withValues(alpha: 0.4 + alpha * 0.5), @@ -826,7 +832,7 @@ class _ClaimQuizSheetState extends State circleMarkers: [ CircleMarker( point: center, - radius: AppPreferences.claimRadiusMeters, + radius: RemoteConfigService.instance.claimRadiusMeters, useRadiusInMeter: true, color: fillColor.withValues(alpha: 0.12), borderColor: borderColor, @@ -927,7 +933,7 @@ class _ClaimQuizSheetState extends State const CircularProgressIndicator(color: postalRed), const SizedBox(height: AppSpacing.md), Text( - 'Scanning within ${AppPreferences.formatShortDistance(AppPreferences.claimRadiusMeters, _distanceUnit)}...', + 'Scanning within ${AppPreferences.formatShortDistance(RemoteConfigService.instance.claimRadiusMeters, _distanceUnit)}...', textAlign: TextAlign.center, ), ], @@ -989,7 +995,7 @@ class _ClaimQuizSheetState extends State .withValues(alpha: 0.2)), const SizedBox(height: AppSpacing.md), Text( - 'No postboxes found within ${AppPreferences.formatShortDistance(AppPreferences.claimRadiusMeters, _distanceUnit)}', + 'No postboxes found within ${AppPreferences.formatShortDistance(RemoteConfigService.instance.claimRadiusMeters, _distanceUnit)}', style: Theme.of(context) .textTheme .titleLarge @@ -1140,7 +1146,7 @@ class _ClaimQuizSheetState extends State : 'All $_count postboxes claimed today') : _claimedToday > 0 ? '${_count - _claimedToday} of $_count available · $_claimedToday claimed today' - : '$_count postbox${_count == 1 ? '' : 'es'} within ${AppPreferences.formatShortDistance(AppPreferences.claimRadiusMeters, _distanceUnit)}', + : '$_count postbox${_count == 1 ? '' : 'es'} within ${AppPreferences.formatShortDistance(RemoteConfigService.instance.claimRadiusMeters, _distanceUnit)}', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), diff --git a/pubspec.yaml b/pubspec.yaml index 5e08594e..91ee47e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: Find postboxes for megapoints # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.3.2+16 +version: 1.4.0+17 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/test/claim_quiz_sheet_test.dart b/test/claim_quiz_sheet_test.dart index 1d1ba9c2..731b35a6 100644 --- a/test/claim_quiz_sheet_test.dart +++ b/test/claim_quiz_sheet_test.dart @@ -46,6 +46,11 @@ class _StubRemoteConfig extends Fake implements FirebaseRemoteConfig { bool getBool(String key) => false; @override String getString(String key) => ''; + // No remote overrides set: 0.0 is out of the claim-radius safety band, so + // RemoteConfigService.claimRadiusMeters falls back to its hard-coded default + // (30 m) — the pre-Remote-Config behaviour. + @override + double getDouble(String key) => 0.0; } Position _fakePos() => Position( diff --git a/test/cross_language_sync_test.dart b/test/cross_language_sync_test.dart index fc08f0ee..41324434 100644 --- a/test/cross_language_sync_test.dart +++ b/test/cross_language_sync_test.dart @@ -32,9 +32,13 @@ // Plain VM tests (use dart:io) — `flutter test` runs with the package root as // the working directory, so the relative paths resolve. +import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/monarch_info.dart'; +import 'package:postbox_game/remote_config_service.dart'; /// Extracts the string literals from the first `[ ... ]` array literal that /// follows [anchor] in [source]. Handles both single- and double-quoted entries @@ -365,4 +369,44 @@ void main() { expect(dart, equals(ts.toDouble())); }); }); + + group('Remote Config game-balance defaults stay in sync', () { + // v1.4: claim_radius_meters + points_by_monarch are Remote-Config-driven, + // but the hard-coded constants remain the canonical fallback. The RC + // *defaults* must therefore equal those constants (ship first with defaults + // == current code), and the server safety bounds (_config.ts) must match + // the client's (RemoteConfigService) so a value the client accepts isn't + // rejected server-side (or vice versa). + + test('RC points_by_monarch default JSON == MonarchInfo.points', () { + final decoded = + (jsonDecode(RemoteConfigService.defaultPointsByMonarchJson) + as Map) + .map((k, v) => MapEntry(k, v as int)); + expect(decoded, equals(MonarchInfo.points)); + }); + + test('RC claim_radius_meters default == AppPreferences.claimRadiusMeters', + () { + expect( + RemoteConfigService.defaults[RemoteConfigService.keyClaimRadiusMeters], + equals(AppPreferences.claimRadiusMeters), + ); + }); + + test('server _config.ts fallback + safety bounds match the Dart client', + () { + final configTs = File('functions/src/_config.ts').readAsStringSync(); + expect(_extractIntConst(configTs, 'DEFAULT_CLAIM_RADIUS_METERS'), + equals(AppPreferences.claimRadiusMeters.toInt())); + expect(_extractIntConst(configTs, 'MIN_CLAIM_RADIUS_METERS'), + equals(RemoteConfigService.minClaimRadiusMeters.toInt())); + expect(_extractIntConst(configTs, 'MAX_CLAIM_RADIUS_METERS'), + equals(RemoteConfigService.maxClaimRadiusMeters.toInt())); + expect(_extractIntConst(configTs, 'MIN_MONARCH_POINTS'), + equals(RemoteConfigService.minMonarchPoints)); + expect(_extractIntConst(configTs, 'MAX_MONARCH_POINTS'), + equals(RemoteConfigService.maxMonarchPoints)); + }); + }); } diff --git a/test/device_id_service_test.dart b/test/device_id_service_test.dart new file mode 100644 index 00000000..ce763b8a --- /dev/null +++ b/test/device_id_service_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:postbox_game/services/device_id_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + DeviceIdService.resetForTest(); + }); + + test('generates a 64-char lowercase-hex id on first use', () async { + final id = await DeviceIdService.get(); + expect(id, isNotNull); + expect(id!.length, equals(64)); + expect(RegExp(r'^[0-9a-f]{64}$').hasMatch(id), isTrue); + }); + + test('returns the same id across calls (persisted)', () async { + final a = await DeviceIdService.get(); + DeviceIdService.resetForTest(); // drop the in-memory cache; prefs keeps it + final b = await DeviceIdService.get(); + expect(a, equals(b)); + }); + + test('reuses an already-stored id', () async { + final existing = 'a' * 64; + SharedPreferences.setMockInitialValues({'device_install_id': existing}); + DeviceIdService.resetForTest(); + expect(await DeviceIdService.get(), equals(existing)); + }); +} diff --git a/test/remote_config_service_test.dart b/test/remote_config_service_test.dart index f66c0b96..a52d9eae 100644 --- a/test/remote_config_service_test.dart +++ b/test/remote_config_service_test.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/monarch_info.dart'; import 'package:postbox_game/remote_config_service.dart'; /// In-process fake of FirebaseRemoteConfig with just the surface @@ -167,6 +169,97 @@ void main() { }); }); + group('RemoteConfigService game balance', () { + test('defaults map includes claim radius and points-by-monarch', () { + expect( + RemoteConfigService.defaults, + containsPair(RemoteConfigService.keyClaimRadiusMeters, + AppPreferences.claimRadiusMeters)); + expect( + RemoteConfigService.defaults, + containsPair(RemoteConfigService.keyPointsByMonarch, + RemoteConfigService.defaultPointsByMonarchJson)); + }); + + test('claimRadiusMeters serves the default after init', () async { + final fake = _FakeRemoteConfig(); + final service = RemoteConfigService(remoteConfig: fake); + await service.init(); + expect(service.claimRadiusMeters, + equals(AppPreferences.claimRadiusMeters)); + }); + + test('claimRadiusMeters applies an in-band remote override', () async { + final fake = _FakeRemoteConfig(remoteValues: { + RemoteConfigService.keyClaimRadiusMeters: 45.0, + }); + final service = RemoteConfigService(remoteConfig: fake); + await service.init(); + expect(service.claimRadiusMeters, equals(45.0)); + }); + + test('claimRadiusMeters falls back when the remote value is out of band', + () async { + final fake = _FakeRemoteConfig(remoteValues: { + RemoteConfigService.keyClaimRadiusMeters: 5000.0, + }); + final service = RemoteConfigService(remoteConfig: fake); + await service.init(); + expect(service.claimRadiusMeters, + equals(AppPreferences.claimRadiusMeters)); + }); + + test('pointsForCipher serves MonarchInfo values by default', () async { + final fake = _FakeRemoteConfig(); + final service = RemoteConfigService(remoteConfig: fake); + await service.init(); + expect(service.pointsForCipher('VR'), equals(MonarchInfo.getPoints('VR'))); + expect(service.pointsForCipher('EIIR'), equals(2)); + }); + + test('pointsForCipher applies a valid remote override', () async { + final fake = _FakeRemoteConfig(remoteValues: { + RemoteConfigService.keyPointsByMonarch: '{"VR":8}', + }); + final service = RemoteConfigService(remoteConfig: fake); + await service.init(); + expect(service.pointsForCipher('VR'), equals(8)); + // Non-overridden cyphers fall back per-key. + expect(service.pointsForCipher('EVIIIR'), equals(12)); + }); + + test('pointsForCipher rejects an out-of-range override wholesale', + () async { + final fake = _FakeRemoteConfig(remoteValues: { + RemoteConfigService.keyPointsByMonarch: '{"VR":999}', + }); + final service = RemoteConfigService(remoteConfig: fake); + await service.init(); + expect(service.pointsForCipher('VR'), equals(MonarchInfo.getPoints('VR'))); + }); + + test('pointsForCipher falls back on invalid JSON', () async { + final fake = _FakeRemoteConfig(remoteValues: { + RemoteConfigService.keyPointsByMonarch: 'not json', + }); + final service = RemoteConfigService(remoteConfig: fake); + await service.init(); + expect(service.pointsForCipher('VR'), equals(MonarchInfo.getPoints('VR'))); + }); + + test('pointsByMonarch merges overrides over the defaults', () async { + final fake = _FakeRemoteConfig(remoteValues: { + RemoteConfigService.keyPointsByMonarch: '{"VR":8}', + }); + final service = RemoteConfigService(remoteConfig: fake); + await service.init(); + final map = service.pointsByMonarch; + expect(map['VR'], equals(8)); + expect(map['EIIR'], equals(2)); + expect(map.length, equals(MonarchInfo.points.length)); + }); + }); + group('RemoteConfigService maintenance flag', () { test('defaults to off with the canned English message', () async { final fake = _FakeRemoteConfig(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 75b76aa8..3dbe8286 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -839,6 +839,35 @@ void main() { }); }); + group('pickCountyLeaderEntry (heatmap friends-only toggle)', () { + // Server-sorted entries: a non-friend leads, a friend is second. + final entries = [ + {'uid': 'stranger', 'displayName': 'Stranger', 'uniquePostboxesClaimed': 9}, + {'uid': 'friend', 'displayName': 'Friend', 'uniquePostboxesClaimed': 5}, + {'uid': 'me', 'displayName': 'Me', 'uniquePostboxesClaimed': 2}, + ]; + final candidates = {'me', 'friend'}; + + test('friends-only picks the top entry within the candidate set', () { + final e = pickCountyLeaderEntry(entries, candidates, true); + expect(e?['uid'], equals('friend')); + }); + + test('global (friends-only off) picks the overall top entry', () { + final e = pickCountyLeaderEntry(entries, candidates, false); + expect(e?['uid'], equals('stranger')); + }); + + test('friends-only returns null when no candidate has claimed the county', () { + final e = pickCountyLeaderEntry(entries, {'nobody'}, true); + expect(e, isNull); + }); + + test('global returns null for an empty county', () { + expect(pickCountyLeaderEntry(const [], candidates, false), isNull); + }); + }); + group('FuzzyCompass.vagueLabel', () { test('returns None for zero', () { expect(FuzzyCompass.vagueLabel(0), equals('None')); From 01674f11dc4f3fdef97716badee9a5746f18e8bc Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Fri, 17 Jul 2026 13:31:57 +0100 Subject: [PATCH 2/2] =?UTF-8?q?feat(v1.4):=20GDPR=20compliance=20finish=20?= =?UTF-8?q?=E2=80=94=20consent,=20export,=20retention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four blockers a full GDPR review of this branch found after the deletion work shipped: no transparency/consent surface, no self-serve right of access, no retention limits, and a partial report anonymisation. - Analytics is now opt-in: manifest-disabled from cold boot, consent collected on a new final intro step (new users) or a one-time ConsentGate prompt (existing installs); Crashlytics/Perf run under legitimate interest with Settings > Privacy opt-outs. All three are driven from ConsentPreferences via applyStoredTelemetryPreferences(). - Privacy policy: single markdown source rendered in-app plus the hosted web/privacy-policy.html brought in line (it falsely claimed location "is not stored permanently"); a sync test pins the pair. - exportMyData callable + Settings "Download my data" returns a JSON bundle of every per-user store (Art. 15/20), mirroring the erasure path's authoritative store list. - dataRetentionSweep (03:30 London): strips claim PII after 90 days (watermark-paged), purges report photos/notes 30 days after review, deletes moderationFlags after 180 days. - anonymiseReportsForUser now strips note + photos (EXIF GPS and uid-bearing storage paths); lat/lng kept so pending missing-box reports stay reviewable. - Hygiene: audit_* exports gitignored and removed, audit CLI defaults to ./audit_exports, deviceIdHash comment corrected. Also fixes pre-existing test breakage: firebase_auth 6.5.5 declares fcpi 7.x compat but uses 8.0 APIs, so every auth-touching Dart test failed to load — bumped firebase_core_platform_interface to ^8.0.0, firebase_auth to ^6.5.6, fake_cloud_firestore to 4.2.0. flutter analyze clean; 464 Dart + 480 functions tests green. Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + android/app/src/main/AndroidManifest.xml | 7 + assets/legal/privacy_policy.md | 88 +++++ docs/plans/ROADMAP.md | 72 +++- functions/audit_user_claims.js | 9 +- functions/src/_accountDeletion.ts | 25 +- functions/src/dataRetention.ts | 295 ++++++++++++++++ functions/src/exportMyData.ts | 167 +++++++++ functions/src/index.ts | 4 + functions/src/startScoring.ts | 10 +- functions/src/test/test.index.ts | 418 +++++++++++++++++++++++ lib/analytics_service.dart | 11 + lib/consent_preferences.dart | 62 ++++ lib/consent_screen.dart | 157 +++++++++ lib/intro.dart | 36 +- lib/legal/privacy_policy_screen.dart | 145 ++++++++ lib/main.dart | 28 +- lib/services/telemetry_consent.dart | 25 ++ lib/settings_screen.dart | 142 ++++++++ pubspec.lock | 140 ++++---- pubspec.yaml | 7 +- test/analytics_service_test.dart | 15 + test/consent_gate_test.dart | 68 ++++ test/consent_preferences_test.dart | 48 +++ test/intro_test.dart | 79 ++++- test/privacy_policy_screen_test.dart | 84 +++++ test/privacy_policy_sync_test.dart | 48 +++ test/telemetry_consent_test.dart | 91 +++++ test/widget_test.dart | 34 ++ web/data-safety.html | 12 + web/privacy-policy.html | 38 ++- 31 files changed, 2238 insertions(+), 132 deletions(-) create mode 100644 assets/legal/privacy_policy.md create mode 100644 functions/src/dataRetention.ts create mode 100644 functions/src/exportMyData.ts create mode 100644 lib/consent_preferences.dart create mode 100644 lib/consent_screen.dart create mode 100644 lib/legal/privacy_policy_screen.dart create mode 100644 lib/services/telemetry_consent.dart create mode 100644 test/consent_gate_test.dart create mode 100644 test/consent_preferences_test.dart create mode 100644 test/privacy_policy_screen_test.dart create mode 100644 test/privacy_policy_sync_test.dart create mode 100644 test/telemetry_consent_test.dart diff --git a/.gitignore b/.gitignore index 25bb823e..c7ae417f 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,11 @@ functions/.last_import_manifest.json /ciiir_postboxes.json /postman_james.svg +# Per-user claim audit exports (functions/audit_user_claims.js) — contain real +# UIDs + GPS movement traces; must never be committed +/audit_* +functions/audit_exports/ + .claude/skills # Desktop is not a build target (mobile-only app; runner scaffolding removed). diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 369b1560..9bea7b4d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,6 +41,13 @@ + + **Status** (branch `feat/v1.4-trust-safety`, `1.4.0+17`): all three workstreams -> implemented; `flutter analyze` + Dart tests + functions lint/tests green. Remaining -> before ship: the manual staging pass for account deletion and the Remote Config -> console setup (publish `claim_radius_meters` + `points_by_monarch` to the client AND -> server templates with values equal to the code defaults). Two roadmap divergences, -> both deliberate: (1) GDPR uses a **self-contained `onUserDeleted` function, not the -> Delete-User-Data extension** (the extension can only delete, not anonymise claims, -> which would break leaderboard integrity); (2) abuse **enforcement/voiding stays -> deferred (Phase 2)** — the ship gate requires shadow mode, so `SHADOW_MODE` remains -> `true`. Remote Config was scoped to the ship-gate pair only (no `quiz_required_streak`, -> other constants left hard-coded). +**Ship gate**: account-deletion flow tested end-to-end in staging; impossible-travel detector in shadow mode logging flags but not blocking; Remote Config drives `claim_radius_meters` and `points_by_monarch` end-to-end (client + Cloud Functions) with the safety bounds enforced; analytics consent gate live (fresh install fires no events before opt-in — verified in DebugView); `exportMyData` returns a complete bundle in staging; `dataRetentionSweep` executed once in staging with logged strip/delete counts. + +> **Status** (branch `feat/v1.4-trust-safety`, `1.4.0+18`): all four workstreams +> implemented (Remote Config balance, GDPR deletion, shadow-mode abuse, and the +> GDPR compliance finish below); `flutter analyze` + Dart tests + functions +> lint/tests green. Remaining before ship: the manual staging passes (account +> deletion, consent DebugView check, export bundle, one forced retention-sweep +> run) and the Remote Config console setup (publish `claim_radius_meters` + +> `points_by_monarch` to the client AND server templates with values equal to +> the code defaults). Two roadmap divergences, both deliberate: (1) GDPR uses a +> **self-contained `onUserDeleted` function, not the Delete-User-Data extension** +> (the extension can only delete, not anonymise claims, which would break +> leaderboard integrity); (2) abuse **enforcement/voiding stays deferred +> (Phase 2)** — the ship gate requires shadow mode, so `SHADOW_MODE` remains +> `true`. Remote Config was scoped to the ship-gate pair only (no +> `quiz_required_streak`, other constants left hard-coded). > **App Check enforcement moved to v1.7** (it's gated on iOS `AppleProvider` > wiring, which lands in v1.7's iOS work). See the App Check item under v1.7. @@ -259,6 +262,49 @@ Admin UI: mirror `lib/admin/admin_reports_screen.dart` as `lib/admin/admin_abuse Shadow mode first — log flags, no user-facing effect — for 2 weeks; tune thresholds from Firestore exports. False-positive guard: require **two** signals (e.g. impossible travel AND repeated device hash) before any action. +### GDPR compliance finish (consent, DSAR export, retention) (new) + +> ✅ Implemented. Closes the four blockers a full GDPR review (2026-07-17) found +> after the deletion work shipped: no consent/transparency surface, no +> self-serve right-of-access, no retention limits, and a partial report +> anonymisation. Also fixed the dependency skew that broke Dart test loading +> (`firebase_core_platform_interface` ^8.0.0 + `fake_cloud_firestore` 4.2.0). + +- **Analytics consent (opt-in)**: `firebase_analytics_collection_enabled=false` + manifest flag keeps the SDK silent from cold boot; consent recorded on the new + final intro step (new users) or the one-time `ConsentGate` prompt in + `lib/consent_screen.dart` (existing installs); Crashlytics/Performance stay on + under legitimate interest. Settings → Privacy has toggles for all three, all + persisted in `lib/consent_preferences.dart` (SharedPreferences, pre-login-safe) + and applied by `applyStoredTelemetryPreferences()` at every cold start. +- **Privacy policy**: single source `assets/legal/privacy_policy.md`, rendered + in-app by `lib/legal/privacy_policy_screen.dart` (no markdown dep) and mirrored + at `web/privacy-policy.html` (hosted at `/privacy-policy`, for the Play + listing); `test/privacy_policy_sync_test.dart` pins the two against each other. + Content now discloses the device token, anti-abuse records, retention + schedule, and in-app rights surfaces. +- **DSAR export (Art. 15/20)**: `exportMyData` callable returns a JSON bundle of + every per-user store (mirrors `_accountDeletion.ts`'s authoritative list, incl. + moderation flags and the Auth record); Settings → Privacy → "Download my data" + shares it as a file via `share_plus`. Claims capped at 20k newest with a + truncation flag (10 MB callable limit). +- **Retention (Art. 5(1)(e))**: `functions/src/dataRetention.ts` nightly sweep + (03:30 London) — strips `CLAIM_PII_FIELDS` off claims after 90 days + (watermark-paged, never rescans), purges report photos/notes 30 days after + review (Storage blobs + doc fields), deletes `moderationFlags` after 180 days. + Sweep chosen over Firestore TTL deliberately (backfill needed either way, + compliance-evidence logs, unit-testable). +- **Erasure gap closed**: `anonymiseReportsForUser` now strips + `REPORT_PII_FIELDS` (note + photos incl. EXIF GPS and uid-bearing storage + paths); report `lat`/`lng` kept for pending-review integrity (documented). +- **Hygiene**: `audit_*` exports gitignored and evicted from the repo root; + `audit_user_claims.js` defaults to `./audit_exports`; `startScoring.ts`'s + deviceIdHash comment corrected (random install token, not a hardware hash). + +Risk: the first sweep night backfills history in bounded pages (~6k docs/night) +— watch the `pagesRemaining` log until the backlog drains. iOS +(`FirebaseAnalyticsCollectionEnabled`) is a v1.7 checklist item. + --- ## v1.5 — Engagement & avatars (Deferred) diff --git a/functions/audit_user_claims.js b/functions/audit_user_claims.js index 4bf4e500..21873164 100644 --- a/functions/audit_user_claims.js +++ b/functions/audit_user_claims.js @@ -10,7 +10,10 @@ * * Usage (run from the functions/ directory so node_modules resolve): * - * node audit_user_claims.js --uid --project the-postbox-game [--out-dir ./audit] + * node audit_user_claims.js --uid --project the-postbox-game [--out-dir ./audit_exports] + * + * Output defaults to ./audit_exports (gitignored) — reports contain real UIDs + * and GPS movement traces, so they must never land somewhere committable. * * Authentication: * Set GOOGLE_APPLICATION_CREDENTIALS to a service account JSON path, OR run @@ -47,7 +50,7 @@ function classifySpeed(mPerMin) { } function parseArgs(argv) { - const opts = { uid: null, projectId: null, outDir: '.', help: false }; + const opts = { uid: null, projectId: null, outDir: './audit_exports', help: false }; const args = argv.slice(2); for (let i = 0; i < args.length; i++) { const a = args[i]; @@ -61,7 +64,7 @@ function parseArgs(argv) { } function usage() { - console.log(`Usage: node audit_user_claims.js --uid --project [--out-dir ]`); + console.log(`Usage: node audit_user_claims.js --uid --project [--out-dir ] (default out-dir: ./audit_exports)`); } function median(arr) { diff --git a/functions/src/_accountDeletion.ts b/functions/src/_accountDeletion.ts index 4d126d79..9e6513b6 100644 --- a/functions/src/_accountDeletion.ts +++ b/functions/src/_accountDeletion.ts @@ -11,8 +11,20 @@ const WRITE_BATCH_SIZE = 400; /** Firestore field names on a claim that carry location/timing PII and must be * removed when a claim is anonymised. `points`/`monarch`/`dailyDate`/etc. are - * kept so the global claim/postbox-history record stays intact. */ -const CLAIM_PII_FIELDS = ["userLat", "userLng", "coordKey6", "clientTsMs", "travelSpeed", "deviceIdHash"] as const; + * kept so the global claim/postbox-history record stays intact. Exported for + * reuse by the 90-day retention sweep (dataRetention.ts) so erasure and + * retention can never disagree about what counts as claim PII. */ +export const CLAIM_PII_FIELDS = ["userLat", "userLng", "coordKey6", "clientTsMs", "travelSpeed", "deviceIdHash"] as const; + +/** Firestore field names on a report that carry PII and must be removed when a + * report is anonymised: `note` is free text, and `photos[]` embeds EXIF GPS + * (`exifLat`/`exifLng`), capture times, and `report_photos/{uid}/…` storage + * paths (the blobs themselves are deleted by onUserDeleted's Storage phase). + * `lat`/`lng` are deliberately KEPT: reviewReport requires them to create the + * postbox for a still-pending missing-box report, and once `reporterUid` is + * the deleted sentinel they describe a public postbox site, unlinkable to a + * person — the same integrity carve-out as claims' `points`/`monarch`. */ +export const REPORT_PII_FIELDS = ["note", "photos"] as const; /** Anonymise every claim by [uid]: rewrite `userid` to the deleted sentinel and * strip location PII. Returns the number of claims rewritten. Batched (400). */ @@ -36,15 +48,20 @@ export async function anonymiseClaimsForUser(db: Firestore, uid: string): Promis } /** Anonymise every report submitted by [uid]: rewrite `reporterUid` to the - * deleted sentinel, keeping the report content for data/OSM integrity. */ + * deleted sentinel and strip REPORT_PII_FIELDS (note + photos), keeping the + * postbox-describing fields (`lat`/`lng`/cypher/status) for data/OSM + * integrity — see the REPORT_PII_FIELDS doc for the reasoning. */ export async function anonymiseReportsForUser(db: Firestore, uid: string): Promise { const snap = await db.collection("reports").where("reporterUid", "==", uid).get(); + const update: Record = { reporterUid: DELETED_UID }; + for (const f of REPORT_PII_FIELDS) update[f] = admin.firestore.FieldValue.delete(); + let rewritten = 0; const batches: admin.firestore.WriteBatch[] = []; for (let i = 0; i < snap.docs.length; i += WRITE_BATCH_SIZE) { const batch = db.batch(); for (const doc of snap.docs.slice(i, i + WRITE_BATCH_SIZE)) { - batch.set(doc.ref, { reporterUid: DELETED_UID }, { merge: true }); + batch.set(doc.ref, update, { merge: true }); rewritten++; } batches.push(batch); diff --git a/functions/src/dataRetention.ts b/functions/src/dataRetention.ts new file mode 100644 index 00000000..1f0bc071 --- /dev/null +++ b/functions/src/dataRetention.ts @@ -0,0 +1,295 @@ +// Scheduled GDPR retention sweep — Art. 5(1)(e) storage limitation. +// +// Retention policy (documented in the privacy policy; keep the two in sync): +// claims — location/timing/device PII fields stripped after 90 days +// (CLAIM_PII_FIELDS from _accountDeletion.ts; the claim record +// itself, points/monarch/dailyDate, is kept for game history) +// reports — photos (Storage blobs + doc array) and free-text note +// removed 30 days after review (pending reports untouched) +// moderationFlags — deleted whole after 180 days +// +// Mechanics: nightly onSchedule at 03:30 Europe/London (avoids the 00:00 +// newDayScoreboard rollover). Claims and reports use a persistent WATERMARK +// (retention/{claimsSweep,reportsSweep}) so the sweep never rescans ranges it +// has already processed: each run pages forward from the watermark to the +// cutoff with a document cursor, persisting the watermark after every page +// (crash-safe resume; a re-run only re-reads docs sharing the boundary +// timestamp, which the needs-strip predicates then skip). Invariant this relies +// on: claims/reports are only ever written with server-side `Timestamp.now()` +// timestamps (startScoring.ts / reports.ts) — nothing back-dates a doc behind +// the watermark. moderationFlags need no watermark (deletion is self-cleaning). +// +// Firestore TTL (fieldOverrides + an expiresAt field) was considered for the +// flags and rejected for now: existing docs carry no expiry field (a backfill +// would be sweep-like code anyway), TTL deletes lag up to ~72 h and emit no +// compliance-evidence logs, and shadow-mode flag volume is tiny. Revisit if +// volume grows. + +import "./adminInit"; +import * as admin from "firebase-admin"; +import { onSchedule } from "firebase-functions/v2/scheduler"; +import { CLAIM_PII_FIELDS, REPORT_PII_FIELDS } from "./_accountDeletion"; + +type Firestore = admin.firestore.Firestore; + +export const CLAIM_PII_RETENTION_DAYS = 90; +export const REPORT_MEDIA_RETENTION_DAYS = 30; // after review +export const FLAG_RETENTION_DAYS = 180; + +/** Docs fetched per query page. */ +export const PAGE_SIZE = 300; +/** Pages processed per collection per nightly run (backfill drains gradually). */ +export const MAX_PAGES_PER_RUN = 20; +const WRITE_BATCH_SIZE = 400; // matches _accountDeletion.ts +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Minimal structural view of a Storage bucket so tests can inject a fake. */ +export interface StorageBucketLike { + file(path: string): { delete(): Promise }; +} + +/** Page-size/page-count overrides for tests. */ +export interface SweepOptions { + pageSize?: number; + maxPages?: number; +} + +export interface ClaimSweepResult { + scanned: number; + stripped: number; + pagesRemaining: boolean; +} + +export interface ReportSweepResult { + scanned: number; + purged: number; + filesDeleted: number; + pagesRemaining: boolean; +} + +export interface FlagSweepResult { + deleted: number; + pagesRemaining: boolean; +} + +/** True when the claim still carries any PII field the sweep must strip. + * Already-anonymised claims (account deletion) and already-swept claims + * return false, keeping re-reads of watermark-boundary docs write-free. */ +export function claimNeedsPiiStrip(data: Record): boolean { + return CLAIM_PII_FIELDS.some((f) => f in data); +} + +/** True when the report still carries purgeable media/note fields. */ +export function reportNeedsMediaPurge(data: Record): boolean { + return REPORT_PII_FIELDS.some((f) => f in data); +} + +/** The Storage paths of a report's photos ([] when none/malformed). */ +export function reportStoragePaths(data: Record): string[] { + const photos = data.photos; + if (!Array.isArray(photos)) return []; + return photos + .map((p) => + p !== null && typeof p === "object" ? (p as Record).storagePath : undefined, + ) + .filter((s): s is string => typeof s === "string" && s.length > 0); +} + +/** Read the persisted watermark for [stateDocId] (epoch 0 on first run). */ +async function readWatermark(db: Firestore, stateDocId: string): Promise { + const snap = await db.collection("retention").doc(stateDocId).get(); + const wm = snap.data()?.watermark as admin.firestore.Timestamp | undefined; + return wm ?? admin.firestore.Timestamp.fromMillis(0); +} + +async function writeWatermark(db: Firestore, stateDocId: string, wm: admin.firestore.Timestamp): Promise { + await db.collection("retention").doc(stateDocId).set( + { watermark: wm, updatedAt: admin.firestore.FieldValue.serverTimestamp() }, + { merge: true }, + ); +} + +/** Commit [refs]→[update] merge-sets in WRITE_BATCH_SIZE chunks. */ +async function batchedMergeSet( + db: Firestore, + targets: Array<{ ref: FirebaseFirestore.DocumentReference; update: Record }>, +): Promise { + const batches: admin.firestore.WriteBatch[] = []; + for (let i = 0; i < targets.length; i += WRITE_BATCH_SIZE) { + const batch = db.batch(); + for (const t of targets.slice(i, i + WRITE_BATCH_SIZE)) batch.set(t.ref, t.update, { merge: true }); + batches.push(batch); + } + await Promise.all(batches.map((b) => b.commit())); +} + +export async function sweepClaimPii( + db: Firestore, + nowMs: number, + opts: SweepOptions = {}, +): Promise { + const pageSize = opts.pageSize ?? PAGE_SIZE; + const maxPages = opts.maxPages ?? MAX_PAGES_PER_RUN; + const cutoff = admin.firestore.Timestamp.fromMillis(nowMs - CLAIM_PII_RETENTION_DAYS * DAY_MS); + const watermark = await readWatermark(db, "claimsSweep"); + + const stripUpdate: Record = {}; + for (const f of CLAIM_PII_FIELDS) stripUpdate[f] = admin.firestore.FieldValue.delete(); + + let scanned = 0; + let stripped = 0; + let cursor: FirebaseFirestore.QueryDocumentSnapshot | undefined; + for (let page = 0; page < maxPages; page++) { + let q = db + .collection("claims") + .where("timestamp", ">=", watermark) + .where("timestamp", "<", cutoff) + .orderBy("timestamp") + .limit(pageSize); + if (cursor !== undefined) q = q.startAfter(cursor); + // Pages are inherently sequential: each depends on the previous cursor, and + // the watermark must land before the next page for crash-safe resume. + // eslint-disable-next-line no-await-in-loop + const snap = await q.get(); + if (snap.docs.length === 0) return { scanned, stripped, pagesRemaining: false }; + + scanned += snap.docs.length; + const dirty = snap.docs.filter((d) => claimNeedsPiiStrip(d.data())); + // eslint-disable-next-line no-await-in-loop + await batchedMergeSet(db, dirty.map((d) => ({ ref: d.ref, update: stripUpdate }))); + stripped += dirty.length; + + cursor = snap.docs[snap.docs.length - 1]; + // eslint-disable-next-line no-await-in-loop + await writeWatermark(db, "claimsSweep", cursor.data().timestamp as admin.firestore.Timestamp); + if (snap.docs.length < pageSize) return { scanned, stripped, pagesRemaining: false }; + } + return { scanned, stripped, pagesRemaining: true }; +} + +export async function sweepReportMedia( + db: Firestore, + bucket: StorageBucketLike, + nowMs: number, + opts: SweepOptions = {}, +): Promise { + const pageSize = opts.pageSize ?? PAGE_SIZE; + const maxPages = opts.maxPages ?? MAX_PAGES_PER_RUN; + const cutoff = admin.firestore.Timestamp.fromMillis(nowMs - REPORT_MEDIA_RETENTION_DAYS * DAY_MS); + const watermark = await readWatermark(db, "reportsSweep"); + + let scanned = 0; + let purged = 0; + let filesDeleted = 0; + let cursor: FirebaseFirestore.QueryDocumentSnapshot | undefined; + for (let page = 0; page < maxPages; page++) { + // Pending reports never have `reviewedAt`, so the range query structurally + // excludes them — only reviewed reports age out. + let q = db + .collection("reports") + .where("reviewedAt", ">=", watermark) + .where("reviewedAt", "<", cutoff) + .orderBy("reviewedAt") + .limit(pageSize); + if (cursor !== undefined) q = q.startAfter(cursor); + // Sequential paging — same cursor/watermark reasoning as sweepClaimPii. + // eslint-disable-next-line no-await-in-loop + const snap = await q.get(); + if (snap.docs.length === 0) return { scanned, purged, filesDeleted, pagesRemaining: false }; + + scanned += snap.docs.length; + const toPurge = snap.docs.filter((d) => reportNeedsMediaPurge(d.data())); + + // Delete the Storage blobs first so a crash between the two steps leaves + // the doc still flagged for purge (re-run safe), never orphaned blobs. + for (const doc of toPurge) { + for (const path of reportStoragePaths(doc.data())) { + try { + // Deliberately serial: bounds Storage concurrency on a background job. + // eslint-disable-next-line no-await-in-loop + await bucket.file(path).delete(); + filesDeleted++; + } catch { + // Already gone (e.g. the reporter deleted their account) — fine. + } + } + } + + const purgedAt = admin.firestore.Timestamp.fromMillis(nowMs); + const update: Record = { mediaPurgedAt: purgedAt }; + for (const f of REPORT_PII_FIELDS) update[f] = admin.firestore.FieldValue.delete(); + // eslint-disable-next-line no-await-in-loop + await batchedMergeSet(db, toPurge.map((d) => ({ ref: d.ref, update }))); + purged += toPurge.length; + + cursor = snap.docs[snap.docs.length - 1]; + // eslint-disable-next-line no-await-in-loop + await writeWatermark(db, "reportsSweep", cursor.data().reviewedAt as admin.firestore.Timestamp); + if (snap.docs.length < pageSize) return { scanned, purged, filesDeleted, pagesRemaining: false }; + } + return { scanned, purged, filesDeleted, pagesRemaining: true }; +} + +export async function sweepModerationFlags( + db: Firestore, + nowMs: number, + opts: SweepOptions = {}, +): Promise { + const pageSize = opts.pageSize ?? PAGE_SIZE; + const maxPages = opts.maxPages ?? MAX_PAGES_PER_RUN; + const cutoff = admin.firestore.Timestamp.fromMillis(nowMs - FLAG_RETENTION_DAYS * DAY_MS); + + let deleted = 0; + for (let page = 0; page < maxPages; page++) { + // No watermark: deletion is self-cleaning (removed docs leave the result + // set), so each page re-queries from the top — sequential by design. + // eslint-disable-next-line no-await-in-loop + const snap = await db + .collection("moderationFlags") + .where("createdAt", "<", cutoff) + .orderBy("createdAt") + .limit(pageSize) + .get(); + if (snap.docs.length === 0) return { deleted, pagesRemaining: false }; + + const batches: admin.firestore.WriteBatch[] = []; + for (let i = 0; i < snap.docs.length; i += WRITE_BATCH_SIZE) { + const batch = db.batch(); + for (const doc of snap.docs.slice(i, i + WRITE_BATCH_SIZE)) batch.delete(doc.ref); + batches.push(batch); + } + // eslint-disable-next-line no-await-in-loop + await Promise.all(batches.map((b) => b.commit())); + deleted += snap.docs.length; + if (snap.docs.length < pageSize) return { deleted, pagesRemaining: false }; + } + return { deleted, pagesRemaining: true }; +} + +/** Nightly retention sweep. Three independent phases — a failure in one must + * not stop the others; each logs its counts for compliance evidence. */ +export const dataRetentionSweep = onSchedule( + { schedule: "30 3 * * *", timeZone: "Europe/London" }, + async () => { + const db = admin.firestore(); + const nowMs = Date.now(); + try { + const r = await sweepClaimPii(db, nowMs); + console.log(`[retention] claims: scanned=${r.scanned} stripped=${r.stripped} more=${r.pagesRemaining}`); + } catch (e) { + console.error("[retention] claim sweep failed:", e); + } + try { + const r = await sweepReportMedia(db, admin.storage().bucket(), nowMs); + console.log(`[retention] reports: scanned=${r.scanned} purged=${r.purged} files=${r.filesDeleted} more=${r.pagesRemaining}`); + } catch (e) { + console.error("[retention] report sweep failed:", e); + } + try { + const r = await sweepModerationFlags(db, nowMs); + console.log(`[retention] flags: deleted=${r.deleted} more=${r.pagesRemaining}`); + } catch (e) { + console.error("[retention] flag sweep failed:", e); + } + }, +); diff --git a/functions/src/exportMyData.ts b/functions/src/exportMyData.ts new file mode 100644 index 00000000..afef669d --- /dev/null +++ b/functions/src/exportMyData.ts @@ -0,0 +1,167 @@ +// exportMyData — GDPR Art. 15 (access) / Art. 20 (portability) self-serve export. +// +// Returns a single JSON bundle of everything stored about the calling user, +// mirroring the authoritative per-user store list in _accountDeletion.ts (the +// erasure and access surfaces must never disagree about what exists): +// users/{uid} + countyStats, claims, reports, moderationFlags (anti-abuse +// profiling records are access-subject data too), fcmTokens, reportQuotas, +// trustScores, leaderboard entries, and the Firebase Auth record (email lives +// only in Auth). The client offers it as Settings → "Download my data". + +import "./adminInit"; +import * as admin from "firebase-admin"; +import * as functions from "firebase-functions"; + +/** Newest-first cap on exported claims: ~300 B/doc keeps the worst case ~6 MB, + * under the 10 MB callable response limit. `claimsTruncated` flags the cut. */ +export const EXPORT_CLAIMS_CAP = 20000; + +/** Recursively convert Firestore types to portable JSON: Timestamp → ISO-8601 + * string, GeoPoint → {lat, lng}. Everything else passes through. */ +export function serialiseForExport(v: unknown): unknown { + if (v === null || v === undefined) return null; + if (v instanceof admin.firestore.Timestamp) return v.toDate().toISOString(); + if (v instanceof admin.firestore.GeoPoint) return { lat: v.latitude, lng: v.longitude }; + if (Array.isArray(v)) return v.map(serialiseForExport); + if (typeof v === "object") { + const out: Record = {}; + for (const [k, val] of Object.entries(v)) out[k] = serialiseForExport(val); + return out; + } + return v; +} + +export interface ExportParts { + uid: string; + generatedAtIso: string; + account: Record | null; + profile: Record | null; + countyStats: Array>; + claims: Array>; + claimsTruncated: boolean; + reports: Array>; + moderationFlags: Array>; + trustScore: Record | null; + reportQuota: Record | null; + fcmTokens: Record | null; + leaderboards: Array<{ board: string; entry: Record }>; +} + +/** Assemble the versioned response bundle (all values run through + * serialiseForExport). Pure — exported for unit testing. */ +export function buildExportBundle(parts: ExportParts): Record { + return { + exportVersion: 1, + generatedAt: parts.generatedAtIso, + uid: parts.uid, + account: serialiseForExport(parts.account), + profile: serialiseForExport(parts.profile), + countyStats: serialiseForExport(parts.countyStats), + claims: serialiseForExport(parts.claims), + claimsTruncated: parts.claimsTruncated, + reports: serialiseForExport(parts.reports), + moderationFlags: serialiseForExport(parts.moderationFlags), + trustScore: serialiseForExport(parts.trustScore), + reportQuota: serialiseForExport(parts.reportQuota), + fcmTokens: serialiseForExport(parts.fcmTokens), + leaderboards: serialiseForExport(parts.leaderboards), + notes: { + photos: + "Files referenced by reports[].photos[].storagePath are available on request " + + "(they are your uploaded report photos).", + moderationFlags: + "Internal anti-abuse anomaly records about your claims; they have no effect on " + + "your account while the detector runs in shadow mode.", + retention: + "Claim location/device metadata is removed after 90 days, report photos and notes " + + "30 days after review, and moderation records after 180 days.", + }, + }; +} + +export const exportMyData = functions.https.onCall(async (request) => { + const uid = request.auth?.uid; + if (!uid) { + throw new functions.https.HttpsError("unauthenticated", "Must be signed in to export your data"); + } + + const db = admin.firestore(); + const withId = (d: admin.firestore.QueryDocumentSnapshot | admin.firestore.DocumentSnapshot) => + ({ id: d.id, ...(d.data() ?? {}) }) as Record; + + const [ + userSnap, + countySnap, + claimsSnap, + reportsSnap, + flagsSnap, + fcmSnap, + quotaSnap, + trustSnap, + authUser, + ] = await Promise.all([ + db.collection("users").doc(uid).get(), + db.collection("users").doc(uid).collection("countyStats").get(), + // No orderBy: userid-equality alone is served by the single-field index; + // sorting/capping happens in memory to avoid a new composite index. + db.collection("claims").where("userid", "==", uid).get(), + db.collection("reports").where("reporterUid", "==", uid).get(), + db.collection("moderationFlags").where("uid", "==", uid).get(), + db.collection("fcmTokens").doc(uid).get(), + db.collection("reportQuotas").doc(uid).get(), + db.collection("trustScores").doc(uid).get(), + admin.auth().getUser(uid).catch(() => null), + ]); + + const claims = claimsSnap.docs + .map(withId) + .sort((a, b) => { + const ta = (a.timestamp as admin.firestore.Timestamp | undefined)?.toMillis?.() ?? 0; + const tb = (b.timestamp as admin.firestore.Timestamp | undefined)?.toMillis?.() ?? 0; + return tb - ta; // newest first + }); + const claimsTruncated = claims.length > EXPORT_CLAIMS_CAP; + + const countySlugs = countySnap.docs.map((d) => d.id); + const boardRefs = [ + ...["daily", "weekly", "monthly", "lifetime"].map((p) => ({ + board: p, + ref: db.collection("leaderboards").doc(p), + })), + ...countySlugs.map((slug) => ({ + board: `lifetime_by_county/${slug}`, + ref: db.collection("leaderboards").doc("lifetime_by_county").collection("counties").doc(slug), + })), + ]; + const boardSnaps = await Promise.all(boardRefs.map((b) => b.ref.get())); + const leaderboards: Array<{ board: string; entry: Record }> = []; + boardSnaps.forEach((snap, i) => { + const entries = (snap.data()?.entries ?? []) as Array>; + for (const e of entries) { + if (e.uid === uid) leaderboards.push({ board: boardRefs[i].board, entry: e }); + } + }); + + return buildExportBundle({ + uid, + generatedAtIso: new Date().toISOString(), + account: authUser + ? { + email: authUser.email ?? null, + displayName: authUser.displayName ?? null, + createdAt: authUser.metadata.creationTime ?? null, + providers: authUser.providerData.map((p) => p.providerId), + } + : null, + profile: userSnap.exists ? withId(userSnap) : null, + countyStats: countySnap.docs.map(withId), + claims: claims.slice(0, EXPORT_CLAIMS_CAP), + claimsTruncated, + reports: reportsSnap.docs.map(withId), + moderationFlags: flagsSnap.docs.map(withId), + trustScore: trustSnap.exists ? withId(trustSnap) : null, + reportQuota: quotaSnap.exists ? withId(quotaSnap) : null, + fcmTokens: fcmSnap.exists ? withId(fcmSnap) : null, + leaderboards, + }); +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index 1a9c97bb..0d918443 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -12,6 +12,8 @@ import { userClaimHistory } from "./userClaimHistory"; import { submitReport, reviewReport } from "./reports"; import { routePostboxes } from "./routePostboxes"; import { onClaimCreated, reviewFlag } from "./abuse"; +import { dataRetentionSweep } from "./dataRetention"; +import { exportMyData } from "./exportMyData"; export { nearbyPostboxes, @@ -29,4 +31,6 @@ export { routePostboxes, onClaimCreated, reviewFlag, + dataRetentionSweep, + exportMyData, }; diff --git a/functions/src/startScoring.ts b/functions/src/startScoring.ts index 1d71f399..95e87321 100644 --- a/functions/src/startScoring.ts +++ b/functions/src/startScoring.ts @@ -48,10 +48,12 @@ interface StartScoringCallData { /** Client wall-clock at claim time (ms since epoch). Optional — legacy/web * clients omit it. Stored for the shadow-mode out-of-window anomaly signal. */ clientTsMs?: number; - /** SHA-256 hash of the client's Firebase installation id. Optional — legacy/ - * web clients omit it. Stored for the shadow-mode repeated-device signal - * (one install claiming across many accounts). Hashed client-side; treated - * as PII and stripped on account deletion (_accountDeletion.ts). */ + /** Random 256-bit per-install token (64 hex chars), generated and persisted + * by lib/services/device_id_service.dart — deliberately NOT derived from + * hardware or any Firebase id. Optional — legacy/web clients omit it. Stored + * for the shadow-mode repeated-device signal (one install claiming across + * many accounts). Treated as PII: stripped on account deletion + * (_accountDeletion.ts) and by the 90-day retention sweep (dataRetention.ts). */ deviceIdHash?: string; } diff --git a/functions/src/test/test.index.ts b/functions/src/test/test.index.ts index ff1349e6..c09de841 100644 --- a/functions/src/test/test.index.ts +++ b/functions/src/test/test.index.ts @@ -3848,6 +3848,28 @@ describe("account deletion: anonymiseReportsForUser (mock Firestore)", () => { assert.strictEqual(writes[0].id, "r1"); assert.strictEqual(writes[0].update.reporterUid, DELETED_UID); }); + + it("strips note and photos (PII) but keeps lat/lng/status/type for data integrity", async () => { + const { db, writes } = makeDb([ + { id: "r1", reporterUid: "u1" }, + ]); + await anonymiseReportsForUser(db, "u1"); + assert.strictEqual(writes.length, 1); + const update = writes[0].update; + // note (free text) and photos (EXIF GPS + uid-bearing storage paths) must be + // FieldValue.delete() sentinels (objects, not values). + for (const f of ["note", "photos"]) { + assert.ok(f in update, `${f} should be in the update`); + assert.notStrictEqual(typeof update[f], "string"); + assert.ok(!Array.isArray(update[f]), `${f} must not survive as an array`); + } + // lat/lng stay: reviewReport needs them to create the postbox for a + // still-pending missing-box report, and post-anonymisation they describe a + // public postbox site, unlinkable to a person. status/type also untouched. + for (const f of ["lat", "lng", "status", "type"]) { + assert.ok(!(f in update), `${f} must not be touched by anonymisation`); + } + }); }); describe("account deletion: removeUserFromFriends (mock Firestore)", () => { @@ -4147,3 +4169,399 @@ describe("sendStreakReminders", () => { assert.deepStrictEqual(sent, ["u2"]); }); }); + +// ── Data retention (GDPR) sweep (unit, mock Firestore) ──────────────────────── +import * as adminRet from "firebase-admin"; +import { + claimNeedsPiiStrip, + reportNeedsMediaPurge, + reportStoragePaths, + sweepClaimPii, + sweepReportMedia, + sweepModerationFlags, + CLAIM_PII_RETENTION_DAYS, + REPORT_MEDIA_RETENTION_DAYS, + FLAG_RETENTION_DAYS, +} from "../dataRetention"; + +const RET_NOW = 1_800_000_000_000; // fixed "now" for deterministic cutoffs +const RET_DAY = 24 * 60 * 60 * 1000; +const retTs = (daysAgo: number) => adminRet.firestore.Timestamp.fromMillis(RET_NOW - daysAgo * RET_DAY); + +type RetDoc = Record & { id: string }; + +/** Shared mock Firestore for the sweeps: a single swept collection filtered on + * one timestamp field (range + orderBy + limit + startAfter cursor), plus the + * `retention` watermark-state collection, plus batch set/delete that mutate + * the live doc array so re-reads observe strips/deletes (as Firestore would). */ +function makeRetentionDb(config: { + collectionName: string; + tsField: string; + docs: RetDoc[]; + initialState?: Record; +}) { + const writes: Array<{ id: string; update: Record }> = []; + const deletes: string[] = []; + const stateWrites: Array> = []; + let state: Record | undefined = config.initialState; + let docs = config.docs.map((d) => ({ ...d })); + + const tsMillis = (d: RetDoc) => + (d[config.tsField] as adminRet.firestore.Timestamp | undefined)?.toMillis?.(); + + const db = { + collection(name: string) { + if (name === "retention") { + return { + doc: () => ({ + async get() { + return { exists: state !== undefined, data: () => state }; + }, + async set(v: Record) { + state = { ...(state ?? {}), ...v }; + stateWrites.push(v); + }, + }), + }; + } + if (name !== config.collectionName) throw new Error(`unexpected collection ${name}`); + let gte: number | undefined; + let lt: number | undefined; + let lim = Infinity; + let afterId: string | undefined; + const q = { + where(field: string, op: string, val: unknown) { + assert.strictEqual(field, config.tsField); + const ms = (val as adminRet.firestore.Timestamp).toMillis(); + if (op === ">=") gte = ms; + else if (op === "<") lt = ms; + else throw new Error(`unexpected op ${op}`); + return q; + }, + orderBy(field: string) { + assert.strictEqual(field, config.tsField); + return q; + }, + limit(n: number) { + lim = n; + return q; + }, + startAfter(snap: { id: string }) { + afterId = snap.id; + return q; + }, + async get() { + let matched = docs + .filter((d) => tsMillis(d) !== undefined) + .filter( + (d) => + (gte === undefined || (tsMillis(d) as number) >= gte) && + (lt === undefined || (tsMillis(d) as number) < lt), + ) + .sort((a, b) => (tsMillis(a) as number) - (tsMillis(b) as number)); + if (afterId !== undefined) { + const idx = matched.findIndex((d) => d.id === afterId); + matched = matched.slice(idx + 1); + } + matched = matched.slice(0, lim); + return { + docs: matched.map((d) => ({ ref: { id: d.id }, id: d.id, data: () => d })), + }; + }, + }; + return q; + }, + batch() { + const ops: Array<() => void> = []; + return { + set(ref: { id: string }, update: Record) { + ops.push(() => { + writes.push({ id: ref.id, update }); + const doc = docs.find((d) => d.id === ref.id); + if (doc) { + for (const [k, v] of Object.entries(update)) { + // FieldValue.delete() sentinel = plain object (not a Timestamp). + if (v !== null && typeof v === "object" && !(v instanceof adminRet.firestore.Timestamp)) { + delete doc[k]; + } else { + doc[k] = v; + } + } + } + }); + }, + delete(ref: { id: string }) { + ops.push(() => { + deletes.push(ref.id); + docs = docs.filter((d) => d.id !== ref.id); + }); + }, + async commit() { + for (const f of ops) f(); + }, + }; + }, + }; + return { + db: db as unknown as import("firebase-admin").firestore.Firestore, + writes, + deletes, + stateWrites, + getState: () => state, + getDocs: () => docs, + }; +} + +describe("data retention: policy constants", () => { + it("pins the documented retention windows", () => { + assert.strictEqual(CLAIM_PII_RETENTION_DAYS, 90); + assert.strictEqual(REPORT_MEDIA_RETENTION_DAYS, 30); + assert.strictEqual(FLAG_RETENTION_DAYS, 180); + }); +}); + +describe("data retention: claimNeedsPiiStrip", () => { + it("true when any claim PII field is present", () => { + assert.strictEqual(claimNeedsPiiStrip({ userLat: 51.4, points: 9 }), true); + assert.strictEqual(claimNeedsPiiStrip({ deviceIdHash: "ab" }), true); + assert.strictEqual(claimNeedsPiiStrip({ clientTsMs: 5 }), true); + }); + it("false for already-clean / already-anonymised claims", () => { + assert.strictEqual(claimNeedsPiiStrip({ userid: "deleted", points: 9, monarch: "VR" }), false); + assert.strictEqual(claimNeedsPiiStrip({}), false); + }); +}); + +describe("data retention: reportNeedsMediaPurge / reportStoragePaths", () => { + it("needs purge when note or photos survive", () => { + assert.strictEqual(reportNeedsMediaPurge({ note: "hi" }), true); + assert.strictEqual(reportNeedsMediaPurge({ photos: [] }), true); + assert.strictEqual(reportNeedsMediaPurge({ lat: 51, lng: -2, status: "accepted" }), false); + }); + it("extracts storage paths, ignoring malformed entries", () => { + assert.deepStrictEqual( + reportStoragePaths({ + photos: [ + { storagePath: "report_photos/u1/a.jpg", exifLat: 51 }, + { storagePath: "" }, + "junk", + { other: 1 }, + { storagePath: "report_photos/u1/b.png" }, + ], + }), + ["report_photos/u1/a.jpg", "report_photos/u1/b.png"], + ); + assert.deepStrictEqual(reportStoragePaths({}), []); + assert.deepStrictEqual(reportStoragePaths({ photos: "nope" }), []); + }); +}); + +describe("data retention: sweepClaimPii (mock Firestore)", () => { + it("strips PII from claims past 90 days only, skips clean docs, advances the watermark", async () => { + const { db, writes, getState } = makeRetentionDb({ + collectionName: "claims", + tsField: "timestamp", + docs: [ + { id: "old-dirty", timestamp: retTs(100), userLat: 51.4, userLng: -2.6, deviceIdHash: "ab", points: 9 }, + { id: "old-clean", timestamp: retTs(95), userid: "deleted", points: 4 }, + { id: "recent-dirty", timestamp: retTs(10), userLat: 51.5, points: 2 }, + ], + }); + const r = await sweepClaimPii(db, RET_NOW); + assert.strictEqual(r.scanned, 2, "only the two >90d docs are scanned"); + assert.strictEqual(r.stripped, 1, "only the dirty old doc is written"); + assert.strictEqual(r.pagesRemaining, false); + assert.strictEqual(writes.length, 1); + assert.strictEqual(writes[0].id, "old-dirty"); + for (const f of ["userLat", "userLng", "coordKey6", "clientTsMs", "travelSpeed", "deviceIdHash"]) { + assert.ok(f in writes[0].update, `${f} in strip update`); + } + assert.strictEqual("points" in writes[0].update, false, "points untouched"); + const wm = getState()?.watermark as adminRet.firestore.Timestamp; + assert.ok(wm, "watermark persisted"); + assert.strictEqual(wm.toMillis(), retTs(95).toMillis(), "watermark = newest processed doc"); + }); + + it("pages with a cursor, honours maxPages, and resumes from the watermark next run", async () => { + const mk = () => + makeRetentionDb({ + collectionName: "claims", + tsField: "timestamp", + docs: [ + { id: "a", timestamp: retTs(120), userLat: 1 }, + { id: "b", timestamp: retTs(110), userLat: 2 }, + { id: "c", timestamp: retTs(100), userLat: 3 }, + ], + }); + const { db, writes, getState } = mk(); + const r1 = await sweepClaimPii(db, RET_NOW, { pageSize: 1, maxPages: 2 }); + assert.strictEqual(r1.stripped, 2); + assert.strictEqual(r1.pagesRemaining, true); + assert.deepStrictEqual(writes.map((w) => w.id), ["a", "b"]); + + // Second run resumes from the persisted watermark and finishes the backlog. + const r2 = await sweepClaimPii(db, RET_NOW, { pageSize: 1, maxPages: 5 }); + assert.strictEqual(r2.stripped, 1); + assert.strictEqual(r2.pagesRemaining, false); + assert.deepStrictEqual(writes.map((w) => w.id), ["a", "b", "c"]); + const wm = getState()?.watermark as adminRet.firestore.Timestamp; + assert.strictEqual(wm.toMillis(), retTs(100).toMillis()); + }); +}); + +describe("data retention: sweepReportMedia (mock Firestore + fake bucket)", () => { + it("purges photos+note of reports reviewed >30 days ago; pending and recent untouched", async () => { + const { db, writes, getDocs } = makeRetentionDb({ + collectionName: "reports", + tsField: "reviewedAt", + docs: [ + { + id: "old-reviewed", + reviewedAt: retTs(40), + status: "accepted", + lat: 51.4, + note: "cypher looks like GR", + photos: [ + { storagePath: "report_photos/u1/a.jpg", exifLat: 51.4 }, + { storagePath: "report_photos/u1/gone.jpg" }, + ], + }, + { id: "recent-reviewed", reviewedAt: retTs(5), status: "rejected", note: "n" }, + { id: "pending", status: "pending", note: "n", photos: [] }, // no reviewedAt + ], + }); + const deleted: string[] = []; + const bucket = { + file: (p: string) => ({ + async delete() { + if (p.endsWith("gone.jpg")) throw Object.assign(new Error("No such object"), { code: 404 }); + deleted.push(p); + }, + }), + }; + const r = await sweepReportMedia(db, bucket, RET_NOW); + assert.strictEqual(r.scanned, 1); + assert.strictEqual(r.purged, 1); + assert.strictEqual(r.filesDeleted, 1, "the 404 blob is tolerated, the other deleted"); + assert.deepStrictEqual(deleted, ["report_photos/u1/a.jpg"]); + assert.strictEqual(writes.length, 1); + assert.strictEqual(writes[0].id, "old-reviewed"); + assert.ok("note" in writes[0].update && "photos" in writes[0].update); + assert.ok(writes[0].update.mediaPurgedAt instanceof adminRet.firestore.Timestamp); + const oldDoc = getDocs().find((d) => d.id === "old-reviewed"); + assert.ok(oldDoc && !("note" in oldDoc) && !("photos" in oldDoc), "live doc stripped"); + assert.strictEqual(oldDoc?.lat, 51.4, "postbox coordinate kept"); + const pending = getDocs().find((d) => d.id === "pending"); + assert.ok(pending && "note" in pending, "pending report untouched"); + }); + + it("skips already-purged reports on watermark-boundary re-reads (idempotent)", async () => { + const { db, writes } = makeRetentionDb({ + collectionName: "reports", + tsField: "reviewedAt", + docs: [{ id: "r1", reviewedAt: retTs(40), status: "accepted", mediaPurgedAt: retTs(1) }], + }); + const bucket = { file: () => ({ async delete() {} }) }; + const r = await sweepReportMedia(db, bucket, RET_NOW); + assert.strictEqual(r.purged, 0); + assert.strictEqual(writes.length, 0, "no rewrite of an already-clean report"); + }); +}); + +describe("data retention: sweepModerationFlags (mock Firestore)", () => { + it("deletes flags older than 180 days, keeps newer ones, pages until drained", async () => { + const { db, deletes, getDocs } = makeRetentionDb({ + collectionName: "moderationFlags", + tsField: "createdAt", + docs: [ + { id: "f-old-1", createdAt: retTs(200), uid: "u1" }, + { id: "f-old-2", createdAt: retTs(190), uid: "u2" }, + { id: "f-new", createdAt: retTs(20), uid: "u1" }, + ], + }); + const r = await sweepModerationFlags(db, RET_NOW, { pageSize: 1, maxPages: 10 }); + assert.strictEqual(r.deleted, 2); + assert.strictEqual(r.pagesRemaining, false); + assert.deepStrictEqual(deletes.sort(), ["f-old-1", "f-old-2"]); + assert.deepStrictEqual(getDocs().map((d) => d.id), ["f-new"]); + }); +}); + +// ── exportMyData (GDPR access/portability) ──────────────────────────────────── +import { serialiseForExport, buildExportBundle, EXPORT_CLAIMS_CAP, type ExportParts } from "../exportMyData"; + +describe("exportMyData: serialiseForExport", () => { + it("converts Timestamps to ISO strings and GeoPoints to {lat,lng}, recursively", () => { + const ts = adminRet.firestore.Timestamp.fromMillis(1_800_000_000_000); + const gp = new adminRet.firestore.GeoPoint(51.45, -2.59); + const out = serialiseForExport({ + when: ts, + where: gp, + nested: { list: [ts, { deep: gp }] }, + n: 7, + s: "x", + b: true, + nothing: null, + }) as Record; + assert.strictEqual(out.when, new Date(1_800_000_000_000).toISOString()); + assert.deepStrictEqual(out.where, { lat: 51.45, lng: -2.59 }); + const nested = out.nested as { list: unknown[] }; + assert.strictEqual(nested.list[0], new Date(1_800_000_000_000).toISOString()); + assert.deepStrictEqual((nested.list[1] as Record).deep, { lat: 51.45, lng: -2.59 }); + assert.strictEqual(out.n, 7); + assert.strictEqual(out.s, "x"); + assert.strictEqual(out.b, true); + assert.strictEqual(out.nothing, null); + }); +}); + +describe("exportMyData: buildExportBundle", () => { + const baseParts: ExportParts = { + uid: "u1", + generatedAtIso: "2026-07-17T10:00:00.000Z", + account: { email: "a@b.c", providers: ["google.com"] }, + profile: { id: "u1", displayName: "Richy", friends: ["u2"] }, + countyStats: [{ id: "avon", totalPoints: 12 }], + claims: [{ id: "c1", timestamp: adminRet.firestore.Timestamp.fromMillis(1_800_000_000_000) }], + claimsTruncated: false, + reports: [{ id: "r1", note: "n" }], + moderationFlags: [{ id: "f1", severity: "low" }], + trustScore: { score: 95 }, + reportQuota: null, + fcmTokens: null, + leaderboards: [{ board: "lifetime", entry: { uid: "u1", totalPoints: 12 } }], + }; + + it("assembles the versioned bundle with Firestore types serialised", () => { + const bundle = buildExportBundle(baseParts) as Record; + assert.strictEqual(bundle.exportVersion, 1); + assert.strictEqual(bundle.generatedAt, "2026-07-17T10:00:00.000Z"); + assert.strictEqual(bundle.uid, "u1"); + assert.deepStrictEqual(bundle.trustScore, { score: 95 }); + assert.strictEqual(bundle.reportQuota, null); + const claims = bundle.claims as Array>; + assert.strictEqual(claims[0].timestamp, new Date(1_800_000_000_000).toISOString()); + assert.strictEqual(bundle.claimsTruncated, false); + const boards = bundle.leaderboards as Array>; + assert.strictEqual((boards[0].entry as Record).totalPoints, 12); + assert.ok(typeof bundle.notes === "object" && bundle.notes !== null, "explanatory notes present"); + }); + + it("keeps a sane claims cap constant", () => { + assert.ok(EXPORT_CLAIMS_CAP >= 1000 && EXPORT_CLAIMS_CAP <= 30000); + }); +}); + +describe("exportMyData callable (auth)", () => { + it("throws unauthenticated when no auth context", async function (this: Mocha.Context) { + this.timeout(5000); + const wrapped = testEnv.wrap(myFunctions.exportMyData) as (data: unknown, context?: unknown) => Promise; + try { + await wrapped({ data: {} }); + assert.fail("Expected unauthenticated error"); + } catch (e: unknown) { + const err = e as { code?: string }; + assert.strictEqual(err.code, "unauthenticated"); + } + }); +}); diff --git a/lib/analytics_service.dart b/lib/analytics_service.dart index 8c81adf0..d8acaf33 100644 --- a/lib/analytics_service.dart +++ b/lib/analytics_service.dart @@ -37,6 +37,17 @@ class Analytics { static final FirebaseAnalyticsObserver observer = FirebaseAnalyticsObserver(analytics: _fa); + /// Toggle Analytics collection (GDPR consent gate). Collection starts OFF at + /// cold boot via the `firebase_analytics_collection_enabled=false` manifest + /// flag; this is the runtime switch driven by ConsentPreferences. + static Future setCollectionEnabled(bool enabled) async { + try { + await _fa.setAnalyticsCollectionEnabled(enabled); + } catch (e) { + debugPrint('Analytics.setCollectionEnabled failed: $e'); + } + } + // --------------------------------------------------------------------------- // Claim scan events // --------------------------------------------------------------------------- diff --git a/lib/consent_preferences.dart b/lib/consent_preferences.dart new file mode 100644 index 00000000..bb48a2f1 --- /dev/null +++ b/lib/consent_preferences.dart @@ -0,0 +1,62 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +const String _keyAnalyticsDecided = 'analytics_consent_decided'; +const String _keyAnalyticsGranted = 'analytics_consent_granted'; +const String _keyCrashEnabled = 'crash_reporting_enabled'; +const String _keyPerfEnabled = 'perf_monitoring_enabled'; + +/// Device-local telemetry consent state (GDPR). +/// +/// SharedPreferences deliberately, not Firestore: consent is per-device SDK +/// state that must be readable before login (the intro runs unauthenticated) +/// and applied at every cold start before any Firebase collection begins. +/// +/// Analytics is OPT-IN (default off until the user decides — the manifest flag +/// `firebase_analytics_collection_enabled=false` keeps the SDK silent before +/// the first runtime enable). Crash reporting and performance monitoring run +/// under legitimate interest, on by default with a Settings opt-out. +class ConsentPreferences { + ConsentPreferences._(); + + /// Whether the user has made the analytics choice (intro step or one-time + /// prompt). Until true, the app treats analytics as not consented. + static Future hasDecidedAnalytics() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyAnalyticsDecided) ?? false; + } + + /// Whether the user opted in to usage analytics. Default off (opt-in). + static Future analyticsGranted() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyAnalyticsGranted) ?? false; + } + + /// Record the analytics choice: marks it decided and stores [granted]. + static Future setAnalyticsConsent(bool granted) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyAnalyticsDecided, true); + await prefs.setBool(_keyAnalyticsGranted, granted); + } + + /// Crash reporting toggle (legitimate interest — default on, objectable). + static Future crashReportingEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyCrashEnabled) ?? true; + } + + static Future setCrashReportingEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyCrashEnabled, enabled); + } + + /// Performance monitoring toggle (legitimate interest — default on). + static Future perfMonitoringEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyPerfEnabled) ?? true; + } + + static Future setPerfMonitoringEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyPerfEnabled, enabled); + } +} diff --git a/lib/consent_screen.dart b/lib/consent_screen.dart new file mode 100644 index 00000000..6be7ca30 --- /dev/null +++ b/lib/consent_screen.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:postbox_game/analytics_service.dart'; +import 'package:postbox_game/consent_preferences.dart'; +import 'package:postbox_game/legal/privacy_policy_screen.dart'; +import 'package:postbox_game/theme.dart'; + +/// The reusable consent disclosure body, shared by the intro's final step and +/// [ConsentGate] so the copy stays single-source. Styled for the dark navy +/// gradient both hosts render on. The host owns the opt-in switch state. +class ConsentContent extends StatelessWidget { + const ConsentContent({ + super.key, + required this.analyticsOptIn, + required this.onAnalyticsChanged, + }); + + final bool analyticsOptIn; + final ValueChanged onAnalyticsChanged; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Your data, your choice', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: AppSpacing.md), + const Text( + 'To keep the app working we collect crash reports and performance ' + 'traces. You can turn those off any time in Settings → Privacy.', + style: TextStyle(color: Colors.white70, height: 1.4), + ), + const SizedBox(height: AppSpacing.md), + const Text( + 'Anonymous usage analytics are optional and OFF unless you opt in:', + style: TextStyle(color: Colors.white70, height: 1.4), + ), + const SizedBox(height: AppSpacing.sm), + SwitchListTile( + value: analyticsOptIn, + onChanged: onAnalyticsChanged, + title: const Text( + 'Share anonymous usage analytics', + style: TextStyle(color: Colors.white), + ), + subtitle: const Text( + 'Helps us understand which features matter', + style: TextStyle(color: Colors.white60), + ), + contentPadding: EdgeInsets.zero, + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + style: TextButton.styleFrom(foregroundColor: postalGold), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PrivacyPolicyScreen()), + ), + child: const Text('Privacy policy'), + ), + ), + ], + ); + } +} + +/// One-time consent prompt for EXISTING users (installs that predate the +/// consent step in the intro). New users decide inside the intro, so the +/// choice is already recorded and this gate passes straight through. +class ConsentGate extends StatefulWidget { + const ConsentGate({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _ConsentGateState(); +} + +class _ConsentGateState extends State { + bool? _decided; + bool _optIn = false; + + @override + void initState() { + super.initState(); + ConsentPreferences.hasDecidedAnalytics().then((d) { + if (mounted) setState(() => _decided = d); + }); + } + + Future _continue() async { + await ConsentPreferences.setAnalyticsConsent(_optIn); + unawaited(Analytics.setCollectionEnabled(_optIn)); + if (mounted) setState(() => _decided = true); + } + + @override + Widget build(BuildContext context) { + final decided = _decided; + if (decided == true) return widget.child; + + // Loading (a few ms) and the prompt share the intro's navy backdrop so + // there's no flash of Home before the takeover. + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [royalNavy, Color(0xFF3D0C13)], + ), + ), + child: SafeArea( + child: decided == null + ? const SizedBox.expand() + : Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xl, + vertical: AppSpacing.lg), + child: ConsentContent( + analyticsOptIn: _optIn, + onAnalyticsChanged: (v) => + setState(() => _optIn = v), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _continue, + child: const Text('Continue'), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/intro.dart b/lib/intro.dart index a9d044cb..6957c99a 100644 --- a/lib/intro.dart +++ b/lib/intro.dart @@ -5,6 +5,9 @@ import 'package:confetti/confetti.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:postbox_game/analytics_service.dart'; +import 'package:postbox_game/consent_preferences.dart'; +import 'package:postbox_game/consent_screen.dart'; import 'package:postbox_game/james_messages.dart'; import 'package:postbox_game/postman_james.dart'; import 'package:postbox_game/remote_config_service.dart'; @@ -30,7 +33,16 @@ class Intro extends StatefulWidget { class _IntroState extends State { int _step = 0; - static const int _totalSteps = 7; + static const int _totalSteps = 8; + + /// Analytics opt-in choice made on the final (consent) step. Default OFF — + /// GDPR consent must be opt-in. + bool _analyticsOptIn = false; + + /// Settings replay skips the consent step (consent is managed from + /// Settings → Privacy once the choice exists), so the replay run terminates + /// one step earlier. + int get _lastStep => widget.replay ? _totalSteps - 2 : _totalSteps - 1; /// True once [_advance] has fired the terminal-step branch (pop / onDone) /// so a double-tap on "Get started" can't pop the screen twice or invoke @@ -72,7 +84,7 @@ class _IntroState extends State { if (_completed) return; // Fire confetti as the user enters the Mega Points step. if (_step == 3) _confettiController.play(); - if (_step < _totalSteps - 1) { + if (_step < _lastStep) { setState(() => _step++); } else { _completed = true; @@ -83,6 +95,10 @@ class _IntroState extends State { if (widget.replay) { Navigator.of(context).pop(); } else { + // Persist the consent-step choice and apply it immediately so + // analytics starts (or stays off) without waiting for a restart. + unawaited(ConsentPreferences.setAnalyticsConsent(_analyticsOptIn)); + unawaited(Analytics.setCollectionEnabled(_analyticsOptIn)); widget.onDone?.call(); } } @@ -111,8 +127,7 @@ class _IntroState extends State { width: double.infinity, child: FilledButton( onPressed: _advance, - child: - Text(_step == _totalSteps - 1 ? 'Get started' : 'Next'), + child: Text(_step == _lastStep ? 'Get started' : 'Next'), ), ), ), @@ -139,11 +154,24 @@ class _IntroState extends State { return _buildOverview(); case 6: return _buildOverviewEnd(); + case 7: + return _buildConsent(); default: return _buildStageWithPostbox(); } } + /// Final first-run step: GDPR telemetry disclosure + analytics opt-in. + /// Never reached on replay (see [_lastStep]). + Widget _buildConsent() { + return _scrollCentre( + ConsentContent( + analyticsOptIn: _analyticsOptIn, + onAnalyticsChanged: (v) => setState(() => _analyticsOptIn = v), + ), + ); + } + // Wraps a step's content so it scrolls (and stays centred when there's room) // instead of overflowing the Expanded slot. Matters in landscape, where the // step area is only a few hundred logical pixels tall. diff --git a/lib/legal/privacy_policy_screen.dart b/lib/legal/privacy_policy_screen.dart new file mode 100644 index 00000000..8abb227c --- /dev/null +++ b/lib/legal/privacy_policy_screen.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:url_launcher/url_launcher.dart'; + +/// Public URL of the hosted copy (served by Firebase Hosting from +/// web/privacy-policy.html — test/privacy_policy_sync_test.dart pins the two +/// documents against each other). Also used for the Play Store listing. +const String kHostedPrivacyPolicyUrl = + 'https://the-postbox-game.web.app/privacy-policy'; + +enum PolicyBlockType { title, heading, bullet, paragraph } + +/// One rendered block of the markdown-lite policy document. +class PolicyBlock { + const PolicyBlock(this.type, this.text); + final PolicyBlockType type; + final String text; +} + +/// Parse the bundled policy markdown into renderable blocks. Markdown-lite +/// only: `# ` title, `## ` headings, `- ` bullets, blank-line-separated +/// paragraphs; `**bold**`/`_italic_` markers are stripped to plain text (no +/// markdown dependency — flutter_markdown is discontinued). +List parsePolicyBlocks(String md) { + final blocks = []; + final para = StringBuffer(); + + void flushPara() { + if (para.isEmpty) return; + blocks.add(PolicyBlock(PolicyBlockType.paragraph, _cleanInline(para.toString()))); + para.clear(); + } + + for (final raw in md.split('\n')) { + final line = raw.trim(); + if (line.isEmpty) { + flushPara(); + } else if (line.startsWith('## ')) { + flushPara(); + blocks.add(PolicyBlock(PolicyBlockType.heading, _cleanInline(line.substring(3)))); + } else if (line.startsWith('# ')) { + flushPara(); + blocks.add(PolicyBlock(PolicyBlockType.title, _cleanInline(line.substring(2)))); + } else if (line.startsWith('- ')) { + flushPara(); + blocks.add(PolicyBlock(PolicyBlockType.bullet, _cleanInline(line.substring(2)))); + } else { + if (para.isNotEmpty) para.write(' '); + para.write(line); + } + } + flushPara(); + return blocks; +} + +/// Strip `**bold**` markers and enclosing `_italics_` to plain text. +String _cleanInline(String s) { + var out = s.replaceAll('**', '').trim(); + if (out.startsWith('_')) out = out.substring(1); + if (out.endsWith('_')) out = out.substring(0, out.length - 1); + return out.trim(); +} + +/// In-app privacy policy, rendered from assets/legal/privacy_policy.md. +class PrivacyPolicyScreen extends StatelessWidget { + const PrivacyPolicyScreen({super.key, @visibleForTesting this.markdownOverride}); + + /// Test seam — bypasses rootBundle so widget tests need no asset bundle. + final String? markdownOverride; + + Future _load() async { + if (markdownOverride != null) return markdownOverride!; + return rootBundle.loadString('assets/legal/privacy_policy.md'); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('Privacy policy'), + actions: [ + IconButton( + icon: const Icon(Icons.open_in_new), + tooltip: 'Open in browser', + onPressed: () => launchUrl( + Uri.parse(kHostedPrivacyPolicyUrl), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + body: FutureBuilder( + future: _load(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final blocks = parsePolicyBlocks(snapshot.data!); + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: blocks.length, + itemBuilder: (context, i) { + final b = blocks[i]; + switch (b.type) { + case PolicyBlockType.title: + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(b.text, style: theme.textTheme.headlineSmall), + ); + case PolicyBlockType.heading: + return Padding( + padding: const EdgeInsets.only(top: 20, bottom: 6), + child: Text( + b.text, + style: theme.textTheme.titleMedium + ?.copyWith(color: theme.colorScheme.primary), + ), + ); + case PolicyBlockType.bullet: + return Padding( + padding: const EdgeInsets.only(left: 8, bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• '), + Expanded( + child: + Text(b.text, style: theme.textTheme.bodyMedium)), + ], + ), + ); + case PolicyBlockType.paragraph: + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(b.text, style: theme.textTheme.bodyMedium), + ); + } + }, + ); + }, + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 5be2bd70..4f985cb7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:postbox_game/theme.dart'; import 'package:postbox_game/claim.dart'; import 'package:postbox_game/claim_history_screen.dart'; import 'package:postbox_game/friends_screen.dart'; +import 'package:postbox_game/consent_screen.dart'; import 'package:postbox_game/home.dart'; import 'package:postbox_game/leaderboard_screen.dart'; import 'package:postbox_game/intro.dart'; @@ -33,7 +34,7 @@ import 'package:postbox_game/analytics_user_properties.dart'; import 'package:postbox_game/notification_service.dart'; import 'package:postbox_game/remote_config_service.dart'; import 'package:postbox_game/services/crashlytics_helper.dart'; -import 'package:postbox_game/services/perf_service.dart'; +import 'package:postbox_game/services/telemetry_consent.dart'; import 'package:postbox_game/route/destination_picker_screen.dart'; import 'package:postbox_game/route/route_notifications.dart'; import 'package:postbox_game/services/user_properties_publisher.dart'; @@ -51,14 +52,16 @@ void main() async { options: DefaultFirebaseOptions.currentPlatform, ); } - // Route uncaught framework + platform errors to Crashlytics as fatals, and - // disable collection in debug so local runs don't pollute the dashboard. + // Route uncaught framework + platform errors to Crashlytics as fatals. FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; WidgetsBinding.instance.platformDispatcher.onError = (error, stack) { FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); return true; }; - unawaited(CrashlyticsHelper.setCollectionEnabled(!kDebugMode)); + // Apply the stored GDPR telemetry consent to Analytics/Crashlytics/Perf in + // one place (analytics is opt-in and starts manifest-disabled; crash/perf + // are legitimate-interest with Settings opt-outs, still off in debug). + unawaited(applyStoredTelemetryPreferences()); try { await FirebaseAppCheck.instance.activate( providerWeb: ReCaptchaV3Provider(kRecaptchaSiteKey), @@ -92,8 +95,6 @@ void main() async { : null, serverClientId: kIsWeb ? null : webClientId, ); - // Keep debug runs out of the Performance dashboard; production collects. - unawaited(PerfService.setCollectionEnabled(!kDebugMode)); await HomeWidgetService.init(); await _checkInitialWidgetLaunch(); await RouteNotifications.initialise(); @@ -265,19 +266,24 @@ class _PostboxGameState extends State with WidgetsBindingObserver { return _UnauthGate(userRepository: _userRepository); } if (state is Authenticated) { + // ConsentGate is a pass-through for anyone who has made the + // analytics choice (new users decide in the intro); existing + // installs get a one-time consent prompt before Home. if (_autoScanEpoch > 0) { // Keying on the epoch forces a fresh Home + Claim mount on // each widget tap. Without the key, Flutter reconciles the // existing _HomeState whose `late final _pages` was built // with autoScan=false, so the new props are ignored and no // scan fires. - return Home( - key: ValueKey('claim-widget-$_autoScanEpoch'), - initialIndex: 1, - autoScan: true, + return ConsentGate( + child: Home( + key: ValueKey('claim-widget-$_autoScanEpoch'), + initialIndex: 1, + autoScan: true, + ), ); } - return const Home(); + return const ConsentGate(child: Home()); } return const Splash(); }, diff --git a/lib/services/telemetry_consent.dart b/lib/services/telemetry_consent.dart new file mode 100644 index 00000000..b6be9fa2 --- /dev/null +++ b/lib/services/telemetry_consent.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'package:postbox_game/analytics_service.dart'; +import 'package:postbox_game/consent_preferences.dart'; +import 'package:postbox_game/services/crashlytics_helper.dart'; +import 'package:postbox_game/services/perf_service.dart'; + +/// Apply the stored telemetry consent to every collection SDK. Called once at +/// cold start from `main()` (replacing the old unconditional `!kDebugMode` +/// calls) and again by whichever surface records a new choice. +/// +/// Analytics is consent-gated (decided AND granted — no debug gate, matching +/// its previous behaviour of having none). Crash reporting and performance +/// monitoring are legitimate-interest with a Settings opt-out, and stay +/// suppressed in debug builds as before. [isDebug] is a test seam. +Future applyStoredTelemetryPreferences({bool isDebug = kDebugMode}) async { + final decided = await ConsentPreferences.hasDecidedAnalytics(); + final granted = await ConsentPreferences.analyticsGranted(); + final crash = await ConsentPreferences.crashReportingEnabled(); + final perf = await ConsentPreferences.perfMonitoringEnabled(); + + await Analytics.setCollectionEnabled(decided && granted); + await CrashlyticsHelper.setCollectionEnabled(!isDebug && crash); + await PerfService.setCollectionEnabled(!isDebug && perf); +} diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index 2d9ffd48..05606008 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_functions/cloud_functions.dart'; import 'package:firebase_auth/firebase_auth.dart'; @@ -9,11 +12,16 @@ import 'package:latlong2/latlong.dart'; import 'package:postbox_game/analytics_service.dart'; import 'package:postbox_game/analytics_user_properties.dart'; import 'package:postbox_game/app_preferences.dart'; +import 'package:postbox_game/consent_preferences.dart'; +import 'package:postbox_game/firebase_functions_eu.dart'; +import 'package:postbox_game/legal/privacy_policy_screen.dart'; import 'package:postbox_game/remote_config_service.dart'; import 'package:postbox_game/authentication_bloc/bloc.dart'; import 'package:postbox_game/county_heatmap.dart'; import 'package:postbox_game/intro.dart'; import 'package:postbox_game/maintenance_guard.dart'; +import 'package:postbox_game/services/telemetry_consent.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:postbox_game/theme.dart'; import 'package:postbox_game/user_repository.dart'; @@ -39,6 +47,11 @@ class _SettingsScreenState extends State { 'streakReminder': true, }; bool _notifPrefsLoaded = false; + bool _analyticsConsent = false; + bool _crashReports = true; + bool _perfMonitoring = true; + bool _privacyPrefsLoaded = false; + bool _isExporting = false; final _userRepository = UserRepository(); @override @@ -46,9 +59,80 @@ class _SettingsScreenState extends State { super.initState(); _loadPrefs(); _loadNotifPrefs(); + _loadPrivacyPrefs(); _loadLastPosition(); } + Future _loadPrivacyPrefs() async { + final analytics = await ConsentPreferences.analyticsGranted(); + final crash = await ConsentPreferences.crashReportingEnabled(); + final perf = await ConsentPreferences.perfMonitoringEnabled(); + if (!mounted) return; + setState(() { + _analyticsConsent = analytics; + _crashReports = crash; + _perfMonitoring = perf; + _privacyPrefsLoaded = true; + }); + } + + // Consent toggles persist to SharedPreferences (device-local — unlike the + // Firestore-backed notification prefs there's no network write to roll + // back), then re-apply the whole telemetry state so the change takes effect + // immediately. + Future _setAnalyticsConsent(bool v) async { + setState(() => _analyticsConsent = v); + await ConsentPreferences.setAnalyticsConsent(v); + unawaited(applyStoredTelemetryPreferences()); + } + + Future _setCrashReports(bool v) async { + setState(() => _crashReports = v); + await ConsentPreferences.setCrashReportingEnabled(v); + unawaited(applyStoredTelemetryPreferences()); + } + + Future _setPerfMonitoring(bool v) async { + setState(() => _perfMonitoring = v); + await ConsentPreferences.setPerfMonitoringEnabled(v); + unawaited(applyStoredTelemetryPreferences()); + } + + /// GDPR Art. 15/20 self-serve export: fetch the full data bundle from the + /// `exportMyData` callable and hand it to the platform share sheet as a + /// JSON file. + Future _downloadMyData() async { + if (MaintenanceGuard.blocked(context, actionLabel: 'export your data')) { + return; + } + setState(() => _isExporting = true); + try { + final result = await appFunctions.httpsCallable('exportMyData').call(); + final jsonStr = const JsonEncoder.withIndent(' ').convert(result.data); + final date = DateTime.now().toIso8601String().split('T').first; + await SharePlus.instance.share(ShareParams( + files: [ + XFile.fromData( + utf8.encode(jsonStr), + mimeType: 'application/json', + ), + ], + fileNameOverrides: ['postbox-game-export-$date.json'], + subject: 'The Postbox Game — my data', + )); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not export your data. Please try again.'), + ), + ); + } + } finally { + if (mounted) setState(() => _isExporting = false); + } + } + Future _loadLastPosition() async { try { final pos = await Geolocator.getLastKnownPosition(); @@ -784,6 +868,64 @@ class _SettingsScreenState extends State { ), ], const Divider(height: 24), + _sectionHeader('Privacy'), + if (!_privacyPrefsLoaded) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.md), + child: LinearProgressIndicator(), + ) + else ...[ + SwitchListTile( + secondary: const Icon(Icons.insights_outlined), + title: const Text('Usage analytics'), + subtitle: const Text( + 'Share anonymous usage statistics to help improve the app'), + value: _analyticsConsent, + onChanged: _setAnalyticsConsent, + ), + SwitchListTile( + secondary: const Icon(Icons.bug_report_outlined), + title: const Text('Crash reports'), + subtitle: + const Text('Anonymous crash reports that help us fix bugs'), + value: _crashReports, + onChanged: _setCrashReports, + ), + SwitchListTile( + secondary: const Icon(Icons.speed_outlined), + title: const Text('Performance monitoring'), + subtitle: const Text( + 'Anonymous timing traces that help us keep the app fast'), + value: _perfMonitoring, + onChanged: _setPerfMonitoring, + ), + ], + ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text('Privacy policy'), + subtitle: const Text('How we handle your data'), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PrivacyPolicyScreen()), + ), + ), + ListTile( + leading: const Icon(Icons.download_outlined), + title: const Text('Download my data'), + subtitle: const Text( + 'Export everything we store about you as a JSON file'), + trailing: _isExporting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + enabled: !_isExporting, + onTap: _downloadMyData, + ), + const Divider(height: 24), _sectionHeader('About'), ListTile( leading: const Icon(Icons.info_outline), diff --git a/pubspec.lock b/pubspec.lock index c1cb81c3..c4feb167 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "85c85838ba41c5823ca05ecbc5fc8d85d78e9f945195d1e4d5069abd451916e7" + sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c" url: "https://pub.dev" source: hosted - version: "1.3.74" + version: "1.3.75" animated_text_kit: dependency: "direct main" description: @@ -93,50 +93,50 @@ packages: dependency: "direct main" description: name: cloud_firestore - sha256: "252994bf8c50b344d5e24b12d52f41a46cbf2b26f66088e8680ff05244fc8daf" + sha256: e494387f1cd15e4f1c935e14a9cb884c97b8847a41364fabfaa912958a2ea842 url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.7.1" cloud_firestore_platform_interface: dependency: transitive description: name: cloud_firestore_platform_interface - sha256: "29bf10c3dca307d5dcee1aff1b1fe06b97b6d9d093b1b85efb64988e03f4f5ce" + sha256: bc8c479a829c1abdfa4741aa2f3a3242918e0bebd3eb50e53d9dde9b303d4330 url: "https://pub.dev" source: hosted - version: "8.0.4" + version: "8.0.5" cloud_firestore_web: dependency: transitive description: name: cloud_firestore_web - sha256: ae5b6b9676b61ab4ef4b1ecfd6532c25794020caca9c8bbbef1e6e068319900d + sha256: cb237b3bf3cff4778ec962a8a9449ae79e831d7b4369e0ad9f5a2b6dfbd84569 url: "https://pub.dev" source: hosted - version: "5.7.0" + version: "5.7.1" cloud_functions: dependency: "direct main" description: name: cloud_functions - sha256: "88c7d95354e5d54b236a2ca4c6625d38cda7a381f2828d4a4b5becc374ecd3d1" + sha256: "25d7e53f309b0b799d237d5350793bcef69306f0096f2a5fd06226a4de747404" url: "https://pub.dev" source: hosted - version: "6.3.4" + version: "6.3.5" cloud_functions_platform_interface: dependency: transitive description: name: cloud_functions_platform_interface - sha256: f71dc23b48acb988981be03e470557623c3d8bbece639e47c191278e7e0b26d1 + sha256: "0fa886ea6189a9e3c1e58dfcb09a9549f788ce9950d2b01a2aae0f6d7429bfa5" url: "https://pub.dev" source: hosted - version: "6.0.4" + version: "6.0.5" cloud_functions_web: dependency: transitive description: name: cloud_functions_web - sha256: "3c3c3a03deaf1e453e1013b1e2d119fdcd68d69cdc452b5cc80bd8ad243afd1f" + sha256: e97ea992822c404412ef21539d9b2d5b51922dfe8350922b2255ee7c4a7f54fd url: "https://pub.dev" source: hosted - version: "5.1.10" + version: "5.1.11" code_assets: dependency: transitive description: @@ -253,10 +253,10 @@ packages: dependency: "direct dev" description: name: fake_cloud_firestore - sha256: fe3fddfda31a49d45aa0a8916530b29e41ed3fd31dbd2ea8816f36868caef2be + sha256: c2ce1f828e5840c2f212584d496dbae0cfc94848daa84112e51a0722358aa24a url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.2.0" fake_firebase_security_rules: dependency: transitive description: @@ -325,58 +325,58 @@ packages: dependency: "direct main" description: name: firebase_analytics - sha256: "86acc776d1f826efa266f442dfd1081026fb6b6bebfc4e6cd0554899e0e328ac" + sha256: "47b68927bc4abd6d7b4e2ddfe7c64fe087cca5f96d0c2b8d9d6deb6120c10ad6" url: "https://pub.dev" source: hosted - version: "12.4.4" + version: "12.4.5" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface - sha256: "885cfee66566604dcf987ef4dbfc18bce239b3114c48b86b7332ee128f2633ed" + sha256: "36ce5feb5a996381e6411792eaa00e89178e75943e54ac8f14a8296b6e9a3e88" url: "https://pub.dev" source: hosted - version: "6.0.4" + version: "6.0.5" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web - sha256: "4ae00b0bc8ca18606b551e81f02b9d4cab87c5fdfc1070422a2aef550c562c3a" + sha256: c12358983c927093cd40d42feb0cb05c8594d3a9b56ab5ad303bec2b8838d252 url: "https://pub.dev" source: hosted - version: "0.6.1+10" + version: "0.6.1+11" firebase_app_check: dependency: "direct main" description: name: firebase_app_check - sha256: b610075bcdad524cde6b21ff29e28463eb3cccd1e8c8abdee3a8e3dcff270177 + sha256: "28c1cc87389132c710236a008ee6688df510d3ce8b542ec377274ed8777006d6" url: "https://pub.dev" source: hosted - version: "0.4.5+1" + version: "0.4.5+2" firebase_app_check_platform_interface: dependency: transitive description: name: firebase_app_check_platform_interface - sha256: "58f981d6589808f82a17988ff05c71251985178db29acfc75c8462bbe0bd27cf" + sha256: "1cccf087884425f595f6ae7159c568bb0bcb8aaff3acb6dd150bde9b421b06d2" url: "https://pub.dev" source: hosted - version: "0.4.1+1" + version: "0.4.1+2" firebase_app_check_web: dependency: transitive description: name: firebase_app_check_web - sha256: da9f5780c8db082448cb20231b9605f393862b640f8eff48c4b315efea548cf7 + sha256: "1049ce1073c43f8e3efc97190206132182ed2f71f4686efe9ada54a2bba27442" url: "https://pub.dev" source: hosted - version: "0.2.5+1" + version: "0.2.5+2" firebase_auth: dependency: "direct main" description: name: firebase_auth - sha256: c3fc520e4fc4bf23a455643df07921d63cd6601936e99aeca3c04b5bd0ce3594 + sha256: "0ced04a58f0d08bb01435771069fdee06e54d85321f2a8c9dc428857098731c8" url: "https://pub.dev" source: hosted - version: "6.5.5" + version: "6.5.6" firebase_auth_mocks: dependency: "direct dev" description: @@ -389,154 +389,154 @@ packages: dependency: transitive description: name: firebase_auth_platform_interface - sha256: de2e32772895ee92fa2d0d02a4482865d8aac51bff99f3fc49a122caf8eb25c1 + sha256: a02aa6edb07fb5676eeea47590ff19d916c0239706ec2a35b1544cc7af44b63a url: "https://pub.dev" source: hosted - version: "9.0.4" + version: "9.0.5" firebase_auth_web: dependency: transitive description: name: firebase_auth_web - sha256: fdb6dcd0ed3086058d64126110191f604574bca1e669ca73e36578648b1a59c3 + sha256: "8daa9f7665f76c6e23a2e68cabaa7f6693e1949eef3ee678712544b3835ce437" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.2.5" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: e744b63446de56d9f72fec870b19e9c6f075fd3da099a3a34535f6e8c4e60823 + sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9" url: "https://pub.dev" source: hosted - version: "4.12.0" + version: "4.12.1" firebase_core_platform_interface: dependency: "direct dev" description: name: firebase_core_platform_interface - sha256: "913e7c96ef83a80ad7e1c3f8a059167b3de23b5d5e07fa3ed8f11abe24de98b6" + sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938 url: "https://pub.dev" source: hosted - version: "7.1.0" + version: "8.0.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "30ba3ae56f5beb2cea836033201570612c911661889f815eca73b6056c7b55bf" + sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555 url: "https://pub.dev" source: hosted - version: "3.9.0" + version: "3.9.1" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: "884fa4d5cfbd87831a6fc6e14a6c80e1d46c8b1532b78ab0c5f805b65525f6e7" + sha256: "9855d3e2286c3e4439cf7d48c740bdf282ac165f29c7551dcdaf121925c59a28" url: "https://pub.dev" source: hosted - version: "5.2.5" + version: "5.2.6" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: c219e98e36d0b30a776cfed41f7064af0e1f341674f9cbb42fe5ab37eff43874 + sha256: aab14de92555ccf8c8616fc1b649272aef04dadb07f986e166e1d36d158f7bd1 url: "https://pub.dev" source: hosted - version: "3.8.25" + version: "3.8.26" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: ae3691770a42741100a33be6fec494b3c88e8fc536e5756fe4e447b8be7cfb4a + sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556" url: "https://pub.dev" source: hosted - version: "16.4.2" + version: "16.4.3" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "7e8dc65dfbd8c97d8f88031bfc0896947ee9cbb47940a5bba5edb343a70919a7" + sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63" url: "https://pub.dev" source: hosted - version: "4.9.1" + version: "4.9.2" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: b469facb97b4bc3e362ffcd5118b78fe6008f77faab8da2f41dbe16ea841157f + sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0 url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.2.3" firebase_performance: dependency: "direct main" description: name: firebase_performance - sha256: dc2957b65ad4d7a85a8e9e19046fa82a60df4d50d09a1d6b7e9608b0874a260e + sha256: f48a585a2ef61ed8d809da4aca80799ad588519bbd63121d9a27fdfbef296958 url: "https://pub.dev" source: hosted - version: "0.11.4+4" + version: "0.11.4+5" firebase_performance_platform_interface: dependency: transitive description: name: firebase_performance_platform_interface - sha256: "135e8dca14f2d940eb51fc17a45b721b4e86a8cc8abd725a4cd69fb88a404484" + sha256: "3a7691d88e31a52215e15c88ded570754b233a1a53dadc5efe3634efab0fd1ec" url: "https://pub.dev" source: hosted - version: "0.2.0+4" + version: "0.2.0+5" firebase_performance_web: dependency: transitive description: name: firebase_performance_web - sha256: "924d505f00168ab5e89b3fd617514f9c8125f1c001520b1fe94377f61652b625" + sha256: "50f0b19c21acd3a8866e05eb02efe1831ed7c595a20451c0446774e61b862082" url: "https://pub.dev" source: hosted - version: "0.1.8+10" + version: "0.1.8+11" firebase_remote_config: dependency: "direct main" description: name: firebase_remote_config - sha256: e2647f592be9bdd765e56c55fd2c17417cf6100a9c900d97fddd9c3dbe6412dd + sha256: d642fe803397a142199d1d1f45683021d822af59f989a78ba8f050d9ebede382 url: "https://pub.dev" source: hosted - version: "6.5.4" + version: "6.5.5" firebase_remote_config_platform_interface: dependency: transitive description: name: firebase_remote_config_platform_interface - sha256: "1e54bb8a993c3bdfe03f7e8b81fd9874c155c122f29b71a2f1e438fb21ee69a7" + sha256: "04ccfae1e90e218096f5251da42c5aa520b1ef4858b0dcb5f0c9eec0dee58715" url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.0.5" firebase_remote_config_web: dependency: transitive description: name: firebase_remote_config_web - sha256: afda618fb82a506c0f9352e04238597719744edcd2c9c6b71f3f88450779bfbf + sha256: a81e9495eabde81f3ae93fe5d132384522925d6f2cce869510ebef7fe43b1c50 url: "https://pub.dev" source: hosted - version: "1.10.11" + version: "1.10.12" firebase_storage: dependency: "direct main" description: name: firebase_storage - sha256: "8b581635d61852bf74d95d21ca181a3c52815813f5fafafa69da3610b870e998" + sha256: f81f2156232bc13f219bb65a80c46f9914d2a85589024b8cacc2bef072f7131e url: "https://pub.dev" source: hosted - version: "13.4.4" + version: "13.4.5" firebase_storage_platform_interface: dependency: transitive description: name: firebase_storage_platform_interface - sha256: "61772e88dd79db1e373f144fcbd29a0b6b3b275806732d44ab17daa62d9d9914" + sha256: d64ba6f7880b64cc095fdc07170adf9b0f3762b00b070cbfdaeff66b3cd737a6 url: "https://pub.dev" source: hosted - version: "6.0.4" + version: "6.0.5" firebase_storage_web: dependency: transitive description: name: firebase_storage_web - sha256: c41d5b7f2c7c6003832692762d86f2cd40a327aefea05756a5ace09ed95a0f77 + sha256: "7b28cad89b8d11223cf1f94c0868929b99f74beb2c1611cce3dd5c02588d4bcd" url: "https://pub.dev" source: hosted - version: "3.11.10" + version: "3.11.11" fixnum: dependency: transitive description: @@ -1220,10 +1220,10 @@ packages: dependency: transitive description: name: rx - sha256: "7f54bd39cc63a01c770c1de4b6ce8e135eb13119614cba2216bd9a93ccd29e56" + sha256: "3c819c80915138089c517e0d78f462792c5a2de05189466ab38bee7b6a8a330f" url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.5.0" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 91ee47e9..c0bb6061 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: Find postboxes for megapoints # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.4.0+17 +version: 1.4.0+18 environment: sdk: ">=3.0.0 <4.0.0" @@ -26,7 +26,7 @@ dependencies: cloud_functions: ^6.1.0 firebase_core: ^4.6.0 # 3.13+ native code uses new Pigeon channel prefix but pubspec still pins platform_interface ^5.x — mismatch breaks initializeApp cloud_firestore: ^6.2.0 - firebase_auth: ^6.3.0 + firebase_auth: ^6.5.6 geolocator: ^14.0.2 google_sign_in: ^7.2.0 flutter_compass: ^0.8.1 @@ -76,7 +76,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - firebase_core_platform_interface: ^7.0.1 + firebase_core_platform_interface: ^8.0.0 fake_cloud_firestore: ^4.0.0 firebase_auth_mocks: ^0.15.0 @@ -110,6 +110,7 @@ flutter: - assets/postman_james.png - assets/postbox.svg - assets/uk_counties_simplified.geojson + - assets/legal/privacy_policy.md # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.io/assets-and-images/#resolution-aware. diff --git a/test/analytics_service_test.dart b/test/analytics_service_test.dart index 454b9656..c81b620d 100644 --- a/test/analytics_service_test.dart +++ b/test/analytics_service_test.dart @@ -11,8 +11,14 @@ import 'package:postbox_game/analytics_user_properties.dart'; class _FakeFirebaseAnalytics extends Fake implements FirebaseAnalytics { String? lastUserId; bool setUserIdCalled = false; + bool? collectionEnabled; final Map properties = {}; + @override + Future setAnalyticsCollectionEnabled(bool enabled) async { + collectionEnabled = enabled; + } + @override Future setUserId({ String? id, @@ -126,6 +132,15 @@ void main() { }); }); + group('Analytics.setCollectionEnabled', () { + test('forwards the flag to FirebaseAnalytics', () async { + await Analytics.setCollectionEnabled(true); + expect(fake.collectionEnabled, isTrue); + await Analytics.setCollectionEnabled(false); + expect(fake.collectionEnabled, isFalse); + }); + }); + group('Analytics.lastSetUserProperties', () { test('returns an unmodifiable map', () async { await Analytics.setUserProperty(AnalyticsUserProps.kIsAdmin, 'false'); diff --git a/test/consent_gate_test.dart b/test/consent_gate_test.dart new file mode 100644 index 00000000..87bd5705 --- /dev/null +++ b/test/consent_gate_test.dart @@ -0,0 +1,68 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:postbox_game/analytics_service.dart'; +import 'package:postbox_game/consent_preferences.dart'; +import 'package:postbox_game/consent_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeAnalytics extends Fake implements FirebaseAnalytics { + bool? collectionEnabled; + @override + Future setAnalyticsCollectionEnabled(bool enabled) async { + collectionEnabled = enabled; + } +} + +void main() { + late _FakeAnalytics fakeAnalytics; + + setUp(() { + fakeAnalytics = _FakeAnalytics(); + Analytics.instance = fakeAnalytics; + }); + + Widget gate() => const MaterialApp( + home: ConsentGate(child: Scaffold(body: Text('HOME'))), + ); + + testWidgets('shows the one-time prompt when the choice is undecided', + (tester) async { + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget(gate()); + await tester.pump(); + + expect(find.text('Your data, your choice'), findsOneWidget); + expect(find.text('HOME'), findsNothing); + expect(tester.widget(find.byType(SwitchListTile)).value, + isFalse); + }); + + testWidgets('Continue persists the choice and reveals the app', + (tester) async { + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget(gate()); + await tester.pump(); + + await tester.tap(find.byType(SwitchListTile)); // opt in + await tester.pump(); + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + expect(find.text('HOME'), findsOneWidget); + expect(await ConsentPreferences.hasDecidedAnalytics(), isTrue); + expect(await ConsentPreferences.analyticsGranted(), isTrue); + expect(fakeAnalytics.collectionEnabled, isTrue); + }); + + testWidgets('passes straight through when the choice was already made', + (tester) async { + SharedPreferences.setMockInitialValues( + {'analytics_consent_decided': true}); + await tester.pumpWidget(gate()); + await tester.pumpAndSettle(); + + expect(find.text('HOME'), findsOneWidget); + expect(find.text('Your data, your choice'), findsNothing); + }); +} diff --git a/test/consent_preferences_test.dart b/test/consent_preferences_test.dart new file mode 100644 index 00000000..9f278c21 --- /dev/null +++ b/test/consent_preferences_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:postbox_game/consent_preferences.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('ConsentPreferences defaults', () { + test('analytics is undecided and not granted (opt-in)', () async { + expect(await ConsentPreferences.hasDecidedAnalytics(), isFalse); + expect(await ConsentPreferences.analyticsGranted(), isFalse); + }); + + test('crash reporting and perf monitoring default on (legitimate interest)', + () async { + expect(await ConsentPreferences.crashReportingEnabled(), isTrue); + expect(await ConsentPreferences.perfMonitoringEnabled(), isTrue); + }); + }); + + group('ConsentPreferences round trips', () { + test('setAnalyticsConsent(true) records decided + granted', () async { + await ConsentPreferences.setAnalyticsConsent(true); + expect(await ConsentPreferences.hasDecidedAnalytics(), isTrue); + expect(await ConsentPreferences.analyticsGranted(), isTrue); + }); + + test('setAnalyticsConsent(false) records decided but not granted', + () async { + await ConsentPreferences.setAnalyticsConsent(false); + expect(await ConsentPreferences.hasDecidedAnalytics(), isTrue); + expect(await ConsentPreferences.analyticsGranted(), isFalse); + }); + + test('crash and perf toggles round-trip', () async { + await ConsentPreferences.setCrashReportingEnabled(false); + await ConsentPreferences.setPerfMonitoringEnabled(false); + expect(await ConsentPreferences.crashReportingEnabled(), isFalse); + expect(await ConsentPreferences.perfMonitoringEnabled(), isFalse); + await ConsentPreferences.setCrashReportingEnabled(true); + expect(await ConsentPreferences.crashReportingEnabled(), isTrue); + }); + }); +} diff --git a/test/intro_test.dart b/test/intro_test.dart index 1087487a..9a80ef4e 100644 --- a/test/intro_test.dart +++ b/test/intro_test.dart @@ -1,17 +1,22 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:postbox_game/analytics_service.dart'; +import 'package:postbox_game/consent_preferences.dart'; import 'package:postbox_game/intro.dart'; import 'package:postbox_game/remote_config_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; // Smoke test for the first-run intro flow. Guards the _step state machine and -// the flutter_animate entrance effects added in v1.2. +// the flutter_animate entrance effects added in v1.2, plus the v1.4 GDPR +// consent step appended as the final first-run step. // // NOTE: never pumpAndSettle() here — the Mega Points step runs an infinite // shimmer (flutter_animate .repeat()) and a confetti burst, so settling would // hang. Pump fixed durations between taps instead. Advancing past a dialogue // step disposes its AnimatedTextKit (the only Timer source), so teardown stays -// clean as long as the flow ends on the outro step. +// clean as long as the flow ends on a timer-free step (outro / consent). /// Stub so JamesMessages.introStep2 can resolve its welcome variant without a /// live Firebase app. Mirrors the stub in claim_quiz_sheet_test.dart. @@ -22,8 +27,22 @@ class _StubRemoteConfig extends Fake implements FirebaseRemoteConfig { String getString(String key) => ''; } +/// Records the collection toggle the consent step fires on completion. +class _FakeAnalytics extends Fake implements FirebaseAnalytics { + bool? collectionEnabled; + @override + Future setAnalyticsCollectionEnabled(bool enabled) async { + collectionEnabled = enabled; + } +} + void main() { + late _FakeAnalytics fakeAnalytics; + setUp(() { + SharedPreferences.setMockInitialValues({}); + fakeAnalytics = _FakeAnalytics(); + Analytics.instance = fakeAnalytics; RemoteConfigService.instance = RemoteConfigService(remoteConfig: _StubRemoteConfig()); }); @@ -36,8 +55,9 @@ void main() { MaterialApp(home: Intro(onDone: () => doneCalled = true)), ); - // Steps 0..5 show "Next"; tapping six times lands on the final step (6). - for (var i = 0; i < 6; i++) { + // Steps 0..6 show "Next"; tapping seven times lands on the final + // (consent) step 7. + for (var i = 0; i < 7; i++) { expect(find.text('Next'), findsOneWidget, reason: 'expected a "Next" button on step $i'); expect(find.text('Get started'), findsNothing); @@ -46,8 +66,14 @@ void main() { await tester.pump(const Duration(milliseconds: 700)); } - // Final step: the CTA flips to "Get started" and invokes onDone. + // Final step: the consent disclosure with the opt-in switch DEFAULT OFF, + // and the CTA flips to "Get started". expect(find.text('Get started'), findsOneWidget); + final switchFinder = find.byType(SwitchListTile); + expect(switchFinder, findsOneWidget); + expect(tester.widget(switchFinder).value, isFalse, + reason: 'analytics consent must be opt-in (default OFF)'); + expect(doneCalled, isFalse); await tester.tap(find.text('Get started')); // Advance the clock so any pending flutter_animate `delay:` timers fire @@ -56,5 +82,48 @@ void main() { // leaves a Timer that the test binding flags as "still pending". await tester.pump(const Duration(seconds: 2)); expect(doneCalled, isTrue); + + // Completing without touching the switch records a decided-but-declined + // consent and keeps analytics collection off. + expect(await ConsentPreferences.hasDecidedAnalytics(), isTrue); + expect(await ConsentPreferences.analyticsGranted(), isFalse); + expect(fakeAnalytics.collectionEnabled, isFalse); + }); + + testWidgets('opting in on the consent step enables analytics', + (tester) async { + await tester.pumpWidget(MaterialApp(home: Intro(onDone: () {}))); + for (var i = 0; i < 7; i++) { + await tester.tap(find.text('Next')); + await tester.pump(const Duration(milliseconds: 700)); + } + await tester.tap(find.byType(SwitchListTile)); + await tester.pump(); + await tester.tap(find.text('Get started')); + await tester.pump(const Duration(seconds: 2)); + + expect(await ConsentPreferences.analyticsGranted(), isTrue); + expect(fakeAnalytics.collectionEnabled, isTrue); + }); + + testWidgets('Settings replay ends on the outro and never shows consent', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Intro(replay: true))); + + // Replay terminates one step earlier: steps 0..5 show "Next", step 6 + // (outro) shows "Get started" and the consent switch never appears. + for (var i = 0; i < 6; i++) { + expect(find.text('Next'), findsOneWidget, + reason: 'expected a "Next" button on replay step $i'); + await tester.tap(find.text('Next')); + await tester.pump(const Duration(milliseconds: 700)); + } + expect(find.text('Get started'), findsOneWidget); + expect(find.byType(SwitchListTile), findsNothing); + + await tester.pump(const Duration(seconds: 2)); + + // Replay must not have recorded any consent choice. + expect(await ConsentPreferences.hasDecidedAnalytics(), isFalse); }); } diff --git a/test/privacy_policy_screen_test.dart b/test/privacy_policy_screen_test.dart new file mode 100644 index 00000000..1db256f1 --- /dev/null +++ b/test/privacy_policy_screen_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:postbox_game/legal/privacy_policy_screen.dart'; + +const _sample = ''' +# Sample Policy + +_Last updated: July 2026_ + +First paragraph spanning +two source lines. + +## 1. Section One + +- **Bold lead:** bullet one +- bullet two + +Closing paragraph. +'''; + +void main() { + group('parsePolicyBlocks', () { + test('maps title, headings, bullets, and paragraphs in order', () { + final blocks = parsePolicyBlocks(_sample); + expect(blocks.map((b) => b.type).toList(), [ + PolicyBlockType.title, + PolicyBlockType.paragraph, // last-updated line + PolicyBlockType.paragraph, + PolicyBlockType.heading, + PolicyBlockType.bullet, + PolicyBlockType.bullet, + PolicyBlockType.paragraph, + ]); + expect(blocks.first.text, 'Sample Policy'); + expect(blocks[3].text, '1. Section One'); + }); + + test('joins wrapped paragraph lines and strips ** and _ markers', () { + final blocks = parsePolicyBlocks(_sample); + expect(blocks[2].text, 'First paragraph spanning two source lines.'); + expect(blocks[1].text, 'Last updated: July 2026'); + expect(blocks[4].text, 'Bold lead: bullet one'); + }); + + test('parses the real bundled policy into a sane document', () { + // Guards the asset itself: a malformed edit that breaks parsing fails here. + final real = + File('assets/legal/privacy_policy.md').readAsStringSync(); + final blocks = parsePolicyBlocks(real); + expect(blocks.first.type, PolicyBlockType.title); + expect( + blocks.where((b) => b.type == PolicyBlockType.heading).length, + greaterThanOrEqualTo(10), + ); + }); + }); + + group('PrivacyPolicyScreen', () { + testWidgets('renders the parsed document without overflow', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: PrivacyPolicyScreen(markdownOverride: _sample), + )); + await tester.pump(); + expect(find.text('Sample Policy'), findsOneWidget); + expect(find.text('1. Section One'), findsOneWidget); + expect(find.textContaining('bullet two'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('renders in landscape without overflow', (tester) async { + tester.view.physicalSize = const Size(1600, 720); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(const MaterialApp( + home: PrivacyPolicyScreen(markdownOverride: _sample), + )); + await tester.pump(); + expect(find.text('Sample Policy'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/privacy_policy_sync_test.dart b/test/privacy_policy_sync_test.dart new file mode 100644 index 00000000..6f4fbc0c --- /dev/null +++ b/test/privacy_policy_sync_test.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Pins the two privacy-policy surfaces against each other so they can't +/// drift: assets/legal/privacy_policy.md (rendered in-app) and +/// web/privacy-policy.html (hosted at /privacy-policy, linked from the Play +/// listing). Same idea as test/cross_language_sync_test.dart. +void main() { + final md = File('assets/legal/privacy_policy.md').readAsStringSync(); + final html = File('web/privacy-policy.html').readAsStringSync(); + + List mdHeadings() => RegExp(r'^## (.+)$', multiLine: true) + .allMatches(md) + .map((m) => m.group(1)!.trim()) + .toList(); + + List htmlHeadings() => RegExp(r'

(.+?)

') + .allMatches(html) + .map((m) => m.group(1)!.trim()) + .toList(); + + test('section headings match, in order', () { + final fromMd = mdHeadings(); + final fromHtml = htmlHeadings(); + expect(fromMd, isNotEmpty); + expect(fromMd, equals(fromHtml), + reason: 'assets/legal/privacy_policy.md and web/privacy-policy.html ' + 'must carry identical sections — update both together'); + }); + + test('Last updated stamps match', () { + final mdStamp = + RegExp(r'Last updated: ([^_\n]+)').firstMatch(md)?.group(1)?.trim(); + final htmlStamp = + RegExp(r'Last updated: ([^<\n]+)').firstMatch(html)?.group(1)?.trim(); + expect(mdStamp, isNotNull); + expect(mdStamp, equals(htmlStamp)); + }); + + test('both documents describe the retention windows', () { + for (final doc in [md, html]) { + expect(doc, contains('90 days')); + expect(doc, contains('30 days')); + expect(doc, contains('180 days')); + } + }); +} diff --git a/test/telemetry_consent_test.dart b/test/telemetry_consent_test.dart new file mode 100644 index 00000000..3dd41f8b --- /dev/null +++ b/test/telemetry_consent_test.dart @@ -0,0 +1,91 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:firebase_performance/firebase_performance.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:postbox_game/analytics_service.dart'; +import 'package:postbox_game/consent_preferences.dart'; +import 'package:postbox_game/services/crashlytics_helper.dart'; +import 'package:postbox_game/services/perf_service.dart'; +import 'package:postbox_game/services/telemetry_consent.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeAnalytics extends Fake implements FirebaseAnalytics { + bool? collectionEnabled; + @override + Future setAnalyticsCollectionEnabled(bool enabled) async { + collectionEnabled = enabled; + } +} + +class _FakeCrashlytics extends Fake implements FirebaseCrashlytics { + bool? collectionEnabled; + @override + Future setCrashlyticsCollectionEnabled(bool enabled) async { + collectionEnabled = enabled; + } +} + +class _FakePerformance extends Fake implements FirebasePerformance { + bool? collectionEnabled; + @override + Future setPerformanceCollectionEnabled(bool enabled) async { + collectionEnabled = enabled; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeAnalytics fakeAnalytics; + late _FakeCrashlytics fakeCrashlytics; + late _FakePerformance fakePerf; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + fakeAnalytics = _FakeAnalytics(); + fakeCrashlytics = _FakeCrashlytics(); + fakePerf = _FakePerformance(); + Analytics.instance = fakeAnalytics; + CrashlyticsHelper.resetForTest(); + CrashlyticsHelper.instance = fakeCrashlytics; + PerfService.resetForTest(); + PerfService.instance = fakePerf; + }); + + test('defaults (release): analytics off until consented, crash+perf on', + () async { + await applyStoredTelemetryPreferences(isDebug: false); + expect(fakeAnalytics.collectionEnabled, isFalse); + expect(fakeCrashlytics.collectionEnabled, isTrue); + expect(fakePerf.collectionEnabled, isTrue); + }); + + test('granted analytics consent enables collection', () async { + await ConsentPreferences.setAnalyticsConsent(true); + await applyStoredTelemetryPreferences(isDebug: false); + expect(fakeAnalytics.collectionEnabled, isTrue); + }); + + test('declined analytics consent keeps collection off', () async { + await ConsentPreferences.setAnalyticsConsent(false); + await applyStoredTelemetryPreferences(isDebug: false); + expect(fakeAnalytics.collectionEnabled, isFalse); + }); + + test('crash/perf opt-outs are honoured in release builds', () async { + await ConsentPreferences.setCrashReportingEnabled(false); + await ConsentPreferences.setPerfMonitoringEnabled(false); + await applyStoredTelemetryPreferences(isDebug: false); + expect(fakeCrashlytics.collectionEnabled, isFalse); + expect(fakePerf.collectionEnabled, isFalse); + }); + + test('debug builds force crash+perf off, analytics still follows consent', + () async { + await ConsentPreferences.setAnalyticsConsent(true); + await applyStoredTelemetryPreferences(isDebug: true); + expect(fakeCrashlytics.collectionEnabled, isFalse); + expect(fakePerf.collectionEnabled, isFalse); + expect(fakeAnalytics.collectionEnabled, isTrue); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 3dbe8286..1837f016 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -22,6 +22,7 @@ import 'package:postbox_game/main.dart'; import 'package:postbox_game/monarch_info.dart'; import 'package:postbox_game/settings_screen.dart'; import 'package:postbox_game/streak_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:postbox_game/theme.dart'; import 'package:postbox_game/user_profile_page.dart'; import 'package:postbox_game/user_repository.dart'; @@ -1362,6 +1363,39 @@ void main() { expect(find.text('Friend overtakes you'), findsOneWidget); expect(find.text('Added as a friend'), findsOneWidget); }); + + testWidgets('Privacy section shows consent toggles and data tiles', + (tester) async { + SharedPreferences.setMockInitialValues({}); + // Even taller viewport: Privacy sits below Notifications. + tester.view.physicalSize = const Size(800, 3400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(buildSettings()); + await tester.pump(); + await tester.pump(); + expect(find.text('Privacy', skipOffstage: false), findsOneWidget); + expect(find.text('Usage analytics', skipOffstage: false), findsOneWidget); + expect(find.text('Crash reports', skipOffstage: false), findsOneWidget); + expect(find.text('Performance monitoring', skipOffstage: false), + findsOneWidget); + expect(find.text('Privacy policy', skipOffstage: false), findsOneWidget); + expect( + find.text('Download my data', skipOffstage: false), findsOneWidget); + + // Analytics is opt-in: its switch must default OFF while the + // legitimate-interest crash/perf switches default ON. + SwitchListTile tile(String title) => tester.widget( + find.ancestor( + of: find.text(title, skipOffstage: false), + matching: find.byType(SwitchListTile, skipOffstage: false), + ), + ); + expect(tile('Usage analytics').value, isFalse); + expect(tile('Crash reports').value, isTrue); + expect(tile('Performance monitoring').value, isTrue); + }); }); // --------------------------------------------------------------------------- diff --git a/web/data-safety.html b/web/data-safety.html index 4c45a229..79b4215b 100644 --- a/web/data-safety.html +++ b/web/data-safety.html @@ -151,6 +151,18 @@

Data Collected

Performance improvements Optional + + Usage analytics + Anonymous usage events (Firebase Analytics) — collected only if you opt in; off by default + Understand how the App is used + Optional + + + Device token + Random per-install identifier sent with claims (not derived from hardware); removed after 90 days + Multi-account abuse detection + Required + diff --git a/web/privacy-policy.html b/web/privacy-policy.html index fd3ead3c..f6445f34 100644 --- a/web/privacy-policy.html +++ b/web/privacy-policy.html @@ -69,7 +69,7 @@

The Postbox Game – Privacy Policy

Your privacy matters to us.

- Last updated: April 2026 + Last updated: July 2026

This Privacy Policy explains how The Postbox Game ("the App", "we", "us") collects, uses, and protects your personal data when you use our mobile application. We are committed to complying with the UK General Data Protection Regulation (UK GDPR) and the Data Protection Act 2018.

@@ -79,18 +79,23 @@

1. Who We Are

2. Data We Collect

  • Account information: When you register or sign in with Google, we receive your email address and display name. When you register with email/password, we store your email address and chosen display name.
  • -
  • Location data: With your permission, we collect your precise GPS location to identify nearby postboxes and validate claims. Location is used only during active gameplay and is not stored permanently.
  • +
  • Location data: With your permission, we collect your precise GPS location to identify nearby postboxes and validate claims. The location you claimed from is stored with each claim for anti-cheat verification and is automatically removed after 90 days.
  • Gameplay data: Postbox claims you make (postbox ID, timestamp, points awarded), your running scores, streaks, and leaderboard entries (display name and points only).
  • +
  • Device token: A random per-install identifier sent with claims to detect multi-account abuse. It is not derived from your hardware and identifies only the app installation; it is removed from claims after 90 days.
  • Friends list: User IDs of friends you choose to add within the App.
  • -
  • Crash and performance data: Anonymous crash reports and performance traces collected by Firebase Crashlytics and Firebase Performance Monitoring to help us fix bugs and improve the App.
  • -
  • Analytics: Anonymous usage events (screens visited, features used) collected by Firebase Analytics to understand how the App is used.
  • +
  • Problem reports: If you report a postbox data problem, we store your report, its location, and any note or photos you attach (photos may include the time and place they were taken).
  • +
  • Anti-abuse records: Automated checks may record anomaly flags and an internal trust score for your account. While this system runs in "shadow mode" it has no effect on your account or gameplay.
  • +
  • Crash and performance data: Crash reports and performance traces collected by Firebase Crashlytics and Firebase Performance Monitoring to help us fix bugs. You can turn these off in Settings → Privacy.
  • +
  • Usage analytics (optional): Anonymous usage events collected by Firebase Analytics, only if you opt in. Analytics is off until you choose to share it, and you can change your choice any time in Settings → Privacy.

3. How We Use Your Data

  • To operate the App and provide gameplay features (claims, leaderboards, friends).
  • To display your scores and display name on leaderboards and to friends.
  • -
  • To improve the App by analysing crash reports, performance data, and aggregated usage statistics.
  • +
  • To keep the game fair by detecting location spoofing and multi-account abuse.
  • +
  • To review postbox data problems you report and improve the postbox database.
  • +
  • To improve the App by analysing crash reports, performance data, and (with your consent) aggregated usage statistics.
  • To associate your gameplay history with your account so it persists across devices.
@@ -98,20 +103,24 @@

4. Legal Basis for Processing

We process your personal data on the following legal bases under UK GDPR:

  • Contract: Processing your account and gameplay data is necessary to provide the service you signed up for.
  • -
  • Legitimate interests: Crash reporting and performance monitoring to maintain a working and safe app.
  • -
  • Consent: Location access, which you can grant or revoke at any time in your device settings.
  • +
  • Legitimate interests: Crash reporting and performance monitoring to maintain a working app (you can object via the Settings → Privacy toggles), and anti-abuse checks to keep the game fair.
  • +
  • Consent: Usage analytics (opt-in inside the App) and location access (grant or revoke at any time in your device settings).

5. Data Sharing

We do not sell your personal data. We share data only with the following service providers, who process it on our behalf:

  • Google Firebase (Authentication, Firestore database, Cloud Functions, Crashlytics, Performance Monitoring, Analytics) – processed within Google's infrastructure. See Google's Privacy Policy.
  • +
  • OpenStreetMap / Nominatim: If you search for a destination in Route Mode, only the text you type is sent to the OpenStreetMap Nominatim search service – never your GPS position.
-

Your display name and score are visible to other users on leaderboards and to friends you add in the App.

+

Your display name and score are visible to other users on leaderboards and to friends you add in the App. Corrections we submit back to OpenStreetMap from accepted postbox reports contain only postbox data, never anything about you.

6. Data Retention

    -
  • Account data is retained for as long as your account exists.
  • +
  • Account data is retained for as long as your account exists; deleting your account anonymises your claims and removes your personal data (see Your Rights).
  • +
  • The GPS position, timing metadata, and device token stored with a claim are automatically removed after 90 days.
  • +
  • Photos and notes attached to a postbox problem report are removed 30 days after the report is reviewed.
  • +
  • Anti-abuse anomaly records are deleted after 180 days.
  • Leaderboard entries are retained for the relevant period (daily / weekly / monthly) then overwritten.
  • Crash and analytics data is retained per Firebase's default retention periods (90 days for Crashlytics; configurable for Analytics).
@@ -119,17 +128,16 @@

6. Data Retention

7. Your Rights

Under UK GDPR you have the right to:

    -
  • Access the personal data we hold about you.
  • +
  • Access and portability: Use Settings → Privacy → "Download my data" in the App to receive a machine-readable copy of everything we store about you.
  • Rectification of inaccurate data (e.g. update your display name in the App).
  • -
  • Erasure ("right to be forgotten") – contact us to delete your account and associated data.
  • -
  • Restriction or objection to certain processing.
  • -
  • Data portability for data you provided to us.
  • -
  • Withdraw consent for location access at any time via your device settings.
  • +
  • Erasure ("right to be forgotten"): use Settings → "Delete account" in the App. Your personal data is deleted and your claims are anonymised immediately.
  • +
  • Restriction or objection to certain processing, including turning off crash reporting, performance monitoring, and analytics in Settings → Privacy.
  • +
  • Withdraw consent for analytics (Settings → Privacy) or location access (device settings) at any time.

To exercise any right, contact us at richard@agilepixel.io. You also have the right to lodge a complaint with the Information Commissioner's Office (ICO).

8. Location Data

-

The App requests access to your device's precise location (while the app is in use) to find nearby postboxes. We do not track your location in the background, share it with third parties, or store it beyond the duration of an active claim check.

+

The App requests access to your device's precise location (while the app is in use) to find nearby postboxes. We do not track your location in the background. The position you claimed from is stored with that claim for anti-cheat verification and automatically removed after 90 days. Other players never see your location – nearby postboxes are shown only as fuzzy directions.

9. Children

The App is not directed at children under 13. We do not knowingly collect personal data from children under 13. If you believe a child has provided us with personal data, contact us and we will delete it.