From a50e550db9aef431cdc25fb494d9dca208ea30ed Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sun, 12 Jul 2026 18:37:19 +0900 Subject: [PATCH 1/9] =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=82=BB=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E7=94=BB=E9=9D=A2=E3=81=AB=E4=BA=AC=E7=8E=8B=E3=83=90?= =?UTF-8?q?=E3=82=B9=E3=81=A8=E6=9D=B1=E6=80=A5=E3=83=90=E3=82=B9=E3=81=AE?= =?UTF-8?q?ODPT=E3=83=A9=E3=82=A4=E3=82=BB=E3=83=B3=E3=82=B9=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E8=A8=98=20(#6399)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/translations/en.json | 2 ++ assets/translations/ja.json | 2 ++ src/screens/Licenses.tsx | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/assets/translations/en.json b/assets/translations/en.json index 24015a41a..a8ce71200 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -247,6 +247,8 @@ "kyotoCity": "Kyoto Municipal Transportation Bureau", "yokohamaCity": "Transportation Bureau, City of Yokohama", "seibuBus": "Seibu Bus Co., Ltd.", + "keioBus": "Keio Bus Co., Ltd.", + "tokyuBus": "Tokyu Bus Corporation", "hakodateCity": "Transportation Department, Hakodate City Enterprise Bureau", "odptBasicLicense": "Public Transportation Open Data Basic License", "odptGtfs": "GTFS (Public Transportation Open Data Center)", diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 9850a0ed4..454aec181 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -248,6 +248,8 @@ "kyotoCity": "京都市交通局", "yokohamaCity": "横浜市交通局", "seibuBus": "西武バス", + "keioBus": "京王バス", + "tokyuBus": "東急バス", "hakodateCity": "函館市企業局交通部", "odptBasicLicense": "公共交通オープンデータ基本ライセンス", "odptGtfs": "GTFS (公共交通オープンデータセンター)", diff --git a/src/screens/Licenses.tsx b/src/screens/Licenses.tsx index 4c80a05c8..19c225ccd 100644 --- a/src/screens/Licenses.tsx +++ b/src/screens/Licenses.tsx @@ -31,6 +31,8 @@ type LicenseId = | 'kyoto_city' | 'yokohama_city' | 'seibu_bus' + | 'keio_bus' + | 'tokyu_bus' | 'hakodate_city' | 'roboto' | 'other_oss'; @@ -211,6 +213,24 @@ const Licenses: React.FC = () => { licenseUrl: ODPT_BASIC_LICENSE_URL, devOnly: false, }, + { + id: 'keio_bus', + title: translate('keioBus'), + icon: '🚌', + href: 'https://www.keio-bus.com/', + license: translate('odptBasicLicense'), + licenseUrl: ODPT_BASIC_LICENSE_URL, + devOnly: false, + }, + { + id: 'tokyu_bus', + title: translate('tokyuBus'), + icon: '🚌', + href: 'https://www.tokyubus.co.jp/', + license: translate('odptBasicLicense'), + licenseUrl: ODPT_BASIC_LICENSE_URL, + devOnly: false, + }, { id: 'hakodate_city', title: translate('hakodateCity'), From b5433e2fb2bee88e2ac4117bd130304b3acee14e Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sun, 12 Jul 2026 19:13:54 +0900 Subject: [PATCH 2/9] =?UTF-8?q?v10.9.1=20=E3=82=92=E3=83=AA=E3=83=AA?= =?UTF-8?q?=E3=83=BC=E3=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle | 8 ++--- app.config.ts | 6 ++-- ios/TrainLCD.xcodeproj/project.pbxproj | 44 +++++++++++++------------- package-lock.json | 4 +-- package.json | 2 +- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 17bce97e8..bf613294e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -143,13 +143,13 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000529 - versionName "10.9.0" + versionCode 100000530 + versionName "10.9.1" } prod { dimension "environment" - versionCode 100000529 - versionName "10.9.0" + versionCode 100000530 + versionName "10.9.1" } } } diff --git a/app.config.ts b/app.config.ts index c4f701f1a..83a509702 100644 --- a/app.config.ts +++ b/app.config.ts @@ -3,7 +3,7 @@ const IS_DEV = process.env.APP_VARIANT === 'dev'; export default { name: 'TrainLCD', slug: 'trainlcd', - version: '10.9.0', + version: '10.9.1', plugins: [ 'expo-image', 'expo-font', @@ -52,7 +52,7 @@ export default { }, }, ios: { - buildNumber: '2742', + buildNumber: '2743', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', supportsTablet: true, @@ -60,7 +60,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000529, + versionCode: 100000530, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 2d6ada917..969554b86 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2407,7 +2407,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2446,7 +2446,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2505,7 +2505,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2561,7 +2561,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.9.0; + MARKETING_VERSION = 10.9.1; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; @@ -2611,7 +2611,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2663,7 +2663,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.9.0; + MARKETING_VERSION = 10.9.1; MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; @@ -2690,7 +2690,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2940,7 +2940,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2991,7 +2991,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3042,7 +3042,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3100,7 +3100,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3151,7 +3151,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3208,7 +3208,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3256,7 +3256,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3307,7 +3307,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3526,7 +3526,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3582,7 +3582,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3632,7 +3632,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3656,7 +3656,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.9.0; + MARKETING_VERSION = 10.9.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3690,7 +3690,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2742; + CURRENT_PROJECT_VERSION = 2743; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3710,7 +3710,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.9.0; + MARKETING_VERSION = 10.9.1; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PODS_ROOT = "${SRCROOT}/Pods"; diff --git a/package-lock.json b/package-lock.json index b4c648762..47e24c03d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trainlcd", - "version": "10.9.0", + "version": "10.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trainlcd", - "version": "10.9.0", + "version": "10.9.1", "hasInstallScript": true, "dependencies": { "@expo-google-fonts/roboto": "^0.2.3", diff --git a/package.json b/package.json index b5be3bfe9..2c569a140 100644 --- a/package.json +++ b/package.json @@ -167,5 +167,5 @@ } }, "name": "trainlcd", - "version": "10.9.0" + "version": "10.9.1" } From 54d3de7e909f80a331346eb0a2dab7f332e69dfc Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sun, 12 Jul 2026 22:01:39 +0900 Subject: [PATCH 3/9] =?UTF-8?q?TTS=20=E9=80=81=E4=BF=A1=E5=89=8D=E3=81=AB?= =?UTF-8?q?=E8=8B=B1=E8=AA=9E=E9=A7=85=E5=90=8D=E3=81=AE=E3=83=9E=E3=82=AF?= =?UTF-8?q?=E3=83=AD=E3=83=B3=E3=82=92=E9=99=A4=E5=8E=BB=E3=81=97=E3=81=A6?= =?UTF-8?q?=E8=AA=A4=E8=AA=AD=E3=82=92=E9=98=B2=E3=81=90=20(#6407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 --- src/utils/ttsSpeechFetcher.test.ts | 71 ++++++++++++++++++++++++++++++ src/utils/ttsSpeechFetcher.ts | 24 +++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/utils/ttsSpeechFetcher.test.ts b/src/utils/ttsSpeechFetcher.test.ts index 34f6719ad..5f4a4b5b8 100644 --- a/src/utils/ttsSpeechFetcher.test.ts +++ b/src/utils/ttsSpeechFetcher.test.ts @@ -188,6 +188,77 @@ describe('fetchSpeechAudio', () => { expect(body.data.ssmlEn).toBe('test'); }); + it('英語 SSML の可視テキストからマクロンを除去して送信する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-127', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Kiryū and Ōhirashita', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.data.ssmlEn).toBe('Kiryu and Ohirashita'); + }); + + it('マクロン除去時に SSML タグと IPA 発音記号 (ph 属性) は保持する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-128', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'the Tōkyō Line', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + // ph 属性内の IPA (長音 ː) はそのまま、可視テキストの Tōkyō のみ Tokyo になる + expect(body.data.ssmlEn).toBe( + 'the Tokyo Line' + ); + }); + + it('マクロン有無で同じキャッシュを共有する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-129', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + const first = await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Tōkyō', + }); + const second = await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Tokyo', + }); + + expect(first).toEqual(second); + // 2 回目はキャッシュヒットで fetch は 1 回だけ + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('PCM MIME の場合は WAV として保存する', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/src/utils/ttsSpeechFetcher.ts b/src/utils/ttsSpeechFetcher.ts index 012ea3e3d..a80d9ca9f 100644 --- a/src/utils/ttsSpeechFetcher.ts +++ b/src/utils/ttsSpeechFetcher.ts @@ -106,6 +106,22 @@ const normalizeOptional = (val: string | undefined): string => { return trimmed.length > 0 ? trimmed : ''; }; +// ヘボン式ローマ字の長音符(マクロン: Ā ā Ē ē Ī ī Ō ō Ū ū)を素の母音へ落とす。 +// NFD で母音と結合マクロン(U+0304)へ分解し、マクロンだけ取り除いて NFC に戻す。 +// 既存の useIsDifferentStationName と同じ手法。 +const COMBINING_MACRON = String.fromCharCode(0x0304); +const stripMacrons = (text: string): string => + text.normalize('NFD').replaceAll(COMBINING_MACRON, '').normalize('NFC'); + +// TTS API(Azure Speech)はマクロン付き母音を正しく読めず、英語駅名を誤読・無音化 +// することがあるため、SSML の可視テキストからマクロンを除去する。 +// タグ(<...>)は属性ごと保護し、タグ外のテキストだけを対象にすることで、 +// の IPA 発音記号など読みの正確さに関わる値は温存する。 +const stripMacronsFromSsmlText = (ssml: string): string => + ssml.replace(/<[^>]*>|[^<]+/g, (token) => + token.startsWith('<') ? token : stripMacrons(token) + ); + const buildCacheKey = (opts: FetchSpeechOptions): string => `${opts.textJa}\0${opts.textEn}\0${normalizeOptional(opts.jaVoiceName)}\0${normalizeOptional(opts.enVoiceName)}`; @@ -136,7 +152,11 @@ export const fetchSpeechAudio = async ( return null; } - const cacheKey = buildCacheKey(options); + // TTS API はマクロン付き英語駅名を誤読・無音化することがあるため、送信前に + // 英語 SSML の可視テキストからマクロンを除去する(日本語側は元々マクロンを含まない)。 + const sanitizedTextEn = stripMacronsFromSsmlText(textEn); + + const cacheKey = buildCacheKey({ ...options, textEn: sanitizedTextEn }); const cached = fetchCache.get(cacheKey); if (cached) { return cached; @@ -148,7 +168,7 @@ export const fetchSpeechAudio = async ( const reqBody = { data: { ssmlJa: `${textJa.trim()}`, - ssmlEn: `${textEn.trim()}`, + ssmlEn: `${sanitizedTextEn.trim()}`, ...(normalizedJaVoiceName ? { jaVoiceName: normalizedJaVoiceName } : {}), ...(normalizedEnVoiceName ? { enVoiceName: normalizedEnVoiceName } : {}), }, From 151d7f4186936aae8421ce5a3b70804958a677d5 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sun, 12 Jul 2026 22:10:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?=E3=82=AA=E3=83=BC=E3=83=88=E3=83=A2?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=A7=E7=B5=82=E7=82=B9=E5=88=B0=E7=9D=80?= =?UTF-8?q?=E5=BE=8C=E3=81=AF1=E5=88=86=E5=81=9C=E8=BB=8A=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=81=8B=E3=82=89=E6=96=B9=E9=9D=A2=E3=82=92=E9=80=86?= =?UTF-8?q?=E8=BB=A2=E3=81=97=E3=81=A6=E6=8A=98=E3=82=8A=E8=BF=94=E3=81=99?= =?UTF-8?q?=E3=82=88=E3=81=86=E5=A4=89=E6=9B=B4=20(#6403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_012dhifdRFT6ZRWghyvrDFzk Co-authored-by: Claude --- src/hooks/useSimulationMode.test.tsx | 143 ++++++++++++++++++++++++--- src/hooks/useSimulationMode.ts | 99 +++++++++++++++---- 2 files changed, 211 insertions(+), 31 deletions(-) diff --git a/src/hooks/useSimulationMode.test.tsx b/src/hooks/useSimulationMode.test.tsx index 00815f62c..1feefa82e 100644 --- a/src/hooks/useSimulationMode.test.tsx +++ b/src/hooks/useSimulationMode.test.tsx @@ -11,6 +11,7 @@ import { import { YAMANOTE_LINE_ID } from '~/constants'; import * as useCurrentTrainTypeModule from '~/hooks/useCurrentTrainType'; import { useGraphQLQuery } from '~/hooks/useGraphQLQuery'; +import { useLoopLine } from '~/hooks/useLoopLine'; import { useSimulationMode } from '~/hooks/useSimulationMode'; import { GET_TRAIN_ROUTE } from '~/lib/graphql/queries'; import { store } from '~/store'; @@ -28,6 +29,7 @@ jest.mock('~/store/atoms/station', () => ({ stationAtom: { toString: () => 'stationAtom' }, stationsAtom: { toString: () => 'stationsAtom' }, selectedDirectionAtom: { toString: () => 'selectedDirectionAtom' }, + selectedBoundAtom: { toString: () => 'selectedBoundAtom' }, })); jest.mock('~/store/atoms/navigation', () => ({ @@ -199,6 +201,10 @@ describe('useSimulationMode', () => { jest.useFakeTimers(); jest.setSystemTime(new Date(100000)); + // 各テストは非ループ線を前提とする。ループ線テストで上書きした実装が + // 後続テストへ漏れないよう毎回明示的にリセットする。 + (useLoopLine as jest.Mock).mockReturnValue({ isLoopLine: false }); + jest .spyOn(useCurrentTrainTypeModule, 'useCurrentTrainType') .mockReturnValue(null); @@ -575,8 +581,8 @@ describe('useSimulationMode', () => { ); }); - it('終端駅到達後、先頭に戻ったときに速度プロファイルを最初から再生する', () => { - // 駅が1つだけ → nextStopStationがない → 即座に先頭リセット + it('終点到達後は即座に折り返さず終点で停車し続ける', () => { + // 駅が1つだけ → nextStopStationがない → 即座に終点扱い const stations = [mockStation(1, 1, 35.681, 139.767)]; setupAtomMocks( @@ -588,37 +594,150 @@ describe('useSimulationMode', () => { { autoModeEnabled: true } ); - // step内でstore.getが呼ばれる + // dwell処理内でstore.getが呼ばれる (store.get as jest.Mock).mockReturnValue( mockLocationObject(35.681, 139.767) ); // 速度プロファイルは空(駅が1つで次の駅がない)なので // interval tick 1: speeds=[], i(0)>=0 → dwellPending=true - // interval tick 2: dwell handler → nextSegment=-1 → 先頭に戻る - // interval tick 3: speeds=[] again → dwellPending=true - // 先頭に戻る際にchildIndexがリセットされていれば、 - // 毎回i=0から開始される(リセットされていないとiが進み続ける) + // interval tick 2以降: dwell handler → nextSegment=-1 → 終点で停車し + // TERMINAL_DWELL_TICKS に達するまで方面逆転せず待機し続ける。 + // 待機中は始発駅へワープせず、終点座標で speed=0 のまま留まる。 renderHook(() => useSimulationMode(), { wrapper: ({ children }) => {children}, }); - // 6秒分進める(複数回のリセットサイクルを経る) + // 6秒分進める(待機継続中) jest.advanceTimersByTime(6000); - // 先頭駅の位置が繰り返しセットされることを確認(リセットが正しく機能している) + // 終点座標で speed=0 の位置更新が繰り返しセットされることを確認 const locationSetCalls = (store.set as jest.Mock).mock.calls .filter((call) => call[0] === locationAtom) .map((call) => call[1]); - const resetCalls = locationSetCalls.filter( + const dwellCalls = locationSetCalls.filter( (loc) => loc?.coords?.latitude === stations[0].latitude && loc?.coords?.longitude === stations[0].longitude && loc?.coords?.speed === 0 ); - // 初期化 + dwellハンドラでの複数回リセット - expect(resetCalls.length).toBeGreaterThanOrEqual(2); + // 終点停車中の複数回の位置更新 + expect(dwellCalls.length).toBeGreaterThanOrEqual(2); + + // 待機時間(60ティック)未満では方面逆転(selectedDirection書き込み)は起きない + const directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + }); + + it('終点で約1分停車したのち方面を逆転して折り返す', () => { + const stations = [ + mockStation(1, 1, 35.681, 139.767), + mockStation(2, 2, 35.691, 139.777), + ]; + + setupAtomMocks( + { + station: stations[0], + stations, + selectedDirection: 'INBOUND', + }, + { autoModeEnabled: true } + ); + + mockTrainRoute(stations); + + // 1駅1ティックで終点まで到達させる + jest + .spyOn(trainSpeedModule, 'generateTrainSpeedProfile') + .mockReturnValue([2000]); + + (store.get as jest.Mock).mockReturnValue( + mockLocationObject(35.691, 139.777) + ); + + renderHook(() => useSimulationMode(), { + wrapper: ({ children }) => {children}, + }); + + // 終点到達 + 30秒程度の停車。まだ待機時間(約60秒)に満たないので折り返さない + jest.advanceTimersByTime(30000); + + let directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + + // さらに進めて待機時間を超過させると方面(selectedDirection/selectedBound)が逆転する + jest.advanceTimersByTime(40000); + + directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls.length).toBeGreaterThanOrEqual(1); + // INBOUND → OUTBOUND へ逆転 + expect(directionSetCalls[0][1]).toBe('OUTBOUND'); + + // 折り返し後の行き先(selectedBound)も更新される + const boundSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedBoundAtom' + ); + expect(boundSetCalls.length).toBeGreaterThanOrEqual(1); + }); + + it('ループ線では終点でも方面を逆転せず先頭に戻って周回を続ける', () => { + (useLoopLine as jest.Mock).mockReturnValue({ isLoopLine: true }); + + const stations = [ + mockStation(1, 1, 35.681, 139.767), + mockStation(2, 2, 35.691, 139.777), + ]; + + setupAtomMocks( + { + station: stations[1], + stations, + selectedDirection: 'INBOUND', + }, + { autoModeEnabled: true } + ); + + // ループ線 + INBOUND では進行順が reverse される([s2, s1]) + mockTrainRoute([stations[1], stations[0]]); + + jest + .spyOn(trainSpeedModule, 'generateTrainSpeedProfile') + .mockReturnValue([2000]); + + (store.get as jest.Mock).mockReturnValue( + mockLocationObject(35.691, 139.777) + ); + + renderHook(() => useSimulationMode(), { + wrapper: ({ children }) => {children}, + }); + + // 非ループ線なら折り返す待機時間を超過しても、ループ線では方面を逆転しない + jest.advanceTimersByTime(70000); + + const directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + + // 先頭駅(周回の始点)へ戻る位置更新が行われている + const locationSetCalls = (store.set as jest.Mock).mock.calls + .filter((call) => call[0] === locationAtom) + .map((call) => call[1]); + const backToStartCalls = locationSetCalls.filter( + (loc) => + loc?.coords?.latitude === stations[1].latitude && + loc?.coords?.longitude === stations[1].longitude && + loc?.coords?.speed === 0 + ); + expect(backToStartCalls.length).toBeGreaterThanOrEqual(1); }); it('速度プロファイルの終端に達したら次のセグメントに移動する', () => { diff --git a/src/hooks/useSimulationMode.ts b/src/hooks/useSimulationMode.ts index 18d7c8b8c..57ea549a2 100644 --- a/src/hooks/useSimulationMode.ts +++ b/src/hooks/useSimulationMode.ts @@ -13,6 +13,7 @@ import { locationAtom } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; import { generateTrainSpeedProfile } from '~/utils/trainSpeed'; import { + selectedBoundAtom, selectedDirectionAtom, stationAtom, stationsAtom, @@ -23,6 +24,10 @@ import { useCurrentTrainType } from './useCurrentTrainType'; import { useGraphQLQuery } from './useGraphQLQuery'; import { useLoopLine } from './useLoopLine'; +// 終点到着後、方面を逆転して折り返すまでの待機時間。 +// step のインターバルが1秒間隔のため、ティック数(≒秒数)としてそのまま扱う。 +const TERMINAL_DWELL_TICKS = 60; + export const useSimulationMode = (): void => { const currentStation = useAtomValue(stationAtom); const rawStations = useAtomValue(stationsAtom); @@ -40,6 +45,11 @@ export const useSimulationMode = (): void => { const speedProfilesRef = useRef([]); const segmentProgressDistanceRef = useRef(0); const dwellPendingRef = useRef(false); + // 終点到着後、方面を逆転して折り返すまで終点で停車し続けたティック数 + const terminalDwellCountRef = useRef(0); + // 方面逆転中フラグ。新しい進行方向の速度プロファイルが再生成されるまで + // 終点で停車したまま待機し、旧方向のプロファイル/ジオメトリでの誤stepを防ぐ。 + const reversingRef = useRef(false); // 区間ごとの (waypoints, cumulativeDistances) キャッシュ。 // step() は毎秒呼ばれる。区間が変わらない限り cumulativeDistances は同じなので、 // waypoints 毎の getDistance / reduce を毎ティック走らせる必要は無い。 @@ -282,6 +292,9 @@ export const useSimulationMode = (): void => { childIndexRef.current = 0; segmentProgressDistanceRef.current = 0; dwellPendingRef.current = false; + terminalDwellCountRef.current = 0; + // 新しい方向の速度プロファイルが揃ったので折り返し待機を解除する + reversingRef.current = false; }, [maybeRevsersedStations, trainRouteData, resolveStartIndex]); const step = useCallback( @@ -427,6 +440,23 @@ export const useSimulationMode = (): void => { const speeds = speedProfilesRef.current[segmentIndexRef.current] ?? []; + // 方面逆転中は新方向の速度プロファイルが再生成されるまで終点で停車して待つ。 + // 旧方向のプロファイル/ジオメトリで step すると位置が飛ぶため、ここで待機する。 + if (reversingRef.current) { + const prev = store.get(locationAtom); + if (prev) { + store.set(locationAtom, { + timestamp: Date.now(), + coords: { + ...prev.coords, + speed: 0, + heading: null, + }, + }); + } + return; + } + if (dwellPendingRef.current) { const prev = store.get(locationAtom); if (prev) { @@ -442,27 +472,58 @@ export const useSimulationMode = (): void => { const nextSegmentIndex = speedProfilesRef.current.findIndex( (seg, idx) => seg.length > 0 && idx > segmentIndexRef.current ); + if (nextSegmentIndex === -1) { - const firstStation = maybeRevsersedStations[0]; - if ( - prev && - firstStation?.latitude != null && - firstStation?.longitude != null - ) { - store.set(locationAtom, { - timestamp: Date.now(), - coords: { - ...prev.coords, - latitude: firstStation.latitude, - longitude: firstStation.longitude, - speed: 0, - heading: null, - }, - }); + if (isLoopLine) { + // ループ線には折り返す終点が無いため、従来どおり先頭に戻って + // 同一方向のまま周回を続ける。 + const firstStation = maybeRevsersedStations[0]; + if ( + prev && + firstStation?.latitude != null && + firstStation?.longitude != null + ) { + store.set(locationAtom, { + timestamp: Date.now(), + coords: { + ...prev.coords, + latitude: firstStation.latitude, + longitude: firstStation.longitude, + speed: 0, + heading: null, + }, + }); + } + segmentIndexRef.current = 0; + childIndexRef.current = 0; + segmentProgressDistanceRef.current = 0; + dwellPendingRef.current = false; + return; + } + + // 終点に到達。すぐには折り返さず、約1分間そのまま停車してから + // 方面(selectedDirection / selectedBound)を逆転させて折り返す。 + if (terminalDwellCountRef.current < TERMINAL_DWELL_TICKS) { + terminalDwellCountRef.current += 1; + return; } + + // 待機完了 → 方面を逆転する。maybeRevsersedStations と trainRoute は + // どちらも selectedDirection に依存して再計算されるため、方向を反転すれば + // 終点始発の折り返し運転として自然にシミュレーションが継続する。 + terminalDwellCountRef.current = 0; + dwellPendingRef.current = false; + reversingRef.current = true; + const reversedDirection = + selectedDirection === 'INBOUND' ? 'OUTBOUND' : 'INBOUND'; + // 反転後の進行方向は現在の maybeRevsersedStations の逆順になるため、 + // 折り返し後の行き先(selectedBound)は現在の始発駅にあたる先頭要素。 + store.set(selectedBoundAtom, maybeRevsersedStations[0] ?? null); + store.set(selectedDirectionAtom, reversedDirection); + return; } - segmentIndexRef.current = - nextSegmentIndex === -1 ? 0 : nextSegmentIndex; + + segmentIndexRef.current = nextSegmentIndex; childIndexRef.current = 0; segmentProgressDistanceRef.current = 0; dwellPendingRef.current = false; @@ -483,5 +544,5 @@ export const useSimulationMode = (): void => { return () => { clearInterval(intervalId); }; - }, [enabled, maybeRevsersedStations, selectedDirection, step]); + }, [enabled, isLoopLine, maybeRevsersedStations, selectedDirection, step]); }; From 9b26e1dddacf1fa8b24f4351d6dd064c94b05707 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 13 Jul 2026 00:11:20 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=E7=9C=81=E9=9B=BB=E5=8A=9B=E3=83=A2?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=82=92=E8=A9=A6=E9=A8=93=E7=9A=84=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=81=8B=E3=82=89=E5=A4=96=E3=81=97=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=81=AB=E3=80=8C=E3=83=90=E3=83=83=E3=83=86=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=80=8D=E7=94=BB=E9=9D=A2=E3=82=92=E6=96=B0=E8=A8=AD=E3=81=97?= =?UTF-8?q?=E3=81=A6=E7=A7=BB=E5=8B=95=20(#6409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 省電力モードを試験的機能から外し設定に「バッテリー」画面を新設して移動 Co-Authored-By: Claude Opus 4.8 * バッテリー項目のアイコン背景色を落ち着いたティール(#30B0C7)へ変更 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- assets/translations/en.json | 1 + assets/translations/ja.json | 1 + src/components/Permitted.tsx | 2 +- ...useStartBackgroundLocationUpdates.test.tsx | 55 +------ .../useStartBackgroundLocationUpdates.ts | 16 +- src/screens/AppSettings.tsx | 9 + src/screens/BatterySettings.test.tsx | 94 +++++++++++ src/screens/BatterySettings.tsx | 155 ++++++++++++++++++ src/screens/ExperimentalSettings.test.tsx | 32 +--- src/screens/ExperimentalSettings.tsx | 31 ---- src/stacks/MainStack.tsx | 6 + src/store/atoms/battery.ts | 15 ++ src/store/atoms/experimental.ts | 14 -- 13 files changed, 292 insertions(+), 139 deletions(-) create mode 100644 src/screens/BatterySettings.test.tsx create mode 100644 src/screens/BatterySettings.tsx create mode 100644 src/store/atoms/battery.ts diff --git a/assets/translations/en.json b/assets/translations/en.json index 724cd4d2d..d0b693f0c 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -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" diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 58d7f6185..507dfc8cc 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -332,6 +332,7 @@ "telemetryDescription": "お使いの端末の地理的座標を解析用サーバに送信します。送信された情報は解析以外に使用されません。", "etaAssistTitle": "到着判定の改善", "etaAssistDescription": "有効にすると、地下鉄などGPSの精度が落ちる区間で、サーバーの到着予測(ETA)を使って到着判定を補助します。到着や現在地はGPSが基準で、ETAはあくまで補助です。", + "batterySettings": "バッテリー", "powerSavingLocationTitle": "省電力測位モード", "powerSavingLocationDescription": "有効にすると、測位精度を電池優先まで下げ、停車中はiOSが測位を自動休止して、長時間の乗車での電池消費と発熱をさらに減らします。精度の低下により駅の判定や到着案内が遅れたりずれたりする場合があります。位置情報の更新頻度の緩和は標準設定に組み込まれています。端末の省電力モード中は自動的に有効になります。", "passStationLabel": "通過" diff --git a/src/components/Permitted.tsx b/src/components/Permitted.tsx index ad316d742..dcc82fd75 100644 --- a/src/components/Permitted.tsx +++ b/src/components/Permitted.tsx @@ -445,7 +445,7 @@ const PermittedLayout: React.FC = ({ children }: Props) => { ); // NOTE: powerSavingLocationEnabledAtom はここでは復元しない。effect復元だと // 継続測位がデフォルト精度で一度起動してから再起動されるため、 - // atom定義側(store/atoms/experimental.ts)でMMKVから同期的に初期値を確定している。 + // atom定義側(store/atoms/battery.ts)でMMKVから同期的に初期値を確定している。 if (themePreferenceKey) { setThemePreference(themePreferenceKey as ThemePreference); diff --git a/src/hooks/useStartBackgroundLocationUpdates.test.tsx b/src/hooks/useStartBackgroundLocationUpdates.test.tsx index 56dfd23eb..e35df4d67 100644 --- a/src/hooks/useStartBackgroundLocationUpdates.test.tsx +++ b/src/hooks/useStartBackgroundLocationUpdates.test.tsx @@ -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; @@ -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', () => ({ @@ -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', }, @@ -92,7 +86,6 @@ describe('useStartBackgroundLocationUpdates', () => { mockAutoModeEnabled = false; mockPowerSavingLocationEnabled = false; mockSystemLowPowerMode = false; - mockIsDevApp = false; mockNeedsJobSchedulerBypass = false; mockStartLocationUpdatesAsync.mockResolvedValue(undefined); mockStopLocationUpdatesAsync.mockResolvedValue(undefined); @@ -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()); @@ -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()); @@ -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()); diff --git a/src/hooks/useStartBackgroundLocationUpdates.ts b/src/hooks/useStartBackgroundLocationUpdates.ts index 90af2ed21..6b4b6e6e9 100644 --- a/src/hooks/useStartBackgroundLocationUpdates.ts +++ b/src/hooks/useStartBackgroundLocationUpdates.ts @@ -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, @@ -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 diff --git a/src/screens/AppSettings.tsx b/src/screens/AppSettings.tsx index 822d2357f..e2112d710 100644 --- a/src/screens/AppSettings.tsx +++ b/src/screens/AppSettings.tsx @@ -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', @@ -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': @@ -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 ? [ diff --git a/src/screens/BatterySettings.test.tsx b/src/screens/BatterySettings.test.tsx new file mode 100644 index 000000000..09cb1031e --- /dev/null +++ b/src/screens/BatterySettings.test.tsx @@ -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( + + + + ); + + 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(); + }); +}); diff --git a/src/screens/BatterySettings.tsx b/src/screens/BatterySettings.tsx new file mode 100644 index 000000000..b02bb8c1a --- /dev/null +++ b/src/screens/BatterySettings.tsx @@ -0,0 +1,155 @@ +import { useNavigation } from '@react-navigation/native'; +import { useAtom, useAtomValue } from 'jotai'; +import React, { useCallback, useRef, useState } from 'react'; +import { + Alert, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + Animated as RNAnimated, + ScrollView, + StyleSheet, + View, +} from 'react-native'; +import Button from '~/components/Button'; +import FooterTabBar from '~/components/FooterTabBar'; +import { SettingsHeader } from '~/components/SettingsHeader'; +import { StatePanel } from '~/components/ToggleButton'; +import Typography from '~/components/Typography'; +import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery'; +import { isLEDThemeAtom } from '~/store/atoms/theme'; +import { translate } from '~/translation'; +import { STORAGE_KEYS } from '../constants'; +import { storage } from '../lib/storage'; + +const styles = StyleSheet.create({ + root: { + paddingHorizontal: 24, + flex: 1, + }, + screenBg: { + backgroundColor: '#FAFAFA', + }, + description: { + marginTop: 16, + color: '#8B8B8B', + lineHeight: 21, + }, + okButton: { + width: 128, + alignSelf: 'center', + marginTop: 32, + }, +}); + +const ToggleItem = ({ + title, + state, + onToggle, +}: { + title: string; + state: boolean; + onToggle: () => void; +}) => { + const isLEDTheme = useAtomValue(isLEDThemeAtom); + + return ( + + + {title} + + + + + ); +}; + +const BatterySettingsScreen: React.FC = () => { + const [headerHeight, setHeaderHeight] = useState(0); + + const scrollY = useRef(new RNAnimated.Value(0)).current; + + const isLEDTheme = useAtomValue(isLEDThemeAtom); + const [powerSavingLocationEnabled, setPowerSavingLocationEnabled] = useAtom( + powerSavingLocationEnabledAtom + ); + + const navigation = useNavigation(); + + const handleTogglePowerSavingLocation = useCallback(() => { + const flag = !powerSavingLocationEnabled; + setPowerSavingLocationEnabled(flag); + try { + storage.set( + STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED, + flag ? 'true' : 'false' + ); + } catch (error) { + // 保存に失敗したままだと次回起動時に設定が巻き戻るため、 + // UIと永続値の不整合を防ぐべくatom状態をロールバックする + setPowerSavingLocationEnabled(!flag); + console.error('Failed to save power saving location setting', error); + Alert.alert(translate('errorTitle'), translate('failedToSavePreference')); + } + }, [powerSavingLocationEnabled, setPowerSavingLocationEnabled]); + + const handleScroll = useCallback( + (e: NativeSyntheticEvent) => { + scrollY.setValue(e.nativeEvent.contentOffset.y); + }, + [scrollY] + ); + + return ( + <> + + + + + {translate('powerSavingLocationDescription')} + + + + + setHeaderHeight(e.nativeEvent.layout.height + 32)} + scrollY={scrollY} + /> + + + ); +}; + +export default React.memo(BatterySettingsScreen); diff --git a/src/screens/ExperimentalSettings.test.tsx b/src/screens/ExperimentalSettings.test.tsx index 191494038..a70d00fab 100644 --- a/src/screens/ExperimentalSettings.test.tsx +++ b/src/screens/ExperimentalSettings.test.tsx @@ -7,7 +7,6 @@ import { storage } from '~/lib/storage'; import { etaAssistManualEnabledAtom, portraitModeEnabledAtom, - powerSavingLocationEnabledAtom, } from '~/store/atoms/experimental'; import tuningState from '~/store/atoms/tuning'; import ExperimentalSettingsScreen from './ExperimentalSettings'; @@ -30,13 +29,11 @@ jest.mock('~/translation', () => ({ const renderWithStore = ( portraitModeEnabled: boolean, telemetryEnabled = false, - etaAssistManualEnabled = false, - powerSavingLocationEnabled = false + etaAssistManualEnabled = false ) => { const store = createStore(); store.set(portraitModeEnabledAtom, portraitModeEnabled); store.set(etaAssistManualEnabledAtom, etaAssistManualEnabled); - store.set(powerSavingLocationEnabledAtom, powerSavingLocationEnabled); store.set(tuningState, (prev) => ({ ...prev, telemetryEnabled })); const screen = render( @@ -145,33 +142,6 @@ describe('ExperimentalSettingsScreen', () => { ); }); - 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( - false, - false, - false, - true - ); - - fireEvent.press(getByLabelText('powerSavingLocationTitle')); - - expect(store.get(powerSavingLocationEnabledAtom)).toBe(false); - expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( - 'false' - ); - }); - it('テレメトリをONにするとatomとストレージへ保存される', () => { const { getByLabelText, store } = renderWithStore(false); diff --git a/src/screens/ExperimentalSettings.tsx b/src/screens/ExperimentalSettings.tsx index e2e9ef48d..692e991cd 100644 --- a/src/screens/ExperimentalSettings.tsx +++ b/src/screens/ExperimentalSettings.tsx @@ -20,7 +20,6 @@ import { isEtaAssistRemoteEnabled } from '~/lib/remoteConfig'; import { etaAssistManualEnabledAtom, portraitModeEnabledAtom, - powerSavingLocationEnabledAtom, } from '~/store/atoms/experimental'; import { isLEDThemeAtom } from '~/store/atoms/theme'; import tuningState from '~/store/atoms/tuning'; @@ -108,9 +107,6 @@ const ExperimentalSettingsScreen: React.FC = () => { const [etaAssistManualEnabled, setEtaAssistManualEnabled] = useAtom( etaAssistManualEnabledAtom ); - const [powerSavingLocationEnabled, setPowerSavingLocationEnabled] = useAtom( - powerSavingLocationEnabledAtom - ); const [tuning, setTuning] = useAtom(tuningState); // Remote Config(マスタースイッチ)が許可していない間は、手動トグルを操作不可にする。 @@ -158,23 +154,6 @@ const ExperimentalSettingsScreen: React.FC = () => { etaAssistRemoteEnabled, ]); - const handleTogglePowerSavingLocation = useCallback(() => { - const flag = !powerSavingLocationEnabled; - setPowerSavingLocationEnabled(flag); - try { - storage.set( - STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED, - flag ? 'true' : 'false' - ); - } catch (error) { - // 保存に失敗したままだと次回起動時に設定が巻き戻るため、 - // UIと永続値の不整合を防ぐべくatom状態をロールバックする - setPowerSavingLocationEnabled(!flag); - console.error('Failed to save power saving location setting', error); - Alert.alert(translate('errorTitle'), translate('failedToSavePreference')); - } - }, [powerSavingLocationEnabled, setPowerSavingLocationEnabled]); - const handleToggleTelemetry = useCallback(() => { const flag = !tuning.telemetryEnabled; setTuning((prev) => ({ ...prev, telemetryEnabled: flag })); @@ -235,16 +214,6 @@ const ExperimentalSettingsScreen: React.FC = () => { {translate('etaAssistDescription')} - - - - - {translate('powerSavingLocationDescription')} - {translate('experimentalSettingsNotice')} diff --git a/src/stacks/MainStack.tsx b/src/stacks/MainStack.tsx index 01d1974b8..90ff54222 100644 --- a/src/stacks/MainStack.tsx +++ b/src/stacks/MainStack.tsx @@ -5,6 +5,7 @@ import { import { useAtomValue } from 'jotai'; import React, { useMemo } from 'react'; import AndroidSettings from '~/screens/AndroidSettings'; +import BatterySettings from '~/screens/BatterySettings'; import ExperimentalSettings from '~/screens/ExperimentalSettings'; import Licenses from '~/screens/Licenses'; import NotificationSettings from '~/screens/NotificationSettings'; @@ -113,6 +114,11 @@ const MainStack: React.FC = () => { name="AndroidSettings" component={AndroidSettings} /> + Date: Mon, 13 Jul 2026 05:58:11 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=E3=82=AA=E3=83=BC=E3=83=88=E3=83=A2?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=AE=E6=8A=98=E3=82=8A=E8=BF=94=E3=81=97?= =?UTF-8?q?=E6=99=82=E3=81=ABTTS=E3=81=AE=E5=88=9D=E5=9B=9E=E6=94=BE?= =?UTF-8?q?=E9=80=81=E3=82=92=E5=86=8D=E7=99=BA=E7=81=AB=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E5=A4=89=E6=9B=B4=20(#6408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * オートモードの折り返し時にTTSの初回放送を再発火するよう変更 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012dhifdRFT6ZRWghyvrDFzk * 折り返し時の初回放送再発火テストで現在値+1と単一発火を検証するよう強化 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012dhifdRFT6ZRWghyvrDFzk --------- Co-authored-by: Claude --- src/hooks/useSimulationMode.test.tsx | 28 ++++++++++++++++++++++++++-- src/hooks/useSimulationMode.ts | 5 +++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSimulationMode.test.tsx b/src/hooks/useSimulationMode.test.tsx index 1feefa82e..41d7d3301 100644 --- a/src/hooks/useSimulationMode.test.tsx +++ b/src/hooks/useSimulationMode.test.tsx @@ -38,6 +38,12 @@ jest.mock('~/store/atoms/navigation', () => ({ autoModeEnabledAtom: { toString: () => 'autoModeEnabledAtom' }, })); +jest.mock('~/store/atoms/speech', () => ({ + __esModule: true, + default: { toString: () => 'speechState' }, + resetFirstSpeechAtom: { toString: () => 'resetFirstSpeechAtom' }, +})); + jest.mock('~/store', () => ({ store: { get: jest.fn(() => null), @@ -654,8 +660,12 @@ describe('useSimulationMode', () => { .spyOn(trainSpeedModule, 'generateTrainSpeedProfile') .mockReturnValue([2000]); - (store.get as jest.Mock).mockReturnValue( - mockLocationObject(35.691, 139.777) + // resetFirstSpeechAtom は非ゼロの数値、それ以外(locationAtom)は位置オブジェクトを返す。 + // 非ゼロ(3)にすることで「現在値 + 1」を読んでいることを検証できる(固定値1だと通ってしまう)。 + (store.get as jest.Mock).mockImplementation((atom) => + atom?.toString?.() === 'resetFirstSpeechAtom' + ? 3 + : mockLocationObject(35.691, 139.777) ); renderHook(() => useSimulationMode(), { @@ -669,6 +679,12 @@ describe('useSimulationMode', () => { (call) => call[0]?.toString?.() === 'selectedDirectionAtom' ); expect(directionSetCalls).toHaveLength(0); + // 折り返す前は初回放送の再発火も起きない + expect( + (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'resetFirstSpeechAtom' + ) + ).toHaveLength(0); // さらに進めて待機時間を超過させると方面(selectedDirection/selectedBound)が逆転する jest.advanceTimersByTime(40000); @@ -685,6 +701,14 @@ describe('useSimulationMode', () => { (call) => call[0]?.toString?.() === 'selectedBoundAtom' ); expect(boundSetCalls.length).toBeGreaterThanOrEqual(1); + + // 折り返し時に初回放送(firstSpeech)が再発火する(resetFirstSpeechをインクリメント)。 + // 現在値3 + 1 = 4 が設定され、二重発火せず1回だけ呼ばれることを検証する。 + const resetFirstSpeechCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'resetFirstSpeechAtom' + ); + expect(resetFirstSpeechCalls).toHaveLength(1); + expect(resetFirstSpeechCalls[0][1]).toBe(4); }); it('ループ線では終点でも方面を逆転せず先頭に戻って周回を続ける', () => { diff --git a/src/hooks/useSimulationMode.ts b/src/hooks/useSimulationMode.ts index 57ea549a2..9ee5ca796 100644 --- a/src/hooks/useSimulationMode.ts +++ b/src/hooks/useSimulationMode.ts @@ -11,6 +11,7 @@ import { GET_TRAIN_ROUTE } from '~/lib/graphql/queries'; import { store } from '~/store'; import { locationAtom } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; +import { resetFirstSpeechAtom } from '~/store/atoms/speech'; import { generateTrainSpeedProfile } from '~/utils/trainSpeed'; import { selectedBoundAtom, @@ -520,6 +521,10 @@ export const useSimulationMode = (): void => { // 折り返し後の行き先(selectedBound)は現在の始発駅にあたる先頭要素。 store.set(selectedBoundAtom, maybeRevsersedStations[0] ?? null); store.set(selectedDirectionAtom, reversedDirection); + // 折り返し後は新しい行き先として初回放送(この電車は〜行きです)を再発火させる。 + // resetFirstSpeechAtom を進めると useTTS 側で firstSpeech が true に戻り、 + // 行き先変更 + 発車後(arrived=false)に初回TTSが改めて再生される。 + store.set(resetFirstSpeechAtom, store.get(resetFirstSpeechAtom) + 1); return; } From 8db439d9b2544231cd5200d92dfe0df20a461ab5 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 13 Jul 2026 06:01:00 +0900 Subject: [PATCH 7/9] =?UTF-8?q?TZ=3DUTC=20=E3=81=AE=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=82=92?= =?UTF-8?q?=20cross-env=20=E7=B5=8C=E7=94=B1=E3=81=AB=E3=81=97=E3=81=A6Win?= =?UTF-8?q?dows=E3=81=A7=E5=8B=95=E3=81=8F=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#6406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 2c569a140..358e43c49 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "web": "expo start --web", "lint": "biome check ./src", "format": "biome format ./src --write", - "test": "TZ=UTC jest", + "test": "cross-env TZ=UTC jest", "typecheck": "tsc --noEmit", - "watch:test": "TZ=UTC jest --watch", + "watch:test": "cross-env TZ=UTC jest --watch", "gql:codegen": "graphql-codegen --config utils/codegen.ts", "version:bump": "node scripts/bump-version.js", "postinstall": "patch-package" From fca5fbaf66d4ed2632bc641c3d8a224483a83587 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 13 Jul 2026 06:01:46 +0900 Subject: [PATCH 8/9] =?UTF-8?q?cherry-pick=E3=83=AA=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E5=BE=8C=E3=81=AE=E5=90=8C=E6=9C=9FPR=E3=81=AE?= =?UTF-8?q?=E7=89=88=E6=95=B0=E3=82=B3=E3=83=B3=E3=83=95=E3=83=AA=E3=82=AF?= =?UTF-8?q?=E3=83=88=E8=A7=A3=E6=B1=BA=E6=89=8B=E9=A0=86=E3=82=92=E3=82=B9?= =?UTF-8?q?=E3=82=AD=E3=83=AB=E3=81=AB=E8=BF=BD=E8=A8=98=20(#6405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/finalize-release/SKILL.md | 2 + .claude/skills/sync-dev-from-master/SKILL.md | 51 ++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/.claude/skills/finalize-release/SKILL.md b/.claude/skills/finalize-release/SKILL.md index 7d25f74b9..9c0d9bbb3 100644 --- a/.claude/skills/finalize-release/SKILL.md +++ b/.claude/skills/finalize-release/SKILL.md @@ -126,6 +126,8 @@ sync 側の走行は **必須**。master→dev 差分が 0 件の場合のみ自 sync が **スキップ**(差分 0 件)された場合はこの手順を飛ばす。新規作成・流用いずれかで dev<-master PR が存在する場合のみ実行する。**緩和→マージ→復元は 1 つの bash 呼び出しにまとめ、`set +e` でマージ失敗を握って復元を無条件実行する**(途中で関数が分かれて復元が飛ぶ事故を防ぐ)。 + **マージ前にコンフリクトを確認する。** `gh pr view --json mergeable,mergeStateStatus` が `CONFLICTING` を返す場合、`master` から特定コミットだけを cherry-pick したリリース(`create-release-pr` の「この変更だけ」指定など)の後に起きる **版数ファイルのねじれ** が典型。このときは Ruleset 緩和より先に `sync-dev-from-master` の「版数ファイルのコンフリクト解決」に従って版数を解決し(本番版数の判断はユーザー承認を取る)、PR を `MERGEABLE` にしてから 6-3 以降へ進む。`mergeable` はプッシュ直後に非同期で古い値を返すことがあるため、`git merge-base --is-ancestor origin/dev origin/chore/dev-from-master` で構造的な包含も確認するとよい。 + 1. マージ対象 PR 番号を確定(手順 5 で新規作成 or 流用した PR)。ローカルが `chore/dev-from-master` に居るとブランチ削除で支障が出るため `git switch dev`(または安全な枝)へ退避する。 2. `OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)` を解決。プレフライトの「マージ方式制約」記録を使う: - dev が `merge` を許可済み → **緩和不要**。そのまま手順 6-5 のマージへ。 diff --git a/.claude/skills/sync-dev-from-master/SKILL.md b/.claude/skills/sync-dev-from-master/SKILL.md index 8d3d55105..cf8febf60 100644 --- a/.claude/skills/sync-dev-from-master/SKILL.md +++ b/.claude/skills/sync-dev-from-master/SKILL.md @@ -75,6 +75,7 @@ description: Open a dev<-master merge PR that syncs master back into dev after a - 何もコミットは積まない(master 先端そのまま)。biome 等のフォーマッタは走らせない(新規コミット無し)。 - push 前に、対象 SHA(`git rev-parse origin/master`)・取り込まれるコミット件数・本文に入れる version をユーザーに提示して承認を取る。 + - **例外**: この PR が版数ファイルで衝突する場合(`master` から特定 PR だけ cherry-pick したリリースの後に起きる。後述の「版数ファイルのコンフリクト解決」を参照)は、この枝に `origin/dev` をマージして解決コミットを 1 つだけ積む。それ以外は master 先端そのまま。 5. **PR 本文を組み立て(テンプレ厳守・全節を実内容で埋める)** @@ -160,6 +161,56 @@ description: Open a dev<-master merge PR that syncs master back into dev after a - テスト欄のチェック状態(全 OFF + 説明文あり) - 使用した `release_version`(どこから取ったか) +## 版数ファイルのコンフリクト解決(cherry-pick / hotfix リリース後) + +通常の「dev から丸ごと」リリースでは `master` が `dev` の完全な祖先になるため、この同期 PR は衝突しない(手順 4 のとおり master 先端そのままで済む)。しかし **リリースブランチを `master` から切って特定 PR だけ cherry-pick したリリース**(`create-release-pr` に「この変更だけ」と指定したホットフィックス型など)では、`dev` を `master` に取り込んでいないため、`master` のリリース版数と `dev` の canary bump 版数が **ねじれたまま** 残り、この同期 PR が版数ファイルで衝突する。 + +衝突するのは版数ファイルのみで、アプリコードは衝突しない: + +- `android/app/build.gradle`(`versionCode` / `versionName`) +- `app.config.ts`(`version` / `buildNumber` / `versionCode`) +- `ios/TrainLCD.xcodeproj/project.pbxproj`(`MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`) + +### 解決方針: semver はリリース版、ビルド番号は最大値 + +| 種別 | 採用する側 | 理由 | +| ---- | ---- | ---- | +| semver(`version` / `versionName` / `MARKETING_VERSION`) | `master`(リリース版数) | `dev` をリリース済みバージョンへ前進させる。過去 PR #6396 も semver 競合をリリース版で解決している | +| ビルド番号(`versionCode` / `CURRENT_PROJECT_VERSION` / `buildNumber`) | `max(dev, master)`(通常は `dev` 側が大きい) | ストアはビルド番号の単調増加を要求する。canary で既発行の番号より下げると次回 bump で衝突する | + +### 解決手順 + +1. `chore/dev-from-master`(= master 先端)に居る状態で `origin/dev` をマージする(手順 4 の「コミットを積まない」原則の唯一の例外): + + ```bash + git switch chore/dev-from-master + git merge --no-ff --no-commit origin/dev + ``` + +2. 衝突した版数 3 ファイルを master 側(`--ours`)で確定してから、ビルド番号だけ `dev` 側の最大値へ引き上げる(下は master=530/2743・dev=531/2744 の例): + + ```bash + git checkout --ours android/app/build.gradle app.config.ts ios/TrainLCD.xcodeproj/project.pbxproj + sed -i 's/versionCode 100000530/versionCode 100000531/g' android/app/build.gradle + sed -i "s/buildNumber: '2743'/buildNumber: '2744'/g; s/versionCode: 100000530/versionCode: 100000531/g" app.config.ts + sed -i 's/CURRENT_PROJECT_VERSION = 2743;/CURRENT_PROJECT_VERSION = 2744;/g' ios/TrainLCD.xcodeproj/project.pbxproj + ``` + + これらの版数ファイルは master↔dev で数値以外の差分が無い(`git diff origin/dev origin/master -- ` で確認できる)ため、`--ours` で master を採ってもコンテンツは失われない。 + +3. **`dev` への正味の変化が semver だけ**(ビルド番号は据え置き)であることを確認してからマージコミットを作成し push する: + + ```bash + git add android/app/build.gradle app.config.ts ios/TrainLCD.xcodeproj/project.pbxproj + git diff origin/dev HEAD # semver(例 10.9.0 -> 10.9.1)のみが出るのが正 + git commit -m "origin/dev をマージし版数競合を解決(semver=、ビルド番号=)" + git push origin chore/dev-from-master + ``` + +4. 以降は通常どおり merge commit でマージする(`finalize-release` が Ruleset 一時緩和つきで実行する)。マージ後は dev HEAD が 2 親の merge commit になり、`git rev-list --count origin/dev..origin/master` が `0`(dev が master を完全包含)になることを検証する。 + +**semver をリリース版数へ更新する判断とビルド番号の採用値は本番の版数に関わるため、自動で確定せずユーザーに確認する。** + ## 注意事項 - **Squash merge 禁止**。これがこのスキルの存在理由の半分。実行時と完了報告で二重に明示する。 From 2f544ac7968a75d0dffc2cfacd42fe70efc2eb04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:08:31 +0900 Subject: [PATCH 9/9] Bump version for canary release (#6411) Co-authored-by: TinyKitten <32848922+TinyKitten@users.noreply.github.com> --- android/app/build.gradle | 4 +-- app.config.ts | 4 +-- ios/TrainLCD.xcodeproj/project.pbxproj | 36 +++++++++++++------------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 76b1303fb..b5b350855 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -143,12 +143,12 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000531 + versionCode 100000532 versionName "10.9.1" } prod { dimension "environment" - versionCode 100000531 + versionCode 100000532 versionName "10.9.1" } } diff --git a/app.config.ts b/app.config.ts index b390b847b..b0afef226 100644 --- a/app.config.ts +++ b/app.config.ts @@ -52,7 +52,7 @@ export default { }, }, ios: { - buildNumber: '2744', + buildNumber: '2745', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', supportsTablet: true, @@ -60,7 +60,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000531, + versionCode: 100000532, }, owner: 'trainlcd', experiments: { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 258e72287..8ca5a2965 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2407,7 +2407,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2446,7 +2446,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2505,7 +2505,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2611,7 +2611,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2690,7 +2690,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2940,7 +2940,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2991,7 +2991,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3042,7 +3042,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3100,7 +3100,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3151,7 +3151,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3208,7 +3208,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3256,7 +3256,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3307,7 +3307,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3526,7 +3526,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3582,7 +3582,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3632,7 +3632,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3690,7 +3690,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2744; + CURRENT_PROJECT_VERSION = 2745; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO;