P2で確認したDG-PRO1RWSのNMEA受信、現在地更新、接続状態表示、航空写真、筆ポリゴン、オフライン地図をAndroidタブレット上で利用できる形にする。
P3の完了条件は次の6点とする。
standard版とトラクター専用端末向けのdedicated版を生成できることdedicated版ではタブレットの電源投入後、オペレーターが手動でアプリを起動しなくても使用可能になっていること(standard版は端末の通常利用を妨げない)配布・運用モードの正本はdocs/decisions/p3-android-deployment-modes.mdとする。製品仕様作成時は、この判断文書の「製品仕様へ必ず反映する要件」を入力条件として扱う。
以下は実装方式を覆す可能性があるため、フェーズ1で実機と照合する。
| 確認事項 | 暫定前提 | 確認方法 | 影響 |
|---|---|---|---|
| 対象タブレットの機種とAndroidバージョン | Android 12以上 | 設定画面とadb shell getprop ro.build.version.sdk |
minSdk、Bluetooth権限、バックグラウンド制約 |
| MapLibre Native Androidのバージョン | android-v11.8.8以上を使う |
Gradleの依存バージョンとrelease noteを確認 | PMTiles asset/local sourceのbyte range対応 |
| DG-PRO1RWSがAndroidから通常のBluetooth SPPで接続できるか | ペアリング済み端末からRFCOMM/SPP接続可能 | 既存のSPP確認アプリまたは最小スパイクでNMEAを受信 | 入力層全体 |
| SPPのService UUID | 標準SPP UUID 00001101-0000-1000-8000-00805F9B34FB |
実機接続と取扱説明書で確認 | RFCOMM socket作成 |
| 地理院航空写真の事前取得データをAndroidアプリに同梱してよいか | P2と同じく個人利用範囲の限定キャッシュ | 利用条件の再確認 | APK同梱または初回導入手順 |
| 画面をオンのまま30分使う運用か | 使用中は画面オン、アプリは前景 | 現地運用の確認 | Wake lock、foreground serviceの要否 |
最初は「ペアリングはAndroid設定画面で行い、アプリはペアリング済み機器だけを選択する」構成とする。アプリ内スキャンを行わないため、Android 12以上で必要な実行時権限は原則BLUETOOTH_CONNECTに限定できる。
DG-PRO1RWS
→ Bluetooth Classic RFCOMM/SPP
→ BluetoothNmeaSource
→ NmeaLineAssembler
→ NmeaParser
→ FixRepository (StateFlow<FixState>)
→ ViewModel
→ MapLibre MapView + Compose/Viewの状態パネル
APK assets
→ offline-basemap.pmtiles
→ fude-overlay.geojson
→ offline aerial-photo tiles
MapViewはAndroidViewでComposeに組み込むViewModel + StateFlowBluetoothSocket / RFCOMMMapLibre Android 11.7.0以降は仕様上pmtiles://asset://...とpmtiles://file://...形式でローカルPMTilesをデータソースにできる。MapLibre Native #3304で報告された、Android 11.8.2のasset/local providerが要求されたbyte rangeではなくPMTiles全体を返してinvalid headerや解凍エラーに至る問題は、修正PR #3404 Add support to range requests in AssetFileSourceが2025-05-13にmergeされ、2025-05-14公開のandroid-v11.8.8以降で修正済みである。そのため本実装ではandroid-v11.8.8以上を使う。
したがって、P2のoffline-basemap.pmtilesを変換せず再利用できることをネイティブ構成の利点に含める。加えて、Bluetooth SPPとlifecycleをAndroid APIで直接管理できること、JavaScript bridgeを追加せず地図と状態を一つの状態モデルから更新できることを根拠に、ネイティブ構成を本線候補とする。フェーズ1では指定バージョンとP2実データの組み合わせで動作することを確認する。
フェーズ1実施後の追記: android-v12.3.1(上記の修正条件を大きく超えるバージョン)でも、実機ではpmtiles://asset://...直読みがstd::runtime_error: incorrect header checkで再現性をもってクラッシュした。.pmtilesをAPK内で無圧縮化するnoCompress指定も単独では解消せず、アプリ内部ストレージへ初回コピーするpmtiles://file://...方式への切替が実質必須だった。根本原因は未特定(#3304/#3404の対象範囲外の別要因の可能性がある)。「P2のoffline-basemap.pmtilesを変換せず再利用できる」という利点自体は成立するが、配置方式はasset直読みではなくfile://を前提とする。詳細はdocs/decisions/p3-phase1-spike.mdを参照。
P2のHTML/JavaScriptは多くをそのまま使えるが、Python HTTPサーバーと/api/fixはAndroid上でそのまま動かない。結局、Bluetooth受信、NMEA解析、再接続、JavaScriptへの状態受け渡しをKotlinで新設する必要がある。また、WebViewで現行のPMTiles JavaScript実装を再利用する場合はHTTP Range相当のアセット配信が別途必要になる。
ただし、フェーズ1でMapLibre Nativeが対象実機で動かない、またはP2と同じ見た目を再現する工数が過大と判断した場合の退避案とする。その場合はWebViewAssetLoaderでHTTPS originとして静的アセットを配信し、file://とローカルファイルへの広いアクセス許可は使わない。
apps/android-tablet/
README.md
settings.gradle.kts
build.gradle.kts
gradle.properties
app/
build.gradle.kts
src/main/
AndroidManifest.xml
java/.../drogger/
MainActivity.kt
bluetooth/
BluetoothDeviceRepository.kt
BluetoothNmeaSource.kt
ReconnectPolicy.kt
nmea/
NmeaLineAssembler.kt
NmeaParser.kt
model/
FixState.kt
ConnectionState.kt
data/
FixRepository.kt
ui/
MainViewModel.kt
MapScreen.kt
StatusPanel.kt
assets/map/
map-style.json
offline-basemap.pmtiles # asset直読みは実機でクラッシュしたためコピー元として保持し、
# 実行時にfilesDir/map/へ初回コピーしてfile://で読む
fude-overlay.geojson
offline-tiles/... # XYZ asset直接参照で問題なし(フェーズ1で確認済み)
src/test/... # parser、状態、再接続のJVM test
src/androidTest/... # asset、地図、lifecycleの実機test
assets/map/のPMTilesはassetに置いたまま、実行時にアプリ内部ストレージへ初回コピーしたfile://で読む(フェーズ1で確認済み、詳細はdocs/decisions/p3-phase1-spike.md)。XYZタイル(航空写真)とGeoJSONはasset直接参照のままで問題ない。
Gradle Wrapperはリポジトリに含め、JDK/Android SDK/採用したGradleとAGPのバージョンをREADME.mdに固定する。パッケージ名は実装開始時に決める(フェーズ1では仮のdev.drogger.tabletを暫定採用し、実装を進めた。正式名称は未確定のまま)。
| P2成果物 | Androidでの扱い |
|---|---|
offline-basemap.pmtiles |
ファイル自体は変換せず再利用。asset直読みは実機でクラッシュしたため、アプリ内部ストレージへ初回コピーしたfile://で読む(フェーズ1で確認済み) |
fude-overlay.geojson |
APK assetに複製し、GeoJSON source + line layerで表示(asset直接参照のままで問題なし) |
offline-tiles/gsi-seamlessphoto/ |
APK assetでのXYZ raster source読み込みで問題なし(フェーズ1で確認済み、layer側にmaxzoomは付けない) |
map-config.json |
設定値と出典を移植。MapLibre GL JS固有のstyle生成は移植しない |
fix_state.py |
状態モデルと無効Fix反映規則をKotlinに移植 |
line_assembler.py |
分割受信、CR/LF、ノイズへの挙動とtest caseをKotlinに移植 |
gnss_stream_stats.py |
GGA/RMC、checksum検証の仕様とtest caseをKotlinに移植 |
live_source.py |
有限バックオフの考え方を移植。TCPではなくRFCOMMに差し替え |
map.js |
UI要件、状態文言、マーカー更新条件を移植。JavaScript自体は再利用しない |
同じ大容量地図データをtools/map/static/とAndroid assetsの2か所で手作業更新すると不整合が起きる。実装時はGradleのSyncMapAssetsタスク、または一つの共通データディレクトリからの生成で同期し、ハッシュをtestで照合する。GitのsymlinkはAndroid/Windowsでの扱いが異なるため使わない。
FixStateはP2の/api/fixと同等の診断情報を持つ。
position: lat, lon, altitude
quality: fixValid, fixQuality, numSatellites
input: lastReceivedAt, sentenceCount, checksumFailCount
invalid: invalidFixCount, lastInvalidReason
error: lastError, errorCount
connection: starting | connected | disconnected | reconnecting | error
device: name, address
fix_quality=0とRMC status=Vは最新の有効座標を上書きしないstaleとして表示するが、接続状態そのものは変更しないInstantを優先し、UI表示時にローカル時刻へ変換するStateFlow<FixState>は不変オブジェクトとして更新し、Bluetooth reader threadとUI threadの共有可変状態を作らないBLUETOOTH_CONNECTの許可を確認する。拒否時は地図を表示したまま、接続に許可が必要と表示するDG-PRO1RWS...またはRWS...を選択する。名前だけで自動確定せず、初回は利用者が選択したMAC addressを保存するconnect()を呼ぶInputStream をチャンクで読み、line assemblerでNMEAセンテンスにするIOException、Bluetooth offを切断とし、1秒→2秒→5秒→10秒の有限バックオフで再接続するBluetoothSocket.close()でblocking IOを解除し、自動再接続しないBluetoothSocket.connect()とInputStream.read()はUI threadで実行しない。接続中にBluetoothが無効化された場合、権限が取り消された場合、アプリが再作成された場合もtest対象にする。
初期画面はP2の機能を絞り込まず、次を一画面に統合する。
屋外確認では、輝度最大、横/縦画面、手袋の有無、直射日光下における文字とコントラスト、タップ領域を記録する。色だけで接続状態を区別しない。
成果:
android-v11.8.8以上に固定し、PMTilesの初期表示と口神ノ川周辺のpan/zoomが動作することを確認するfile://で切り分ける検証:
./gradlew test / ./gradlew lint / ./gradlew assembleDebugこのフェーズでPMTiles、GeoJSON、SPPの3本柱を同時に確認し、後続実装後の方式変更を避ける。
成果:
FixState、FixRepository、ログ再生source検証:
samples/map-fixtures/minimal.nmeaの最終FixとカウントをP2と照合成果:
standard版とdedicated版を同一コードベースから生成するstandard版は通常ランチャーとして動作し、HOME intent、Device Owner、Lock Taskへ依存しないdedicated版はDevice OwnerとカスタムHomeを使い、端末電源投入後にオペレーターの手動起動なしで画面を表示する。必要な範囲だけLock Task modeを使用するBOOT_COMPLETED診断実装は実現可能性確認用であり、BroadcastReceiverからの単純なstartActivity()を製品方式にはしない検証:
standard版で端末のHome・他アプリ・通常操作を妨げないことを確認dedicated版をDevice Ownerとしてプロビジョニングし、実機の電源投入から手動操作なしにアプリが表示され使用可能になることを確認成果:
検証:
成果:
検証シナリオ:
| 層 | 主な対象 | 実機不要か |
|---|---|---|
| JVM unit test | parser、line assembler、FixState、stale判定、再接続policy | はい |
| repository test | fake streamからの状態遷移、停止、再接続 | はい |
| Robolectricまたはinstrumentation | 権限、lifecycle、ViewModel、asset参照 | 一部必要 |
| instrumentation / 手動 | MapLibre描画、PMTiles、GeoJSON、航空写真 | はい |
| 実機手動 | DG-PRO1RWS SPP、再接続、30分連続、屋外UI | いいえ |
スクリーンショットだけでは30分連続性の証跡にならない。開始/終了時刻、総センテンス数、checksum不整合数、再接続回数、最終Fix、エラーをアプリ内部の診断ログとして保存し、確認記録に転記する。診断ログには生NMEAや経路全体を既定で保存せず、必要な場合だけ明示的に有効化する。
| P3完了条件 | 実装 | 証跡 |
|---|---|---|
| 現在地表示・更新 | Bluetooth source → FixRepository → MapView | ログ再生testと実機受信記録 |
| 再接続 | RFCOMM有限バックオフ | fake stream testと実機電源off/on |
| 30分連続 | 前景中のreader継続と診断カウンタ | 開始/終了時刻付き記録 |
| 屋外の視認性・操作性 | 大きな操作領域、文字+色の状態表示 | 条件と所見を含む屋外確認記録 |
| 現地手順 | 権限、機器選択、復旧操作 | docs/procedures/の手順書 |
| 標準/専用端末運用 | Product Flavor、Device Owner、カスタムHome | 両APKのbuild test、専用端末の再起動試験、導入・復元手順 |
| リスク | 退避策 |
|---|---|
| 機種固有でRFCOMM接続できない | フェーズ1で最初に実機確認。他の入力方式は事実確認後に別trackで比較 |
| Androidのバックグラウンド制約で受信が止まる | 初期スコープは画面表示中。バックグラウンド継続が必要と確定したらforeground serviceを追加 |
PMTiles asset直読み(pmtiles://asset://...)が実機でクラッシュする(フェーズ1でandroid-v12.3.1でも再現、根本原因未特定) |
asset直読みは採用せず、アプリ内部ストレージへ初回コピーするpmtiles://file://...方式を既定とする。依存更新時はP2のPMTilesで表示とpan/zoomを回帰確認する |
| MapLibre Nativeでasset航空写真をXYZ参照できない | フェーズ1で検証し、MBTiles等の単一ファイル化または限定ローカル配信に切り替え |
| APKが大きくなる | 対象地域を限定し、地図データのサイズをCIで記録。必要なら初回導入パックに分離 |
| P2とAndroidでparserの挙動がずれる | 同じNMEA fixtureと期待値を両実装で使う |
| OSにプロセスを破棄される | 選択機器とUI設定を保存し、再起動後に再接続可能にする |
BOOT_COMPLETEDからstartActivity()を呼んでもAndroidのバックグラウンドActivity起動制限により前面表示されない(track #15で実機確認) |
フルスクリーンIntentは採用せず、dedicated版をDevice Owner+カスタムHomeとして構成する。standard版は手動起動または通知案内とする |
| Device Owner化に端末の初期セットアップ・復元作業が必要 | 専用端末版だけに限定し、導入前のデータ退避、プロビジョニング、通常端末への復元手順を用意する |
| 専用端末向けManifest設定が標準版へ混入し、普段使いを妨げる | Product FlavorでManifest/source setを分離し、standard成果物にHOME intentとDevice Admin componentがないことを自動検査する |
apps/android-tablet/に最小プロジェクトを作り、各フェーズでtest / lint / assembleDebugを維持する