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:
agra
2026-05-13 21:43:50 +03:00
parent 71c84179a6
commit 3d36f17edf
5 changed files with 178 additions and 0 deletions

View File

@@ -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'
}

View File

@@ -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.

View File

@@ -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

View 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) }
}
}
}

View 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.
}
}