diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c694bd0..7e7c544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,11 @@ on: branches: [main] pull_request: workflow_call: + inputs: + ios: + type: boolean + default: true + description: "Compile the iOS app (macOS runner)" concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -41,3 +46,37 @@ jobs: dart pub get dart analyze --fatal-infos dart test --exclude-tags perf + + # The Swift side (the background keep-alive engine) is only ever compiled + # here: there is no macOS in the development setup, so without this a build + # error would not show up until a release tag. + ios: + if: ${{ inputs.ios == null || inputs.ios }} + runs-on: macos-15 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: "3.47.4" + cache: true + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + - uses: Swatinem/rust-cache@v2 + with: { workspaces: packages/pepo_native/rust } + - name: Newest Xcode on the runner (plugins use the iOS 18.4+ SDK) + run: | + sudo xcode-select -s "$(ls -d /Applications/Xcode_*.app | sort -V | tail -n1)" + xcodebuild -version + - run: flutter pub get + - run: flutter build ios --release --no-codesign + - name: The background keys have to survive into the bundle + run: | + set -eu + plist=build/ios/iphoneos/Runner.app/Info.plist + read_key() { /usr/libexec/PlistBuddy -c "Print :$1" "$plist"; } + read_key UIBackgroundModes | grep -q audio + read_key BGTaskSchedulerPermittedIdentifiers | grep -q org.pepoconnect.app.sync + read_key NSBonjourServices | grep -q _pepoconnect._tcp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32a3cea..18f97ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,9 @@ env: jobs: check: uses: ./.github/workflows/ci.yml + # This workflow builds iOS itself right below. + with: + ios: false windows: if: github.event_name == 'push' || inputs.windows diff --git a/README.en.md b/README.en.md index 8a53a6e..fcddb5e 100644 --- a/README.en.md +++ b/README.en.md @@ -38,7 +38,12 @@ sideloading and is not an App Store download. ## What you can do - Browse your phone's gallery from your computer and download original files. -- See newly taken photos while the devices are connected. +- See newly taken photos as they happen, with the app closed. On Android a + foreground service keeps the engine alive; on iPhone the system audio mode + does (an inaudible silent loop that leaves your music alone). Whatever cannot + be sent right away — computer asleep, Wi-Fi gone — is kept and sent on + reconnect. Closing the app from the iPhone app switcher stops the engine + until you open it again. - Send files in both directions, resume interrupted transfers and connect several devices at once. - Share clipboard text. Android background clipboard restrictions still apply; diff --git a/README.md b/README.md index d3ee5b7..a111a3e 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,16 @@ Capturas de la aplicación real. dispositivo (`Fotos/`, `Vídeos/`, `Archivos/`) o unificado desde Ajustes. - **Emparejamiento por QR** (móvil) o por código de 6 dígitos (PC↔PC), con reconexión automática. -- **Sigue conectado con la app cerrada** (Android): con «Servicio en segundo - plano» activado, un servicio en primer plano mantiene vivo el motor aunque - cierres la ventana o la quites de recientes, y lo rearranca solo si Android - mata el proceso o reinicias el móvil. +- **Sigue conectado con la app cerrada**: con «Servicio en segundo plano» + activado, haces una foto y llega al PC sin abrir PepoConnect. En Android, un + servicio en primer plano mantiene vivo el motor aunque cierres la ventana o + la quites de recientes, y lo rearranca solo si Android mata el proceso o + reinicias el móvil. En iPhone, PepoConnect se mantiene en marcha con el modo + de audio del sistema (un bucle de silencio inaudible que no interrumpe tu + música); si cierras la app desde el selector de apps o reinicias el iPhone, + las fotos pendientes esperan en cola y salen al volver a abrirla. Lo que no + se puede enviar en el momento —el PC apagado, la Wi-Fi caída— queda guardado + y se envía solo al reconectar. - **Portapapeles compartido**: un solo interruptor por pareja de dispositivos (se activa en cualquiera de los dos y vale para ambos sentidos). El PC lo envía al instante; Android no deja leer el portapapeles en segundo plano, @@ -177,8 +183,12 @@ Segunda instancia en el mismo PC para probar: `flutter run -d windows --dart-ent 2. Instálalo con Sideloadly (Windows) o AltStore con tu Apple ID. 3. Con Apple ID gratuito la app caduca cada 7 días (AltServer la renueva sola si está en la misma Wi-Fi) y puedes tener 3 apps así a la vez. -4. La primera vez, acepta el permiso de "Red local"; en iPhone las fotos - llegan al PC mientras PepoConnect está en pantalla. +4. La primera vez, acepta el permiso de "Red local" y da acceso **completo** a + las fotos: con «Acceso limitado» iOS oculta las fotos nuevas y el envío + automático se queda sin nada que enviar (Ajustes › Este móvil avisa si pasa). +5. Para que las fotos salgan con la app cerrada, deja activado «Servicio en + segundo plano» en Ajustes › Este móvil. «Estado del segundo plano» dice si + el motor está vivo y desde cuándo. ## Estructura diff --git a/docs/packaging.md b/docs/packaging.md index af44731..fa1bc7b 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -213,8 +213,20 @@ vacío al lado. El proyecto usa Swift Package Manager para los plugins (no hay `Podfile`). - `ios/Runner/Info.plist`: `CFBundleDisplayName` PepoConnect, textos de uso de fototeca (leer y guardar), cámara y red local, `NSBonjourServices` (`_pepoconnect._tcp`), `UIBackgroundModes` - (`processing`), `UIFileSharingEnabled`, `LSSupportsOpeningDocumentsInPlace`, - `ITSAppUsesNonExemptEncryption=false` y el esquema de URL `pepoconnect`. + (`audio`, `fetch`, `processing`), `BGTaskSchedulerPermittedIdentifiers` + (`org.pepoconnect.app.refresh`, `org.pepoconnect.app.sync`), `UIFileSharingEnabled`, + `LSSupportsOpeningDocumentsInPlace`, `ITSAppUsesNonExemptEncryption=false` y el esquema de URL + `pepoconnect`. Ninguna de esas claves necesita entitlements, así que sobreviven a la refirma de + AltStore o Sideloadly con un Apple ID gratuito. El job `ios` de `ci.yml` comprueba que siguen en + el bundle compilado. +- Segundo plano (`ios/Runner/PepoBackground.swift`): iOS no despierta una app suspendida al hacer + una foto —`PHPhotoLibraryChangeObserver` solo entrega a un proceso vivo y las `BGTask` son + oportunistas—, así que `KeepAlive` reproduce un bucle de silencio con la sesión de audio en + `.playback` + `.mixWithOthers` para que el proceso no llegue a suspenderse. `BackgroundHold` + envuelve las transferencias en un `beginBackgroundTask` y `BackgroundTasks` registra las dos + `BGTask` como red de seguridad. Todo se controla desde Dart por el canal `org.pepoconnect/native` + (`keepAliveStart`, `keepAliveStop`, `backgroundHold`, `backgroundStatus`, `backgroundReady`, + `backgroundTaskDone`, y `backgroundTask` en sentido contrario). - `ios/ExportOptions.plist`: exportación ad-hoc con firma manual; `packaging/ios/sign-and-export.sh` sustituye `TEAM_ID` y `PROFILE_NAME` con los secretos `IOS_TEAM_ID` y el nombre del perfil, y ejecuta `flutter build ipa --export-options-plist=ios/ExportOptions.plist`. @@ -228,6 +240,10 @@ vacío al lado. - `linux/runner/my_application.cc`: título `PepoConnect`, tamaño por defecto 1100x700. - Scripts en `packaging/linux/`: `make-tarball.sh`, `make-appimage.sh`, `make-flatpak.sh`, `bundle-extra-libs.sh` (indicador de bandeja y libnotify), `install-desktop-entry.sh`. +- Descubrimiento mDNS: `bonsoir_linux` habla con Avahi por el bus **de sistema**, así que el + manifiesto Flatpak lleva `--system-talk-name=org.freedesktop.Avahi`. Sin `avahi-daemon` no hay + anuncio ni exploración y queda solo el beacon UDP, que en Linux sigue activo; el iPhone, que no + puede usar UDP, dejaría de encontrar ese equipo. - Iconos: `flutter_launcher_icons` no soporta Linux. Los scripts copian a mano `assets/icon/pepoconnect-256.png` y `assets/icon/pepoconnect-512.png` como `org.pepoconnect.PepoConnect.png`; genera esos dos PNG junto con el resto. diff --git a/integration_test/e2e_test.dart b/integration_test/e2e_test.dart index 5f70187..6afb97a 100644 --- a/integration_test/e2e_test.dart +++ b/integration_test/e2e_test.dart @@ -101,4 +101,39 @@ void main() { await settle(tester); await pumpUntilFound(tester, find.text('Guion PepoTech.txt')); }); + + testWidgets('auto-send: photos leave the phone on their own, offline ones too', (tester) async { + final w = await E2eWorld.start(tester); + await tester.tap(find.text('Añadir móvil')); + await settle(tester); + final pc = await w.pairThroughQr(tester); + await pumpUntilFound(tester, find.text('Emparejado'), timeout: const Duration(seconds: 15)); + await tester.tap(find.text('Empezar')); + await settle(tester); + + // The phone forwards every new photo to this PC, with nobody watching. + await w.phone.setAutoSend(pc.deviceId, true); + + final first = await w.takePhoto('IMG_AUTO_0001.jpg', r: 30, g: 200, b: 90); + final arrived = await waitForFile(tester, w.hubDownloads, 'IMG_AUTO_0001.jpg'); + expect(await arrived.length(), await first.length()); + + // Taken with the PC away: it has to wait in the queue and go out when + // the connection comes back, not be dropped on the floor. + await w.phone.sessions.disconnect(pc.deviceId); + final offline = await w.takePhoto('IMG_AUTO_0002.jpg', r: 200, g: 40, b: 40); + await waitFor(tester, () { + final queue = w.phone.mediaServer?.autoSend; + return queue != null && queue.pendingCount > 0 ? true : null; + }, what: 'the offline photo waiting in the queue'); + + w.phone.reconnectAll(); + final drained = await waitForFile( + tester, + w.hubDownloads, + 'IMG_AUTO_0002.jpg', + timeout: const Duration(seconds: 60), + ); + expect(await drained.length(), await offline.length()); + }); } diff --git a/integration_test/support.dart b/integration_test/support.dart index 64005ec..d95e346 100644 --- a/integration_test/support.dart +++ b/integration_test/support.dart @@ -68,6 +68,8 @@ class E2eWorld { cacheDir: p.join(hubData, 'cache'), ), options: const LaunchOptions(profile: 'e2e', port: hubPort), + // Keep the test off the real network. + bonjour: false, ); final phone = PepoEngine( diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ffbe353..09013ab 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 7B10A4022F1A55B000C636F2 /* PepoBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B10A4012F1A55B000C636F2 /* PepoBackground.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -50,6 +51,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7B10A4012F1A55B000C636F2 /* PepoBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PepoBackground.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -123,6 +125,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 7B10A4012F1A55B000C636F2 /* PepoBackground.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -284,6 +287,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + 7B10A4022F1A55B000C636F2 /* PepoBackground.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e46e543..cda4e6c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -10,6 +10,10 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + // iOS refuses identifiers registered after launch finishes, so this has + // to happen before anything else asks for time. + PepoTasks.shared.register() + PepoTasks.shared.schedule() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -21,9 +25,10 @@ import UIKit } } -/// The `org.pepoconnect/native` channel on iOS. Only `playWav` exists here: -/// it plays a short 16-bit mono WAV (the transfer chime). Everything else -/// answers "not implemented", which the Dart side treats as a no-op. +/// The `org.pepoconnect/native` channel on iOS: the transfer chime and the +/// background engine ([KeepAlive], [BackgroundHold], [PepoTasks]). +/// Everything else answers "not implemented", which the Dart side treats as +/// a no-op. final class PepoNativeChannel: NSObject, AVAudioPlayerDelegate { private let channel: FlutterMethodChannel private var players: [AVAudioPlayer] = [] @@ -45,12 +50,69 @@ final class PepoNativeChannel: NSObject, AVAudioPlayerDelegate { return } result(self.play(wav.data)) + case "keepAliveStart": + result(KeepAlive.shared.start()) + case "keepAliveStop": + KeepAlive.shared.stop() + result(nil) + case "backgroundHold": + let on = (call.arguments as? [String: Any])?["on"] as? Bool ?? false + BackgroundHold.shared.set(on) + result(BackgroundHold.shared.isHeld) + case "backgroundStatus": + result(self.status()) + case "backgroundReady": + // Native → Dart calls made before the Dart handler exists sit in the + // channel buffer unanswered, so nothing is pushed until this arrives. + self.listenForTasks() + result(nil) + case "openAppSettings": + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + result(nil) + case "backgroundTaskDone": + let args = call.arguments as? [String: Any] + let identifier = args?["id"] as? String ?? "" + let ok = args?["ok"] as? Bool ?? true + PepoTasks.shared.complete(identifier, success: ok) + result(nil) default: result(FlutterMethodNotImplemented) } } } + private func listenForTasks() { + PepoTasks.shared.onTask = { [weak self] identifier in + self?.channel.invokeMethod("backgroundTask", arguments: ["id": identifier]) + } + PepoTasks.shared.flush() + } + + private func status() -> [String: Any] { + let engine = KeepAlive.shared + var out: [String: Any] = [ + "running": engine.running, + "playing": engine.isPlaying, + "held": BackgroundHold.shared.isHeld, + "refresh": PepoNativeChannel.refreshState(), + ] + if let started = engine.startedAt { + out["startedAt"] = Int(started.timeIntervalSince1970 * 1000) + } + return out + } + + private static func refreshState() -> String { + switch UIApplication.shared.backgroundRefreshStatus { + case .available: return "available" + case .denied: return "denied" + case .restricted: return "restricted" + @unknown default: return "unknown" + } + } + private func play(_ data: Data) -> Bool { do { let player = try AVAudioPlayer(data: data, fileTypeHint: AVFileType.wav.rawValue) @@ -65,5 +127,7 @@ final class PepoNativeChannel: NSObject, AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { players.removeAll { $0 === player } + // Never deactivate the session here: the chime shares it with the + // keep-alive loop, and tearing it down would suspend the app. } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6d02603..7ab4333 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,11 @@ + BGTaskSchedulerPermittedIdentifiers + + org.pepoconnect.app.refresh + org.pepoconnect.app.sync + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -80,6 +85,8 @@ UIBackgroundModes + audio + fetch processing UIFileSharingEnabled diff --git a/ios/Runner/PepoBackground.swift b/ios/Runner/PepoBackground.swift new file mode 100644 index 0000000..d8ecdbd --- /dev/null +++ b/ios/Runner/PepoBackground.swift @@ -0,0 +1,359 @@ +import AVFoundation +import BackgroundTasks +import UIKit + +/// Keeps the process alive while PepoConnect is not on screen. +/// +/// iOS has no API that wakes a suspended app when a photo is taken: +/// `PHPhotoLibraryChangeObserver` only delivers to a running process, and the +/// `BGTaskScheduler` tasks run when the system feels like it — minutes or +/// hours later. The only way for a new photo to leave the phone on its own is +/// for the process never to be suspended, and the `audio` background mode is +/// the one way to get there without asking for a permission a transfer app has +/// no business asking for. +/// +/// So this plays one second of silence on a loop. The samples are zeroes, the +/// session mixes with whatever else is playing and we never publish +/// now-playing info, so nothing is audible, other apps keep their audio and no +/// indicator appears. What it buys is a process iOS does not suspend, which is +/// all the Dart engine needs: the photo library observer, the TLS control +/// socket and the transfers keep running exactly as they do in the foreground. +final class KeepAlive: NSObject { + static let shared = KeepAlive() + + /// The watchdog beat. Short enough to recover from an interruption before + /// anyone notices, long enough to cost nothing. + private static let watchdogInterval: TimeInterval = 30 + + private var player: AVAudioPlayer? + private var watchdog: Timer? + private var observing = false + + /// Whether the engine is meant to be running. The watchdog only revives the + /// player while this is true. + private(set) var running = false + + /// When the loop started. It resets with the process, so "running for 47 + /// minutes" is proof from the phone itself that iOS never suspended it — + /// no cable, no Xcode. + private(set) var startedAt: Date? + + var isPlaying: Bool { player?.isPlaying ?? false } + + @discardableResult + func start() -> Bool { + observe() + if running { return resume() } + running = true + let ok = resume() + if ok { startedAt = Date() } + startWatchdog() + return ok + } + + func stop() { + running = false + watchdog?.invalidate() + watchdog = nil + player?.stop() + player = nil + startedAt = nil + try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + } + + /// Brings the session and the player back up. Safe to call at any time: + /// everything here is idempotent, which is what lets the watchdog and the + /// notifications all funnel into the same path. + @discardableResult + private func resume() -> Bool { + guard running else { return false } + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try session.setActive(true) + } catch { + NSLog("PepoConnect: audio session failed: \(error.localizedDescription)") + return false + } + if player == nil { + do { + let made = try AVAudioPlayer( + data: KeepAlive.silence, fileTypeHint: AVFileType.wav.rawValue) + made.numberOfLoops = -1 + made.volume = 0.01 + made.prepareToPlay() + player = made + } catch { + NSLog("PepoConnect: silent player failed: \(error.localizedDescription)") + return false + } + } + guard let player = player else { return false } + return player.isPlaying || player.play() + } + + // --------------------------------------------------------------------------- + // Recovery + + private func startWatchdog() { + watchdog?.invalidate() + let timer = Timer(timeInterval: KeepAlive.watchdogInterval, repeats: true) { [weak self] _ in + guard let self = self, self.running, !self.isPlaying else { return } + if self.resume() && self.startedAt == nil { self.startedAt = Date() } + } + // Common modes: the beat must survive whatever the run loop is doing. + RunLoop.main.add(timer, forMode: .common) + watchdog = timer + } + + private func observe() { + guard !observing else { return } + observing = true + let center = NotificationCenter.default + center.addObserver( + self, selector: #selector(onInterruption(_:)), + name: AVAudioSession.interruptionNotification, object: nil) + center.addObserver( + self, selector: #selector(onMediaReset(_:)), + name: AVAudioSession.mediaServicesWereResetNotification, object: nil) + center.addObserver( + self, selector: #selector(onRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, object: nil) + center.addObserver( + self, selector: #selector(onEnterBackground(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil) + center.addObserver( + self, selector: #selector(onEnterForeground(_:)), + name: UIApplication.willEnterForegroundNotification, object: nil) + // Flutter runs on the scene lifecycle now; both sets fire today, and + // everything they call is idempotent, so listen to both rather than bet + // on which one keeps firing. + center.addObserver( + self, selector: #selector(onEnterBackground(_:)), + name: UIScene.didEnterBackgroundNotification, object: nil) + center.addObserver( + self, selector: #selector(onEnterForeground(_:)), + name: UIScene.willEnterForegroundNotification, object: nil) + } + + /// A phone call or another app taking the session over. `.ended` is the + /// normal way back; when it never arrives the watchdog picks it up. + @objc private func onInterruption(_ note: Notification) { + guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: raw) + else { return } + if type == .ended { resume() } + } + + /// The media server died and took the session and the player with it. + @objc private func onMediaReset(_ note: Notification) { + player = nil + resume() + } + + @objc private func onRouteChange(_ note: Notification) { + resume() + } + + @objc private func onEnterBackground(_ note: Notification) { + resume() + PepoTasks.shared.schedule() + } + + @objc private func onEnterForeground(_ note: Notification) { + resume() + } + + // --------------------------------------------------------------------------- + + /// One second of 16-bit mono PCM silence in a RIFF container, built in code + /// so there is no asset to lose when the IPA is re-signed for sideloading. + private static let silence: Data = KeepAlive.makeSilence(seconds: 1, sampleRate: 44100) + + private static func makeSilence(seconds: Int, sampleRate: Int) -> Data { + let channels = 1 + let bitsPerSample = 16 + let blockAlign = channels * bitsPerSample / 8 + let byteRate = sampleRate * blockAlign + let dataSize = byteRate * seconds + var out = Data(capacity: 44 + dataSize) + func ascii(_ text: String) { out.append(contentsOf: Array(text.utf8)) } + // Little endian by hand: no unsafe pointers to get wrong in a file that + // only ever gets compiled on CI. + func u32(_ value: Int) { + let v = UInt32(value) + out.append(contentsOf: [ + UInt8(v & 0xFF), + UInt8((v >> 8) & 0xFF), + UInt8((v >> 16) & 0xFF), + UInt8((v >> 24) & 0xFF), + ]) + } + func u16(_ value: Int) { + let v = UInt16(value) + out.append(contentsOf: [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF)]) + } + ascii("RIFF") + u32(36 + dataSize) + ascii("WAVE") + ascii("fmt ") + u32(16) + u16(1) // PCM + u16(channels) + u32(sampleRate) + u32(byteRate) + u16(blockAlign) + u16(bitsPerSample) + ascii("data") + u32(dataSize) + out.append(Data(count: dataSize)) + return out + } +} + +/// A background-task assertion around work that must not be cut short. +/// +/// [KeepAlive] makes this mostly redundant, but a transfer that starts just as +/// the app leaves the screen still deserves the ~30 s iOS grants on request — +/// and when the engine is off, or has just been interrupted, this is the only +/// thing holding the process up. +final class BackgroundHold { + static let shared = BackgroundHold() + + private var token: UIBackgroundTaskIdentifier = .invalid + + var isHeld: Bool { token != .invalid } + + func set(_ on: Bool) { + if on { + begin() + } else { + end() + } + } + + private func begin() { + guard token == .invalid else { return } + token = UIApplication.shared.beginBackgroundTask(withName: "pepoconnect.transfer") { + [weak self] in + self?.end() + } + } + + private func end() { + guard token != .invalid else { return } + UIApplication.shared.endBackgroundTask(token) + token = .invalid + } +} + +/// The opportunistic tier: `BGAppRefreshTask` and `BGProcessingTask`. +/// +/// These are the safety net for when [KeepAlive] is not running — the app was +/// closed from the app switcher, the phone rebooted, iOS reclaimed the +/// process. The system decides when they run, which in practice means minutes +/// to hours, so nothing about "take a photo and it arrives" rests on them: +/// they exist so a backlog eventually drains on its own. +/// +/// When iOS launches the app straight into the background for one of these, +/// the scene may never connect and the Flutter engine never starts. Then +/// nobody answers and [answerTimeout] hands the task back rather than leaving +/// it hanging until the system kills it. +final class PepoTasks { + static let shared = PepoTasks() + + static let refreshIdentifier = "org.pepoconnect.app.refresh" + static let syncIdentifier = "org.pepoconnect.app.sync" + + /// How long a task waits for Dart before we give it back. + private static let answerTimeout: TimeInterval = 25 + + /// Earliest the system may run the next one. + private static let earliest: TimeInterval = 15 * 60 + + /// Set by the channel once Dart is listening. + var onTask: ((String) -> Void)? + + private var active: [String: BGTask] = [:] + private var timeouts: [String: Timer] = [:] + private var undelivered: [String] = [] + private var registered = false + + /// Must run before `didFinishLaunchingWithOptions` returns. + func register() { + guard !registered else { return } + registered = true + for identifier in [PepoTasks.refreshIdentifier, PepoTasks.syncIdentifier] { + let ok = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { + [weak self] task in + self?.run(task) + } + if !ok { + // Refused identifiers fail silently later on submit, so say it once here. + NSLog("PepoConnect: cannot register background task \(identifier)") + } + } + } + + func schedule() { + guard registered else { return } + let refresh = BGAppRefreshTaskRequest(identifier: PepoTasks.refreshIdentifier) + refresh.earliestBeginDate = Date(timeIntervalSinceNow: PepoTasks.earliest) + let sync = BGProcessingTaskRequest(identifier: PepoTasks.syncIdentifier) + sync.requiresNetworkConnectivity = true + sync.requiresExternalPower = false + sync.earliestBeginDate = Date(timeIntervalSinceNow: PepoTasks.earliest) + for request in [refresh, sync] as [BGTaskRequest] { + do { + // Submitting over a pending request for the same identifier is an + // error on some releases; dropping it first never is. + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: request.identifier) + try BGTaskScheduler.shared.submit(request) + } catch { + NSLog( + "PepoConnect: cannot schedule \(request.identifier): \(error.localizedDescription)") + } + } + } + + /// Anything that arrived before Dart was listening. + func flush() { + let pending = undelivered + undelivered.removeAll() + for identifier in pending { deliver(identifier) } + } + + func complete(_ identifier: String, success: Bool) { + timeouts.removeValue(forKey: identifier)?.invalidate() + // A task handed back on the timeout must not be handed to Dart later. + undelivered.removeAll { $0 == identifier } + guard let task = active.removeValue(forKey: identifier) else { return } + task.setTaskCompleted(success: success) + } + + private func run(_ task: BGTask) { + let identifier = task.identifier + // Ask for the next one first: a task that ends without rescheduling is + // the last one the app ever gets. + schedule() + active[identifier] = task + task.expirationHandler = { [weak self] in + self?.complete(identifier, success: false) + } + let timer = Timer(timeInterval: PepoTasks.answerTimeout, repeats: false) { + [weak self] _ in + self?.complete(identifier, success: false) + } + RunLoop.main.add(timer, forMode: .common) + timeouts[identifier] = timer + deliver(identifier) + } + + private func deliver(_ identifier: String) { + guard let handler = onTask else { + undelivered.append(identifier) + return + } + handler(identifier) + } +} diff --git a/lib/app/app_services.dart b/lib/app/app_services.dart index 15c06db..f75cf47 100644 --- a/lib/app/app_services.dart +++ b/lib/app/app_services.dart @@ -14,7 +14,9 @@ import '../platform/android_service.dart'; import '../platform/autostart.dart'; import '../platform/clipboard_sync.dart'; import '../platform/desktop_integration.dart'; +import '../platform/ios_background.dart'; import '../platform/media_source_photo_manager.dart'; +import '../platform/mobile_permissions.dart'; import '../platform/notifications.dart'; import '../platform/open_helper.dart'; import '../platform/pepo_native.dart'; @@ -66,6 +68,7 @@ class AppServices with WidgetsBindingObserver { ProviderSubscription? _transfersSub; ProviderSubscription>? _devicesSub; AppLocalizations? _l10n; + Timer? _exportCleanup; bool _started = false; PepoEngine get engine => ref.read(engineProvider); @@ -95,6 +98,12 @@ class AppServices with WidgetsBindingObserver { if (DesktopIntegration.isSupported) { DesktopIntegration.instance.setActiveTransfers(next.activeCount); } + // iOS: hold the process up for as long as something is moving, so a + // transfer started right before the app left the screen still lands. + if (IosBackground.isSupported) { + unawaited(IosBackground.hold(next.active.isNotEmpty)); + } + if (next.active.isEmpty) _scheduleExportCleanup(); }); _devicesSub = ref.listen>(devicesProvider, _onDevices); final clip = ref.read(clipboardSyncProvider); @@ -109,13 +118,68 @@ class AppServices with WidgetsBindingObserver { if (PepoNative.isSupported) { // Quick-settings tile / launcher shortcut hand the clipboard over here. PepoNative.onClipboardText = _onNativeClipboard; + PepoNative.onBackgroundTask = _onBackgroundTask; PepoNative.init(); await PepoNative.markClipboardReady(); + await PepoNative.markBackgroundReady(); } if (Autostart.isSupported) unawaited(Autostart.refresh()); SystemNotifications.instance.onTap = _onNotificationTap; WidgetsBinding.instance.addObserver(this); - await _updateService(ref.read(devicesProvider)); + await _applyAutoSend(); + await _updateBackground(ref.read(devicesProvider)); + } + + /// Re-applies the phone's "send every new photo" target from the setting. + /// + /// The engine keeps it in memory only, so without this the switch reads on + /// after a restart while nothing is ever sent — which is precisely the case + /// that matters once the app is off screen. + Future _applyAutoSend({String? previousHubId}) async { + final hub = settings.defaultHubId; + if (previousHubId != null && previousHubId != hub) { + await engine.setAutoSend(previousHubId, false); + } + if (hub == null) return; + await engine.setAutoSend(hub, settings.autoSendPhotos); + } + + /// Sending a photo means exporting it out of the gallery to a temporary + /// file first, and those copies are never cleaned up while the process + /// lives — which, with the background engine, is days. Wipe them once + /// nothing is queued or in flight and could still be pointing at one. + void _scheduleExportCleanup() { + if (!MobilePermissions.isMobile) return; + _exportCleanup?.cancel(); + _exportCleanup = Timer(const Duration(minutes: 2), () { + final idle = + ref.read(transfersProvider).active.isEmpty && + (engine.mediaServer?.autoSend.isEmpty ?? true); + if (!idle) { + _scheduleExportCleanup(); + return; + } + unawaited(MediaSourcePhotoManager.clearExportedOriginals()); + }); + } + + /// iOS handed us a `BGAppRefreshTask` / `BGProcessingTask`: reconnect, + /// push whatever is queued and give the task straight back. The budget for + /// the next ones depends on not overstaying. + Future _onBackgroundTask(String id) async { + try { + engine.reconnectAll(); + final deadline = DateTime.now().add(const Duration(seconds: 8)); + while (DateTime.now().isBefore(deadline) && + !engine.sessions.sessions.any((s) => s.isConnected)) { + await Future.delayed(const Duration(milliseconds: 500)); + } + await engine.flushAutoSend().timeout(const Duration(seconds: 12)); + } catch (_) { + // Out of time or nothing reachable: the queue keeps it for next time. + } finally { + await PepoNative.backgroundTaskDone(id); + } } /// Back to the foreground: reconnect, re-read the photo library if the @@ -124,6 +188,8 @@ class AppServices with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state != AppLifecycleState.resumed) return; engine.reconnectAll(); + // Back from a kill or a long sleep: anything the queue still owes. + unawaited(engine.flushAutoSend()); // Started behind the Android service: the permission dialog waited for a window. unawaited(SystemNotifications.instance.promptOnce()); final source = engine.ownMediaSource; @@ -234,12 +300,15 @@ class AppServices with WidgetsBindingObserver { unawaited(engine.setDeviceName(name)); } if (prev?.backgroundService != next.backgroundService) { - unawaited(_updateService(ref.read(devicesProvider))); + unawaited(_updateBackground(ref.read(devicesProvider))); + } + if (prev?.autoSendPhotos != next.autoSendPhotos || prev?.defaultHubId != next.defaultHubId) { + unawaited(_applyAutoSend(previousHubId: prev?.defaultHubId)); } } void _onDevices(List? prev, List next) { - unawaited(_updateService(next)); + unawaited(_updateBackground(next)); // The shared-clipboard switch can arrive from the peer (device.info); // it lands after the connection event, so this is the trigger that // works the first time. It also turns the global switch on, which the @@ -255,10 +324,22 @@ class AppServices with WidgetsBindingObserver { _pushClipboard(); } - Future _updateService(List devices) async { + /// The background engine: a foreground service on Android, the silent + /// keep-alive loop on iOS. One switch, one rule — there is nothing to hold + /// the process up for with no device paired. + Future _updateBackground(List devices) async { + final wanted = settings.backgroundService && devices.isNotEmpty; + if (IosBackground.isSupported) { + if (wanted) { + await IosBackground.start(); + } else { + await IosBackground.stop(); + } + return; + } if (!AndroidService.isSupported) return; final l = _l10n; - if (settings.backgroundService && devices.isNotEmpty) { + if (wanted) { final connected = devices.where((d) => d.connected).map((d) => d.device.name).toList(); final idle = l?.mobileServiceIdle ?? 'PepoConnect espera al PC'; final text = connected.isEmpty @@ -503,6 +584,7 @@ class AppServices with WidgetsBindingObserver { Future dispose() async { WidgetsBinding.instance.removeObserver(this); + _exportCleanup?.cancel(); for (final s in _subs) { await s.cancel(); } diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index ce8c642..a238847 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:pepo_core/pepo_core.dart'; +import '../platform/bonjour_discovery.dart'; import '../state/app_settings.dart'; import '../state/stores.dart'; @@ -137,6 +138,7 @@ Future startEngine({ List mediaRoots = const [], OfferPolicy? offerPolicy, DeletePolicy? deletePolicy, + bool bonjour = true, }) async { final identity = await defaultDeviceIdentity(); final pkg = await PackageInfo.fromPlatform(); @@ -166,9 +168,14 @@ Future startEngine({ ); final engine = PepoEngine( config, + // The UDP beacon cannot run on iPhone (broadcast needs an entitlement + // Apple does not hand out), so mDNS is the only way an iPhone finds a PC + // that changed address. Both ends have to announce for that to work. + discovery: [if (config.udpDiscovery) UdpBeacon(), if (bonjour) BonjourDiscovery()], deviceStore: JsonDeviceStore(paths.dataDir), transferStore: JsonTransferStore(paths.dataDir), mediaStateStore: JsonMediaStateStore(paths.dataDir), + autoSendStore: JsonAutoSendStore(paths.dataDir), mediaSource: mediaSource, offerPolicy: offerPolicy, deletePolicy: deletePolicy, diff --git a/lib/features/settings/permissions.dart b/lib/features/settings/permissions.dart index ad8518c..7dbca82 100644 --- a/lib/features/settings/permissions.dart +++ b/lib/features/settings/permissions.dart @@ -44,15 +44,21 @@ class PermissionRows extends ConsumerWidget { return const Column(children: [_LoadingRow(), _LoadingRow(), _LoadingRow()]); } final granted = value ?? _none; + // A limited selection reads as "granted" to iOS but hides every photo + // taken afterwards, and only the system settings can widen it. + final limited = granted.photos && granted.photosLimited; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ PermissionRow( icon: FluentIcons.image_multiple_24_regular, title: t.setPermissionPhotos, - description: t.setPermissionPhotosBody, - granted: granted.photos, - onAllow: () => _request(ref, MobilePermission.photos), + description: limited ? t.setPermissionPhotosLimited : t.setPermissionPhotosBody, + granted: granted.photos && !limited, + allowLabel: limited ? t.setOpenSystemSettings : null, + onAllow: () => limited + ? MobilePermissions.openSystemSettings() + : _request(ref, MobilePermission.photos), ), Divider(height: 1, color: context.pepo.divider), PermissionRow( @@ -90,6 +96,7 @@ class PermissionRow extends StatelessWidget { required this.description, required this.granted, required this.onAllow, + this.allowLabel, }); final IconData icon; @@ -98,6 +105,9 @@ class PermissionRow extends StatelessWidget { final bool granted; final VoidCallback onAllow; + /// Overrides the "Allow" label when the fix is somewhere else. + final String? allowLabel; + @override Widget build(BuildContext context) { final t = context.t; @@ -118,7 +128,7 @@ class PermissionRow extends StatelessWidget { ], ), ) - : FluentButton(label: t.setAllow, onPressed: onAllow), + : FluentButton(label: allowLabel ?? t.setAllow, onPressed: onAllow), ); } } diff --git a/lib/features/settings/sections/phone_section.dart b/lib/features/settings/sections/phone_section.dart index 66fbb3a..4d8ef60 100644 --- a/lib/features/settings/sections/phone_section.dart +++ b/lib/features/settings/sections/phone_section.dart @@ -1,23 +1,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../platform/ios_background.dart'; import '../../../shared/i18n/l10n.dart'; import '../../../shared/motion/toast.dart'; import '../../../shared/theme/tokens.dart'; +import '../../../shared/util/format.dart'; import '../../../shared/widgets/device_icon.dart'; import '../../../shared/widgets/dropdown_button_fluent.dart'; import '../../../shared/widgets/toggle_switch.dart'; import '../../../state/app_settings.dart'; import '../../../state/engine_providers.dart'; import '../permissions.dart'; -import '../settings_providers.dart'; import '../settings_widgets.dart'; +/// Polls the iOS background engine, so the phone can show on its own screen +/// how long the process has been alive. Without it there is no way to tell a +/// working keep-alive from a silently suspended app short of a Mac. +final iosBackgroundProvider = StreamProvider.autoDispose((ref) async* { + if (!IosBackground.isSupported) return; + yield await IosBackground.status(); + yield* Stream.periodic(const Duration(seconds: 5)).asyncMap((_) => IosBackground.status()); +}); + /// Rows that only make sense on the phone: automatic sending, the -/// background service, the main PC and the permission state. +/// background engine, the main PC and the permission state. class PhoneSection extends ConsumerWidget { const PhoneSection({super.key}); + /// Only the setting is written here: `AppServices` owns applying it to the + /// engine, so it lands the same way at every start and not just when the + /// switch is touched. Future _toggleAutoSend(BuildContext context, WidgetRef ref, bool value) async { final t = context.t; final settings = ref.read(settingsProvider); @@ -31,19 +44,12 @@ class PhoneSection extends ConsumerWidget { await ref .read(settingsProvider.notifier) .update((s) => s.copyWith(autoSendPhotos: value, defaultHubId: hub)); - ref.read(settingsActionsProvider).setAutoSend(hub, value); } Future _setHub(WidgetRef ref, String hubId) async { final settings = ref.read(settingsProvider); - final previous = settings.defaultHubId; - if (previous == hubId) return; + if (settings.defaultHubId == hubId) return; await ref.read(settingsProvider.notifier).update((s) => s.copyWith(defaultHubId: hubId)); - if (settings.autoSendPhotos) { - final actions = ref.read(settingsActionsProvider); - if (previous != null) actions.setAutoSend(previous, false); - actions.setAutoSend(hubId, true); - } } @override @@ -53,6 +59,7 @@ class PhoneSection extends ConsumerWidget { final devices = ref.watch(devicesProvider); final notifier = ref.read(settingsProvider.notifier); final hubKnown = devices.any((d) => d.deviceId == settings.defaultHubId); + final onIos = IosBackground.isSupported; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -69,12 +76,18 @@ class PhoneSection extends ConsumerWidget { ), SettingsRow( title: t.setBackgroundService, - description: t.setBackgroundServiceBody, + description: onIos ? t.setBackgroundServiceBodyIos : t.setBackgroundServiceBody, trailing: ToggleSwitch( value: settings.backgroundService, onChanged: (v) => notifier.update((s) => s.copyWith(backgroundService: v)), ), ), + if (onIos) + SettingsRow( + title: t.setBackgroundEngine, + description: t.setBackgroundEngineBody, + trailing: const _BackgroundEngineState(), + ), SettingsRow( title: t.setDefaultHub, description: t.setDefaultHubBody, @@ -103,3 +116,34 @@ class PhoneSection extends ConsumerWidget { ); } } + +/// "Activo desde hace 47 min" and the two ways it can fall short of that. +class _BackgroundEngineState extends ConsumerWidget { + const _BackgroundEngineState(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = context.t; + final colors = context.pepo; + final state = ref.watch(iosBackgroundProvider).value ?? IosBackgroundState.off; + final String label; + final Color color; + final started = state.startedAt; + if (!state.running) { + label = t.setBackgroundEngineOff; + color = colors.textSecondary; + } else if (!state.playing) { + // The session was taken away (a call, another app) and the watchdog + // has not won it back yet. + label = t.setBackgroundEngineRecovering; + color = colors.caution; + } else if (started == null) { + label = t.setBackgroundEngineStarting; + color = colors.success; + } else { + label = t.setBackgroundEngineOn(formatShortDuration(DateTime.now().difference(started))); + color = colors.success; + } + return Text(label, style: context.text.caption.copyWith(color: color)); + } +} diff --git a/lib/features/settings/settings_providers.dart b/lib/features/settings/settings_providers.dart index dfb00d1..9d3a3cf 100644 --- a/lib/features/settings/settings_providers.dart +++ b/lib/features/settings/settings_providers.dart @@ -87,11 +87,6 @@ class SettingsActions { _ref.invalidate(localDeviceFactsProvider); } - /// Mobile: forward every new photo to [hubId] (or stop doing so). - void setAutoSend(String hubId, bool enabled) { - _ref.read(engineProvider).setAutoSend(hubId, enabled); - } - /// Every preference back to its default. Paired devices, the window /// geometry and the onboarding flag are kept. Future resetAll() async { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7316998..4ff1b1c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -424,6 +424,15 @@ "setChooseFolder": "Choose where to save received files", "setBackgroundService": "Background service", "setBackgroundServiceBody": "Keeps the connection to the PC", + "setBackgroundServiceBodyIos": "Keeps PepoConnect running with the app closed so photos leave on their own. It uses some battery.", + "setBackgroundEngine": "Background state", + "setBackgroundEngineOn": "Running for {uptime}", + "setBackgroundEngineStarting": "Starting…", + "setBackgroundEngineRecovering": "Recovering…", + "setBackgroundEngineOff": "Stopped", + "setBackgroundEngineBody": "Closing PepoConnect from the app switcher stops it until you open it again.", + "setPermissionPhotosLimited": "Limited access: PepoConnect only sees the photos you picked, so new ones are never sent.", + "setOpenSystemSettings": "Open Settings", "setAutoSendPhotos": "Send new photos automatically", "setAutoSendPhotosBody": "Every photo you take reaches the main PC right away", "setDefaultHub": "Main PC", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index cb82b75..8a0be1b 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -954,6 +954,22 @@ "setChooseFolder": "Elige dónde guardar los archivos recibidos", "setBackgroundService": "Servicio en segundo plano", "setBackgroundServiceBody": "Mantiene la conexión con el PC", + "setBackgroundServiceBodyIos": "Mantiene PepoConnect en marcha con la app cerrada para que las fotos salgan solas. Gasta algo de batería.", + "setBackgroundEngine": "Estado del segundo plano", + "setBackgroundEngineOn": "Activo desde hace {uptime}", + "@setBackgroundEngineOn": { + "placeholders": { + "uptime": { + "type": "String" + } + } + }, + "setBackgroundEngineStarting": "Arrancando…", + "setBackgroundEngineRecovering": "Recuperándose…", + "setBackgroundEngineOff": "Parado", + "setBackgroundEngineBody": "Si cierras PepoConnect desde el selector de apps, deja de funcionar hasta que la vuelvas a abrir.", + "setPermissionPhotosLimited": "Acceso limitado: PepoConnect solo ve las fotos que elegiste, así que las nuevas no se envían.", + "setOpenSystemSettings": "Abrir Ajustes", "setAutoSendPhotos": "Enviar fotos nuevas automáticamente", "setAutoSendPhotosBody": "Cada foto que hagas llega al PC principal al momento", "setDefaultHub": "PC principal", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 1418945..ef30efb 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -2553,6 +2553,60 @@ abstract class AppLocalizations { /// **'Mantiene la conexión con el PC'** String get setBackgroundServiceBody; + /// No description provided for @setBackgroundServiceBodyIos. + /// + /// In es, this message translates to: + /// **'Mantiene PepoConnect en marcha con la app cerrada para que las fotos salgan solas. Gasta algo de batería.'** + String get setBackgroundServiceBodyIos; + + /// No description provided for @setBackgroundEngine. + /// + /// In es, this message translates to: + /// **'Estado del segundo plano'** + String get setBackgroundEngine; + + /// No description provided for @setBackgroundEngineOn. + /// + /// In es, this message translates to: + /// **'Activo desde hace {uptime}'** + String setBackgroundEngineOn(String uptime); + + /// No description provided for @setBackgroundEngineStarting. + /// + /// In es, this message translates to: + /// **'Arrancando…'** + String get setBackgroundEngineStarting; + + /// No description provided for @setBackgroundEngineRecovering. + /// + /// In es, this message translates to: + /// **'Recuperándose…'** + String get setBackgroundEngineRecovering; + + /// No description provided for @setBackgroundEngineOff. + /// + /// In es, this message translates to: + /// **'Parado'** + String get setBackgroundEngineOff; + + /// No description provided for @setBackgroundEngineBody. + /// + /// In es, this message translates to: + /// **'Si cierras PepoConnect desde el selector de apps, deja de funcionar hasta que la vuelvas a abrir.'** + String get setBackgroundEngineBody; + + /// No description provided for @setPermissionPhotosLimited. + /// + /// In es, this message translates to: + /// **'Acceso limitado: PepoConnect solo ve las fotos que elegiste, así que las nuevas no se envían.'** + String get setPermissionPhotosLimited; + + /// No description provided for @setOpenSystemSettings. + /// + /// In es, this message translates to: + /// **'Abrir Ajustes'** + String get setOpenSystemSettings; + /// No description provided for @setAutoSendPhotos. /// /// In es, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 37d913c..ffb247a 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -1505,6 +1505,38 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setBackgroundServiceBody => 'Keeps the connection to the PC'; + @override + String get setBackgroundServiceBodyIos => + 'Keeps PepoConnect running with the app closed so photos leave on their own. It uses some battery.'; + + @override + String get setBackgroundEngine => 'Background state'; + + @override + String setBackgroundEngineOn(String uptime) { + return 'Running for $uptime'; + } + + @override + String get setBackgroundEngineStarting => 'Starting…'; + + @override + String get setBackgroundEngineRecovering => 'Recovering…'; + + @override + String get setBackgroundEngineOff => 'Stopped'; + + @override + String get setBackgroundEngineBody => + 'Closing PepoConnect from the app switcher stops it until you open it again.'; + + @override + String get setPermissionPhotosLimited => + 'Limited access: PepoConnect only sees the photos you picked, so new ones are never sent.'; + + @override + String get setOpenSystemSettings => 'Open Settings'; + @override String get setAutoSendPhotos => 'Send new photos automatically'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index d9e664e..47e2d46 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -1506,6 +1506,38 @@ class AppLocalizationsEs extends AppLocalizations { @override String get setBackgroundServiceBody => 'Mantiene la conexión con el PC'; + @override + String get setBackgroundServiceBodyIos => + 'Mantiene PepoConnect en marcha con la app cerrada para que las fotos salgan solas. Gasta algo de batería.'; + + @override + String get setBackgroundEngine => 'Estado del segundo plano'; + + @override + String setBackgroundEngineOn(String uptime) { + return 'Activo desde hace $uptime'; + } + + @override + String get setBackgroundEngineStarting => 'Arrancando…'; + + @override + String get setBackgroundEngineRecovering => 'Recuperándose…'; + + @override + String get setBackgroundEngineOff => 'Parado'; + + @override + String get setBackgroundEngineBody => + 'Si cierras PepoConnect desde el selector de apps, deja de funcionar hasta que la vuelvas a abrir.'; + + @override + String get setPermissionPhotosLimited => + 'Acceso limitado: PepoConnect solo ve las fotos que elegiste, así que las nuevas no se envían.'; + + @override + String get setOpenSystemSettings => 'Abrir Ajustes'; + @override String get setAutoSendPhotos => 'Enviar fotos nuevas automáticamente'; diff --git a/lib/main.dart b/lib/main.dart index caaacae..ff56633 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; -import 'package:pepo_core/pepo_core.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app/app.dart'; @@ -30,16 +29,18 @@ Future main(List args) async { final settings = SettingsNotifier.load(prefs); final paths = await AppPaths.resolve(options); - MediaSource? mediaSource; - if (Platform.isAndroid || Platform.isIOS) { - mediaSource = MediaSourcePhotoManager(cacheDir: p.join(paths.cacheDir, 'own')); - } + final photos = Platform.isAndroid || Platform.isIOS + ? MediaSourcePhotoManager(cacheDir: p.join(paths.cacheDir, 'own')) + : null; final engine = await startEngine( settings: settings, paths: paths, options: options, - mediaSource: mediaSource, + mediaSource: photos, ); + // The engine only has the slot for HEIC -> JPEG; the conversion itself is + // platform code, so it has to be handed over here. + if (photos != null) engine.mediaServer?.convertHeicToJpeg = photos.convertHeicToJpeg; if (DesktopIntegration.isSupported) { final b = settings.windowBounds; diff --git a/lib/platform/bonjour_discovery.dart b/lib/platform/bonjour_discovery.dart new file mode 100644 index 0000000..63ba74a --- /dev/null +++ b/lib/platform/bonjour_discovery.dart @@ -0,0 +1,276 @@ +import 'dart:async'; + +import 'package:bonsoir/bonsoir.dart'; +import 'package:logging/logging.dart'; +import 'package:pepo_core/pepo_core.dart'; + +final _log = Logger('pepo.discovery.bonjour'); + +/// The service PepoConnect advertises. Declared in `ios/Runner/Info.plist` +/// under `NSBonjourServices`, without which iOS refuses to browse for it. +const String pepoServiceType = '_pepoconnect._tcp'; + +/// Finds peers over Bonjour/mDNS, on every platform. +/// +/// The UDP beacon cannot run on iPhone: since iOS 14 broadcast and multicast +/// need an entitlement Apple grants case by case, and never to a free +/// account. Without this an iPhone can only ever dial the last address it saw, +/// so a PC that reboots onto a new DHCP lease disappears for good. mDNS asks +/// for nothing beyond the local-network permission the app already has. +/// +/// It runs on the desktops and on Android too, because the discovery only +/// works if the other end is announcing itself. +class BonjourDiscovery implements Discovery { + BonjourDiscovery({ + this.serviceType = pepoServiceType, + this.rediscoverAfter = const Duration(seconds: 20), + }); + + final String serviceType; + + /// A probe restarts the browse at most this often. mDNS keeps listening on + /// its own, so restarting more than that is churn for nothing. + final Duration rediscoverAfter; + + final _found = StreamController.broadcast(); + + /// Last candidate seen per device, so a probe can answer from memory + /// instead of waiting for the network to say it again. + final Map _seen = {}; + + BonsoirBroadcast? _broadcast; + BonsoirDiscovery? _browser; + StreamSubscription? _events; + StreamSubscription? _broadcastEvents; + DiscoveryAdvert? _advert; + DateTime? _browsingSince; + bool _running = false; + int _nameAttempt = 0; + + @override + Stream get found => _found.stream; + + bool get isRunning => _running; + + @override + Future start(DiscoveryAdvert advert) async { + _advert = advert; + if (_running) return; + _running = true; + await _startBroadcast(); + await _startBrowse(); + } + + @override + Future updateAdvert(DiscoveryAdvert advert) async { + final previous = _advert; + _advert = advert; + if (!_running) return; + if (previous != null && previous.name == advert.name && previous.port == advert.port) { + return; + } + _nameAttempt = 0; + await _stopBroadcast(); + await _startBroadcast(); + } + + @override + Future stop() async { + _running = false; + await _stopBroadcast(); + await _stopBrowse(); + _seen.clear(); + } + + void dispose() { + unawaited(stop()); + _found.close(); + } + + /// mDNS has no probe of its own: what it can do is hand back what it + /// already knows right away, and start the browse over when it has been + /// running long enough that a restart might turn something up. + @override + Future probe() async { + if (!_running) return; + final advert = _advert; + for (final candidate in _seen.values) { + if (advert != null && candidate.deviceId == advert.deviceId) continue; + if (!_found.isClosed) _found.add(candidate); + } + final since = _browsingSince; + if (_browser == null || since == null || DateTime.now().difference(since) > rediscoverAfter) { + await _stopBrowse(); + await _startBrowse(); + } + } + + // --------------------------------------------------------------------------- + + Future _startBroadcast() async { + final advert = _advert; + if (advert == null || advert.port <= 0) return; + try { + final broadcast = BonsoirBroadcast( + printLogs: false, + service: BonsoirService( + name: _serviceName(advert), + type: serviceType, + port: advert.port, + attributes: { + 'id': advert.deviceId, + 'fp16': advert.shortFingerprint, + 'name': advert.name, + 'platform': advert.platform.code, + 'role': advert.role.code, + }, + ), + ); + await broadcast.initialize(); + _broadcastEvents = broadcast.eventStream?.listen(_onBroadcastEvent); + await broadcast.start(); + _broadcast = broadcast; + } catch (e) { + // No Avahi, no permission, no mDNS stack: the other backends carry on. + _log.info('cannot announce over mDNS: $e'); + await _stopBroadcast(); + } + } + + /// Another device on the network already answers to this name. Most + /// platforms rename silently; the ones that do not say so here. + void _onBroadcastEvent(BonsoirBroadcastEvent event) { + if (event is! BonsoirBroadcastNameAlreadyExistsEvent) return; + if (_nameAttempt >= 3) return; + _nameAttempt++; + unawaited(() async { + await _stopBroadcast(); + await _startBroadcast(); + }()); + } + + Future _stopBroadcast() async { + final broadcast = _broadcast; + _broadcast = null; + await _broadcastEvents?.cancel(); + _broadcastEvents = null; + if (broadcast == null || broadcast.isStopped) return; + try { + await broadcast.stop(); + } catch (_) {} + } + + Future _startBrowse() async { + try { + final browser = BonsoirDiscovery(printLogs: false, type: serviceType); + await browser.initialize(); + _events = browser.eventStream?.listen((event) => _onEvent(browser, event)); + await browser.start(); + _browser = browser; + _browsingSince = DateTime.now(); + } catch (e) { + _log.info('cannot browse over mDNS: $e'); + await _stopBrowse(); + } + } + + Future _stopBrowse() async { + final browser = _browser; + _browser = null; + _browsingSince = null; + await _events?.cancel(); + _events = null; + if (browser == null || browser.isStopped) return; + try { + await browser.stop(); + } catch (_) {} + } + + void _onEvent(BonsoirDiscovery browser, BonsoirDiscoveryEvent event) { + switch (event) { + case BonsoirDiscoveryServiceFoundEvent(): + // Found only carries the name; the address and the TXT record need + // a second round trip. + unawaited(_resolve(browser, event.service)); + case BonsoirDiscoveryServiceResolvedEvent(): + _publish(event.service); + case BonsoirDiscoveryServiceUpdatedEvent(): + _publish(event.service); + case BonsoirDiscoveryServiceLostEvent(): + _seen.removeWhere((_, c) => c.name == event.service.name); + default: + break; + } + } + + Future _resolve(BonsoirDiscovery browser, BonsoirService service) async { + try { + await service.resolve(browser.serviceResolver); + } catch (e) { + _log.fine('cannot resolve ${service.name}: $e'); + } + } + + void _publish(BonsoirService service) { + final advert = _advert; + final candidate = candidateFromService(service, selfId: advert?.deviceId); + if (candidate == null) return; + _seen[candidate.deviceId] = candidate; + if (!_found.isClosed) _found.add(candidate); + } + + /// `Mi iPhone` for the phone next to you, and something that still works + /// when two of them are called the same: the id in the TXT record is what + /// identifies the device, the name is only what other Bonjour browsers + /// show. + String _serviceName(DiscoveryAdvert advert) { + final base = advert.name.trim().isEmpty ? 'PepoConnect' : advert.name.trim(); + final name = _nameAttempt == 0 + ? base + : '$base (${advert.deviceId.substring(advert.deviceId.length - 4)})'; + // RFC 6763 allows 63 bytes; accented characters take two. + return name.length > 40 ? name.substring(0, 40) : name; + } +} + +/// Turns a resolved mDNS service into a candidate, or null when it is not +/// one of ours, is this very device, or has not been resolved yet. +/// +/// Kept apart from the plugin so the mapping can be tested without a +/// platform channel. +PeerCandidate? candidateFromService(BonsoirService service, {String? selfId, DateTime? at}) { + final attributes = service.attributes; + final id = attributes['id']; + if (id == null || id.length != 26 || id == selfId) return null; + if (service.port <= 0) return null; + final addresses = usableAddresses(service.hostAddresses); + if (addresses.isEmpty) return null; + return PeerCandidate( + deviceId: id, + shortFingerprint: attributes['fp16'] ?? '', + name: attributes['name'] ?? service.name, + addresses: addresses, + port: service.port, + platform: DevicePlatform.fromCode(attributes['platform']), + role: DeviceRole.fromCode(attributes['role']), + source: DiscoverySource.mdns, + seenAt: at ?? DateTime.now(), + ); +} + +/// Drops what cannot be dialled: APIPA, IPv6 link-local (the scope id the +/// peer reports means nothing on this side) and loopback. +List usableAddresses(List addresses) { + final out = []; + for (final address in addresses) { + final value = address.trim(); + if (value.isEmpty) continue; + if (value.startsWith('169.254.') || value == '127.0.0.1') continue; + final lower = value.toLowerCase(); + if (lower.startsWith('fe80:') || lower == '::1') continue; + if (!out.contains(value)) out.add(value); + } + // IPv4 first: it is what the rest of the app pairs and reconnects with. + out.sort((a, b) => (a.contains(':') ? 1 : 0).compareTo(b.contains(':') ? 1 : 0)); + return out; +} diff --git a/lib/platform/ios_background.dart b/lib/platform/ios_background.dart new file mode 100644 index 0000000..f225b0b --- /dev/null +++ b/lib/platform/ios_background.dart @@ -0,0 +1,89 @@ +import 'pepo_native.dart'; + +/// What the iOS side reports about the background engine. +class IosBackgroundState { + const IosBackgroundState({ + this.running = false, + this.playing = false, + this.held = false, + this.refresh = 'unknown', + this.startedAt, + }); + + /// The engine is meant to be running. + final bool running; + + /// The silent loop really is playing. Running without playing means iOS + /// took the audio session away and the watchdog has not won it back yet. + final bool playing; + + /// A `beginBackgroundTask` assertion is held (a transfer is in flight). + final bool held; + + /// `available`, `denied`, `restricted` or `unknown`: the system switch for + /// Background App Refresh, which gates the `BGTask` safety net. + final String refresh; + + /// When the loop started. It resets with the process, so how long it has + /// been running is the phone's own proof that iOS never suspended it. + final DateTime? startedAt; + + bool get healthy => running && playing; + + static const IosBackgroundState off = IosBackgroundState(); + + factory IosBackgroundState.fromMap(Map map) { + final started = map['startedAt']; + return IosBackgroundState( + running: map['running'] as bool? ?? false, + playing: map['playing'] as bool? ?? false, + held: map['held'] as bool? ?? false, + refresh: map['refresh'] as String? ?? 'unknown', + startedAt: started is int ? DateTime.fromMillisecondsSinceEpoch(started) : null, + ); + } +} + +/// The iOS counterpart of the Android foreground service: what keeps the +/// engine in this isolate running once PepoConnect leaves the screen. +/// +/// iOS never wakes a suspended app for a new photo — no API does that — so +/// the only way a photo leaves the phone on its own is for the process not to +/// be suspended in the first place. [start] plays an inaudible loop under the +/// `audio` background mode to get exactly that, and everything else (the +/// photo library observer, the control socket, the transfers) keeps working +/// untouched. +/// +/// What it does not survive: the app being closed from the app switcher, or a +/// reboot. Then the pending photos wait in the auto-send queue until the app +/// is opened again, or until iOS grants one of the `BGTask`s. +class IosBackground { + const IosBackground._(); + + static bool get isSupported => PepoNative.isIos; + + /// Starts the keep-alive engine. False when the audio session refused, + /// which the watchdog on the native side will keep retrying. + static Future start() async { + if (!isSupported) return false; + return PepoNative.keepAliveStart(); + } + + static Future stop() async { + if (!isSupported) return; + await PepoNative.keepAliveStop(); + } + + /// Holds the process up for the ~30 s iOS grants on request. Cheap enough + /// to leave held for as long as a transfer runs. + static Future hold(bool on) async { + if (!isSupported) return; + await PepoNative.backgroundHold(on); + } + + static Future status() async { + if (!isSupported) return IosBackgroundState.off; + final raw = await PepoNative.backgroundStatus(); + return raw == null ? IosBackgroundState.off : IosBackgroundState.fromMap(raw); + } +} diff --git a/lib/platform/media_source_photo_manager.dart b/lib/platform/media_source_photo_manager.dart index 843593e..d9310b6 100644 --- a/lib/platform/media_source_photo_manager.dart +++ b/lib/platform/media_source_photo_manager.dart @@ -51,30 +51,34 @@ class MediaSourcePhotoManager extends MediaSource { @override Stream get changes => _changes.stream; + static const _permission = pm.PermissionRequestOption( + androidPermission: pm.AndroidPermission(type: pm.RequestType.common, mediaLocation: true), + iosAccessLevel: pm.IosAccessLevel.readWrite, + ); + /// Asks for photo permissions. Returns true when at least limited access /// was granted. static Future requestPermission() async { - final state = await pm.PhotoManager.requestPermissionExtend( - requestOption: const pm.PermissionRequestOption( - androidPermission: pm.AndroidPermission(type: pm.RequestType.common, mediaLocation: true), - iosAccessLevel: pm.IosAccessLevel.readWrite, - ), - ); + final state = await pm.PhotoManager.requestPermissionExtend(requestOption: _permission); return state.hasAccess; } /// Current permission state without prompting. - static Future hasPermission() async { + static Future hasPermission() async => (await _state())?.hasAccess ?? false; + + /// True when iOS granted only a hand-picked selection. Photos taken later + /// are invisible to the app then, so automatic sending quietly does + /// nothing and the only fix is in the system settings. + static Future permissionLimited() async { + if (!Platform.isIOS) return false; + return await _state() == pm.PermissionState.limited; + } + + static Future _state() async { try { - final state = await pm.PhotoManager.getPermissionState( - requestOption: const pm.PermissionRequestOption( - androidPermission: pm.AndroidPermission(type: pm.RequestType.common, mediaLocation: true), - iosAccessLevel: pm.IosAccessLevel.readWrite, - ), - ); - return state.hasAccess; + return await pm.PhotoManager.getPermissionState(requestOption: _permission); } catch (_) { - return false; + return null; } } @@ -300,6 +304,19 @@ class MediaSourcePhotoManager extends MediaSource { } } + /// Wipes the copies the platform makes when an original is exported. + /// + /// [originalPath] cannot hand out a path into the photo library, so every + /// photo that gets sent is first written to the app's temporary folder. + /// They are never cleaned up while the process lives, and with the + /// background engine the process lives for days. Only `photo_manager`'s own + /// cache folders are removed, never anything of the user's. + static Future clearExportedOriginals() async { + try { + await pm.PhotoManager.clearFileCache(); + } catch (_) {} + } + /// Converts a HEIC/HEIF file to JPEG next to the cache dir, keeping EXIF. Future convertHeicToJpeg(String path) async { try { diff --git a/lib/platform/mobile_permissions.dart b/lib/platform/mobile_permissions.dart index e055748..143f278 100644 --- a/lib/platform/mobile_permissions.dart +++ b/lib/platform/mobile_permissions.dart @@ -10,13 +10,19 @@ class MobilePermissionState { required this.photos, required this.notifications, required this.batteryUnrestricted, + this.photosLimited = false, }); final bool photos; final bool notifications; final bool batteryUnrestricted; - bool get allGranted => photos && notifications && batteryUnrestricted; + /// iOS only: access was granted to a hand-picked selection. It counts as + /// [photos] for iOS, but new photos never show up, so automatic sending + /// silently does nothing. + final bool photosLimited; + + bool get allGranted => photos && !photosLimited && notifications && batteryUnrestricted; } /// Requests and checks Android/iOS permissions (photos, notifications, @@ -36,6 +42,7 @@ class MobilePermissions { } return MobilePermissionState( photos: await MediaSourcePhotoManager.hasPermission(), + photosLimited: await MediaSourcePhotoManager.permissionLimited(), notifications: await SystemNotifications.instance.enabled(), batteryUnrestricted: Platform.isIOS || await PepoNative.isIgnoringBatteryOptimizations(), ); @@ -52,4 +59,8 @@ class MobilePermissions { if (!Platform.isAndroid) return; await PepoNative.requestIgnoreBatteryOptimizations(); } + + /// Only the system settings can turn a limited photo selection into full + /// access; the in-app picker just adds more photos to the selection. + static Future openSystemSettings() => PepoNative.openAppSettings(); } diff --git a/lib/platform/pepo_native.dart b/lib/platform/pepo_native.dart index d8c109c..5c922a7 100644 --- a/lib/platform/pepo_native.dart +++ b/lib/platform/pepo_native.dart @@ -1,26 +1,46 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; /// Thin wrapper over the `org.pepoconnect/native` channel (`PepoNative.kt` on -/// Android, `AppDelegate.swift` on iOS). Every call is a no-op elsewhere. +/// Android, `AppDelegate.swift` + `PepoBackground.swift` on iOS). Every call +/// is a no-op elsewhere. class PepoNative { const PepoNative._(); static const _channel = MethodChannel('org.pepoconnect/native'); - static bool get _available => Platform.isAndroid; + /// Which native side to talk to. Only tests set it; everywhere else it is + /// the platform the app is really running on. + @visibleForTesting + static String? debugPlatform; - /// Android has the native side of this channel. - static bool get isSupported => _available; + static String get _os => debugPlatform ?? Platform.operatingSystem; + + /// The foreground service, the clipboard tile, MediaStore: Android only. + static bool get _android => _os == 'android'; + + /// The background engine: iPhone only. + static bool get isIos => _os == 'ios'; + + /// The chime and the background engine exist on both phones. + static bool get _mobile => _android || isIos; + + /// True where the channel has a native side at all. + static bool get isSupported => _mobile; /// Clipboard text handed over by the native side (Android quick-settings /// tile / launcher shortcut). Returns `{'sent': [device names]}`. static Future> Function(String text)? onClipboardText; + /// iOS handed us a `BGAppRefreshTask` / `BGProcessingTask`. Whatever this + /// does must finish quickly and end with [backgroundTaskDone]. + static Future Function(String id)? onBackgroundTask; + /// Listens for native → Dart calls. Idempotent. static void init() { - if (!_available) return; + if (!_mobile) return; _channel.setMethodCallHandler(_onCall); } @@ -31,6 +51,12 @@ class PepoNative { final handler = onClipboardText; if (handler == null || text.isEmpty) return {'sent': []}; return handler(text); + case 'backgroundTask': + final args = (call.arguments as Map?)?.cast(); + final id = args?['id'] as String? ?? ''; + final handler = onBackgroundTask; + if (handler != null) await handler(id); + return null; default: throw MissingPluginException('${call.method} is not handled in Dart'); } @@ -40,7 +66,7 @@ class PepoNative { /// before the handler exists sits in the channel buffer unanswered, so /// the native side must not use the channel until it hears this. static Future markClipboardReady() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('clipboardReady'); } catch (_) {} @@ -52,7 +78,7 @@ class PepoNative { /// permission dialogs) have nobody to answer them then, and some never /// complete. Always true where there is no such service. static Future hasActivity() async { - if (!_available) return true; + if (!_android) return true; try { return await _channel.invokeMethod('hasActivity') ?? false; } catch (_) { @@ -68,7 +94,7 @@ class PepoNative { required String text, required String idle, }) async { - if (!_available) return false; + if (!_android) return false; try { return await _channel.invokeMethod('serviceStart', { 'title': title, @@ -82,7 +108,7 @@ class PepoNative { } static Future serviceUpdate({required String title, required String text}) async { - if (!_available) return false; + if (!_android) return false; try { return await _channel.invokeMethod('serviceUpdate', {'title': title, 'text': text}) ?? false; @@ -92,14 +118,14 @@ class PepoNative { } static Future serviceStop() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('serviceStop'); } catch (_) {} } static Future serviceRunning() async { - if (!_available) return false; + if (!_android) return false; try { return await _channel.invokeMethod('serviceRunning') ?? false; } catch (_) { @@ -107,22 +133,86 @@ class PepoNative { } } + // --------------------------------------------------------------------------- + // iOS background engine + + /// Tells iOS that [init] ran, so it may push `backgroundTask` calls. Also + /// hands over any task that arrived while nobody was listening. + static Future markBackgroundReady() async { + if (!isIos) return; + try { + await _channel.invokeMethod('backgroundReady'); + } catch (_) {} + } + + /// Starts the silent-audio loop that keeps the process out of iOS's + /// suspended state. False when the audio session refused. + static Future keepAliveStart() async { + if (!isIos) return false; + try { + return await _channel.invokeMethod('keepAliveStart') ?? false; + } catch (_) { + return false; + } + } + + static Future keepAliveStop() async { + if (!isIos) return; + try { + await _channel.invokeMethod('keepAliveStop'); + } catch (_) {} + } + + /// Holds (or releases) a `beginBackgroundTask` assertion: the ~30 s iOS + /// grants on request, so a transfer in flight is not cut off when the app + /// leaves the screen. + static Future backgroundHold(bool on) async { + if (!isIos) return false; + try { + return await _channel.invokeMethod('backgroundHold', {'on': on}) ?? false; + } catch (_) { + return false; + } + } + + /// `{running, playing, held, refresh, startedAt}`; null off iOS. + static Future?> backgroundStatus() async { + if (!isIos) return null; + try { + final raw = await _channel.invokeMethod>('backgroundStatus'); + return raw?.cast(); + } catch (_) { + return null; + } + } + + /// Hands a `BGTask` back to iOS. Not answering it wastes the app's budget + /// for the next ones. + static Future backgroundTaskDone(String id, {bool ok = true}) async { + if (!isIos) return; + try { + await _channel.invokeMethod('backgroundTaskDone', {'id': id, 'ok': ok}); + } catch (_) {} + } + + // --------------------------------------------------------------------------- + static Future acquireMulticastLock() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('acquireMulticastLock'); } catch (_) {} } static Future releaseMulticastLock() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('releaseMulticastLock'); } catch (_) {} } static Future isIgnoringBatteryOptimizations() async { - if (!_available) return true; + if (!_android) return true; try { return await _channel.invokeMethod('isIgnoringBatteryOptimizations') ?? true; } catch (_) { @@ -131,7 +221,7 @@ class PepoNative { } static Future requestIgnoreBatteryOptimizations() async { - if (!_available) return; + if (!_android) return; try { await _channel.invokeMethod('requestIgnoreBatteryOptimizations'); } catch (_) {} @@ -140,7 +230,7 @@ class PepoNative { /// Copies a received file into `Download/PepoConnect` through MediaStore /// so it shows up in the Files app. Returns the content URI or null. static Future saveToDownloads(String path, String name, String mime) async { - if (!_available) return null; + if (!_android) return null; try { return await _channel.invokeMethod('saveToDownloads', { 'path': path, @@ -155,7 +245,7 @@ class PepoNative { /// Plays a short 16-bit mono WAV (the transfer chime) on the notification /// stream. Android and iOS; false when it could not be played. static Future playWav(Uint8List wav, {required int sampleRate}) async { - if (!Platform.isAndroid && !Platform.isIOS) return false; + if (!_mobile) return false; try { return await _channel.invokeMethod('playWav', {'wav': wav, 'sampleRate': sampleRate}) ?? false; @@ -165,7 +255,7 @@ class PepoNative { } static Future openAppSettings() async { - if (!_available) return; + if (!_mobile) return; try { await _channel.invokeMethod('openAppSettings'); } catch (_) {} diff --git a/lib/shared/util/format.dart b/lib/shared/util/format.dart index 6a147cc..1b2edc6 100644 --- a/lib/shared/util/format.dart +++ b/lib/shared/util/format.dart @@ -87,6 +87,16 @@ String _time(DateTime local, String locale) { } } +/// "18 s" / "47 min" / "3 h 12 min": how long something has been going. +/// The units read the same in Spanish and English, so this needs no +/// localization. +String formatShortDuration(Duration d) { + if (d.inMinutes < 1) return '${d.inSeconds.clamp(0, 59)} s'; + if (d.inHours < 1) return '${d.inMinutes} min'; + final minutes = d.inMinutes.remainder(60); + return minutes == 0 ? '${d.inHours} h' : '${d.inHours} h $minutes min'; +} + /// "1:05" / "12:07:30" for video lengths. String formatDuration(Duration d) { final h = d.inHours; diff --git a/lib/state/stores.dart b/lib/state/stores.dart index 331167f..a418746 100644 --- a/lib/state/stores.dart +++ b/lib/state/stores.dart @@ -179,6 +179,31 @@ class JsonTransferStore implements TransferStore { } } +/// The phone's auto-send queue in `autosend.json`. Written through (not +/// just debounced): its whole point is to survive the process dying. +class JsonAutoSendStore implements AutoSendStore { + JsonAutoSendStore(String dataDir) : _file = JsonFile(p.join(dataDir, 'autosend.json')); + + final JsonFile _file; + + @override + Future load() async { + final raw = await _file.read(); + if (raw is! Map) return AutoSendData.empty; + try { + return AutoSendData.fromJson(raw.cast()); + } catch (_) { + return AutoSendData.empty; + } + } + + @override + Future save(AutoSendData data) async { + _file.write(data.toJson()); + await _file.flush(); + } +} + /// Per-device gallery item states in `gallery/.json`: /// `{"seenUntil": , "items": [...]}`. Files written before the /// watermark existed are a bare list and still load. diff --git a/packages/pepo_core/lib/pepo_core.dart b/packages/pepo_core/lib/pepo_core.dart index 04b41ca..4bcf8cb 100644 --- a/packages/pepo_core/lib/pepo_core.dart +++ b/packages/pepo_core/lib/pepo_core.dart @@ -9,6 +9,7 @@ export 'src/engine/pepo_engine.dart'; export 'src/identity/certificate_factory.dart'; export 'src/identity/identity.dart'; export 'src/identity/identity_store.dart'; +export 'src/media/auto_send_queue.dart'; export 'src/media/gallery_client.dart'; export 'src/media/image_ops.dart'; export 'src/media/media_server.dart' show MediaServer, DeletePolicy; diff --git a/packages/pepo_core/lib/src/engine/pepo_engine.dart b/packages/pepo_core/lib/src/engine/pepo_engine.dart index 2930353..6f19b42 100644 --- a/packages/pepo_core/lib/src/engine/pepo_engine.dart +++ b/packages/pepo_core/lib/src/engine/pepo_engine.dart @@ -9,11 +9,12 @@ import '../discovery/discovery.dart'; import '../discovery/udp_beacon.dart'; import '../identity/identity.dart'; import '../identity/identity_store.dart'; -import '../native/native_bulk.dart'; +import '../media/auto_send_queue.dart'; import '../media/gallery_client.dart'; import '../media/media_server.dart'; import '../media/media_source.dart'; import '../media/media_source_fs.dart'; +import '../native/native_bulk.dart'; import '../net/frame.dart'; import '../net/handshake.dart'; import '../net/peer_connection.dart'; @@ -68,6 +69,7 @@ class PepoEngine { TransferStore? transferStore, MediaStateStore? mediaStateStore, MediaSource? mediaSource, + AutoSendStore? autoSendStore, List? discovery, DeviceStatus Function()? localStatus, OfferPolicy? offerPolicy, @@ -79,6 +81,8 @@ class PepoEngine { // ignore: prefer_initializing_formals _mediaSource = mediaSource, // ignore: prefer_initializing_formals + _autoSendStore = autoSendStore, + // ignore: prefer_initializing_formals _discovery = discovery, // ignore: prefer_initializing_formals _localStatus = localStatus, @@ -93,6 +97,7 @@ class PepoEngine { final TransferStore _transferStore; final MediaStateStore _mediaStateStore; MediaSource? _mediaSource; + final AutoSendStore? _autoSendStore; final List? _discovery; final DeviceStatus Function()? _localStatus; final OfferPolicy? _offerPolicy; @@ -194,6 +199,7 @@ class PepoEngine { sessions: sessions, transfers: transfers, deletePolicy: _deletePolicy, + autoSendStore: _autoSendStore, ); sessions.handlers.add(mediaServer!); await mediaServer!.start(); @@ -214,6 +220,9 @@ class PepoEngine { case DeviceConnectedEvent(): _emit(DeviceConnectionEvent(deviceId: e.deviceId, connected: true)); _emit(DevicesChangedEvent()); + // Whatever piled up while this device was away. + final server = mediaServer; + if (server != null) unawaited(server.autoSend.drain()); case DeviceDisconnectedEvent(): _emit(DeviceConnectionEvent(deviceId: e.deviceId, connected: false, reason: e.reason)); _emit(DevicesChangedEvent()); @@ -667,16 +676,17 @@ class PepoEngine { MediaSource? get ownMediaSource => _mediaSource; /// Enables/disables automatic sending of new photos to [deviceId]. - void setAutoSend(String deviceId, bool enabled) { + Future setAutoSend(String deviceId, bool enabled) async { + await mediaServer?.setAutoSend(deviceId, enabled); + } + + /// Picks up whatever this phone owes its hubs and sends what it can: on + /// reconnect, on start, and when iOS hands the app a background task. + Future flushAutoSend() async { final server = mediaServer; if (server == null) return; - final set = server.autoSendTo ?? {}; - if (enabled) { - set.add(deviceId); - } else { - set.remove(deviceId); - } - server.autoSendTo = set; + await server.catchUp(); + await server.autoSend.drain(); } } diff --git a/packages/pepo_core/lib/src/media/auto_send_queue.dart b/packages/pepo_core/lib/src/media/auto_send_queue.dart new file mode 100644 index 0000000..e79d547 --- /dev/null +++ b/packages/pepo_core/lib/src/media/auto_send_queue.dart @@ -0,0 +1,378 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; + +final _log = Logger('pepo.media.autosend'); + +/// What one attempt at sending an item ended up as. +enum AutoSendResult { + /// Handed over to the transfer engine, which owns it from here. + sent, + + /// The item is not there any more (deleted, no readable original): drop it. + skip, + + /// Could not be sent right now. It stays queued. + retry, +} + +/// One item waiting to go to one device. +class AutoSendEntry { + const AutoSendEntry({ + required this.deviceId, + required this.itemId, + required this.queuedAt, + this.attempts = 0, + }); + + final String deviceId; + final String itemId; + final DateTime queuedAt; + + /// Failed tries so far. A photo that never makes it must not wedge the + /// queue behind it forever. + final int attempts; + + String get key => '$deviceId/$itemId'; + + AutoSendEntry retried() => + AutoSendEntry(deviceId: deviceId, itemId: itemId, queuedAt: queuedAt, attempts: attempts + 1); + + Map toJson() => { + 'deviceId': deviceId, + 'itemId': itemId, + 'at': queuedAt.toUtc().toIso8601String(), + 'tries': attempts, + }; + + factory AutoSendEntry.fromJson(Map json) => AutoSendEntry( + deviceId: json['deviceId'] as String, + itemId: json['itemId'] as String, + queuedAt: DateTime.tryParse(json['at'] as String? ?? '') ?? DateTime.now(), + attempts: json['tries'] as int? ?? 0, + ); +} + +/// Everything the queue keeps across restarts. +class AutoSendData { + const AutoSendData({ + this.enabledSince, + this.catchUpAt, + this.pending = const [], + this.done = const [], + }); + + /// When automatic sending was switched on. Nothing older is ever queued, + /// so turning the switch on does not push the whole camera roll to the PC. + final DateTime? enabledSince; + + /// Newest item the catch-up pass has already looked at. This is what makes + /// a photo taken while the process was dead arrive at the next start + /// instead of being silently skipped. + final DateTime? catchUpAt; + + final List pending; + + /// `/` already handed to the transfer engine. + final List done; + + AutoSendData copyWith({ + DateTime? enabledSince, + DateTime? catchUpAt, + List? pending, + List? done, + bool clearEnabledSince = false, + }) => AutoSendData( + enabledSince: clearEnabledSince ? null : (enabledSince ?? this.enabledSince), + catchUpAt: catchUpAt ?? this.catchUpAt, + pending: pending ?? this.pending, + done: done ?? this.done, + ); + + Map toJson() => { + 'enabledSince': ?enabledSince?.toUtc().toIso8601String(), + 'catchUpAt': ?catchUpAt?.toUtc().toIso8601String(), + 'pending': pending.map((e) => e.toJson()).toList(), + 'done': done, + }; + + factory AutoSendData.fromJson(Map json) { + final pending = []; + for (final e in (json['pending'] as List? ?? const [])) { + try { + pending.add(AutoSendEntry.fromJson((e as Map).cast())); + } catch (_) {} + } + return AutoSendData( + enabledSince: DateTime.tryParse(json['enabledSince'] as String? ?? ''), + catchUpAt: DateTime.tryParse(json['catchUpAt'] as String? ?? ''), + pending: pending, + done: (json['done'] as List? ?? const []).cast(), + ); + } + + static const empty = AutoSendData(); +} + +/// Where [AutoSendQueue] keeps its state. +abstract class AutoSendStore { + Future load(); + Future save(AutoSendData data); +} + +/// In-memory store (tests, and any engine without a data directory). +class MemoryAutoSendStore implements AutoSendStore { + AutoSendData _data = AutoSendData.empty; + + @override + Future load() async => _data; + + @override + Future save(AutoSendData data) async => _data = data; +} + +/// Makes "send every new photo to the PC" a promise instead of a coin flip. +/// +/// Sending straight from the photo-library notification only works when the +/// peer happens to be connected at that instant. With the PC asleep, the +/// Wi-Fi switching over, or the phone's process freshly restarted, the photo +/// was simply dropped and never retried. This queue writes every pending item +/// to disk, hands it over as soon as the device is reachable again, and +/// remembers what already went so a restart does not send it twice. +class AutoSendQueue { + AutoSendQueue({ + required this.store, + required this.send, + required this.isReachable, + this.maxPending = 200, + this.maxDone = 500, + this.maxAttempts = 8, + this.maxAge = const Duration(days: 7), + }); + + final AutoSendStore store; + + /// Tries to send one item. See [AutoSendResult]. + final Future Function(String deviceId, String itemId) send; + + /// Whether that device has a live session at this moment. + final bool Function(String deviceId) isReachable; + + /// A phone left offline for a week should not wake up and flood the PC. + final int maxPending; + final int maxDone; + final int maxAttempts; + final Duration maxAge; + + AutoSendData _data = AutoSendData.empty; + final Set _targets = {}; + + /// Every change to [_data] queues up behind this. A photo landing while a + /// reconnect is draining would otherwise have both sides write their own + /// version of the state, and one of them would be lost. + Future _writes = Future.value(); + bool _loaded = false; + bool _draining = false; + bool _again = false; + + /// Devices that get every new photo automatically. + Set get targets => Set.unmodifiable(_targets); + + bool get isEmpty => _data.pending.isEmpty; + int get pendingCount => _data.pending.length; + + /// Nothing taken before this is ever queued. + DateTime? get enabledSince => _data.enabledSince; + + /// Where the catch-up pass should start looking. + DateTime? get catchUpFrom { + final since = _data.enabledSince; + final mark = _data.catchUpAt; + if (since == null) return mark; + if (mark == null) return since; + return mark.toUtc().isAfter(since.toUtc()) ? mark : since; + } + + Future load() async { + if (_loaded) return; + _loaded = true; + try { + _data = await store.load(); + } catch (e) { + _log.warning('cannot read the auto-send queue: $e'); + _data = AutoSendData.empty; + } + } + + /// Replaces the set of devices that get new photos. Switching it on for + /// the first time stamps [enabledSince]; switching it off entirely drops + /// whatever was still queued, because nobody asked for it any more. + Future setTargets(Iterable deviceIds, {DateTime? now}) async { + await load(); + final next = deviceIds.toSet(); + if (_setEquals(next, _targets)) return; + _targets + ..clear() + ..addAll(next); + await _mutate((data) { + if (_targets.isEmpty) { + return data.copyWith(pending: const [], clearEnabledSince: true); + } + var out = data; + // Only the first time it is switched on. Re-applying the setting at + // every start must not move the mark forward, or the photos taken + // while the process was dead would all fall before it. + if (out.enabledSince == null) { + final at = now ?? DateTime.now(); + out = out.copyWith(enabledSince: at, catchUpAt: at); + } + // Anything queued for a device that is no longer a target is dead mail. + return out.copyWith( + pending: out.pending.where((e) => _targets.contains(e.deviceId)).toList(), + ); + }, now: now); + } + + /// Queues [itemId] for every target. [takenAt] is when the photo was taken; + /// anything older than [enabledSince] is ignored. + Future add(String itemId, {DateTime? takenAt, DateTime? now}) async { + await load(); + if (_targets.isEmpty) return; + final since = _data.enabledSince; + if (takenAt != null && since != null && takenAt.toUtc().isBefore(since.toUtc())) return; + final at = now ?? DateTime.now(); + var added = false; + await _mutate((data) { + final pending = List.of(data.pending); + final queued = pending.map((e) => e.key).toSet(); + final done = data.done.toSet(); + for (final deviceId in _targets) { + final key = '$deviceId/$itemId'; + if (queued.contains(key) || done.contains(key)) continue; + pending.add(AutoSendEntry(deviceId: deviceId, itemId: itemId, queuedAt: at)); + added = true; + } + return added ? data.copyWith(pending: pending) : data; + }, now: at); + if (added) await drain(); + } + + /// Sends everything that can be sent right now. A device that refuses is + /// parked until the next call, so a PC that just went away does not burn + /// the rest of its queue. + Future drain() async { + await load(); + // A photo from last week is not something anyone is still waiting for, + // and nothing else would ever clear it out of a queue that never drains. + if (_prune(_data, DateTime.now()).pending.length != _data.pending.length) { + await _mutate((data) => data); + } + if (_draining) { + // A change landed mid-drain: go round again instead of dropping it. + _again = true; + return; + } + _draining = true; + try { + do { + _again = false; + await _drainOnce(); + } while (_again); + } finally { + _draining = false; + } + } + + Future _drainOnce() async { + if (_data.pending.isEmpty) return; + final parked = {}; + final sent = {}; + final dropped = {}; + final retried = {}; + for (final entry in List.of(_data.pending)) { + if (parked.contains(entry.deviceId)) continue; + if (!_targets.contains(entry.deviceId) || !isReachable(entry.deviceId)) continue; + AutoSendResult result; + try { + result = await send(entry.deviceId, entry.itemId); + } catch (e) { + _log.fine('auto-send of ${entry.itemId} failed: $e'); + result = AutoSendResult.retry; + } + switch (result) { + case AutoSendResult.sent: + sent.add(entry.key); + case AutoSendResult.skip: + dropped.add(entry.key); + case AutoSendResult.retry: + if (entry.attempts + 1 >= maxAttempts) { + _log.info('giving up on ${entry.itemId} after ${entry.attempts + 1} tries'); + dropped.add(entry.key); + } else { + retried.add(entry.key); + } + parked.add(entry.deviceId); + } + } + if (sent.isEmpty && dropped.isEmpty && retried.isEmpty) return; + final gone = {...sent, ...dropped}; + // Expressed against whatever the state is when this lands, not against + // the snapshot the loop above started from: a photo taken while these + // were on the wire has to survive. + await _mutate( + (data) => data.copyWith( + pending: [ + for (final e in data.pending) + if (!gone.contains(e.key)) (retried.contains(e.key) ? e.retried() : e), + ], + done: [...data.done, ...sent], + ), + ); + } + + /// Remembers how far the catch-up pass got, so the next start only looks + /// at what came after. + Future markCaughtUp(DateTime at) async { + await load(); + final current = _data.catchUpAt; + if (current != null && !at.toUtc().isAfter(current.toUtc())) return; + await _mutate((data) => data.copyWith(catchUpAt: at)); + } + + /// Applies [change] to the current state and writes it out, one at a time. + /// [change] is handed the state as it is when its turn comes, never a + /// snapshot taken earlier. + Future _mutate(AutoSendData Function(AutoSendData data) change, {DateTime? now}) { + final previous = _writes; + final mine = Completer(); + _writes = mine.future; + return previous + .then((_) async { + _data = _prune(change(_data), now ?? DateTime.now()); + try { + await store.save(_data); + } catch (e) { + _log.warning('cannot write the auto-send queue: $e'); + } + }) + .whenComplete(mine.complete); + } + + AutoSendData _prune(AutoSendData data, DateTime now) { + final cutoff = now.toUtc().subtract(maxAge); + var pending = data.pending + .where((e) => !e.queuedAt.toUtc().isBefore(cutoff)) + .toList(growable: false); + // Oldest first by construction: a backlog this long means the oldest are + // the least likely to still be wanted. + if (pending.length > maxPending) { + pending = pending.sublist(pending.length - maxPending); + } + var done = data.done; + if (done.length > maxDone) done = done.sublist(done.length - maxDone); + return data.copyWith(pending: pending, done: done); + } + + static bool _setEquals(Set a, Set b) => + a.length == b.length && a.every(b.contains); +} diff --git a/packages/pepo_core/lib/src/media/media_server.dart b/packages/pepo_core/lib/src/media/media_server.dart index 4ede4f0..7d7eea2 100644 --- a/packages/pepo_core/lib/src/media/media_server.dart +++ b/packages/pepo_core/lib/src/media/media_server.dart @@ -10,6 +10,7 @@ import '../protocol/message_types.dart'; import '../protocol/models.dart'; import '../transfer/transfer_engine.dart'; import '../util/bytes.dart'; +import 'auto_send_queue.dart'; import 'media_source.dart'; final _log = Logger('pepo.media.server'); @@ -28,8 +29,14 @@ class MediaServer implements MessageHandler { DeletePolicy? deletePolicy, this.thumbPx = 320, this.maxThumbBatch = 32, - this.autoSendTo, - }) : deletePolicy = deletePolicy ?? ((_, _) async => true); + AutoSendStore? autoSendStore, + }) : deletePolicy = deletePolicy ?? ((_, _) async => true) { + autoSend = AutoSendQueue( + store: autoSendStore ?? MemoryAutoSendStore(), + send: _autoSend, + isReachable: (deviceId) => sessions.session(deviceId)?.isConnected ?? false, + ); + } final MediaSource source; final SessionManager sessions; @@ -38,15 +45,24 @@ class MediaServer implements MessageHandler { final int thumbPx; final int maxThumbBatch; + /// New photos waiting to go to the hub. Persisted, so a photo taken while + /// the PC was off still arrives once it comes back. + late final AutoSendQueue autoSend; + /// Device ids that get every new photo sent automatically (phone-side - /// "auto send" setting). Null = none. - Set? autoSendTo; + /// "auto send" setting). + Set get autoSendTo => autoSend.targets; + + /// How far back the catch-up pass is willing to walk. Five pages of 100 is + /// a long weekend of photos; past that, the phone was not really syncing. + static const _maxCatchUpPages = 5; StreamSubscription? _sub; Future start() async { await source.start(); _sub = source.changes.listen(_onChange); + await autoSend.load(); } Future stop() async { @@ -55,6 +71,48 @@ class MediaServer implements MessageHandler { await source.stop(); } + /// Enables or disables automatic sending to [deviceId], then picks up + /// whatever happened while nobody was watching. + Future setAutoSend(String deviceId, bool enabled) async { + final next = {...autoSend.targets}; + if (enabled) { + next.add(deviceId); + } else { + next.remove(deviceId); + } + await autoSend.setTargets(next); + if (enabled) await catchUp(); + await autoSend.drain(); + } + + /// Queues everything that appeared while nothing was listening: the + /// process was killed, the phone rebooted, the app was closed from the + /// switcher. [MediaSource] hands out a fresh snapshot when it starts, so + /// without this pass those photos are never reported as new and would sit + /// on the phone forever. + Future catchUp() async { + if (autoSend.targets.isEmpty) return; + final since = autoSend.catchUpFrom; + if (since == null) return; + try { + var newest = since; + var page = 0; + for (var i = 0; i < _maxCatchUpPages; i++) { + final result = await source.index(page: page, pageSize: 100, since: since); + for (final item in result.items) { + await autoSend.add(item.id, takenAt: item.takenAt); + if (item.takenAt.isAfter(newest)) newest = item.takenAt; + } + // Newest first, so an empty page means we are past the watermark. + if (result.items.isEmpty || result.nextPage == null) break; + page = result.nextPage!; + } + await autoSend.markCaughtUp(newest); + } catch (e) { + _log.fine('auto-send catch-up failed: $e'); + } + } + Future _onChange(MediaChange change) async { if (change.removed.isNotEmpty) { for (final s in sessions.sessions) { @@ -73,11 +131,10 @@ class MediaServer implements MessageHandler { for (final s in sessions.sessions) { if (!s.isConnected) continue; s.control?.send(MsgType.mediaNew, data: {'item': item.toJson()}, body: thumb); - final auto = autoSendTo; - if (auto != null && auto.contains(s.deviceId)) { - unawaited(_sendOriginal(s.deviceId, item.id, convert: false)); - } } + // Queued rather than sent straight to whoever happens to be connected: + // that is what makes it survive a PC that is asleep right now. + await autoSend.add(item.id, takenAt: item.takenAt); } } @@ -133,8 +190,8 @@ class MediaServer implements MessageHandler { case MsgType.fileRequest: final id = m.str('id'); final convert = m.optStr('convert') == 'jpeg'; - final ok = await _sendOriginal(deviceId, id, convert: convert); - if (ok) { + final result = await _sendOriginal(deviceId, id, convert: convert); + if (result == AutoSendResult.sent) { conn.respond(m.reqId, MsgType.fileAccept, data: {'id': id}); } else { conn.respondError(m.reqId, ErrorCode.notFound, 'item $id not available'); @@ -153,10 +210,20 @@ class MediaServer implements MessageHandler { } } - Future _sendOriginal(String deviceId, String id, {required bool convert}) async { + /// One queued item. The HEIC setting is the one this phone keeps for that + /// PC, the same flag its own downloads use. + Future _autoSend(String deviceId, String itemId) { + final convert = sessions.session(deviceId)?.device.convertHeic ?? false; + return _sendOriginal(deviceId, itemId, convert: convert); + } + + Future _sendOriginal(String deviceId, String id, {required bool convert}) async { final item = await source.item(id); + if (item == null) return AutoSendResult.skip; final path = await source.originalPath(id); - if (item == null || path == null) return false; + // The item is there but the original is not readable yet: on iOS that is + // an asset still coming down from iCloud, which is worth another try. + if (path == null) return AutoSendResult.retry; var sendPath = path; var name = item.name; if (convert && _isHeic(item)) { @@ -175,10 +242,10 @@ class MediaServer implements MessageHandler { mediaKind: item.kind, sourceId: id, ); - return true; + return AutoSendResult.sent; } catch (e) { _log.warning('cannot send $id: $e'); - return false; + return AutoSendResult.retry; } } diff --git a/packages/pepo_core/test/auto_send_queue_test.dart b/packages/pepo_core/test/auto_send_queue_test.dart new file mode 100644 index 0000000..01ff3fc --- /dev/null +++ b/packages/pepo_core/test/auto_send_queue_test.dart @@ -0,0 +1,298 @@ +import 'dart:async'; + +import 'package:pepo_core/src/media/auto_send_queue.dart'; +import 'package:test/test.dart'; + +const hub = 'HUB00000000000000000000000'; +const other = 'OTH00000000000000000000000'; + +/// Records what the queue tried to send and answers with whatever the test +/// lined up for that item. +class FakeSender { + final List calls = []; + final Map answers = {}; + AutoSendResult fallback = AutoSendResult.sent; + Set reachable = {hub, other}; + + /// Holds the next send open, so a test can make something else happen + /// while it is in flight. + Completer? gate; + + Future send(String deviceId, String itemId) async { + calls.add('$deviceId/$itemId'); + final held = gate; + if (held != null) { + gate = null; + return held.future; + } + return answers['$deviceId/$itemId'] ?? fallback; + } + + bool isReachable(String deviceId) => reachable.contains(deviceId); +} + +AutoSendQueue queueOn( + FakeSender sender, + AutoSendStore store, { + int maxPending = 200, + int maxDone = 500, + int maxAttempts = 8, + Duration maxAge = const Duration(days: 7), +}) => AutoSendQueue( + store: store, + send: sender.send, + isReachable: sender.isReachable, + maxPending: maxPending, + maxDone: maxDone, + maxAttempts: maxAttempts, + maxAge: maxAge, +); + +void main() { + late FakeSender sender; + late MemoryAutoSendStore store; + late AutoSendQueue queue; + + setUp(() { + sender = FakeSender(); + store = MemoryAutoSendStore(); + queue = queueOn(sender, store); + }); + + test('a new photo goes out as soon as it is queued', () async { + await queue.setTargets([hub]); + await queue.add('photo-1'); + expect(sender.calls, ['$hub/photo-1']); + expect(queue.isEmpty, isTrue); + }); + + test('nothing is queued while the switch is off', () async { + await queue.add('photo-1'); + expect(sender.calls, isEmpty); + expect(queue.isEmpty, isTrue); + }); + + test('a photo taken with the PC off waits and goes out on reconnect', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + expect(sender.calls, isEmpty); + expect(queue.pendingCount, 1); + + sender.reachable = {hub}; + await queue.drain(); + expect(sender.calls, ['$hub/photo-1']); + expect(queue.isEmpty, isTrue); + }); + + test('the queue survives a restart and does not send twice', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + + // Same store, new process. + final restarted = queueOn(sender, store); + await restarted.load(); + await restarted.setTargets([hub]); + sender.reachable = {hub}; + await restarted.drain(); + expect(sender.calls, ['$hub/photo-1']); + + await restarted.add('photo-1'); + expect(sender.calls, ['$hub/photo-1'], reason: 'already sent once'); + }); + + test('re-applying the setting at start keeps the original enabledSince', () async { + final before = DateTime.utc(2026, 9, 1); + await queue.setTargets([hub], now: before); + expect(queue.enabledSince, before); + + final restarted = queueOn(sender, store); + await restarted.setTargets([hub], now: DateTime.utc(2026, 9, 18)); + expect( + restarted.enabledSince, + before, + reason: 'moving it forward would hide every photo taken while dead', + ); + }); + + test('photos older than the switch being turned on are left alone', () async { + await queue.setTargets([hub], now: DateTime.utc(2026, 9, 10)); + await queue.add('old', takenAt: DateTime.utc(2026, 9, 9)); + await queue.add('new', takenAt: DateTime.utc(2026, 9, 11)); + expect(sender.calls, ['$hub/new']); + }); + + test('turning the switch off drops what was still queued', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + expect(queue.pendingCount, 1); + + await queue.setTargets(const []); + expect(queue.pendingCount, 0); + expect(queue.enabledSince, isNull); + }); + + test('changing the hub drops the old one and keeps the new', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + await queue.setTargets([other]); + expect(queue.pendingCount, 0); + + sender.reachable = {other}; + await queue.add('photo-2'); + expect(sender.calls, ['$other/photo-2']); + }); + + test('every target gets its own copy', () async { + await queue.setTargets([hub, other]); + await queue.add('photo-1'); + expect(sender.calls, containsAll(['$hub/photo-1', '$other/photo-1'])); + expect(sender.calls.length, 2); + }); + + test('a deleted photo is dropped instead of retried forever', () async { + await queue.setTargets([hub]); + sender.answers['$hub/gone'] = AutoSendResult.skip; + await queue.add('gone'); + expect(queue.isEmpty, isTrue); + + await queue.drain(); + expect(sender.calls, ['$hub/gone'], reason: 'not tried again'); + }); + + test('a photo that keeps failing is given up on after maxAttempts', () async { + queue = queueOn(sender, store, maxAttempts: 3); + await queue.setTargets([hub]); + sender.fallback = AutoSendResult.retry; + await queue.add('stuck'); + expect(queue.pendingCount, 1); + await queue.drain(); + expect(queue.pendingCount, 1); + await queue.drain(); + expect(queue.pendingCount, 0, reason: 'three tries is enough'); + expect(sender.calls.length, 3); + }); + + test('one device refusing does not burn the other device queue', () async { + await queue.setTargets([hub, other]); + sender.reachable = {}; + await queue.add('photo-1'); + sender.reachable = {hub, other}; + sender.answers['$hub/photo-1'] = AutoSendResult.retry; + await queue.drain(); + expect(sender.calls, containsAll(['$hub/photo-1', '$other/photo-1'])); + expect(queue.pendingCount, 1, reason: 'only the hub entry is left'); + }); + + test('a backlog is capped at maxPending, newest kept', () async { + queue = queueOn(sender, store, maxPending: 3); + await queue.setTargets([hub]); + sender.reachable = {}; + for (var i = 0; i < 6; i++) { + await queue.add('photo-$i'); + } + expect(queue.pendingCount, 3); + + sender.reachable = {hub}; + await queue.drain(); + expect(sender.calls, ['$hub/photo-3', '$hub/photo-4', '$hub/photo-5']); + }); + + test('entries older than maxAge are forgotten on the next drain', () async { + // A queue file written days ago and loaded now. + final stale = DateTime.now().subtract(const Duration(days: 2)); + await store.save( + AutoSendData( + enabledSince: stale.subtract(const Duration(days: 1)), + pending: [AutoSendEntry(deviceId: hub, itemId: 'stale', queuedAt: stale)], + ), + ); + queue = queueOn(sender, store, maxAge: const Duration(hours: 1)); + await queue.load(); + expect(queue.pendingCount, 1); + + await queue.setTargets([hub]); + await queue.drain(); + expect(sender.calls, isEmpty); + expect(queue.pendingCount, 0); + }); + + test('the sent list is capped so it cannot grow without end', () async { + queue = queueOn(sender, store, maxDone: 2); + await queue.setTargets([hub]); + for (var i = 0; i < 5; i++) { + await queue.add('photo-$i'); + } + final saved = await store.load(); + expect(saved.done, ['$hub/photo-3', '$hub/photo-4']); + }); + + test('the catch-up mark moves forward only', () async { + final start = DateTime.utc(2026, 9, 10); + await queue.setTargets([hub], now: start); + expect(queue.catchUpFrom, start); + + await queue.markCaughtUp(DateTime.utc(2026, 9, 12)); + expect(queue.catchUpFrom, DateTime.utc(2026, 9, 12)); + + await queue.markCaughtUp(DateTime.utc(2026, 9, 11)); + expect(queue.catchUpFrom, DateTime.utc(2026, 9, 12)); + }); + + test('state round-trips through JSON', () async { + await queue.setTargets([hub], now: DateTime.utc(2026, 9, 10)); + sender.reachable = {}; + await queue.add('photo-1'); + final json = (await store.load()).toJson(); + final back = AutoSendData.fromJson(json); + expect(back.enabledSince, DateTime.utc(2026, 9, 10)); + expect(back.pending.single.deviceId, hub); + expect(back.pending.single.itemId, 'photo-1'); + }); + + test('a photo taken mid-drain is not lost', () async { + await queue.setTargets([hub]); + sender.reachable = {}; + await queue.add('photo-1'); + + sender.reachable = {hub}; + final held = Completer(); + sender.gate = held; + final draining = queue.drain(); + await Future.delayed(Duration.zero); + + // The camera fires while the first one is still on the wire. + await queue.add('photo-2'); + held.complete(AutoSendResult.sent); + await draining; + + expect(sender.calls, ['$hub/photo-1', '$hub/photo-2']); + expect(queue.isEmpty, isTrue); + }); + + test('a broken store does not take the queue down with it', () async { + queue = AutoSendQueue( + store: _BrokenStore(), + send: sender.send, + isReachable: sender.isReachable, + ); + await queue.setTargets([hub]); + await queue.add('photo-1'); + expect(sender.calls, ['$hub/photo-1']); + }); +} + +class _BrokenStore implements AutoSendStore { + @override + Future load() async => throw const FileSystemExceptionStub(); + + @override + Future save(AutoSendData data) async => throw const FileSystemExceptionStub(); +} + +class FileSystemExceptionStub implements Exception { + const FileSystemExceptionStub(); +} diff --git a/packages/pepo_core/test/media_test.dart b/packages/pepo_core/test/media_test.dart index 62a5cd7..30340aa 100644 --- a/packages/pepo_core/test/media_test.dart +++ b/packages/pepo_core/test/media_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:pepo_core/src/identity/certificate_factory.dart'; +import 'package:pepo_core/src/media/auto_send_queue.dart'; import 'package:pepo_core/src/media/gallery_client.dart'; import 'package:pepo_core/src/media/image_ops.dart'; import 'package:pepo_core/src/media/media_server.dart'; @@ -19,7 +20,125 @@ Future writeJpeg(Directory dir, String name, {int w = 640, int h = 480, in return f; } +/// Polls [ready] until it holds or the time is up. The media source reports +/// a change on its own schedule and the server handles it asynchronously, so +/// there is nothing single to await. +Future pumpUntil( + bool Function() ready, { + Duration timeout = const Duration(seconds: 10), +}) async { + final deadline = DateTime.now().add(timeout); + while (!ready() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 25)); + } +} + void main() { + group('auto send', () { + const hubId = 'HUB00000000000000000000000'; + late Directory root; + late Directory cache; + late MediaSourceFs source; + late SessionManager sessions; + late TransferEngine transfers; + late MemoryAutoSendStore store; + late MediaServer server; + + Future buildServer() async { + source = MediaSourceFs( + roots: [root.path], + cacheDir: cache.path, + settleTime: const Duration(milliseconds: 150), + ); + sessions = SessionManager( + identity: const CertificateFactory().generate(commonName: 'phone'), + info: const LocalDeviceInfo( + name: 'Phone', + platform: DevicePlatform.android, + role: DeviceRole.phone, + appVersion: 't', + ), + deviceStore: MemoryDeviceStore(), + preferredPort: 0, + ); + transfers = TransferEngine( + channels: sessions, + store: MemoryTransferStore(), + destination: (_, _) async => root.path, + ); + sessions.transfers = transfers; + final built = MediaServer( + source: source, + sessions: sessions, + transfers: transfers, + autoSendStore: store, + ); + await built.start(); + return built; + } + + setUp(() async { + root = await Directory.systemTemp.createTemp('pepo_auto_'); + cache = await Directory.systemTemp.createTemp('pepo_autoc_'); + store = MemoryAutoSendStore(); + }); + + tearDown(() async { + await server.stop(); + await transfers.dispose(); + await sessions.dispose(); + for (final d in [root, cache]) { + try { + await d.delete(recursive: true); + } catch (_) {} + } + }); + + test('a photo taken with the PC away waits in the queue', () async { + server = await buildServer(); + await server.setAutoSend(hubId, true); + expect(server.autoSendTo, {hubId}); + + await writeJpeg(root, 'IMG_0100.jpg'); + await pumpUntil(() => server.autoSend.pendingCount == 1); + expect( + server.autoSend.pendingCount, + 1, + reason: 'nothing is connected, so it has to be kept for later', + ); + }); + + test('the switch off means nothing is queued at all', () async { + server = await buildServer(); + await writeJpeg(root, 'IMG_0100.jpg'); + final change = source.changes.first; + await change.timeout(const Duration(seconds: 10)); + await Future.delayed(const Duration(milliseconds: 100)); + expect(server.autoSend.pendingCount, 0); + }); + + test('catch-up picks up what appeared while the process was down', () async { + // The queue remembers the switch was already on and how far it got. + final past = DateTime.now().toUtc().subtract(const Duration(hours: 1)); + await store.save(AutoSendData(enabledSince: past, catchUpAt: past)); + await writeJpeg(root, 'IMG_0001.jpg'); + await writeJpeg(root, 'IMG_0002.jpg'); + + server = await buildServer(); + await server.setAutoSend(hubId, true); + expect(server.autoSend.enabledSince, past, reason: 'the mark must not move'); + expect(server.autoSend.pendingCount, 2); + }); + + test('turning it on now does not push the whole camera roll', () async { + await writeJpeg(root, 'IMG_0001.jpg'); + await writeJpeg(root, 'IMG_0002.jpg'); + server = await buildServer(); + await server.setAutoSend(hubId, true); + expect(server.autoSend.pendingCount, 0); + }); + }); + group('MediaSourceFs', () { late Directory root; late Directory cache; diff --git a/packaging/linux/make-flatpak.sh b/packaging/linux/make-flatpak.sh index b902ac3..3382bed 100644 --- a/packaging/linux/make-flatpak.sh +++ b/packaging/linux/make-flatpak.sh @@ -32,7 +32,8 @@ finish-args: - --filesystem=xdg-download - --filesystem=xdg-pictures - --filesystem=xdg-videos - - --talk-name=org.freedesktop.Avahi + # Avahi lives on the system bus; the session name alone gets us nothing. + - --system-talk-name=org.freedesktop.Avahi - --talk-name=org.freedesktop.Notifications - --talk-name=org.kde.StatusNotifierWatcher modules: diff --git a/test/features/settings/phone_section_test.dart b/test/features/settings/phone_section_test.dart new file mode 100644 index 0000000..a59d1cd --- /dev/null +++ b/test/features/settings/phone_section_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pepo_core/pepo_core.dart'; +import 'package:pepoconnect/features/settings/sections/phone_section.dart'; +import 'package:pepoconnect/features/settings/settings_widgets.dart'; +import 'package:pepoconnect/platform/pepo_native.dart'; +import 'package:pepoconnect/shared/widgets/toggle_switch.dart'; +import 'package:pepoconnect/state/app_settings.dart'; + +import '../../ui/harness.dart'; +import '../pairing/test_support.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('org.pepoconnect/native'); + final messenger = binding.defaultBinaryMessenger; + Map status = const {}; + + final hub = viewOf( + fakePairedDevice(id: 'torre', name: 'Torre', platform: DevicePlatform.windows), + ); + + setUp(() { + status = const {'running': false, 'playing': false, 'held': false, 'refresh': 'available'}; + messenger.setMockMethodCallHandler( + channel, + (call) async => call.method == 'backgroundStatus' ? status : null, + ); + }); + + tearDown(() { + PepoNative.debugPlatform = null; + messenger.setMockMethodCallHandler(channel, null); + }); + + Future pumpSection( + WidgetTester tester, { + AppSettings settings = const AppSettings(animations: false), + List? devices, + }) async { + tester.view.physicalSize = const Size(900, 1800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: featureOverrides(settings: settings, devices: devices ?? [hub]), + child: harness( + animations: false, + child: const SingleChildScrollView(child: PhoneSection()), + ), + ), + ); + await tester.pump(); + await tester.pump(); + return ProviderScope.containerOf(tester.element(find.byType(PhoneSection))); + } + + /// Stops the 5 s status poll before the test ends. + Future close(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + } + + testWidgets('on iPhone the switch says what it really does and the state is shown', ( + tester, + ) async { + PepoNative.debugPlatform = 'ios'; + await pumpSection(tester); + + expect( + find.textContaining('Mantiene PepoConnect en marcha con la app cerrada'), + findsOneWidget, + ); + expect(find.text('Estado del segundo plano'), findsOneWidget); + expect(find.text('Parado'), findsOneWidget); + await close(tester); + }); + + testWidgets('a running engine shows how long the process has been alive', (tester) async { + PepoNative.debugPlatform = 'ios'; + status = { + 'running': true, + 'playing': true, + 'held': false, + 'refresh': 'available', + 'startedAt': DateTime.now().subtract(const Duration(minutes: 47)).millisecondsSinceEpoch, + }; + await pumpSection(tester); + + expect(find.text('Activo desde hace 47 min'), findsOneWidget); + await close(tester); + }); + + testWidgets('a session lost to another app reads as recovering', (tester) async { + PepoNative.debugPlatform = 'ios'; + status = const {'running': true, 'playing': false, 'held': false, 'refresh': 'available'}; + await pumpSection(tester); + + expect(find.text('Recuperándose…'), findsOneWidget); + await close(tester); + }); + + testWidgets('on Android the row is not there and the text is the service one', (tester) async { + PepoNative.debugPlatform = 'android'; + await pumpSection(tester); + + expect(find.text('Mantiene la conexión con el PC'), findsOneWidget); + expect(find.text('Estado del segundo plano'), findsNothing); + await close(tester); + }); + + testWidgets('the auto-send switch only writes the setting', (tester) async { + PepoNative.debugPlatform = 'android'; + final container = await pumpSection(tester); + final row = find.ancestor( + of: find.text('Enviar fotos nuevas automáticamente'), + matching: find.byType(SettingsRow), + ); + final toggle = find.descendant(of: row, matching: find.byType(ToggleSwitch)); + expect(tester.widget(toggle).value, isFalse); + + await tester.tap(toggle); + await tester.pumpAndSettle(); + + final settings = container.read(settingsProvider); + expect(settings.autoSendPhotos, isTrue); + expect( + settings.defaultHubId, + hub.deviceId, + reason: 'the only paired PC becomes the hub, and AppServices applies it', + ); + await close(tester); + }); + + testWidgets('with no PC paired the switch stays off', (tester) async { + PepoNative.debugPlatform = 'android'; + final container = await pumpSection(tester, devices: const []); + final row = find.ancestor( + of: find.text('Enviar fotos nuevas automáticamente'), + matching: find.byType(SettingsRow), + ); + await tester.tap(find.descendant(of: row, matching: find.byType(ToggleSwitch))); + await tester.pumpAndSettle(); + + expect(container.read(settingsProvider).autoSendPhotos, isFalse); + await close(tester); + }); +} diff --git a/test/platform/bonjour_discovery_test.dart b/test/platform/bonjour_discovery_test.dart new file mode 100644 index 0000000..1c16b4d --- /dev/null +++ b/test/platform/bonjour_discovery_test.dart @@ -0,0 +1,104 @@ +import 'package:bonsoir/bonsoir.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pepo_core/pepo_core.dart'; +import 'package:pepoconnect/platform/bonjour_discovery.dart'; + +const self = 'SELF0000000000000000000000'; +const peer = 'PEER0000000000000000000000'; + +BonsoirService service({ + String name = 'Torre', + int port = 47473, + List addresses = const ['192.168.1.20'], + Map? attributes, +}) => BonsoirService.ignoreNorms( + name: name, + type: pepoServiceType, + port: port, + hostAddresses: addresses, + attributes: + attributes ?? + const { + 'id': peer, + 'fp16': '0123456789abcdef', + 'name': 'Torre', + 'platform': 'win', + 'role': 'hub', + }, +); + +void main() { + group('candidateFromService', () { + test('a resolved PepoConnect service becomes a candidate', () { + final candidate = candidateFromService(service(), selfId: self)!; + expect(candidate.deviceId, peer); + expect(candidate.shortFingerprint, '0123456789abcdef'); + expect(candidate.name, 'Torre'); + expect(candidate.addresses, ['192.168.1.20']); + expect(candidate.port, 47473); + expect(candidate.platform, DevicePlatform.windows); + expect(candidate.role, DeviceRole.hub); + expect(candidate.source, DiscoverySource.mdns); + }); + + test('this very device is ignored', () { + final own = service(attributes: const {'id': self}); + expect(candidateFromService(own, selfId: self), isNull); + }); + + test('something else on the network is ignored', () { + expect(candidateFromService(service(attributes: const {}), selfId: self), isNull); + expect( + candidateFromService(service(attributes: const {'id': 'short'}), selfId: self), + isNull, + ); + }); + + test('a service that has not resolved yet is not a candidate', () { + expect(candidateFromService(service(addresses: const []), selfId: self), isNull); + expect(candidateFromService(service(port: 0), selfId: self), isNull); + }); + + test('the mDNS name is the fallback when the TXT record has none', () { + final noName = service( + name: 'MacBook de Ana', + attributes: const {'id': peer, 'platform': 'mac'}, + ); + final candidate = candidateFromService(noName, selfId: self)!; + expect(candidate.name, 'MacBook de Ana'); + expect(candidate.shortFingerprint, ''); + }); + }); + + group('usableAddresses', () { + test('drops what cannot be dialled and puts IPv4 first', () { + final out = usableAddresses([ + 'fe80::1c2d:3e4f:5a6b:7c8d', + '169.254.10.5', + '127.0.0.1', + '::1', + '2001:db8::5', + '192.168.1.20', + ]); + expect(out, ['192.168.1.20', '2001:db8::5']); + }); + + test('keeps every distinct address once', () { + expect(usableAddresses(['10.0.0.2', '10.0.0.2', ' ', '10.0.0.3']), ['10.0.0.2', '10.0.0.3']); + }); + }); + + group('BonjourDiscovery', () { + test('probe and stop do nothing before it has been started', () async { + final discovery = BonjourDiscovery(); + await expectLater(discovery.probe(), completes); + await expectLater(discovery.stop(), completes); + expect(discovery.isRunning, isFalse); + }); + + test('it announces the service iOS declares in Info.plist', () { + expect(pepoServiceType, '_pepoconnect._tcp'); + expect(BonjourDiscovery().serviceType, pepoServiceType); + }); + }); +} diff --git a/test/platform/ios_background_test.dart b/test/platform/ios_background_test.dart new file mode 100644 index 0000000..8794fe0 --- /dev/null +++ b/test/platform/ios_background_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pepoconnect/platform/ios_background.dart'; +import 'package:pepoconnect/platform/pepo_native.dart'; + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('org.pepoconnect/native'); + final messenger = binding.defaultBinaryMessenger; + late List calls; + Map status = const {}; + + setUp(() { + calls = []; + status = const {'running': true, 'playing': true, 'held': false, 'refresh': 'available'}; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'keepAliveStart' => true, + 'backgroundHold' => (call.arguments as Map)['on'] as bool, + 'backgroundStatus' => status, + _ => null, + }; + }); + }); + + tearDown(() { + PepoNative.debugPlatform = null; + PepoNative.onBackgroundTask = null; + messenger.setMockMethodCallHandler(channel, null); + }); + + group('off iOS', () { + test('Android never hears about the keep-alive engine', () async { + PepoNative.debugPlatform = 'android'; + expect(IosBackground.isSupported, isFalse); + expect(await IosBackground.start(), isFalse); + await IosBackground.stop(); + await IosBackground.hold(true); + expect((await IosBackground.status()).running, isFalse); + expect(calls, isEmpty, reason: 'the Android service is what holds it up there'); + }); + + test('the desktop is untouched as well', () async { + PepoNative.debugPlatform = 'windows'; + expect(IosBackground.isSupported, isFalse); + await IosBackground.start(); + expect(calls, isEmpty); + }); + }); + + group('on iOS', () { + setUp(() => PepoNative.debugPlatform = 'ios'); + + test('start and stop drive the silent loop', () async { + expect(await IosBackground.start(), isTrue); + await IosBackground.stop(); + expect(calls.map((c) => c.method), ['keepAliveStart', 'keepAliveStop']); + }); + + test('a transfer in flight holds the process up and then lets go', () async { + await IosBackground.hold(true); + await IosBackground.hold(false); + expect(calls.map((c) => c.method), ['backgroundHold', 'backgroundHold']); + expect(calls.first.arguments, {'on': true}); + expect(calls.last.arguments, {'on': false}); + }); + + test('the state comes back with the uptime the phone can show', () async { + final started = DateTime(2026, 9, 18, 21, 30); + status = { + 'running': true, + 'playing': true, + 'held': true, + 'refresh': 'available', + 'startedAt': started.millisecondsSinceEpoch, + }; + final state = await IosBackground.status(); + expect(state.healthy, isTrue); + expect(state.held, isTrue); + expect(state.refresh, 'available'); + expect(state.startedAt, started); + }); + + test('running without playing is not healthy', () async { + status = const {'running': true, 'playing': false, 'held': false, 'refresh': 'denied'}; + final state = await IosBackground.status(); + expect(state.running, isTrue); + expect(state.healthy, isFalse, reason: 'the session was taken away'); + }); + + test('a native side that answers nothing degrades to off', () async { + messenger.setMockMethodCallHandler(channel, (call) async => null); + final state = await IosBackground.status(); + expect(state.running, isFalse); + expect(state.startedAt, isNull); + }); + + test('a background task reaches Dart and is answered', () async { + final seen = []; + PepoNative.onBackgroundTask = (id) async => seen.add(id); + PepoNative.init(); + await messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('backgroundTask', {'id': 'org.pepoconnect.app.sync'}), + ), + (_) {}, + ); + expect(seen, ['org.pepoconnect.app.sync']); + + await PepoNative.backgroundTaskDone('org.pepoconnect.app.sync'); + expect(calls.last.method, 'backgroundTaskDone'); + expect(calls.last.arguments, {'id': 'org.pepoconnect.app.sync', 'ok': true}); + }); + + test('a task arriving with nobody listening is not an error', () async { + PepoNative.onBackgroundTask = null; + PepoNative.init(); + await expectLater( + messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('backgroundTask', {'id': 'x'}), + ), + (_) {}, + ), + completes, + ); + }); + }); +}