fix(android): unblock mic permission startup
This commit is contained in:
+4
-3
@@ -108,7 +108,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
permissionsChannel = channel
|
permissionsChannel = channel
|
||||||
val requester = AndroidPermissionRequester()
|
val requester = AndroidPermissionRequester()
|
||||||
requester.stateChangeListener = { permission, state ->
|
requester.stateChangeListener = { permission, state ->
|
||||||
val stateName = state.toString()
|
val stateName = state.javaClass.simpleName
|
||||||
channel.invokeMethod(
|
channel.invokeMethod(
|
||||||
MethodChannels.METHOD_PERMISSION_STATE_CHANGED,
|
MethodChannels.METHOD_PERMISSION_STATE_CHANGED,
|
||||||
mapOf(
|
mapOf(
|
||||||
@@ -148,8 +148,9 @@ class MainActivity : FlutterActivity() {
|
|||||||
"requestRecordAudio" -> {
|
"requestRecordAudio" -> {
|
||||||
val r = permissionRequester
|
val r = permissionRequester
|
||||||
if (r != null) {
|
if (r != null) {
|
||||||
r.ensureRecordAudioPermission(this) { _ -> }
|
r.ensureRecordAudioPermission(this) { state ->
|
||||||
result.success(null)
|
result.success(state.javaClass.simpleName)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result.error(
|
result.error(
|
||||||
"no_requester",
|
"no_requester",
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
bool _inChannel = false;
|
bool _inChannel = false;
|
||||||
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
||||||
bool _hardMute = false;
|
bool _hardMute = false;
|
||||||
|
bool _hardMuteByPermission = false;
|
||||||
int _releaseTailMs = 200;
|
int _releaseTailMs = 200;
|
||||||
BigInt? _currentVoiceChannelId;
|
BigInt? _currentVoiceChannelId;
|
||||||
BigInt? _pendingVoiceChannelId;
|
BigInt? _pendingVoiceChannelId;
|
||||||
@@ -436,6 +437,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
_inChannel = inChannel;
|
_inChannel = inChannel;
|
||||||
_transmitMode = transmitMode;
|
_transmitMode = transmitMode;
|
||||||
_hardMute = mute;
|
_hardMute = mute;
|
||||||
|
if (!mute) _hardMuteByPermission = false;
|
||||||
_releaseTailMs = releaseTailMs;
|
_releaseTailMs = releaseTailMs;
|
||||||
_audioStarted = inChannel;
|
_audioStarted = inChannel;
|
||||||
_currentVoiceChannelId = currentChannelId;
|
_currentVoiceChannelId = currentChannelId;
|
||||||
@@ -687,7 +689,12 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
// time via the Grant / Open Settings action.
|
// time via the Grant / Open Settings action.
|
||||||
try {
|
try {
|
||||||
await rust.setHardMute(muted: true);
|
await rust.setHardMute(muted: true);
|
||||||
if (mounted) setState(() => _hardMute = true);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_hardMute = true;
|
||||||
|
_hardMuteByPermission = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Best-effort clamp; if the bridge isn't ready we still
|
// Best-effort clamp; if the bridge isn't ready we still
|
||||||
// proceed. The capture path also self-clamps on Android
|
// proceed. The capture path also self-clamps on Android
|
||||||
@@ -696,6 +703,14 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
// AtomicU8 clamp); this Dart setHardMute is the
|
// AtomicU8 clamp); this Dart setHardMute is the
|
||||||
// defence-in-depth path.
|
// defence-in-depth path.
|
||||||
}
|
}
|
||||||
|
} else if (_hardMuteByPermission) {
|
||||||
|
await rust.setHardMute(muted: false);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_hardMute = false;
|
||||||
|
_hardMuteByPermission = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -787,7 +802,10 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
await rust.setHardMute(muted: next);
|
await rust.setHardMute(muted: next);
|
||||||
await rust.setInputMuted(muted: next);
|
await rust.setInputMuted(muted: next);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _inputMuted = next);
|
setState(() {
|
||||||
|
_inputMuted = next;
|
||||||
|
_hardMuteByPermission = false;
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _error = e.toString());
|
setState(() => _error = e.toString());
|
||||||
@@ -2107,7 +2125,10 @@ class _SnapshotView extends StatelessWidget {
|
|||||||
: () => onJoinChannel(ch),
|
: () => onJoinChannel(ch),
|
||||||
),
|
),
|
||||||
selected: ch.id == currentVoiceChannelId,
|
selected: ch.id == currentVoiceChannelId,
|
||||||
onTap: hasJoinPending || !canJoinVoiceChannel || ch.id == currentVoiceChannelId
|
onTap:
|
||||||
|
hasJoinPending ||
|
||||||
|
!canJoinVoiceChannel ||
|
||||||
|
ch.id == currentVoiceChannelId
|
||||||
? null
|
? null
|
||||||
: () => onJoinChannel(ch),
|
: () => onJoinChannel(ch),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -131,10 +131,11 @@ class AndroidPermissionsService {
|
|||||||
/// production code uses the default channel keyed on
|
/// production code uses the default channel keyed on
|
||||||
/// [androidPermissionsChannelName].
|
/// [androidPermissionsChannelName].
|
||||||
AndroidPermissionsService({MethodChannel? channel})
|
AndroidPermissionsService({MethodChannel? channel})
|
||||||
: _channel = channel ??
|
: _channel =
|
||||||
(_isAndroid
|
channel ??
|
||||||
? const MethodChannel(androidPermissionsChannelName)
|
(_isAndroid
|
||||||
: null);
|
? const MethodChannel(androidPermissionsChannelName)
|
||||||
|
: null);
|
||||||
|
|
||||||
/// Platform-detection seam. Web counts as non-Android.
|
/// Platform-detection seam. Web counts as non-Android.
|
||||||
static bool get _isAndroid {
|
static bool get _isAndroid {
|
||||||
@@ -148,13 +149,13 @@ class AndroidPermissionsService {
|
|||||||
|
|
||||||
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
final ValueNotifier<AndroidRecordAudioPermissionState> _state =
|
||||||
ValueNotifier<AndroidRecordAudioPermissionState>(
|
ValueNotifier<AndroidRecordAudioPermissionState>(
|
||||||
// On non-Android, present as granted so the voice-join gate is a
|
// On non-Android, present as granted so the voice-join gate is a
|
||||||
// no-op (desktop / iOS have separate audio-permission paths owned
|
// no-op (desktop / iOS have separate audio-permission paths owned
|
||||||
// elsewhere; see SDD-101 for iOS).
|
// elsewhere; see SDD-101 for iOS).
|
||||||
_isAndroid
|
_isAndroid
|
||||||
? AndroidRecordAudioPermissionState.unknown
|
? AndroidRecordAudioPermissionState.unknown
|
||||||
: AndroidRecordAudioPermissionState.granted,
|
: AndroidRecordAudioPermissionState.granted,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool _started = false;
|
bool _started = false;
|
||||||
|
|
||||||
@@ -214,16 +215,14 @@ class AndroidPermissionsService {
|
|||||||
if (ch == null) {
|
if (ch == null) {
|
||||||
return AndroidRecordAudioPermissionState.granted;
|
return AndroidRecordAudioPermissionState.granted;
|
||||||
}
|
}
|
||||||
// Snapshot the pre-invocation state. If the platform synchronously
|
// Snapshot the pre-invocation state. New Android hosts return the
|
||||||
// resolves the request to a NEW state during invokeMethod (the
|
// resolved Kotlin PermissionState string from requestRecordAudio;
|
||||||
// platform may emit permissionStateChanged before invokeMethod
|
// older/test hosts may still return null and rely only on the
|
||||||
// returns), we can return immediately. If the state is unchanged
|
// permissionStateChanged callback below.
|
||||||
// (typical: user is being re-prompted after a previous denial; the
|
|
||||||
// platform always re-prompts and the resolution arrives
|
|
||||||
// asynchronously), we must wait for the next listener fire.
|
|
||||||
final preInvokeState = _state.value;
|
final preInvokeState = _state.value;
|
||||||
|
String? returnedState;
|
||||||
try {
|
try {
|
||||||
await ch.invokeMethod<void>(methodRequestRecordAudio);
|
returnedState = await ch.invokeMethod<String>(methodRequestRecordAudio);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Channel-side failure (e.g. missing handler in a debug build).
|
// Channel-side failure (e.g. missing handler in a debug build).
|
||||||
// Fall back to whatever state we currently hold; if still
|
// Fall back to whatever state we currently hold; if still
|
||||||
@@ -232,6 +231,11 @@ class AndroidPermissionsService {
|
|||||||
// "proceed in listen-only".
|
// "proceed in listen-only".
|
||||||
return _state.value;
|
return _state.value;
|
||||||
}
|
}
|
||||||
|
final parsedReturnedState = _parseState(returnedState);
|
||||||
|
if (parsedReturnedState != AndroidRecordAudioPermissionState.unknown) {
|
||||||
|
_state.value = parsedReturnedState;
|
||||||
|
return parsedReturnedState;
|
||||||
|
}
|
||||||
// M1 fix (corrected): only short-circuit if the platform changed
|
// M1 fix (corrected): only short-circuit if the platform changed
|
||||||
// the state synchronously to a resolved value. A still-unknown
|
// the state synchronously to a resolved value. A still-unknown
|
||||||
// state means we must wait. A still-equal-to-pre-invoke state
|
// state means we must wait. A still-equal-to-pre-invoke state
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
// test is actually exercised. The non-Android short-circuit (channel
|
// test is actually exercised. The non-Android short-circuit (channel
|
||||||
// == null) is covered explicitly by the final test.
|
// == null) is covered explicitly by the final test.
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@@ -50,13 +48,10 @@ void main() {
|
|||||||
}) async {
|
}) async {
|
||||||
const codec = StandardMethodCodec();
|
const codec = StandardMethodCodec();
|
||||||
final encoded = codec.encodeMethodCall(
|
final encoded = codec.encodeMethodCall(
|
||||||
MethodCall(
|
MethodCall(methodPermissionStateChanged, <String, dynamic>{
|
||||||
methodPermissionStateChanged,
|
'permission': permission,
|
||||||
<String, dynamic>{
|
'state': state,
|
||||||
'permission': permission,
|
}),
|
||||||
'state': state,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.handlePlatformMessage(channel.name, encoded, (_) {});
|
.handlePlatformMessage(channel.name, encoded, (_) {});
|
||||||
@@ -73,11 +68,11 @@ void main() {
|
|||||||
// response set `outgoingResponder`.
|
// response set `outgoingResponder`.
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(channel, (call) async {
|
.setMockMethodCallHandler(channel, (call) async {
|
||||||
outgoingCalls.add(call);
|
outgoingCalls.add(call);
|
||||||
final r = outgoingResponder;
|
final r = outgoingResponder;
|
||||||
if (r != null) return r(call);
|
if (r != null) return r(call);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
@@ -85,8 +80,7 @@ void main() {
|
|||||||
.setMockMethodCallHandler(channel, null);
|
.setMockMethodCallHandler(channel, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106: a fresh service exposes a deterministic '
|
||||||
'SWE4-UV-041 / SDD-106: a fresh service exposes a deterministic '
|
|
||||||
'initial recordAudioState (granted on non-Android host, where these '
|
'initial recordAudioState (granted on non-Android host, where these '
|
||||||
'unit tests run; the Android cold-launch case is unknown)', () {
|
'unit tests run; the Android cold-launch case is unknown)', () {
|
||||||
// Trace: SDD-106 §5 state machine; non-Android short-circuit
|
// Trace: SDD-106 §5 state machine; non-Android short-circuit
|
||||||
@@ -103,8 +97,7 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
|
||||||
'SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
|
|
||||||
'state=Granted transitions recordAudioState to granted and notifies '
|
'state=Granted transitions recordAudioState to granted and notifies '
|
||||||
'listeners', () async {
|
'listeners', () async {
|
||||||
final svc = AndroidPermissionsService(channel: channel)..start();
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
@@ -138,8 +131,7 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
|
||||||
'SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
|
|
||||||
'state=Denied transitions recordAudioState to denied', () async {
|
'state=Denied transitions recordAudioState to denied', () async {
|
||||||
final svc = AndroidPermissionsService(channel: channel)..start();
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
@@ -155,8 +147,7 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106 §3: inbound permissionStateChanged with '
|
||||||
'SWE4-UV-041 / SDD-106 §3: inbound permissionStateChanged with '
|
|
||||||
'state=PermanentlyDenied transitions recordAudioState to '
|
'state=PermanentlyDenied transitions recordAudioState to '
|
||||||
'permanentlyDenied (drives the "open app settings" UX)', () async {
|
'permanentlyDenied (drives the "open app settings" UX)', () async {
|
||||||
final svc = AndroidPermissionsService(channel: channel)..start();
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
@@ -173,8 +164,7 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106 §5: a malformed state string parses to '
|
||||||
'SWE4-UV-041 / SDD-106 §5: a malformed state string parses to '
|
|
||||||
'unknown (the _parseState default branch)', () async {
|
'unknown (the _parseState default branch)', () async {
|
||||||
// Implementation contract: `_parseState` returns `unknown` for any
|
// Implementation contract: `_parseState` returns `unknown` for any
|
||||||
// string outside {Granted, Denied, PermanentlyDenied}.
|
// string outside {Granted, Denied, PermanentlyDenied}.
|
||||||
@@ -202,11 +192,9 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106: inbound permissionStateChanged for a '
|
||||||
'SWE4-UV-041 / SDD-106: inbound permissionStateChanged for a '
|
|
||||||
'permission other than RECORD_AUDIO is ignored (POST_NOTIFICATIONS '
|
'permission other than RECORD_AUDIO is ignored (POST_NOTIFICATIONS '
|
||||||
'will route through a sibling listenable per the impl note)',
|
'will route through a sibling listenable per the impl note)', () async {
|
||||||
() async {
|
|
||||||
final svc = AndroidPermissionsService(channel: channel)..start();
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
// Seed a known baseline.
|
// Seed a known baseline.
|
||||||
@@ -229,14 +217,12 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
svc.recordAudioState.value,
|
svc.recordAudioState.value,
|
||||||
AndroidRecordAudioPermissionState.denied,
|
AndroidRecordAudioPermissionState.denied,
|
||||||
reason:
|
reason: 'POST_NOTIFICATIONS must not mutate the RECORD_AUDIO listenable',
|
||||||
'POST_NOTIFICATIONS must not mutate the RECORD_AUDIO listenable',
|
|
||||||
);
|
);
|
||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106: start() is idempotent — calling it twice '
|
||||||
'SWE4-UV-041 / SDD-106: start() is idempotent — calling it twice '
|
|
||||||
'does not double-register the handler and inbound messages still '
|
'does not double-register the handler and inbound messages still '
|
||||||
'fire exactly once', () async {
|
'fire exactly once', () async {
|
||||||
final svc = AndroidPermissionsService(channel: channel)
|
final svc = AndroidPermissionsService(channel: channel)
|
||||||
@@ -265,8 +251,7 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106: stop() removes the handler — subsequent '
|
||||||
'SWE4-UV-041 / SDD-106: stop() removes the handler — subsequent '
|
|
||||||
'inbound messages have no effect on recordAudioState', () async {
|
'inbound messages have no effect on recordAudioState', () async {
|
||||||
final svc = AndroidPermissionsService(channel: channel)..start();
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
@@ -294,8 +279,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() emits an outbound '
|
||||||
'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() emits an outbound '
|
|
||||||
'requestRecordAudio MethodCall on the channel; when the platform '
|
'requestRecordAudio MethodCall on the channel; when the platform '
|
||||||
'stub raises (e.g. handler missing in a debug build) the impl '
|
'stub raises (e.g. handler missing in a debug build) the impl '
|
||||||
'catches and resolves to the current cached state', () async {
|
'catches and resolves to the current cached state', () async {
|
||||||
@@ -324,8 +308,11 @@ void main() {
|
|||||||
.skip(priorOutgoing)
|
.skip(priorOutgoing)
|
||||||
.where((c) => c.method == methodRequestRecordAudio)
|
.where((c) => c.method == methodRequestRecordAudio)
|
||||||
.toList();
|
.toList();
|
||||||
expect(requests, hasLength(1),
|
expect(
|
||||||
reason: 'ensureRecordAudio() must invoke requestRecordAudio');
|
requests,
|
||||||
|
hasLength(1),
|
||||||
|
reason: 'ensureRecordAudio() must invoke requestRecordAudio',
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
result,
|
result,
|
||||||
AndroidRecordAudioPermissionState.denied,
|
AndroidRecordAudioPermissionState.denied,
|
||||||
@@ -335,11 +322,9 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves with the '
|
||||||
'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves with the '
|
|
||||||
'new state when an inbound permissionStateChanged is delivered '
|
'new state when an inbound permissionStateChanged is delivered '
|
||||||
'while the request is in-flight (the realistic Kotlin flow)',
|
'while the request is in-flight (the realistic Kotlin flow)', () async {
|
||||||
() async {
|
|
||||||
final svc = AndroidPermissionsService(channel: channel)..start();
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
// Seed away from the host default (granted on linux) so that the
|
// Seed away from the host default (granted on linux) so that the
|
||||||
@@ -386,7 +371,34 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'SWE4-UV-041 / SDD-106 §3: openAppSettings() emits an outbound '
|
'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves from the '
|
||||||
|
'platform return value even when the resolved state is unchanged',
|
||||||
|
() async {
|
||||||
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
|
await sendPermissionStateChanged(
|
||||||
|
permission: 'android.permission.RECORD_AUDIO',
|
||||||
|
state: 'Granted',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
svc.recordAudioState.value,
|
||||||
|
AndroidRecordAudioPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
outgoingResponder = (call) async => 'Granted';
|
||||||
|
|
||||||
|
final result = await svc.ensureRecordAudio();
|
||||||
|
|
||||||
|
expect(result, AndroidRecordAudioPermissionState.granted);
|
||||||
|
expect(
|
||||||
|
outgoingCalls.where((c) => c.method == methodRequestRecordAudio),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
svc.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('SWE4-UV-041 / SDD-106 §3: openAppSettings() emits an outbound '
|
||||||
'openAppSettings MethodCall on the channel', () async {
|
'openAppSettings MethodCall on the channel', () async {
|
||||||
final svc = AndroidPermissionsService(channel: channel)..start();
|
final svc = AndroidPermissionsService(channel: channel)..start();
|
||||||
|
|
||||||
@@ -399,8 +411,7 @@ void main() {
|
|||||||
svc.dispose();
|
svc.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test('SWE4-UV-041 / SDD-106: non-Android short-circuit — when channel '
|
||||||
'SWE4-UV-041 / SDD-106: non-Android short-circuit — when channel '
|
|
||||||
'is null, recordAudioState seeds to granted and ensureRecordAudio '
|
'is null, recordAudioState seeds to granted and ensureRecordAudio '
|
||||||
'resolves synchronously without touching any channel', () async {
|
'resolves synchronously without touching any channel', () async {
|
||||||
final svc = AndroidPermissionsService(channel: null);
|
final svc = AndroidPermissionsService(channel: null);
|
||||||
|
|||||||
@@ -1119,17 +1119,13 @@ fn call_voice_service_static(method: &str) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let class = match env.find_class(ANDROID_VOICE_FG_SERVICE_FQCN) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: find_class failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// SAFETY: ndk_context::context() is the application Context
|
// SAFETY: ndk_context::context() is the application Context
|
||||||
// jobject; valid global ref for process lifetime.
|
// jobject; valid global ref for process lifetime.
|
||||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||||
|
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
match env.call_static_method(
|
match env.call_static_method(
|
||||||
&class,
|
&class,
|
||||||
method,
|
method,
|
||||||
@@ -1147,3 +1143,60 @@ fn call_voice_service_static(method: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_app_class<'local>(
|
||||||
|
env: &mut jni::JNIEnv<'local>,
|
||||||
|
context_obj: &jni::objects::JObject<'local>,
|
||||||
|
slash_name: &str,
|
||||||
|
) -> Option<jni::objects::JClass<'local>> {
|
||||||
|
match env.find_class(slash_name) {
|
||||||
|
Ok(c) => return Some(c),
|
||||||
|
Err(e) => {
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loader = match env
|
||||||
|
.call_method(
|
||||||
|
context_obj,
|
||||||
|
"getClassLoader",
|
||||||
|
"()Ljava/lang/ClassLoader;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.and_then(|v| v.l())
|
||||||
|
{
|
||||||
|
Ok(loader) => loader,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, "android: Context.getClassLoader failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let dotted_name = slash_name.replace('/', ".");
|
||||||
|
let class_name = match env.new_string(&dotted_name) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: class-name string allocation failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let class_name_obj = jni::objects::JObject::from(class_name);
|
||||||
|
match env
|
||||||
|
.call_method(
|
||||||
|
&loader,
|
||||||
|
"loadClass",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||||
|
&[jni::objects::JValue::Object(&class_name_obj)],
|
||||||
|
)
|
||||||
|
.and_then(|v| v.l())
|
||||||
|
{
|
||||||
|
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
|
||||||
|
Err(e) => {
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user