diff --git a/app/App.tsx b/app/App.tsx
index afaa5dda7..27e21d99a 100644
--- a/app/App.tsx
+++ b/app/App.tsx
@@ -28,6 +28,7 @@ import { useNotificationRouter } from './src/hooks/useNotificationRouter';
import { ErrorBoundary } from './src/components/ErrorBoundary';
import { closeAllTranslationDbs } from './src/db/translationManager';
import { ContentUpdateProvider } from './src/providers/ContentUpdateProvider';
+import { AmicusConsentProvider } from './src/services/amicus/consent';
import { DbDownloadScreen } from './src/screens/DbDownloadScreen';
import { Sentry, DSN } from './src/lib/sentry';
@@ -221,7 +222,9 @@ function App() {
-
+
+
+
diff --git a/app/src/components/amicus/AmicusFirstUseModal.tsx b/app/src/components/amicus/AmicusFirstUseModal.tsx
new file mode 100644
index 000000000..f87a70c49
--- /dev/null
+++ b/app/src/components/amicus/AmicusFirstUseModal.tsx
@@ -0,0 +1,162 @@
+/**
+ * components/amicus/AmicusFirstUseModal.tsx — one-time privacy disclosure
+ * shown the first time a user sends an Amicus query (#1458).
+ */
+import React from 'react';
+import {
+ BackHandler,
+ Linking,
+ Modal,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { MessageSquare } from 'lucide-react-native';
+import { fontFamily, spacing, useTheme } from '../../theme';
+
+export interface AmicusFirstUseModalProps {
+ visible: boolean;
+ onAccept: () => void;
+ onDecline: () => void;
+ /** Overridable for tests. */
+ privacyPolicyUrl?: string;
+}
+
+const DEFAULT_PRIVACY_URL = 'https://contentcompanionstudy.com/privacy';
+
+export default function AmicusFirstUseModal(
+ props: AmicusFirstUseModalProps,
+): React.ReactElement {
+ const { base } = useTheme();
+
+ React.useEffect(() => {
+ if (!props.visible) return undefined;
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
+ props.onDecline();
+ return true;
+ });
+ return () => sub.remove();
+ }, [props]);
+
+ const openPrivacy = (): void => {
+ void Linking.openURL(props.privacyPolicyUrl ?? DEFAULT_PRIVACY_URL);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Meet Amicus
+
+
+ Before we get started
+
+
+ Amicus is your scholarly study companion. It answers questions by drawing on
+ the curated Companion Study corpus — our 72 scholars, word studies, debates,
+ and cross-references. It never fabricates scholar positions.
+
+
+ What stays on your device
+
+
+ • Your notes, highlights, and bookmarks{'\n'}
+ • Your full reading history{'\n'}
+ • Your Amicus conversations
+
+
+ What gets sent to our AI provider when you ask a question
+
+
+ • Your question text{'\n'}
+ • An abstract summary of your reading patterns{'\n'}
+ • The retrieved scholarly content your question is answered from
+
+
+ You can inspect exactly what gets sent in Settings → Amicus → Show My
+ Profile.
+
+
+ Our AI provider has a zero-retention commitment. Your data is never used to
+ train models.
+
+
+
+
+
+
+ I understand, let’s begin
+
+
+
+ Not now
+
+
+
+ Read our full privacy commitment
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' },
+ card: {
+ maxHeight: '90%',
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ },
+ content: { padding: spacing.lg, paddingBottom: spacing.md, gap: spacing.sm },
+ iconRow: { alignItems: 'center', marginTop: spacing.sm },
+ title: { fontSize: 22, textAlign: 'center' },
+ subtitle: { fontSize: 14, textAlign: 'center', marginBottom: spacing.md },
+ heading: { fontSize: 14, marginTop: spacing.md },
+ body: { fontSize: 15, lineHeight: 22 },
+ bullet: { fontSize: 15, lineHeight: 22 },
+ footer: {
+ padding: spacing.md,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ gap: spacing.sm,
+ },
+ primary: { paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
+ primaryText: { fontSize: 15 },
+ secondary: { alignItems: 'center', paddingVertical: 8 },
+ secondaryText: { fontSize: 13 },
+ link: { alignItems: 'center' },
+ linkText: { fontSize: 12, textDecorationLine: 'underline' },
+});
diff --git a/app/src/components/amicus/__tests__/AmicusFirstUseModal.test.tsx b/app/src/components/amicus/__tests__/AmicusFirstUseModal.test.tsx
new file mode 100644
index 000000000..8a9005e72
--- /dev/null
+++ b/app/src/components/amicus/__tests__/AmicusFirstUseModal.test.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react-native';
+import { renderWithProviders } from '../../../../__tests__/helpers/renderWithProviders';
+import AmicusFirstUseModal from '@/components/amicus/AmicusFirstUseModal';
+
+describe('AmicusFirstUseModal', () => {
+ it('renders all required disclosure copy when visible', () => {
+ const { getByText } = renderWithProviders(
+ undefined}
+ onDecline={() => undefined}
+ />,
+ );
+ expect(getByText('Meet Amicus')).toBeTruthy();
+ expect(getByText('What stays on your device')).toBeTruthy();
+ expect(
+ getByText(/What gets sent to our AI provider when you ask a question/),
+ ).toBeTruthy();
+ expect(getByText(/zero-retention commitment/)).toBeTruthy();
+ });
+
+ it('invokes onAccept on primary button tap', () => {
+ const onAccept = jest.fn();
+ const { getByLabelText } = renderWithProviders(
+ undefined}
+ />,
+ );
+ fireEvent.press(getByLabelText("I understand, let's begin"));
+ expect(onAccept).toHaveBeenCalledTimes(1);
+ });
+
+ it('invokes onDecline on "Not now" tap', () => {
+ const onDecline = jest.fn();
+ const { getByLabelText } = renderWithProviders(
+ undefined}
+ onDecline={onDecline}
+ />,
+ );
+ fireEvent.press(getByLabelText('Not now'));
+ expect(onDecline).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders nothing when not visible', () => {
+ const { queryByText } = render(
+ undefined}
+ onDecline={() => undefined}
+ />,
+ );
+ // Modal component may not render content to the tree when visible=false.
+ expect(queryByText('Meet Amicus')).toBeNull();
+ });
+});
diff --git a/app/src/screens/AmicusThreadScreen.tsx b/app/src/screens/AmicusThreadScreen.tsx
index 70c831a24..0e4d6b4f6 100644
--- a/app/src/screens/AmicusThreadScreen.tsx
+++ b/app/src/screens/AmicusThreadScreen.tsx
@@ -26,6 +26,7 @@ import {
navigateToCitation,
type MetaFaqArticle,
} from '../services/amicus/citationNav';
+import { useAmicusConsent } from '../services/amicus/consent';
import type { AmicusCitation, AmicusThread } from '../types';
import type { ScreenNavProp, ScreenRouteProp } from '../navigation/types';
import { logger } from '../utils/logger';
@@ -38,6 +39,7 @@ export default function AmicusThreadScreen(): React.ReactElement {
const [thread, setThread] = useState(null);
const [faqArticle, setFaqArticle] = useState(null);
+ const { requestAmicusConsent } = useAmicusConsent();
const { messages, isStreaming, error, sendMessage, abortStream, clearError } =
useAmicusThread(threadId);
@@ -64,9 +66,15 @@ export default function AmicusThreadScreen(): React.ReactElement {
logger.warn('Amicus', 'no auth token — aborting send');
return;
}
+ // Gate on one-time privacy acknowledgement (#1458).
+ const accepted = await requestAmicusConsent();
+ if (!accepted) {
+ logger.info('Amicus', 'opt-in declined — not sending');
+ return;
+ }
await sendMessage(text, authToken);
},
- [sendMessage],
+ [sendMessage, requestAmicusConsent],
);
const handleCitation = useCallback(
diff --git a/app/src/services/amicus/__tests__/consent.test.ts b/app/src/services/amicus/__tests__/consent.test.ts
new file mode 100644
index 000000000..bd0844c09
--- /dev/null
+++ b/app/src/services/amicus/__tests__/consent.test.ts
@@ -0,0 +1,65 @@
+/**
+ * Tests for services/amicus/consent.ts (pure helpers).
+ */
+import { getMockUserDb, resetMockUserDb } from '../../../../__tests__/helpers/mockUserDb';
+
+jest.mock('@/db/userDatabase', () =>
+ require('../../../../__tests__/helpers/mockUserDb').mockUserDatabaseModule(),
+);
+
+import {
+ AMICUS_OPT_IN_KEY,
+ acceptAmicusOptIn,
+ hasAcceptedAmicusOptIn,
+ resetAmicusOptIn,
+} from '../consent';
+
+beforeEach(() => {
+ resetMockUserDb();
+});
+
+describe('hasAcceptedAmicusOptIn', () => {
+ it('returns true when a valid timestamp is stored', async () => {
+ getMockUserDb().getFirstAsync.mockResolvedValueOnce({
+ value: '2026-04-17T10:00:00.000Z',
+ });
+ expect(await hasAcceptedAmicusOptIn()).toBe(true);
+ });
+
+ it('returns false when the pref is absent', async () => {
+ getMockUserDb().getFirstAsync.mockResolvedValueOnce(null);
+ expect(await hasAcceptedAmicusOptIn()).toBe(false);
+ });
+
+ it('returns false when the pref has been reset (empty string)', async () => {
+ getMockUserDb().getFirstAsync.mockResolvedValueOnce({ value: '' });
+ expect(await hasAcceptedAmicusOptIn()).toBe(false);
+ });
+});
+
+describe('acceptAmicusOptIn', () => {
+ it('writes the current timestamp under the opt-in key', async () => {
+ await acceptAmicusOptIn();
+ const calls = getMockUserDb().runAsync.mock.calls;
+ const writeCall = calls.find((c: unknown[]) =>
+ typeof c[0] === 'string' && c[0].includes('user_preferences'),
+ );
+ expect(writeCall).toBeTruthy();
+ const params = writeCall?.[1] as [string, string];
+ expect(params[0]).toBe(AMICUS_OPT_IN_KEY);
+ expect(Date.parse(params[1])).not.toBeNaN();
+ });
+});
+
+describe('resetAmicusOptIn', () => {
+ it('writes an empty string to the opt-in key', async () => {
+ await resetAmicusOptIn();
+ const writeCall = getMockUserDb().runAsync.mock.calls.find(
+ (c: unknown[]) =>
+ typeof c[0] === 'string' && c[0].includes('user_preferences'),
+ );
+ const params = writeCall?.[1] as [string, string];
+ expect(params[0]).toBe(AMICUS_OPT_IN_KEY);
+ expect(params[1]).toBe('');
+ });
+});
diff --git a/app/src/services/amicus/consent.tsx b/app/src/services/amicus/consent.tsx
new file mode 100644
index 000000000..84c056844
--- /dev/null
+++ b/app/src/services/amicus/consent.tsx
@@ -0,0 +1,112 @@
+/**
+ * services/amicus/consent.ts — First-use consent storage + a tiny React
+ * context provider that exposes `requestAmicusConsent()` as a promise.
+ *
+ * The provider mounts AmicusFirstUseModal once anywhere in the tree; any
+ * screen inside the provider can await consent before calling the proxy.
+ */
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import AmicusFirstUseModal from '../../components/amicus/AmicusFirstUseModal';
+import { getPreference } from '../../db/userQueries';
+import { setPreference } from '../../db/userMutations';
+import { logger } from '../../utils/logger';
+
+export const AMICUS_OPT_IN_KEY = 'amicus_opt_in_accepted_at';
+
+export async function hasAcceptedAmicusOptIn(): Promise {
+ const value = await getPreference(AMICUS_OPT_IN_KEY);
+ return typeof value === 'string' && value.length > 0;
+}
+
+export async function acceptAmicusOptIn(): Promise {
+ await setPreference(AMICUS_OPT_IN_KEY, new Date().toISOString());
+ logger.info('Amicus', 'opt-in accepted');
+}
+
+export async function resetAmicusOptIn(): Promise {
+ await setPreference(AMICUS_OPT_IN_KEY, '');
+ logger.info('Amicus', 'opt-in reset');
+}
+
+// ── React context ────────────────────────────────────────────────────
+
+interface ConsentContextValue {
+ /** Returns `true` if consent was just accepted (or was already on file). */
+ requestAmicusConsent: () => Promise;
+}
+
+const ConsentContext = createContext(null);
+
+interface PendingRequest {
+ resolve: (accepted: boolean) => void;
+}
+
+export function AmicusConsentProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}): React.ReactElement {
+ const [visible, setVisible] = useState(false);
+ const pendingRef = useRef(null);
+
+ const handleAccept = useCallback(async () => {
+ setVisible(false);
+ try {
+ await acceptAmicusOptIn();
+ } catch (err) {
+ logger.error('Amicus', 'opt-in write failed', err);
+ }
+ pendingRef.current?.resolve(true);
+ pendingRef.current = null;
+ }, []);
+
+ const handleDecline = useCallback(() => {
+ setVisible(false);
+ pendingRef.current?.resolve(false);
+ pendingRef.current = null;
+ }, []);
+
+ const requestAmicusConsent = useCallback(async (): Promise => {
+ if (await hasAcceptedAmicusOptIn()) return true;
+ return new Promise((resolve) => {
+ pendingRef.current?.resolve(false);
+ pendingRef.current = { resolve };
+ setVisible(true);
+ });
+ }, []);
+
+ const ctx = useMemo(
+ () => ({ requestAmicusConsent }),
+ [requestAmicusConsent],
+ );
+
+ return (
+
+ {children}
+ void handleAccept()}
+ onDecline={handleDecline}
+ />
+
+ );
+}
+
+export function useAmicusConsent(): ConsentContextValue {
+ const ctx = useContext(ConsentContext);
+ if (!ctx) {
+ // A safe fallback means an over-cautious consent flow rather than a
+ // crash when the provider isn't mounted (e.g. in test harnesses).
+ return {
+ requestAmicusConsent: async () => hasAcceptedAmicusOptIn(),
+ };
+ }
+ return ctx;
+}