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認証あり |
| AirDrop | ✅ | ✅ | ✅ | △ | Apple端末間のみ |
| Bluetooth転送 | ✅ | ✅ | △ | ❌(低速) | ペアリング必要 |
| USBケーブル | ❌ | △ | ✅ | ✅ | 物理接続 |
| クラウド同期 | ✅ | ❌ | ✅ | △(容量制限) | サービス依存 |
2. 技術スタック
| カテゴリ | 技術・ライブラリ | バージョン | 役割 |
|---|---|---|---|
| 言語 | Kotlin | 最新安定版 | Androidアプリ全体 |
| UI | Jetpack Compose + Material3 | — | 宣言的UIフレームワーク |
| HTTPサーバー | Ktor CIO | 2.3.12 | iPhoneからのアップロード受信 |
| DB | Room | — | 転送履歴の永続化 |
| 設定 | SharedPreferences | — | PIN・保存先・フラグ管理 |
| ファイル保存 | MediaStore API | Android 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へファイルを保存します。
3-2. APIシーケンス
3-3. DBスキーマ
4. 画面構成・機能一覧
| 機能 | 説明 | 実装箇所 |
|---|---|---|
| サーバー起動・停止 | ボタン1つでKtorサーバーをON/OFF | MainActivity + 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. 初回セットアップ
- SafariDropアプリをAndroidにインストールします。
- iPhoneとAndroidを同じWi-Fiネットワークに接続します。
- SafariDropを起動し、「サーバー開始」ボタンをタップします。
- 表示されたIPアドレス(例:
http://192.168.1.10:8080)またはQRコードをiPhoneのSafariで開きます。 - (任意)必要に応じて☰設定からPIN認証や保存先フォルダを設定します。
5-2. 主要機能の使い方
- iPhoneのSafariでアプリのURLを開き、「ファイルを選択」から写真・動画を選びます。
- 選択後、全ファイルの読み込みが完了するとアップロードボタンが有効になります(読み込み中はスピナーが表示されます)。
- 「アップロード」をタップするとファイルが10件ずつバッチ送信されます。
- 転送完了後、Androidに件数一致の確認結果とプッシュ通知が届きます。
- 転送履歴は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 CIO | KotlinネイティブのHTTPフレームワーク。CIOはCoroutines I/Oエンジンでストリーミングに強い。 | ポート8080でHTTPサーバーを立ち上げ、iPhoneからのファイルアップロードを受信 |
| Jetpack Compose | Androidの公式宣言的UIフレームワーク。状態が変わると自動でUI再描画(recompose)される。 | サーバー状態・履歴・設定ダイアログをすべてCompose UIで実装 |
| Room | SQLiteのKotlinラッパー。Entity・DAO・Databaseの3層構造でDBを扱う。 | 転送セッションとファイル情報を永続化し、履歴画面に表示 |
| MediaStore API | Android標準のメディアファイル管理API。DATE_TAKEN等のメタデータも設定できる。 | 写真・動画をPictures/DCIMに保存し、Google Photosへの自動インデックスを実現 |
| ForegroundService | ユーザーが見える通知を表示しながらバックグラウンド処理を継続するAndroidの仕組み。 | アプリを閉じてもサーバーを稼働し続け、転送完了を通知バーに表示 |
| ZXing | QRコード生成・読み取りライブラリ。 | 接続URLをQRコード画像に変換してAndroid画面に表示 |
8. 開発プロセス
| フェーズ | 期間 | 主な実装内容 |
|---|---|---|
| Phase 1: 基盤構築 | 2026-04-29 | NanoHTTPD HTTPサーバー、Compose UI、MediaStore保存、50件バッチアップロード |
| Phase 2: 転送確認 | 2026-04-29 | ファイルごとのステータス表示、プッシュ通知、件数一致チェック |
| Phase 3: UI強化 | 2026-04-29 | 全選択・失敗ファイルリトライ、HTMLモックアップ |
| Phase 4: Ktor移行 | 2026-04-29 | NanoHTTPD→Ktor CIO、ストリーミング保存、コルーチン対応 |
| Phase 5: 5機能追加 | 2026-04-29 | QRコード、フォアグラウンドサービス、セッション履歴、重複スキップ、転送速度 |
| Phase 6: UX改善 | 2026-04-29 | URL全幅表示修正、レイアウト改善、アプリ内ヘルプ画面 |
| 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 が必要です。
サラリーマンの相場道 