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

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