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
3 changes: 3 additions & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@
"ttsAlertText": "The automatic announcement feature does not work in silent mode or when the Internet is not connected. Please be careful.",
"bgTtsAlertText": "When the background audio feature is enabled, sound will play even when silent mode is on. Please be especially mindful when on a train.",
"bgTtsAppClipAlertText": "To use the background audio feature, please download the full version.",
"ttsFeatureDisabledText": "The automatic announcement feature is temporarily unavailable. Please check the Service Status page below for the latest availability.",
"serviceStatus": "Service Status",
"failedToOpenLink": "Failed to open the link. Please try again later.",
"weekdayNotice": "Weekday transit stations are included. The corresponding station will be displayed as a passage. Please be careful.",
"holidayNotice": "Includes holiday transit stations. The corresponding station will be displayed as a passage. Please Be careful.",
"shareNotice": "Press and hold the screen for easy sharing😊.",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@
"ttsAlertText": "自動アナウンス機能はマナーモードもしくはインターネット未接続状態では動作いたしません。ご注意ください。",
"bgTtsAlertText": "バックグラウンド音声機能を有効にすると、マナーモード設定中でも音声が流れます。特に電車内ではご注意ください。",
"bgTtsAppClipAlertText": "バックグラウンド音声機能をご利用になるには完全版をダウンロードしてください。",
"ttsFeatureDisabledText": "自動アナウンス機能は現在一時的にご利用いただけません。最新の稼働状況は下記のサービスステータスをご確認ください。",
"serviceStatus": "サービスステータス",
"failedToOpenLink": "リンクを開けませんでした。しばらく経ってからもう一度お試しください。",
"weekdayNotice": "平日通過の駅が含まれています。該当の駅は通過表示となります。ご注意ください。",
"holidayNotice": "休日通過の駅が含まれています。該当の駅は通過表示となります。ご注意ください。",
"shareNotice": "画面を長押しすると簡単にシェアができます😊",
Expand Down
4 changes: 3 additions & 1 deletion docs/state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ const active = useAtomValue(pictureInPictureActiveAtom);
プロファイル時に React DevTools 上でフック単位の再レンダー回数・コストを
計測可能にするため。新しい常駐フックを足すときも同じ形式で追加する
- 設定・プラットフォームで不要になるフックは条件付きマウントで丸ごと止める。
実例: `FxTTS` (TTS 有効時のみ)、`FxUpdateLiveActivities` (iOS のみ)、
実例: `FxTTS` (ユーザー設定と Remote Config キルスイッチの両方が有効なときのみ。
単体テストできるよう本体は `src/components/FxTTS.tsx` に定義)、
`FxUpdateLiveActivities` (iOS のみ)、
`Permitted.tsx` のウェアラブル連携 (OS 別)。フックを条件分岐で呼ぶことは
できないが、ホストコンポーネントのマウント自体を条件にすれば安全に止められる

Expand Down
110 changes: 110 additions & 0 deletions src/components/FxTTS.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { act, render } from '@testing-library/react-native';
import { createStore, Provider } from 'jotai';
import { useTTS } from '~/hooks/useTTS';
import { isTTSFeatureEnabled } from '~/lib/remoteConfig';
import speechState from '~/store/atoms/speech';
import { FxTTS } from './FxTTS';

jest.mock('~/utils/isDevApp', () => ({
isDevApp: false,
}));

jest.mock('~/hooks/useTTS', () => ({
useTTS: jest.fn(),
}));

// useTTSFeatureEnabled(useSyncExternalStore) が購読するリスナーを捕捉し、
// Remote Config 取得完了(キャッシュ更新)をテストから擬似的に発火できるようにする。
const mockRemoteConfigListeners = new Set<() => void>();
jest.mock('~/lib/remoteConfig', () => ({
isTTSFeatureEnabled: jest.fn(() => true),
subscribeRemoteConfig: jest.fn((listener: () => void) => {
mockRemoteConfigListeners.add(listener);
return () => {
mockRemoteConfigListeners.delete(listener);
};
}),
}));

const mockedIsTTSFeatureEnabled = jest.mocked(isTTSFeatureEnabled);
const mockedUseTTS = jest.mocked(useTTS);

const emitRemoteConfigUpdate = () => {
act(() => {
for (const listener of mockRemoteConfigListeners) {
listener();
}
});
};

const renderFxTTS = (enabled: boolean) => {
const store = createStore();
store.set(speechState, {
enabled,
backgroundEnabled: false,
ttsEnabledLanguages: ['JA', 'EN'],
monetizedPlanEnabled: false,
});

return render(
<Provider store={store}>
<FxTTS />
</Provider>
);
};

describe('FxTTS', () => {
afterEach(() => {
jest.clearAllMocks();
mockRemoteConfigListeners.clear();
});

it.each`
userEnabled | featureEnabled | shouldMount
${true} | ${true} | ${true}
${true} | ${false} | ${false}
${false} | ${true} | ${false}
${false} | ${false} | ${false}
`(
'ユーザー設定=$userEnabled / Remote Config=$featureEnabled のときマウント=$shouldMount',
({ userEnabled, featureEnabled, shouldMount }) => {
mockedIsTTSFeatureEnabled.mockReturnValue(featureEnabled);

renderFxTTS(userEnabled);

if (shouldMount) {
expect(mockedUseTTS).toHaveBeenCalled();
} else {
expect(mockedUseTTS).not.toHaveBeenCalled();
}
}
);

it('マウント後にRemote Configで無効化されるとアンマウントされる', () => {
mockedIsTTSFeatureEnabled.mockReturnValue(true);

renderFxTTS(true);

expect(mockedUseTTS).toHaveBeenCalled();
mockedUseTTS.mockClear();

// 起動時の非同期取得が後から tts_enabled=false を返したケースを再現する
mockedIsTTSFeatureEnabled.mockReturnValue(false);
emitRemoteConfigUpdate();

expect(mockedUseTTS).not.toHaveBeenCalled();
});

it('マウント後にRemote Configで有効化されるとマウントされる', () => {
mockedIsTTSFeatureEnabled.mockReturnValue(false);

renderFxTTS(true);

expect(mockedUseTTS).not.toHaveBeenCalled();

mockedIsTTSFeatureEnabled.mockReturnValue(true);
emitRemoteConfigUpdate();

expect(mockedUseTTS).toHaveBeenCalled();
});
});
21 changes: 21 additions & 0 deletions src/components/FxTTS.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useAtomValue } from 'jotai';
import type React from 'react';
import { useTTS } from '~/hooks/useTTS';
import { useTTSFeatureEnabled } from '~/hooks/useTTSFeatureEnabled';
import speechState from '~/store/atoms/speech';

const FxTTSInner: React.FC = () => {
useTTS();
return null;
};

// TTS無効時は useTTSText のテキスト構築(毎tick約19ms)ごとスキップする。
// 有効化時にマウントされ直し、行先選択直後と同じ初回発話抑止から始まる。
// Remote Config のキルスイッチ(tts_enabled=false)時は、ユーザー設定が有効でも
// 再生を止める。設定画面のトグル表示(無効化)と実挙動を一致させるため。
// 起動時の非同期取得完了後に false が届いたケースでも購読経由で確実にアンマウントする。
export const FxTTS: React.FC = () => {
const { enabled } = useAtomValue(speechState);
const ttsFeatureEnabled = useTTSFeatureEnabled();
return enabled && ttsFeatureEnabled ? <FxTTSInner /> : null;
};
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export { useTransferLinesFromStation } from './useTransferLinesFromStation';
export { useTransitionHeaderState } from './useTransitionHeaderState';
export { useTTS } from './useTTS';
export { useTTSCache } from './useTTSCache';
export { useTTSFeatureEnabled } from './useTTSFeatureEnabled';
export { useTTSText } from './useTTSText';
export { useTypeWillChange } from './useTypeWillChange';
export { useUnderMaintenance } from './useUnderMaintenance';
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/useTTSFeatureEnabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useSyncExternalStore } from 'react';
import { isTTSFeatureEnabled, subscribeRemoteConfig } from '~/lib/remoteConfig';

// TTS機能キルスイッチ(tts_enabled)のリアクティブ版。setupRemoteConfig は起動時に
// 非同期で完了するため、同期読みだけではコールドスタート時にフォールバック(true)で
// 描画された後、false 到着時の再レンダーが保証されない。キャッシュ更新を購読して
// 確実に再評価させる。
export const useTTSFeatureEnabled = (): boolean =>
useSyncExternalStore(subscribeRemoteConfig, isTTSFeatureEnabled);
58 changes: 58 additions & 0 deletions src/lib/remoteConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
getMaxPermitAccuracy,
isEtaAssistEnabled,
isForceNotArrivedOnLowAccuracyEnabled,
isTTSFeatureEnabled,
resetRemoteConfigCache,
setupRemoteConfig,
subscribeRemoteConfig,
} from './remoteConfig';

jest.mock('./workerApi', () => ({
Expand Down Expand Up @@ -164,6 +166,62 @@ describe('getEtaFallbackMaxDurationMin', () => {
});
});

describe('isTTSFeatureEnabled(サーバー側キルスイッチ)', () => {
it('falls back to true before setup', () => {
expect(isTTSFeatureEnabled()).toBe(true);
});

it('returns the remote boolean after setup', async () => {
mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false });
await setupRemoteConfig();
expect(isTTSFeatureEnabled()).toBe(false);
});

it('RemoteがONなら有効', async () => {
mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: true });
await setupRemoteConfig();
expect(isTTSFeatureEnabled()).toBe(true);
});

it('falls back to true when the boolean is missing', async () => {
mockRemoteConfig({ max_permit_accuracy: 1500 });
await setupRemoteConfig();
expect(isTTSFeatureEnabled()).toBe(true);
});

it('falls back to true when fetching remote config fails', async () => {
mockRemoteConfig({}, false);
await expect(setupRemoteConfig()).rejects.toThrow(
'remote config fetch failed: 503'
);
expect(isTTSFeatureEnabled()).toBe(true);
});
});

describe('subscribeRemoteConfig(キャッシュ更新の購読)', () => {
it('setupRemoteConfig 完了時にリスナーへ通知される', async () => {
const listener = jest.fn();
const unsubscribe = subscribeRemoteConfig(listener);

mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false });
await setupRemoteConfig();

expect(listener).toHaveBeenCalled();
unsubscribe();
});

it('購読解除後は通知されない', async () => {
const listener = jest.fn();
const unsubscribe = subscribeRemoteConfig(listener);
unsubscribe();

mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false });
await setupRemoteConfig();

expect(listener).not.toHaveBeenCalled();
});
});

describe('setupRemoteConfig', () => {
it('fetches the remote config endpoint', async () => {
mockRemoteConfig({
Expand Down
44 changes: 44 additions & 0 deletions src/lib/remoteConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export const REMOTE_CONFIG_KEYS = {
// ETAフォールバックを継続してよい最大時間(分)。GPS喪失がこれを超えて続く場合は
// フォールバックを打ち切り、不確実な推定に依存し続けないようにする。
ETA_FALLBACK_MAX_DURATION_MIN: 'eta_fallback_max_duration_min',
// TTS(自動アナウンス)機能の有効/無効。false のとき設定画面のTTSトグルを無効化し、
// 音声合成バックエンド障害時などにサーバー側から機能を止められるようにする。
TTS_ENABLED: 'tts_enabled',
} as const;

type RemoteConfigResponse = {
Expand All @@ -24,6 +27,7 @@ type RemoteConfigResponse = {
eta_assist_enabled?: boolean;
eta_fallback_arrival_confirm_margin_sec?: number;
eta_fallback_max_duration_min?: number;
tts_enabled?: boolean;
};

// 精度超過時に到着判定を未到着へ強制する機能のフォールバック既定値。
Expand All @@ -37,6 +41,10 @@ const ETA_FALLBACK_ARRIVAL_CONFIRM_MARGIN_SEC_FALLBACK = 30;
// ETAフォールバックを継続してよい最大時間(分)のフォールバック既定値。
const ETA_FALLBACK_MAX_DURATION_MIN_FALLBACK = 30;

// TTS機能のフォールバック既定値。キルスイッチ用途のため、未配信・取得失敗時は
// 既存挙動(利用可能)を維持する true をフォールバックとする。
const TTS_ENABLED_FALLBACK = true;

// リモート設定の数値は「有限かつ正」のみ受理する(0・負値・非数はフォールバックへ倒す)。
// 真偽値や配列は Number() で 1 や 5 に化けるため、number 型に限定してから検証する。
const parsePositiveFiniteNumber = (value: unknown): number | null => {
Expand All @@ -54,6 +62,26 @@ let cachedForceNotArrivedEnabled: boolean | null = null;
let cachedEtaAssistEnabled: boolean | null = null;
let cachedEtaFallbackArrivalConfirmMarginSec: number | null = null;
let cachedEtaFallbackMaxDurationMin: number | null = null;
let cachedTTSEnabled: boolean | null = null;

// setupRemoteConfig は起動時に非同期で完了するため、初回レンダー後にキャッシュが
// 更新されても React は再レンダーしない。UI(FxTTS・設定画面)が useSyncExternalStore
// 経由でキャッシュ更新へ追従できるよう、変更通知のリスナーを提供する。
const remoteConfigListeners = new Set<() => void>();

// キャッシュ更新をUIへ購読させる。戻り値は購読解除関数(useSyncExternalStore 互換)。
export const subscribeRemoteConfig = (listener: () => void): (() => void) => {
remoteConfigListeners.add(listener);
return () => {
remoteConfigListeners.delete(listener);
};
};

const notifyRemoteConfigListeners = (): void => {
for (const listener of remoteConfigListeners) {
listener();
}
};

// テスト用および値の再取得時にキャッシュを破棄する。
export const resetRemoteConfigCache = (): void => {
Expand All @@ -62,6 +90,8 @@ export const resetRemoteConfigCache = (): void => {
cachedEtaAssistEnabled = null;
cachedEtaFallbackArrivalConfirmMarginSec = null;
cachedEtaFallbackMaxDurationMin = null;
cachedTTSEnabled = null;
notifyRemoteConfigListeners();
};

// 起動時に一度だけ Worker からリモート設定を取得しキャッシュへ格納する。
Expand Down Expand Up @@ -97,6 +127,10 @@ export const setupRemoteConfig = async (): Promise<void> => {
if (maxDurationMin != null) {
cachedEtaFallbackMaxDurationMin = maxDurationMin;
}
if (typeof data.tts_enabled === 'boolean') {
cachedTTSEnabled = data.tts_enabled;
}
notifyRemoteConfigListeners();
};

// 最大許容精度(m)を同期的に取得する。setupRemoteConfig 完了後は取得済みの
Expand Down Expand Up @@ -150,3 +184,13 @@ export const getEtaFallbackMaxDurationMin = (): number => {
}
return ETA_FALLBACK_MAX_DURATION_MIN_FALLBACK;
};

// TTS(自動アナウンス)機能の有効/無効を同期的に取得する。setupRemoteConfig 完了後は
// 取得済みのリモート値を、未設定・取得失敗時はフォールバック(true=利用可能)を返す。
// false のとき設定画面のTTSトグルは無効化される(サーバー側キルスイッチ)。
export const isTTSFeatureEnabled = (): boolean => {
if (cachedTTSEnabled != null) {
return cachedTTSEnabled;
}
return TTS_ENABLED_FALLBACK;
};
13 changes: 1 addition & 12 deletions src/screens/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '~/@types/graphql';
import AndroidPictureInPictureView from '~/components/AndroidPictureInPictureView';
import DevOverlay from '~/components/DevOverlay';
import { FxTTS } from '~/components/FxTTS';
Comment thread
TinyKitten marked this conversation as resolved.
import Header from '~/components/Header';
import { SelectBoundModal } from '~/components/SelectBoundModal';
import { STORAGE_KEYS } from '~/constants';
Expand All @@ -52,7 +53,6 @@ import {
useTelemetrySender,
useTransferLines,
useTransitionHeaderState,
useTTS,
useTypeWillChange,
useUpdateBottomState,
useUpdateLiveActivities,
Expand Down Expand Up @@ -83,7 +83,6 @@ import navigationState, {
leftStationsAtom,
} from '../store/atoms/navigation';
import { pictureInPictureActiveAtom } from '../store/atoms/pictureInPicture';
import speechState from '../store/atoms/speech';
import stationState, {
arrivedAtom,
selectedDirectionAtom,
Expand Down Expand Up @@ -167,16 +166,6 @@ const FxStartBackgroundLocationUpdates: React.FC = () => {
useStartBackgroundLocationUpdates();
return null;
};
const FxTTSInner: React.FC = () => {
useTTS();
return null;
};
// TTS無効時は useTTSText のテキスト構築(毎tick約19ms)ごとスキップする。
// 有効化時にマウントされ直し、行先選択直後と同じ初回発話抑止から始まる。
const FxTTS: React.FC = () => {
const { enabled } = useAtomValue(speechState);
return enabled ? <FxTTSInner /> : null;
};
const FxUpdateLiveActivitiesInner: React.FC = () => {
useUpdateLiveActivities();
return null;
Expand Down
Loading
Loading