Reviewer lens: what the sidecar stores, who could read it, and whether each permission and dependency is the minimum needed.
Inputs: docs/PRD-original.md, docs/DESIGN-DRAFT.md (2026-10-03).
Verdict: the direction is right: no INTERNET, no bodies by default, Person-uri contact inference, OTP redaction.
But the draft has five real holes, and each one would quietly break a privacy claim the PRD makes:
android.title). The smsto: action needs the number anyway. (§2)dumpsys notification. (§7)allowBackup="false" alone does not stop device-to-device transfer. (§3)The design needs to state this explicitly. Each control below should map to one of these rows. Otherwise "encrypt everything" becomes cargo cult.
| # | Adversary | Realistic for Byron? | Primary control |
|---|---|---|---|
| T1 | Google account / cloud backup compromise | Yes | Backup exclusion (§3) |
| T2 | Another app on the phone (incl. other notification listeners, accessibility apps, keyboards) | Yes | Sandbox; minimal content in our notifications (§7); FLAG_SECURE |
| T3 | A person holding the unlocked phone, or watching the lock screen | Yes | Notification visibility (§7); optional biometric gate on History/Review screens |
| T4 | Forensic extraction / rooted device after first unlock (AFU) | Low | SQLCipher + Keystore-wrapped key (§3) |
| T5 | The developer/agent pipeline during Phase 0 (probe exports pasted into chats, CI logs) | Yes, this week | Probe export allowlist (§8) |
| T6 | Senders (third parties who never consented) | Ethical, minor legal | Retention TTLs, no bodies (§9) |
| T7 | Google (AICore/ML Kit telemetry) | Only if LLM enabled | Keep the LLM out of the no-network build (§5) |
Out of scope: a compromised OS, and a malicious Google Messages (it already has everything).
Short answer: it is weak as pseudonymization, useful as an index, and good for crypto-shredding. Only that last one is worth claiming.
KeyProperties.KEY_ALGORITHM_HMAC_SHA256, StrongBox if available). Then each HMAC needs an on-device Keystore call, which is roughly milliseconds in the TEE. Enumerating the space means running code as our app on this device for weeks, and an attacker who can do that can simply read new notifications. So it raises the bar for offline attacks on an exfiltrated DB (T4) or export (T5), and nothing more.android.title is the formatted number. "Ask who this is" (smsto:<number>) and the PendingIntent fallback both need the cleartext number. So the number is in the DB regardless.Recommendation.
sender_key = HMAC_keystore(E.164-normalized number) as the lookup key.sc:12345, alpha:AMAZON). If normalization is wrong, one person splits into two "unknown" senders, and the trajectory feature silently fails.androidx.security:security-crypto) is deprecated. "Deprecated all APIs in favour of existing platform APIs and direct use of Android Keystore". This landed in 1.1.0-alpha07 (Apr 2025) and beta01 (Jun 2025). 1.1.0 stable (2025-07-30) is the final, deprecated release. Do not use EncryptedSharedPreferences / MasterKey. Source: https://developer.android.com/jetpack/androidx/releases/securitynet.zetetic:sqlcipher-android (4.x, with the SupportOpenHelperFactory for Room). The legacy net.zetetic:android-database-sqlcipher artifact is deprecated. (Reviewer did not re-verify the current version number; pin it at scaffold time.)directBootAware: we want nothing written before first unlock.noBackupFilesDir.setUnlockedDeviceRequired(true) on the DB key. The listener must write while the phone is locked, so that flag would break ingestion. This is the main reason SQLCipher only buys partial T4 protection (AFU state).setUnlockedDeviceRequired(true), plus optional setUserAuthenticationRequired.android:allowBackup="false".android:dataExtractionRules="@xml/data_extraction_rules" (API 31+). Exclude every domain (root, file, database, sharedpref, external) under both <cloud-backup> and <device-transfer>. For apps targeting 12+, allowBackup=false still permits device-to-device migration.android:fullBackupContent="@xml/backup_rules", excluding all, for API ≤30.The draft says "drop the DB plus rotate the HMAC key". Rotate is the wrong verb: delete it. Because flash storage doesn't securely erase, the real erasure mechanism is crypto-shredding. The full sequence, in order:
cancelAll() our own posted notifications.androidx.work.workdb) that persists Data inputs. Rule: never pass message text, numbers or names as WorkManager input. Pass only an opaque row ID.-wal, -shm and -journal files.filesDir, noBackupFilesDir, cacheDir, DataStore files, and any local crash ring buffer.Note: this is distinct from "revoke Notification Access", which should be offered alongside. Add an instrumentation test: populate everything, delete, then assert that the app's data dir contains only freshly created empty files.
The claim is good, but nothing enforces it yet. A manifest-enforced guarantee only holds if the merged manifest is checked on every build.
assembleRelease, run apkanalyzer manifest permissions app-release.apk (or aapt2 dump permissions). Fail the build unless the set equals an explicit allowlist, e.g.:POST_NOTIFICATIONSRECEIVE_BOOT_COMPLETED (if needed for the heartbeat)DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION (auto-added by AndroidX)The service itself is protected by BIND_NOTIFICATION_LISTENER_SERVICE; that is a permission= attribute, not a uses-permission. Deny explicitly: INTERNET, ACCESS_NETWORK_STATE, READ_CONTACTS, READ_CALENDAR (until §10), READ_SMS, RECEIVE_SMS, SEND_SMS, QUERY_ALL_PACKAGES, BIND_ACCESSIBILITY_SERVICE, READ_PHONE_STATE.
./gradlew :app:dependencies --configuration releaseRuntimeClasspath:com.google.firebase:*com.google.android.datatransport:*play-services-measurement*okhttp, ktor-client, cronetadb shell dumpsys package <pkg> | grep -A30 "requested permissions".INTERNET + ACCESS_NETWORK_STATE from transitive deps (to verify at scaffold time). We could strip them with tools:node="remove", but whether inference survives that is untested, and the disclosure gives no opt-out.Recommendation: keep the LLM out of v1 entirely, as the deterministic engine must stand alone anyway. If it is added later, ship it as a separate build flavor, ideally a separate opt-in APK talking to the core over a signature-protected bound service. The core APK keeps a provable zero-network manifest, and the transparency screen states which build is installed. Prefer a fully local runtime (llama.cpp / a GGUF model loaded through SAF) over AICore if a manifest-provable guarantee is wanted. Check MediaPipe LLM Inference for telemetry before choosing it.
smsto: draft is user-confirmed and fine, but never fire ACTION_VIEW on URLs from messages; the PRD already forbids auto-open.ClipDescription.EXTRA_IS_SENSITIVE.Code rule: the only outbound intent carrying message-derived data is the user-tapped smsto:, and it carries a fixed template, never quoted message text.
Log.v/d/i via R8 -assumenosideeffects. Use a logging wrapper whose release variant drops all args except a static event code.StatusBarNotification.toString(), Notification.extras, Bundle.toString() or Person. These dump titles and text.IllegalArgumentException("bad URL: $url") puts content into the stack trace. Lint rule: exception messages must be constants.adb logcat, bug reports and OEM log collectors (Samsung's diagnostic upload) still can.adb shell run-as read the DB. The probe APK can be debuggable, but its data must stay structural-only (§8).The sidecar posts derived content, such as "Likely scam from +1 415… — asks for a code". Android 15's OTP/sensitive redaction doesn't apply to our content. Every other notification listener on the device, paired watches, and the lock screen see it.
VISIBILITY_PRIVATE with a setPublicVersion() that says only "Message checked", showing no verdict, no sender and no number.VISIBILITY_SECRET for anything touching OTP/financial/health detectors, and for "Likely scam" (the verdict itself is sensitive: it says a scam targeted you).lockscreenVisibility should match, because the user can override per-channel.setLocalOnly(true), so annotations don't bridge to Wear OS / Galaxy Watch.dumpsys notification.FLAG_IMMUTABLE and explicit component intents.The draft says the probe "logs structure, never message text". Structure still leaks:
| Field | Why it leaks | Probe should emit |
|---|---|---|
android.title, android.conversationTitle, android.subText, android.selfDisplayName | Contact names, numbers, group names, Byron's own name | presence + type + length bucket |
Person.name, Person.key, Person.uri | Names; tel:+1…; contacts lookup keys | uri scheme only (tel / content / mailto / null); name present y/n |
shortcutId, getGroup(), getSortKey(), notification tag | Google Messages may embed conversation or participant IDs | presence + length + charset class (digits / alnum) |
android.messages[] | Text, timestamps, senders, Byron's own replies | count; per-entry: has text, text length bucket, has sender Person, is-self |
android.remoteInputHistory, android.bigText, android.tickerText, android.infoText, android.summaryText | Duplicates of content | presence + length bucket |
Exact timestamps / when | With lengths, enough to re-identify messages against Byron's inbox | relative order only, or minute-rounded with a per-export random offset |
| Actions | Labels are fine; RemoteInput keys fine | action titles + RemoteInput present y/n + semanticAction |
Probe rules:
onNotificationPosted, before touching extras. Notification Access grants every app's notifications, and the probe must not even parse the others.{key, type} only.ACTION_CREATE_DOCUMENT to a user-chosen location, never to shared Downloads automatically. Show a pre-export preview screen.Design for that now; it costs nothing.
Person.uri to a content://com.android.contacts/...lookup URI for saved contacts and tel: for others, that scheme is the "in contacts" signal, with no permission needed. Do not treat Person.name as evidence. Messages may show business names or caller-ID names for non-contacts. If the spike shows URIs are absent (Samsung? RCS business?), the fallback is READ_CONTACTS used only for a single-number ContactsContract.PhoneLookup per message: never enumerate, never cache contact data, persist only a boolean. "Add to Contacts" uses ACTION_INSERT and needs no permission. Note: READ_CONTACTS is all-or-nothing and covers every contact's emails, notes and addresses, a big grant for one boolean.TITLE, EVENT_LOCATION, DTSTARTCALENDAR_MATCHThe manual expected-senders ledger delivers most of the value without it.
NotificationGate class that is the only code allowed to call snoozeNotification / cancelNotification, and it asserts the package is Google Messages| Field | Purpose | Retention | Location | Protection |
|---|---|---|---|---|
| Raw notification extras (title, text, messages[], people) | Scoring | In memory only, discarded after scoring | Process memory | Never logged / persisted / passed to WorkManager |
sender_key = HMAC(E.164 / short code / alpha ID) | DB lookup key; export-safe ID | With sender row | Room/SQLCipher | Keystore non-exportable HMAC key; key deletion = unlink |
| Sender number / display label | smsto: draft, UI | With sender row (90 d idle; 30 d if never acted on) | Room/SQLCipher | DB encryption; never in notifications/logs |
| Sender trust state (Trusted / Not legit / none) + set-at | User decision | Until user removes | Room/SQLCipher | DB encryption |
| Verdict history: (verdict, reason codes, 5 dimension scores, completeness, timestamp) | Trajectory, explanations | Last 10 per sender; 90 d | Room/SQLCipher | DB encryption; codes only, no spans |
| URL evidence | Explain BRAND_DOMAIN_MISMATCH etc. | With verdict | Room/SQLCipher | eTLD+1 + flags only, never full URL |
| Matched text spans | Live explanation | In memory only | Process memory | Only shown in our FLAG_SECURE UI / our notification as template text |
| Message body excerpt (opt-in) | Review | 48 h default, ≤7 d | Room/SQLCipher | + Keystore asymmetric envelope, private key unlocked-device-required |
| OTP / financial / health spans | (none) | Never stored | — | Detectors run before persistence, LLM and notification composition |
| Context ledger (expected senders, notes) | Raise context_confidence | Mandatory expiry (14 d default, ≤1 y) | Room/SQLCipher | DB encryption; may contain health data |
| Calendar match (if v1.x opt-in) | Context | Transient; persist code only | Memory | Next-7-days query, selected calendars only |
| Contact status | identity_confidence | Boolean per verdict | Room/SQLCipher | Derived from Person.uri scheme; no READ_CONTACTS |
| contentIntent PendingIntent | "Open conversation" | Until notification removed / process death | Memory | Never persisted (can't be) |
| Snoozed-notification keys | Un-snooze on delete / reversibility | Until un-snoozed | Room/SQLCipher | Finite snooze duration |
| Per-code weight multipliers (feedback) | Learning | Until reset | Room/SQLCipher | Bounded; no sender data |
| Settings (toggles, thresholds, chosen packages) | Config | Until reset | DataStore (plain) | No personal data allowed here |
| DB passphrase (wrapped) | Unlock DB | Until delete-all | noBackupFilesDir | Wrapped by Keystore AES-GCM (StrongBox if present) |
| Scrubbed crash signatures | Debugging | Ring buffer, 50 entries / 30 d | noBackupFilesDir | No exception messages; manual export only |
| Probe export (Phase 0) | Feasibility spike | 14 d auto-delete; user-held copies out of scope | SAF user-chosen | Allowlist serializer + canary test |
| WorkManager DB | Purge/heartbeat jobs | System-managed | App DB (unencrypted) | Inputs must be opaque IDs only |
| Our posted notifications | Annotation | Until dismissed / delete-all | System (notification manager) | PRIVATE/SECRET, public version, setLocalOnly, no text/number |
net.zetetic:sqlcipher-android, passphrase wrapped by a Keystore AES-GCM key; no security-crypto (deprecated 2025)". Add the asymmetric envelope for opt-in bodies.allowBackup=false + dataExtractionRules excluding cloud and device-transfer + fullBackupContent.core CI job.