Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@
"telemetryDescription": "Your device's geographic coordinates are sent to our analytics server. The information is used only for analytics.",
"etaAssistTitle": "Improve arrival detection",
"etaAssistDescription": "When enabled, in sections where GPS accuracy drops such as subways, the app uses the server's estimated arrival times (ETA) to assist arrival detection. GPS remains the source of truth for arrival and current location; ETA is only an aid.",
"batterySettings": "Battery",
"powerSavingLocationTitle": "Power-saving location mode",
"powerSavingLocationDescription": "When enabled, location accuracy is lowered to a battery-friendly level and iOS pauses tracking automatically while you are stopped, further reducing battery drain and heat on long rides. The lower accuracy may delay or shift station detection and arrival announcements. The relaxed update frequency is already part of the default settings. It also turns on automatically while your device is in low-power mode.",
"passStationLabel": "Pass"
Expand Down
1 change: 1 addition & 0 deletions assets/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@
"telemetryDescription": "お使いの端末の地理的座標を解析用サーバに送信します。送信された情報は解析以外に使用されません。",
"etaAssistTitle": "到着判定の改善",
"etaAssistDescription": "有効にすると、地下鉄などGPSの精度が落ちる区間で、サーバーの到着予測(ETA)を使って到着判定を補助します。到着や現在地はGPSが基準で、ETAはあくまで補助です。",
"batterySettings": "バッテリー",
"powerSavingLocationTitle": "省電力測位モード",
"powerSavingLocationDescription": "有効にすると、測位精度を電池優先まで下げ、停車中はiOSが測位を自動休止して、長時間の乗車での電池消費と発熱をさらに減らします。精度の低下により駅の判定や到着案内が遅れたりずれたりする場合があります。位置情報の更新頻度の緩和は標準設定に組み込まれています。端末の省電力モード中は自動的に有効になります。",
"passStationLabel": "通過"
Expand Down
2 changes: 1 addition & 1 deletion src/components/Permitted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ const PermittedLayout: React.FC<Props> = ({ children }: Props) => {
);
// NOTE: powerSavingLocationEnabledAtom はここでは復元しない。effect復元だと
// 継続測位がデフォルト精度で一度起動してから再起動されるため、
// atom定義側(store/atoms/experimental.ts)でMMKVから同期的に初期値を確定している。
// atom定義側(store/atoms/battery.ts)でMMKVから同期的に初期値を確定している。

if (themePreferenceKey) {
setThemePreference(themePreferenceKey as ThemePreference);
Expand Down
55 changes: 2 additions & 53 deletions src/hooks/useStartBackgroundLocationUpdates.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { useStartBackgroundLocationUpdates } from './useStartBackgroundLocationU

let mockNeedsJobSchedulerBypass = false;
let mockSystemLowPowerMode = false;
let mockIsDevApp = false;
jest.mock('../constants/native', () => ({
get NEEDS_JOBSCHEDULER_BYPASS() {
return mockNeedsJobSchedulerBypass;
Expand All @@ -23,11 +22,6 @@ jest.mock('../constants/native', () => ({
jest.mock('expo-battery', () => ({
useLowPowerMode: () => mockSystemLowPowerMode,
}));
jest.mock('~/utils/isDevApp', () => ({
get isDevApp() {
return mockIsDevApp;
},
}));
jest.mock('expo-location');
jest.mock('./useLocationPermissionsGranted');
jest.mock('~/store', () => ({
Expand All @@ -44,7 +38,7 @@ jest.mock('~/store/atoms/navigation', () => ({
default: {},
autoModeEnabledAtom: { toString: () => 'autoModeEnabledAtom' },
}));
jest.mock('~/store/atoms/experimental', () => ({
jest.mock('~/store/atoms/battery', () => ({
powerSavingLocationEnabledAtom: {
toString: () => 'powerSavingLocationEnabledAtom',
},
Expand Down Expand Up @@ -92,7 +86,6 @@ describe('useStartBackgroundLocationUpdates', () => {
mockAutoModeEnabled = false;
mockPowerSavingLocationEnabled = false;
mockSystemLowPowerMode = false;
mockIsDevApp = false;
mockNeedsJobSchedulerBypass = false;
mockStartLocationUpdatesAsync.mockResolvedValue(undefined);
mockStopLocationUpdatesAsync.mockResolvedValue(undefined);
Expand Down Expand Up @@ -391,7 +384,6 @@ describe('useStartBackgroundLocationUpdates', () => {
describe('power saving location mode', () => {
test('should lower accuracy and allow automatic pauses for background updates when enabled', async () => {
mockPowerSavingLocationEnabled = true;
mockIsDevApp = true;
mockUseLocationPermissionsGranted.mockReturnValue(true);

renderHook(() => useStartBackgroundLocationUpdates());
Expand Down Expand Up @@ -429,10 +421,9 @@ describe('useStartBackgroundLocationUpdates', () => {
);
});

test('should enable the power-saving profile in the dev app while the system low-power mode is active', async () => {
test('should enable the power-saving profile while the system low-power mode is active', async () => {
mockPowerSavingLocationEnabled = false;
mockSystemLowPowerMode = true;
mockIsDevApp = true;
mockUseLocationPermissionsGranted.mockReturnValue(true);

renderHook(() => useStartBackgroundLocationUpdates());
Expand All @@ -449,50 +440,8 @@ describe('useStartBackgroundLocationUpdates', () => {
);
});

test('should keep the default profile in production while the system low-power mode is active', async () => {
mockPowerSavingLocationEnabled = false;
mockSystemLowPowerMode = true;
mockIsDevApp = false;
mockUseLocationPermissionsGranted.mockReturnValue(true);

renderHook(() => useStartBackgroundLocationUpdates());

await new Promise(process.nextTick);

expect(mockStartLocationUpdatesAsync).toHaveBeenCalledWith(
LOCATION_TASK_NAME,
expect.objectContaining({
...LOCATION_TASK_OPTIONS,
accuracy: Location.Accuracy.High,
pausesUpdatesAutomatically: false,
foregroundService: expect.objectContaining({
killServiceOnDestroy: true,
}),
})
);
});

test('should ignore the experimental setting outside the dev app', async () => {
mockPowerSavingLocationEnabled = true;
mockIsDevApp = false;
mockUseLocationPermissionsGranted.mockReturnValue(true);

renderHook(() => useStartBackgroundLocationUpdates());

await new Promise(process.nextTick);

expect(mockStartLocationUpdatesAsync).toHaveBeenCalledWith(
LOCATION_TASK_NAME,
expect.objectContaining({
accuracy: Location.Accuracy.High,
pausesUpdatesAutomatically: false,
})
);
});

test('should apply the power-saving accuracy to foreground watchPositionAsync when enabled', async () => {
mockPowerSavingLocationEnabled = true;
mockIsDevApp = true;
mockUseLocationPermissionsGranted.mockReturnValue(false);

renderHook(() => useStartBackgroundLocationUpdates());
Expand Down
16 changes: 7 additions & 9 deletions src/hooks/useStartBackgroundLocationUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import * as Location from 'expo-location';
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { store } from '~/store';
import { powerSavingLocationEnabledAtom } from '~/store/atoms/experimental';
import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery';
import { backgroundLocationTrackingAtom } from '~/store/atoms/location';
import { autoModeEnabledAtom } from '~/store/atoms/navigation';
import { handleTrackingLocation } from '~/utils/handleTrackingLocation';
import { isDevApp } from '~/utils/isDevApp';
import {
LOCATION_START_MAX_RETRIES,
LOCATION_START_RETRY_BASE_DELAY_MS,
Expand All @@ -30,16 +29,15 @@ export const useStartBackgroundLocationUpdates = () => {
const bgPermGranted = useLocationPermissionsGranted();
const autoModeEnabled = useAtomValue(autoModeEnabledAtom);
const systemLowPowerMode = Battery.useLowPowerMode();
// 省電力測位モード(実験的機能)。精度をBalancedへ下げ、停車中の測位自動休止
// (iOSのみ)を許可する。旧プロファイルのHigh精度・更新間隔の緩和は実車検証を
// 経て既定値へ昇格済み(constants/location.ts)。
// 省電力測位モード。精度をBalancedへ下げ、停車中の測位自動休止(iOSのみ)を
// 許可する。旧プロファイルのHigh精度・更新間隔の緩和は実車検証を経て既定値へ
// 昇格済み(constants/location.ts)。
const powerSavingSettingEnabled = useAtomValue(
powerSavingLocationEnabledAtom
);
// 試験用機能のためdevアプリだけで有効化する。手動設定に加えて、
// 端末の省電力モード中も自動的に同じプロファイルへ切り替える。
const powerSavingEnabled =
isDevApp && (powerSavingSettingEnabled || systemLowPowerMode);
// 「バッテリー」設定でONにしたときに加え、端末の省電力モード中も自動的に
// 同じプロファイルへ切り替える。
const powerSavingEnabled = powerSavingSettingEnabled || systemLowPowerMode;
// 選択するオブジェクトはモジュール定数なので、effect依存でも参照が安定する。
const watchOptions = powerSavingEnabled
? LOCATION_WATCH_OPTIONS_POWER_SAVING
Expand Down
9 changes: 9 additions & 0 deletions src/screens/AppSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const SETTING_ITEM_ID_MAP = {
personalize_tts: 'personalize_tts',
personalize_languages: 'personalize_languages',
personalize_notifications: 'personalize_notifications',
personalize_battery: 'personalize_battery',
personalize_experimental: 'personalize_experimental',
personalize_android: 'personalize_android',
about_app_licenses: 'about_app_licenses',
Expand Down Expand Up @@ -110,6 +111,8 @@ const SettingsItem = ({
return 'globe';
case 'personalize_notifications':
return 'notifications';
case 'personalize_battery':
return 'battery-half';
case 'personalize_experimental':
return 'flask';
case 'personalize_android':
Expand Down Expand Up @@ -312,6 +315,12 @@ const AppSettingsScreen: React.FC = () => {
color: '#FF3B30',
onPress: () => navigation.navigate('NotificationSettings' as never),
},
{
id: SETTING_ITEM_ID_MAP.personalize_battery,
title: translate('batterySettings'),
color: '#30B0C7',
onPress: () => navigation.navigate('BatterySettings' as never),
},
// 試験的機能はカナリアリリース(devアプリ)限定で表示する
...(isDevApp
? [
Expand Down
94 changes: 94 additions & 0 deletions src/screens/BatterySettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { fireEvent, render } from '@testing-library/react-native';
import { createStore, Provider } from 'jotai';
import { Alert } from 'react-native';
import { STORAGE_KEYS } from '~/constants';
import { storage } from '~/lib/storage';
import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery';
import BatterySettingsScreen from './BatterySettings';

jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
goBack: jest.fn(),
}),
}));

jest.mock('~/components/FooterTabBar', () => () => null);
jest.mock('~/components/SettingsHeader', () => ({
SettingsHeader: () => null,
}));
jest.mock('~/components/Button', () => () => null);
jest.mock('~/translation', () => ({
translate: (key: string) => key,
}));

const renderWithStore = (powerSavingLocationEnabled = false) => {
const store = createStore();
store.set(powerSavingLocationEnabledAtom, powerSavingLocationEnabled);

const screen = render(
<Provider store={store}>
<BatterySettingsScreen />
</Provider>
);

return { ...screen, store };
};

describe('BatterySettingsScreen', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('省電力測位モードをONにするとatomとストレージへ保存される', () => {
const { getByLabelText, store } = renderWithStore(false);

fireEvent.press(getByLabelText('powerSavingLocationTitle'));

expect(store.get(powerSavingLocationEnabledAtom)).toBe(true);
expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe(
'true'
);
});

it('省電力測位モードをOFFにするとatomとストレージへ保存される', () => {
const { getByLabelText, store } = renderWithStore(true);

fireEvent.press(getByLabelText('powerSavingLocationTitle'));

expect(store.get(powerSavingLocationEnabledAtom)).toBe(false);
expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe(
'false'
);
});

it('ストレージへの保存に失敗した場合はatom状態をロールバックしエラーを通知する', () => {
const setSpy = jest.spyOn(storage, 'set').mockImplementationOnce(() => {
throw new Error('storage failure');
});
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});

const { getByLabelText, store } = renderWithStore(false);

fireEvent.press(getByLabelText('powerSavingLocationTitle'));

// 保存失敗後にロールバックされる(MMKVは同期APIのため即時)
expect(store.get(powerSavingLocationEnabledAtom)).toBe(false);

// エラーログとユーザーへのアラート表示を検証
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to save power saving location setting',
expect.any(Error)
);
expect(alertSpy).toHaveBeenCalledWith(
'errorTitle',
'failedToSavePreference'
);

setSpy.mockRestore();
consoleErrorSpy.mockRestore();
alertSpy.mockRestore();
});
});
Loading
Loading