Skip to content

Commit 14b0cd2

Browse files
committed
fix(mobile): offer to allow exact alarms on Android 12+ so reminders and the pomodoro alert stop arriving late (#528)
1 parent 6707532 commit 14b0cd2

20 files changed

Lines changed: 643 additions & 2 deletions
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import React from 'react';
2+
import renderer, { act } from 'react-test-renderer';
3+
import { Text } from 'react-native';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
const { mockRelevant, mockRefresh, mockOpen, appStateListeners } = vi.hoisted(() => ({
7+
mockRelevant: vi.fn(() => true),
8+
mockRefresh: vi.fn(async () => true),
9+
mockOpen: vi.fn(async () => undefined),
10+
appStateListeners: [] as ((state: string) => void)[],
11+
}));
12+
13+
vi.mock('@/lib/exact-alarm-permission', () => ({
14+
isExactAlarmPermissionRelevant: mockRelevant,
15+
refreshExactAlarmPermission: mockRefresh,
16+
openExactAlarmSettings: mockOpen,
17+
}));
18+
19+
vi.mock('@/hooks/use-theme-colors', () => ({
20+
useThemeColors: () => ({
21+
bg: '#0f172a',
22+
cardBg: '#111827',
23+
border: '#334155',
24+
text: '#f8fafc',
25+
secondaryText: '#94a3b8',
26+
}),
27+
}));
28+
29+
vi.mock('react-native', async () => {
30+
const actual = await vi.importActual<typeof import('react-native')>('react-native');
31+
return {
32+
...actual,
33+
AppState: {
34+
currentState: 'active',
35+
addEventListener: (_event: string, listener: (state: string) => void) => {
36+
appStateListeners.push(listener);
37+
return {
38+
remove: () => {
39+
const index = appStateListeners.indexOf(listener);
40+
if (index >= 0) appStateListeners.splice(index, 1);
41+
},
42+
};
43+
},
44+
},
45+
};
46+
});
47+
48+
import { ExactAlarmNoticeRow, useExactAlarmPermission } from './exact-alarm-notice';
49+
50+
function Probe({ enabled }: { enabled: boolean }) {
51+
const { showNotice } = useExactAlarmPermission(enabled);
52+
return <Text>{showNotice ? 'notice' : 'no-notice'}</Text>;
53+
}
54+
55+
const renderProbe = async (enabled: boolean) => {
56+
let tree!: renderer.ReactTestRenderer;
57+
await act(async () => {
58+
tree = renderer.create(<Probe enabled={enabled} />);
59+
});
60+
return tree;
61+
};
62+
63+
const noticeState = (tree: renderer.ReactTestRenderer) => tree.root.findByType(Text).props.children;
64+
65+
beforeEach(() => {
66+
appStateListeners.length = 0;
67+
mockRelevant.mockReturnValue(true);
68+
mockRefresh.mockClear();
69+
mockRefresh.mockResolvedValue(true);
70+
mockOpen.mockClear();
71+
});
72+
73+
describe('useExactAlarmPermission', () => {
74+
it('shows the notice when the feature is on and exact alarms are denied', async () => {
75+
mockRefresh.mockResolvedValue(false);
76+
const tree = await renderProbe(true);
77+
expect(noticeState(tree)).toBe('notice');
78+
});
79+
80+
it('hides the notice when exact alarms are allowed', async () => {
81+
const tree = await renderProbe(true);
82+
expect(noticeState(tree)).toBe('no-notice');
83+
});
84+
85+
it('does not read the permission while the feature is off', async () => {
86+
mockRefresh.mockResolvedValue(false);
87+
const tree = await renderProbe(false);
88+
expect(noticeState(tree)).toBe('no-notice');
89+
expect(mockRefresh).not.toHaveBeenCalled();
90+
});
91+
92+
it('does not read the permission below Android 12', async () => {
93+
mockRelevant.mockReturnValue(false);
94+
mockRefresh.mockResolvedValue(false);
95+
const tree = await renderProbe(true);
96+
expect(noticeState(tree)).toBe('no-notice');
97+
expect(mockRefresh).not.toHaveBeenCalled();
98+
});
99+
100+
it('re-reads the permission when the app returns to the foreground', async () => {
101+
mockRefresh.mockResolvedValue(false);
102+
const tree = await renderProbe(true);
103+
expect(noticeState(tree)).toBe('notice');
104+
expect(mockRefresh).toHaveBeenCalledTimes(1);
105+
106+
mockRefresh.mockResolvedValue(true);
107+
await act(async () => {
108+
appStateListeners.forEach((listener) => listener('active'));
109+
});
110+
111+
expect(mockRefresh).toHaveBeenCalledTimes(2);
112+
expect(noticeState(tree)).toBe('no-notice');
113+
});
114+
115+
it('ignores background transitions', async () => {
116+
const tree = await renderProbe(true);
117+
await act(async () => {
118+
appStateListeners.forEach((listener) => listener('background'));
119+
});
120+
expect(mockRefresh).toHaveBeenCalledTimes(1);
121+
expect(noticeState(tree)).toBe('no-notice');
122+
});
123+
124+
it('detaches its listener on unmount', async () => {
125+
const tree = await renderProbe(true);
126+
expect(appStateListeners).toHaveLength(1);
127+
await act(async () => {
128+
tree.unmount();
129+
});
130+
expect(appStateListeners).toHaveLength(0);
131+
});
132+
});
133+
134+
describe('ExactAlarmNoticeRow', () => {
135+
it('opens the system screen from its action button', async () => {
136+
let tree!: renderer.ReactTestRenderer;
137+
await act(async () => {
138+
tree = renderer.create(
139+
<ExactAlarmNoticeRow label="Late" description="Why" actionLabel="Allow" />
140+
);
141+
});
142+
143+
const button = tree.root.findByProps({ testID: 'exact-alarm-allow' });
144+
await act(async () => {
145+
button.props.onPress();
146+
});
147+
148+
expect(mockOpen).toHaveBeenCalledTimes(1);
149+
});
150+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React, { useCallback, useEffect, useState } from 'react';
2+
import { AppState, Text, TouchableOpacity } from 'react-native';
3+
4+
import {
5+
isExactAlarmPermissionRelevant,
6+
openExactAlarmSettings,
7+
refreshExactAlarmPermission,
8+
} from '@/lib/exact-alarm-permission';
9+
10+
import { SettingRow } from './setting-row';
11+
import { styles } from './settings.styles';
12+
13+
/**
14+
* Tracks whether the OS will let this app set exact alarms, for the settings
15+
* screens that own a feature which depends on them.
16+
*
17+
* Only runs while `enabled` is true — the user has the reminder or pomodoro
18+
* alert switched on and is looking at the screen that offers the fix. No
19+
* polling and no background work: the state can only change on the system
20+
* screen we send them to, so re-reading it when the app comes back to the
21+
* foreground is enough.
22+
*/
23+
export function useExactAlarmPermission(enabled: boolean): { showNotice: boolean } {
24+
const [allowed, setAllowed] = useState(true);
25+
26+
useEffect(() => {
27+
if (!enabled || !isExactAlarmPermissionRelevant()) return;
28+
let cancelled = false;
29+
const check = () => {
30+
refreshExactAlarmPermission()
31+
.then((next) => {
32+
if (!cancelled) setAllowed(next);
33+
})
34+
.catch(() => undefined);
35+
};
36+
check();
37+
const subscription = AppState.addEventListener('change', (state) => {
38+
if (state === 'active') check();
39+
});
40+
return () => {
41+
cancelled = true;
42+
subscription.remove();
43+
};
44+
}, [enabled]);
45+
46+
return { showNotice: enabled && isExactAlarmPermissionRelevant() && !allowed };
47+
}
48+
49+
export interface ExactAlarmNoticeRowProps {
50+
/** Already-translated title, description and button label. */
51+
label: string;
52+
description: string;
53+
actionLabel: string;
54+
divider?: boolean;
55+
}
56+
57+
/** The one row both reminder and pomodoro settings show while exact alarms are denied. */
58+
export function ExactAlarmNoticeRow({ label, description, actionLabel, divider }: ExactAlarmNoticeRowProps) {
59+
const onPress = useCallback(() => {
60+
openExactAlarmSettings().catch(console.error);
61+
}, []);
62+
63+
return (
64+
<SettingRow
65+
divider={divider}
66+
label={label}
67+
description={description}
68+
testID="exact-alarm-notice"
69+
>
70+
<TouchableOpacity
71+
style={[styles.manageEditorButton, styles.manageEditorButtonPrimary]}
72+
onPress={onPress}
73+
accessibilityRole="button"
74+
accessibilityLabel={actionLabel}
75+
testID="exact-alarm-allow"
76+
>
77+
<Text style={[styles.manageEditorButtonText, styles.manageEditorButtonPrimaryText]}>
78+
{actionLabel}
79+
</Text>
80+
</TouchableOpacity>
81+
</SettingRow>
82+
);
83+
}

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

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

46+
import { ExactAlarmNoticeRow, useExactAlarmPermission } from './exact-alarm-notice';
4647
import { SettingRow, SettingToggleRow } from './setting-row';
4748
import type { SettingsScreen } from './settings.constants';
4849
import { useSettingsLocalization, useSettingsScrollContent } from './settings.hooks';
@@ -137,6 +138,9 @@ export function GtdSettingsScreen({
137138
// Defaults on: the alert is the point of the timer, and an off-by-default
138139
// switch is what made #528 read as broken.
139140
const pomodoroCompletionAlert = settings.gtd?.pomodoro?.completionAlert !== false;
141+
const { showNotice: showExactAlarmNotice } = useExactAlarmPermission(
142+
screen === 'gtd-pomodoro' && pomodoroCompletionAlert
143+
);
140144
const [pomodoroFocusDraft, setPomodoroFocusDraft] = useState(String(pomodoroCustomDurations.focusMinutes));
141145
const [pomodoroBreakDraft, setPomodoroBreakDraft] = useState(String(pomodoroCustomDurations.breakMinutes));
142146
const [defaultScheduleTimeDraft, setDefaultScheduleTimeDraft] = useState(defaultScheduleTime);
@@ -697,6 +701,14 @@ export function GtdSettingsScreen({
697701
value={pomodoroCompletionAlert}
698702
onChange={(value) => updatePomodoroSettings({ completionAlert: value })}
699703
/>
704+
{showExactAlarmNotice && (
705+
<ExactAlarmNoticeRow
706+
divider
707+
label={t('settings.exactAlarmsLabel')}
708+
description={t('settings.exactAlarmsDesc')}
709+
actionLabel={t('settings.exactAlarmsAllow')}
710+
/>
711+
)}
700712
</View>
701713
)}
702714
</ScrollView>

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from '@/lib/persistent-capture-notification';
2121
import { useThemeColors } from '@/hooks/use-theme-colors';
2222

23+
import { ExactAlarmNoticeRow, useExactAlarmPermission } from './exact-alarm-notice';
2324
import { SettingRow, SettingToggleRow } from './setting-row';
2425
import { useSettingsLocalization, useSettingsScrollContent } from './settings.hooks';
2526
import { SettingsTopBar } from './settings.shell';
@@ -37,6 +38,7 @@ export function NotificationsSettingsScreen() {
3738
const [weeklyReviewDayPickerOpen, setWeeklyReviewDayPickerOpen] = useState(false);
3839

3940
const notificationsEnabled = areTaskRemindersEnabled(settings);
41+
const { showNotice: showExactAlarmNotice } = useExactAlarmPermission(notificationsEnabled);
4042
const startDateNotificationsEnabled = areStartDateRemindersEnabled(settings);
4143
const dueDateNotificationsEnabled = areDueDateRemindersEnabled(settings);
4244
const dailyDigestMorningEnabled = settings.dailyDigestMorningEnabled === true;
@@ -238,6 +240,15 @@ export function NotificationsSettingsScreen() {
238240
}}
239241
/>
240242

243+
{showExactAlarmNotice && (
244+
<ExactAlarmNoticeRow
245+
divider
246+
label={t('settings.exactAlarmsLabel')}
247+
description={t('settings.exactAlarmsDesc')}
248+
actionLabel={t('settings.exactAlarmsAllow')}
249+
/>
250+
)}
251+
241252
{isPersistentCaptureSupported() && (
242253
<SettingToggleRow
243254
divider

0 commit comments

Comments
 (0)