diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index f2d4d69..f2e412e 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -73,14 +73,26 @@ export default function SettingsScreen() { const { settings, hasBiometrics, updateSettings, lock } = useAuth() const { notifications, setNotification } = useSettings() const [osGranted, setOsGranted] = useState(null) + const [telemetryUpdating, setTelemetryUpdating] = useState(false) // Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false. // We initialise local state from in-memory value; updates call setTelemetryConsent(). const [crashReporting, setCrashReporting] = useState(hasTelemetryConsent() ?? false) const handleCrashReportingToggle = useCallback(async (value: boolean) => { - setCrashReporting(value) - await setTelemetryConsent(value) + setTelemetryUpdating(true) + try { + await setTelemetryConsent(value) + setCrashReporting(value) + } catch { + setCrashReporting(hasTelemetryConsent() ?? false) + Alert.alert( + "Privacy Setting Not Saved", + "Crash reporting is off for this session, but your choice could not be saved. Please try again.", + ) + } finally { + setTelemetryUpdating(false) + } }, []) // Check OS permission state on first toggle attempt @@ -193,6 +205,7 @@ export default function SettingsScreen() { } diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index 73bd9b4..4328056 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -98,7 +98,7 @@ export default function EditConnectionScreen() { url.trim(), username.trim() && password ? { username: username.trim(), password } : undefined, ) - captureDiagnostic(report, result.error ? new Error(result.error) : undefined) + captureDiagnostic(report) setIsTesting(false) Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [ diff --git a/app/connection/add.tsx b/app/connection/add.tsx index 40d2565..34416f3 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -100,7 +100,7 @@ export default function AddConnectionScreen() { serverUrl, username.trim() && password ? { username: username.trim(), password } : undefined, ) - captureDiagnostic(report, result.error ? new Error(result.error) : undefined) + captureDiagnostic(report) setIsConnecting(false) Alert.alert( "Connection Failed", diff --git a/distribution/app-store-listing.md b/distribution/app-store-listing.md index 6566c31..9e6f594 100644 --- a/distribution/app-store-listing.md +++ b/distribution/app-store-listing.md @@ -171,7 +171,7 @@ OpenCode Mobile does NOT collect any of the following: - Browsing history, search history - Sensitive info - User content (code, prompts, AI responses are not sent to our servers) -- Identifiers (User ID, Device ID — Sentry uses an installation-scoped anonymous ID, see below) +- User ID (the app has no accounts or user identity) ### Data Linked to You: None @@ -179,13 +179,14 @@ OpenCode Mobile does NOT collect any of the following: | Data Type | Category | Purpose | Optional? | |---|---|---|---| -| Crash Data | Diagnostics | App functionality | No — always on (see note) | -| Performance Data | Diagnostics | App functionality | No — always on (see note) | -| Other Diagnostic Data | Diagnostics | App functionality | No | +| Crash Data | Diagnostics | App functionality | Yes — explicit opt-in | +| Performance Data | Diagnostics | App functionality | Yes — explicit opt-in | +| Other Diagnostic Data | Diagnostics | App functionality | Yes — explicit opt-in | +| Device ID | Identifiers | App functionality | Yes — Sentry installation ID with explicit opt-in | **Explanation**: Sentry crash reporting sends device model, OS version, app version, and stack traces. Sentry assigns an anonymous installation ID (not linked to any Apple ID or personal information). No user-generated content (code, prompts, responses) is ever sent. -**Sentry opt-in status**: As of v0.2.3, Sentry is **always on** when a DSN is configured. If you add a settings toggle for Sentry consent (planned), change Optional? to "Yes" and add a note that users who decline are in the "Data Not Collected" category. In App Store Connect, once opt-in is implemented, this section can be removed or marked optional. +**Sentry opt-in status**: Crash reporting is off by default. The first-launch consent prompt and Settings → Privacy toggle control it. Declining does not initialize Sentry; turning it off later closes the active SDK and stops new event capture. **"Are you or your third-party partners using this data to track users?"**: No diff --git a/distribution/privacy-policy.html b/distribution/privacy-policy.html index b9921a6..19c370f 100644 --- a/distribution/privacy-policy.html +++ b/distribution/privacy-policy.html @@ -186,7 +186,7 @@
  • Open the app → Settings → Privacy → Crash reporting toggle.
  • -
  • When the toggle is off, Sentry is never initialised and no data leaves your device.
  • +
  • If you decline, Sentry is never initialised. If you turn reporting off later, the active SDK is closed and no new events are captured.

5. Third-Party Services

diff --git a/distribution/privacy-policy.md b/distribution/privacy-policy.md index 170d3a6..70776ac 100644 --- a/distribution/privacy-policy.md +++ b/distribution/privacy-policy.md @@ -55,7 +55,7 @@ URL scrubbing: before any event is sent to Sentry, our code strips all server UR Crash reporting is **opt-in and off by default**. On first launch you will see a consent prompt. You can change this at any time: - Open the app → **Settings** → **Privacy** → **Crash reporting** toggle. -- When the toggle is off, Sentry is never initialised and no data leaves your device. +- If you decline, Sentry is never initialised. If you turn reporting off later, the active SDK is closed and no new events are captured. --- diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index 015f3a1..2655c32 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -13,17 +13,18 @@ import * as Sentry from "@sentry/react-native" import appJson from "../../app.json" import { log } from "./logbuffer" import type { DiagnosticReport } from "./diagnostics" -import { scrubUrl, scrubString, scrubObject } from "./scrub" const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" let enabled = false +let handlersInstalled = false export function initSentry() { + if (enabled) return if (!DSN) { log.info("sentry", "no DSN configured — telemetry disabled") - installGlobalHandlers(false) + installGlobalHandlers() return } try { @@ -50,9 +51,9 @@ export function initSentry() { }, beforeBreadcrumb(crumb) { if (crumb.data && typeof crumb.data === "object") { - crumb.data = scrubObject(crumb.data as Record) + crumb.data = redactObject(crumb.data as Record) } - if (typeof crumb.message === "string") crumb.message = scrubString(crumb.message) + if (typeof crumb.message === "string") crumb.message = redactString(crumb.message) return crumb }, }) @@ -62,7 +63,14 @@ export function initSentry() { } catch (e) { log.warn("sentry", "init failed", String(e)) } - installGlobalHandlers(enabled) + installGlobalHandlers() +} + +export async function disableSentry() { + if (!enabled) return + enabled = false + await Sentry.close() + log.info("sentry", "disabled by user") } // Install belt-and-braces global handlers. The Sentry RN SDK already wires @@ -72,7 +80,10 @@ export function initSentry() { // shared diagnostic report) even when Sentry is disabled. // * Telemetry-disabled builds still leave a breadcrumb that something blew // up, which is invaluable when triaging a user-shared report offline. -function installGlobalHandlers(sentryEnabled: boolean) { +function installGlobalHandlers() { + if (handlersInstalled) return + handlersInstalled = true + type GlobalErrorUtils = { getGlobalHandler?: () => (err: unknown, isFatal?: boolean) => void setGlobalHandler?: (handler: (err: unknown, isFatal?: boolean) => void) => void @@ -83,7 +94,7 @@ function installGlobalHandlers(sentryEnabled: boolean) { errorUtils.setGlobalHandler((err: unknown, isFatal?: boolean) => { const error = toError(err) log.error("crash", isFatal ? "FATAL" : "non-fatal", error.message, error.stack ?? "") - if (sentryEnabled) { + if (enabled) { Sentry.captureException(error, (scope) => { scope.setLevel(isFatal ? "fatal" : "error") scope.setTag("crash.source", "js-global") @@ -104,7 +115,7 @@ function installGlobalHandlers(sentryEnabled: boolean) { g.onunhandledrejection = (event) => { const error = toError(event?.reason) log.error("crash", "unhandled-rejection", error.message, error.stack ?? "") - if (sentryEnabled) { + if (enabled) { Sentry.captureException(error, (scope) => { scope.setLevel("error") scope.setTag("crash.source", "promise-rejection") @@ -130,24 +141,62 @@ function toError(value: unknown): Error { export { scrubUrl } from "./scrub" function scrubEvent(event: T): T { - if (event.request?.url) event.request.url = scrubUrl(event.request.url) - if (event.message) event.message = scrubString(event.message) + if (event.request?.url) event.request.url = "" + if (event.message) event.message = redactString(event.message) if (event.exception?.values) { for (const ex of event.exception.values) { - if (ex.value) ex.value = scrubString(ex.value) + if (ex.value) ex.value = redactString(ex.value) } } if (event.breadcrumbs) { for (const crumb of event.breadcrumbs) { - if (typeof crumb.message === "string") crumb.message = scrubString(crumb.message) + if (typeof crumb.message === "string") crumb.message = redactString(crumb.message) if (crumb.data && typeof crumb.data === "object") { - crumb.data = scrubObject(crumb.data as Record) + crumb.data = redactObject(crumb.data as Record) } } } return event } +function redactString(value: string): string { + return value.replace(/https?:\/\/[^\s)\]}"']+/gi, "") +} + +function redactObject(value: Record): Record { + const redacted: Record = {} + for (const [key, item] of Object.entries(value)) { + if ( + /^(?:id|.*Id|.*ID|url|host|hostname|port|address|server|serverUrl|target|endpoint|authorization|auth|token|password|secret|apiKey|username|cookie)$/i.test( + key, + ) + ) { + redacted[key] = "" + continue + } + if (typeof item === "string") { + redacted[key] = redactString(item) + continue + } + if (Array.isArray(item)) { + redacted[key] = item.map((entry) => + typeof entry === "string" + ? redactString(entry) + : entry && typeof entry === "object" + ? redactObject(entry as Record) + : entry, + ) + continue + } + if (item && typeof item === "object") { + redacted[key] = redactObject(item as Record) + continue + } + redacted[key] = item + } + return redacted +} + // --- Helpers exposed to the rest of the app ------------------------------ export type Breadcrumb = { @@ -183,18 +232,14 @@ export function captureException( }) } -export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown) { +export function captureDiagnostic(report: DiagnosticReport) { log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)") if (!enabled) return Sentry.withScope((scope) => { scope.setTag("connect.classification", report.classification) scope.setTag("connect.scheme", report.scheme ?? "n/a") scope.setContext("connection", { - url: scrubUrl(report.url), - host: report.host, - port: report.port, - isHostname: report.isHostname, - summary: report.summary, + targetType: report.isHostname ? "hostname" : "ip-address", }) scope.setContext("probes", { attempts: report.attempts.map((a) => ({ @@ -202,13 +247,10 @@ export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown) ok: a.ok, status: a.status, durationMs: a.durationMs, - error: a.error, - cause: a.errorCause, })), }) scope.setContext("device", report.device) - const err = rawError instanceof Error ? rawError : new Error(`connect ${report.classification}: ${report.summary}`) - Sentry.captureException(err) + Sentry.captureException(new Error(`connect ${report.classification}`)) }) } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 9092cc8..b259dd6 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -20,13 +20,14 @@ */ import * as SecureStore from "expo-secure-store" -import { initSentry, sentryEnabled } from "./sentry" +import { disableSentry, initSentry, sentryEnabled } from "./sentry" const CONSENT_KEY = "opencode_telemetry_consent" export type ConsentState = "granted" | "denied" | "unknown" let _resolved: boolean | null = null // null = unknown, true = granted, false = denied +let transition = Promise.resolve() /** * Load persisted consent from SecureStore. @@ -47,9 +48,8 @@ export async function loadTelemetryConsent(): Promise { _resolved = null return "unknown" } catch { - // SecureStore unavailable — don't clobber a previously resolved in-memory state. - // Return unknown so the caller can surface the modal. - return "unknown" + _resolved = false + return "denied" } } @@ -67,14 +67,26 @@ export function hasTelemetryConsent(): boolean | null { * Persist the user's consent decision and, if granted and Sentry is not yet * running, initialise it immediately. */ -export async function setTelemetryConsent(granted: boolean): Promise { - _resolved = granted - try { - await SecureStore.setItemAsync(CONSENT_KEY, granted ? "granted" : "denied") - } catch { - // Best-effort persist — in-memory state is still correct for this session. +export function setTelemetryConsent(granted: boolean): Promise { + const next = transition.then(() => applyTelemetryConsent(granted)) + transition = next.catch(() => undefined) + return next +} + +async function applyTelemetryConsent(granted: boolean): Promise { + if (granted) { + await SecureStore.setItemAsync(CONSENT_KEY, "granted") + _resolved = true + if (!sentryEnabled()) initSentry() + return } - if (granted && !sentryEnabled()) { - initSentry() + + _resolved = false + await disableSentry() + try { + await SecureStore.setItemAsync(CONSENT_KEY, "denied") + } catch (error) { + await SecureStore.deleteItemAsync(CONSENT_KEY) + throw error } }