実機3台と、消えたリクエスト ― 位置共有アプリ「qzira mesh」開発日誌
qzira mesh 開発日誌
個人開発でメッセージングアプリ「qzira mesh(クジラメッシュ)」を作っています。机の上には検証用のスマートフォンが3台。それぞれを「黒」「白」「ピンク」と呼んでいて、開発ログにもその呼び名のまま残っています。
この記事は、その3台で進めている開発の記録です。とくに「相手の現在地を聞く」という、シンプルに見えて実装するとやたら難しい機能について、設計で悩んだこと・実機で起きた事件・地味に効いた道具の話を書きます。
qzira mesh は「圏外でも、隣の人とはつながる」アプリ
qzira mesh は Flutter で書いたスマートフォンアプリで、中身は2本立てです。
- インターネット経由のリアルタイム通信(WebSocket。サーバーはエッジで動く軽量なもの)
- 近距離のBluetooth通信によるメッシュ(端末同士が直接、あるいはバケツリレーで届ける)
普段は普通のチャットアプリのように動き、電波が届かない場所では近くの端末同士で会話を中継する。災害時やイベント会場のような「回線はないが人はいる」場所を想定した設計です。1対1のメッセージは端末間で暗号化(E2EE)していて、サーバーは中身を読めません。
そのうえに乗る機能のひとつが、開発中ずっと「KIZUNA」と呼んでいる位置共有です。友だちのトークから「現在地を聞く」を選ぶと相手にリクエストが飛び、応じれば地図上に現在地が返ってくる。文字にすると2行ですが、この2行のために数十セッション分の作業ログが積み上がっています。
なぜ、開発機が3台必要だったのか
「位置を聞く」機能は、送る側と受け取る側の両方を同時に目で見ないと、何が起きているか分からないタイプの機能です。
「リクエストを送ったのに返ってこない」という現象ひとつでも、原因の候補はこれだけあります。
- そもそも送信側から出ていない
- 出ているがサーバーで落ちている
- 届いているが受信側アプリが無視している
- 受信側が処理はしたが、画面に出していない
- 返信は作られたが、送信側に戻っていない
片方の端末だけを見ていると、この5つは全部「何も起きない」という同じ見た目になります。だから黒(送る側=保護者役)と白(受け取る側=子ども役)を並べ、第三者からの見え方を確認するためにピンクを足しました。
エミュレータではダメなのか、とよく聞かれます。ダメでした。Bluetooth の実挙動、GPS、OSの通知、バックグラウンドに落ちたときの挙動は実機でしか再現しません。私が踏んだバグのほとんどは「エミュレータでは絶対に出ない種類」でした。
3台を「壊さずに・生かし続ける」という地味な課題
実機検証を続けていると分かるのですが、検証端末は外身も中身も激しく消耗します。朝からケーブルに繋いでは外し、机を滑らせ、位置情報テストのためポケットに入れて外を歩き、帰ってまた放る。一度、検証中の1台を駐車場のアスファルトに落としました。ガラスフィルムが割れて本体は無事、という典型的な救われ方をしたのですが、貼っていなかったらスプリントが1本飛んでいたはずです。
以来、フィルムは「保護具」ではなく「消耗品」として扱っています。割れたら替える。だから選ぶ基準も、性能より先に「貼りやすいか」「予備が付いているか」になりました。ホコリと格闘して30分溶かすのは、検証時間として惜しすぎる。
最近まとめ買いしているのは、ガイド枠付きで2枚入りのタイプです。枠をかぶせて上から落とすだけなので位置合わせで失敗せず、カメラレンズ用も付いてくるので机に置きっぱなしのレンズ側の傷も防げます。検証端末の機種に合わせて、iPhone 15用や13用などをそれぞれストックしています。
そして、画面と同じくらい直面するのがバッテリーの急速な劣化問題です。常時GPSを取得し、バックグラウンドでBluetoothのスキャンを回し続ける開発スタイルは、バッテリーにとって酷使そのもの。半日も経たずに残量が底をつき、バッテリーの最大容量もあっという間に削られていきます。
だからといって、バッテリーがへたるたびに新しい検証機へ買い替える余裕はありません。そのため、保証の切れた検証機は自分で互換バッテリーと付属の工具を使ってDIY交換し、延命させています。
最近使ったのは、大容量タイプで交換用の工具や動画解説までセットになっているものです。手順に沿って自分で作業すれば1日中ログを回しても耐えられるスタミナが復活するので、古い検証機を現役で稼働させ続けるための力強い味方になっています。
「位置を聞く」機能に、フェアネスを持ち込む
技術の話に戻ります。位置共有で一番長く悩んだのは暗号でもプロトコルでもなく、「聞く側だけが安全な位置にいるのは不公平ではないか」という点でした。片方が一方的に相手の現在地を取得できる仕組みは、便利であると同時に監視の道具にもなります。保護者と子どもという想定でも、それは変わりません。
そこで入れたのが、開発中「フェアロケーション」と呼んでいる仕組みです。考え方は単純で、現在地を聞くときは、聞く側の現在地も一緒に送る。尋ねられた側の画面には「この人はいま、ここにいます」が最初から見えている。一方向の取得を、双方向のやりとりに変える設計判断です。
実装は、リクエストのパケットに「フェア送信である」というフラグと、送り主の緯度・経度・取得時刻を足すだけ。ただし手を抜くと、フラグだけ立てて位置を送らない・座標を偽装する、といった抜け道ができます。なので3点を固めました。
- 全部あるか、全部ないか:フェアの項目は5つ揃って初めて有効。ひとつでも欠けたら受信側で破棄する
- 改ざん検知:追加分にも専用の署名を付ける。既存の署名には手を入れず、別の署名を1本増やす(古い版の端末と通信しても壊れないように)
- 鮮度は5分:取得時刻が5分以上ずれていたら受け取らない。過去も未来も同じ幅で切る
この「署名を1本足す」判断は、実装直前に出てきたものです。当初は既存の署名項目に追加する前提で設計書を書いていたのに、コードを読んだら署名対象が固定のホワイトリスト方式だった。ここで一度スプリントを止めて設計をやり直しました。止めたのは、いま振り返っても正しい判断でした。
事件:届いていたのに、届いていなかった
ここからが、この開発でいちばん印象に残っている出来事です。実機テストで、黒(送る側)から白(受け取る側)へ位置リクエストを1回送りました。白の画面には何も出ない。黒の画面にも何も起きない。完全な無反応です。
この時点での仮説は「通信が届いていない」。以前にも中継サーバー側の不具合でパケットが痩せる事象があったので、また同じ系統だろう、と。ところが送信直後に白のログを吸い出すと、こう並んでいました。
16:50:09.033 WebSocket受信 imadoko_request
16:50:09.033 公開鍵取得 成功 → HMAC検証OK
16:50:09.033 fair検証OK
16:50:09.034 imadoko_request: KIZUNA機能OFF → 無視
届いていました。署名の検証も、フェア項目の検証も、全部パスしている。1ミリ秒の間に4行が流れ、最後の1行で捨てられていたのです。
受信側の設定で「KIZUNA機能」がオフだったため、アプリはリクエストをデータベースに保存する前に破棄していました。例外もクラッシュもない。設計どおりに、静かに、無視していたわけです。
この「無視」は、書いた当時は正しい実装のつもりでした。オフの人の位置を勝手に返すわけにはいかないので返信はしない。それは今でも正しい。間違っていたのは、返信しないことと記録しないことを同じ扱いにしていた点でした。
オフにしている間に、誰かが居場所を心配して連絡してきた——その事実は残るべきです。返事は送らないけれど「◯月◯日◯時、この人があなたの現在地を聞きました」は見えていい。見えないと、オフの人は「誰にも呼ばれていない」と誤解してしまいます。
修正は「捨てる」を「残す」に変えるだけ、のはずだった
修正方針はこうなりました。オフのときも、
- 受け取ったリクエストは記録し、トーク画面に吹き出しとして表示する
- 吹き出しの2行目に「自動返信がオフのため、現在地は送信していません。」と明示する
- 返信処理は呼び出しごと通さない(送らないことを、条件分岐ではなく構造で保証する)
- 聞いた側にも「相手の設定によっては、返信されないことがあります。」と注記を出す
差分は数十行の単一の書き換えです。ただこの数十行を安全に入れるために、静的レビュー・テスト追加・ビルド・インストール・実機再検証とゲートを7つ通しました。個人開発でここまでやるのかと自分でも思いますが、位置情報の機能で「送らないはずが送られていた」は絶対に起こせません。
ログは78秒で流れていく
もうひとつ、実機検証で学んだ現実的な話を。Android のログバッファは有限です。qzira mesh は Bluetooth のスキャンログを大量に吐くので、ログが数分で一周して古い行が消えます。実測すると、残っている幅はおよそ78〜82秒でした。
つまり「あれ、動かないな」と思ってから落ち着いてPCに向かった時点で、証拠はもう消えている。実際、送信から2分49秒でログを取りに行って処理経路がまるごと失われました。しかもその2分49秒の内訳は「報告用のスクショを送ろうとして別ウィンドウが前面に出て、コマンドの入力状態が消えた」という、技術と関係のない事故です。
以来、時間制約のある採取ではコマンドを打ち込んだ状態で待機し、合図が来たらEnterを1回押すだけにしました。人間側の手順を先に固めないと、機械側の証拠は待ってくれません。実機検証は、こういう泥臭い話の集合体です。
ちなみにこの手の検証を1日に何度も繰り返すと、端末は本当にすり減ります。だから前述のとおり、フィルムやバッテリーの交換キットなど、消耗品とメンテ手段をあらかじめ準備しておくのが結局いちばん安上がりでした。1枚割れるたび、あるいはバッテリーが落ちるたびに検証を止めるより、はるかに健全です。
次にやること
位置共有まわりが一段落したので、次はチャット体験の地味な穴を埋めます。
- チャット一覧の整合性:未会話の友だちが出ない/履歴の全消しで相手ごと消える/初回送信で送信側に枠ができない
- 位置リクエストの導線改善:長押しメニューの中では見つけてもらえない。「聞く」行為の心理的な抵抗も含め言い回しから考え直す
- 双方向の履歴:いまは聞いた側の記録が数秒で消える通知にしか残らない。聞いた側のトークにも残したい
まとめ
今回いちばん学んだのは、「動かない」の中身は5種類くらいあるということでした。届いていないのか、届いて捨てられたのか、処理されたが表示されていないのか。区別できるのは、両側の端末を並べて両側のログを同時に見たときだけ。3台を買ったのは、その区別のためのコストでした。
そして、その3台を毎日ぞんざいに扱いながら壊さずに、かつスタミナを維持して稼働させ続けられているのは、ガラスフィルムやバッテリー交換キットのような数百〜数千円の手道具やメンテ手段のおかげだったりします。検証環境は「開発と直接関係ない部分」から崩れていく——というのが今回の副産物としての気づきでした。
qzira mesh はまだ開発中です。次はチャット一覧まわりの穴を埋めます。進捗はまたこのブログに書きます。


