diff --git a/assets/translations/en.json b/assets/translations/en.json index d755ed588..6bf1c245d 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -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😊.", diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 1447780ba..335f26277 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -125,6 +125,9 @@ "ttsAlertText": "自動アナウンス機能はマナーモードもしくはインターネット未接続状態では動作いたしません。ご注意ください。", "bgTtsAlertText": "バックグラウンド音声機能を有効にすると、マナーモード設定中でも音声が流れます。特に電車内ではご注意ください。", "bgTtsAppClipAlertText": "バックグラウンド音声機能をご利用になるには完全版をダウンロードしてください。", + "ttsFeatureDisabledText": "自動アナウンス機能は現在一時的にご利用いただけません。最新の稼働状況は下記のサービスステータスをご確認ください。", + "serviceStatus": "サービスステータス", + "failedToOpenLink": "リンクを開けませんでした。しばらく経ってからもう一度お試しください。", "weekdayNotice": "平日通過の駅が含まれています。該当の駅は通過表示となります。ご注意ください。", "holidayNotice": "休日通過の駅が含まれています。該当の駅は通過表示となります。ご注意ください。", "shareNotice": "画面を長押しすると簡単にシェアができます😊", diff --git a/docs/state-management.md b/docs/state-management.md index 1288dff9c..c6d03894a 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -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 別)。フックを条件分岐で呼ぶことは できないが、ホストコンポーネントのマウント自体を条件にすれば安全に止められる diff --git a/src/components/FxTTS.test.tsx b/src/components/FxTTS.test.tsx new file mode 100644 index 000000000..bb8e40cbd --- /dev/null +++ b/src/components/FxTTS.test.tsx @@ -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( + + + + ); +}; + +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(); + }); +}); diff --git a/src/components/FxTTS.tsx b/src/components/FxTTS.tsx new file mode 100644 index 000000000..e617927e8 --- /dev/null +++ b/src/components/FxTTS.tsx @@ -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 ? : null; +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 58d3e9813..e66792c94 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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'; diff --git a/src/hooks/useTTSFeatureEnabled.ts b/src/hooks/useTTSFeatureEnabled.ts new file mode 100644 index 000000000..df7ae7edc --- /dev/null +++ b/src/hooks/useTTSFeatureEnabled.ts @@ -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); diff --git a/src/lib/remoteConfig.test.ts b/src/lib/remoteConfig.test.ts index c97d57a76..05445bdf1 100644 --- a/src/lib/remoteConfig.test.ts +++ b/src/lib/remoteConfig.test.ts @@ -5,8 +5,10 @@ import { getMaxPermitAccuracy, isEtaAssistEnabled, isForceNotArrivedOnLowAccuracyEnabled, + isTTSFeatureEnabled, resetRemoteConfigCache, setupRemoteConfig, + subscribeRemoteConfig, } from './remoteConfig'; jest.mock('./workerApi', () => ({ @@ -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({ diff --git a/src/lib/remoteConfig.ts b/src/lib/remoteConfig.ts index 88bf5fe49..c62d5b500 100644 --- a/src/lib/remoteConfig.ts +++ b/src/lib/remoteConfig.ts @@ -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 = { @@ -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; }; // 精度超過時に到着判定を未到着へ強制する機能のフォールバック既定値。 @@ -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 => { @@ -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 => { @@ -62,6 +90,8 @@ export const resetRemoteConfigCache = (): void => { cachedEtaAssistEnabled = null; cachedEtaFallbackArrivalConfirmMarginSec = null; cachedEtaFallbackMaxDurationMin = null; + cachedTTSEnabled = null; + notifyRemoteConfigListeners(); }; // 起動時に一度だけ Worker からリモート設定を取得しキャッシュへ格納する。 @@ -97,6 +127,10 @@ export const setupRemoteConfig = async (): Promise => { if (maxDurationMin != null) { cachedEtaFallbackMaxDurationMin = maxDurationMin; } + if (typeof data.tts_enabled === 'boolean') { + cachedTTSEnabled = data.tts_enabled; + } + notifyRemoteConfigListeners(); }; // 最大許容精度(m)を同期的に取得する。setupRemoteConfig 完了後は取得済みの @@ -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; +}; diff --git a/src/screens/Main.tsx b/src/screens/Main.tsx index e6b2f2dc0..405d1162d 100644 --- a/src/screens/Main.tsx +++ b/src/screens/Main.tsx @@ -27,6 +27,7 @@ import { } from '~/@types/graphql'; import AndroidPictureInPictureView from '~/components/AndroidPictureInPictureView'; import DevOverlay from '~/components/DevOverlay'; +import { FxTTS } from '~/components/FxTTS'; import Header from '~/components/Header'; import { SelectBoundModal } from '~/components/SelectBoundModal'; import { STORAGE_KEYS } from '~/constants'; @@ -52,7 +53,6 @@ import { useTelemetrySender, useTransferLines, useTransitionHeaderState, - useTTS, useTypeWillChange, useUpdateBottomState, useUpdateLiveActivities, @@ -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, @@ -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 ? : null; -}; const FxUpdateLiveActivitiesInner: React.FC = () => { useUpdateLiveActivities(); return null; diff --git a/src/screens/TTSSettings.test.tsx b/src/screens/TTSSettings.test.tsx index 3f9851ff8..cdc6abf83 100644 --- a/src/screens/TTSSettings.test.tsx +++ b/src/screens/TTSSettings.test.tsx @@ -1,6 +1,8 @@ -import { fireEvent, render } from '@testing-library/react-native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { createStore, Provider } from 'jotai'; -import { STORAGE_KEYS } from '~/constants'; +import { Alert, Linking } from 'react-native'; +import { STATUS_URL, STORAGE_KEYS } from '~/constants'; +import { isTTSFeatureEnabled } from '~/lib/remoteConfig'; import { storage } from '~/lib/storage'; import speechState, { type StationState } from '~/store/atoms/speech'; import TTSSettingsScreen from './TTSSettings'; @@ -9,6 +11,13 @@ jest.mock('~/utils/isDevApp', () => ({ isDevApp: false, })); +jest.mock('~/lib/remoteConfig', () => ({ + isTTSFeatureEnabled: jest.fn(() => true), + subscribeRemoteConfig: jest.fn(() => () => {}), +})); + +const mockedIsTTSFeatureEnabled = jest.mocked(isTTSFeatureEnabled); + jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ goBack: jest.fn(), @@ -48,6 +57,10 @@ const renderWithSpeechState = (speech: Partial) => { }; describe('TTSSettingsScreen', () => { + beforeEach(() => { + mockedIsTTSFeatureEnabled.mockReturnValue(true); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -89,4 +102,81 @@ describe('TTSSettingsScreen', () => { expect(store.get(speechState).ttsEnabledLanguages).toEqual(['JA', 'EN']); expect(storage.contains(STORAGE_KEYS.TTS_ENABLED_LANGUAGES)).toBe(false); }); + + describe('feature flag(tts_enabled)がfalseの場合', () => { + beforeEach(() => { + mockedIsTTSFeatureEnabled.mockReturnValue(false); + }); + + it('TTSトグルをOFF表示かつ無効化し設定を変更できない', () => { + const { getByLabelText, store } = renderWithSpeechState({ + enabled: true, + }); + + const ttsToggle = getByLabelText('toEnabled'); + expect(ttsToggle.props.accessibilityState).toMatchObject({ + checked: false, + disabled: true, + }); + + fireEvent.press(ttsToggle); + + // 保存済みのユーザー設定自体は破棄しない(フラグ復帰時に元へ戻る) + expect(store.get(speechState).enabled).toBe(true); + expect(storage.contains(STORAGE_KEYS.SPEECH_ENABLED)).toBe(false); + }); + + it('バックグラウンド再生・言語トグルも無効化される', () => { + const { getByLabelText } = renderWithSpeechState({ + enabled: true, + backgroundEnabled: true, + }); + + expect( + getByLabelText('autoAnnounceBackgroundTitle').props.accessibilityState + ).toMatchObject({ checked: false, disabled: true }); + expect(getByLabelText('japanese').props.accessibilityState).toMatchObject( + { disabled: true } + ); + expect(getByLabelText('english').props.accessibilityState).toMatchObject({ + disabled: true, + }); + }); + + it('利用不可の説明とサービスステータスリンクを表示しタップでブラウザを開く', () => { + const openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); + + const { getByText } = renderWithSpeechState({ enabled: true }); + + expect(getByText('ttsFeatureDisabledText')).toBeTruthy(); + + fireEvent.press(getByText('serviceStatus')); + + expect(openURLSpy).toHaveBeenCalledWith(STATUS_URL); + }); + + it('サービスステータスリンクを開けなかった場合はエラーAlertを表示する', async () => { + jest + .spyOn(Linking, 'openURL') + .mockRejectedValue(new Error('cannot open')); + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { getByText } = renderWithSpeechState({ enabled: true }); + + fireEvent.press(getByText('serviceStatus')); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith('errorTitle', 'failedToOpenLink'); + }); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + it('feature flagがtrueの場合はサービスステータスリンクを表示しない', () => { + const { queryByText } = renderWithSpeechState({ enabled: true }); + + expect(queryByText('ttsFeatureDisabledText')).toBeNull(); + expect(queryByText('serviceStatus')).toBeNull(); + }); }); diff --git a/src/screens/TTSSettings.tsx b/src/screens/TTSSettings.tsx index 57e2d81d5..680bee4c9 100644 --- a/src/screens/TTSSettings.tsx +++ b/src/screens/TTSSettings.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Alert, type GestureResponderEvent, + Linking, Pressable, Animated as RNAnimated, StyleSheet, @@ -15,10 +16,11 @@ import FooterTabBar from '~/components/FooterTabBar'; import { SettingsHeader } from '~/components/SettingsHeader'; import { StatePanel } from '~/components/ToggleButton'; import Typography from '~/components/Typography'; +import { useTTSFeatureEnabled } from '~/hooks/useTTSFeatureEnabled'; import speechState from '~/store/atoms/speech'; import { isLEDThemeAtom } from '~/store/atoms/theme'; import { translate } from '~/translation'; -import { STORAGE_KEYS } from '../constants'; +import { STATUS_URL, STORAGE_KEYS } from '../constants'; import { storage } from '../lib/storage'; type SettingItem = { @@ -100,13 +102,17 @@ const ListFooter = ({ ttsLanguageItems, ttsEnabledLanguages, speechEnabled, + ttsFeatureEnabled, onToggleTTSLanguage, + onPressServiceStatus, onPressOK, }: { ttsLanguageItems: TTSLanguageSettingItem[]; ttsEnabledLanguages: TTSLanguage[]; speechEnabled: boolean; + ttsFeatureEnabled: boolean; onToggleTTSLanguage: (language: TTSLanguage) => void; + onPressServiceStatus: () => void; onPressOK: () => void; }) => ( <> @@ -140,6 +146,31 @@ const ListFooter = ({ > {translate('requireJapaneseOrEnglish')} + {!ttsFeatureEnabled ? ( + <> + + {translate('ttsFeatureDisabledText')} + + + {translate('serviceStatus')} + + + ) : null}