From db8b439609affce52dcf214780894909cf5824ce Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Jul 2026 15:28:57 -0400 Subject: [PATCH 01/35] Add consent-gated Android Sentry runtime init --- SENTRY.md | 30 ++++++++++--- src-tauri/gen/android/app/build.gradle.kts | 19 ++++++++ .../android/app/src/main/AndroidManifest.xml | 13 +++++- .../social/cloudhub/charm/CharmApplication.kt | 45 +++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt diff --git a/SENTRY.md b/SENTRY.md index 31dc37a9..b8158622 100644 --- a/SENTRY.md +++ b/SENTRY.md @@ -24,12 +24,32 @@ already-running frontend client for the current window and apply to Rust crash monitoring on restart. Re-enabling after a same-window opt-out flips the frontend client back on without calling `Sentry.init()` a second time. +Android JVM setup lives in +`src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt`. +The app manifest removes Sentry's Android `ContentProvider` auto-init path, so +adding the runtime SDK does not start Sentry before application code runs. +`CharmApplication` initializes `SentryAndroid` only when all conditions are true: + +- `SENTRY_DSN` or `VITE_SENTRY_DSN` was present at Android build time. +- `observability.json` in Android app storage has + `observability.state.sentryEnabled: true`. + +The Android runtime initializer re-checks the same store in `beforeSend`, keeps +`sendDefaultPii` off, disables Android auto-session tracking, and sets +`tracesSampleRate` to `0.0`. This initial Android coverage is therefore scoped +to Sentry Android's native/JVM crash and ANR capture after opt-in. Android +Mobile Vitals/performance transactions remain disabled until Charm has a +same-session native consent bridge that can shut down or reconfigure the SDK +immediately when a user opts out. + ## Environment Use these variables for local or release builds: - `VITE_SENTRY_DSN`: public frontend DSN. -- `SENTRY_DSN`: Rust/native DSN. +- `SENTRY_DSN`: Rust/native DSN. Android also embeds this at build time for + native runtime crash coverage, falling back to `VITE_SENTRY_DSN` when + `SENTRY_DSN` is absent. - `VITE_SENTRY_ENVIRONMENT` / `SENTRY_ENVIRONMENT`: Sentry environment. - `VITE_SENTRY_RELEASE` / `SENTRY_RELEASE`: release override. - `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT`: artifact upload through @@ -65,9 +85,9 @@ bundle shape as shipped releases. Manual runs can override the Sentry release name and environment; tag runs default the release name to the tag, and manual runs without a release input default to the commit SHA. -Signed iOS device-release dSYMs, native Android SDK runtime crash coverage, and -Sentry size-analysis uploads are still Phase 3 follow-ups. Add them to the -release artifact workflow once the corresponding signed/release or native SDK +Signed iOS device-release dSYMs, Android Mobile Vitals, and Sentry size-analysis +uploads are still Phase 3 follow-ups. Add them to the release artifact workflow +once the corresponding signed/release, native consent bridge, or size-analysis pipeline exists. ## Scrubbing Rules @@ -91,5 +111,5 @@ and Rust attachment-upload IPC breadcrumbs correlated by the frontend operation ID header. User feedback, screenshots, broader Rust tracing/log bridges, native Android -SDK runtime coverage, signed iOS device-release dSYMs, and size analysis remain +Mobile Vitals, signed iOS device-release dSYMs, and size analysis remain separate follow-up phases from Spec 21. diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index 0c114f57..45d9e374 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -30,6 +30,9 @@ if (hasGoogleServicesConfig) { fun requiredSentryEnv(name: String): String = System.getenv(name) ?: error("$name is required when SENTRY_ANDROID_UPLOAD=true") +fun buildConfigString(value: String): String = + "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + val sentryAndroidUpload = System.getenv("SENTRY_ANDROID_UPLOAD") == "true" if (sentryAndroidUpload) { apply(plugin = "io.sentry.android.gradle") @@ -64,6 +67,21 @@ android { targetSdk = 36 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + buildConfigField( + "String", + "SENTRY_DSN", + buildConfigString(System.getenv("SENTRY_DSN") ?: System.getenv("VITE_SENTRY_DSN") ?: ""), + ) + buildConfigField( + "String", + "SENTRY_ENVIRONMENT", + buildConfigString(System.getenv("SENTRY_ENVIRONMENT") ?: System.getenv("VITE_SENTRY_ENVIRONMENT") ?: ""), + ) + buildConfigField( + "String", + "SENTRY_RELEASE", + buildConfigString(System.getenv("SENTRY_RELEASE") ?: System.getenv("VITE_SENTRY_RELEASE") ?: ""), + ) } buildTypes { getByName("debug") { @@ -108,6 +126,7 @@ dependencies { // — `matrix::secret_store`'s Android implementation, called via JNI, // stands in for `keyring`'s desktop-only OS-keychain backends there. implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("io.sentry:sentry-android:8.48.0") // Spec 11: UnifiedPush-first push transport, falling back to the // embedded FCM distributor when no external distributor is installed — // see `push::android`'s doc comment. Coordinates/versions per each diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 3358394d..1e743f8d 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + Date: Wed, 8 Jul 2026 19:43:41 -0400 Subject: [PATCH 24/35] Avoid mid-write Android consent observer reads --- .../app/src/main/java/social/cloudhub/charm/CharmApplication.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt b/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt index a61b5f12..3a292f01 100644 --- a/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt +++ b/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt @@ -44,9 +44,7 @@ class CharmApplication : Application() { private fun startSentryConsentObservers() { val mask = FileObserver.CLOSE_WRITE or - FileObserver.CREATE or FileObserver.DELETE or - FileObserver.MODIFY or FileObserver.MOVED_FROM or FileObserver.MOVED_TO listOf(File(applicationInfo.dataDir), filesDir) From 0a80490d39c71afad3be15efa942e12e27240f42 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Jul 2026 19:51:52 -0400 Subject: [PATCH 25/35] Tighten observability consent write handling --- .../social/cloudhub/charm/CharmApplication.kt | 2 +- src/observability/persistence.test.ts | 15 +++++++++++++++ src/observability/persistence.ts | 5 ++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt b/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt index 3a292f01..d3695464 100644 --- a/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt +++ b/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt @@ -53,7 +53,7 @@ class CharmApplication : Application() { @Suppress("DEPRECATION") val observer = object : FileObserver(directory.absolutePath, mask) { override fun onEvent(event: Int, path: String?) { - if (path == null || path == "observability.json") { + if (path == "observability.json") { sentryConsentEnabled = readSentryEnabledFromStore() } } diff --git a/src/observability/persistence.test.ts b/src/observability/persistence.test.ts index 4ca795c7..60c9fc97 100644 --- a/src/observability/persistence.test.ts +++ b/src/observability/persistence.test.ts @@ -94,6 +94,21 @@ describe("observability persistence", () => { ); }); + it("warns when Tauri store persistence fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = new Error("store failed"); + mocks.isTauri.mockReturnValue(true); + mocks.load.mockRejectedValue(error); + + await persistObservabilitySettings(DEFAULT_OBSERVABILITY_SETTINGS, 42); + + expect(warn).toHaveBeenCalledWith( + "Failed to persist observability settings to the Tauri store", + error, + ); + warn.mockRestore(); + }); + it("syncs log opt-outs to Rust before awaiting durable persistence", async () => { let resolveStoreWrite!: () => void; const storeWrite = new Promise((resolve) => { diff --git a/src/observability/persistence.ts b/src/observability/persistence.ts index b023fb79..6ee2f527 100644 --- a/src/observability/persistence.ts +++ b/src/observability/persistence.ts @@ -107,7 +107,10 @@ export async function persistObservabilitySettings( const store = await getStore(); await store.set(OBSERVABILITY_STORE_KEY, envelope); await store.save(); - } catch { + } catch (error) { + if (isTauri()) { + console.warn("Failed to persist observability settings to the Tauri store", error); + } // The local mirror already landed; plain-browser tests and dev previews use it. } if (mutationId === persistMutationId && envelope.state.logsEnabled) { From 444a8f924e0e020422c7f9035c2e342b99ee32c4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Jul 2026 19:58:01 -0400 Subject: [PATCH 26/35] Broaden Sentry provider removal stubs --- .../gen/android/app/src/main/AndroidManifest.xml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 70f20d90..0a849c1d 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -33,9 +33,10 @@ android:usesCleartextTraffic="${usesCleartextTraffic}" android:dataExtractionRules="@xml/secure_store_backup_rules" android:fullBackupContent="@xml/secure_store_backup_rules_legacy"> - - + + + Date: Wed, 8 Jul 2026 20:03:44 -0400 Subject: [PATCH 27/35] Keep compatible Sentry auto-init manifest keys --- src-tauri/gen/android/app/src/main/AndroidManifest.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 0a849c1d..8ecfb59a 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -33,6 +33,8 @@ android:usesCleartextTraffic="${usesCleartextTraffic}" android:dataExtractionRules="@xml/secure_store_backup_rules" android:fullBackupContent="@xml/secure_store_backup_rules_legacy"> + + - + - - Date: Wed, 8 Jul 2026 20:35:58 -0400 Subject: [PATCH 31/35] Serialize observability store persistence --- src/observability/persistence.test.ts | 18 +++++++++++++++--- src/observability/persistence.ts | 25 +++++++++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/observability/persistence.test.ts b/src/observability/persistence.test.ts index d8b59a30..90f1df47 100644 --- a/src/observability/persistence.test.ts +++ b/src/observability/persistence.test.ts @@ -158,7 +158,7 @@ describe("observability persistence", () => { await persist; }); - it("does not let an older opt-in overwrite a newer opt-out IPC sync", async () => { + it("does not let an older opt-in overwrite a newer opt-out durable write or IPC sync", async () => { let resolveFirstWrite!: () => void; const firstWrite = new Promise((resolve) => { resolveFirstWrite = resolve; @@ -179,7 +179,7 @@ describe("observability persistence", () => { expect(mocks.storeSet).toHaveBeenCalledTimes(1); }); - await persistObservabilitySettings( + const optOut = persistObservabilitySettings( { ...DEFAULT_OBSERVABILITY_SETTINGS, sentryEnabled: true, @@ -188,8 +188,20 @@ describe("observability persistence", () => { 101, ); resolveFirstWrite(); - await optIn; + await Promise.all([optIn, optOut]); + expect(mocks.storeSet).toHaveBeenNthCalledWith(2, "observability", { + state: { + ...DEFAULT_OBSERVABILITY_SETTINGS, + sentryEnabled: true, + logsEnabled: false, + }, + updatedAt: 101, + }); + expect(mocks.storeSave).toHaveBeenCalledOnce(); + expect(mocks.storeSet.mock.invocationCallOrder[1]).toBeLessThan( + mocks.storeSave.mock.invocationCallOrder[0], + ); expect(mocks.invoke).toHaveBeenCalledTimes(1); expect(mocks.invoke).toHaveBeenCalledWith("update_observability_log_consent", { logsEnabled: false, diff --git a/src/observability/persistence.ts b/src/observability/persistence.ts index fe914f27..29d43695 100644 --- a/src/observability/persistence.ts +++ b/src/observability/persistence.ts @@ -15,6 +15,7 @@ interface PersistedEnvelope { } let persistMutationId = 0; +let durablePersistTail = Promise.resolve(); function isPersistedEnvelope(value: unknown): value is PersistedEnvelope { return ( @@ -105,10 +106,26 @@ export async function persistObservabilitySettings( } let persisted = false; try { - const store = await getStore(); - await store.set(OBSERVABILITY_STORE_KEY, envelope); - await store.save(); - persisted = true; + const durablePersist = durablePersistTail.then(async () => { + if (mutationId !== persistMutationId) { + return false; + } + const store = await getStore(); + if (mutationId !== persistMutationId) { + return false; + } + await store.set(OBSERVABILITY_STORE_KEY, envelope); + if (mutationId !== persistMutationId) { + return false; + } + await store.save(); + return mutationId === persistMutationId; + }); + durablePersistTail = durablePersist.then( + () => undefined, + () => undefined, + ); + persisted = await durablePersist; } catch (error) { if (isTauri()) { console.warn("Failed to persist observability settings to the Tauri store", error); From d7c11e7a1e52f6131d040b1cb60d94a78fd452f4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Jul 2026 20:46:45 -0400 Subject: [PATCH 32/35] Clarify Android Sentry opt-out queue limits --- SENTRY.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/SENTRY.md b/SENTRY.md index 474ff822..970800d8 100644 --- a/SENTRY.md +++ b/SENTRY.md @@ -38,11 +38,12 @@ The Android runtime initializer watches the same store and gates `beforeSend` and `beforeBreadcrumb` from an in-memory consent flag, keeps `sendDefaultPii` off, disables Android auto-session tracking, and leaves performance tracing unconfigured. This initial Android coverage is therefore scoped to Sentry -Android's JVM crash and ANR capture after opt-in. Same-session opt-out is -enforced by the callback gates; opting back in during the same session resumes -events only if Sentry was already initialized at startup. If Android starts with -consent disabled, first opt-in still requires an app restart because -`SentryAndroid.init` only runs from `Application.onCreate`. +Android's JVM crash and ANR capture after opt-in. Same-session opt-out prevents +new captures through the callback gates, but events already accepted by the SDK +can still be retried or delivered from Sentry's queue. Opting back in during the +same session resumes events only if Sentry was already initialized at startup. If +Android starts with consent disabled, first opt-in still requires an app restart +because `SentryAndroid.init` only runs from `Application.onCreate`. NDK/native crash capture, Android Mobile Vitals, and performance transactions remain disabled until Charm has the corresponding SDK integration and a native consent bridge that can shut down or reconfigure the SDK immediately when a user From 67d5ddc13e0d60f83ee3c45f08ce2442394166d7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Jul 2026 20:54:27 -0400 Subject: [PATCH 33/35] Use explicit observability store saves --- src/observability/persistence.test.ts | 4 ++++ src/observability/persistence.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/observability/persistence.test.ts b/src/observability/persistence.test.ts index 90f1df47..708b851e 100644 --- a/src/observability/persistence.test.ts +++ b/src/observability/persistence.test.ts @@ -84,6 +84,10 @@ describe("observability persistence", () => { }; await persistObservabilitySettings(settings, 42); + expect(mocks.load).toHaveBeenCalledWith("observability.json", { + autoSave: false, + defaults: {}, + }); expect(mocks.storeSet).toHaveBeenCalledWith("observability", { state: settings, updatedAt: 42, diff --git a/src/observability/persistence.ts b/src/observability/persistence.ts index 29d43695..68ff0f6b 100644 --- a/src/observability/persistence.ts +++ b/src/observability/persistence.ts @@ -56,7 +56,7 @@ function writeLocalEnvelope(envelope: PersistedEnvelope): void { async function getStore() { const { load } = await import("@tauri-apps/plugin-store"); - return load(OBSERVABILITY_STORE_FILENAME, { autoSave: true, defaults: {} }); + return load(OBSERVABILITY_STORE_FILENAME, { autoSave: false, defaults: {} }); } async function syncRustLogConsent(logsEnabled: boolean): Promise { From 2b14c8737d7c3ee7c79ec8870f86fd27c4e305aa Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Jul 2026 21:01:36 -0400 Subject: [PATCH 34/35] Handle nested Android consent store events --- .../java/social/cloudhub/charm/CharmApplication.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt b/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt index d3695464..13782012 100644 --- a/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt +++ b/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt @@ -12,6 +12,7 @@ class CharmApplication : Application() { @Volatile private var sentryConsentEnabled: Boolean = false private val sentryConsentObservers = mutableListOf() + private val observabilityStoreFilename = "observability.json" override fun onCreate() { super.onCreate() @@ -53,7 +54,7 @@ class CharmApplication : Application() { @Suppress("DEPRECATION") val observer = object : FileObserver(directory.absolutePath, mask) { override fun onEvent(event: Int, path: String?) { - if (path == "observability.json") { + if (isObservabilityStoreEvent(path)) { sentryConsentEnabled = readSentryEnabledFromStore() } } @@ -63,12 +64,17 @@ class CharmApplication : Application() { } } + private fun isObservabilityStoreEvent(path: String?): Boolean = + path == null || + path == observabilityStoreFilename || + path.endsWith("/$observabilityStoreFilename") + private fun readSentryEnabledFromStore(): Boolean { - val appDataFile = File(applicationInfo.dataDir, "observability.json") + val appDataFile = File(applicationInfo.dataDir, observabilityStoreFilename) val file = if (appDataFile.isFile) { appDataFile } else { - File(filesDir, "observability.json").takeIf { it.isFile } + File(filesDir, observabilityStoreFilename).takeIf { it.isFile } } if (file == null) return false From b1550ba67f847a27e03ab1e2f159375dbfe5ef80 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Jul 2026 21:08:04 -0400 Subject: [PATCH 35/35] Avoid Android consent reads on null observer paths --- .../main/java/social/cloudhub/charm/CharmApplication.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt b/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt index 13782012..212491c1 100644 --- a/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt +++ b/src-tauri/gen/android/app/src/main/java/social/cloudhub/charm/CharmApplication.kt @@ -54,6 +54,10 @@ class CharmApplication : Application() { @Suppress("DEPRECATION") val observer = object : FileObserver(directory.absolutePath, mask) { override fun onEvent(event: Int, path: String?) { + if (path == null) { + sentryConsentEnabled = false + return + } if (isObservabilityStoreEvent(path)) { sentryConsentEnabled = readSentryEnabledFromStore() } @@ -64,9 +68,8 @@ class CharmApplication : Application() { } } - private fun isObservabilityStoreEvent(path: String?): Boolean = - path == null || - path == observabilityStoreFilename || + private fun isObservabilityStoreEvent(path: String): Boolean = + path == observabilityStoreFilename || path.endsWith("/$observabilityStoreFilename") private fun readSentryEnabledFromStore(): Boolean {