3_Androidタブレット対応_実装案.md のフェーズ3(Bluetooth接続の製品化)・フェーズ4(地図UIとオフライン化)に入る前に、Issue #15(親Issue #3、track #13/#14のadopted判断を受けた続き)の4領域を実機で観察・記録するための手順。
このtrackはフェーズ3・4の製品実装ではなく事前検証であり、自動再接続ロジックや製品UIは作り込まない。フェーズ1のスパイクコード(NmeaSpikeSource.kt/BluetoothSpikePanel.kt/MapScreen.kt)に最小限の計測・診断ログを追加し、その状態で実機を操作して観察結果を記録することが目的。
| 項目 | 内容 |
|---|---|
| フェーズ1の状態 | track #13 完了(outcome=adopted)。対象実機はRedmi Pad SE 8.7 (HyperOS 2.0.203.0.VHXMIXM, Android 15 / API 35, arm64-v8a) |
| フェーズ2の状態 | track #14 完了(outcome=adopted)。nmea/・model/・data/配下にNMEA parser・FixRepository・ReplayNmeaSourceのKotlin実装とJVM unit test 36件がある |
| 現状のBluetoothコード | BluetoothSpikePanel.ktがremember { NmeaSpikeSource(context) }で接続元を保持しており、ViewModel所有・DisposableEffectでのdisconnect()呼び出しがない。フェーズ2で作ったFixRepositoryとは未接続のまま(フェーズ3以降で接続する) |
| 現状のPMTilesコピー | MapScreen.ktのensurePmtilesCopiedToInternalStorageはdest.exists()のみで完了済み判定しており、コピー中断で生じた不完全ファイルをそのまま「完了済み」として扱う実装になっている(判断条件4の検証対象) |
| DG-PRO1RWS | 現物とタブレットのペアリング済み(フェーズ1で確認済み) |
| パッケージ名 | 仮のdev.drogger.tabletのまま(フェーズ1から未確定)。本手順のadbコマンドはこのパッケージ名を前提にする |
NmeaSpikeSource.ktに、以下の計測・診断ログを追加する。自動再接続やUI変更は行わず、Log出力と最小限の状態保持のみ行う。
companion objectにAtomicIntegerのインスタンスカウンタを追加し、生成時にinstanceIdを採番する。生成・disconnect()呼び出し時にLog.i(TAG, "instance=$instanceId created|disconnect() called")を出す(判断条件2「二重接続有無」の確認用。画面回転等で複数instanceが並行して動いていれば、異なるinstanceIdのログが同時に流れることで検知できる)connect()開始・成功・失敗の各時点でSystem.currentTimeMillis()を記録し、経過時間(ms)付きでログ出力するreadLoopで受信した行数をinstanceIdごとに累積カウントし、10行ごと(または5秒ごと)にLog.d(TAG, "instance=$instanceId heartbeat lines=$count lastLineAt=$lastLineAt")を出す(着手前レビュー反映。生成/切断ログだけでは「画面が切り替わって見えなくなった旧instanceが、実は今も受信を続けているか」が分からないため、生存中のinstanceが分かる低頻度ログを別途持つ)readLoopとconnect()の例外捕捉を広げ、CancellationExceptionは再throwしたうえで、それ以外の例外(IOException・SecurityException等)のクラス名・メッセージ・直前の正常受信からの経過時間をログ出力してからConnectionState.Failedにする(着手前レビュー反映。単純にcatch (e: Exception)にすると、Activity破棄等でscopeがcancelされた際のCancellationExceptionまで「異常切断」として記録してしまい、構造化された並行処理のキャンセル伝播も壊れる。if (e is CancellationException) throw eを先頭で行ってから残りの例外を記録する)lastLineAtを追加し、BluetoothSpikePanelに「最終受信からの経過秒」を表示する。Composeは時間経過だけでは再描画されないため、LaunchedEffectで1秒ごとにdelay(1000)してカウンタ用のmutableStateOf<Int>を更新するtickerを明示的に用意する(着手前レビュー反映。lastLineAtを保持するだけでは画面表示が固まったままになる)この段階のcommit境界: 「診断ログが入るが実機確認はまだ」で1コミット。./gradlew assembleDebug / ./gradlew lint / ./gradlew testが引き続き成功することを確認する。
MapScreen.ktのensurePmtilesCopiedToInternalStorageに、コピー開始・終了時刻と結果ファイルサイズ(bytes)をログ出力する処理を追加する。コピー完了判定ロジック自体(dest.exists()のみで判定する現状の実装)は変更しない。中断時の挙動を実際に観察するのが本trackの目的であり、ここで対策を実装すると観察対象が消えてしまうため。
この段階のcommit境界: ステップ1と合わせて1コミットでよい。
./gradlew installDebugで実機にインストールするadb logcat -s NmeaSpikeSource:* PmtilesCopy:*(Claudeが付けたログtagに合わせて調整)で診断ログが流れることを確認するadb devicesで実機(24075RP89G)が認識され、installDebugもそのまま実行できたINSTALL_FAILED_UPDATE_INCOMPATIBLEが発生した: 別環境/セッションで一度インストールされていたdev.drogger.tabletと署名が一致せず、そのままでは上書きインストールできなかった。adb uninstall dev.drogger.tabletで明示的にアンインストールしてからinstallDebugを再実行して解決したINSTALL_FAILED_USER_RESTRICTEDは「USB経由でインストール」「USBデバッグ(セキュリティ設定)」を有効化済みでも発生した: track #13の手順書は設定有効化のみを対策としていたが、実際は毎回のインストールで実機側に確認ダイアログが表示され、それを明示的に許可する必要があった(誤って「拒否」を押すと同じエラーで失敗し、再実行が必要になる)NmeaSpikeSourceとPmtilesCopyの同時tailではPmtilesCopyが埋もれる: 実機のNMEA受信レートは想定より高く(実測で約50〜60行/秒)、10行ごとのheartbeatログが頻発するため、起動時に1回しか出ないPmtilesCopyログがtailの範囲外に流れてしまった。adb logcat -d -s PmtilesCopy:*のように個別のtagで確認する方が確実領域①〜④の各シナリオ実施前に、以下が揃っていることを確認する。前のシナリオの後始末が漏れたまま次のシナリオに入ると、結果の原因切り分けができなくなるため、シナリオ間で毎回確認する(着手前レビュー反映)。
反復可能な項目は、共通のAndroid実機開発ツールを使って一括確認する。全項目が通ると終了コード0、1つでも失敗すると終了コード1になる。Issueへ結果を貼る場合は--markdownを付ける。ツールの位置づけ、詳しい使い方、失敗時の対処はAndroid実機開発 ベースライン確認ツールを参照する。
tools/android/check_android_baseline.sh
tools/android/check_android_baseline.sh --markdown
スクリプトはadb接続、Bluetooth権限・ON状態、単一process/instance、heartbeatの鮮度、PMTilesサイズ、Doze状態を確認する。DG-PRO1RWSの電源と地図・航空写真・筆ポリゴンの表示は自動判定できないため、出力末尾のMANUAL項目に従って目視確認する。
adb shell dumpsys package dev.drogger.tablet | grep BLUETOOTH_CONNECTの出力行にgranted=trueが含まれることを確認する(権限名が出力に含まれるだけでは拒否状態でも一致するため、許可状態そのものを確認する。着手前レビュー反映)adb shell pidof dev.drogger.tabletが1つのPIDのみを返し、アプリ内の接続元(instance)も1つであるadb shell dumpsys deviceidleで強制Dozeが解除されている(mForceIdle=false相当)判断条件1に対応。各シナリオを複数回行い、切断検出方法・例外・検出時間・手動再接続可否・復旧時間をlogcatタイムスタンプとあわせて記録する。
DG-PRO1RWS電源off/on: 接続中に本体電源を切り、再度入れる
タブレットBluetooth off/on: 設定画面からBluetoothを切り、再度入れる
socket close相当: アプリの「切断」ボタンを押す(正常系の比較対象として記録する)
接続中の権限取り消し: 実行前にadb shell pidof dev.drogger.tabletでPIDを記録してから、接続を維持したままadb shell pm revoke dev.drogger.tablet android.permission.BLUETOOTH_CONNECTを実行する。UIから設定 > アプリ > 権限でも同じ操作ができるが、adbの方が実行タイミングを制御しやすい。権限取り消しはOS側がアプリのprocessごと終了させる場合があるため、SecurityExceptionのログが出ることだけを期待せず、直後にadb shell pidof dev.drogger.tabletを再実行してPIDが変わっている(=processが再起動された)かprocess自体が消えたかも記録する(着手前レビュー反映)
試験後は必ず次の順で復元する(着手前レビュー反映。これを行わないと、続く電波範囲外試験やlifecycle試験を同じ初期条件で開始できない)。
adb shell pm grant dev.drogger.tablet android.permission.BLUETOOTH_CONNECTでBLUETOOTH_CONNECTを再許可するinstanceIdが1つだけで、受信が継続していることを確認する(単一インスタンスでの受信確認)電波範囲外への移動: DG-PRO1RWSまたはタブレットを持って離れ、Bluetooth通信が届かない距離まで移動する。読み込みがブロックしたまま戻らない可能性があるため、ステップ1で追加した「最終受信からの経過秒」表示で無反応状態を確認する。何分経過しても検出されない場合はその旨を記録する。
保留中: このPCとタブレット/DG-PRO1RWSが同じ電源環境にあり、USB(adb)接続を維持したまま物理的に離れられないため未実施。DG-PRO1RWSを電池駆動にする、またはadb tcpipでのワイヤレスadbに切り替えてから後日実施する。なお、タイムアウト監視の要否自体はこの実測を待たずに「必要」と確定済み(未決定事項を参照)。この実測は具体的な検出時間・タイムアウト閾値の妥当性を検証する目的で行う
判断条件2に対応。各操作後、logcatのinstanceIdが増えていないか(=新しいインスタンスが生成されているか)、古いinstanceIdからのログがまだ流れていないか(=二重接続)を確認する。
それぞれについて、受信継続の有無、disconnect()が呼ばれたか(呼ばれていなければsocketリークの可能性)、二重接続の有無を記録する。
instance=1のまま変化なし。受信は「最終受信からの経過: 0秒」を維持して継続し、二重接続なし、disconnect()も呼ばれなかった(呼ぶ必要がなかった)。回転単体ではリスクは再現しなかったinstance=1が生存(=旧インスタンスが破棄されない、remember{}ベースの現行実装の欠陥)。アプリ復帰時に生成された新instance=2は同じDG-PRO1RWSへ接続を試み、112ms後にIOException: read failed, socket might closed or timeout, read ret: -1で失敗。その6ms後、直前まで正常だったinstance=1もIOException: bt socket closed, read return: -1で切断された。DG-PRO1RWSは同時1接続までしか受け付けられず、2本目の接続要求で既存接続ごとリセットされたとみられる。両インスタンスとも失われ、UIから再接続してもボタン操作だけでは復旧できず、adb shell am force-stopによるプロセス完全終了と再起動が必要だったinstance=1のまま変化なし、heartbeatも一切のgapなく継続した(受信レートは約68行/秒で安定)。初回試行ではadb logcat -dのスナップショット取得で約9.8秒のログ欠落が見え、受信の一時停止を疑ったが、adb logcat -v time ... > fileによる継続的なファイル書き出しで再検証した結果、実際には受信・ログとも完全に継続していたと判明した。教訓: ロック/バックグラウンド関連の検証ではadb logcat -dのスナップショットではなく、継続的なファイル書き出しキャプチャを使うこと(スナップショットは実際には起きていない欠落を誤検知しうる)instance=1から採番し直され、単一インスタンスで正常に受信を再開した。設定UIの見た目(グレーアウト・タスク一覧残存)だけでは強制停止の成否は判断できず、adb shell pidofでPIDの変化を確認する方が確実領域②の結論: シナリオ2(Activity再作成)でのみ、判断条件2の仮説どおり二重接続による接続破壊が実機で再現した。フェーズ3では接続の所有者をViewModel/DisposableEffectで一元化し、画面破棄時に確実にdisconnect()する設計変更が必須という結論を強く支持する。
判断条件3に対応。同じシナリオ(前景30分、画面off 5〜10分)を、電池最適化設定を変えて複数回行う。
**初期状態(電池最適化の対象のまま)**で、前景30分の受信継続性を確認する
初期状態で、画面off 5〜10分の受信継続性を確認する。まず自然経過でのadb shell dumpsys deviceidleのDoze状態(mState等)を記録する。5〜10分の範囲では画面off・充電なし・静止だけではDozeへ自然遷移しない可能性があるため、自然経過の結果はそのまま記録したうえで、Doze自体の影響を切り分けたい場合はadb shell dumpsys deviceidle force-idleで強制的にDozeへ入れる補助試験を別条件として分離して記録する(着手前レビュー反映。自然条件と強制条件を混同しない)。
force-idleを使った場合は、確認が終わり次第、次の手順で必ず解除する(着手前レビュー反映。解除を忘れると以降の試験がすべて強制Doze下という異なる条件になる)。
adb shell dumpsys deviceidle unforceadb shell dumpsys deviceidleでDoze状態が解除されたことを確認する設定 > アプリ > (アプリ名) > バッテリー から「制限なし」等、電池最適化除外に変更し、ステップ1・2を再実行する
HyperOS固有の自動起動管理(セキュリティアプリまたは設定内の「自動起動」「バックグラウンド権限」に相当する画面)の初期状態(許可/拒否)を記録し、必要なら許可に変更してステップ1・2を再実行する
各条件での受信継続性(最終受信時刻の途切れ有無、disconnect()ログの有無)を比較表にする
adb logcat -dのスナップショットは長時間試験ではバッファ欠落を誤って「受信中断」と誤検知しうる(ステップ6シナリオ4の教訓)ため、本ステップは全てadb logcat -v time -s NmeaSpikeSource:* > file &による継続的なファイル書き出しで実施した。
| 条件 | 実施時間 | 結果 |
|---|---|---|
| 初期状態・前景30分 | 09:54:45〜10:35:16(約40.5分) | instance=1のまま変化なし、161,340行を約66行/秒で継続受信。切断・二重接続なし |
| 初期状態・画面off自然経過 | 10:39:42〜10:51:56(約12分13秒) | instance=1のまま変化なし、49,250行を約67行/秒で継続受信。ロック前後ともDoze状態はACTIVEのまま(自然遷移なし) |
| 初期状態・force-idle補助試験 | 10:55:01〜11:00:01(約5分) | 強制Doze中(mState=IDLE)を含む全期間で20,140行を約67行/秒で継続受信。gapなし |
| 電池最適化除外・前景30分 | 11:17:02〜11:52:02(約35分) | instance=1のまま変化なし、139,680行を約66.5行/秒で継続受信 |
| 電池最適化除外・画面off | 11:52:55〜12:04:58(約12分3秒) | instance=1のまま変化なし、47,720行を約66行/秒で継続受信。Doze自然遷移なし |
| HyperOS自動起動管理 | 初期状態確認のみ | 設定検索「自動起動」→「バックグラウンドでの自動起動」でOFF(拒否)。現行スパイクに自動再起動・自動再接続ロジックが存在しないため、ONへの変更・再試験は技術的に無意味と判断し省略した |
結論: 電池最適化・Doze(自然/強制)・自動起動権限のいずれの組み合わせでも、切断・二重接続・受信遅延は一切発生しなかった。現行のフォアグラウンド限定・スパイク実装の範囲では、Foreground Serviceや電池最適化除外案内が必須という結果にはならなかった。
将来への申し送り: トラクター等の実運用でタブレット電源投入時にアプリを自動起動させたい場合、HyperOSでは自動起動権限がOFFのままだと起動時の自動launchがブロックされる。この権限は現行スパイクの受信継続性には影響しないが、フェーズ3以降でForeground Service採用や起動時自動launchを検討する場合は、初回起動時の許可案内(オンボーディング)が必要になりうる。
判断条件4に対応。
初回コピー時間・容量: adb shell pm clear dev.drogger.tabletでアプリデータを削除し、起動してlogcatのPmtilesCopyログからコピー時間とファイルサイズを記録する。pm clearはBLUETOOTH_CONNECT等の実行時権限も消すため、このステップ以降で改めてBluetooth関連の確認に戻る場合は、アプリ起動時の許可ダイアログで再度許可するかadb shell pm grant dev.drogger.tablet android.permission.BLUETOOTH_CONNECTで再許可してから行う(着手前レビュー反映)
コピー中断のシミュレーション: 対象ファイルは小さく実コピーが一瞬で終わる可能性が高いため、実際の強制終了によるタイミング競合の再現性は低いと想定する。主な検証方法として、正常コピー後にファイルを意図的に不完全な状態にし、アプリを再起動して挙動(クラッシュ、白画面、エラーログ等)を観察する。
ファイル書き換えの前に必ずadb shell am force-stop dev.drogger.tabletでアプリを止め、MapLibreがファイルを使用していない状態にしてから書き換える(着手前レビュー反映。起動中のMapLibreが読んでいるファイルを直接書き換えると、このtrackで観察したい「再起動時にどう振る舞うか」とは別の予期しない挙動を引き起こしうる)。
まずadb shell run-as dev.drogger.tablet truncate -s 1024 files/map/offline-basemap.pmtilesが使えるか確認する。機種のtoybox実装によってはtruncateが無い場合があるため、その場合はadb shell run-as dev.drogger.tablet sh -c 'dd if=files/map/offline-basemap.pmtiles of=files/map/offline-basemap.pmtiles.trunc bs=1024 count=1 && mv files/map/offline-basemap.pmtiles.trunc files/map/offline-basemap.pmtiles'を代替手段とする(着手前レビュー反映)。実際の強制終了によるコピー中断は、pm clear直後に起動して即座にadb shell am force-stop dev.drogger.tabletを送るベストエフォートの補助確認として行う
再起動後の状態: 中断シミュレーション後、アプリを再起動して自動修復されるか、不完全ファイルのまま使われ続けるかを確認する
試験後の復元: 確認が終わったらadb shell pm clear dev.drogger.tabletまたは再インストールでアプリデータを初期化し、PMTilesが正常な状態から再コピーされ、通常どおり地図・航空写真・筆ポリゴンが表示されることを確認する(着手前レビュー反映。破損ファイルを残したまま次のシナリオに進まない)
機内モード表示: 上記の復元後、機内モードでPMTiles・航空写真・筆ポリゴンが表示されることを確認する(フェーズ1と同じ確認だが、電池最適化設定変更後の状態でも問題ないことをあわせて確認する)
診断ログ追加後、フェーズ1・2のビルドが壊れていないことを確認する。
./gradlew test
./gradlew lint
./gradlew assembleDebug
track_recordするdocs/decisions/p3-phase3a-preverify.md(新規作成)にまとめ、track_finishするadb logcat -s NmeaSpikeSource:* PmtilesCopy:* > <ファイル>のようにファイルへリダイレクトして保存するか、-b allでのバッファサイズ確認・拡張(adb logcat -G)を検討するInputStream.read()がタイムアウトなしに無限ブロックしうるという仮説自体はほぼ確実であり、位置情報表示アプリで無反応のまま古い位置が表示され続けるのは運用上のリスクが大きい。そのため判断条件5のうち「電波切れ時の状態遷移の扱い」は実測結果を待たず「タイムアウト監視を追加する」で確定し、フェーズ3実装の最初のコミットから組み込む。シナリオ5の実測(具体的な検出時間)は、電池駆動での再現後にタイムアウト閾値の妥当性を検証する目的で別途行うrun-asでのtruncate/dd)が実際の強制終了と同じ失敗モードになるとは限らない。差異が疑われる場合は次の検証案として明記する