Skip to content

Commit 9d3c178

Browse files
committed
feat: add opt-in Android automation capture (#1149)
1 parent 524a2cd commit 9d3c178

39 files changed

Lines changed: 1088 additions & 13 deletions
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import React from 'react';
2+
import renderer, { act } from 'react-test-renderer';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { AndroidCaptureIntentSection } from './android-capture-intent-section';
6+
7+
const nativeMocks = vi.hoisted(() => ({
8+
getCaptureIntentConfig: vi.fn(),
9+
isSupported: vi.fn(() => true),
10+
setCaptureIntentEnabled: vi.fn(),
11+
}));
12+
const clipboardMocks = vi.hoisted(() => ({ setStringAsync: vi.fn() }));
13+
const showToast = vi.hoisted(() => vi.fn());
14+
15+
vi.mock('@/modules/android-widget', () => nativeMocks);
16+
vi.mock('expo-clipboard', () => clipboardMocks);
17+
vi.mock('@/hooks/use-theme-colors', () => ({
18+
useThemeColors: () => ({
19+
bg: '#0f172a',
20+
cardBg: '#111827',
21+
border: '#334155',
22+
text: '#f8fafc',
23+
secondaryText: '#94a3b8',
24+
tint: '#3b82f6',
25+
}),
26+
}));
27+
vi.mock('@/contexts/toast-context', () => ({
28+
useToast: () => ({ showToast }),
29+
}));
30+
vi.mock('./settings.hooks', () => ({
31+
useSettingsLocalization: () => ({
32+
tr: (key: string) => ({
33+
'settings.automationCapture': 'Automation capture',
34+
'settings.automationCaptureDesc': 'Allow trusted automation apps with your token to queue text to Inbox. Captures appear the next time Mindwtr opens.',
35+
'settings.automationCaptureToken': 'Capture token',
36+
'settings.automationCaptureCopyToken': 'Copy token',
37+
'settings.automationCaptureCopied': 'Capture token copied.',
38+
'settings.automationCaptureCopyFailed': "Couldn't copy the capture token.",
39+
'settings.automationCaptureLoadFailed': "Couldn't load automation capture settings.",
40+
'settings.automationCaptureUpdateFailed': "Couldn't update automation capture settings.",
41+
}[key] ?? key),
42+
}),
43+
}));
44+
45+
const TOKEN = 'ab'.repeat(32);
46+
const settle = async () => {
47+
await Promise.resolve();
48+
await Promise.resolve();
49+
};
50+
51+
async function renderSection() {
52+
let tree!: renderer.ReactTestRenderer;
53+
await act(async () => {
54+
tree = renderer.create(<AndroidCaptureIntentSection />);
55+
await settle();
56+
});
57+
return tree;
58+
}
59+
60+
describe('AndroidCaptureIntentSection', () => {
61+
beforeEach(() => {
62+
vi.clearAllMocks();
63+
nativeMocks.isSupported.mockReturnValue(true);
64+
nativeMocks.getCaptureIntentConfig.mockResolvedValue({ enabled: false, token: null });
65+
nativeMocks.setCaptureIntentEnabled.mockImplementation(async (enabled: boolean) => (
66+
enabled ? { enabled: true, token: TOKEN } : { enabled: false, token: null }
67+
));
68+
clipboardMocks.setStringAsync.mockResolvedValue(true);
69+
});
70+
71+
it('loads off, enables, copies the selectable token, and disables with native-confirmed state', async () => {
72+
const tree = await renderSection();
73+
expect(tree.root.findByProps({ testID: 'android-capture-intent-section' })).toBeTruthy();
74+
expect(tree.root.findByProps({ testID: 'android-capture-intent-switch' }).props).toMatchObject({
75+
value: false,
76+
disabled: false,
77+
});
78+
79+
await act(async () => {
80+
tree.root.findByProps({ testID: 'android-capture-intent-switch' }).props.onValueChange(true);
81+
await settle();
82+
});
83+
expect(nativeMocks.setCaptureIntentEnabled).toHaveBeenCalledWith(true);
84+
expect(tree.root.findByProps({ testID: 'android-capture-intent-token' }).props).toMatchObject({
85+
selectable: true,
86+
children: TOKEN,
87+
});
88+
89+
await act(async () => {
90+
tree.root.findByProps({ testID: 'android-capture-intent-copy' }).props.onPress();
91+
await settle();
92+
});
93+
expect(clipboardMocks.setStringAsync).toHaveBeenCalledWith(TOKEN);
94+
expect(showToast).toHaveBeenCalledWith({ message: 'Capture token copied.', tone: 'info' });
95+
96+
await act(async () => {
97+
tree.root.findByProps({ testID: 'android-capture-intent-switch' }).props.onValueChange(false);
98+
await settle();
99+
});
100+
expect(nativeMocks.setCaptureIntentEnabled).toHaveBeenLastCalledWith(false);
101+
expect(tree.root.findByProps({ testID: 'android-capture-intent-switch' }).props.value).toBe(false);
102+
expect(tree.root.findAllByProps({ testID: 'android-capture-intent-token' })).toHaveLength(0);
103+
});
104+
105+
it('keeps confirmed state when a native mutation fails and surfaces the error', async () => {
106+
nativeMocks.getCaptureIntentConfig.mockResolvedValue({ enabled: true, token: TOKEN });
107+
nativeMocks.setCaptureIntentEnabled.mockRejectedValue(new Error('disk full'));
108+
const tree = await renderSection();
109+
110+
await act(async () => {
111+
tree.root.findByProps({ testID: 'android-capture-intent-switch' }).props.onValueChange(false);
112+
await settle();
113+
});
114+
115+
expect(tree.root.findByProps({ testID: 'android-capture-intent-switch' }).props.value).toBe(true);
116+
expect(showToast).toHaveBeenCalledWith({
117+
message: "Couldn't update automation capture settings.",
118+
tone: 'error',
119+
});
120+
});
121+
122+
it('disables unknown state and surfaces a config load failure', async () => {
123+
nativeMocks.getCaptureIntentConfig.mockRejectedValue(new Error('corrupt'));
124+
const tree = await renderSection();
125+
126+
expect(tree.root.findByProps({ testID: 'android-capture-intent-switch' }).props.disabled).toBe(true);
127+
expect(showToast).toHaveBeenCalledWith({
128+
message: "Couldn't load automation capture settings.",
129+
tone: 'error',
130+
});
131+
});
132+
133+
it('omits the section when the optional Android native module is unavailable', async () => {
134+
nativeMocks.isSupported.mockReturnValue(false);
135+
const tree = await renderSection();
136+
137+
expect(tree.toJSON()).toBeNull();
138+
expect(nativeMocks.getCaptureIntentConfig).not.toHaveBeenCalled();
139+
});
140+
});
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
3+
import * as Clipboard from 'expo-clipboard';
4+
5+
import { useThemeColors } from '@/hooks/use-theme-colors';
6+
import { useToast } from '@/contexts/toast-context';
7+
import {
8+
getCaptureIntentConfig,
9+
isSupported,
10+
setCaptureIntentEnabled,
11+
type CaptureIntentConfig,
12+
} from '@/modules/android-widget';
13+
14+
import { SettingToggleRow } from './setting-row';
15+
import { useSettingsLocalization } from './settings.hooks';
16+
import { styles as settingsStyles } from './settings.styles';
17+
18+
function normalizeConfig(value: CaptureIntentConfig): CaptureIntentConfig {
19+
if (value?.enabled === true && typeof value.token === 'string' && /^[0-9a-f]{64}$/.test(value.token)) {
20+
return { enabled: true, token: value.token };
21+
}
22+
if (value?.enabled === false) return { enabled: false, token: null };
23+
throw new Error('Invalid capture intent config');
24+
}
25+
26+
export function AndroidCaptureIntentSection() {
27+
const tc = useThemeColors();
28+
const { showToast } = useToast();
29+
const { tr } = useSettingsLocalization();
30+
const supported = isSupported();
31+
const [config, setConfig] = useState<CaptureIntentConfig | null>(null);
32+
const [updating, setUpdating] = useState(false);
33+
34+
const label = tr('settings.automationCapture');
35+
const description = tr('settings.automationCaptureDesc');
36+
const tokenLabel = tr('settings.automationCaptureToken');
37+
const copyLabel = tr('settings.automationCaptureCopyToken');
38+
const copiedMessage = tr('settings.automationCaptureCopied');
39+
const copyFailedMessage = tr('settings.automationCaptureCopyFailed');
40+
const loadFailedMessage = tr('settings.automationCaptureLoadFailed');
41+
const updateFailedMessage = tr('settings.automationCaptureUpdateFailed');
42+
43+
useEffect(() => {
44+
if (!supported) return;
45+
let active = true;
46+
void getCaptureIntentConfig()
47+
.then((value) => {
48+
if (active) setConfig(normalizeConfig(value));
49+
})
50+
.catch(() => {
51+
if (!active) return;
52+
showToast({ message: loadFailedMessage, tone: 'error' });
53+
});
54+
return () => {
55+
active = false;
56+
};
57+
}, [loadFailedMessage, showToast, supported]);
58+
59+
if (!supported) return null;
60+
61+
const updateEnabled = async (enabled: boolean) => {
62+
if (!config || updating) return;
63+
setUpdating(true);
64+
try {
65+
const next = normalizeConfig(await setCaptureIntentEnabled(enabled));
66+
setConfig(next);
67+
} catch {
68+
showToast({ message: updateFailedMessage, tone: 'error' });
69+
} finally {
70+
setUpdating(false);
71+
}
72+
};
73+
74+
const copyToken = async () => {
75+
if (!config?.enabled || !config.token) return;
76+
try {
77+
await Clipboard.setStringAsync(config.token);
78+
showToast({ message: copiedMessage, tone: 'info' });
79+
} catch {
80+
showToast({ message: copyFailedMessage, tone: 'error' });
81+
}
82+
};
83+
84+
return (
85+
<View
86+
testID="android-capture-intent-section"
87+
style={[settingsStyles.settingCard, localStyles.card, { backgroundColor: tc.cardBg }]}
88+
>
89+
<SettingToggleRow
90+
label={label}
91+
description={description}
92+
value={config?.enabled === true}
93+
disabled={!config || updating}
94+
onChange={(enabled) => { void updateEnabled(enabled); }}
95+
switchTestID="android-capture-intent-switch"
96+
/>
97+
{config?.enabled && config.token ? (
98+
<View style={[localStyles.tokenRow, { borderTopColor: tc.border }]}>
99+
<Text style={[localStyles.tokenLabel, { color: tc.text }]}>{tokenLabel}</Text>
100+
<Text
101+
selectable
102+
testID="android-capture-intent-token"
103+
style={[localStyles.token, { color: tc.secondaryText, backgroundColor: tc.bg, borderColor: tc.border }]}
104+
>
105+
{config.token}
106+
</Text>
107+
<TouchableOpacity
108+
accessibilityRole="button"
109+
testID="android-capture-intent-copy"
110+
style={[localStyles.copyButton, { borderColor: tc.tint }]}
111+
onPress={() => { void copyToken(); }}
112+
activeOpacity={0.75}
113+
>
114+
<Text style={[localStyles.copyLabel, { color: tc.tint }]}>{copyLabel}</Text>
115+
</TouchableOpacity>
116+
</View>
117+
) : null}
118+
</View>
119+
);
120+
}
121+
122+
const localStyles = StyleSheet.create({
123+
card: {
124+
marginTop: 12,
125+
},
126+
tokenRow: {
127+
borderTopWidth: StyleSheet.hairlineWidth,
128+
paddingHorizontal: 16,
129+
paddingVertical: 14,
130+
gap: 8,
131+
},
132+
tokenLabel: {
133+
fontSize: 15,
134+
fontWeight: '600',
135+
},
136+
token: {
137+
borderWidth: StyleSheet.hairlineWidth,
138+
borderRadius: 8,
139+
fontFamily: 'monospace',
140+
fontSize: 13,
141+
lineHeight: 19,
142+
paddingHorizontal: 10,
143+
paddingVertical: 9,
144+
},
145+
copyButton: {
146+
alignSelf: 'flex-start',
147+
borderWidth: 1,
148+
borderRadius: 8,
149+
paddingHorizontal: 12,
150+
paddingVertical: 8,
151+
},
152+
copyLabel: {
153+
fontSize: 14,
154+
fontWeight: '600',
155+
},
156+
});

apps/mobile/components/settings/gtd-settings-screen.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ vi.mock('./settings.shell', () => ({
159159
MenuItem: (props: any) => React.createElement('MenuItem', props, props.children),
160160
}));
161161

162+
vi.mock('./android-capture-intent-section', () => ({
163+
AndroidCaptureIntentSection: () => React.createElement('AndroidCaptureIntentSection'),
164+
}));
165+
162166
vi.mock('@/components/task-edit/task-edit-modal.utils', () => ({
163167
buildTaskEditorPresetConfig: () => ({ order: ['status', 'project'], hidden: [], sections: {}, sectionOpen: {} }),
164168
resolveTaskEditorPresetId: () => 'custom',

apps/mobile/components/settings/gtd-settings-screen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from '@mindwtr/core';
4545

4646
import { ExactAlarmNoticeRow, useExactAlarmPermission } from './exact-alarm-notice';
47+
import { AndroidCaptureIntentSection } from './android-capture-intent-section';
4748
import { SettingRow, SettingToggleRow } from './setting-row';
4849
import type { SettingsScreen } from './settings.constants';
4950
import { useSettingsLocalization, useSettingsScrollContent } from './settings.hooks';
@@ -830,6 +831,7 @@ export function GtdSettingsScreen({
830831
}}
831832
/>
832833
</View>
834+
<AndroidCaptureIntentSection />
833835
</ScrollView>
834836
<Modal
835837
visible={defaultAreaPickerVisible}

apps/mobile/lib/pending-captures.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,61 @@ describe('ingestPendingCaptures', () => {
216216
});
217217
});
218218

219+
it('logs Android automation capture only after the Inbox task is durably saved', async () => {
220+
oneFile('automation.json', {
221+
id: 'automation-1',
222+
title: 'Dictated task',
223+
createdAt: '2026-09-07T10:00:00.000Z',
224+
source: 'android-capture-intent',
225+
});
226+
const addTask = addTaskMock();
227+
const flushPendingSave = vi.fn(async () => undefined);
228+
229+
expect(await ingestPendingCaptures({
230+
addTask,
231+
updateTask,
232+
addProject,
233+
projects: [],
234+
areas: [],
235+
tasks: [],
236+
people: [],
237+
settings: emptySettings,
238+
flushPendingSave,
239+
})).toBe(1);
240+
241+
expect(addTask).toHaveBeenCalledWith('Dictated task', { status: 'inbox' });
242+
expect(flushPendingSave).toHaveBeenCalledOnce();
243+
expect(appLogMocks.logInfo).toHaveBeenCalledWith('Android automation capture ingested', {
244+
scope: 'capture',
245+
extra: { releaseCheck: 'v1.2.9/android-capture-intent' },
246+
});
247+
expect(flushPendingSave.mock.invocationCallOrder[0])
248+
.toBeLessThan(appLogMocks.logInfo.mock.invocationCallOrder[0]);
249+
});
250+
251+
it('retains an Android automation capture when the durable Inbox save fails', async () => {
252+
oneFile('automation.json', {
253+
id: 'automation-1',
254+
title: 'Keep this dictation',
255+
source: 'android-capture-intent',
256+
});
257+
258+
expect(await ingestPendingCaptures({
259+
addTask: addTaskMock(),
260+
updateTask,
261+
addProject,
262+
projects: [],
263+
areas: [],
264+
tasks: [],
265+
people: [],
266+
settings: emptySettings,
267+
flushPendingSave: vi.fn(async () => { throw new Error('disk full'); }),
268+
})).toBe(0);
269+
270+
expect(fileSystemMocks.deleteAsync).not.toHaveBeenCalled();
271+
expect(appLogMocks.logInfo).not.toHaveBeenCalled();
272+
});
273+
219274
it('completes a checked-off task through updateTask and treats done or missing tasks as a no-op that still clears the file', async () => {
220275
const item = (taskId: string) => JSON.stringify({ kind: 'complete', id: `c-${taskId}`, taskId, source: 'android-widget' });
221276
fileSystemMocks.readDirectoryAsync.mockResolvedValue(['a.json', 'b.json', 'c.json']);

0 commit comments

Comments
 (0)