camera: app-lifecycle pause / resume (Phase 6 polish)
Camera page kept the session running while the host app was
backgrounded — wastes battery, holds the hardware, and blocks
other apps from grabbing the camera. Add per-platform observers
that pause/resume the session on app foreground/background, with
a uniform `pauseForBackground` / `resumeForForeground` pair on the
shared CameraInstance.
Behaviour:
- On background: any in-flight recording is hard-cancelled
(matches every messaging app — the take ends with the app
switch). The session stops so the OS can release the camera.
- On foreground: session restarts iff it had been running.
Emits `sessionInterrupted` (`reason: appBackgrounded`) and
`sessionResumed` events so the Dart side can surface UX
affordances if needed.
iOS — `ios/Classes/Camera/CameraInstance+iOS.swift`:
Subscribes to UIApplication.{willResignActive, didBecomeActive}
notifications. Work hops onto sessionQueue so AV mutations stay
serialised. Storage uses the shared
`CameraInstance.lifecycleCleanup` closure slot — extension
doesn't need to add stored properties.
Android — added `androidx.lifecycle:lifecycle-process:2.7.0`,
observes `ProcessLifecycleOwner.get().lifecycle`. ON_STOP →
`pauseForBackground` (cancels recording + drops
CustomLifecycleOwner to CREATED → CameraX releases camera).
ON_START → `resumeForForeground`. Observer add/remove on main
thread per `ProcessLifecycleOwner` contract.
macOS — `macos/Classes/Camera/CameraInstance+macOS.swift`:
Intentional no-op. macOS desktop background semantics are
softer; the chat composer's Card dialog typically stays
foregrounded. Slot is wired so the shared
`observeLifecycle()` call still compiles.
Verified: all four platforms (iOS / Android / macOS / app tests)
build clean. Pod install picks up the new iOS extension file
once Pods/ is fresh — `flutter clean` if mid-iteration.
This commit is contained in:
@@ -63,6 +63,11 @@ dependencies {
|
||||
implementation "androidx.camera:camera-lifecycle:$cameraxVersion"
|
||||
implementation "androidx.camera:camera-view:$cameraxVersion"
|
||||
implementation "androidx.camera:camera-video:$cameraxVersion"
|
||||
// ProcessLifecycleOwner so CameraInstance can release the camera
|
||||
// when the host app backgrounds (and re-acquire on foreground).
|
||||
// camera-lifecycle pulls in lifecycle-common but not the process
|
||||
// observer; this adds it.
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
||||
// Pure-Kotlin/Java QR decoder. ~470 KB jar, no Play Services dep.
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import io.flutter.view.TextureRegistry
|
||||
|
||||
/// One per `UxCameraController` on the Dart side. Owns CameraX's
|
||||
@@ -42,6 +45,44 @@ class CameraInstance(
|
||||
private var enableAudio: Boolean = false
|
||||
private var disposed = false
|
||||
|
||||
/// True iff `pauseForBackground` actually stopped a running
|
||||
/// lifecycle owner — `resumeForForeground` consults this so it
|
||||
/// doesn't restart instances the caller never initialised.
|
||||
private var wasRunningBeforePause = false
|
||||
|
||||
/// Observer attached to [ProcessLifecycleOwner.get] in [create]
|
||||
/// and removed in [dispose]. ON_STOP releases the camera so a
|
||||
/// backgrounded chat composer doesn't keep streaming; ON_START
|
||||
/// reacquires if we stopped it.
|
||||
private val processObserver = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_STOP -> pauseForBackground()
|
||||
Lifecycle.Event.ON_START -> resumeForForeground()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseForBackground() {
|
||||
if (disposed) return
|
||||
if (video.isRecording) video.cancel()
|
||||
// Move the per-instance lifecycle owner to CREATED so
|
||||
// CameraX releases the camera. STARTED → CREATED is
|
||||
// CameraX's "release-but-keep-bindings" transition.
|
||||
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
lifecycleOwner.stop()
|
||||
wasRunningBeforePause = true
|
||||
emit(mapOf("event" to "sessionInterrupted", "reason" to "appBackgrounded"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumeForForeground() {
|
||||
if (disposed) return
|
||||
if (!wasRunningBeforePause) return
|
||||
wasRunningBeforePause = false
|
||||
lifecycleOwner.start()
|
||||
emit(mapOf("event" to "sessionResumed"))
|
||||
}
|
||||
|
||||
val textureId: Long get() = previewSink.textureId
|
||||
val previewSize: Size get() = previewSink.previewSize
|
||||
val currentLens: String get() = lens
|
||||
@@ -100,6 +141,15 @@ class CameraInstance(
|
||||
onReady(t)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
|
||||
// ProcessLifecycleOwner is the app-wide foreground/background
|
||||
// signal — independent of which Activity the engine is bound
|
||||
// to. Observer must be added on the main thread.
|
||||
ContextCompat.getMainExecutor(context).execute {
|
||||
if (!disposed) {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(processObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the lifecycle owner to STARTED — CameraX starts streaming
|
||||
@@ -114,6 +164,12 @@ class CameraInstance(
|
||||
fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
// Drop the app-lifecycle observer FIRST so a foreground/background
|
||||
// hop during teardown can't try to start/stop a half-disposed
|
||||
// session. Removal must be on the main thread.
|
||||
ContextCompat.getMainExecutor(context).execute {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(processObserver)
|
||||
}
|
||||
// Mirror iOS' dispose: any in-flight recording is hard-cancelled
|
||||
// (file discarded) rather than left to flush, since there's no
|
||||
// caller waiting on the result anymore.
|
||||
|
||||
@@ -164,6 +164,56 @@ final class CameraInstance {
|
||||
"orientation": next.rawValue,
|
||||
])
|
||||
}
|
||||
|
||||
observeLifecycle()
|
||||
}
|
||||
|
||||
// MARK: - App lifecycle
|
||||
|
||||
/// Set to `true` by [pauseForBackground] when it stops a
|
||||
/// running session, so [resumeForForeground] knows whether to
|
||||
/// restart. Skipped when the session was already stopped
|
||||
/// (e.g. dispose racing background).
|
||||
private var wasRunningBeforePause = false
|
||||
|
||||
/// Cleanup closure installed by per-platform `observeLifecycle()`
|
||||
/// — undoes whatever observers it registered. Invoked from
|
||||
/// [dispose] so the instance doesn't leak NotificationCenter /
|
||||
/// `ProcessLifecycleOwner` subscriptions.
|
||||
var lifecycleCleanup: (() -> Void)?
|
||||
|
||||
/// Called by the per-platform lifecycle observer when the host
|
||||
/// app moves to background. Hard-cancels any in-flight recording
|
||||
/// (matches every messaging app — backgrounding ends the take)
|
||||
/// and stops the session so the camera hardware is released.
|
||||
/// Runs on sessionQueue.
|
||||
func pauseForBackground() {
|
||||
if disposed { return }
|
||||
if let recorder = videoRecorder {
|
||||
recorder.cancel()
|
||||
videoRecorder = nil
|
||||
fanout.recorder = nil
|
||||
emit([
|
||||
"event": "sessionInterrupted",
|
||||
"reason": "appBackgrounded",
|
||||
])
|
||||
}
|
||||
if session.av.isRunning {
|
||||
session.stop()
|
||||
wasRunningBeforePause = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the per-platform lifecycle observer when the host
|
||||
/// app returns to foreground. Restarts the session only if
|
||||
/// [pauseForBackground] stopped a running one. Runs on
|
||||
/// sessionQueue.
|
||||
func resumeForForeground() {
|
||||
if disposed { return }
|
||||
guard wasRunningBeforePause else { return }
|
||||
wasRunningBeforePause = false
|
||||
session.start()
|
||||
emit(["event": "sessionResumed"])
|
||||
}
|
||||
|
||||
/// Start the session. Must run on sessionQueue.
|
||||
@@ -195,6 +245,12 @@ final class CameraInstance {
|
||||
if disposed { return }
|
||||
disposed = true
|
||||
|
||||
// Tear down the lifecycle observer FIRST so a notification
|
||||
// arriving mid-dispose doesn't try to pause/resume a
|
||||
// half-torn-down session.
|
||||
lifecycleCleanup?()
|
||||
lifecycleCleanup = nil
|
||||
|
||||
if let recorder = videoRecorder {
|
||||
recorder.cancel()
|
||||
videoRecorder = nil
|
||||
|
||||
41
ios/Classes/Camera/CameraInstance+iOS.swift
Normal file
41
ios/Classes/Camera/CameraInstance+iOS.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// iOS-only app-lifecycle observer for [CameraInstance]. Hooks
|
||||
/// `UIApplication.willResignActiveNotification` /
|
||||
/// `.didBecomeActiveNotification` so backgrounding the app releases
|
||||
/// the camera (and any in-flight recording is hard-cancelled —
|
||||
/// matching every messaging app), and foregrounding restarts the
|
||||
/// session if it had been running. macOS counterpart is a no-op
|
||||
/// (`CameraInstance+macOS.swift`) — desktop background semantics are
|
||||
/// less load-bearing for chat composer use.
|
||||
///
|
||||
/// `willResignActive` fires for phone calls and the app switcher;
|
||||
/// `didBecomeActive` fires when the user returns. We hop work onto
|
||||
/// the instance's `sessionQueue` so the AV teardown / start runs
|
||||
/// serialised with other session mutations.
|
||||
extension CameraInstance {
|
||||
func observeLifecycle() {
|
||||
let center = NotificationCenter.default
|
||||
var observers: [NSObjectProtocol] = []
|
||||
observers.append(center.addObserver(
|
||||
forName: UIApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.sessionQueueAsync { self.pauseForBackground() }
|
||||
})
|
||||
observers.append(center.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.sessionQueueAsync { self.resumeForForeground() }
|
||||
})
|
||||
lifecycleCleanup = {
|
||||
for o in observers { center.removeObserver(o) }
|
||||
}
|
||||
}
|
||||
}
|
||||
20
macos/Classes/Camera/CameraInstance+macOS.swift
Normal file
20
macos/Classes/Camera/CameraInstance+macOS.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
/// macOS counterpart of `CameraInstance+iOS.swift`.
|
||||
///
|
||||
/// macOS doesn't have iOS's hard "the app is backgrounded, you must
|
||||
/// release the camera" lifecycle. A backgrounded Mac app keeps
|
||||
/// running, and there's no equivalent UIApplication notification —
|
||||
/// the closest analogues are `NSApplication.didResignActive` /
|
||||
/// `…didBecomeActive`, but the chat composer's macOS surface (a
|
||||
/// Card dialog) typically stays in the foreground long enough that
|
||||
/// teardown on resign-active would cost more than it saves.
|
||||
///
|
||||
/// Left as a no-op so the shared `CameraInstance.observeLifecycle()`
|
||||
/// call compiles. If a need surfaces later, wire NSApplication
|
||||
/// observers here calling `pauseForBackground` / `resumeForForeground`.
|
||||
extension CameraInstance {
|
||||
func observeLifecycle() {
|
||||
// Intentionally empty.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user