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