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:
@@ -31,6 +31,8 @@ class CameraPlugin :
|
||||
|
||||
companion object {
|
||||
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())
|
||||
@@ -43,8 +45,17 @@ class CameraPlugin :
|
||||
|
||||
private var activity: Activity? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private var pendingPermission: ((Boolean, String) -> Unit)? = null
|
||||
private var pendingPermissionKind: String = ""
|
||||
private var pending: PendingPermission? = null
|
||||
|
||||
/// 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 var nextHandle = 1
|
||||
@@ -88,16 +99,12 @@ class CameraPlugin :
|
||||
if (code != PERMISSION_REQUEST_CODE) {
|
||||
return@addRequestPermissionsResultListener false
|
||||
}
|
||||
// Camera grant gates the whole flow; mic is optional (we
|
||||
// tolerate a missing audio input gracefully). Match iOS.
|
||||
val cameraIndex = permissions.indexOf(Manifest.permission.CAMERA)
|
||||
val cameraGranted = cameraIndex >= 0 &&
|
||||
results.getOrNull(cameraIndex) == PackageManager.PERMISSION_GRANTED
|
||||
val cb = pendingPermission
|
||||
pendingPermission = null
|
||||
val kind = pendingPermissionKind
|
||||
pendingPermissionKind = ""
|
||||
cb?.invoke(cameraGranted, if (cameraGranted) "" else kind)
|
||||
val p = pending ?: return@addRequestPermissionsResultListener true
|
||||
pending = null
|
||||
val idx = permissions.indexOf(p.primary)
|
||||
val granted = idx >= 0 &&
|
||||
results.getOrNull(idx) == PackageManager.PERMISSION_GRANTED
|
||||
p.cb(granted)
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -107,9 +114,8 @@ class CameraPlugin :
|
||||
activityBinding = null
|
||||
// If a request is still pending when the activity tears down,
|
||||
// settle it as denied so the Dart Future doesn't hang.
|
||||
pendingPermission?.invoke(false, pendingPermissionKind)
|
||||
pendingPermission = null
|
||||
pendingPermissionKind = ""
|
||||
pending?.cb?.invoke(false)
|
||||
pending = null
|
||||
}
|
||||
|
||||
// MARK: - EventChannel
|
||||
@@ -138,6 +144,7 @@ class CameraPlugin :
|
||||
"startVideoRecording" -> handleStartVideo(call, result)
|
||||
"stopVideoRecording" -> handleStopVideo(call, result)
|
||||
"audioPermissionStatus" -> result.success(isAudioGranted())
|
||||
"requestAudioPermission" -> handleRequestAudioPermission(result)
|
||||
"openSettings" -> handleOpenSettings(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
@@ -343,12 +350,16 @@ class CameraPlugin :
|
||||
private fun handleOpenSettings(result: MethodChannel.Result) {
|
||||
val act = activity
|
||||
?: 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 {
|
||||
data = Uri.fromParts("package", act.packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
act.startActivity(intent)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun isAudioGranted(): Boolean {
|
||||
@@ -358,6 +369,45 @@ class CameraPlugin :
|
||||
) == 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(
|
||||
perms: List<String>,
|
||||
cb: (Boolean, String) -> Unit,
|
||||
@@ -368,12 +418,25 @@ class CameraPlugin :
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (toRequest.isEmpty()) return cb(true, "")
|
||||
if (pendingPermission != null) {
|
||||
if (pending != null) {
|
||||
return cb(false, "camera") // serialize
|
||||
}
|
||||
pendingPermission = cb
|
||||
pendingPermissionKind = if (toRequest.contains(Manifest.permission.CAMERA))
|
||||
"camera" else "microphone"
|
||||
// Camera grant gates the session-init flow; mic is optional.
|
||||
val hasCamera = toRequest.contains(Manifest.permission.CAMERA)
|
||||
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(
|
||||
act,
|
||||
toRequest.toTypedArray(),
|
||||
|
||||
@@ -272,6 +272,27 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler {
|
||||
let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
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":
|
||||
DispatchQueue.main.async {
|
||||
// Per-platform helper: iOS opens app-specific Settings
|
||||
|
||||
@@ -5,19 +5,19 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Widget;
|
||||
|
||||
import '../file.dart' show UxFile;
|
||||
import '../file.dart' show XFile;
|
||||
import '../log.dart' show Log;
|
||||
import '../sensor.dart' show UxSensor;
|
||||
import '../sensor.dart' show XSensor;
|
||||
import 'camera_backend.dart';
|
||||
import 'camera_preview.dart' show UxCameraPreview;
|
||||
import 'camera_preview.dart' show XCameraPreview;
|
||||
|
||||
final _log = Log.tag('camera');
|
||||
|
||||
/// 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.
|
||||
class UxCameraDescription {
|
||||
const UxCameraDescription({
|
||||
class XCameraDescription {
|
||||
const XCameraDescription({
|
||||
required this.id,
|
||||
required this.lens,
|
||||
required this.sensorOrientation,
|
||||
@@ -28,7 +28,7 @@ class UxCameraDescription {
|
||||
/// interprets it.
|
||||
final String id;
|
||||
|
||||
final UxCameraLens lens;
|
||||
final XCameraLens lens;
|
||||
|
||||
/// Clockwise rotation in degrees (0/90/180/270) from the camera
|
||||
/// sensor's natural orientation to the device's portrait-up
|
||||
@@ -39,7 +39,7 @@ class UxCameraDescription {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UxCameraDescription &&
|
||||
other is XCameraDescription &&
|
||||
other.id == id &&
|
||||
other.lens == lens &&
|
||||
other.sensorOrientation == sensorOrientation;
|
||||
@@ -48,22 +48,22 @@ class UxCameraDescription {
|
||||
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
|
||||
/// (`high`); the enum exists so future presets can land without
|
||||
/// breaking call sites.
|
||||
enum UxResolutionPreset { high }
|
||||
enum XResolutionPreset { high }
|
||||
|
||||
/// Flash mode applied to the next still capture. Only the two values
|
||||
/// 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-
|
||||
/// orientation events from the native side.
|
||||
class UxCameraValue {
|
||||
const UxCameraValue({
|
||||
class XCameraValue {
|
||||
const XCameraValue({
|
||||
required this.description,
|
||||
this.previewSize,
|
||||
this.isInitialized = false,
|
||||
@@ -75,10 +75,10 @@ class UxCameraValue {
|
||||
this.errorDescription,
|
||||
});
|
||||
|
||||
factory UxCameraValue.uninitialized(UxCameraDescription d) =>
|
||||
UxCameraValue(description: d);
|
||||
factory XCameraValue.uninitialized(XCameraDescription d) =>
|
||||
XCameraValue(description: d);
|
||||
|
||||
final UxCameraDescription description;
|
||||
final XCameraDescription description;
|
||||
|
||||
/// Pixel dimensions of the active video format, in the camera
|
||||
/// 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
|
||||
/// the controller initialises and on
|
||||
/// [UxCameraController.refreshAudioPermission]. Independent of
|
||||
/// [XCameraController.refreshAudioPermission]. Independent of
|
||||
/// [enableAudio] — a controller can request audio (`enableAudio:
|
||||
/// true`) without having permission, in which case recordings have
|
||||
/// 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
|
||||
/// the Surface and the consumer rotates — typically 1 for
|
||||
/// `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.
|
||||
final int previewRotationQuarterTurns;
|
||||
|
||||
@@ -120,8 +120,8 @@ class UxCameraValue {
|
||||
|
||||
bool get hasError => errorDescription != null;
|
||||
|
||||
UxCameraValue copyWith({
|
||||
UxCameraDescription? description,
|
||||
XCameraValue copyWith({
|
||||
XCameraDescription? description,
|
||||
Size? previewSize,
|
||||
bool? isInitialized,
|
||||
bool? isRecordingVideo,
|
||||
@@ -131,7 +131,7 @@ class UxCameraValue {
|
||||
int? previewRotationQuarterTurns,
|
||||
Object? errorDescription = _unset,
|
||||
}) =>
|
||||
UxCameraValue(
|
||||
XCameraValue(
|
||||
description: description ?? this.description,
|
||||
previewSize: previewSize ?? this.previewSize,
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
@@ -149,24 +149,24 @@ class UxCameraValue {
|
||||
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
|
||||
/// `~/banlu/plans/ux_camera.md` §error-model for the full set.
|
||||
class UxCameraException implements Exception {
|
||||
const UxCameraException(this.code, [this.description]);
|
||||
class XCameraException implements Exception {
|
||||
const XCameraException(this.code, [this.description]);
|
||||
|
||||
final String code;
|
||||
final String? description;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UxCameraException($code${description == null ? '' : ': $description'})';
|
||||
'XCameraException($code${description == null ? '' : ': $description'})';
|
||||
}
|
||||
|
||||
/// Enumerate the cameras the platform exposes. Stable for the lifetime
|
||||
/// of the process; safe to cache the result.
|
||||
Future<List<UxCameraDescription>> uxAvailableCameras() =>
|
||||
UxCameraBackend.instance.availableCameras();
|
||||
Future<List<XCameraDescription>> uxAvailableCameras() =>
|
||||
XCameraBackend.instance.availableCameras();
|
||||
|
||||
/// Owns one native camera session. Mirrors the surface
|
||||
/// `package:camera`'s `CameraController` exposes today — every method
|
||||
@@ -174,29 +174,29 @@ Future<List<UxCameraDescription>> uxAvailableCameras() =>
|
||||
/// orientation snapshot is passed explicitly on capture calls.
|
||||
///
|
||||
/// Lifecycle: [initialize] → use → [dispose]. After [dispose] every
|
||||
/// other method throws `UxCameraException("disposed")`.
|
||||
class UxCameraController extends ValueNotifier<UxCameraValue> {
|
||||
UxCameraController(
|
||||
UxCameraDescription description,
|
||||
/// other method throws `XCameraException("disposed")`.
|
||||
class XCameraController extends ValueNotifier<XCameraValue> {
|
||||
XCameraController(
|
||||
XCameraDescription description,
|
||||
this.resolutionPreset, {
|
||||
required bool enableAudio,
|
||||
}) : super(UxCameraValue(
|
||||
}) : super(XCameraValue(
|
||||
description: description,
|
||||
enableAudio: enableAudio,
|
||||
));
|
||||
|
||||
final UxResolutionPreset resolutionPreset;
|
||||
final XResolutionPreset resolutionPreset;
|
||||
|
||||
int? _handle;
|
||||
int? _textureId;
|
||||
StreamSubscription<UxCameraEvent>? _eventsSub;
|
||||
StreamSubscription<XCameraEvent>? _eventsSub;
|
||||
bool _disposed = false;
|
||||
|
||||
UxCameraDescription get description => value.description;
|
||||
XCameraDescription get description => value.description;
|
||||
bool get enableAudio => value.enableAudio;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Configure the native session and begin streaming preview frames.
|
||||
@@ -206,22 +206,22 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
|
||||
Future<void> initialize() async {
|
||||
_throwIfDisposed('initialize');
|
||||
try {
|
||||
final result = await UxCameraBackend.instance.create(
|
||||
final result = await XCameraBackend.instance.create(
|
||||
cameraId: description.id,
|
||||
enableAudio: value.enableAudio,
|
||||
preset: resolutionPreset,
|
||||
);
|
||||
_handle = result.handle;
|
||||
_textureId = result.textureId;
|
||||
_eventsSub = UxCameraBackend.instance.events(result.handle).listen(
|
||||
_eventsSub = XCameraBackend.instance.events(result.handle).listen(
|
||||
_onEvent,
|
||||
onError: (Object error, StackTrace? stack) {
|
||||
value = value.copyWith(errorDescription: error.toString());
|
||||
},
|
||||
);
|
||||
await UxCameraBackend.instance.initialize(result.handle);
|
||||
await XCameraBackend.instance.initialize(result.handle);
|
||||
final audioGranted =
|
||||
await UxCameraBackend.instance.audioPermissionGranted();
|
||||
await XCameraBackend.instance.audioPermissionGranted();
|
||||
value = value.copyWith(
|
||||
isInitialized: true,
|
||||
previewSize: result.previewSize,
|
||||
@@ -239,18 +239,18 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(UxCameraEvent event) {
|
||||
void _onEvent(XCameraEvent event) {
|
||||
switch (event) {
|
||||
case UxCameraDeviceOrientationChanged(:final orientation):
|
||||
case XCameraDeviceOrientationChanged(:final orientation):
|
||||
value = value.copyWith(deviceOrientation: orientation);
|
||||
case UxCameraSessionError(:final code, :final description):
|
||||
case XCameraSessionError(:final code, :final description):
|
||||
value = value.copyWith(errorDescription: description ?? code);
|
||||
case UxCameraSessionInterrupted():
|
||||
case UxCameraSessionResumed():
|
||||
case XCameraSessionInterrupted():
|
||||
case XCameraSessionResumed():
|
||||
break;
|
||||
case UxCameraDiagnostic(:final message):
|
||||
case XCameraDiagnostic(:final message):
|
||||
_log.i('recorder: $message');
|
||||
case UxCameraPreviewSizeChanged(:final previewSize):
|
||||
case XCameraPreviewSizeChanged(:final previewSize):
|
||||
value = value.copyWith(previewSize: previewSize);
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
|
||||
final sub = _eventsSub;
|
||||
_eventsSub = null;
|
||||
if (handle != null) {
|
||||
await UxCameraBackend.instance.disposeInstance(handle);
|
||||
await XCameraBackend.instance.disposeInstance(handle);
|
||||
}
|
||||
await sub?.cancel();
|
||||
super.dispose();
|
||||
@@ -272,9 +272,9 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
|
||||
|
||||
/// Switch lenses without tearing down the controller. Blocked while a
|
||||
/// recording is in flight — call [stopVideoRecording] first.
|
||||
Future<void> setDescription(UxCameraDescription description) async {
|
||||
Future<void> setDescription(XCameraDescription description) async {
|
||||
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(
|
||||
description: description,
|
||||
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');
|
||||
await UxCameraBackend.instance.setFlashMode(handle, mode);
|
||||
await XCameraBackend.instance.setFlashMode(handle, mode);
|
||||
}
|
||||
|
||||
Future<void> lockCaptureOrientation(DeviceOrientation orientation) async {
|
||||
final handle = _requireHandle('lockCaptureOrientation');
|
||||
await UxCameraBackend.instance.lockCaptureOrientation(handle, orientation);
|
||||
await XCameraBackend.instance.lockCaptureOrientation(handle, orientation);
|
||||
}
|
||||
|
||||
Future<void> unlockCaptureOrientation() async {
|
||||
final handle = _requireHandle('unlockCaptureOrientation');
|
||||
await UxCameraBackend.instance.unlockCaptureOrientation(handle);
|
||||
await XCameraBackend.instance.unlockCaptureOrientation(handle);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// deterministic.
|
||||
Future<UxFile> takePicture({DeviceOrientation? captureOrientation}) async {
|
||||
Future<XFile> takePicture({DeviceOrientation? captureOrientation}) async {
|
||||
final handle = _requireHandle('takePicture');
|
||||
final orientation = captureOrientation ?? UxSensor.orientation;
|
||||
return UxCameraBackend.instance.takePicture(handle, orientation);
|
||||
final orientation = captureOrientation ?? XSensor.orientation;
|
||||
return XCameraBackend.instance.takePicture(handle, orientation);
|
||||
}
|
||||
|
||||
/// Start recording. [captureOrientation] becomes the video file's
|
||||
/// rotation transform (iOS: AVAssetWriterInput.transform; Android:
|
||||
/// CameraX targetRotation). Defaults to [UxSensor.orientation] —
|
||||
/// CameraX targetRotation). Defaults to [XSensor.orientation] —
|
||||
/// pass explicitly from tests.
|
||||
Future<void> startVideoRecording({
|
||||
DeviceOrientation? captureOrientation,
|
||||
}) async {
|
||||
final handle = _requireHandle('startVideoRecording');
|
||||
final orientation = captureOrientation ?? UxSensor.orientation;
|
||||
await UxCameraBackend.instance.startVideoRecording(handle, orientation);
|
||||
final orientation = captureOrientation ?? XSensor.orientation;
|
||||
await XCameraBackend.instance.startVideoRecording(handle, orientation);
|
||||
value = value.copyWith(isRecordingVideo: true);
|
||||
}
|
||||
|
||||
Future<UxFile> stopVideoRecording() async {
|
||||
Future<XFile> stopVideoRecording() async {
|
||||
final handle = _requireHandle('stopVideoRecording');
|
||||
final file = await UxCameraBackend.instance.stopVideoRecording(handle);
|
||||
final file = await XCameraBackend.instance.stopVideoRecording(handle);
|
||||
value = value.copyWith(isRecordingVideo: false);
|
||||
return file;
|
||||
}
|
||||
@@ -332,7 +332,7 @@ class UxCameraController extends ValueNotifier<UxCameraValue> {
|
||||
/// `AppLifecycleState.resumed` to pick up grants made via Settings.
|
||||
Future<void> refreshAudioPermission() async {
|
||||
_throwIfDisposed('refreshAudioPermission');
|
||||
final granted = await UxCameraBackend.instance.audioPermissionGranted();
|
||||
final granted = await XCameraBackend.instance.audioPermissionGranted();
|
||||
if (granted != value.audioPermissionGranted) {
|
||||
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.
|
||||
/// the camera page's "Tap to enable mic" banner).
|
||||
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
|
||||
/// mic permission. Static because it doesn't depend on any active
|
||||
/// controller — useful from the banner tap before the controller
|
||||
/// has finished initialising.
|
||||
static Future<void> openSystemSettings() =>
|
||||
UxCameraBackend.instance.openSettings();
|
||||
XCameraBackend.instance.openSettings();
|
||||
|
||||
/// Texture-backed widget that renders the live preview at its parent's
|
||||
/// size. Hero-flightable.
|
||||
Widget buildPreview() => UxCameraPreview(controller: this);
|
||||
Widget buildPreview() => XCameraPreview(controller: this);
|
||||
|
||||
int _requireHandle(String op) {
|
||||
_throwIfDisposed(op);
|
||||
final h = _handle;
|
||||
if (h == null) {
|
||||
throw const UxCameraException('not_initialized');
|
||||
throw const XCameraException('not_initialized');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
void _throwIfDisposed(String op) {
|
||||
if (_disposed) {
|
||||
throw UxCameraException('disposed', '$op called on a disposed controller');
|
||||
throw XCameraException('disposed', '$op called on a disposed controller');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,43 +2,43 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../file.dart' show UxFile;
|
||||
import 'camera.dart' show UxCameraDescription, UxFlashMode, UxResolutionPreset;
|
||||
import 'camera_channel.dart' show MethodChannelUxCameraBackend;
|
||||
import '../file.dart' show XFile;
|
||||
import 'camera.dart' show XCameraDescription, XFlashMode, XResolutionPreset;
|
||||
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` /
|
||||
/// `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
|
||||
/// the plugin can route to the right native session. Multiple
|
||||
/// controllers can hold simultaneous handles.
|
||||
abstract class UxCameraBackend {
|
||||
abstract class XCameraBackend {
|
||||
/// 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
|
||||
/// of the process.
|
||||
Future<List<UxCameraDescription>> availableCameras();
|
||||
Future<List<XCameraDescription>> availableCameras();
|
||||
|
||||
/// Allocate a native camera instance bound to [cameraId]. Returns the
|
||||
/// handle (for subsequent calls), the FlutterTexture id (for the
|
||||
/// preview widget), and the sensor-natural-orientation preview size.
|
||||
///
|
||||
/// Throws [UxCameraException("device_busy")] if another instance
|
||||
/// already holds the device, or [UxCameraException("audio_busy")]
|
||||
/// Throws [XCameraException("device_busy")] if another instance
|
||||
/// already holds the device, or [XCameraException("audio_busy")]
|
||||
/// when [enableAudio] is true and another instance holds the
|
||||
/// app-global audio session.
|
||||
Future<UxCameraCreateResult> create({
|
||||
Future<XCameraCreateResult> create({
|
||||
required String cameraId,
|
||||
required bool enableAudio,
|
||||
required UxResolutionPreset preset,
|
||||
required XResolutionPreset preset,
|
||||
});
|
||||
|
||||
/// Start the session. Native side resolves camera + audio permissions
|
||||
/// before the future completes. Throws
|
||||
/// [UxCameraException("permission_denied")] on denial.
|
||||
/// [XCameraException("permission_denied")] on denial.
|
||||
Future<void> initialize(int handle);
|
||||
|
||||
/// 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
|
||||
/// any pending recording. Returns the new preview size and rotation;
|
||||
/// 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(
|
||||
int handle,
|
||||
String cameraId,
|
||||
@@ -58,7 +58,7 @@ abstract class UxCameraBackend {
|
||||
/// Set the flash mode used for the next [takePicture]. On front cameras
|
||||
/// without a screen-flash fallback the backend silently no-ops; the
|
||||
/// 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
|
||||
/// 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
|
||||
/// connection just before capture so the file's EXIF orientation
|
||||
/// 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
|
||||
/// 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.
|
||||
Future<UxFile> stopVideoRecording(int handle);
|
||||
Future<XFile> stopVideoRecording(int handle);
|
||||
|
||||
/// Live event stream for [handle]: device-orientation changes,
|
||||
/// session errors, interrupted/resumed lifecycle pings. The
|
||||
/// controller subscribes during [initialize] and unsubscribes on
|
||||
/// [disposeInstance].
|
||||
Stream<UxCameraEvent> events(int handle);
|
||||
Stream<XCameraEvent> events(int handle);
|
||||
|
||||
/// True iff the user has granted microphone access. Cheap; safe to
|
||||
/// re-poll on app foregrounding to detect grants made via Settings.
|
||||
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
|
||||
/// expected to refresh [audioPermissionGranted] on
|
||||
/// `AppLifecycleState.resumed`.
|
||||
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
|
||||
/// subsequent calls.
|
||||
class UxCameraCreateResult {
|
||||
const UxCameraCreateResult({
|
||||
class XCameraCreateResult {
|
||||
const XCameraCreateResult({
|
||||
required this.handle,
|
||||
required this.textureId,
|
||||
required this.previewSize,
|
||||
@@ -118,7 +127,7 @@ class UxCameraCreateResult {
|
||||
/// Initial preview size; may be `Size.zero` when the native side
|
||||
/// can't determine it synchronously (Android CameraX needs the
|
||||
/// first `SurfaceRequest` to fire before it knows). In that case
|
||||
/// a [UxCameraPreviewSizeChanged] event follows.
|
||||
/// a [XCameraPreviewSizeChanged] event follows.
|
||||
final Size previewSize;
|
||||
|
||||
/// 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 —
|
||||
/// new variants land here as the contract grows.
|
||||
sealed class UxCameraEvent {
|
||||
const UxCameraEvent(this.handle);
|
||||
sealed class XCameraEvent {
|
||||
const XCameraEvent(this.handle);
|
||||
final int handle;
|
||||
}
|
||||
|
||||
class UxCameraDeviceOrientationChanged extends UxCameraEvent {
|
||||
const UxCameraDeviceOrientationChanged(super.handle, this.orientation);
|
||||
class XCameraDeviceOrientationChanged extends XCameraEvent {
|
||||
const XCameraDeviceOrientationChanged(super.handle, this.orientation);
|
||||
final DeviceOrientation orientation;
|
||||
}
|
||||
|
||||
class UxCameraSessionError extends UxCameraEvent {
|
||||
const UxCameraSessionError(super.handle, this.code, this.description);
|
||||
class XCameraSessionError extends XCameraEvent {
|
||||
const XCameraSessionError(super.handle, this.code, this.description);
|
||||
final String code;
|
||||
final String? description;
|
||||
}
|
||||
|
||||
class UxCameraSessionInterrupted extends UxCameraEvent {
|
||||
const UxCameraSessionInterrupted(super.handle, this.reason);
|
||||
class XCameraSessionInterrupted extends XCameraEvent {
|
||||
const XCameraSessionInterrupted(super.handle, this.reason);
|
||||
final String reason;
|
||||
}
|
||||
|
||||
class UxCameraSessionResumed extends UxCameraEvent {
|
||||
const UxCameraSessionResumed(super.handle);
|
||||
class XCameraSessionResumed extends XCameraEvent {
|
||||
const XCameraSessionResumed(super.handle);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// iOS doesn't emit it (the size lands in the `create` result
|
||||
/// synchronously from `device.activeFormat`).
|
||||
class UxCameraPreviewSizeChanged extends UxCameraEvent {
|
||||
const UxCameraPreviewSizeChanged(super.handle, this.previewSize);
|
||||
class XCameraPreviewSizeChanged extends XCameraEvent {
|
||||
const XCameraPreviewSizeChanged(super.handle, this.previewSize);
|
||||
final Size previewSize;
|
||||
}
|
||||
|
||||
/// Free-text diagnostic message from the native recorder. Routed by
|
||||
/// the controller to `Log.tag('camera').i(...)` so it lands in the
|
||||
/// log_server pipeline (`~/banlu/tools/log_server/data/banlu.jsonl`).
|
||||
class UxCameraDiagnostic extends UxCameraEvent {
|
||||
const UxCameraDiagnostic(super.handle, this.message);
|
||||
class XCameraDiagnostic extends XCameraEvent {
|
||||
const XCameraDiagnostic(super.handle, this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
@@ -2,23 +2,23 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../file.dart' show UxFile;
|
||||
import '../file.dart' show XFile;
|
||||
import '../log.dart' show Log;
|
||||
import 'camera.dart'
|
||||
show
|
||||
UxCameraDescription,
|
||||
UxCameraException,
|
||||
UxCameraLens,
|
||||
UxFlashMode,
|
||||
UxResolutionPreset;
|
||||
XCameraDescription,
|
||||
XCameraException,
|
||||
XCameraLens,
|
||||
XFlashMode,
|
||||
XResolutionPreset;
|
||||
import 'camera_backend.dart';
|
||||
|
||||
final _log = Log.tag('camera');
|
||||
|
||||
/// Production [UxCameraBackend]. Hand-rolled MethodChannel +
|
||||
/// Production [XCameraBackend]. Hand-rolled MethodChannel +
|
||||
/// EventChannel — matches the rest of `package:ux`, no pigeon.
|
||||
class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
MethodChannelUxCameraBackend();
|
||||
class MethodChannelXCameraBackend implements XCameraBackend {
|
||||
MethodChannelXCameraBackend();
|
||||
|
||||
static const _channel = MethodChannel('ux/camera');
|
||||
static const _eventsChannel = EventChannel('ux/camera/events');
|
||||
@@ -43,11 +43,11 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
}();
|
||||
|
||||
@override
|
||||
Future<List<UxCameraDescription>> availableCameras() async {
|
||||
Future<List<XCameraDescription>> availableCameras() async {
|
||||
final raw = await _invoke<List<Object?>>('availableCameras');
|
||||
return raw.map((e) {
|
||||
final m = (e as Map).cast<Object?, Object?>();
|
||||
return UxCameraDescription(
|
||||
return XCameraDescription(
|
||||
id: m['id'] as String,
|
||||
lens: _parseLens(m['lens'] as String?),
|
||||
sensorOrientation: (m['sensorOrientation'] as num?)?.toInt() ?? 0,
|
||||
@@ -56,10 +56,10 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxCameraCreateResult> create({
|
||||
Future<XCameraCreateResult> create({
|
||||
required String cameraId,
|
||||
required bool enableAudio,
|
||||
required UxResolutionPreset preset,
|
||||
required XResolutionPreset preset,
|
||||
}) async {
|
||||
final m = await _invokeMap('create', {
|
||||
'cameraId': cameraId,
|
||||
@@ -67,7 +67,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
'preset': _presetArg(preset),
|
||||
});
|
||||
final size = (m['previewSize'] as Map).cast<Object?, Object?>();
|
||||
return UxCameraCreateResult(
|
||||
return XCameraCreateResult(
|
||||
handle: (m['handle'] as num).toInt(),
|
||||
textureId: (m['textureId'] as num).toInt(),
|
||||
previewSize: Size(
|
||||
@@ -108,7 +108,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFlashMode(int handle, UxFlashMode mode) =>
|
||||
Future<void> setFlashMode(int handle, XFlashMode mode) =>
|
||||
_invokeVoid('setFlashMode', {
|
||||
'handle': handle,
|
||||
'mode': _flashArg(mode),
|
||||
@@ -129,7 +129,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
_invokeVoid('unlockCaptureOrientation', {'handle': handle});
|
||||
|
||||
@override
|
||||
Future<UxFile> takePicture(
|
||||
Future<XFile> takePicture(
|
||||
int handle,
|
||||
DeviceOrientation snapshotOrientation,
|
||||
) async {
|
||||
@@ -137,7 +137,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
'handle': handle,
|
||||
'snapshotOrientation': _orientationArg(snapshotOrientation),
|
||||
});
|
||||
return UxFile(m['path'] as String);
|
||||
return XFile(m['path'] as String);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -151,19 +151,22 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
});
|
||||
|
||||
@override
|
||||
Future<UxFile> stopVideoRecording(int handle) async {
|
||||
Future<XFile> stopVideoRecording(int handle) async {
|
||||
final m = await _invokeMap('stopVideoRecording', {'handle': handle});
|
||||
return UxFile(m['path'] as String);
|
||||
return XFile(m['path'] as String);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> audioPermissionGranted() => _invoke<bool>('audioPermissionStatus');
|
||||
|
||||
@override
|
||||
Future<bool> requestAudioPermission() => _invoke<bool>('requestAudioPermission');
|
||||
|
||||
@override
|
||||
Future<void> openSettings() => _invokeVoid('openSettings');
|
||||
|
||||
@override
|
||||
Stream<UxCameraEvent> events(int handle) {
|
||||
Stream<XCameraEvent> events(int handle) {
|
||||
return _rawEvents
|
||||
.map((e) => (e as Map).cast<Object?, Object?>())
|
||||
.where((m) => (m['handle'] as num).toInt() == handle)
|
||||
@@ -172,35 +175,35 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
|
||||
// ---- parsers / arg encoders -------------------------------------
|
||||
|
||||
static UxCameraEvent _decodeEvent(Map<Object?, Object?> m) {
|
||||
static XCameraEvent _decodeEvent(Map<Object?, Object?> m) {
|
||||
final handle = (m['handle'] as num).toInt();
|
||||
switch (m['event'] as String?) {
|
||||
case 'deviceOrientationChanged':
|
||||
return UxCameraDeviceOrientationChanged(
|
||||
return XCameraDeviceOrientationChanged(
|
||||
handle,
|
||||
_parseOrientation(m['orientation'] as String?),
|
||||
);
|
||||
case 'sessionError':
|
||||
return UxCameraSessionError(
|
||||
return XCameraSessionError(
|
||||
handle,
|
||||
m['code'] as String? ?? 'session_runtime_error',
|
||||
m['description'] as String?,
|
||||
);
|
||||
case 'sessionInterrupted':
|
||||
return UxCameraSessionInterrupted(
|
||||
return XCameraSessionInterrupted(
|
||||
handle,
|
||||
m['reason'] as String? ?? '',
|
||||
);
|
||||
case 'sessionResumed':
|
||||
return UxCameraSessionResumed(handle);
|
||||
return XCameraSessionResumed(handle);
|
||||
case 'diagnostic':
|
||||
return UxCameraDiagnostic(
|
||||
return XCameraDiagnostic(
|
||||
handle,
|
||||
m['message'] as String? ?? '',
|
||||
);
|
||||
case 'previewSizeChanged':
|
||||
final s = (m['previewSize'] as Map).cast<Object?, Object?>();
|
||||
return UxCameraPreviewSizeChanged(
|
||||
return XCameraPreviewSizeChanged(
|
||||
handle,
|
||||
Size(
|
||||
(s['width'] as num).toDouble(),
|
||||
@@ -208,13 +211,13 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
),
|
||||
);
|
||||
default:
|
||||
return UxCameraSessionError(handle, 'unknown_event', null);
|
||||
return XCameraSessionError(handle, 'unknown_event', null);
|
||||
}
|
||||
}
|
||||
|
||||
static UxCameraLens _parseLens(String? raw) => switch (raw) {
|
||||
'front' => UxCameraLens.front,
|
||||
_ => UxCameraLens.back,
|
||||
static XCameraLens _parseLens(String? raw) => switch (raw) {
|
||||
'front' => XCameraLens.front,
|
||||
_ => XCameraLens.back,
|
||||
};
|
||||
|
||||
static DeviceOrientation _parseOrientation(String? raw) => switch (raw) {
|
||||
@@ -231,13 +234,13 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
DeviceOrientation.landscapeRight => 'landscapeRight',
|
||||
};
|
||||
|
||||
static String _flashArg(UxFlashMode m) => switch (m) {
|
||||
UxFlashMode.off => 'off',
|
||||
UxFlashMode.always => 'always',
|
||||
static String _flashArg(XFlashMode m) => switch (m) {
|
||||
XFlashMode.off => 'off',
|
||||
XFlashMode.always => 'always',
|
||||
};
|
||||
|
||||
static String _presetArg(UxResolutionPreset p) => switch (p) {
|
||||
UxResolutionPreset.high => 'high',
|
||||
static String _presetArg(XResolutionPreset p) => switch (p) {
|
||||
XResolutionPreset.high => 'high',
|
||||
};
|
||||
|
||||
// ---- channel adapter --------------------------------------------
|
||||
@@ -247,7 +250,7 @@ class MethodChannelUxCameraBackend implements UxCameraBackend {
|
||||
final result = await _channel.invokeMethod<Object?>(method, args);
|
||||
return result as T;
|
||||
} 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 {
|
||||
await _channel.invokeMethod<void>(method, args);
|
||||
} on PlatformException catch (e) {
|
||||
throw UxCameraException(e.code, e.message);
|
||||
throw XCameraException(e.code, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@ import 'dart:ui' show Size;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ux/src/camera/camera.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
|
||||
/// `UxCameraBackend.instance = FakeUxCameraBackend(...)` before any UI
|
||||
/// mounts; restore with `UxCameraBackend.instance =
|
||||
/// MethodChannelUxCameraBackend()` in `tearDown`.
|
||||
/// In-memory backend for [XCameraController] tests. Swap in via
|
||||
/// `XCameraBackend.instance = FakeXCameraBackend(...)` before any UI
|
||||
/// mounts; restore with `XCameraBackend.instance =
|
||||
/// MethodChannelXCameraBackend()` in `tearDown`.
|
||||
///
|
||||
/// Captures every call into per-method lists for assertions, and lets
|
||||
/// tests drive events deterministically via [emitOrientationChanged]
|
||||
/// and friends.
|
||||
class FakeUxCameraBackend implements UxCameraBackend {
|
||||
FakeUxCameraBackend({
|
||||
List<UxCameraDescription> cameras = const [],
|
||||
class FakeXCameraBackend implements XCameraBackend {
|
||||
FakeXCameraBackend({
|
||||
List<XCameraDescription> cameras = const [],
|
||||
this.previewSize = const Size(1920, 1080),
|
||||
this.picturePath = '/tmp/fake_picture.jpg',
|
||||
this.videoPath = '/tmp/fake_video.mp4',
|
||||
@@ -24,12 +24,12 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
|
||||
// ---- captured calls ---------------------------------------------
|
||||
|
||||
final List<({String cameraId, bool enableAudio, UxResolutionPreset preset})>
|
||||
final List<({String cameraId, bool enableAudio, XResolutionPreset preset})>
|
||||
createCalls = [];
|
||||
final List<int> initializeCalls = [];
|
||||
final List<int> disposeCalls = [];
|
||||
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})>
|
||||
lockCaptureOrientationCalls = [];
|
||||
final List<int> unlockCaptureOrientationCalls = [];
|
||||
@@ -44,38 +44,43 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
|
||||
/// Cameras returned by [availableCameras]. Mutable so tests can swap
|
||||
/// the set between assertions.
|
||||
List<UxCameraDescription> cameras;
|
||||
List<XCameraDescription> cameras;
|
||||
Size previewSize;
|
||||
String picturePath;
|
||||
String videoPath;
|
||||
|
||||
/// If non-null, [create] throws this exception instead of returning
|
||||
/// a result — exercises permission denial / device busy paths.
|
||||
UxCameraException? createError;
|
||||
XCameraException? createError;
|
||||
|
||||
/// Optional override; set to throw on any particular method to
|
||||
/// drive failure paths.
|
||||
UxCameraException? initializeError;
|
||||
UxCameraException? takePictureError;
|
||||
UxCameraException? startVideoRecordingError;
|
||||
UxCameraException? stopVideoRecordingError;
|
||||
XCameraException? initializeError;
|
||||
XCameraException? takePictureError;
|
||||
XCameraException? startVideoRecordingError;
|
||||
XCameraException? stopVideoRecordingError;
|
||||
|
||||
/// Audio permission state returned by [audioPermissionGranted].
|
||||
/// Tests mutate this to drive the mic-permission UI banner.
|
||||
bool audioPermission = true;
|
||||
int audioPermissionCalls = 0;
|
||||
int openSettingsCalls = 0;
|
||||
int requestAudioPermissionCalls = 0;
|
||||
|
||||
/// Result returned by [requestAudioPermission]. Tests mutate to
|
||||
/// exercise grant / permanent-denial paths.
|
||||
bool requestAudioPermissionResult = true;
|
||||
|
||||
// ---- internal ---------------------------------------------------
|
||||
|
||||
int _nextHandle = 1;
|
||||
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(
|
||||
handle,
|
||||
() => StreamController<UxCameraEvent>.broadcast(),
|
||||
() => StreamController<XCameraEvent>.broadcast(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,38 +90,38 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
/// subscribed controller will update its `value.deviceOrientation`.
|
||||
void emitOrientationChanged(int handle, DeviceOrientation orientation) {
|
||||
_controllerFor(handle)
|
||||
.add(UxCameraDeviceOrientationChanged(handle, orientation));
|
||||
.add(XCameraDeviceOrientationChanged(handle, orientation));
|
||||
}
|
||||
|
||||
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) {
|
||||
_controllerFor(handle).add(UxCameraSessionInterrupted(handle, reason));
|
||||
_controllerFor(handle).add(XCameraSessionInterrupted(handle, reason));
|
||||
}
|
||||
|
||||
void emitSessionResumed(int handle) {
|
||||
_controllerFor(handle).add(UxCameraSessionResumed(handle));
|
||||
_controllerFor(handle).add(XCameraSessionResumed(handle));
|
||||
}
|
||||
|
||||
void emitDiagnostic(int handle, String message) {
|
||||
_controllerFor(handle).add(UxCameraDiagnostic(handle, message));
|
||||
_controllerFor(handle).add(XCameraDiagnostic(handle, message));
|
||||
}
|
||||
|
||||
// ---- UxCameraBackend impl --------------------------------------
|
||||
// ---- XCameraBackend impl --------------------------------------
|
||||
|
||||
@override
|
||||
Future<List<UxCameraDescription>> availableCameras() async {
|
||||
Future<List<XCameraDescription>> availableCameras() async {
|
||||
availableCamerasCalls += 1;
|
||||
return cameras;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxCameraCreateResult> create({
|
||||
Future<XCameraCreateResult> create({
|
||||
required String cameraId,
|
||||
required bool enableAudio,
|
||||
required UxResolutionPreset preset,
|
||||
required XResolutionPreset preset,
|
||||
}) async {
|
||||
createCalls.add((
|
||||
cameraId: cameraId,
|
||||
@@ -127,7 +132,7 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
if (err != null) throw err;
|
||||
final handle = _nextHandle++;
|
||||
final textureId = _nextTextureId++;
|
||||
return UxCameraCreateResult(
|
||||
return XCameraCreateResult(
|
||||
handle: handle,
|
||||
textureId: textureId,
|
||||
previewSize: previewSize,
|
||||
@@ -159,11 +164,11 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
/// Push a `previewSizeChanged` event for [handle]. Tests that need
|
||||
/// to simulate Android's async preview-size resolve drive this.
|
||||
void emitPreviewSizeChanged(int handle, Size size) {
|
||||
_controllerFor(handle).add(UxCameraPreviewSizeChanged(handle, size));
|
||||
_controllerFor(handle).add(XCameraPreviewSizeChanged(handle, size));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setFlashMode(int handle, UxFlashMode mode) async {
|
||||
Future<void> setFlashMode(int handle, XFlashMode mode) async {
|
||||
setFlashModeCalls.add((handle: handle, mode: mode));
|
||||
}
|
||||
|
||||
@@ -182,7 +187,7 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxFile> takePicture(
|
||||
Future<XFile> takePicture(
|
||||
int handle,
|
||||
DeviceOrientation snapshotOrientation,
|
||||
) async {
|
||||
@@ -190,7 +195,7 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
.add((handle: handle, snapshotOrientation: snapshotOrientation));
|
||||
final err = takePictureError;
|
||||
if (err != null) throw err;
|
||||
return UxFile(picturePath);
|
||||
return XFile(picturePath);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -205,15 +210,15 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxFile> stopVideoRecording(int handle) async {
|
||||
Future<XFile> stopVideoRecording(int handle) async {
|
||||
stopVideoRecordingCalls.add(handle);
|
||||
final err = stopVideoRecordingError;
|
||||
if (err != null) throw err;
|
||||
return UxFile(videoPath);
|
||||
return XFile(videoPath);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<UxCameraEvent> events(int handle) => _controllerFor(handle).stream;
|
||||
Stream<XCameraEvent> events(int handle) => _controllerFor(handle).stream;
|
||||
|
||||
@override
|
||||
Future<bool> audioPermissionGranted() async {
|
||||
@@ -221,6 +226,13 @@ class FakeUxCameraBackend implements UxCameraBackend {
|
||||
return audioPermission;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> requestAudioPermission() async {
|
||||
requestAudioPermissionCalls += 1;
|
||||
if (requestAudioPermissionResult) audioPermission = true;
|
||||
return requestAudioPermissionResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {
|
||||
openSettingsCalls += 1;
|
||||
|
||||
@@ -3,29 +3,29 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ux/testing.dart';
|
||||
import 'package:ux/ux.dart';
|
||||
|
||||
const _front = UxCameraDescription(
|
||||
const _front = XCameraDescription(
|
||||
id: 'front',
|
||||
lens: UxCameraLens.front,
|
||||
lens: XCameraLens.front,
|
||||
sensorOrientation: 270,
|
||||
);
|
||||
const _back = UxCameraDescription(
|
||||
const _back = XCameraDescription(
|
||||
id: 'back',
|
||||
lens: UxCameraLens.back,
|
||||
lens: XCameraLens.back,
|
||||
sensorOrientation: 90,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late FakeUxCameraBackend fake;
|
||||
late FakeXCameraBackend fake;
|
||||
|
||||
setUp(() {
|
||||
fake = FakeUxCameraBackend(cameras: const [_front, _back]);
|
||||
UxCameraBackend.instance = fake;
|
||||
fake = FakeXCameraBackend(cameras: const [_front, _back]);
|
||||
XCameraBackend.instance = fake;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
UxCameraBackend.instance = MethodChannelUxCameraBackend();
|
||||
XCameraBackend.instance = MethodChannelXCameraBackend();
|
||||
});
|
||||
|
||||
test('uxAvailableCameras dispatches through the backend', () async {
|
||||
@@ -36,7 +36,7 @@ void main() {
|
||||
|
||||
test('initialize creates the native instance, subscribes to events, '
|
||||
'and reports previewSize / isInitialized', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
expect(ctrl.value.isInitialized, isFalse);
|
||||
@@ -45,7 +45,7 @@ void main() {
|
||||
await ctrl.initialize();
|
||||
|
||||
expect(fake.createCalls.single,
|
||||
(cameraId: 'front', enableAudio: false, preset: UxResolutionPreset.high));
|
||||
(cameraId: 'front', enableAudio: false, preset: XResolutionPreset.high));
|
||||
expect(fake.initializeCalls.single, 1);
|
||||
expect(ctrl.textureId, 100);
|
||||
expect(ctrl.value.isInitialized, isTrue);
|
||||
@@ -53,7 +53,7 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -79,7 +79,7 @@ void main() {
|
||||
);
|
||||
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);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -94,7 +94,7 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -105,7 +105,7 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -116,9 +116,9 @@ void main() {
|
||||
(handle: 1, snapshotOrientation: DeviceOrientation.landscapeRight));
|
||||
});
|
||||
|
||||
test('takePicture without captureOrientation falls back to UxSensor', () async {
|
||||
// UxSensor.orientation returns portraitUp under flutter_test (no native sensor).
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
test('takePicture without captureOrientation falls back to XSensor', () async {
|
||||
// XSensor.orientation returns portraitUp under flutter_test (no native sensor).
|
||||
final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -129,7 +129,7 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -141,7 +141,7 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
await ctrl.initialize();
|
||||
await ctrl.startVideoRecording(captureOrientation: DeviceOrientation.landscapeLeft);
|
||||
@@ -155,7 +155,7 @@ void main() {
|
||||
|
||||
test('previewSizeChanged events update value.previewSize without losing rotation',
|
||||
() async {
|
||||
final ctrl = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false);
|
||||
final ctrl = XCameraController(_back, XResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -170,7 +170,7 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -183,18 +183,18 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
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',
|
||||
() async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
|
||||
@@ -207,7 +207,7 @@ void main() {
|
||||
});
|
||||
|
||||
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.dispose();
|
||||
@@ -218,28 +218,28 @@ void main() {
|
||||
expect(fake.disposeCalls, [1]);
|
||||
});
|
||||
|
||||
test('calls against a disposed controller throw UxCameraException', () async {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
test('calls against a disposed controller throw XCameraException', () async {
|
||||
final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
|
||||
await ctrl.initialize();
|
||||
await ctrl.dispose();
|
||||
|
||||
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 {
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
expect(() => ctrl.setFlashMode(UxFlashMode.off),
|
||||
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'not_initialized')));
|
||||
expect(() => ctrl.setFlashMode(XFlashMode.off),
|
||||
throwsA(isA<XCameraException>().having((e) => e.code, 'code', 'not_initialized')));
|
||||
});
|
||||
|
||||
test('multi-instance: two controllers get distinct handles + textures',
|
||||
() async {
|
||||
final a = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
final b = UxCameraController(_back, UxResolutionPreset.high, enableAudio: false);
|
||||
final a = XCameraController(_front, XResolutionPreset.high, enableAudio: false);
|
||||
final b = XCameraController(_back, XResolutionPreset.high, enableAudio: false);
|
||||
addTearDown(a.dispose);
|
||||
addTearDown(b.dispose);
|
||||
|
||||
@@ -259,7 +259,7 @@ void main() {
|
||||
|
||||
test('initialize captures audioPermissionGranted into value', () async {
|
||||
fake.audioPermission = false;
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true);
|
||||
final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: true);
|
||||
addTearDown(ctrl.dispose);
|
||||
|
||||
await ctrl.initialize();
|
||||
@@ -270,7 +270,7 @@ void main() {
|
||||
|
||||
test('refreshAudioPermission re-polls and updates value', () async {
|
||||
fake.audioPermission = false;
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: true);
|
||||
final ctrl = XCameraController(_front, XResolutionPreset.high, enableAudio: true);
|
||||
addTearDown(ctrl.dispose);
|
||||
await ctrl.initialize();
|
||||
expect(ctrl.value.audioPermissionGranted, isFalse);
|
||||
@@ -283,17 +283,27 @@ void main() {
|
||||
});
|
||||
|
||||
test('openSystemSettings dispatches to the backend', () async {
|
||||
await UxCameraController.openSystemSettings();
|
||||
await XCameraController.openSystemSettings();
|
||||
expect(fake.openSettingsCalls, 1);
|
||||
});
|
||||
|
||||
test('initialize propagates UxCameraException("permission_denied")', () async {
|
||||
fake.createError = const UxCameraException('permission_denied', 'camera');
|
||||
final ctrl = UxCameraController(_front, UxResolutionPreset.high, enableAudio: false);
|
||||
test('requestAudioPermission returns the backend result', () async {
|
||||
fake.requestAudioPermissionResult = 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);
|
||||
|
||||
expect(() => ctrl.initialize(),
|
||||
throwsA(isA<UxCameraException>().having((e) => e.code, 'code', 'permission_denied')));
|
||||
throwsA(isA<XCameraException>().having((e) => e.code, 'code', 'permission_denied')));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user