diff --git a/AGENTS.md b/AGENTS.md
index 1dc2f9c88..1a07e6914 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,6 +37,42 @@ This handbook defines how automation agents collaborate safely and effectively o
- Do not treat CI after a push as a substitute for local validation before the
commit or push.
+### Publishing gate
+
+docs/・README・PR のタイトルと本文・レビュー返信・issue(他リポジトリのものを含む)
+は、push や投稿をした時点で取り消せない公開物になる。TrainLCD のリポジトリは公開
+されており、書いた内容はそのまま作者の設計判断として読まれる。文を足す前に次を
+満たすこと。
+
+- **出典の無い主張を書かない。** 書いてよいのは、(a) リポジトリのコードや設定
+ ファイル、(b) 実際に取得した一次情報(公開ページの原文、仕様書)、(c) メンテナの
+ 回答、のいずれかに辿れる内容だけ。辿れないものは書かずにタスクスレッドで聞く。
+ 記憶から書いた製品仕様・ストアの審査要件・条文の要旨は、原文を当たるまでは
+ 出典の無い主張として扱う。
+- **「未確認」「〜かもしれない」で逃げない。** アプリ・基盤・ポリシーはいずれも
+ 同じメンテナのものなので、自分たちの構成が不明である旨を公開物に書くのは事実に
+ 反するうえ、調べていないようにしか読めない。分からないことは書かず、聞けば済む。
+ ソースから確定できるものは、聞く前に読んで確定させる。
+- **実装から言えることと、運用の結果を混同しない。** 「ルータに定義が無い」はコード
+ の事実、「リクエストが届いていない」は運用の結果で、前者から後者は導けない。片方
+ しか確かめていないなら、確かめた方だけを書く。仮説を立てて別リポジトリを探しに
+ 行く前に、確定済みの事実で足りるかを見直す。
+- **引用は一次情報から取る。** 日付・条文・公開文書の文言は、要約ではなく原文を
+ 当たる。要約ツールの出力をそのまま引用として貼らない。
+- **メンテナに質問中の事項は公開しない。** 確認を出したなら、その答えに依存する
+ 記述は回答を得るまで書かない。質問と並行して公開すると、回答前に誤りが出回る。
+- **運用上の秘匿情報は粒度を落とす。** サーバの設置場所やネットワーク構成など、
+ 目的に対して不要な粒度は書かない。公開済みのプライバシーポリシーと同じ粒度に
+ 留める。
+- **レビューボットの指摘も検証してから従う。** 指摘が誤っていることもある。原文や
+ コードで裏を取り、違う対応を採るならその理由を返信に書く。裏取りをせずに指摘の
+ 文面をそのまま本文へ反映しない。
+
+公開済みの文に誤りや裏付けの無い記述を見つけたら、直すより先にタスクスレッドで
+報告する。どの記述が・なぜ問題で・どう直すつもりかを示し、メンテナの指示を得てから、
+push・PR 本文の編集・Issue の編集・訂正コメントやレビュー返信の投稿など、訂正内容を
+公開する操作を行う。自分の判断で訂正内容を公開しない。
+
## Repository Map
- `src/`: Expo React Native app code.
@@ -166,6 +202,16 @@ This repository is managed with **Git**, and agents drive version control throug
- [ ] Update cross-references (README, docs/) to prevent drift.
- [ ] Spell-check or self-review for clarity and typos.
+**Before publishing prose (docs, PR body, review replies, issues)**
+
+- [ ] Every claim traces to repository code, a primary source you fetched, or a maintainer answer.
+- [ ] No "未確認" / "かもしれない" hedges about our own app, backend, or policy; unknowns are questions, not sentences.
+- [ ] Code-level facts and runtime outcomes are not conflated.
+- [ ] Quotes come from the original text, not from a summary.
+- [ ] Nothing depends on a question you have asked and not yet had answered.
+- [ ] Operationally sensitive details are written at the same granularity as the public privacy policy.
+- [ ] Review-bot findings are verified against code or the original text before you act on them.
+
**For workflow, release, or CI updates**
- [ ] Cross-check `.github/workflows/` for consistency.
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 896b7c0e5..93f25ab03 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -161,12 +161,12 @@ android {
dimension "environment"
applicationId "me.tinykitten.trainlcd.dev"
versionNameSuffix "-dev"
- versionCode 100000763
+ versionCode 100000765
versionName "10.15.1"
}
prod {
dimension "environment"
- versionCode 100000763
+ versionCode 100000765
versionName "10.15.1"
}
// ストア配信する dev (Canary) と applicationId を分けた、ローカル検証専用のフレーバー。
@@ -177,7 +177,7 @@ android {
dimension "environment"
applicationId "me.tinykitten.trainlcd.local"
versionNameSuffix "-local"
- versionCode 100000763
+ versionCode 100000765
versionName "10.15.1"
}
}
diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts
index 2f8c3d6aa..82eda00ec 100644
--- a/android/wearable/build.gradle.kts
+++ b/android/wearable/build.gradle.kts
@@ -85,12 +85,12 @@ android {
dimension = "environment"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
- versionCode = 100000764
+ versionCode = 100000766
versionName = "10.15.1"
}
create("prod") {
dimension = "environment"
- versionCode = 100000764
+ versionCode = 100000766
versionName = "10.15.1"
}
// :app の local フレーバーと対になるローカル検証専用フレーバー。Wear の Data Layer は
@@ -100,7 +100,7 @@ android {
dimension = "environment"
applicationIdSuffix = ".local"
versionNameSuffix = "-local"
- versionCode = 100000764
+ versionCode = 100000766
versionName = "10.15.1"
}
}
diff --git a/app.config.ts b/app.config.ts
index d7e6f77d6..c61421af9 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -57,7 +57,7 @@ export default {
userInterfaceStyle: 'automatic',
// Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する
deploymentTarget: '16.4',
- buildNumber: '2911',
+ buildNumber: '2912',
scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD',
bundleIdentifier: IS_DEV
? 'me.tinykitten.trainlcd.dev'
@@ -71,7 +71,7 @@ export default {
? 'me.tinykitten.trainlcd.dev'
: 'me.tinykitten.trainlcd',
permissions: [],
- versionCode: 100000763,
+ versionCode: 100000765,
},
owner: 'trainlcd',
experiments: {
diff --git a/docs/README.md b/docs/README.md
index db9abf419..df0f2c65f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,6 +13,7 @@
- [本番リリース PR でのバージョン更新](./bump-version-on-release-pr.md)
- [AI コードレビュー (GPT-5.6 Sol)](./ai-code-review-workflow.md)
- [Firebase Test Lab の Robo テスト](./test-lab-robo.md)
+- [テレメトリの本番オプトイン: リスク評価](./telemetry-production-optin-risk.md)
- [AIエージェント設計書](./spec/ai-agent/architecture.md)
- [リモートTTS(iOS)設計書](./spec/tts/remote-tts.md)
- [オンデバイスTTS(VOICEVOX / iOS)設計書](./spec/tts/on-device-tts-ios.md)
diff --git a/docs/telemetry-production-optin-risk.md b/docs/telemetry-production-optin-risk.md
new file mode 100644
index 000000000..9316b5d92
--- /dev/null
+++ b/docs/telemetry-production-optin-risk.md
@@ -0,0 +1,185 @@
+# テレメトリの本番オプトイン: リスク評価
+
+試験的機能として canary 限定で動いているテレメトリを、本番 (App Store / Google Play
+配布ビルド) でもオプトイン可能にする場合に何が起きるかを整理したもの。実装前の
+意思決定用メモであり、この文書の時点でコードには一切手を付けていない。
+
+結論として、**プライバシーポリシーの改定は必須**で、かつそれだけでは足りない。
+コード側の前提を 3 点変えないと本番投入は危険。
+
+## 現状のゲート
+
+```text
+isTelemetryEnabledByBuild = isDevApp && ENABLE_EXPERIMENTAL_TELEMETRY === 'true'
+```
+
+- `src/utils/telemetryConfig.ts`: ビルド時定数。canary (dev アプリ) でのみ true
+- `src/screens/AppSettings.tsx`: 試験的機能の導線自体が `isDevApp` でガードされている
+- `src/screens/ExperimentalSettings.tsx`: ユーザー側トグル。既定は false (オプトイン)
+- `src/components/Permitted.tsx`: MMKV (`STORAGE_KEYS.TELEMETRY_ENABLED`) から復元
+
+つまり「本番でオプトイン」は、ビルド時ゲートを外すことと、試験的機能画面の
+`isDevApp` ガードを外す (あるいはトグルを通常の設定画面へ出す) ことの両方を指す。
+
+これとは別に、送信先 URL (`EXPERIMENTAL_TELEMETRY_ENDPOINT_URL`) が空なら、下に挙げる
+送信経路はいずれも早期 return する。コンソールテレメトリの場合は `console.*` の差し替え自体が
+行われない。ビルド時ゲートを外しても、本番ビルドへ URL を焼き込まない限り送信は
+起きない。
+
+## オプトインすると実際に何が送信されるか
+
+| 経路 | 中身 | 頻度 |
+| --- | --- | --- |
+| `sendLocation` | 緯度・経度 (生の精度)、accuracy、速度、lineId / stationId、移動状態、バッテリー残量・充電状態、端末モデル名、アプリバージョン (ビルド番号込)、OS、channel、sessionId | 最短 1 秒に 1 回 (`TELEMETRY_THROTTLE_MS = 1000`) |
+| `sendLogEvent` / `sendInteractionEvent` | アプリ内ログ、`app_launch` などの操作イベント | 随時 |
+| `useConsoleTelemetry` | `console.log` / `debug` / `warn` / `error` の全出力 | 5 秒ごとにフラッシュ |
+
+送信先はいずれも `EXPERIMENTAL_TELEMETRY_ENDPOINT_URL`。GraphQL ミューテーション
+(`/graphql`) と、コンソールテレメトリだけが使う REST (`/api/log`) の 2 系統がある。
+
+ただし後者には対応するルートが無い。THQ (`TrainLCD/THQ`) のルータに定義されて
+いるのは `/`、`/ws`、`/healthz`、`/graphql` の 4 つで、`/api/log` は含まれない
+(`src/server.rs`)。GraphQL 側には同じ役割の `sendLogEvent` があり、そちらは
+`sessionId` / `appVersion` / `platform` / `channel` も受け取れる。クライアント側は
+カスケードエラーを防ぐため送信結果を見ないので、この不一致はアプリ側からは
+分からない。どちらへ寄せるかの方針決めは TrainLCD/THQ#38。
+
+### 同意文言と取得範囲のズレ
+
+トグルの説明は `telemetryDescription` (`assets/translations/ja.json:351`,
+`assets/translations/en.json:350`) の「お使いの端末の地理的座標を解析用サーバに
+送信します」だけ。バッテリー・端末モデル・ログ本文・操作イベントは説明されて
+いない。同意を取る地点の文言が実際の取得範囲を下回っているので、ポリシーと
+あわせてこの文言も直す必要がある。
+
+## プライバシーポリシーとの矛盾
+
+現行ポリシー (`https://trainlcd.app/privacy-policy` / 最終更新 令和 8 年 8 月 13 日)
+には、本番テレメトリと正面から矛盾する記述が 2 つある。
+
+1. **「取得した位置情報は、駅の判定のために一時的に TinyKitten の管理するサーバーへ
+ 送信されますが、サーバー上に保管されることはありません。」**
+ 本番テレメトリは位置ログを解析基盤に永続化して集計するのが目的なので、この
+ 一文はそのままでは事実に反する。「解析目的で送信された位置情報は、利用者が
+ 明示的に有効化した場合に限り N 日間保管される」という趣旨へ書き換える。
+1. **「位置情報を、広告の配信や利用者様の行動の追跡 (トラッキング) のために
+ 利用することは一切ありません」**
+ 広告目的でないのは事実だが、1Hz の連続測位ログは移動軌跡そのもので、
+ sessionId によりプロセス単位で連結できる。「やらないこと」だけを書くのでは
+ なく、「セッション単位の移動軌跡を解析目的で保管する。ただし個人の特定・
+ 広告配信・アプリ横断の追跡には利用しない」と、やることを書いた上で否定する
+ 形にする。
+
+### 新設が必要な項目
+
+現行ポリシーに項目自体が無く、追記が要るもの。
+
+- 取得項目の追加: バッテリー残量・充電状態、端末モデル名、アプリバージョン、
+ アプリ内ログ本文、操作イベント
+- 保管期間の明示: 現状は「一定の期間」で数値が無い。位置ログは日数を切る
+- オプトアウト導線: 「端末の設定から停止できる」だけでなく、アプリ内設定から
+ 個別に無効化できることを明記する
+- 保管先: テレメトリ基盤 (THQ) の記載が無い。THQ は TinyKitten が管理する国内の
+ サーバで動き、Cloudflare 経由で公開している。保管も国内で完結し、第三者への
+ 提供も無いので、外国にある第三者への提供 (個人情報保護法 28 条) と、外国で
+ 取り扱う場合の「外的環境の把握」(安全管理措置) はどちらも論点にならない。
+ 経路に入る Cloudflare は現行ポリシーが既に外部サービスとして記載している
+- 開示・削除請求への応答方針: 後述のとおり、特定可能性の評価と手順の決めが要る
+
+## コード側で先に直すべき 3 点
+
+### 1. コンソールテレメトリは本番に出さない
+
+`useConsoleTelemetry` は `console.*` を横取りして送る。`babel.config.js` は
+production 環境のビルドで `transform-remove-console` を有効にし、`warn` / `error`
+以外の `console.*` を除去するので、本番ビルドでこの経路に乗るのは警告とエラーに
+なる。それでもサードパーティ製ライブラリのエラーや、ディープリンク URL・ユーザー
+入力を含む例外メッセージは `console.error` で出るため、何が入るかは制御できない。
+`sanitizeTelemetryMessage` の防御は正規表現 4 本
+(`src/utils/sanitizeTelemetryMessage.ts`) で、キー名が想定外の秘密情報や生の JWT は
+素通りする。
+
+加えて `/api/log` への送信ペイロードには `channel` も `sessionId` も入っていない
+ため、本番ログと canary ログが基盤側で区別できない。
+
+前述のとおり、この経路の送信先 `/api/log` は THQ のルータに無い。受け口を用意すれば
+上記の内容がそのまま流れ込むので、本番へ出す前にコンソール経路を `sendLogEvent` へ
+寄せるか、経路ごと落とすかを決めておく。
+
+対応は、本番では `FxConsoleTelemetry` (`src/screens/Main.tsx`) をマウントしない。
+出すなら `warn` / `error` 限定かつ送信元を許可リストで絞る作りに変えてから。
+
+### 2. 静的 Bearer トークンをやめる
+
+`EXPERIMENTAL_TELEMETRY_TOKEN` は Babel プラグイン (`babel.config.js` の
+`module:react-native-dotenv`) がビルド時に値へ置き換えるので、トークンはバンドルの
+中身そのものになる。canary は配布先が限られるため許容できていたが、本番は公開配布
+なので、アプリを入手した第三者は正規クライアントと同じ資格情報を持つことになる。
+書き込み権限を端末ごとに絞れないため、汚染された位置ログを後から見分ける手段も
+無い。
+
+`src/lib/session.ts` に installId を短期セッショントークンへ交換する仕組み
+(`/auth/token`) が既にあるので、これに乗せ替えるのが筋。
+
+### 3. リモートキルスイッチを付ける
+
+現在のゲートはビルド時定数なので、事故ってもストア審査を通さないと止められない。
+`REMOTE_CONFIG_KEYS.AI_AGENT_ENABLED` に「障害・コスト超過時にサーバー側から機能を
+止められるようにするキルスイッチ」という先例があるので、同じ形で
+`telemetry_enabled` を追加する。
+
+## その他のリスク
+
+### ストア申告
+
+App Store のプライバシーラベルと Google Play のデータセーフティは、アプリが実際に
+収集する内容と一致している必要がある。位置情報を解析目的で収集する状態になるので、
+申告内容を見直す。ATT が必要かどうかは「アプリ横断の追跡に使うか」「データブローカー
+へ提供するか」で決まるため、そこを広げない設計を前提にする。
+
+### 開示・削除請求への対応方針
+
+`sessionId` はプロセスごとの使い捨て UUID (`src/hooks/useTelemetrySender.ts` の
+`getOrCreateSessionId`) で端末に残らない。ただし「端末に ID が残らない」ことだけを
+根拠に特定不能と決めつけない。送信されるのは連続した移動軌跡と時刻・端末モデル・
+アプリバージョンなので、保管側の他の情報と突き合わせれば特定できる場合がある。
+まず照合可能性の評価が要る。
+
+そのうえで方針を決める。GDPR 17 条の削除請求に対して 11 条 (特定を要しない取扱い)
+を援用できるのは、特定できないことを管理者が示せる場合に限られ、請求者が特定に
+足る追加情報を出してきたときは同条を理由に一律で断れない。請求を受けたときの手順
+(申請内容との照合、対象データの特定、削除可否と理由の記録、特定できない場合の説明、
+追加情報が出てきたときの再評価) を決めておく。削除に応じられる形にするなら
+installId 紐づけへ変える選択肢もあるが、匿名性は下がる。基盤側の論点として
+TrainLCD/THQ#37 に起票済み。
+
+### コストと電池
+
+スロットル (`TELEMETRY_THROTTLE_MS`) が効くのは `sendLocation` だけで、最大 1 秒 1
+リクエストに制限されるものの、バッチングもリトライ制御も無い。毎秒の測位が続けば
+30 分乗車で 1 ユーザーあたり 1,800 リクエストになる。他の経路はこの制限の外にあり、
+`sendLogEvent` / `sendInteractionEvent` は呼ばれるたびに送信し、コンソール経路は
+5 秒ごとのフラッシュでキューに溜まったログ 1 件につき 1 リクエストを出す。送信は画面を見ている間に限らない。`index.js` の `TaskManager`
+タスクが `handleTrackingLocation` 経由で `locationAtom` を更新し、
+`FxTelemetrySender` がそれを購読しているため、バックグラウンド測位が動いている
+あいだは測位のたびに送信が続く。canary の母数では問題にならなかった数字が、本番の DAU では桁が
+変わる。本番はサンプリング (対象ユーザーの N 分の 1) か送信間隔の緩和 (5〜10 秒)
+が現実的。省電力測位モードの効果を毎秒の通信で部分的に打ち消す点も検討が要る。
+
+### 同意のスコープクリープ
+
+MMKV に `'true'` が永続化されるだけなので、将来取得項目を増やしたときに古い同意が
+そのまま流用される。同意バージョンを持たせ、範囲を広げるときは再同意を取る設計に
+しておく。
+
+## 推奨する出し方
+
+段階を踏むなら、次の構成で本番へ出す。
+
+- 位置イベントのみ (コンソールテレメトリとインタラクションイベントは canary 据え置き)
+- サンプリングまたは送信間隔の緩和
+- Remote Config によるキルスイッチ
+- セッショントークン認証
+
+プライバシーポリシーはこの「位置イベントのみ」の範囲で書き換え、取得項目を
+増やすときに改めて改定する。
diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj
index dff3abb1c..2b3c3bd9e 100644
--- a/ios/TrainLCD.xcodeproj/project.pbxproj
+++ b/ios/TrainLCD.xcodeproj/project.pbxproj
@@ -2550,7 +2550,7 @@
CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = E6R2G33Z36;
INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist;
@@ -2590,7 +2590,7 @@
CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEVELOPMENT_TEAM = E6R2G33Z36;
INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TrainLCD;
@@ -2649,7 +2649,7 @@
CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -2756,7 +2756,7 @@
CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -2837,7 +2837,7 @@
CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = E6R2G33Z36;
INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist;
@@ -2877,7 +2877,7 @@
CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = E6R2G33Z36;
INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist;
@@ -3089,7 +3089,7 @@
CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = E6R2G33Z36;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -3141,7 +3141,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = E6R2G33Z36;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -3192,7 +3192,7 @@
CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = E6R2G33Z36;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -3250,7 +3250,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = E6R2G33Z36;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -3301,7 +3301,7 @@
CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = E6R2G33Z36;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -3358,7 +3358,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = E6R2G33Z36;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -3407,7 +3407,7 @@
CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = E6R2G33Z36;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -3459,7 +3459,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = E6R2G33Z36;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -3678,7 +3678,7 @@
CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = E6R2G33Z36;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -3734,7 +3734,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = E6R2G33Z36;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -3784,7 +3784,7 @@
CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = E6R2G33Z36;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -3842,7 +3842,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
- CURRENT_PROJECT_VERSION = 2911;
+ CURRENT_PROJECT_VERSION = 2912;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = E6R2G33Z36;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
diff --git a/package-lock.json b/package-lock.json
index db9656854..069393955 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"expo-battery": "~57.0.2",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.14",
+ "expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.10",
"expo-crypto": "~57.0.2",
"expo-device": "~57.0.1",
@@ -8965,6 +8966,17 @@
"node": ">=10"
}
},
+ "node_modules/expo-clipboard": {
+ "version": "57.0.2",
+ "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz",
+ "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-constants": {
"version": "57.0.14",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.14.tgz",
diff --git a/package.json b/package.json
index 0652df1f0..5b4af88ab 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"expo-battery": "~57.0.2",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.14",
+ "expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.10",
"expo-crypto": "~57.0.2",
"expo-device": "~57.0.1",
diff --git a/src/components/DevOverlay.test.tsx b/src/components/DevOverlay.test.tsx
index e4d2fb9b0..79cc0bb34 100644
--- a/src/components/DevOverlay.test.tsx
+++ b/src/components/DevOverlay.test.tsx
@@ -1,4 +1,4 @@
-import { act, render } from '@testing-library/react-native';
+import { act, fireEvent, render } from '@testing-library/react-native';
import * as Application from 'expo-application';
import { useAtomValue } from 'jotai';
import { Dimensions, StyleSheet } from 'react-native';
@@ -55,8 +55,14 @@ jest.mock('~/utils/etaPhaseNow', () => ({
getEtaPhaseNow: jest.fn(() => null),
}));
+// クリップボードは react-native core の非推奨 Clipboard を触るため、テストでは差し替える
+jest.mock('~/utils/clipboard', () => ({
+ copyTextToClipboard: jest.fn(),
+}));
+
// Import mocked hooks for type safety
import { useDistanceToNextStation, useNextStation } from '~/hooks';
+import { copyTextToClipboard } from '~/utils/clipboard';
const mockUseAtomValue = useAtomValue as jest.MockedFunction<
typeof useAtomValue
@@ -72,6 +78,9 @@ const mockUseNextStation = useNextStation as jest.MockedFunction<
const mockGetEtaPhaseNow = getEtaPhaseNow as jest.MockedFunction<
typeof getEtaPhaseNow
>;
+const mockCopyTextToClipboard = copyTextToClipboard as jest.MockedFunction<
+ typeof copyTextToClipboard
+>;
describe('DevOverlay', () => {
const mockDimensionsGet = jest.spyOn(Dimensions, 'get');
@@ -291,6 +300,95 @@ describe('DevOverlay', () => {
});
});
+ describe('診断情報のコピー', () => {
+ // DevOverlay の COPIED_FEEDBACK_DURATION_MS と同値。exportしていないのでここで持つ
+ const COPIED_FEEDBACK_DURATION_MS = 1500;
+
+ beforeEach(() => {
+ mockCopyTextToClipboard.mockResolvedValue(true);
+ });
+
+ it('ボタンを押すと診断情報をクリップボードへ載せる', async () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ // コピーはPromiseを返すので、解決後の状態更新までactの中で流す
+ await act(async () => {});
+
+ expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1);
+ const copied = JSON.parse(mockCopyTextToClipboard.mock.calls[0][0]);
+ // 座標だけでなく実効設定も載っていること。設定が無いと同じ測位でも
+ // 挙動を説明できないため、これが欠けると持ち出す意味が薄れる
+ expect(copied.config).toMatchObject({
+ maxPermitAccuracy: MAX_PERMIT_ACCURACY,
+ telemetryEnabled: true,
+ autoModeEnabled: false,
+ });
+ expect(copied.location.raw).toMatchObject({ accuracy: 15 });
+ expect(copied.build.appVersion).toBe(
+ `${Application.nativeApplicationVersion}(${Application.nativeBuildVersion})`
+ );
+ });
+
+ it('押した直後はCOPIED表示になり、一定時間で戻る', async () => {
+ jest.useFakeTimers();
+ try {
+ const { getByTestId, getByText, queryByText } = render();
+ expect(getByText('COPY')).toBeTruthy();
+
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ await act(async () => {});
+ expect(getByText('COPIED')).toBeTruthy();
+
+ act(() => {
+ jest.advanceTimersByTime(COPIED_FEEDBACK_DURATION_MS);
+ });
+ expect(queryByText('COPIED')).toBeNull();
+ expect(getByText('COPY')).toBeTruthy();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('成功直後にコピーが失敗したらCOPIED表示とタイマーを解除する', async () => {
+ // 成功のタイマーが生きている間に失敗すると、古い表示が残って
+ // 「最後のコピーは失敗しているのにCOPIEDに見える」状態になる
+ jest.useFakeTimers();
+ try {
+ const { getByTestId, getByText, queryByText } = render();
+
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ await act(async () => {});
+ expect(getByText('COPIED')).toBeTruthy();
+
+ mockCopyTextToClipboard.mockResolvedValue(false);
+ act(() => {
+ jest.advanceTimersByTime(COPIED_FEEDBACK_DURATION_MS / 2);
+ });
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ await act(async () => {});
+
+ expect(queryByText('COPIED')).toBeNull();
+ expect(getByText('COPY')).toBeTruthy();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('クリップボードへ載せられなかった場合はCOPIEDを出さない', async () => {
+ // 失敗しているのに成功表示を出すと、貼り付けてみるまで気付けない
+ mockCopyTextToClipboard.mockResolvedValue(false);
+ const { getByTestId, getByText, queryByText } = render();
+
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ await act(async () => {});
+
+ expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1);
+ expect(queryByText('COPIED')).toBeNull();
+ expect(getByText('COPY')).toBeTruthy();
+ });
+ });
+
describe('D&D座標変換', () => {
it('物理横向きではドラッグ量をright/top基準の移動量に変換する', () => {
expect(getDevOverlayDragTranslation(24, 10, false)).toEqual({
diff --git a/src/components/DevOverlay.tsx b/src/components/DevOverlay.tsx
index 7a67f909d..83072c378 100644
--- a/src/components/DevOverlay.tsx
+++ b/src/components/DevOverlay.tsx
@@ -7,6 +7,8 @@ import {
Animated,
Easing,
PanResponder,
+ Platform,
+ Pressable,
type StyleProp,
StyleSheet,
type TextStyle,
@@ -29,11 +31,14 @@ import {
rawLocationAtom,
} from '~/store/atoms/location';
import { autoModeEnabledAtom } from '~/store/atoms/navigation';
+import { copyTextToClipboard } from '~/utils/clipboard';
+import { formatDevDiagnosticsSnapshot } from '~/utils/devDiagnosticsSnapshot';
import {
getDisplacementSpeed,
hasMeasuredSpeed,
} from '~/utils/displacementSpeed';
import { getEtaPhaseNow } from '~/utils/etaPhaseNow';
+import { isDevApp } from '~/utils/isDevApp';
import AccuracyHistoryChart from './AccuracyHistoryChart';
import Typography from './Typography';
@@ -45,6 +50,9 @@ const EXPAND_DURATION = 280;
const ACCURACY_CHART_SAMPLE_INTERVAL_MS = 1000;
const ACCURACY_CHART_LIMIT = 12;
+// 「コピーした」表示を出しておく時間(ms)
+const COPIED_FEEDBACK_DURATION_MS = 1500;
+
const PANEL_BORDER = 'rgba(255,255,255,0.18)';
const PANEL_BG = 'rgba(7, 11, 24, 0.78)';
const LABEL_COLOR = 'rgba(199, 210, 254, 0.72)';
@@ -163,6 +171,19 @@ const styles = StyleSheet.create({
borderWidth: 1,
minWidth: 72,
},
+ copyButton: {
+ borderRadius: 999,
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderWidth: 1,
+ minWidth: 72,
+ borderColor: 'rgba(148, 163, 184, 0.45)',
+ backgroundColor: 'rgba(30, 41, 59, 0.55)',
+ },
+ copyButtonPressed: {
+ borderColor: 'rgba(56, 189, 248, 0.6)',
+ backgroundColor: 'rgba(14, 165, 233, 0.28)',
+ },
statusLabel: {
color: 'rgba(226, 232, 240, 0.78)',
fontSize: 8,
@@ -502,6 +523,64 @@ const DevOverlay: React.FC = ({ unrotated = false }) => {
const versionLabel = `TrainLCD DO ${Application.nativeApplicationVersion}(${Application.nativeBuildVersion})`;
const telemetryValue = isTelemetryEnabled ? 'ON' : 'OFF';
const backgroundValue = isBackgroundLocationTracking ? 'ON' : 'OFF';
+
+ // 診断情報をクリップボードへ載せたことの一時的なフィードバック。
+ // タイマーはアンマウントと連打で必ず張り直す(残ると解除済みの状態を書きに行く)。
+ const [hasCopied, setHasCopied] = useState(false);
+ const copiedTimerRef = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (copiedTimerRef.current !== null) {
+ clearTimeout(copiedTimerRef.current);
+ }
+ },
+ []
+ );
+
+ const handleCopyDiagnostics = async () => {
+ // 実際に載ったときだけ COPIED を出す。失敗しているのに成功表示を出すと、
+ // 貼り付けてみるまで気付けない。
+ const copied = await copyTextToClipboard(
+ formatDevDiagnosticsSnapshot({
+ // レンダー中ではなくイベントハンドラ内なので Date.now() を直接読んでよい
+ nowMs: Date.now(),
+ appVersion: Application.nativeApplicationVersion ?? 'unknown',
+ buildNumber: Application.nativeBuildVersion ?? 'unknown',
+ channel: isDevApp ? 'canary' : 'production',
+ platform: Platform.OS,
+ osVersion: Platform.Version,
+ autoModeEnabled,
+ telemetryEnabled: isTelemetryEnabled,
+ backgroundLocationTracking: isBackgroundLocationTracking,
+ rawLocation,
+ filteredLocation: simulatedLocation,
+ accuracyHistory: chartHistory,
+ effectiveSpeedMps: effectiveSpeed,
+ hasMeasuredSpeed: hasEverMeasuredSpeed,
+ maxPermitAccuracy,
+ etaAssistEnabled,
+ etaPhase,
+ etaAnchor,
+ nextStation,
+ distanceToNextStation,
+ })
+ );
+ if (copiedTimerRef.current !== null) {
+ clearTimeout(copiedTimerRef.current);
+ copiedTimerRef.current = null;
+ }
+ if (!copied) {
+ // 直前の成功で立てた表示とタイマーをここで落とす。残すと、最後のコピーが
+ // 失敗しているのに前回のタイマーが切れるまでCOPIEDのままになる。
+ setHasCopied(false);
+ return;
+ }
+ setHasCopied(true);
+ copiedTimerRef.current = setTimeout(() => {
+ copiedTimerRef.current = null;
+ setHasCopied(false);
+ }, COPIED_FEEDBACK_DURATION_MS);
+ };
// ETA推定フェーズ(RUNNING/APPROACHING/DWELLING)を表示。フェーズ未推定時は IDLE。
const etaFallbackValue = etaPhase?.kind ?? 'IDLE';
// 推定対象の駅ID(走行/接近中は目標駅、停車中は当該駅)。
@@ -850,6 +929,27 @@ const DevOverlay: React.FC = ({ unrotated = false }) => {
value={backgroundValue}
style={statusPillStyle}
/>
+ {/* パネルのPanResponderはcaptureを使っていないため、子のPressableが
+ 先にタッチを取る。展開/折りたたみのトグルとは競合しない。
+ 折りたたみ中は上に載るcollapsedOverlayがタッチを受けるので押せない。 */}
+ {
+ void handleCopyDiagnostics();
+ }}
+ style={({ pressed }) => [
+ styles.copyButton,
+ statusPillStyle,
+ pressed && styles.copyButtonPressed,
+ ]}
+ >
+ DIAGNOSTICS
+
+ {hasCopied ? 'COPIED' : 'COPY'}
+
+
diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts
new file mode 100644
index 000000000..01eae4220
--- /dev/null
+++ b/src/utils/clipboard.ts
@@ -0,0 +1,19 @@
+import * as Clipboard from 'expo-clipboard';
+
+/**
+ * 文字列をクリップボードへ載せる。載せられたかどうかを返す。
+ *
+ * react-native の Clipboard は core から切り出され「将来のリリースで削除する」と
+ * 予告されている(参照するとその旨の警告が出る)。新しく使い始める先としては選ばない。
+ *
+ * 呼び出し側をこの1か所に閉じてあるので、載せ方を変えるときはこの関数の中だけで済む。
+ * 用途は DevOverlay の診断情報の持ち出しに限る(本番の画面からは呼ばない)。
+ */
+export const copyTextToClipboard = async (text: string): Promise => {
+ try {
+ return await Clipboard.setStringAsync(text);
+ } catch (error) {
+ console.warn('クリップボードへのコピーに失敗しました:', error);
+ return false;
+ }
+};
diff --git a/src/utils/devDiagnosticsSnapshot.test.ts b/src/utils/devDiagnosticsSnapshot.test.ts
new file mode 100644
index 000000000..9cd9ca299
--- /dev/null
+++ b/src/utils/devDiagnosticsSnapshot.test.ts
@@ -0,0 +1,152 @@
+import type * as Location from 'expo-location';
+import type { Station } from '~/@types/graphql';
+import {
+ buildDevDiagnosticsSnapshot,
+ type DevDiagnosticsInput,
+ formatDevDiagnosticsSnapshot,
+} from './devDiagnosticsSnapshot';
+
+const makeLocation = (
+ latitude: number,
+ longitude: number,
+ accuracy: number | null,
+ timestamp: number
+): Location.LocationObject => ({
+ coords: {
+ latitude,
+ longitude,
+ accuracy,
+ altitude: null,
+ altitudeAccuracy: null,
+ heading: null,
+ speed: 12.5,
+ },
+ timestamp,
+});
+
+const baseInput: DevDiagnosticsInput = {
+ nowMs: Date.UTC(2026, 8, 15, 4, 5, 6),
+ appVersion: '10.15.1',
+ buildNumber: '123',
+ channel: 'canary',
+ platform: 'ios',
+ osVersion: '18.2',
+ autoModeEnabled: false,
+ telemetryEnabled: true,
+ backgroundLocationTracking: true,
+ rawLocation: makeLocation(35.732538, 139.670653, 312, 1_700_000_000_000),
+ filteredLocation: makeLocation(35.7325, 139.6706, 312, 1_700_000_000_000),
+ accuracyHistory: [20, 45, 310],
+ effectiveSpeedMps: 12.5,
+ hasMeasuredSpeed: true,
+ maxPermitAccuracy: 1500,
+ etaAssistEnabled: false,
+ etaPhase: { kind: 'RUNNING', targetStationId: 9930135 },
+ etaAnchor: {
+ stationId: 9930134,
+ kind: 'DEPARTED',
+ observedAtMs: 1_700_000_000_000,
+ },
+ nextStation: { id: 9930135, name: '練馬' } as Station,
+ distanceToNextStation: '1,234',
+};
+
+describe('buildDevDiagnosticsSnapshot', () => {
+ it('実効設定を座標と一緒に持ち出す', () => {
+ // 設定が分からないと同じ測位でも挙動を説明できないため、
+ // 座標だけを持ち出せても診断には足りない
+ const snapshot = buildDevDiagnosticsSnapshot(baseInput);
+
+ expect(snapshot.config).toEqual({
+ maxPermitAccuracy: 1500,
+ etaAssistEnabled: false,
+ autoModeEnabled: false,
+ telemetryEnabled: true,
+ backgroundLocationTracking: true,
+ });
+ expect(snapshot.build).toEqual({
+ appVersion: '10.15.1(123)',
+ channel: 'canary',
+ platform: 'ios',
+ osVersion: '18.2',
+ });
+ });
+
+ it('フィルタ前の生の測位と、アプリが使っている測位の両方を持つ', () => {
+ // 片方だけだと「どの測位がどう補正されたか」が追えない
+ const snapshot = buildDevDiagnosticsSnapshot(baseInput);
+
+ expect(snapshot.location.raw).toMatchObject({
+ latitude: 35.732538,
+ longitude: 139.670653,
+ accuracy: 312,
+ timestamp: 1_700_000_000_000,
+ });
+ expect(snapshot.location.filtered).toMatchObject({
+ latitude: 35.7325,
+ longitude: 139.6706,
+ });
+ expect(snapshot.location.accuracyHistory).toEqual([20, 45, 310]);
+ });
+
+ it('測位が無い場合もnullで表現して壊れない', () => {
+ const snapshot = buildDevDiagnosticsSnapshot({
+ ...baseInput,
+ rawLocation: null,
+ filteredLocation: null,
+ });
+
+ expect(snapshot.location.raw).toBeNull();
+ expect(snapshot.location.filtered).toBeNull();
+ });
+
+ it('タイムスタンプをISO文字列でも併記する', () => {
+ // 生のミリ秒だけだと目視で時系列を追えない
+ const snapshot = buildDevDiagnosticsSnapshot(baseInput);
+
+ expect(snapshot.capturedAt).toBe('2026-09-15T04:05:06.000Z');
+ expect(snapshot.location.raw?.timestampISO).toBe(
+ new Date(1_700_000_000_000).toISOString()
+ );
+ });
+
+ it('タイムスタンプを持たない測位でも例外を出さない', () => {
+ // 診断情報の持ち出しで落ちては本末転倒。Date は不正な値へ toISOString すると送出する
+ const withoutTimestamp = {
+ coords: { speed: 10, accuracy: 15 },
+ } as unknown as Location.LocationObject;
+
+ const snapshot = buildDevDiagnosticsSnapshot({
+ ...baseInput,
+ rawLocation: withoutTimestamp,
+ });
+
+ expect(snapshot.location.raw).toMatchObject({
+ accuracy: 15,
+ timestamp: null,
+ timestampISO: null,
+ latitude: null,
+ });
+ });
+
+ it('ETAのフェーズとアンカーをそのまま持つ', () => {
+ const snapshot = buildDevDiagnosticsSnapshot(baseInput);
+
+ expect(snapshot.eta.phase).toEqual({
+ kind: 'RUNNING',
+ targetStationId: 9930135,
+ });
+ expect(snapshot.eta.anchor?.stationId).toBe(9930134);
+ });
+});
+
+describe('formatDevDiagnosticsSnapshot', () => {
+ it('貼り付けられる整形済みJSONを返す', () => {
+ const text = formatDevDiagnosticsSnapshot(baseInput);
+
+ expect(() => JSON.parse(text)).not.toThrow();
+ expect(JSON.parse(text)).toEqual(buildDevDiagnosticsSnapshot(baseInput));
+ // 読みながら貼れるようインデントする
+ expect(text).toContain('\n "config": {');
+ });
+});
diff --git a/src/utils/devDiagnosticsSnapshot.ts b/src/utils/devDiagnosticsSnapshot.ts
new file mode 100644
index 000000000..b1e3f64f5
--- /dev/null
+++ b/src/utils/devDiagnosticsSnapshot.ts
@@ -0,0 +1,129 @@
+import type * as Location from 'expo-location';
+import type { Station } from '~/@types/graphql';
+import type { EtaAnchor, EtaPhase } from './etaFallback';
+
+/**
+ * DevOverlay が表示している診断値を、そのまま貼り付けられる JSON へ組み立てる。
+ *
+ * 画面の数値を読み上げてもらう代わりに、判断に要る値を丸ごと持ち出せるようにするのが目的。
+ * 画面には出していない実効設定(リモート設定の値・プラットフォーム)も含める。設定が
+ * 分からないと同じ測位でも挙動を説明できないため、座標だけ持ち出しても再現できない。
+ *
+ * ここで持ち出せるのは「その瞬間のスナップショット」であって、棄却された測位の履歴や
+ * 測位の出所(継続測位/補完測位)ではない。それらは setLocation / handleTrackingLocation の
+ * 判定箇所に記録を足さないと取れないので、本関数の対象外。
+ */
+
+export type DevDiagnosticsInput = {
+ /** 生成時刻(ms)。呼び出し側から渡してレンダーの純粋性を保つ */
+ nowMs: number;
+ appVersion: string;
+ buildNumber: string;
+ /** canary(開発ビルド)か production か */
+ channel: 'canary' | 'production';
+ platform: string;
+ osVersion: string | number;
+ autoModeEnabled: boolean;
+ telemetryEnabled: boolean;
+ backgroundLocationTracking: boolean;
+ /** 継続測位の生の値(フィルタ前) */
+ rawLocation: Location.LocationObject | null;
+ /** フィルタ・スムージングを通した、アプリが現在地として使っている値 */
+ filteredLocation: Location.LocationObject | null;
+ /** DevOverlay のチャートが持つ精度履歴(古い順) */
+ accuracyHistory: number[];
+ /** 表示に使っている速度(m/s)と、それが実測かどうか */
+ effectiveSpeedMps: number;
+ hasMeasuredSpeed: boolean;
+ maxPermitAccuracy: number;
+ etaAssistEnabled: boolean;
+ etaPhase: EtaPhase | null;
+ etaAnchor: EtaAnchor | null;
+ nextStation: Station | null | undefined;
+ /**
+ * 次駅までの距離。useDistanceToNextStation は表示用に桁区切りした文字列
+ * (測位が無いときは 0)を返すので、型もそれに合わせる。ここで数値へ直すと
+ * フックの算出と二重に持つことになるため、表示値をそのまま持ち出す。
+ */
+ distanceToNextStation: string | number | null | undefined;
+};
+
+type CoordsSnapshot = {
+ latitude: number | null;
+ longitude: number | null;
+ accuracy: number | null;
+ speed: number | null;
+ timestamp: number | null;
+ timestampISO: string | null;
+} | null;
+
+/**
+ * ミリ秒をISO文字列にする。値が無い・不正なら null を返す。
+ *
+ * 診断情報の持ち出しで例外を出すわけにはいかない。Date は不正な値へ toISOString すると
+ * 送出するので、ここで必ず止める。OSやシミュレーションの経路によっては timestamp を
+ * 持たない測位が届きうる。
+ */
+const toISOStringOrNull = (ms: number | null | undefined): string | null => {
+ if (ms == null || !Number.isFinite(ms)) {
+ return null;
+ }
+ const date = new Date(ms);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+};
+
+const toCoordsSnapshot = (
+ location: Location.LocationObject | null
+): CoordsSnapshot => {
+ if (!location) {
+ return null;
+ }
+ return {
+ latitude: location.coords?.latitude ?? null,
+ longitude: location.coords?.longitude ?? null,
+ accuracy: location.coords?.accuracy ?? null,
+ speed: location.coords?.speed ?? null,
+ timestamp: location.timestamp ?? null,
+ timestampISO: toISOStringOrNull(location.timestamp),
+ };
+};
+
+export const buildDevDiagnosticsSnapshot = (input: DevDiagnosticsInput) => ({
+ capturedAt: toISOStringOrNull(input.nowMs),
+ build: {
+ appVersion: `${input.appVersion}(${input.buildNumber})`,
+ channel: input.channel,
+ platform: input.platform,
+ osVersion: String(input.osVersion),
+ },
+ // 実効設定。同じ測位でもこれが違えば挙動が変わるので、座標と必ずセットで持ち出す
+ config: {
+ maxPermitAccuracy: input.maxPermitAccuracy,
+ etaAssistEnabled: input.etaAssistEnabled,
+ autoModeEnabled: input.autoModeEnabled,
+ telemetryEnabled: input.telemetryEnabled,
+ backgroundLocationTracking: input.backgroundLocationTracking,
+ },
+ location: {
+ raw: toCoordsSnapshot(input.rawLocation),
+ filtered: toCoordsSnapshot(input.filteredLocation),
+ accuracyHistory: input.accuracyHistory,
+ effectiveSpeedMps: input.effectiveSpeedMps,
+ // 変位から算出した値か、測位が運んできた実測かを区別する
+ speedIsMeasured: input.hasMeasuredSpeed,
+ },
+ eta: {
+ phase: input.etaPhase,
+ anchor: input.etaAnchor,
+ },
+ derived: {
+ nextStationId: input.nextStation?.id ?? null,
+ nextStationName: input.nextStation?.name ?? null,
+ distanceToNextStation: input.distanceToNextStation ?? null,
+ },
+});
+
+/** クリップボードへ載せる文字列。読みながら貼れるよう整形する */
+export const formatDevDiagnosticsSnapshot = (
+ input: DevDiagnosticsInput
+): string => JSON.stringify(buildDevDiagnosticsSnapshot(input), null, 2);