Support reports and local reliability counts¶
Both phone apps provide explicit report previews, GitHub issue drafts, private email and offline export. Support reports and local counts are never uploaded automatically. Android and the Apple phone/Catalyst separately offer opt-in Firebase Analytics and Crashlytics; Watch/widgets contain no Firebase SDK. There is no GitHub token in the apps. Watch keeps privacy/support access but has no report UI or durable counter store.
Optional Firebase telemetry¶
Allowlisted usage events¶
Both phone apps and Catalyst report only these custom Analytics events, through the same saved opt-in as automatic usage analytics and crash reporting:
| Event | Meaning | Parameters |
|---|---|---|
espurr_connection_result |
A real native-owner connection attempt finished. | outcome, duration_bucket |
espurr_firmware_result |
A started firmware update finished or became uncertain. | outcome, duration_bucket |
Parameters are strings selected by typed enums. There is no arbitrary event name, free-text value, raw exception, device/accessory identifier, firmware version, image name/hash/size, setting, sensor sample or exact elapsed time in this payload. The SDK still attaches its own ordinary event/installation metadata, as disclosed below. No start events, polling, settings edits, logs, recordings or discovery results are reported. Demo sessions and Watch/widgets have no reporting hook.
Connection outcomes are confirmed, timed_out, authentication, refused,
invalid_response, transport, cancelled, unknown or failed. Firmware uses
the same failure/cancellation categories plus selected and verified; it does
not use confirmed. Selected is not verification: Apple ends an update after
validated image selection/reboot request and reports selected; Android reports
verified only after reconnecting and checking the running project/version. Its
selection milestone is not a second custom event. Interrupted finalisation remains
unknown; it is not converted to success or failure. Automatic reconnect attempts
use the same connection categories and are attempts, not a distinct-user count.
duration_bucket |
Monotonic elapsed duration |
|---|---|
under_2s |
Less than 2 seconds |
2_to_5s |
At least 2, less than 5 seconds |
5_to_15s |
At least 5, less than 15 seconds |
15_to_60s |
At least 15, less than 60 seconds |
1_to_5m |
At least 1, less than 5 minutes |
5m_or_more |
At least 5 minutes |
Timings cover the existing owner's operation, not separate radio/GATT phases. Android firmware time includes its post-reboot check, whereas Apple stops at image selection. Compare those outcomes separately. Counts are not persisted or batched by an app telemetry queue: the existing support attempt passes a typed terminal result through a one-use reporter. The reporter is created only in an allowed consent period and checks that the same period is still allowed at completion. Unknown/declined consent, revocation preparation on Android, and revoke/re-enable cycles discard in-flight reporters. An operation started while opted out cannot be backfilled after opt-in. Local reliability-count settings remain independent; their stored counters/history are never forwarded. SDK logging failure cannot break an Android control operation.
The shared schema fixture exhaustively
checks allowed operation/outcome combinations and duration boundaries in both
native test suites. UsageTelemetry.swift / UsageTelemetry.kt build payloads;
TelemetryConsentController gates them; only the Firebase adapters call the SDK.
Review this contract and the privacy policy before expanding the schema.
For parameter breakdowns in Analytics reports, outcome and
duration_bucket were registered as event-scoped custom dimensions on
11 November 2026; see the console review and
Firebase's custom-parameter guidance.
The console definitions and debug-build live receipt
were verified separately from source tests. Final distribution builds need their
own receipt/symbol checks. Use event counts split by event name, platform, outcome and
duration bucket; no custom numeric metric or installation-ID dimension is needed.
Keep advertising/audience integrations and independent exports disabled unless
separately reviewed; these events do not require enabling them.
Android¶
Android asks Allow analytics and crash reports? on first opening, with Allow and Don't allow actions and offline Privacy Policy access. Back declines; tapping outside does not choose. Unknown, corrupt and declined consent never initialise Firebase. Share analytics and crash reports in Settings and Support controls all installed Firebase products together. Support remains reachable without hardware. An upgrade without a saved choice also starts with the prompt and no Firebase.
TelemetryConsentController persists a versioned choice and a purge-required marker
synchronously before constructing its backend. Every backend startup checks that
marker and clears it only after successful SDK-store cleanup. Failed cleanup stays
fail-closed across relaunches; declined cleanup failure never prevents offline use. FirebaseInitProvider is removed from the merged manifest;
Analytics, Crashlytics and Firebase-wide collection default off. On unknown/denied
startup, Application synchronously disables Analytics and DataTransport background
services/receivers before any Firebase access; only a configured, allowed backend
enables them. Their manifest declarations must retain enabled=true: the pinned
SDK's readiness checks read that bit and reject uploads when it is false, even
with an enabled runtime override. Revocation disables these entry points and cancels
their pending app jobs. The only Firebase
initialisation/access is in FirebaseTelemetry, behind this gate. Missing local
Firebase configuration leaves source/CI builds usable without telemetry.
The integration enables basic automatic Analytics events plus Java/Kotlin/native crashes and ANRs, plus the two bounded usage events below. No user IDs, custom crash keys/logs or non-fatal exception uploads are added. Advertising ID/Android ID collection, all advertising consents, automatic screen reporting and Firebase Sessions quality telemetry are disabled. Generated SDK installation identifiers, phone/OS metadata, timestamps, crash state and network metadata still exist: this is pseudonymous collection, not guaranteed anonymity. Accessory/support data is not attached to Firebase. A fatal exception's message/state can still contain data; do not promise automatic crash reports are universally free of personal information.
Revocation is blocked during connection, edits, exports and firmware updates. A
confirmation explains Turn off and close. A controller-level stopping state and
non-dismissible modal block new work across Activity recreation. The choice is
saved before reporting stops: the choice is persisted, SDK collection
is disabled, Analytics reset and pending-report deletion are requested, then the
controller disconnects and the process exits. This is necessary because
Crashlytics' disable API takes effect on the next run.
The next denied/unknown startup never initialises Firebase and clears only
SDK-owned crash stores, the DataTransport queue and Analytics local queue/preferences. A fresh opt-in also clears these before SDK
startup, so old reports cannot be sent by later re-enabling. Deletion covers the
pinned SDK's .crashlytics.v3 and legacy .com.google.firebase.crashlytics.files*
stores, including open and native sessions, plus com.google.android.datatransport.events
and google_app_measurement_local.db; recheck these storage contracts on SDK upgrades.
Instrumentation's runner persists a declined choice before Application startup, and
consent UI tests use fake backends so tests never upload to the configured project.
Continuously allowed restarts preserve pending crashes for normal reporting.
Already transmitted/in-flight requests cannot be recalled; turning off does not
delete server-held data or disable explicit support/firmware-download actions.
The 11 November console review records disabled optional Analytics account sharing, non-Firebase uses of Firebase Service Data, Google Signals, ads personalisation, granular location/device collection and enhanced audience integration, with two-month user/event retention and activity reset off. The live follow-up also verified Crash Insights cross-app sharing disabled for both apps. Recheck these controls before distribution. Do not enable advertising integrations. Firebase's SDK disclosure guide includes transitive installation/transport dependencies. Update Play Data Safety for optional analytics, diagnostics and identifiers; opt-in does not mean no data is collected. Apple platform differences are recorded in mobile parity.
Apple phone, iPad and Catalyst¶
The Apple app uses Firebase 13.1.1's FirebaseAnalyticsCore and FirebaseCrashlytics.
FirebaseAnalyticsWithoutAdId is no longer the current SwiftPM product name; Core
omits the identity-support and advertising/conversion products. App-instance/Firebase
installation identifiers still exist. Advertising consents, IDFV collection, automatic
screen reporting and Firebase Sessions telemetry are disabled in the phone/Catalyst
Info plists. Neither Watch nor widgets link Firebase or create a telemetry preference.
AppTelemetry uses the shared, Firebase-free consent model in EspurrControl.
An unknown/declined choice never calls FirebaseApp.configure(). The first-open
privacy screen offers the same Allow/Don't allow decision and offline policy; the
Settings and disconnected Support switch controls both services. No ATT prompt is
requested: ESPurr does not use advertising identifiers or cross-company tracking.
Apple keeps automatic Crashlytics collection permanently disabled. Only a cold
start with continuous saved consent explicitly calls sendUnsentReports() for
previous reports. A fresh opt-in discards old pending data, and opting out disables
and resets Analytics, requests deletion of unsent crashes and marks SDK data for
purging before a later configure. This avoids terminating an iOS app. Requests
already authorised/in flight cannot be recalled. A failed purge keeps its durable
marker and does not configure Firebase. No app code uploads raw support/accessory
history, logs, audio or custom exceptions.
Apple's Share With App Developers controls Apple's own diagnostics pipeline; there is no supported app API for using that switch as Firebase consent. Apple's device-analytics explanation explicitly distinguishes an app's own analytics. System diagnostics continue to follow system settings independently. The in-app choice covers Firebase only.
User flow and data boundary¶
Support is reachable from welcome, Settings and recovery, including demo and permission-denied/disconnected use. Report previews retain their captured technical history if opening a browser, mail app or document picker releases the BLE session. Covered ear previews pause while Support is shown. There is no application account and no additional permission request.
- Open GitHub report shows a public/account warning before external navigation.
A fixed
neggles/espurr/issues/newURL selectsmobile-bug.ymland prefills the app platform/version, OS, allowlisted firmware family/numeric version and typed problem category. Opening the draft transfers those URL fields to GitHub before posting; the warning says so. No model, narrative, email/name, BLE identity, credentials, operation history, counts or raw logs enter the URL. The encoded URL has a 2,000-byte limit and falls back to template/title. The user writes the description in GitHub, reviews it, and decides whether to submit. - Email support explains sender-address/display-name sharing before composing to
espurr-support@neggles.dev. iOS uses MessageUI with editable reviewed details; unavailable MessageUI falls back to subject-only mailto plus Save/Copy instructions. Android uses the native SENDTO composer with editable details; mail clients that ignore body extras can use Save/Copy. Missing handlers surface recovery text. Opening/dismissing a draft never claims delivery. No mail is sent by the app. - Save report / Copy technical details work offline. The native document picker writes a plain-text Markdown report; copy is explicit. Apple clipboard copy is local and expires after two minutes; Android uses its normal clipboard. Destinations can have their own cloud sync. Copies already exported are outside app-history deletion.
- Include reliability counts / Include accessory logs default off per preview. They affect saved/copied/emailed details only. Raw accessory logs require an explicit owner-serialised idle read or reuse of an already requested buffer; the user can inspect them. The report caps included logs at 4,096 characters. No audio recording, sensor sample, free-form exception, password or Wi-Fi value is automatically attached.
The issue form is mobile-bug.yml. Its field IDs
must stay aligned with both SupportReport URL builders. Maintainer configuration is
in the two shared diagnostic models (issueBase/ISSUE_BASE and email/EMAIL), not
an arbitrary runtime URL or a secret/token. The private support route deliberately
uses the user's mail identity instead of collecting another name/email profile.
Diagnostic and counter contract¶
SupportDiagnostics.swift and SupportDiagnostics.kt accept enum operation/outcome
pairs, not arbitrary logging strings. They retain at most 100 active events and three
100-event failure sequences in memory. A frozen failure retains its allowlisted firmware
family/version even after another device/session is seen. Raw events/snapshots are not
persisted; disconnect preserves them, process termination and Clear diagnostic history
remove them. Clear also discards the current support/session log buffer. No crash
interception or durable event archive was added.
Keep reliability counts on this phone defaults off. Enabling captures subsequent real operations only. Preferences hold schema 1, up to 30 UTC-day buckets, allowlisted integer counters saturated at 1,000 per key/day, the independent suggestion preference and last-shown time. Unknown keys, future/expired days and invalid payloads are discarded; duplicate days merge with maximum, not addition. Off/reset clears counts and invalidates in-flight counter tickets, preventing completion from backfilling the new epoch. Pruning happens on access/mutation; it is not a background retention worker. Android app backup is disabled; Apple preferences can follow system backup policy. No developer gets these preferences automatically.
| Counter/event | Meaning |
|---|---|
connection.started / result |
Explicit authentication attempt and confirmed/error/cancel outcome. Duration ranges measure complete confirmed connection, not individual GATT/CoC stages. |
connection.duration.* |
Confirmed connection duration under 2 s, 2–5 s, 5–15 s or at least 15 s. |
settings.started / result |
A sent coalesced batch, palette restore or AP setup operation; no control names/values/passwords recorded. Unsent slider edits are not batches. |
refresh.* |
Typed read failures, excluded from repeat-failure suggestion thresholds. Routine successful polls are not counted. |
calibration.*, recording.*, logs.* |
Owner operation outcomes, with no captured content in the event. Expected refusals/cancellation do not prompt. |
persistence.failed |
One episode when firmware reports a nonzero save error, until that error clears; not one event per poll. |
link.lost |
Unexpected active-link loss; intentional disconnect/background/handoff is not counted as loss. |
firmware.started, result/milestone |
Attempt, confirmed abort/cancel, failure, ambiguous outcome, selected image and (Android only) checked post-reboot firmware. Selected is a milestone, not installation verification. Do not sum milestones as mutually exclusive terminal outcomes. |
Demo/preview sessions are excluded at the real-owner boundary: Apple injects diagnostics
only into native phone clients; Android excludes state.demo. Watch instrumentation is
nil by default and does not create a Watch preferences store or reporting UI. Existing
owner generation guards prevent late callbacks from counting a newer attempt. Tests use
in-memory or isolated stores; they never upload counters.
Report suggestions are inline and dismissible, default on but independent of counts. They require an invalid response or repeated matching unexpected user-operation failure within five minutes, with at most one shown per process and a seven-day persisted gap. Confirmed success resets the repeat candidate. Expected refusals, authentication errors, cancellation, ordinary discovery/no-hardware states, permission denial and intentional release do not qualify. Apple suppresses generic disconnected connection suggestions because the existing transport also uses that error for Bluetooth unavailable states; manual reporting remains available. Automatic refresh errors do not advance thresholds. Presentation waits for foreground/idle and defers around pairing, OTA, other modals and Apple's existing error alert. The original recovery/error UI remains primary.
Privacy and maintenance¶
The shared bundled privacy policy is used by both phones and Watch. It is imported from the web-owned policy at a reviewed commit; provenance and an offline CI check prevent unrecorded local edits. Follow the import workflow for changes. Publishing it and deciding support-provider retention are manual store-release tasks. Local counts stay on-device unless explicitly included in a report. Voluntary submitted support content still needs to be considered in final store disclosures.
Apple app privacy manifests declare private preferences, app-container file
timestamps and elapsed timers. Android's preference DTOs have scoped R8 rules.
Keep the two models, issue-form field IDs and shared
tests/fixtures/support-diagnostics-v1.json aligned when changing this contract.
Dated implementation and release checks record validation and remaining native-provider, accessibility and physical checks.