SafariDrop — iPhoneのSafariからAndroidへワイヤレス転送するアプリを作った

1. SafariDrop とは

1-1. 開発の背景・課題

iPhoneで撮影した写真・動画をAndroidスマートフォンへ転送したい場面は意外と多くあります。しかし既存の手段はどれも一長一短です。AirDropはApple端末間のみ対応、Bluetoothは速度が遅い、USBケーブルは持ち歩きが必要、クラウドサービスは通信量と同期待ちが発生します。

SafariDropはこれらの課題を解決するために開発したAndroidアプリです。iPhoneとAndroidが同一Wi-Fiネットワークに接続されていれば、iPhoneのSafariブラウザからAndroid上のHTTPサーバーへ直接ファイルをアップロードできます。追加アプリのインストールも、iCloud契約も、ケーブルも不要です。

1-2. 既存ツールとの比較

手段ケーブル不要アプリ追加不要撮影日付保持大容量ファイルセキュリティ
SafariDrop✅(iPhone側)PIN認証あり
AirDropApple端末間のみ
Bluetooth転送❌(低速)ペアリング必要
USBケーブル物理接続
クラウド同期△(容量制限)サービス依存

2. 技術スタック

技術スタック UI 層 Jetpack Compose Material 3 MainScreen.kt ViewModel / 状態管理層 MainActivity.kt AppSettings.kt State / Flow サーバー / Repository 層 FileTransferServer.kt ServerForegroundService Ktor CIO データ層 Room DB MediaStore API SharedPreferences
カテゴリ技術・ライブラリバージョン役割
言語Kotlin最新安定版Androidアプリ全体
UIJetpack Compose + Material3宣言的UIフレームワーク
HTTPサーバーKtor CIO2.3.12iPhoneからのアップロード受信
DBRoom転送履歴の永続化
設定SharedPreferencesPIN・保存先・フラグ管理
ファイル保存MediaStore APIAndroid Q+撮影日付保持でGoogle Photos連携
QRコードZXing接続URLをQRコード表示
バックグラウンドForegroundService画面OFF時も受信継続

3. アーキテクチャ・データフロー

3-1. アーキテクチャ解説

SafariDropはMVVM(Model-View-ViewModel)に近い構成を採用しています。MainActivityが状態(State)を保持しコールバックを管理します。MainScreenはCompose UIで状態を表示するだけに留め、ビジネスロジックを持ちません。FileTransferServerはKtor CIOでHTTPサーバーを実装し、iPhoneからのPOSTリクエストを受信してMediaStoreへファイルを保存します。

アーキテクチャ・データフロー iPhone Safari Browser HTTP POST Ktor CIO FileTransferServer Stream MediaStore API (Android Q+) Pictures/ DCIM / Downloads Room DB 転送履歴 (Session/File) MainActivity State管理 / callback登録 MainScreen (Compose) サーバー制御・履歴・設定UI ServerForegroundService バックグラウンド稼働 / 通知

3-2. APIシーケンス

APIシーケンス図 Safari (iPhone) Ktor HTTP Server MediaStore Room DB GET / (HTML配信要求) 200 OK + HTML/JS/CSS POST /auth (PIN検証) Set-Cookie: session_token POST /upload (multipart + fileDate) insert(DATE_TAKEN, MIME_TYPE) insert TransferSession + ReceivedFile 200 OK {saved: filename} POST /done {success: N} {clientSuccess, serverReceived, match} Push通知 (NotificationManager)

3-3. DBスキーマ

Room DB スキーマ transfer_sessions 🔑 id: Long (PK, auto) deviceName: String startTime: Long endTime: Long? fileCount: Int successCount: Int totalBytes: Long isExpanded: Boolean clientIp: String received_files 🔑 id: Long (PK, auto) 🔗 sessionId: Long (FK) fileName: String fileSize: Long mimeType: String savedUri: String success: Boolean errorMessage: String? receivedAt: Long dateTaken: Long? 1:N AppDatabase (RoomDatabase) version=1 / exportSchema=false / entities=[TransferSession, ReceivedFile]

4. 画面構成・機能一覧

画面構成・機能一覧 MainScreen サーバー制御カード IP・QRコード・ON/OFF サーバー稼働カード 年月整理スイッチ / アドレス全幅表示 転送履歴リスト セッション別カード タップで展開 個別削除 / 全削除 左端カラーバー アクセスログ IP・時刻・ファイル名 設定で有効化 設定ダイアログ PIN認証・種別制限 自動停止・保存先 ☰ハンバーガーから開く 使い方ダイアログ 6ステップガイド TopAppBar ? ボタンから Safari Web UI ファイル選択・進捗・送信 全選択・リトライ・結果表示
機能説明実装箇所
サーバー起動・停止ボタン1つでKtorサーバーをON/OFFMainActivity + ServerForegroundService
QRコード表示接続URLをQRコードで表示してiPhoneで読み取りQrCodeGenerator.kt / ZXing
転送履歴セッション単位で履歴を保存、タップ展開、個別削除Room DB + MainScreen
PIN認証Safariアクセス時にPINを要求FileTransferServer / AppSettings
年月フォルダ整理ファイル日付でSafariDrop/YYYY-MM/に自動仕分けFileTransferServer
保存先設定Pictures/DCIM/Downloadsとフォルダ名を設定AppSettings + SettingsDialog
自動停止最後の操作から30分でサーバーを自動停止ServerForegroundService
アクセスログ接続IPとファイル名をアプリ内に記録FileTransferServer + MainScreen

5. 使い方ガイド

5-1. 初回セットアップ

  1. SafariDropアプリをAndroidにインストールします。
  2. iPhoneとAndroidを同じWi-Fiネットワークに接続します。
  3. SafariDropを起動し、「サーバー開始」ボタンをタップします。
  4. 表示されたIPアドレス(例: http://192.168.1.10:8080)またはQRコードをiPhoneのSafariで開きます。
  5. (任意)必要に応じて☰設定からPIN認証や保存先フォルダを設定します。

5-2. 主要機能の使い方

  1. iPhoneのSafariでアプリのURLを開き、「ファイルを選択」から写真・動画を選びます。
  2. 選択後、全ファイルの読み込みが完了するとアップロードボタンが有効になります(読み込み中はスピナーが表示されます)。
  3. 「アップロード」をタップするとファイルが10件ずつバッチ送信されます。
  4. 転送完了後、Androidに件数一致の確認結果とプッシュ通知が届きます。
  5. 転送履歴はAndroidアプリのリスト画面でセッション別に確認できます。

6. 主な実装上のポイント

6-1. NanoHTTPDからKtor CIOへの移行

初期実装はNanoHTTPDを使用していましたが、大容量ファイルのアップロード時にメモリ上に全データを展開する問題がありました。Ktor CIOへ移行することで、マルチパートストリームを直接ディスクに書き込み、大容量動画でも安全に転送できるようになりました。

// Ktor CIOによるストリーミング受信
routing {
    post("/upload") {
        val multipart = call.receiveMultipart()
        multipart.forEachPart { part ->
            if (part is PartData.FileItem) {
                part.streamProvider().use { input ->
                    saveFileFromStream(input, part.originalFileName ?: "file")
                }
            }
            part.dispose()
        }
    }
}

6-2. Google Photos撮影日付保持

MediaStoreにファイルを保存する際、DATE_TAKENを設定しないと転送日(当日)が撮影日として登録されてしまいます。SafariDropではiPhone側のJavaScriptでfile.lastModified(撮影日時のms)を送信し、Kotlin側でMediaStoreに反映します。

// Safari JS側: 撮影日付を送信
fd.append('fileDate', file.lastModified.toString())

// Kotlin側: MediaStoreにDATE_TAKENをセット
if (fileDate != null) {
    put(MediaStore.MediaColumns.DATE_TAKEN, fileDate)          // ms
    put(MediaStore.MediaColumns.DATE_MODIFIED, fileDate / 1000L) // 秒
}

6-3. iOS Safariの制約への対応

iPhoneのフォトピッカーで「追加」をタップしてからJavaScriptのchangeイベントが発火するまでの間、JSは一時停止します(iOSの仕様)。スピナーはこのタイミングでは表示できないため、ピッカーを閉じた後にposition:fixedの全画面オーバーレイで対応しました。またwindow.alert()はローカルIPでブロックされるため、カスタムHTMLモーダルを実装しています。

// changeイベント後(ピッカーが閉じてから)スピナーを表示
fileInput.addEventListener('change', async (e) => {
    document.getElementById('loadingOverlay').style.display = 'flex';
    // ... ファイル読み込み処理
    document.getElementById('loadingOverlay').style.display = 'none';
});

6-4. 保存先フォルダ設計の選択

保存先をユーザーが自由に選べるよう、SAF(Storage Access Framework)のフォルダピッカーも検討しましたが、DocumentFile経由ではMediaStoreを使わないためDATE_TAKENが設定できず撮影日付が失われます。そのためPictures/DCIM/Downloadsのドロップダウン選択方式を採用し、MediaStore経由を維持しています。

7. 使用技術の解説

技術概要SafariDropでの使い方
Ktor CIOKotlinネイティブのHTTPフレームワーク。CIOはCoroutines I/Oエンジンでストリーミングに強い。ポート8080でHTTPサーバーを立ち上げ、iPhoneからのファイルアップロードを受信
Jetpack ComposeAndroidの公式宣言的UIフレームワーク。状態が変わると自動でUI再描画(recompose)される。サーバー状態・履歴・設定ダイアログをすべてCompose UIで実装
RoomSQLiteのKotlinラッパー。Entity・DAO・Databaseの3層構造でDBを扱う。転送セッションとファイル情報を永続化し、履歴画面に表示
MediaStore APIAndroid標準のメディアファイル管理API。DATE_TAKEN等のメタデータも設定できる。写真・動画をPictures/DCIMに保存し、Google Photosへの自動インデックスを実現
ForegroundServiceユーザーが見える通知を表示しながらバックグラウンド処理を継続するAndroidの仕組み。アプリを閉じてもサーバーを稼働し続け、転送完了を通知バーに表示
ZXingQRコード生成・読み取りライブラリ。接続URLをQRコード画像に変換してAndroid画面に表示

8. 開発プロセス

フェーズ期間主な実装内容
Phase 1: 基盤構築2026-04-29NanoHTTPD HTTPサーバー、Compose UI、MediaStore保存、50件バッチアップロード
Phase 2: 転送確認2026-04-29ファイルごとのステータス表示、プッシュ通知、件数一致チェック
Phase 3: UI強化2026-04-29全選択・失敗ファイルリトライ、HTMLモックアップ
Phase 4: Ktor移行2026-04-29NanoHTTPD→Ktor CIO、ストリーミング保存、コルーチン対応
Phase 5: 5機能追加2026-04-29QRコード、フォアグラウンドサービス、セッション履歴、重複スキップ、転送速度
Phase 6: UX改善2026-04-29URL全幅表示修正、レイアウト改善、アプリ内ヘルプ画面
Phase 7: セキュリティ2026-04-30〜05-03年月フォルダ整理、ブラウザ閉じるボタン、PIN認証、種別制限、自動停止、アクセスログ
Phase 8: 品質向上2026-05-04ファイル選択UIスピナー、バッチ送信、エラーモーダル、件数上限撤廃
Phase 9: 保存先設定2026-05-13保存先フォルダUI、MIMEタイプ設定、DATE_TAKEN修正、DEVLOG追加

9. 今後の改善候補

  • 既存ファイルのDATE_TAKEN一括修正(ADB + ExifTool による過去の転送ファイルの撮影日付修正)
  • 転送済みファイルの一覧表示からダウンロード(Android→iPhone 逆方向転送)
  • 複数デバイスへの同時転送対応
  • HTTPS対応(自己署名証明書によるセキュア通信)
  • ファイル名の重複時のリネーム戦略設定(連番付与・上書き・スキップの選択)
  • 転送速度・残り時間のリアルタイム表示改善

10. 動作要件

  • Android端末: Android 8.0(API 26)以上。Android 10(API 29)以上でMediaStore API(DATE_TAKEN保持)が有効になります。
  • iPhone: iOS 14以上推奨。Safari最新版。追加アプリのインストールは不要です。
  • ネットワーク: iPhoneとAndroidが同一Wi-Fiに接続されている必要があります。モバイルデータ通信では動作しません。
  • ポート: HTTPサーバーはポート8080を使用します。ルーターのファイアウォール設定によっては追加設定が必要な場合があります。
  • 権限: WRITE_EXTERNAL_STORAGE(Android 9以下)、POST_NOTIFICATIONS(Android 13以上)、FOREGROUND_SERVICE が必要です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA