Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
20 changes: 18 additions & 2 deletions docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions integration_test/e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
}
2 changes: 2 additions & 0 deletions integration_test/support.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -50,6 +51,7 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7B10A4012F1A55B000C636F2 /* PepoBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PepoBackground.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
Expand Down Expand Up @@ -123,6 +125,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
7B10A4012F1A55B000C636F2 /* PepoBackground.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
Expand Down Expand Up @@ -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;
};
Expand Down
70 changes: 67 additions & 3 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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] = []
Expand All @@ -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)
Expand All @@ -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.
}
}
7 changes: 7 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.pepoconnect.app.refresh</string>
<string>org.pepoconnect.app.sync</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
Expand Down Expand Up @@ -80,6 +85,8 @@
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
</array>
<key>UIFileSharingEnabled</key>
Expand Down
Loading
Loading