mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
fix: remove App Tracking Transparency (ATT) from native analytics flow
- Drop `expo-tracking-transparency` dependency and plugin config from `app.json` - Replace `applyTrackingTransparency` with `initAnalytics()` that simply syncs PostHog opt-in/out from the stored preference on startup - Remove ATT permission prompt, IDFA/AAID identification, and one-time migration logic from `posthog.ts` - Analytics now starts disabled on both iOS and Android; user opts in via Settings toggle - Update telemetry docs and privacy policy to reflect the simplified consent flow (no ATT prompt, no advertising IDs)
This commit is contained in:
@@ -108,12 +108,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-localization",
|
"expo-localization",
|
||||||
[
|
|
||||||
"expo-tracking-transparency",
|
|
||||||
{
|
|
||||||
"userTrackingPermission": "This identifier will be used to measure app performance and improve your experience."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-widgets",
|
"expo-widgets",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,7 +63,6 @@
|
|||||||
"expo-splash-screen": "55.0.12",
|
"expo-splash-screen": "55.0.12",
|
||||||
"expo-status-bar": "55.0.4",
|
"expo-status-bar": "55.0.4",
|
||||||
"expo-system-ui": "55.0.10",
|
"expo-system-ui": "55.0.10",
|
||||||
"expo-tracking-transparency": "55.0.9",
|
|
||||||
"expo-updates": "55.0.15",
|
"expo-updates": "55.0.15",
|
||||||
"expo-web-browser": "55.0.10",
|
"expo-web-browser": "55.0.10",
|
||||||
"expo-widgets": "55.0.7",
|
"expo-widgets": "55.0.7",
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ import {
|
|||||||
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import {
|
|
||||||
getAdvertisingId,
|
|
||||||
getTrackingPermissionsAsync,
|
|
||||||
requestTrackingPermissionsAsync,
|
|
||||||
} from "expo-tracking-transparency";
|
|
||||||
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
|
import { PostHogErrorBoundary, PostHogProvider } from "posthog-react-native";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
@@ -28,7 +23,7 @@ import { ServerUnreachableBanner } from "@/components/ui/server-unreachable-bann
|
|||||||
import { useServerConnection } from "@/hooks/use-server-connection";
|
import { useServerConnection } from "@/hooks/use-server-connection";
|
||||||
import { useWidgetRefresh } from "@/hooks/use-widget-refresh";
|
import { useWidgetRefresh } from "@/hooks/use-widget-refresh";
|
||||||
import { initLocale } from "@/lib/i18n";
|
import { initLocale } from "@/lib/i18n";
|
||||||
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
|
import { initAnalytics, posthog } from "@/lib/posthog";
|
||||||
import { queryClient } from "@/lib/query-client";
|
import { queryClient } from "@/lib/query-client";
|
||||||
import { getScopeKey, initialize, onStorageScopeChange, queryPersister } from "@/lib/server";
|
import { getScopeKey, initialize, onStorageScopeChange, queryPersister } from "@/lib/server";
|
||||||
import { sofaTheme } from "@/lib/theme";
|
import { sofaTheme } from "@/lib/theme";
|
||||||
@@ -67,42 +62,20 @@ function AppContent() {
|
|||||||
localeReady.then(() => setLocaleReady(true)).catch(() => setLocaleReady(true));
|
localeReady.then(() => setLocaleReady(true)).catch(() => setLocaleReady(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- App Tracking Transparency (must resolve before screen tracking) ---
|
// --- Analytics init (sync PostHog opt-in/out from stored preference) ---
|
||||||
const [trackingReady, setTrackingReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
initAnalytics();
|
||||||
const { status } = await getTrackingPermissionsAsync();
|
|
||||||
const granted =
|
|
||||||
status === "undetermined"
|
|
||||||
? (await requestTrackingPermissionsAsync()).granted
|
|
||||||
: status === "granted";
|
|
||||||
|
|
||||||
const enabled = applyTrackingTransparency(granted);
|
|
||||||
|
|
||||||
// Use the platform advertising ID (IDFA / AAID) as the PostHog
|
|
||||||
// distinct ID, but only when the resolved state is actually enabled
|
|
||||||
// (respects both ATT result and the user's settings override).
|
|
||||||
if (enabled && posthog) {
|
|
||||||
const advertisingId = getAdvertisingId();
|
|
||||||
if (advertisingId) {
|
|
||||||
posthog.identify(advertisingId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTrackingReady(true);
|
|
||||||
})();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- PostHog screen tracking (waits for ATT to resolve) ---
|
// --- PostHog screen tracking ---
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useGlobalSearchParams();
|
const params = useGlobalSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trackingReady && posthog && pathname) {
|
if (posthog && pathname) {
|
||||||
posthog.screen(pathname, params);
|
posthog.screen(pathname, params);
|
||||||
}
|
}
|
||||||
}, [trackingReady, pathname, params]);
|
}, [pathname, params]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Uniwind.setTheme("dark");
|
Uniwind.setTheme("dark");
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const host = process.env.EXPO_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
|||||||
|
|
||||||
const ANALYTICS_ENABLED_KEY = "sofa_analytics_enabled";
|
const ANALYTICS_ENABLED_KEY = "sofa_analytics_enabled";
|
||||||
const ANALYTICS_EXPLICIT_KEY = "sofa_analytics_explicit";
|
const ANALYTICS_EXPLICIT_KEY = "sofa_analytics_explicit";
|
||||||
const ATT_MIGRATED_KEY = "sofa_att_migrated";
|
|
||||||
|
|
||||||
const posthogStorage: PostHogCustomStorage = {
|
const posthogStorage: PostHogCustomStorage = {
|
||||||
getItem: (key: string) => globalStorage.getString(key) ?? null,
|
getItem: (key: string) => globalStorage.getString(key) ?? null,
|
||||||
@@ -16,7 +15,7 @@ const posthogStorage: PostHogCustomStorage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// PostHog throws if apiKey is empty, so only construct when configured.
|
// PostHog throws if apiKey is empty, so only construct when configured.
|
||||||
// Start opted-out; the ATT check in root layout will opt in if appropriate.
|
// Start opted-out; initAnalytics() in root layout will opt in based on stored preference.
|
||||||
export const posthog: PostHog | null = posthogApiKey
|
export const posthog: PostHog | null = posthogApiKey
|
||||||
? new PostHog(posthogApiKey, {
|
? new PostHog(posthogApiKey, {
|
||||||
host,
|
host,
|
||||||
@@ -52,7 +51,7 @@ export function setAnalyticsEnabled(enabled: boolean): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync PostHog opt-in/out state based on the resolved analytics flag.
|
* Sync PostHog opt-in/out state based on the resolved analytics flag.
|
||||||
* Called after ATT check and from the settings toggle.
|
* Called from initAnalytics() and from the settings toggle.
|
||||||
*/
|
*/
|
||||||
export function syncPosthog(enabled: boolean): void {
|
export function syncPosthog(enabled: boolean): void {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -63,38 +62,9 @@ export function syncPosthog(enabled: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve analytics state after an ATT permission check.
|
* Initialize analytics on app startup.
|
||||||
* If the user has an explicit preference (or a legacy preference from before
|
* Reads the stored preference and syncs PostHog opt-in/out state.
|
||||||
* ATT was introduced), that wins. Otherwise the ATT result is stored as the
|
|
||||||
* current default and PostHog is synced.
|
|
||||||
*
|
|
||||||
* Returns the resolved enabled state so callers can decide whether to proceed
|
|
||||||
* with identifying, etc.
|
|
||||||
*/
|
*/
|
||||||
export function applyTrackingTransparency(granted: boolean): boolean {
|
export function initAnalytics(): void {
|
||||||
// One-time migration: on the first launch after the ATT update, check if
|
syncPosthog(isAnalyticsEnabled());
|
||||||
// the user had previously toggled analytics via the settings switch (which
|
|
||||||
// was the only way ANALYTICS_ENABLED_KEY got set before ATT). If so,
|
|
||||||
// promote it to an explicit preference so the ATT result doesn't overwrite
|
|
||||||
// it. This runs exactly once — subsequent launches skip it because
|
|
||||||
// ATT_MIGRATED_KEY is set, preventing applyTrackingTransparency's own
|
|
||||||
// writes to ANALYTICS_ENABLED_KEY from being misidentified as legacy.
|
|
||||||
if (!globalStorage.getBoolean(ATT_MIGRATED_KEY)) {
|
|
||||||
globalStorage.set(ATT_MIGRATED_KEY, true);
|
|
||||||
if (globalStorage.getBoolean(ANALYTICS_ENABLED_KEY) !== undefined && !hasExplicitPreference()) {
|
|
||||||
globalStorage.set(ANALYTICS_EXPLICIT_KEY, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasExplicitPreference()) {
|
|
||||||
// User already made a choice in settings — honour it.
|
|
||||||
const enabled = isAnalyticsEnabled();
|
|
||||||
syncPosthog(enabled);
|
|
||||||
return enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No explicit preference yet — follow the ATT result.
|
|
||||||
globalStorage.set(ANALYTICS_ENABLED_KEY, granted);
|
|
||||||
syncPosthog(granted);
|
|
||||||
return granted;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,6 @@
|
|||||||
"expo-splash-screen": "55.0.12",
|
"expo-splash-screen": "55.0.12",
|
||||||
"expo-status-bar": "55.0.4",
|
"expo-status-bar": "55.0.4",
|
||||||
"expo-system-ui": "55.0.10",
|
"expo-system-ui": "55.0.10",
|
||||||
"expo-tracking-transparency": "55.0.9",
|
|
||||||
"expo-updates": "55.0.15",
|
"expo-updates": "55.0.15",
|
||||||
"expo-web-browser": "55.0.10",
|
"expo-web-browser": "55.0.10",
|
||||||
"expo-widgets": "55.0.7",
|
"expo-widgets": "55.0.7",
|
||||||
@@ -1999,8 +1998,6 @@
|
|||||||
|
|
||||||
"expo-system-ui": ["expo-system-ui@55.0.10", "", { "dependencies": { "@react-native/normalize-colors": "0.83.2", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-s0Fyf37ma3TaWhh18uocg914Uz0tKPaYBf+mEME7Tp88d3FxOTg+tTv1S9mUe4lJNXY+0KlWFz9J4SMNyjGRWQ=="],
|
"expo-system-ui": ["expo-system-ui@55.0.10", "", { "dependencies": { "@react-native/normalize-colors": "0.83.2", "debug": "^4.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-s0Fyf37ma3TaWhh18uocg914Uz0tKPaYBf+mEME7Tp88d3FxOTg+tTv1S9mUe4lJNXY+0KlWFz9J4SMNyjGRWQ=="],
|
||||||
|
|
||||||
"expo-tracking-transparency": ["expo-tracking-transparency@55.0.9", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NHjc9tTK2kRg+mtjm1MF6ROsqFl48nYp/eftx+STy+ZGvqQUdwRC4QetCPsAkgY9P/A2Q3iZO+bmHBKo16vo8w=="],
|
|
||||||
|
|
||||||
"expo-updates": ["expo-updates@55.0.15", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/plist": "^0.5.2", "@expo/spawn-async": "^1.7.2", "arg": "^4.1.0", "chalk": "^4.1.2", "debug": "^4.3.4", "expo-eas-client": "~55.0.2", "expo-manifests": "~55.0.11", "expo-structured-headers": "~55.0.0", "expo-updates-interface": "~55.1.3", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" }, "bin": { "expo-updates": "bin/cli.js" } }, "sha512-UE9Ik56trq//kNeJ/BlC5vOTYdNTvsHwhfWFYMazP1UOQK4lnX59/t0qz8Ut+3aPXZZT7+B6mnbWtic0QqN1wA=="],
|
"expo-updates": ["expo-updates@55.0.15", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/plist": "^0.5.2", "@expo/spawn-async": "^1.7.2", "arg": "^4.1.0", "chalk": "^4.1.2", "debug": "^4.3.4", "expo-eas-client": "~55.0.2", "expo-manifests": "~55.0.11", "expo-structured-headers": "~55.0.0", "expo-updates-interface": "~55.1.3", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" }, "bin": { "expo-updates": "bin/cli.js" } }, "sha512-UE9Ik56trq//kNeJ/BlC5vOTYdNTvsHwhfWFYMazP1UOQK4lnX59/t0qz8Ut+3aPXZZT7+B6mnbWtic0QqN1wA=="],
|
||||||
|
|
||||||
"expo-updates-interface": ["expo-updates-interface@55.1.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-UVVIiZqymQZJL+o/jh65kXOI97xdkbqBJJM0LMabaPMNLFnc6/WvOMOzmQs7SPyKb8+0PeBaFd7tj5DzF6JeQg=="],
|
"expo-updates-interface": ["expo-updates-interface@55.1.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-UVVIiZqymQZJL+o/jh65kXOI97xdkbqBJJM0LMabaPMNLFnc6/WvOMOzmQs7SPyKb8+0PeBaFd7tj5DzF6JeQg=="],
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ Sofa includes optional, privacy-focused telemetry to help understand how the pro
|
|||||||
|
|
||||||
Sofa has two separate telemetry systems depending on the platform:
|
Sofa has two separate telemetry systems depending on the platform:
|
||||||
|
|
||||||
| | Server | Mobile App | Web App |
|
| | Server | Mobile App | Web App |
|
||||||
| ----------- | ---------------- | ----------------- | ------- |
|
| ----------- | ---------------- | ------------ | ------- |
|
||||||
| **System** | Custom reporting | PostHog | None |
|
| **System** | Custom reporting | PostHog | None |
|
||||||
| **Default** | Disabled | Disabled | N/A |
|
| **Default** | Disabled | Disabled | N/A |
|
||||||
| **Opt-in** | Admin toggle | User toggle + ATT | N/A |
|
| **Opt-in** | Admin toggle | User toggle | N/A |
|
||||||
|
|
||||||
The web app has **zero analytics** — no tracking scripts, no third-party services.
|
The web app has **zero analytics** — no tracking scripts, no third-party services.
|
||||||
|
|
||||||
@@ -81,15 +81,7 @@ The native iOS and Android app uses [PostHog](https://posthog.com/) for anonymou
|
|||||||
|
|
||||||
### Consent Flow
|
### Consent Flow
|
||||||
|
|
||||||
On iOS, the app follows Apple's App Tracking Transparency (ATT) framework:
|
Analytics starts disabled on both iOS and Android. You can enable it in **Settings → Anonymous Usage Reporting** and change your preference at any time.
|
||||||
|
|
||||||
1. On first launch, iOS prompts you to allow or deny tracking
|
|
||||||
2. If you allow, analytics is enabled by default (you can still disable it in settings)
|
|
||||||
3. If you deny, analytics stays disabled
|
|
||||||
|
|
||||||
On Android, analytics starts disabled and can be toggled on in settings.
|
|
||||||
|
|
||||||
Regardless of platform, you can change your preference at any time in **Settings → Anonymous Usage Reporting**.
|
|
||||||
|
|
||||||
### What Gets Tracked
|
### What Gets Tracked
|
||||||
|
|
||||||
@@ -108,13 +100,11 @@ When analytics is enabled:
|
|||||||
|
|
||||||
### Identification
|
### Identification
|
||||||
|
|
||||||
When analytics is enabled, the app uses your device's platform advertising ID (IDFA on iOS, AAID on Android) as an anonymous identifier. This is the same ID managed by your device's privacy settings — if you've disabled ad tracking at the OS level, the app won't attempt to use it.
|
PostHog generates an anonymous, random device identifier. No advertising IDs, personal data, or other identifying information is sent.
|
||||||
|
|
||||||
No other identifying information is sent.
|
|
||||||
|
|
||||||
### Disabling
|
### Disabling
|
||||||
|
|
||||||
Toggle analytics off in **Settings → Anonymous Usage Reporting**. This immediately opts out of PostHog and stops all event collection. Your explicit preference persists even if ATT permissions change later.
|
Toggle analytics off in **Settings → Anonymous Usage Reporting**. This immediately opts out of PostHog and stops all event collection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function PrivacyPolicyPage() {
|
|||||||
return (
|
return (
|
||||||
<article className="prose prose-sm prose-invert [&_a]:text-fd-primary hover:[&_a]:text-fd-primary/80 [&_h2]:font-display [&_h3]:font-display [&_p]:text-fd-muted-foreground [&_li]:text-fd-muted-foreground [&_strong]:text-fd-foreground mx-auto max-w-3xl px-6 py-16 [&_a]:underline [&_a]:underline-offset-4 [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-2xl [&_h2]:font-semibold [&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-normal [&_li]:text-[15px] [&_li]:leading-relaxed [&_p]:text-[15px] [&_p]:leading-relaxed [&_strong]:font-medium [&_ul]:space-y-1">
|
<article className="prose prose-sm prose-invert [&_a]:text-fd-primary hover:[&_a]:text-fd-primary/80 [&_h2]:font-display [&_h3]:font-display [&_p]:text-fd-muted-foreground [&_li]:text-fd-muted-foreground [&_strong]:text-fd-foreground mx-auto max-w-3xl px-6 py-16 [&_a]:underline [&_a]:underline-offset-4 [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-2xl [&_h2]:font-semibold [&_h3]:mt-8 [&_h3]:mb-3 [&_h3]:text-xl [&_h3]:font-normal [&_li]:text-[15px] [&_li]:leading-relaxed [&_p]:text-[15px] [&_p]:leading-relaxed [&_strong]:font-medium [&_ul]:space-y-1">
|
||||||
<h1 className="font-display mb-2 text-3xl font-semibold tracking-tight">Privacy Policy</h1>
|
<h1 className="font-display mb-2 text-3xl font-semibold tracking-tight">Privacy Policy</h1>
|
||||||
<p className="text-fd-muted-foreground/60 !mt-0 mb-8 text-sm">Last updated: March 16, 2026</p>
|
<p className="text-fd-muted-foreground/60 !mt-0 mb-8 text-sm">Last updated: March 22, 2026</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Sofa is a self-hosted application. When you run Sofa, your data lives on your own server and
|
Sofa is a self-hosted application. When you run Sofa, your data lives on your own server and
|
||||||
@@ -109,8 +109,8 @@ export default function PrivacyPolicyPage() {
|
|||||||
The native iOS and Android app includes optional, anonymous analytics powered by PostHog.
|
The native iOS and Android app includes optional, anonymous analytics powered by PostHog.
|
||||||
This tracks screen views and app lifecycle events — no personal data, search queries,
|
This tracks screen views and app lifecycle events — no personal data, search queries,
|
||||||
or watch history is collected. Analytics is <strong>disabled by default</strong> and
|
or watch history is collected. Analytics is <strong>disabled by default</strong> and
|
||||||
requires explicit opt-in. On iOS, Apple’s App Tracking Transparency prompt is shown
|
requires explicit opt-in. You can change your preference at any time in the app’s
|
||||||
first. You can change your preference at any time in the app’s settings.
|
settings.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Media Server Integrations</h3>
|
<h3>Media Server Integrations</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user