0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android実機開発でAPK・スクショ・テキストをWindowsと往復するのが面倒なのでLANブリッジを作った

0
Posted at

背景

Windows PCでAndroidアプリを開発していると、実機との間で細かい往復作業が頻繁に発生します。

  • PCでビルドしたAPKを実機にインストールしたい
  • Androidで撮ったスクリーンショットをすぐPCに戻したい
  • PCで見ているURLやコマンドの断片を、ちょっとだけAndroid側にも渡したい

それぞれ別の手段(USB/adb、クラウドストレージ経由、チャットアプリへの自己送信など)でこなすことはできますが、毎回「今回はどれを使うんだっけ」となるのが地味に面倒でした。

そこで、この3つの往復だけに絞った小さなLAN上のbridgeを作りました。

何を作ったか

SafeDrop — Android Dev Bridge

Androidアプリ(Kotlin/Jetpack Compose)と、Windows側で動くNode.jsサーバーのペアです。同じ信頼できるLAN(自宅Wi-Fiなど)上で使うことを前提にしています。

Dropbox/AirDropのような汎用ファイル転送アプリを目指したものではなく、開発時・日常の短い往復作業に絞ったツールです。

3つの主要ワークフロー

PC → Android: APK配布

Windows側サーバーに登録したAPKを、Androidのブラウザから一覧表示してダウンロード・インストールできます。この経路自体はadb接続を必要としません。

PC ↔ Android: Text Inbox

IPアドレスやURL、コマンドの断片といった短いテキストを、PCとAndroidの間でやり取りできます。直近10件を保持し、それぞれコピー・再送信フォームへの読み込み・個別削除ができます。

Android → PC: スクリーンショット / ファイル共有

Androidの共有メニューからSafeDropを選ぶだけで、Windows側の指定フォルダにファイルが届きます。

なぜLAN上のHTTPにしたか

  • Android・Windowsどちらからも扱いやすい(ブラウザとHTTPさえあれば良い)
  • 毎回USB接続を要求しない
  • ローカルネットワーク内で完結する
  • サーバー側をNode.js標準モジュールだけの小さな1ファイルに保てる(http/https/fs/path/os/cryptoのみ使用、npm依存なし)

ただし、これは「安全な暗号化ファイル転送プロトコル」を目指したものではありません。信頼できるLAN内での利用を前提に、トークンによる誤送信防止程度の保護にとどめています(詳細は後述)。

技術的な話:同期I/Oでイベントループ全体が止まった話

開発中に見つけたバグの話です。SafeDropサーバーには、設定したWebアプリへのリンクを表示する「Hub」画面があり、各カードの表示可否を確認するために、対象パスの存在確認をしていました。

最初の実装では、この存在確認に同期版のfs.statSync()を使っていました。ここで、対象パスがWSL経由のUNCパス(\\wsl.localhost\...)のようなネットワーク越しの遅い/応答しないパスだった場合に問題が起きました。

計測結果(実測値):

  • 修正前: GET /(Hub画面のトップ)が 約80秒 かかる
  • さらに悪いことに、その約80秒の間に別途投げた GET /ping(本来ミリ秒で返る、fsアクセスなしの単純なエンドポイント)も 約78秒 ブロックされる

これは、Node.jsがシングルスレッドでイベントループを回している以上、同期的なfs.statSync()呼び出しが特定のリクエスト処理だけでなく、サーバー全体のイベントループを止めてしまうために起きます。UNCパスやネットワークドライブは、OS側のタイムアウト(今回の環境では約60〜80秒)に達するまで応答が返ってこないため、その間サーバーは他のリクエストを一切処理できなくなっていました。

修正は、この存在確認を非同期・タイムアウト付きにすることでした。実際のコード(pc-server/server.js)の該当部分を簡略化すると、次のような形です。

const APP_CARD_STAT_TIMEOUT_MS = 400;

function statWithTimeout(targetPath, timeoutMs, statFn = fs.promises.stat) {
  return new Promise((resolve) => {
    let settled = false;
    const timer = setTimeout(() => {
      if (settled) return;
      settled = true;
      resolve({ ok: false });
    }, timeoutMs);
    if (typeof timer.unref === "function") timer.unref();
    Promise.resolve()
      .then(() => statFn(targetPath))
      .then((stat) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        resolve({ ok: true, stat });
      })
      .catch(() => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        resolve({ ok: false });
      });
  });
}

// 複数のカードを並列・タイムアウト付きでチェックする
const availabilities = await Promise.all(
  items.map(item => appCardAvailable(item, statFn, timeoutMs))
);

ポイントは3つです。

  • fs.promises.stat()(非同期版)を使い、イベントループを塞がない
  • setTimeoutで400msの上限を設け、応答がなければ「利用不可」として早めに諦める
  • 複数カードがあってもPromise.allで並列にチェックし、直列に待たない

修正後の計測値:

  • GET /約0.4秒
  • 同時に投げた GET /ping約1.9ms(イベントループは塞がれていない)

得られた教訓は、「Node.jsサーバーのリクエスト処理経路に、ネットワークファイルシステムへの同期I/Oを置くと、そのリクエストだけでなくイベントループ全体が止まる」ということです。特にUNCパス・WSL越しのパス・ネットワークドライブなどは、ローカルディスクと同じ感覚でstatSyncしてはいけない、というのが今回の具体的な教訓でした(一般論として全ての同期I/Oが危険というより、ネットワーク越しの応答不定なパスに対して同期I/Oを使うのが危険、という話です)。

セキュリティ・プライバシーについて

正確に書いておきます。

  • 信頼できるLAN内での利用を前提にしています。ポートフォワーディングや公開リバースプロキシで、インターネットに直接公開することは想定していません。
  • デフォルトはプレーンHTTPです。暗号化したい場合はreceiver.config.jsonでHTTPSを明示的に設定する必要があり、その場合は自分でローカル証明書を用意する必要があります。
  • トークンによる保護は、あくまで「無関係な通信が誤って届くのを防ぐ」レベルのものです。悪意ある攻撃者がいる前提のネットワークでの利用は想定していません。
  • テレメトリ・アナリティクスの類は一切ありません。

現在の位置づけ・ダウンロード

セットアップ手順はリポジトリのREADMEに沿って進めてください(サーバーの展開・トークン設定・起動・Android側でのプロファイル追加、の順です)。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?