feat: multi-platform bug fixes, Android audio path, and build tooling

Flutter UI fixes:
- Fix stale channel badge/speaker when moved by others (derive current
  channel from ownClientId instead of optimistic local state)
- Fix Linux PTT via focused fallback key handler
- Distinguish ServerQuery clients with terminal icon in client list
- Reduce duplicate current-channel badge display
- Prevent PTT key-bind save from permanently closing voice settings
- Fix Linux GTK reopen-after-close (quit app on window destroy)
- Fix focused PTT: consume key events, release held keys on
  disconnect/leave-channel/mode/backend changes, suppress stale errors

Flutter Rust bridge:
- Thread is_server_query flag through protocol→bridge→Dart
- Add own_client_id to BridgeSnapshot DTO
- Add log_file_path_str() for platform log path queries

Rust protocol:
- Add ServerQuery test coverage (query_client_type_maps_to_server_query_flag)
- Split reqwest TLS: native-tls for desktop/iOS, rustls for Android

Rust audio:
- Upgrade cpal 0.16→0.17.3 with API adjustments (SampleRate, description())
- Suppress Android-only dead-code warnings (open_log_file, keyring_account)

Android build tooling:
- tools/build-opus-android.sh: NDK auto-discovery, correct CMake
  Android variables (ANDROID_ABI, ANDROID_PLATFORM), portable baseline
- tools/build-android-rust.sh: build+copy Rust cdylib for arm64-v8a,
  armeabi-v7a, x86_64 into android/app/src/main/jniLibs/
- Add jniLibs/ to .gitignore

Rust bridge:
- Guard open_log_file() on non-Android (Android uses logcat)
This commit is contained in:
Edison Jwa
2026-05-18 00:13:21 +09:00
parent 00692b76a2
commit dc9c5c0a4e
17 changed files with 760 additions and 208 deletions
+1
View File
@@ -12,3 +12,4 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
/app/src/main/jniLibs/
+126 -140
View File
@@ -32,6 +32,48 @@ bool get _isMacOS => !kIsWeb && Platform.isMacOS;
/// Top padding for macOS to clear traffic-light buttons.
const double _macOSTrafficLightPad = 56.0;
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
/// stored by the PTT binding flow.
String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
if (k == LogicalKeyboardKey.space) return 'Space';
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
return 'Enter';
}
if (k == LogicalKeyboardKey.tab) return 'Tab';
if (k == LogicalKeyboardKey.escape) return 'Escape';
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
if (k == LogicalKeyboardKey.delete) return 'Delete';
if (k == LogicalKeyboardKey.insert) return 'Insert';
if (k == LogicalKeyboardKey.home) return 'Home';
if (k == LogicalKeyboardKey.end) return 'End';
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
if (k == LogicalKeyboardKey.shift ||
k == LogicalKeyboardKey.shiftLeft ||
k == LogicalKeyboardKey.shiftRight ||
k == LogicalKeyboardKey.control ||
k == LogicalKeyboardKey.controlLeft ||
k == LogicalKeyboardKey.controlRight ||
k == LogicalKeyboardKey.alt ||
k == LogicalKeyboardKey.altLeft ||
k == LogicalKeyboardKey.altRight ||
k == LogicalKeyboardKey.meta ||
k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight ||
k == LogicalKeyboardKey.capsLock ||
k == LogicalKeyboardKey.numLock ||
k == LogicalKeyboardKey.scrollLock) {
return null;
}
final fallback = k.keyLabel.trim();
if (fallback.isEmpty) return null;
return fallback;
}
/// True when the host is a touch-only mobile platform without a
/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
/// and widgets/voice_settings.dart so the AppBar + narrow-mode
@@ -241,17 +283,50 @@ class _BetaHomeState extends State<_BetaHome> {
// only the platform-neutral label that already crossed into
// the Rust side.
String _pttBoundKeyLabel = '';
final Set<LogicalKeyboardKey> _focusedPttHeldKeys = <LogicalKeyboardKey>{};
List<rust.BridgeBookmark> _bookmarks = const [];
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
_eventsSub = rust.eventsStream().listen(_onEvent);
unawaited(_reloadBookmarks());
unawaited(_hydratePttBinding());
}
bool _handleFocusedPttKey(KeyEvent event) {
final label = _pttDisplayLabelForKey(event.logicalKey);
final isBoundKey = _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) {
if (_focusedPttHeldKeys.remove(event.logicalKey)) {
unawaited(_setPtt(false));
}
return true;
}
if (_pttBackendId != 'focused' ||
_phase != _Phase.connected ||
!_inChannel ||
_transmitMode != rust.BridgeTransmitMode.ptt ||
!isBoundKey) {
return false;
}
if (event is KeyDownEvent) {
if (_focusedPttHeldKeys.add(event.logicalKey)) {
unawaited(_setPtt(true));
}
return true;
}
return false;
}
void _releaseFocusedPttIfHeld() {
if (_focusedPttHeldKeys.isEmpty) return;
_focusedPttHeldKeys.clear();
unawaited(_setPtt(false, reportError: false));
}
/// Hydrate the bound-key display state from the bridge so the
/// Voice Bar shows the user's persisted hotkey label immediately
/// at launch — without having to wait for them to open the
@@ -326,6 +401,9 @@ class _BetaHomeState extends State<_BetaHome> {
_pttBackendId = backendId;
_pttBoundInputClass = boundInputClass;
});
if (backendId != 'focused') {
_releaseFocusedPttIfHeld();
}
case rust.BridgeEvent_VoiceState(
:final inChannel,
:final transmitMode,
@@ -341,9 +419,15 @@ class _BetaHomeState extends State<_BetaHome> {
});
if (inChannel) {
_ensureStatsTimer();
unawaited(_onRefresh());
} else {
_statsTimer?.cancel();
_statsTimer = null;
_releaseFocusedPttIfHeld();
setState(() => _currentVoiceChannelId = null);
}
if (transmitMode != rust.BridgeTransmitMode.ptt) {
_releaseFocusedPttIfHeld();
}
case rust.BridgeEvent_InterruptionState(
:final began,
@@ -403,6 +487,7 @@ class _BetaHomeState extends State<_BetaHome> {
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
_eventsSub?.cancel();
_statsTimer?.cancel();
_hostCtl.dispose();
@@ -421,6 +506,7 @@ class _BetaHomeState extends State<_BetaHome> {
_error = null;
_snapshot = null;
});
_releaseFocusedPttIfHeld();
try {
final snap = await rust.connect(
host: (host ?? _hostCtl.text).trim(),
@@ -430,7 +516,7 @@ class _BetaHomeState extends State<_BetaHome> {
if (!mounted) return;
setState(() {
_phase = _Phase.connected;
_snapshot = snap;
_applySnapshot(snap);
});
} catch (e) {
if (!mounted) return;
@@ -483,11 +569,11 @@ class _BetaHomeState extends State<_BetaHome> {
}
// ignore: unused_element
Future<void> _setPtt(bool active) async {
Future<void> _setPtt(bool active, {bool reportError = true}) async {
try {
await rust.setPtt(active: active);
} catch (e) {
if (!mounted) return;
if (!mounted || !reportError) return;
setState(() => _error = e.toString());
}
}
@@ -719,6 +805,9 @@ class _BetaHomeState extends State<_BetaHome> {
}
if (result.bindKeyRequested && mounted) {
await _onConfigurePtt(context);
if (mounted) {
unawaited(_onOpenVoiceSettings());
}
}
}
@@ -741,7 +830,7 @@ class _BetaHomeState extends State<_BetaHome> {
try {
final snap = await rust.snapshot();
if (!mounted) return;
setState(() => _snapshot = snap);
setState(() => _applySnapshot(snap));
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
@@ -766,6 +855,7 @@ class _BetaHomeState extends State<_BetaHome> {
_inChannel = false;
_currentVoiceChannelId = null;
});
_releaseFocusedPttIfHeld();
}
String _currentVoiceChannelName() {
@@ -778,6 +868,21 @@ class _BetaHomeState extends State<_BetaHome> {
return '';
}
void _applySnapshot(rust.BridgeSnapshot snap) {
_snapshot = snap;
if (!_inChannel) {
_currentVoiceChannelId = null;
return;
}
for (final client in snap.clients) {
if (client.id == snap.ownClientId) {
_currentVoiceChannelId = client.channel;
return;
}
}
_currentVoiceChannelId = null;
}
Future<void> _onShowDiagnostics(BuildContext context) async {
final l10n = AppL10n.of(context);
final text = rust.exportDiagnostics();
@@ -1045,11 +1150,7 @@ class _BetaHomeState extends State<_BetaHome> {
],
];
final headerTitle = _AppBarTitle(
phase: _phase,
inChannel: _inChannel,
channelName: _currentVoiceChannelName(),
);
const headerTitle = _AppBarTitle();
final bodyContent = LayoutBuilder(
builder: (ctx, bodyConstraints) {
@@ -1260,81 +1361,16 @@ class _BetaHomeState extends State<_BetaHome> {
}
}
/// AppBar title that shows just the app name when idle / connecting,
/// and 'app name · #channel' (channel as an outlined chip-style
/// pill) when the user is in a voice channel. The chip is read-
/// only — tapping does nothing because the source of truth for
/// "current channel" is the channel tree below. Long names ellipsize.
/// App header title. The current channel is highlighted only in the
/// channel tree and Voice Bar to avoid repeating the same badge in
/// multiple places.
class _AppBarTitle extends StatelessWidget {
const _AppBarTitle({
required this.phase,
required this.inChannel,
required this.channelName,
});
final _Phase phase;
final bool inChannel;
final String channelName;
const _AppBarTitle();
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
if (phase != _Phase.connected || !inChannel || channelName.isEmpty) {
return Text(l10n.appTitle);
}
// When in a voice channel on a narrow phone width, the AppBar
// is already crowded with mic / headset / settings / about /
// diagnostics / disconnect icons (5-6 action buttons). Keeping
// the "Chanora" app-name label in the title here causes the
// Row to overflow at typical iPhone widths and Flutter renders
// its yellow-and-black "OVERFLOWED BY X PIXELS" debug strip
// next to the title — the user reported seeing "RFLOWED BY"
// there. Drop the app-name label on narrow widths and let the
// channel pill be the only title content; the user knows
// they're in Chanora because they just opened it. On wide
// widths (>= 840 dp, tablet/desktop) restore the app name
// because there's plenty of room.
final isNarrow = MediaQuery.of(context).size.width < 840.0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isNarrow) ...[Text(l10n.appTitle), const SizedBox(width: 12)],
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.tag,
size: 14,
color: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Flexible(
child: Text(
channelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
),
),
],
);
return Text(l10n.appTitle);
}
}
@@ -1995,8 +2031,16 @@ class _SnapshotView extends StatelessWidget {
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: const Icon(Icons.person, size: 18),
title: Text(cl.name),
leading: Icon(
cl.isServerQuery ? Icons.terminal : Icons.person,
size: 18,
),
title: Text(
cl.name,
style: cl.isServerQuery
? TextStyle(color: theme.colorScheme.onSurfaceVariant)
: null,
),
),
),
],
@@ -2049,7 +2093,7 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final label = _displayLabelForKey(event.logicalKey);
final label = _pttDisplayLabelForKey(event.logicalKey);
if (label == null) return KeyEventResult.ignored;
setState(() {
_captured = label;
@@ -2058,64 +2102,6 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
return KeyEventResult.handled;
}
/// Translate a [LogicalKeyboardKey] into the platform-neutral
/// label string the bridge expects (matching the entries in
/// `crates/chanora_audio/src/ptt_backends/windows_keymap.rs`).
///
/// `LogicalKeyboardKey.keyLabel` returns `" "` for Space, empty
/// for pure modifiers (shift / ctrl / alt / meta), and localised
/// strings for some special keys; we normalise to the canonical
/// English label so the badge displays something readable AND so
/// the Windows backend's keymap can resolve to a `VK_*`. Pure
/// modifier keys are intentionally rejected — chording into the
/// real binding (e.g. Ctrl+Shift+M) is supported by ignoring the
/// individual modifier-down events.
String? _displayLabelForKey(LogicalKeyboardKey k) {
// Whitespace / common control keys whose keyLabel is unhelpful.
if (k == LogicalKeyboardKey.space) return 'Space';
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
return 'Enter';
}
if (k == LogicalKeyboardKey.tab) return 'Tab';
if (k == LogicalKeyboardKey.escape) return 'Escape';
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
if (k == LogicalKeyboardKey.delete) return 'Delete';
if (k == LogicalKeyboardKey.insert) return 'Insert';
if (k == LogicalKeyboardKey.home) return 'Home';
if (k == LogicalKeyboardKey.end) return 'End';
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
// Pure modifier keys are not bindable on their own (user can
// still chord by pressing a non-modifier while holding them).
if (k == LogicalKeyboardKey.shift ||
k == LogicalKeyboardKey.shiftLeft ||
k == LogicalKeyboardKey.shiftRight ||
k == LogicalKeyboardKey.control ||
k == LogicalKeyboardKey.controlLeft ||
k == LogicalKeyboardKey.controlRight ||
k == LogicalKeyboardKey.alt ||
k == LogicalKeyboardKey.altLeft ||
k == LogicalKeyboardKey.altRight ||
k == LogicalKeyboardKey.meta ||
k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight ||
k == LogicalKeyboardKey.capsLock ||
k == LogicalKeyboardKey.numLock ||
k == LogicalKeyboardKey.scrollLock) {
return null;
}
// Fall back to keyLabel for letters, digits, function keys,
// numpad digits, and punctuation. Trim whitespace as a final
// belt-and-braces guard.
final fallback = k.keyLabel.trim();
if (fallback.isEmpty) return null;
return fallback;
}
void _captureMouseSideButton(int button) {
setState(() {
_captured = 'mouse-side-button:$button';
+8 -2
View File
@@ -338,14 +338,19 @@ class BridgeClient {
/// Nickname.
final String name;
/// True for TeamSpeak ServerQuery clients.
final bool isServerQuery;
const BridgeClient({
required this.id,
required this.channel,
required this.name,
required this.isServerQuery,
});
@override
int get hashCode => id.hashCode ^ channel.hashCode ^ name.hashCode;
int get hashCode =>
id.hashCode ^ channel.hashCode ^ name.hashCode ^ isServerQuery.hashCode;
@override
bool operator ==(Object other) =>
@@ -354,7 +359,8 @@ class BridgeClient {
runtimeType == other.runtimeType &&
id == other.id &&
channel == other.channel &&
name == other.name;
name == other.name &&
isServerQuery == other.isServerQuery;
}
@freezed
@@ -1157,12 +1157,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeClient dco_decode_bridge_client(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 3)
throw Exception('unexpected arr length: expect 3 but see ${arr.length}');
if (arr.length != 4)
throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
return BridgeClient(
id: dco_decode_u_64(arr[0]),
channel: dco_decode_u_64(arr[1]),
name: dco_decode_String(arr[2]),
isServerQuery: dco_decode_bool(arr[3]),
);
}
@@ -1454,7 +1455,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_id = sse_decode_u_64(deserializer);
var var_channel = sse_decode_u_64(deserializer);
var var_name = sse_decode_String(deserializer);
return BridgeClient(id: var_id, channel: var_channel, name: var_name);
var var_isServerQuery = sse_decode_bool(deserializer);
return BridgeClient(
id: var_id,
channel: var_channel,
name: var_name,
isServerQuery: var_isServerQuery,
);
}
@protected
@@ -1799,6 +1806,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_64(self.id, serializer);
sse_encode_u_64(self.channel, serializer);
sse_encode_String(self.name, serializer);
sse_encode_bool(self.isServerQuery, serializer);
}
@protected
@@ -19,6 +19,10 @@ static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
static void window_destroy_cb(GtkWidget* widget, gpointer user_data) {
g_application_quit(G_APPLICATION(user_data));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
@@ -45,13 +49,15 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "chanora_flutter");
gtk_header_bar_set_title(header_bar, "Chanora");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "chanora_flutter");
gtk_window_set_title(window, "Chanora");
}
g_signal_connect(window, "destroy", G_CALLBACK(window_destroy_cb), application);
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();