camera: requestAudioPermission — in-app prompt first, settings on permanent denial

Banner-tap entry point that shows the system mic prompt when the OS
will still surface one, and deep-links to Settings only on permanent
denial. Fixes the fresh-install trap where the mic entry isn't in the
Privacy pane until requestAccess has fired at least once.

Android tracks the first-asked state in SharedPreferences because
shouldShowRequestPermissionRationale returns false in two
observationally identical states (never asked vs permanently denied).
The existing initialize() request path writes the flag too, so a
banner tap after a record-then-deny correctly routes to Settings.

Refactored Android pendingPermission into PendingPermission(primary,
kind, cb) so audio-only requests check RECORD_AUDIO results instead
of always checking CAMERA.
This commit is contained in:
agra
2026-05-21 08:50:39 +03:00
parent 1a7ce1ac1b
commit a508aca2bb
7 changed files with 369 additions and 242 deletions

View File

@@ -31,6 +31,8 @@ class CameraPlugin :
companion object { companion object {
private const val PERMISSION_REQUEST_CODE = 0xC2A0 private const val PERMISSION_REQUEST_CODE = 0xC2A0
private const val PREFS_NAME = "ux.camera"
private const val PREF_AUDIO_ASKED = "audio_asked"
} }
private val main = Handler(Looper.getMainLooper()) private val main = Handler(Looper.getMainLooper())
@@ -43,8 +45,17 @@ class CameraPlugin :
private var activity: Activity? = null private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null private var activityBinding: ActivityPluginBinding? = null
private var pendingPermission: ((Boolean, String) -> Unit)? = null private var pending: PendingPermission? = null
private var pendingPermissionKind: String = ""
/// Per-request bookkeeping. `primary` is the permission whose grant
/// state gates the caller — camera for the session initialize flow,
/// mic for the standalone request. `kind` is the human-readable
/// label surfaced in the denial error code.
private data class PendingPermission(
val primary: String,
val kind: String,
val cb: (Boolean) -> Unit,
)
private val instances = mutableMapOf<Int, CameraInstance>() private val instances = mutableMapOf<Int, CameraInstance>()
private var nextHandle = 1 private var nextHandle = 1
@@ -88,16 +99,12 @@ class CameraPlugin :
if (code != PERMISSION_REQUEST_CODE) { if (code != PERMISSION_REQUEST_CODE) {
return@addRequestPermissionsResultListener false return@addRequestPermissionsResultListener false
} }
// Camera grant gates the whole flow; mic is optional (we val p = pending ?: return@addRequestPermissionsResultListener true
// tolerate a missing audio input gracefully). Match iOS. pending = null
val cameraIndex = permissions.indexOf(Manifest.permission.CAMERA) val idx = permissions.indexOf(p.primary)
val cameraGranted = cameraIndex >= 0 && val granted = idx >= 0 &&
results.getOrNull(cameraIndex) == PackageManager.PERMISSION_GRANTED results.getOrNull(idx) == PackageManager.PERMISSION_GRANTED
val cb = pendingPermission p.cb(granted)
pendingPermission = null
val kind = pendingPermissionKind
pendingPermissionKind = ""
cb?.invoke(cameraGranted, if (cameraGranted) "" else kind)
true true
} }
} }
@@ -107,9 +114,8 @@ class CameraPlugin :
activityBinding = null activityBinding = null
// If a request is still pending when the activity tears down, // If a request is still pending when the activity tears down,
// settle it as denied so the Dart Future doesn't hang. // settle it as denied so the Dart Future doesn't hang.
pendingPermission?.invoke(false, pendingPermissionKind) pending?.cb?.invoke(false)
pendingPermission = null pending = null
pendingPermissionKind = ""
} }
// MARK: - EventChannel // MARK: - EventChannel
@@ -138,6 +144,7 @@ class CameraPlugin :
"startVideoRecording" -> handleStartVideo(call, result) "startVideoRecording" -> handleStartVideo(call, result)
"stopVideoRecording" -> handleStopVideo(call, result) "stopVideoRecording" -> handleStopVideo(call, result)
"audioPermissionStatus" -> result.success(isAudioGranted()) "audioPermissionStatus" -> result.success(isAudioGranted())
"requestAudioPermission" -> handleRequestAudioPermission(result)
"openSettings" -> handleOpenSettings(result) "openSettings" -> handleOpenSettings(result)
else -> result.notImplemented() else -> result.notImplemented()
} }
@@ -343,12 +350,16 @@ class CameraPlugin :
private fun handleOpenSettings(result: MethodChannel.Result) { private fun handleOpenSettings(result: MethodChannel.Result) {
val act = activity val act = activity
?: return result.error("no_activity", "plugin not attached", null) ?: return result.error("no_activity", "plugin not attached", null)
openAppSettings(act)
result.success(null)
}
private fun openAppSettings(act: Activity) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", act.packageName, null) data = Uri.fromParts("package", act.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
act.startActivity(intent) act.startActivity(intent)
result.success(null)
} }
private fun isAudioGranted(): Boolean { private fun isAudioGranted(): Boolean {
@@ -358,6 +369,45 @@ class CameraPlugin :
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} }
/// Banner-tap entry point: show the in-app mic prompt when the OS
/// will still surface one (fresh install, or first denial without
/// "don't ask again"); otherwise deep-link into app Settings.
///
/// We track first-ever-asked in SharedPrefs because Android's
/// `shouldShowRequestPermissionRationale` returns false in two
/// observationally identical states: "never asked yet" and
/// "permanently denied". Without the flag, the first banner tap on
/// a fresh install would land in the settings fallback before the
/// permission entry even exists in the Privacy pane.
private fun handleRequestAudioPermission(result: MethodChannel.Result) {
val act = activity
?: return result.error("no_activity", "plugin not attached", null)
if (isAudioGranted()) return result.success(true)
val prefs = act.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val askedBefore = prefs.getBoolean(PREF_AUDIO_ASKED, false)
val rationale = ActivityCompat.shouldShowRequestPermissionRationale(
act, Manifest.permission.RECORD_AUDIO,
)
if (askedBefore && !rationale) {
openAppSettings(act)
return result.success(false)
}
if (pending != null) {
return result.error("in_progress", "another permission request is in flight", null)
}
prefs.edit().putBoolean(PREF_AUDIO_ASKED, true).apply()
pending = PendingPermission(
primary = Manifest.permission.RECORD_AUDIO,
kind = "microphone",
cb = { granted -> result.success(granted) },
)
ActivityCompat.requestPermissions(
act,
arrayOf(Manifest.permission.RECORD_AUDIO),
PERMISSION_REQUEST_CODE,
)
}
private fun requestPermissions( private fun requestPermissions(
perms: List<String>, perms: List<String>,
cb: (Boolean, String) -> Unit, cb: (Boolean, String) -> Unit,
@@ -368,12 +418,25 @@ class CameraPlugin :
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
} }
if (toRequest.isEmpty()) return cb(true, "") if (toRequest.isEmpty()) return cb(true, "")
if (pendingPermission != null) { if (pending != null) {
return cb(false, "camera") // serialize return cb(false, "camera") // serialize
} }
pendingPermission = cb // Camera grant gates the session-init flow; mic is optional.
pendingPermissionKind = if (toRequest.contains(Manifest.permission.CAMERA)) val hasCamera = toRequest.contains(Manifest.permission.CAMERA)
"camera" else "microphone" val primary = if (hasCamera) Manifest.permission.CAMERA else Manifest.permission.RECORD_AUDIO
val kind = if (hasCamera) "camera" else "microphone"
// Persist the audio-asked flag whenever a mic request leaves
// the dispatch queue; the banner-tap path keys off this to tell
// "never asked" apart from "permanently denied" later.
if (toRequest.contains(Manifest.permission.RECORD_AUDIO)) {
act.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(PREF_AUDIO_ASKED, true).apply()
}
pending = PendingPermission(
primary = primary,
kind = kind,
cb = { granted -> cb(granted, if (granted) "" else kind) },
)
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
act, act,
toRequest.toTypedArray(), toRequest.toTypedArray(),

View File

@@ -272,6 +272,27 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler {
let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
result(granted) result(granted)
case "requestAudioPermission":
// On `.notDetermined` the system can still show the in-app
// prompt; on `.denied`/`.restricted` it can't and on a
// fresh install the mic entry isn't in the Privacy pane
// until requestAccess has fired at least once, so the
// settings fallback only makes sense after first prompt.
let status = AVCaptureDevice.authorizationStatus(for: .audio)
switch status {
case .authorized:
result(true)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .audio) { granted in
DispatchQueue.main.async { result(granted) }
}
default:
DispatchQueue.main.async {
CameraSettings.openAppSettings()
result(false)
}
}
case "openSettings": case "openSettings":
DispatchQueue.main.async { DispatchQueue.main.async {
// Per-platform helper: iOS opens app-specific Settings // Per-platform helper: iOS opens app-specific Settings

View File

@@ -5,19 +5,19 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show Widget; import 'package:flutter/widgets.dart' show Widget;
import '../file.dart' show UxFile; import '../file.dart' show XFile;
import '../log.dart' show Log; import '../log.dart' show Log;
import '../sensor.dart' show UxSensor; import '../sensor.dart' show XSensor;
import 'camera_backend.dart'; import 'camera_backend.dart';
import 'camera_preview.dart' show UxCameraPreview; import 'camera_preview.dart' show XCameraPreview;
final _log = Log.tag('camera'); final _log = Log.tag('camera');
/// Describes a camera device on the system. Returned by /// Describes a camera device on the system. Returned by
/// [uxAvailableCameras]; passed to [UxCameraController] to bind a /// [uxAvailableCameras]; passed to [XCameraController] to bind a
/// specific lens. /// specific lens.
class UxCameraDescription { class XCameraDescription {
const UxCameraDescription({ const XCameraDescription({
required this.id, required this.id,
required this.lens, required this.lens,
required this.sensorOrientation, required this.sensorOrientation,
@@ -28,7 +28,7 @@ class UxCameraDescription {
/// interprets it. /// interprets it.
final String id; final String id;
final UxCameraLens lens; final XCameraLens lens;
/// Clockwise rotation in degrees (0/90/180/270) from the camera /// Clockwise rotation in degrees (0/90/180/270) from the camera
/// sensor's natural orientation to the device's portrait-up /// sensor's natural orientation to the device's portrait-up
@@ -39,7 +39,7 @@ class UxCameraDescription {
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is UxCameraDescription && other is XCameraDescription &&
other.id == id && other.id == id &&
other.lens == lens && other.lens == lens &&
other.sensorOrientation == sensorOrientation; other.sensorOrientation == sensorOrientation;
@@ -48,22 +48,22 @@ class UxCameraDescription {
int get hashCode => Object.hash(id, lens, sensorOrientation); int get hashCode => Object.hash(id, lens, sensorOrientation);
} }
enum UxCameraLens { front, back } enum XCameraLens { front, back }
/// Resolution preset for both photo and video. Single value today /// Resolution preset for both photo and video. Single value today
/// (`high`); the enum exists so future presets can land without /// (`high`); the enum exists so future presets can land without
/// breaking call sites. /// breaking call sites.
enum UxResolutionPreset { high } enum XResolutionPreset { high }
/// Flash mode applied to the next still capture. Only the two values /// Flash mode applied to the next still capture. Only the two values
/// the chat composer actually uses today. /// the chat composer actually uses today.
enum UxFlashMode { off, always } enum XFlashMode { off, always }
/// Immutable snapshot of a [UxCameraController]'s state, broadcast to /// Immutable snapshot of a [XCameraController]'s state, broadcast to
/// listeners. Updated on lifecycle transitions and on device- /// listeners. Updated on lifecycle transitions and on device-
/// orientation events from the native side. /// orientation events from the native side.
class UxCameraValue { class XCameraValue {
const UxCameraValue({ const XCameraValue({
required this.description, required this.description,
this.previewSize, this.previewSize,
this.isInitialized = false, this.isInitialized = false,
@@ -75,10 +75,10 @@ class UxCameraValue {
this.errorDescription, this.errorDescription,
}); });
factory UxCameraValue.uninitialized(UxCameraDescription d) => factory XCameraValue.uninitialized(XCameraDescription d) =>
UxCameraValue(description: d); XCameraValue(description: d);
final UxCameraDescription description; final XCameraDescription description;
/// Pixel dimensions of the active video format, in the camera /// Pixel dimensions of the active video format, in the camera
/// sensor's natural orientation (so for typical phone sensors this /// sensor's natural orientation (so for typical phone sensors this
@@ -98,7 +98,7 @@ class UxCameraValue {
/// True iff the user has granted microphone access. Updated when /// True iff the user has granted microphone access. Updated when
/// the controller initialises and on /// the controller initialises and on
/// [UxCameraController.refreshAudioPermission]. Independent of /// [XCameraController.refreshAudioPermission]. Independent of
/// [enableAudio] — a controller can request audio (`enableAudio: /// [enableAudio] — a controller can request audio (`enableAudio:
/// true`) without having permission, in which case recordings have /// true`) without having permission, in which case recordings have
/// no audio track and callers should surface a hint. /// no audio track and callers should surface a hint.
@@ -110,7 +110,7 @@ class UxCameraValue {
/// is always 0. On Android CameraX delivers sensor-native frames to /// is always 0. On Android CameraX delivers sensor-native frames to
/// the Surface and the consumer rotates — typically 1 for /// the Surface and the consumer rotates — typically 1 for
/// `sensorOrientation: 90` back cams and 3 for `sensorOrientation: /// `sensorOrientation: 90` back cams and 3 for `sensorOrientation:
/// 270` front cams. Applied inside [UxCameraPreview]; consumers /// 270` front cams. Applied inside [XCameraPreview]; consumers
/// don't read this directly. /// don't read this directly.
final int previewRotationQuarterTurns; final int previewRotationQuarterTurns;
@@ -120,8 +120,8 @@ class UxCameraValue {
bool get hasError => errorDescription != null; bool get hasError => errorDescription != null;
UxCameraValue copyWith({ XCameraValue copyWith({
UxCameraDescription? description, XCameraDescription? description,
Size? previewSize, Size? previewSize,
bool? isInitialized, bool? isInitialized,
bool? isRecordingVideo, bool? isRecordingVideo,
@@ -131,7 +131,7 @@ class UxCameraValue {
int? previewRotationQuarterTurns, int? previewRotationQuarterTurns,
Object? errorDescription = _unset, Object? errorDescription = _unset,
}) => }) =>
UxCameraValue( XCameraValue(
description: description ?? this.description, description: description ?? this.description,
previewSize: previewSize ?? this.previewSize, previewSize: previewSize ?? this.previewSize,
isInitialized: isInitialized ?? this.isInitialized, isInitialized: isInitialized ?? this.isInitialized,
@@ -149,24 +149,24 @@ class UxCameraValue {
static const _unset = Object(); static const _unset = Object();
} }
/// Throws when a [UxCameraController] call fails on the platform side. /// Throws when a [XCameraController] call fails on the platform side.
/// Maps from native `FlutterError` codes; see the table in /// Maps from native `FlutterError` codes; see the table in
/// `~/banlu/plans/ux_camera.md` §error-model for the full set. /// `~/banlu/plans/ux_camera.md` §error-model for the full set.
class UxCameraException implements Exception { class XCameraException implements Exception {
const UxCameraException(this.code, [this.description]); const XCameraException(this.code, [this.description]);
final String code; final String code;
final String? description; final String? description;
@override @override
String toString() => String toString() =>
'UxCameraException($code${description == null ? '' : ': $description'})'; 'XCameraException($code${description == null ? '' : ': $description'})';
} }
/// Enumerate the cameras the platform exposes. Stable for the lifetime /// Enumerate the cameras the platform exposes. Stable for the lifetime
/// of the process; safe to cache the result. /// of the process; safe to cache the result.
Future<List<UxCameraDescription>> uxAvailableCameras() => Future<List<XCameraDescription>> uxAvailableCameras() =>
UxCameraBackend.instance.availableCameras(); XCameraBackend.instance.availableCameras();
/// Owns one native camera session. Mirrors the surface /// Owns one native camera session. Mirrors the surface
/// `package:camera`'s `CameraController` exposes today — every method /// `package:camera`'s `CameraController` exposes today — every method
@@ -174,29 +174,29 @@ Future<List<UxCameraDescription>> uxAvailableCameras() =>
/// orientation snapshot is passed explicitly on capture calls. /// orientation snapshot is passed explicitly on capture calls.
/// ///
/// Lifecycle: [initialize] → use → [dispose]. After [dispose] every /// Lifecycle: [initialize] → use → [dispose]. After [dispose] every
/// other method throws `UxCameraException("disposed")`. /// other method throws `XCameraException("disposed")`.
class UxCameraController extends ValueNotifier<UxCameraValue> { class XCameraController extends ValueNotifier<XCameraValue> {
UxCameraController( XCameraController(
UxCameraDescription description, XCameraDescription description,
this.resolutionPreset, { this.resolutionPreset, {
required bool enableAudio, required bool enableAudio,
}) : super(UxCameraValue( }) : super(XCameraValue(
description: description, description: description,
enableAudio: enableAudio, enableAudio: enableAudio,
)); ));
final UxResolutionPreset resolutionPreset; final XResolutionPreset resolutionPreset;
int? _handle; int? _handle;
int? _textureId; int? _textureId;
StreamSubscription<UxCameraEvent>? _eventsSub; StreamSubscription<XCameraEvent>? _eventsSub;
bool _disposed = false; bool _disposed = false;
UxCameraDescription get description => value.description; XCameraDescription get description => value.description;
bool get enableAudio => value.enableAudio; bool get enableAudio => value.enableAudio;
/// Texture id once the session has been created (during [initialize]). /// Texture id once the session has been created (during [initialize]).
/// Read by [UxCameraPreview]; null before init or after dispose. /// Read by [XCameraPreview]; null before init or after dispose.
int? get textureId => _textureId; int? get textureId => _textureId;
/// Configure the native session and begin streaming preview frames. /// Configure the native session and begin streaming preview frames.
@@ -206,22 +206,22 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
Future<void> initialize() async { Future<void> initialize() async {
_throwIfDisposed('initialize'); _throwIfDisposed('initialize');
try { try {
final result = await UxCameraBackend.instance.create( final result = await XCameraBackend.instance.create(
cameraId: description.id, cameraId: description.id,
enableAudio: value.enableAudio, enableAudio: value.enableAudio,
preset: resolutionPreset, preset: resolutionPreset,
); );
_handle = result.handle; _handle = result.handle;
_textureId = result.textureId; _textureId = result.textureId;
_eventsSub = UxCameraBackend.instance.events(result.handle).listen( _eventsSub = XCameraBackend.instance.events(result.handle).listen(
_onEvent, _onEvent,
onError: (Object error, StackTrace? stack) { onError: (Object error, StackTrace? stack) {
value = value.copyWith(errorDescription: error.toString()); value = value.copyWith(errorDescription: error.toString());
}, },
); );
await UxCameraBackend.instance.initialize(result.handle); await XCameraBackend.instance.initialize(result.handle);
final audioGranted = final audioGranted =
await UxCameraBackend.instance.audioPermissionGranted(); await XCameraBackend.instance.audioPermissionGranted();
value = value.copyWith( value = value.copyWith(
isInitialized: true, isInitialized: true,
previewSize: result.previewSize, previewSize: result.previewSize,
@@ -239,18 +239,18 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
} }
} }
void _onEvent(UxCameraEvent event) { void _onEvent(XCameraEvent event) {
switch (event) { switch (event) {
case UxCameraDeviceOrientationChanged(:final orientation): case XCameraDeviceOrientationChanged(:final orientation):
value = value.copyWith(deviceOrientation: orientation); value = value.copyWith(deviceOrientation: orientation);
case UxCameraSessionError(:final code, :final description): case XCameraSessionError(:final code, :final description):
value = value.copyWith(errorDescription: description ?? code); value = value.copyWith(errorDescription: description ?? code);
case UxCameraSessionInterrupted(): case XCameraSessionInterrupted():
case UxCameraSessionResumed(): case XCameraSessionResumed():
break; break;
case UxCameraDiagnostic(:final message): case XCameraDiagnostic(:final message):
_log.i('recorder: $message'); _log.i('recorder: $message');
case UxCameraPreviewSizeChanged(:final previewSize): case XCameraPreviewSizeChanged(:final previewSize):
value = value.copyWith(previewSize: previewSize); value = value.copyWith(previewSize: previewSize);
} }
} }
@@ -264,7 +264,7 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
final sub = _eventsSub; final sub = _eventsSub;
_eventsSub = null; _eventsSub = null;
if (handle != null) { if (handle != null) {
await UxCameraBackend.instance.disposeInstance(handle); await XCameraBackend.instance.disposeInstance(handle);
} }
await sub?.cancel(); await sub?.cancel();
super.dispose(); super.dispose();
@@ -272,9 +272,9 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
/// Switch lenses without tearing down the controller. Blocked while a /// Switch lenses without tearing down the controller. Blocked while a
/// recording is in flight — call [stopVideoRecording] first. /// recording is in flight — call [stopVideoRecording] first.
Future<void> setDescription(UxCameraDescription description) async { Future<void> setDescription(XCameraDescription description) async {
final handle = _requireHandle('setDescription'); final handle = _requireHandle('setDescription');
final r = await UxCameraBackend.instance.setDescription(handle, description.id); final r = await XCameraBackend.instance.setDescription(handle, description.id);
value = value.copyWith( value = value.copyWith(
description: description, description: description,
previewSize: r.previewSize, previewSize: r.previewSize,
@@ -282,47 +282,47 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
); );
} }
Future<void> setFlashMode(UxFlashMode mode) async { Future<void> setFlashMode(XFlashMode mode) async {
final handle = _requireHandle('setFlashMode'); final handle = _requireHandle('setFlashMode');
await UxCameraBackend.instance.setFlashMode(handle, mode); await XCameraBackend.instance.setFlashMode(handle, mode);
} }
Future<void> lockCaptureOrientation(DeviceOrientation orientation) async { Future<void> lockCaptureOrientation(DeviceOrientation orientation) async {
final handle = _requireHandle('lockCaptureOrientation'); final handle = _requireHandle('lockCaptureOrientation');
await UxCameraBackend.instance.lockCaptureOrientation(handle, orientation); await XCameraBackend.instance.lockCaptureOrientation(handle, orientation);
} }
Future<void> unlockCaptureOrientation() async { Future<void> unlockCaptureOrientation() async {
final handle = _requireHandle('unlockCaptureOrientation'); final handle = _requireHandle('unlockCaptureOrientation');
await UxCameraBackend.instance.unlockCaptureOrientation(handle); await XCameraBackend.instance.unlockCaptureOrientation(handle);
} }
/// Capture a still. [captureOrientation] is the orientation to embed /// Capture a still. [captureOrientation] is the orientation to embed
/// in the resulting JPEG; defaults to [UxSensor.orientation] read at /// in the resulting JPEG; defaults to [XSensor.orientation] read at
/// call time. Tests pass an explicit value to keep the assertion /// call time. Tests pass an explicit value to keep the assertion
/// deterministic. /// deterministic.
Future<UxFile> takePicture({DeviceOrientation? captureOrientation}) async { Future<XFile> takePicture({DeviceOrientation? captureOrientation}) async {
final handle = _requireHandle('takePicture'); final handle = _requireHandle('takePicture');
final orientation = captureOrientation ?? UxSensor.orientation; final orientation = captureOrientation ?? XSensor.orientation;
return UxCameraBackend.instance.takePicture(handle, orientation); return XCameraBackend.instance.takePicture(handle, orientation);
} }
/// Start recording. [captureOrientation] becomes the video file's /// Start recording. [captureOrientation] becomes the video file's
/// rotation transform (iOS: AVAssetWriterInput.transform; Android: /// rotation transform (iOS: AVAssetWriterInput.transform; Android:
/// CameraX targetRotation). Defaults to [UxSensor.orientation] — /// CameraX targetRotation). Defaults to [XSensor.orientation] —
/// pass explicitly from tests. /// pass explicitly from tests.
Future<void> startVideoRecording({ Future<void> startVideoRecording({
DeviceOrientation? captureOrientation, DeviceOrientation? captureOrientation,
}) async { }) async {
final handle = _requireHandle('startVideoRecording'); final handle = _requireHandle('startVideoRecording');
final orientation = captureOrientation ?? UxSensor.orientation; final orientation = captureOrientation ?? XSensor.orientation;
await UxCameraBackend.instance.startVideoRecording(handle, orientation); await XCameraBackend.instance.startVideoRecording(handle, orientation);
value = value.copyWith(isRecordingVideo: true); value = value.copyWith(isRecordingVideo: true);
} }
Future<UxFile> stopVideoRecording() async { Future<XFile> stopVideoRecording() async {
final handle = _requireHandle('stopVideoRecording'); final handle = _requireHandle('stopVideoRecording');
final file = await UxCameraBackend.instance.stopVideoRecording(handle); final file = await XCameraBackend.instance.stopVideoRecording(handle);
value = value.copyWith(isRecordingVideo: false); value = value.copyWith(isRecordingVideo: false);
return file; return file;
} }
@@ -332,7 +332,7 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
/// `AppLifecycleState.resumed` to pick up grants made via Settings. /// `AppLifecycleState.resumed` to pick up grants made via Settings.
Future<void> refreshAudioPermission() async { Future<void> refreshAudioPermission() async {
_throwIfDisposed('refreshAudioPermission'); _throwIfDisposed('refreshAudioPermission');
final granted = await UxCameraBackend.instance.audioPermissionGranted(); final granted = await XCameraBackend.instance.audioPermissionGranted();
if (granted != value.audioPermissionGranted) { if (granted != value.audioPermissionGranted) {
value = value.copyWith(audioPermissionGranted: granted); value = value.copyWith(audioPermissionGranted: granted);
} }
@@ -343,31 +343,40 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
/// needs the status before any controller has been created (e.g. /// needs the status before any controller has been created (e.g.
/// the camera page's "Tap to enable mic" banner). /// the camera page's "Tap to enable mic" banner).
static Future<bool> audioPermissionGranted() => static Future<bool> audioPermissionGranted() =>
UxCameraBackend.instance.audioPermissionGranted(); XCameraBackend.instance.audioPermissionGranted();
/// Banner-tap entry point that shows the in-app system mic prompt
/// when the OS still will, and deep-links to Settings on permanent
/// denial. Returns the post-call granted state. Prefer this over
/// jumping straight to [openSystemSettings] — on a fresh install
/// the mic entry isn't even in the Privacy pane until the first
/// request has been made.
static Future<bool> requestAudioPermission() =>
XCameraBackend.instance.requestAudioPermission();
/// Deep-link into the system Settings page so the user can grant /// Deep-link into the system Settings page so the user can grant
/// mic permission. Static because it doesn't depend on any active /// mic permission. Static because it doesn't depend on any active
/// controller — useful from the banner tap before the controller /// controller — useful from the banner tap before the controller
/// has finished initialising. /// has finished initialising.
static Future<void> openSystemSettings() => static Future<void> openSystemSettings() =>
UxCameraBackend.instance.openSettings(); XCameraBackend.instance.openSettings();
/// Texture-backed widget that renders the live preview at its parent's /// Texture-backed widget that renders the live preview at its parent's
/// size. Hero-flightable. /// size. Hero-flightable.
Widget buildPreview() => UxCameraPreview(controller: this); Widget buildPreview() => XCameraPreview(controller: this);
int _requireHandle(String op) { int _requireHandle(String op) {
_throwIfDisposed(op); _throwIfDisposed(op);
final h = _handle; final h = _handle;
if (h == null) { if (h == null) {
throw const UxCameraException('not_initialized'); throw const XCameraException('not_initialized');
} }
return h; return h;
} }
void _throwIfDisposed(String op) { void _throwIfDisposed(String op) {
if (_disposed) { if (_disposed) {
throw UxCameraException('disposed', '$op called on a disposed controller'); throw XCameraException('disposed', '$op called on a disposed controller');
} }
} }
} }

View File

@@ -2,43 +2,43 @@ import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../file.dart' show UxFile; import '../file.dart' show XFile;
import 'camera.dart' show UxCameraDescription, UxFlashMode, UxResolutionPreset; import 'camera.dart' show XCameraDescription, XFlashMode, XResolutionPreset;
import 'camera_channel.dart' show MethodChannelUxCameraBackend; import 'camera_channel.dart' show MethodChannelXCameraBackend;
/// Backend contract that [UxCameraController] dispatches into. The default /// Backend contract that [XCameraController] dispatches into. The default
/// implementation calls into native code via the `ux/camera` / /// implementation calls into native code via the `ux/camera` /
/// `ux/camera/events` channels; tests substitute their own (see /// `ux/camera/events` channels; tests substitute their own (see
/// `package:ux/testing.dart`'s `FakeUxCameraBackend`). /// `package:ux/testing.dart`'s `FakeXCameraBackend`).
/// ///
/// Every per-instance call carries a `handle` returned by [create] so /// Every per-instance call carries a `handle` returned by [create] so
/// the plugin can route to the right native session. Multiple /// the plugin can route to the right native session. Multiple
/// controllers can hold simultaneous handles. /// controllers can hold simultaneous handles.
abstract class UxCameraBackend { abstract class XCameraBackend {
/// Swap to inject a fake before any UI code mounts a controller. /// Swap to inject a fake before any UI code mounts a controller.
static UxCameraBackend instance = MethodChannelUxCameraBackend(); static XCameraBackend instance = MethodChannelXCameraBackend();
/// Enumerate camera devices. The result is stable for the lifetime /// Enumerate camera devices. The result is stable for the lifetime
/// of the process. /// of the process.
Future<List<UxCameraDescription>> availableCameras(); Future<List<XCameraDescription>> availableCameras();
/// Allocate a native camera instance bound to [cameraId]. Returns the /// Allocate a native camera instance bound to [cameraId]. Returns the
/// handle (for subsequent calls), the FlutterTexture id (for the /// handle (for subsequent calls), the FlutterTexture id (for the
/// preview widget), and the sensor-natural-orientation preview size. /// preview widget), and the sensor-natural-orientation preview size.
/// ///
/// Throws [UxCameraException("device_busy")] if another instance /// Throws [XCameraException("device_busy")] if another instance
/// already holds the device, or [UxCameraException("audio_busy")] /// already holds the device, or [XCameraException("audio_busy")]
/// when [enableAudio] is true and another instance holds the /// when [enableAudio] is true and another instance holds the
/// app-global audio session. /// app-global audio session.
Future<UxCameraCreateResult> create({ Future<XCameraCreateResult> create({
required String cameraId, required String cameraId,
required bool enableAudio, required bool enableAudio,
required UxResolutionPreset preset, required XResolutionPreset preset,
}); });
/// Start the session. Native side resolves camera + audio permissions /// Start the session. Native side resolves camera + audio permissions
/// before the future completes. Throws /// before the future completes. Throws
/// [UxCameraException("permission_denied")] on denial. /// [XCameraException("permission_denied")] on denial.
Future<void> initialize(int handle); Future<void> initialize(int handle);
/// Tear down the session. Cancels any in-flight recording, releases /// Tear down the session. Cancels any in-flight recording, releases
@@ -49,7 +49,7 @@ abstract class UxCameraBackend {
/// Swap to a different camera mid-session. Resets the lock and clears /// Swap to a different camera mid-session. Resets the lock and clears
/// any pending recording. Returns the new preview size and rotation; /// any pending recording. Returns the new preview size and rotation;
/// the size may again be `Size.zero` initially on Android, with a /// the size may again be `Size.zero` initially on Android, with a
/// follow-up [UxCameraPreviewSizeChanged] event. /// follow-up [XCameraPreviewSizeChanged] event.
Future<({Size previewSize, int previewRotationQuarterTurns})> setDescription( Future<({Size previewSize, int previewRotationQuarterTurns})> setDescription(
int handle, int handle,
String cameraId, String cameraId,
@@ -58,7 +58,7 @@ abstract class UxCameraBackend {
/// Set the flash mode used for the next [takePicture]. On front cameras /// Set the flash mode used for the next [takePicture]. On front cameras
/// without a screen-flash fallback the backend silently no-ops; the /// without a screen-flash fallback the backend silently no-ops; the
/// caller is responsible for not offering flash UI there. /// caller is responsible for not offering flash UI there.
Future<void> setFlashMode(int handle, UxFlashMode mode); Future<void> setFlashMode(int handle, XFlashMode mode);
/// Pin the preview's connection orientation. Used today only to lock /// Pin the preview's connection orientation. Used today only to lock
/// the preview to portrait so it never appears stretched/rotated. /// the preview to portrait so it never appears stretched/rotated.
@@ -72,7 +72,7 @@ abstract class UxCameraBackend {
/// Take a still photo. [snapshotOrientation] is applied to the photo /// Take a still photo. [snapshotOrientation] is applied to the photo
/// connection just before capture so the file's EXIF orientation /// connection just before capture so the file's EXIF orientation
/// matches how the user was holding the device. /// matches how the user was holding the device.
Future<UxFile> takePicture(int handle, DeviceOrientation snapshotOrientation); Future<XFile> takePicture(int handle, DeviceOrientation snapshotOrientation);
/// Begin recording video. [snapshotOrientation] is baked into the /// Begin recording video. [snapshotOrientation] is baked into the
/// writer track's transform — the file plays back rotated even if /// writer track's transform — the file plays back rotated even if
@@ -83,29 +83,38 @@ abstract class UxCameraBackend {
); );
/// Stop recording and return the resulting MP4 on disk. /// Stop recording and return the resulting MP4 on disk.
Future<UxFile> stopVideoRecording(int handle); Future<XFile> stopVideoRecording(int handle);
/// Live event stream for [handle]: device-orientation changes, /// Live event stream for [handle]: device-orientation changes,
/// session errors, interrupted/resumed lifecycle pings. The /// session errors, interrupted/resumed lifecycle pings. The
/// controller subscribes during [initialize] and unsubscribes on /// controller subscribes during [initialize] and unsubscribes on
/// [disposeInstance]. /// [disposeInstance].
Stream<UxCameraEvent> events(int handle); Stream<XCameraEvent> events(int handle);
/// True iff the user has granted microphone access. Cheap; safe to /// True iff the user has granted microphone access. Cheap; safe to
/// re-poll on app foregrounding to detect grants made via Settings. /// re-poll on app foregrounding to detect grants made via Settings.
Future<bool> audioPermissionGranted(); Future<bool> audioPermissionGranted();
/// Attempt to grant mic access. Shows the in-app system prompt when
/// the OS is willing to surface one (fresh install, or a first
/// denial that's still retryable on Android); on permanent denial,
/// deep-links into Settings and resolves to `false`. Returns the
/// post-call granted state. Avoids the fresh-install trap where the
/// mic entry isn't in the Privacy pane until [requestAccess] has
/// fired at least once.
Future<bool> requestAudioPermission();
/// Deep-link into the system Settings page for this app. Caller is /// Deep-link into the system Settings page for this app. Caller is
/// expected to refresh [audioPermissionGranted] on /// expected to refresh [audioPermissionGranted] on
/// `AppLifecycleState.resumed`. /// `AppLifecycleState.resumed`.
Future<void> openSettings(); Future<void> openSettings();
} }
/// The tuple returned by [UxCameraBackend.create] — everything the /// The tuple returned by [XCameraBackend.create] — everything the
/// controller needs to start serving the preview widget and routing /// controller needs to start serving the preview widget and routing
/// subsequent calls. /// subsequent calls.
class UxCameraCreateResult { class XCameraCreateResult {
const UxCameraCreateResult({ const XCameraCreateResult({
required this.handle, required this.handle,
required this.textureId, required this.textureId,
required this.previewSize, required this.previewSize,
@@ -118,7 +127,7 @@ class UxCameraCreateResult {
/// Initial preview size; may be `Size.zero` when the native side /// Initial preview size; may be `Size.zero` when the native side
/// can't determine it synchronously (Android CameraX needs the /// can't determine it synchronously (Android CameraX needs the
/// first `SurfaceRequest` to fire before it knows). In that case /// first `SurfaceRequest` to fire before it knows). In that case
/// a [UxCameraPreviewSizeChanged] event follows. /// a [XCameraPreviewSizeChanged] event follows.
final Size previewSize; final Size previewSize;
/// Number of 90° CW rotations the Texture widget needs. iOS: 0. /// Number of 90° CW rotations the Texture widget needs. iOS: 0.
@@ -128,29 +137,29 @@ class UxCameraCreateResult {
/// Events pushed by the native side over `ux/camera/events`. Sealed — /// Events pushed by the native side over `ux/camera/events`. Sealed —
/// new variants land here as the contract grows. /// new variants land here as the contract grows.
sealed class UxCameraEvent { sealed class XCameraEvent {
const UxCameraEvent(this.handle); const XCameraEvent(this.handle);
final int handle; final int handle;
} }
class UxCameraDeviceOrientationChanged extends UxCameraEvent { class XCameraDeviceOrientationChanged extends XCameraEvent {
const UxCameraDeviceOrientationChanged(super.handle, this.orientation); const XCameraDeviceOrientationChanged(super.handle, this.orientation);
final DeviceOrientation orientation; final DeviceOrientation orientation;
} }
class UxCameraSessionError extends UxCameraEvent { class XCameraSessionError extends XCameraEvent {
const UxCameraSessionError(super.handle, this.code, this.description); const XCameraSessionError(super.handle, this.code, this.description);
final String code; final String code;
final String? description; final String? description;
} }
class UxCameraSessionInterrupted extends UxCameraEvent { class XCameraSessionInterrupted extends XCameraEvent {
const UxCameraSessionInterrupted(super.handle, this.reason); const XCameraSessionInterrupted(super.handle, this.reason);
final String reason; final String reason;
} }
class UxCameraSessionResumed extends UxCameraEvent { class XCameraSessionResumed extends XCameraEvent {
const UxCameraSessionResumed(super.handle); const XCameraSessionResumed(super.handle);
} }
/// Fired when the native side learns or revises the preview's pixel /// Fired when the native side learns or revises the preview's pixel
@@ -158,15 +167,15 @@ class UxCameraSessionResumed extends UxCameraEvent {
/// `SurfaceRequest` resolves (the size isn't known at `create` time); /// `SurfaceRequest` resolves (the size isn't known at `create` time);
/// iOS doesn't emit it (the size lands in the `create` result /// iOS doesn't emit it (the size lands in the `create` result
/// synchronously from `device.activeFormat`). /// synchronously from `device.activeFormat`).
class UxCameraPreviewSizeChanged extends UxCameraEvent { class XCameraPreviewSizeChanged extends XCameraEvent {
const UxCameraPreviewSizeChanged(super.handle, this.previewSize); const XCameraPreviewSizeChanged(super.handle, this.previewSize);
final Size previewSize; final Size previewSize;
} }
/// Free-text diagnostic message from the native recorder. Routed by /// Free-text diagnostic message from the native recorder. Routed by
/// the controller to `Log.tag('camera').i(...)` so it lands in the /// the controller to `Log.tag('camera').i(...)` so it lands in the
/// log_server pipeline (`~/banlu/tools/log_server/data/banlu.jsonl`). /// log_server pipeline (`~/banlu/tools/log_server/data/banlu.jsonl`).
class UxCameraDiagnostic extends UxCameraEvent { class XCameraDiagnostic extends XCameraEvent {
const UxCameraDiagnostic(super.handle, this.message); const XCameraDiagnostic(super.handle, this.message);
final String message; final String message;
} }

View File

@@ -2,23 +2,23 @@ import 'dart:async';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../file.dart' show UxFile; import '../file.dart' show XFile;
import '../log.dart' show Log; import '../log.dart' show Log;
import 'camera.dart' import 'camera.dart'
show show
UxCameraDescription, XCameraDescription,
UxCameraException, XCameraException,
UxCameraLens, XCameraLens,
UxFlashMode, XFlashMode,
UxResolutionPreset; XResolutionPreset;
import 'camera_backend.dart'; import 'camera_backend.dart';
final _log = Log.tag('camera'); final _log = Log.tag('camera');
/// Production [UxCameraBackend]. Hand-rolled MethodChannel + /// Production [XCameraBackend]. Hand-rolled MethodChannel +
/// EventChannel — matches the rest of `package:ux`, no pigeon. /// EventChannel — matches the rest of `package:ux`, no pigeon.
class MethodChannelUxCameraBackend implements UxCameraBackend { class MethodChannelXCameraBackend implements XCameraBackend {
MethodChannelUxCameraBackend(); MethodChannelXCameraBackend();
static const _channel = MethodChannel('ux/camera'); static const _channel = MethodChannel('ux/camera');
static const _eventsChannel = EventChannel('ux/camera/events'); static const _eventsChannel = EventChannel('ux/camera/events');
@@ -43,11 +43,11 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
}(); }();
@override @override
Future<List<UxCameraDescription>> availableCameras() async { Future<List<XCameraDescription>> availableCameras() async {
final raw = await _invoke<List<Object?>>('availableCameras'); final raw = await _invoke<List<Object?>>('availableCameras');
return raw.map((e) { return raw.map((e) {
final m = (e as Map).cast<Object?, Object?>(); final m = (e as Map).cast<Object?, Object?>();
return UxCameraDescription( return XCameraDescription(
id: m['id'] as String, id: m['id'] as String,
lens: _parseLens(m['lens'] as String?), lens: _parseLens(m['lens'] as String?),
sensorOrientation: (m['sensorOrientation'] as num?)?.toInt() ?? 0, sensorOrientation: (m['sensorOrientation'] as num?)?.toInt() ?? 0,
@@ -56,10 +56,10 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
} }
@override @override
Future<UxCameraCreateResult> create({ Future<XCameraCreateResult> create({
required String cameraId, required String cameraId,
required bool enableAudio, required bool enableAudio,
required UxResolutionPreset preset, required XResolutionPreset preset,
}) async { }) async {
final m = await _invokeMap('create', { final m = await _invokeMap('create', {
'cameraId': cameraId, 'cameraId': cameraId,
@@ -67,7 +67,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
'preset': _presetArg(preset), 'preset': _presetArg(preset),
}); });
final size = (m['previewSize'] as Map).cast<Object?, Object?>(); final size = (m['previewSize'] as Map).cast<Object?, Object?>();
return UxCameraCreateResult( return XCameraCreateResult(
handle: (m['handle'] as num).toInt(), handle: (m['handle'] as num).toInt(),
textureId: (m['textureId'] as num).toInt(), textureId: (m['textureId'] as num).toInt(),
previewSize: Size( previewSize: Size(
@@ -108,7 +108,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
} }
@override @override
Future<void> setFlashMode(int handle, UxFlashMode mode) => Future<void> setFlashMode(int handle, XFlashMode mode) =>
_invokeVoid('setFlashMode', { _invokeVoid('setFlashMode', {
'handle': handle, 'handle': handle,
'mode': _flashArg(mode), 'mode': _flashArg(mode),
@@ -129,7 +129,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
_invokeVoid('unlockCaptureOrientation', {'handle': handle}); _invokeVoid('unlockCaptureOrientation', {'handle': handle});
@override @override
Future<UxFile> takePicture( Future<XFile> takePicture(
int handle, int handle,
DeviceOrientation snapshotOrientation, DeviceOrientation snapshotOrientation,
) async { ) async {
@@ -137,7 +137,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
'handle': handle, 'handle': handle,
'snapshotOrientation': _orientationArg(snapshotOrientation), 'snapshotOrientation': _orientationArg(snapshotOrientation),
}); });
return UxFile(m['path'] as String); return XFile(m['path'] as String);
} }
@override @override
@@ -151,19 +151,22 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
}); });
@override @override
Future<UxFile> stopVideoRecording(int handle) async { Future<XFile> stopVideoRecording(int handle) async {
final m = await _invokeMap('stopVideoRecording', {'handle': handle}); final m = await _invokeMap('stopVideoRecording', {'handle': handle});
return UxFile(m['path'] as String); return XFile(m['path'] as String);
} }
@override @override
Future<bool> audioPermissionGranted() => _invoke<bool>('audioPermissionStatus'); Future<bool> audioPermissionGranted() => _invoke<bool>('audioPermissionStatus');
@override
Future<bool> requestAudioPermission() => _invoke<bool>('requestAudioPermission');
@override @override
Future<void> openSettings() => _invokeVoid('openSettings'); Future<void> openSettings() => _invokeVoid('openSettings');
@override @override
Stream<UxCameraEvent> events(int handle) { Stream<XCameraEvent> events(int handle) {
return _rawEvents return _rawEvents
.map((e) => (e as Map).cast<Object?, Object?>()) .map((e) => (e as Map).cast<Object?, Object?>())
.where((m) => (m['handle'] as num).toInt() == handle) .where((m) => (m['handle'] as num).toInt() == handle)
@@ -172,35 +175,35 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
// ---- parsers / arg encoders ------------------------------------- // ---- parsers / arg encoders -------------------------------------
static UxCameraEvent _decodeEvent(Map<Object?, Object?> m) { static XCameraEvent _decodeEvent(Map<Object?, Object?> m) {
final handle = (m['handle'] as num).toInt(); final handle = (m['handle'] as num).toInt();
switch (m['event'] as String?) { switch (m['event'] as String?) {
case 'deviceOrientationChanged': case 'deviceOrientationChanged':
return UxCameraDeviceOrientationChanged( return XCameraDeviceOrientationChanged(
handle, handle,
_parseOrientation(m['orientation'] as String?), _parseOrientation(m['orientation'] as String?),
); );
case 'sessionError': case 'sessionError':
return UxCameraSessionError( return XCameraSessionError(
handle, handle,
m['code'] as String? ?? 'session_runtime_error', m['code'] as String? ?? 'session_runtime_error',
m['description'] as String?, m['description'] as String?,
); );
case 'sessionInterrupted': case 'sessionInterrupted':
return UxCameraSessionInterrupted( return XCameraSessionInterrupted(
handle, handle,
m['reason'] as String? ?? '', m['reason'] as String? ?? '',
); );
case 'sessionResumed': case 'sessionResumed':
return UxCameraSessionResumed(handle); return XCameraSessionResumed(handle);
case 'diagnostic': case 'diagnostic':
return UxCameraDiagnostic( return XCameraDiagnostic(
handle, handle,
m['message'] as String? ?? '', m['message'] as String? ?? '',
); );
case 'previewSizeChanged': case 'previewSizeChanged':
final s = (m['previewSize'] as Map).cast<Object?, Object?>(); final s = (m['previewSize'] as Map).cast<Object?, Object?>();
return UxCameraPreviewSizeChanged( return XCameraPreviewSizeChanged(
handle, handle,
Size( Size(
(s['width'] as num).toDouble(), (s['width'] as num).toDouble(),
@@ -208,13 +211,13 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
), ),
); );
default: default:
return UxCameraSessionError(handle, 'unknown_event', null); return XCameraSessionError(handle, 'unknown_event', null);
} }
} }
static UxCameraLens _parseLens(String? raw) => switch (raw) { static XCameraLens _parseLens(String? raw) => switch (raw) {
'front' => UxCameraLens.front, 'front' => XCameraLens.front,
_ => UxCameraLens.back, _ => XCameraLens.back,
}; };
static DeviceOrientation _parseOrientation(String? raw) => switch (raw) { static DeviceOrientation _parseOrientation(String? raw) => switch (raw) {
@@ -231,13 +234,13 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
DeviceOrientation.landscapeRight => 'landscapeRight', DeviceOrientation.landscapeRight => 'landscapeRight',
}; };
static String _flashArg(UxFlashMode m) => switch (m) { static String _flashArg(XFlashMode m) => switch (m) {
UxFlashMode.off => 'off', XFlashMode.off => 'off',
UxFlashMode.always => 'always', XFlashMode.always => 'always',
}; };
static String _presetArg(UxResolutionPreset p) => switch (p) { static String _presetArg(XResolutionPreset p) => switch (p) {
UxResolutionPreset.high => 'high', XResolutionPreset.high => 'high',
}; };
// ---- channel adapter -------------------------------------------- // ---- channel adapter --------------------------------------------
@@ -247,7 +250,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
final result = await _channel.invokeMethod<Object?>(method, args); final result = await _channel.invokeMethod<Object?>(method, args);
return result as T; return result as T;
} on PlatformException catch (e) { } on PlatformException catch (e) {
throw UxCameraException(e.code, e.message); throw XCameraException(e.code, e.message);
} }
} }
@@ -263,7 +266,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
try { try {
await _channel.invokeMethod<void>(method, args); await _channel.invokeMethod<void>(method, args);
} on PlatformException catch (e) { } on PlatformException catch (e) {
throw UxCameraException(e.code, e.message); throw XCameraException(e.code, e.message);
} }
} }
} }

View File

@@ -4,19 +4,19 @@ import 'dart:ui' show Size;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:ux/src/camera/camera.dart'; import 'package:ux/src/camera/camera.dart';
import 'package:ux/src/camera/camera_backend.dart'; import 'package:ux/src/camera/camera_backend.dart';
import 'package:ux/src/file.dart' show UxFile; import 'package:ux/src/file.dart' show XFile;
/// In-memory backend for [UxCameraController] tests. Swap in via /// In-memory backend for [XCameraController] tests. Swap in via
/// `UxCameraBackend.instance = FakeUxCameraBackend(...)` before any UI /// `XCameraBackend.instance = FakeXCameraBackend(...)` before any UI
/// mounts; restore with `UxCameraBackend.instance = /// mounts; restore with `XCameraBackend.instance =
/// MethodChannelUxCameraBackend()` in `tearDown`. /// MethodChannelXCameraBackend()` in `tearDown`.
/// ///
/// Captures every call into per-method lists for assertions, and lets /// Captures every call into per-method lists for assertions, and lets
/// tests drive events deterministically via [emitOrientationChanged] /// tests drive events deterministically via [emitOrientationChanged]
/// and friends. /// and friends.
class FakeUxCameraBackend implements UxCameraBackend { class FakeXCameraBackend implements XCameraBackend {
FakeUxCameraBackend({ FakeXCameraBackend({
List<UxCameraDescription> cameras = const [], List<XCameraDescription> cameras = const [],
this.previewSize = const Size(1920, 1080), this.previewSize = const Size(1920, 1080),
this.picturePath = '/tmp/fake_picture.jpg', this.picturePath = '/tmp/fake_picture.jpg',
this.videoPath = '/tmp/fake_video.mp4', this.videoPath = '/tmp/fake_video.mp4',
@@ -24,12 +24,12 @@ class FakeUxCameraBackend implements UxCameraBackend {
// ---- captured calls --------------------------------------------- // ---- captured calls ---------------------------------------------
final List<({String cameraId, bool enableAudio, UxResolutionPreset preset})> final List<({String cameraId, bool enableAudio, XResolutionPreset preset})>
createCalls = []; createCalls = [];
final List<int> initializeCalls = []; final List<int> initializeCalls = [];
final List<int> disposeCalls = []; final List<int> disposeCalls = [];
final List<({int handle, String cameraId})> setDescriptionCalls = []; final List<({int handle, String cameraId})> setDescriptionCalls = [];
final List<({int handle, UxFlashMode mode})> setFlashModeCalls = []; final List<({int handle, XFlashMode mode})> setFlashModeCalls = [];
final List<({int handle, DeviceOrientation orientation})> final List<({int handle, DeviceOrientation orientation})>
lockCaptureOrientationCalls = []; lockCaptureOrientationCalls = [];
final List<int> unlockCaptureOrientationCalls = []; final List<int> unlockCaptureOrientationCalls = [];
@@ -44,38 +44,43 @@ class FakeUxCameraBackend implements UxCameraBackend {
/// Cameras returned by [availableCameras]. Mutable so tests can swap /// Cameras returned by [availableCameras]. Mutable so tests can swap
/// the set between assertions. /// the set between assertions.
List<UxCameraDescription> cameras; List<XCameraDescription> cameras;
Size previewSize; Size previewSize;
String picturePath; String picturePath;
String videoPath; String videoPath;
/// If non-null, [create] throws this exception instead of returning /// If non-null, [create] throws this exception instead of returning
/// a result — exercises permission denial / device busy paths. /// a result — exercises permission denial / device busy paths.
UxCameraException? createError; XCameraException? createError;
/// Optional override; set to throw on any particular method to /// Optional override; set to throw on any particular method to
/// drive failure paths. /// drive failure paths.
UxCameraException? initializeError; XCameraException? initializeError;
UxCameraException? takePictureError; XCameraException? takePictureError;
UxCameraException? startVideoRecordingError; XCameraException? startVideoRecordingError;
UxCameraException? stopVideoRecordingError; XCameraException? stopVideoRecordingError;
/// Audio permission state returned by [audioPermissionGranted]. /// Audio permission state returned by [audioPermissionGranted].
/// Tests mutate this to drive the mic-permission UI banner. /// Tests mutate this to drive the mic-permission UI banner.
bool audioPermission = true; bool audioPermission = true;
int audioPermissionCalls = 0; int audioPermissionCalls = 0;
int openSettingsCalls = 0; int openSettingsCalls = 0;
int requestAudioPermissionCalls = 0;
/// Result returned by [requestAudioPermission]. Tests mutate to
/// exercise grant / permanent-denial paths.
bool requestAudioPermissionResult = true;
// ---- internal --------------------------------------------------- // ---- internal ---------------------------------------------------
int _nextHandle = 1; int _nextHandle = 1;
int _nextTextureId = 100; int _nextTextureId = 100;
final Map<int, StreamController<UxCameraEvent>> _eventControllers = {}; final Map<int, StreamController<XCameraEvent>> _eventControllers = {};
StreamController<UxCameraEvent> _controllerFor(int handle) { StreamController<XCameraEvent> _controllerFor(int handle) {
return _eventControllers.putIfAbsent( return _eventControllers.putIfAbsent(
handle, handle,
() => StreamController<UxCameraEvent>.broadcast(), () => StreamController<XCameraEvent>.broadcast(),
); );
} }
@@ -85,38 +90,38 @@ class FakeUxCameraBackend implements UxCameraBackend {
/// subscribed controller will update its `value.deviceOrientation`. /// subscribed controller will update its `value.deviceOrientation`.
void emitOrientationChanged(int handle, DeviceOrientation orientation) { void emitOrientationChanged(int handle, DeviceOrientation orientation) {
_controllerFor(handle) _controllerFor(handle)
.add(UxCameraDeviceOrientationChanged(handle, orientation)); .add(XCameraDeviceOrientationChanged(handle, orientation));
} }
void emitSessionError(int handle, String code, [String? description]) { void emitSessionError(int handle, String code, [String? description]) {
_controllerFor(handle).add(UxCameraSessionError(handle, code, description)); _controllerFor(handle).add(XCameraSessionError(handle, code, description));
} }
void emitSessionInterrupted(int handle, String reason) { void emitSessionInterrupted(int handle, String reason) {
_controllerFor(handle).add(UxCameraSessionInterrupted(handle, reason)); _controllerFor(handle).add(XCameraSessionInterrupted(handle, reason));
} }
void emitSessionResumed(int handle) { void emitSessionResumed(int handle) {
_controllerFor(handle).add(UxCameraSessionResumed(handle)); _controllerFor(handle).add(XCameraSessionResumed(handle));
} }
void emitDiagnostic(int handle, String message) { void emitDiagnostic(int handle, String message) {
_controllerFor(handle).add(UxCameraDiagnostic(handle, message)); _controllerFor(handle).add(XCameraDiagnostic(handle, message));
} }
// ---- UxCameraBackend impl -------------------------------------- // ---- XCameraBackend impl --------------------------------------
@override @override
Future<List<UxCameraDescription>> availableCameras() async { Future<List<XCameraDescription>> availableCameras() async {
availableCamerasCalls += 1; availableCamerasCalls += 1;
return cameras; return cameras;
} }
@override @override
Future<UxCameraCreateResult> create({ Future<XCameraCreateResult> create({
required String cameraId, required String cameraId,
required bool enableAudio, required bool enableAudio,
required UxResolutionPreset preset, required XResolutionPreset preset,
}) async { }) async {
createCalls.add(( createCalls.add((
cameraId: cameraId, cameraId: cameraId,
@@ -127,7 +132,7 @@ class FakeUxCameraBackend implements UxCameraBackend {
if (err != null) throw err; if (err != null) throw err;
final handle = _nextHandle++; final handle = _nextHandle++;
final textureId = _nextTextureId++; final textureId = _nextTextureId++;
return UxCameraCreateResult( return XCameraCreateResult(
handle: handle, handle: handle,
textureId: textureId, textureId: textureId,
previewSize: previewSize, previewSize: previewSize,
@@ -159,11 +164,11 @@ class FakeUxCameraBackend implements UxCameraBackend {
/// Push a `previewSizeChanged` event for [handle]. Tests that need /// Push a `previewSizeChanged` event for [handle]. Tests that need
/// to simulate Android's async preview-size resolve drive this. /// to simulate Android's async preview-size resolve drive this.
void emitPreviewSizeChanged(int handle, Size size) { void emitPreviewSizeChanged(int handle, Size size) {
_controllerFor(handle).add(UxCameraPreviewSizeChanged(handle, size)); _controllerFor(handle).add(XCameraPreviewSizeChanged(handle, size));
} }
@override @override
Future<void> setFlashMode(int handle, UxFlashMode mode) async { Future<void> setFlashMode(int handle, XFlashMode mode) async {
setFlashModeCalls.add((handle: handle, mode: mode)); setFlashModeCalls.add((handle: handle, mode: mode));
} }
@@ -182,7 +187,7 @@ class FakeUxCameraBackend implements UxCameraBackend {
} }
@override @override
Future<UxFile> takePicture( Future<XFile> takePicture(
int handle, int handle,
DeviceOrientation snapshotOrientation, DeviceOrientation snapshotOrientation,
) async { ) async {
@@ -190,7 +195,7 @@ class FakeUxCameraBackend implements UxCameraBackend {
.add((handle: handle, snapshotOrientation: snapshotOrientation)); .add((handle: handle, snapshotOrientation: snapshotOrientation));
final err = takePictureError; final err = takePictureError;
if (err != null) throw err; if (err != null) throw err;
return UxFile(picturePath); return XFile(picturePath);
} }
@override @override
@@ -205,15 +210,15 @@ class FakeUxCameraBackend implements UxCameraBackend {
} }
@override @override
Future<UxFile> stopVideoRecording(int handle) async { Future<XFile> stopVideoRecording(int handle) async {
stopVideoRecordingCalls.add(handle); stopVideoRecordingCalls.add(handle);
final err = stopVideoRecordingError; final err = stopVideoRecordingError;
if (err != null) throw err; if (err != null) throw err;
return UxFile(videoPath); return XFile(videoPath);
} }
@override @override
Stream<UxCameraEvent> events(int handle) => _controllerFor(handle).stream; Stream<XCameraEvent> events(int handle) => _controllerFor(handle).stream;
@override @override
Future<bool> audioPermissionGranted() async { Future<bool> audioPermissionGranted() async {
@@ -221,6 +226,13 @@ class FakeUxCameraBackend implements UxCameraBackend {
return audioPermission; return audioPermission;
} }
@override
Future<bool> requestAudioPermission() async {
requestAudioPermissionCalls += 1;
if (requestAudioPermissionResult) audioPermission = true;
return requestAudioPermissionResult;
}
@override @override
Future<void> openSettings() async { Future<void> openSettings() async {
openSettingsCalls += 1; openSettingsCalls += 1;

View File

@@ -3,29 +3,29 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:ux/testing.dart'; import 'package:ux/testing.dart';
import 'package:ux/ux.dart'; import 'package:ux/ux.dart';
const _front = UxCameraDescription( const _front = XCameraDescription(
id: 'front', id: 'front',
lens: UxCameraLens.front, lens: XCameraLens.front,
sensorOrientation: 270, sensorOrientation: 270,
); );
const _back = UxCameraDescription( const _back = XCameraDescription(
id: 'back', id: 'back',
lens: UxCameraLens.back, lens: XCameraLens.back,
sensorOrientation: 90, sensorOrientation: 90,
); );
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
late FakeUxCameraBackend fake; late FakeXCameraBackend fake;
setUp(() { setUp(() {
fake = FakeUxCameraBackend(cameras: const [_front, _back]); fake = FakeXCameraBackend(cameras: const [_front, _back]);
UxCameraBackend.instance = fake; XCameraBackend.instance = fake;
}); });
tearDown(() { tearDown(() {
UxCameraBackend.instance = MethodChannelUxCameraBackend(); XCameraBackend.instance = MethodChannelXCameraBackend();
}); });
test('uxAvailableCameras dispatches through the backend', () async { test('uxAvailableCameras dispatches through the backend', () async {
@@ -36,7 +36,7 @@ void main() {
test('initialize creates the native instance, subscribes to events, ' test('initialize creates the native instance, subscribes to events, '
'and reports previewSize / isInitialized', () async { 'and reports previewSize / isInitialized', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
expect(ctrl.value.isInitialized, isFalse); expect(ctrl.value.isInitialized, isFalse);
@@ -45,7 +45,7 @@ void main() {
await ctrl.initialize(); await ctrl.initialize();
expect(fake.createCalls.single, expect(fake.createCalls.single,
(cameraId: 'front', enableAudio: false, preset: UxResolutionPreset.high)); (cameraId: 'front', enableAudio: false, preset: XResolutionPreset.high));
expect(fake.initializeCalls.single, 1); expect(fake.initializeCalls.single, 1);
expect(ctrl.textureId, 100); expect(ctrl.textureId, 100);
expect(ctrl.value.isInitialized, isTrue); expect(ctrl.value.isInitialized, isTrue);
@@ -53,7 +53,7 @@ void main() {
}); });
test('deviceOrientationChanged events update value.deviceOrientation', () async { test('deviceOrientationChanged events update value.deviceOrientation', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -79,7 +79,7 @@ void main() {
); );
addTearDown(() => Log.configure(sink: prevSink, captureCrashes: () {})); addTearDown(() => Log.configure(sink: prevSink, captureCrashes: () {}));
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -94,7 +94,7 @@ void main() {
}); });
test('sessionError events surface as value.errorDescription', () async { test('sessionError events surface as value.errorDescription', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -105,7 +105,7 @@ void main() {
}); });
test('takePicture passes the explicit captureOrientation through', () async { test('takePicture passes the explicit captureOrientation through', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -116,9 +116,9 @@ void main() {
(handle: 1, snapshotOrientation: DeviceOrientation.landscapeRight)); (handle: 1, snapshotOrientation: DeviceOrientation.landscapeRight));
}); });
test('takePicture without captureOrientation falls back to UxSensor', () async { test('takePicture without captureOrientation falls back to XSensor', () async {
// UxSensor.orientation returns portraitUp under flutter_test (no native sensor). // XSensor.orientation returns portraitUp under flutter_test (no native sensor).
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -129,7 +129,7 @@ void main() {
}); });
test('startVideoRecording flips isRecordingVideo and passes orientation', () async { test('startVideoRecording flips isRecordingVideo and passes orientation', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: true);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -141,7 +141,7 @@ void main() {
}); });
test('stopVideoRecording resets isRecordingVideo and returns the file', () async { test('stopVideoRecording resets isRecordingVideo and returns the file', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: true);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
await ctrl.startVideoRecording(captureOrientation: DeviceOrientation.landscapeLeft); await ctrl.startVideoRecording(captureOrientation: DeviceOrientation.landscapeLeft);
@@ -155,7 +155,7 @@ void main() {
test('previewSizeChanged events update value.previewSize without losing rotation', test('previewSizeChanged events update value.previewSize without losing rotation',
() async { () async {
final ctrl = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_back, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -170,7 +170,7 @@ void main() {
}); });
test('setDescription updates value.description and previewSize', () async { test('setDescription updates value.description and previewSize', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -183,18 +183,18 @@ void main() {
}); });
test('setFlashMode forwards to the backend', () async { test('setFlashMode forwards to the backend', () async {
final ctrl = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_back, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
await ctrl.setFlashMode(UxFlashMode.always); await ctrl.setFlashMode(XFlashMode.always);
expect(fake.setFlashModeCalls.single, (handle: 1, mode: UxFlashMode.always)); expect(fake.setFlashModeCalls.single, (handle: 1, mode: XFlashMode.always));
}); });
test('lockCaptureOrientation / unlockCaptureOrientation forward to backend', test('lockCaptureOrientation / unlockCaptureOrientation forward to backend',
() async { () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -207,7 +207,7 @@ void main() {
}); });
test('dispose tears down the native instance and is idempotent', () async { test('dispose tears down the native instance and is idempotent', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
await ctrl.initialize(); await ctrl.initialize();
await ctrl.dispose(); await ctrl.dispose();
@@ -218,28 +218,28 @@ void main() {
expect(fake.disposeCalls, [1]); expect(fake.disposeCalls, [1]);
}); });
test('calls against a disposed controller throw UxCameraException', () async { test('calls against a disposed controller throw XCameraException', () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
await ctrl.initialize(); await ctrl.initialize();
await ctrl.dispose(); await ctrl.dispose();
expect(() => ctrl.takePicture(captureOrientation: DeviceOrientation.portraitUp), expect(() => ctrl.takePicture(captureOrientation: DeviceOrientation.portraitUp),
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'disposed'))); throwsA(isA<XCameraException>().having((e) => e.code, 'code', 'disposed')));
}); });
test('calls before initialize throw UxCameraException("not_initialized")', test('calls before initialize throw XCameraException("not_initialized")',
() async { () async {
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
expect(() => ctrl.setFlashMode(UxFlashMode.off), expect(() => ctrl.setFlashMode(XFlashMode.off),
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'not_initialized'))); throwsA(isA<XCameraException>().having((e) => e.code, 'code', 'not_initialized')));
}); });
test('multi-instance: two controllers get distinct handles + textures', test('multi-instance: two controllers get distinct handles + textures',
() async { () async {
final a = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); final a = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
final b = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false); final b = XCameraController(_back, XResolutionPreset.high, enableAudio: false);
addTearDown(a.dispose); addTearDown(a.dispose);
addTearDown(b.dispose); addTearDown(b.dispose);
@@ -259,7 +259,7 @@ void main() {
test('initialize captures audioPermissionGranted into value', () async { test('initialize captures audioPermissionGranted into value', () async {
fake.audioPermission = false; fake.audioPermission = false;
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: true);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
@@ -270,7 +270,7 @@ void main() {
test('refreshAudioPermission re-polls and updates value', () async { test('refreshAudioPermission re-polls and updates value', () async {
fake.audioPermission = false; fake.audioPermission = false;
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true); final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: true);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
await ctrl.initialize(); await ctrl.initialize();
expect(ctrl.value.audioPermissionGranted, isFalse); expect(ctrl.value.audioPermissionGranted, isFalse);
@@ -283,17 +283,27 @@ void main() {
}); });
test('openSystemSettings dispatches to the backend', () async { test('openSystemSettings dispatches to the backend', () async {
await UxCameraController.openSystemSettings(); await XCameraController.openSystemSettings();
expect(fake.openSettingsCalls, 1); expect(fake.openSettingsCalls, 1);
}); });
test('initialize propagates UxCameraException("permission_denied")', () async { test('requestAudioPermission returns the backend result', () async {
fake.createError = const UxCameraException('permission_denied', 'camera'); fake.requestAudioPermissionResult = false;
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false); expect(await XCameraController.requestAudioPermission(), isFalse);
expect(fake.requestAudioPermissionCalls, 1);
fake.requestAudioPermissionResult = true;
expect(await XCameraController.requestAudioPermission(), isTrue);
expect(fake.requestAudioPermissionCalls, 2);
});
test('initialize propagates XCameraException("permission_denied")', () async {
fake.createError = const XCameraException('permission_denied', 'camera');
final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
addTearDown(ctrl.dispose); addTearDown(ctrl.dispose);
expect(() => ctrl.initialize(), expect(() => ctrl.initialize(),
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'permission_denied'))); throwsA(isA<XCameraException>().having((e) => e.code, 'code', 'permission_denied')));
}); });
} }