Skip to content

Commit d590952

Browse files
authored
startLocationUpdatesAsyncに指数バックオフ付きリトライ処理を追加 (#5401)
1 parent 1e054c4 commit d590952

File tree

3 files changed

+203
-25
lines changed

3 files changed

+203
-25
lines changed

src/constants/location.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export const LOCATION_TIME_INTERVAL = 2000;
77

88
export const MAX_PERMIT_ACCURACY = 4000;
99

10+
export const LOCATION_START_MAX_RETRIES = 3;
11+
export const LOCATION_START_RETRY_BASE_DELAY_MS = 1000;
12+
1013
export const LOCATION_TASK_OPTIONS: Location.LocationTaskOptions = {
1114
accuracy: LOCATION_ACCURACY,
1215
distanceInterval: LOCATION_DISTANCE_INTERVAL,

src/hooks/useStartBackgroundLocationUpdates.test.tsx

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { renderHook } from '@testing-library/react-native';
22
import * as Location from 'expo-location';
3-
import { LOCATION_TASK_NAME, LOCATION_TASK_OPTIONS } from '../constants';
3+
import {
4+
LOCATION_START_MAX_RETRIES,
5+
LOCATION_TASK_NAME,
6+
LOCATION_TASK_OPTIONS,
7+
} from '../constants';
48
import { useLocationPermissionsGranted } from './useLocationPermissionsGranted';
59
import { useStartBackgroundLocationUpdates } from './useStartBackgroundLocationUpdates';
610

@@ -98,27 +102,157 @@ describe('useStartBackgroundLocationUpdates', () => {
98102
);
99103
});
100104

101-
test('should handle startLocationUpdatesAsync error gracefully', async () => {
105+
test('should retry startLocationUpdatesAsync on failure and succeed', async () => {
106+
jest.useFakeTimers();
107+
const consoleWarnSpy = jest
108+
.spyOn(console, 'warn')
109+
.mockImplementation(() => {});
110+
mockStartLocationUpdatesAsync
111+
.mockRejectedValueOnce(new Error('Temporary failure'))
112+
.mockResolvedValueOnce(undefined);
113+
mockAutoModeEnabled = false;
114+
mockUseLocationPermissionsGranted.mockReturnValue(true);
115+
116+
renderHook(() => useStartBackgroundLocationUpdates());
117+
118+
// 初回失敗
119+
await jest.advanceTimersByTimeAsync(0);
120+
121+
expect(consoleWarnSpy).toHaveBeenCalledWith(
122+
`バックグラウンド位置情報の更新開始に失敗しました(リトライ 1/${LOCATION_START_MAX_RETRIES}):`,
123+
expect.any(Error)
124+
);
125+
126+
// リトライ待機(1000ms)後に成功
127+
await jest.advanceTimersByTimeAsync(1000);
128+
129+
expect(mockStartLocationUpdatesAsync).toHaveBeenCalledTimes(2);
130+
131+
consoleWarnSpy.mockRestore();
132+
jest.useRealTimers();
133+
});
134+
135+
test('should log final error after all retries exhausted', async () => {
136+
jest.useFakeTimers();
102137
const consoleWarnSpy = jest
103138
.spyOn(console, 'warn')
104139
.mockImplementation(() => {});
105140
mockStartLocationUpdatesAsync.mockRejectedValue(
106-
new Error('Permission denied')
141+
new Error('Persistent failure')
107142
);
108143
mockAutoModeEnabled = false;
109144
mockUseLocationPermissionsGranted.mockReturnValue(true);
110145

111146
renderHook(() => useStartBackgroundLocationUpdates());
112147

148+
// 初回 + リトライ3回分すべて実行
149+
for (let i = 0; i <= LOCATION_START_MAX_RETRIES; i++) {
150+
await jest.advanceTimersByTimeAsync(1000 * 2 ** i);
151+
}
152+
153+
expect(mockStartLocationUpdatesAsync).toHaveBeenCalledTimes(
154+
LOCATION_START_MAX_RETRIES + 1
155+
);
156+
expect(consoleWarnSpy).toHaveBeenLastCalledWith(
157+
'バックグラウンド位置情報の更新開始に失敗しました(リトライ上限到達):',
158+
expect.any(Error)
159+
);
160+
161+
consoleWarnSpy.mockRestore();
162+
jest.useRealTimers();
163+
});
164+
165+
test('should stop location updates if cancelled during startLocationUpdatesAsync', async () => {
166+
mockAutoModeEnabled = false;
167+
mockUseLocationPermissionsGranted.mockReturnValue(true);
168+
169+
// startLocationUpdatesAsyncが解決する前にunmountされるケースをシミュレート
170+
let resolveStart: () => void = () => {};
171+
mockStartLocationUpdatesAsync.mockImplementation(
172+
() =>
173+
new Promise<void>((resolve) => {
174+
resolveStart = resolve;
175+
})
176+
);
177+
178+
const { unmount } = renderHook(() => useStartBackgroundLocationUpdates());
179+
180+
// startが進行中の間にクリーンアップ
181+
unmount();
182+
183+
// startが完了
184+
resolveStart();
113185
await new Promise(process.nextTick);
114186

187+
// クリーンアップのstop + cancelled検知後のstopで2回呼ばれる
188+
expect(mockStopLocationUpdatesAsync).toHaveBeenCalledTimes(2);
189+
expect(mockStopLocationUpdatesAsync).toHaveBeenCalledWith(
190+
LOCATION_TASK_NAME
191+
);
192+
});
193+
194+
test('should not retry when cancelled stop fails after successful start', async () => {
195+
const consoleWarnSpy = jest
196+
.spyOn(console, 'warn')
197+
.mockImplementation(() => {});
198+
mockAutoModeEnabled = false;
199+
mockUseLocationPermissionsGranted.mockReturnValue(true);
200+
201+
let resolveStart: () => void = () => {};
202+
mockStartLocationUpdatesAsync.mockImplementation(
203+
() =>
204+
new Promise<void>((resolve) => {
205+
resolveStart = resolve;
206+
})
207+
);
208+
mockStopLocationUpdatesAsync.mockRejectedValue(new Error('Stop failed'));
209+
210+
const { unmount } = renderHook(() => useStartBackgroundLocationUpdates());
211+
212+
// startが進行中の間にクリーンアップ
213+
unmount();
214+
215+
// startが完了 → cancelled=trueなのでstopを試みるが失敗する
216+
resolveStart();
217+
await new Promise(process.nextTick);
218+
219+
// startは1回だけ(stop失敗でリトライされない)
220+
expect(mockStartLocationUpdatesAsync).toHaveBeenCalledTimes(1);
115221
expect(consoleWarnSpy).toHaveBeenCalledWith(
116-
'バックグラウンド位置情報の更新開始に失敗しました:',
222+
'バックグラウンド位置情報の更新停止に失敗しました:',
117223
expect.any(Error)
118224
);
119225

120226
consoleWarnSpy.mockRestore();
121227
});
228+
229+
test('should stop retrying on cleanup', async () => {
230+
jest.useFakeTimers();
231+
const consoleWarnSpy = jest
232+
.spyOn(console, 'warn')
233+
.mockImplementation(() => {});
234+
mockStartLocationUpdatesAsync.mockRejectedValue(new Error('Failure'));
235+
mockAutoModeEnabled = false;
236+
mockUseLocationPermissionsGranted.mockReturnValue(true);
237+
238+
const { unmount } = renderHook(() => useStartBackgroundLocationUpdates());
239+
240+
// 初回失敗
241+
await jest.advanceTimersByTimeAsync(0);
242+
243+
expect(mockStartLocationUpdatesAsync).toHaveBeenCalledTimes(1);
244+
245+
// クリーンアップでリトライを中止
246+
unmount();
247+
248+
// リトライ待機時間を経過させてもこれ以上呼ばれない
249+
await jest.advanceTimersByTimeAsync(10000);
250+
251+
expect(mockStartLocationUpdatesAsync).toHaveBeenCalledTimes(1);
252+
253+
consoleWarnSpy.mockRestore();
254+
jest.useRealTimers();
255+
});
122256
});
123257

124258
describe('foreground location watch (fallback)', () => {

src/hooks/useStartBackgroundLocationUpdates.ts

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@ import { useAtomValue } from 'jotai';
33
import { useEffect } from 'react';
44
import { setLocation } from '~/store/atoms/location';
55
import navigationState from '~/store/atoms/navigation';
6-
import { LOCATION_TASK_NAME, LOCATION_TASK_OPTIONS } from '../constants';
6+
import {
7+
LOCATION_START_MAX_RETRIES,
8+
LOCATION_START_RETRY_BASE_DELAY_MS,
9+
LOCATION_TASK_NAME,
10+
LOCATION_TASK_OPTIONS,
11+
} from '../constants';
712
import { translate } from '../translation';
813
import { useLocationPermissionsGranted } from './useLocationPermissionsGranted';
914

15+
const wait = (ms: number) =>
16+
new Promise<void>((resolve) => {
17+
setTimeout(resolve, ms);
18+
});
19+
1020
export const useStartBackgroundLocationUpdates = () => {
1121
const bgPermGranted = useLocationPermissionsGranted();
1222
const { autoModeEnabled } = useAtomValue(navigationState);
@@ -16,31 +26,62 @@ export const useStartBackgroundLocationUpdates = () => {
1626
return;
1727
}
1828

29+
let cancelled = false;
30+
1931
(async () => {
20-
try {
21-
// Android/iOS共通でexpo-locationのフォアグラウンドサービスを使用
22-
// Android 16以降ではJobSchedulerにランタイムクォータが適用されるため、
23-
// expo-locationのフォアグラウンドサービス内で直接位置更新を実行する必要がある
24-
await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
25-
...LOCATION_TASK_OPTIONS,
26-
// NOTE: マップマッチが勝手に行われると電車での経路と大きく異なることがあるはずなので
27-
// OtherNavigationは必須
28-
activityType: Location.ActivityType.OtherNavigation,
29-
foregroundService: {
30-
notificationTitle: translate('bgAlertTitle'),
31-
notificationBody: translate('bgAlertContent'),
32-
killServiceOnDestroy: false,
33-
},
34-
});
35-
} catch (error) {
36-
console.warn(
37-
'バックグラウンド位置情報の更新開始に失敗しました:',
38-
error
39-
);
32+
for (let attempt = 0; attempt <= LOCATION_START_MAX_RETRIES; attempt++) {
33+
if (cancelled) {
34+
return;
35+
}
36+
37+
try {
38+
// Android/iOS共通でexpo-locationのフォアグラウンドサービスを使用
39+
// Android 16以降ではJobSchedulerにランタイムクォータが適用されるため、
40+
// expo-locationのフォアグラウンドサービス内で直接位置更新を実行する必要がある
41+
await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, {
42+
...LOCATION_TASK_OPTIONS,
43+
// NOTE: マップマッチが勝手に行われると電車での経路と大きく異なることがあるはずなので
44+
// OtherNavigationは必須
45+
activityType: Location.ActivityType.OtherNavigation,
46+
foregroundService: {
47+
notificationTitle: translate('bgAlertTitle'),
48+
notificationBody: translate('bgAlertContent'),
49+
killServiceOnDestroy: false,
50+
},
51+
});
52+
// クリーンアップがstartの完了前に実行された場合、
53+
// stopが先に走り開始済みの更新が残るため、ここで再度停止する
54+
if (cancelled) {
55+
try {
56+
await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
57+
} catch (stopError) {
58+
console.warn(
59+
'バックグラウンド位置情報の更新停止に失敗しました:',
60+
stopError
61+
);
62+
}
63+
}
64+
return;
65+
} catch (error) {
66+
if (attempt < LOCATION_START_MAX_RETRIES) {
67+
const delay = LOCATION_START_RETRY_BASE_DELAY_MS * 2 ** attempt;
68+
console.warn(
69+
`バックグラウンド位置情報の更新開始に失敗しました(リトライ ${attempt + 1}/${LOCATION_START_MAX_RETRIES}):`,
70+
error
71+
);
72+
await wait(delay);
73+
} else {
74+
console.warn(
75+
'バックグラウンド位置情報の更新開始に失敗しました(リトライ上限到達):',
76+
error
77+
);
78+
}
79+
}
4080
}
4181
})();
4282

4383
return () => {
84+
cancelled = true;
4485
Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME).catch((error) => {
4586
console.warn(
4687
'バックグラウンド位置情報の更新停止に失敗しました:',

0 commit comments

Comments
 (0)