feat: integrate chat voice and diagnostics client
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
class AndroidAudioOutputDevice {
|
||||
const AndroidAudioOutputDevice({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.isSelected,
|
||||
required this.isAvailableForCommunication,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String type;
|
||||
final bool isSelected;
|
||||
final bool isAvailableForCommunication;
|
||||
|
||||
factory AndroidAudioOutputDevice.fromMap(Map<dynamic, dynamic> map) {
|
||||
return AndroidAudioOutputDevice(
|
||||
id: map['id']?.toString() ?? '',
|
||||
name: map['name']?.toString() ?? '',
|
||||
type: map['type']?.toString() ?? 'unknown',
|
||||
isSelected: map['isSelected'] == true,
|
||||
isAvailableForCommunication: map['isAvailableForCommunication'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<AndroidAudioOutputDevice> parseAndroidAudioOutputDevices(
|
||||
List<dynamic> raw,
|
||||
) {
|
||||
return raw
|
||||
.whereType<Map<dynamic, dynamic>>()
|
||||
.map(AndroidAudioOutputDevice.fromMap)
|
||||
.toList();
|
||||
}
|
||||
|
||||
AndroidAudioOutputDevice? selectedAndroidAudioOutputDevice(
|
||||
List<AndroidAudioOutputDevice> devices,
|
||||
) {
|
||||
return devices.where((device) => device.isSelected).firstOrNull;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
const String appSemverBaseline = 'v0.1.0';
|
||||
const String _sileroVadAsset = 'assets/models/silero_vad.onnx';
|
||||
const String _tenVadAsset = 'assets/models/ten_vad.onnx';
|
||||
|
||||
Future<File> _copyBundledAssetToDocuments({
|
||||
required String assetPath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
final data = await rootBundle.load(assetPath);
|
||||
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
|
||||
if (await file.exists() && await file.length() == bytes.length) {
|
||||
return file;
|
||||
}
|
||||
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<void> configureBundledVadModels() async {
|
||||
final silero = await _copyBundledAssetToDocuments(
|
||||
assetPath: _sileroVadAsset,
|
||||
fileName: 'silero_vad.onnx',
|
||||
);
|
||||
final ten = await _copyBundledAssetToDocuments(
|
||||
assetPath: _tenVadAsset,
|
||||
fileName: 'ten_vad.onnx',
|
||||
);
|
||||
await rust.setVadModelPath(path: silero.path);
|
||||
await rust.setTenVadModelPath(path: ten.path);
|
||||
}
|
||||
|
||||
/// Resolve the human-readable app version displayed in About.
|
||||
///
|
||||
/// The semver baseline is kept in code because iOS strips pre-release
|
||||
/// identifiers from `CFBundleShortVersionString`; `package_info_plus`
|
||||
/// still provides the platform build counter.
|
||||
Future<String> resolveAppVersion({
|
||||
String semverBaseline = appSemverBaseline,
|
||||
}) async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
return appVersionFromBuildNumber(
|
||||
semverBaseline: semverBaseline,
|
||||
buildNumber: info.buildNumber,
|
||||
);
|
||||
} catch (_) {
|
||||
return semverBaseline;
|
||||
}
|
||||
}
|
||||
|
||||
String appVersionFromBuildNumber({
|
||||
required String semverBaseline,
|
||||
required String buildNumber,
|
||||
}) {
|
||||
final build = buildNumber.isEmpty ? '' : '+$buildNumber';
|
||||
return '$semverBaseline$build';
|
||||
}
|
||||
|
||||
Future<void> wireStorage() async {
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
await rust.initStorage(dir: dir.path);
|
||||
} catch (_) {
|
||||
// Best-effort; missing storage just means no identity persistence
|
||||
// and no bookmark list this session.
|
||||
}
|
||||
}
|
||||
|
||||
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
|
||||
if (results.isEmpty) return rust.BridgeNetworkState.unknown;
|
||||
final allNone = results.every((r) => r == ConnectivityResult.none);
|
||||
if (allNone) return rust.BridgeNetworkState.offline;
|
||||
return rust.BridgeNetworkState.online;
|
||||
}
|
||||
|
||||
Future<void> wireConnectivity() async {
|
||||
final connectivity = Connectivity();
|
||||
try {
|
||||
final initial = await connectivity.checkConnectivity();
|
||||
rust.setNetworkState(state: _mapConnectivity(initial));
|
||||
} catch (_) {}
|
||||
connectivity.onConnectivityChanged.listen((results) {
|
||||
rust.setNetworkState(state: _mapConnectivity(results));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
|
||||
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
|
||||
|
||||
rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
||||
switch (value) {
|
||||
case 'Earpiece':
|
||||
return rust.BridgeAudioRoute.earpiece;
|
||||
case 'Speaker':
|
||||
return rust.BridgeAudioRoute.speaker;
|
||||
case 'WiredHeadset':
|
||||
return rust.BridgeAudioRoute.wiredHeadset;
|
||||
case 'BluetoothHfp':
|
||||
return rust.BridgeAudioRoute.bluetoothHfp;
|
||||
case 'BluetoothA2dp':
|
||||
return rust.BridgeAudioRoute.bluetoothA2Dp;
|
||||
default:
|
||||
return rust.BridgeAudioRoute.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
void wireAudioLifecycle() {
|
||||
wireIosAudioLifecycle();
|
||||
wireAndroidAudioLifecycle();
|
||||
}
|
||||
|
||||
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
||||
///
|
||||
/// Swift side (AppDelegate) posts route-change and interruption events through
|
||||
/// this channel. The handler dispatches them to the FRB bridge functions on
|
||||
/// the Rust side.
|
||||
void wireIosAudioLifecycle({
|
||||
MethodChannel channel = const MethodChannel(iosAudioLifecycleChannelName),
|
||||
}) {
|
||||
channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
switch (call.method) {
|
||||
case 'handleRouteChange':
|
||||
final routeStr = call.arguments as String? ?? 'Unknown';
|
||||
final route = parseBridgeAudioRoute(routeStr);
|
||||
rust.handleRouteChange(route: route);
|
||||
break;
|
||||
case 'handleMediaServicesReset':
|
||||
final routeClass = call.arguments as String? ?? 'Unknown';
|
||||
rust.handleMediaServicesResetWithRoute(routeClass: routeClass);
|
||||
break;
|
||||
case 'handleInterruptionBegan':
|
||||
rust.handleInterruptionBegan();
|
||||
break;
|
||||
case 'handleInterruptionEnded':
|
||||
final shouldResume = call.arguments as bool? ?? false;
|
||||
rust.handleInterruptionEnded(shouldResume: shouldResume);
|
||||
break;
|
||||
case 'handleWillResignActive':
|
||||
case 'handleDidEnterBackground':
|
||||
rust.handleInterruptionBegan();
|
||||
break;
|
||||
case 'handleWillEnterForeground':
|
||||
rust.handleInterruptionEnded(shouldResume: true);
|
||||
break;
|
||||
case 'handleWillTerminate':
|
||||
rust.handleInterruptionBegan();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Errors from the Rust side are already logged there; do not propagate
|
||||
// exceptions to the platform framework.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Wire the Android audio lifecycle MethodChannel.
|
||||
///
|
||||
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change events
|
||||
/// through this channel. This mirrors the iOS dispatch path.
|
||||
void wireAndroidAudioLifecycle({
|
||||
bool isAndroid = false,
|
||||
MethodChannel channel = const MethodChannel(androidAudioLifecycleChannelName),
|
||||
}) {
|
||||
if (!isAndroid && !Platform.isAndroid) return;
|
||||
channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
switch (call.method) {
|
||||
case 'handleRouteChange':
|
||||
final args = call.arguments;
|
||||
final routeStr = args is Map
|
||||
? (args['routeType'] as String? ?? 'Unknown')
|
||||
: (args as String? ?? 'Unknown');
|
||||
final route = parseBridgeAudioRoute(routeStr);
|
||||
rust.handleRouteChange(route: route);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Errors from the Rust side are already logged there; do not propagate
|
||||
// exceptions to the platform framework.
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/lib.dart' as rust_err;
|
||||
|
||||
/// Map a `voiceJoin` error to a localized user-facing message.
|
||||
String channelJoinErrorMessage(AppL10n l10n, Object error) {
|
||||
if (error is rust_err.BridgeError) {
|
||||
return error.when(
|
||||
invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
dnsFailed: (host, reason) =>
|
||||
l10n.channelJoinFailedGeneric('$host: $reason'),
|
||||
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
|
||||
alreadyConnected: () =>
|
||||
l10n.channelJoinFailedGeneric('already connected'),
|
||||
serverRejected: (code, message) {
|
||||
switch (code) {
|
||||
case 0x0001:
|
||||
return l10n.channelJoinFailedTimeout;
|
||||
case 0x0a08:
|
||||
return l10n.channelJoinFailedPermission;
|
||||
case 0x0302:
|
||||
return l10n.channelJoinAlreadyIn;
|
||||
case 0x030d:
|
||||
return l10n.channelJoinFailedPassword;
|
||||
case 0x0309:
|
||||
return l10n.channelJoinFailedFull;
|
||||
case 0x030a:
|
||||
return l10n.channelJoinFailedFamilyFull;
|
||||
case 0x030e:
|
||||
return l10n.channelJoinFailedPrivate;
|
||||
default:
|
||||
return l10n.channelJoinFailedGeneric(message);
|
||||
}
|
||||
},
|
||||
unmapped: (msg) => l10n.channelJoinFailedGeneric(msg),
|
||||
);
|
||||
}
|
||||
return l10n.channelJoinFailedGeneric(error.toString());
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/// Alignment requested by a TeamSpeak spacer channel tag.
|
||||
enum SpacerAlignment {
|
||||
/// Left-aligned spacer content.
|
||||
left,
|
||||
|
||||
/// Right-aligned spacer content.
|
||||
right,
|
||||
|
||||
/// Center-aligned spacer content.
|
||||
center,
|
||||
}
|
||||
|
||||
/// Built-in TeamSpeak spacer separator patterns.
|
||||
enum SpacerSpecialType {
|
||||
/// `___`
|
||||
solidLine,
|
||||
|
||||
/// `---`
|
||||
dashLine,
|
||||
|
||||
/// `...`
|
||||
dotLine,
|
||||
|
||||
/// `-.-`
|
||||
dashDotLine,
|
||||
|
||||
/// `-..`
|
||||
dashDotDotLine,
|
||||
}
|
||||
|
||||
/// Parsed form of a TeamSpeak spacer channel name.
|
||||
class SpacerChannelNameParseResult {
|
||||
/// Construct a spacer parse result.
|
||||
const SpacerChannelNameParseResult({
|
||||
required this.isSpacer,
|
||||
required this.isValid,
|
||||
required this.alignment,
|
||||
required this.isRepeating,
|
||||
required this.uniqueSuffix,
|
||||
required this.text,
|
||||
required this.specialType,
|
||||
required this.isBlankSpacer,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
/// True only when the name begins with a valid bracketed spacer tag.
|
||||
final bool isSpacer;
|
||||
|
||||
/// True when the bracketed spacer tag follows the supported syntax.
|
||||
final bool isValid;
|
||||
|
||||
/// Optional alignment flag. Null means the server/client default.
|
||||
final SpacerAlignment? alignment;
|
||||
|
||||
/// True when `*` appears in the tag and the text should repeat.
|
||||
final bool isRepeating;
|
||||
|
||||
/// The exact suffix after `Spacer` and before `]`.
|
||||
final String uniqueSuffix;
|
||||
|
||||
/// The exact text after the closing `]`.
|
||||
final String text;
|
||||
|
||||
/// Built-in separator type for special text values.
|
||||
final SpacerSpecialType? specialType;
|
||||
|
||||
/// True for the known blank-looking right-aligned dot spacer.
|
||||
final bool isBlankSpacer;
|
||||
|
||||
/// Parse error/reason for non-spacer or malformed names.
|
||||
final String? reason;
|
||||
}
|
||||
|
||||
/// Options for formatting a TeamSpeak spacer channel name.
|
||||
class SpacerChannelNameFormatOptions {
|
||||
/// Construct spacer formatting options.
|
||||
const SpacerChannelNameFormatOptions({
|
||||
this.alignment,
|
||||
this.isRepeating = false,
|
||||
this.uniqueSuffix = '',
|
||||
this.text = '',
|
||||
});
|
||||
|
||||
/// Optional alignment flag. Null means omit the alignment prefix.
|
||||
final SpacerAlignment? alignment;
|
||||
|
||||
/// Whether to include the repeating `*` tag flag.
|
||||
final bool isRepeating;
|
||||
|
||||
/// Uniqueness suffix to place after `Spacer`.
|
||||
final String uniqueSuffix;
|
||||
|
||||
/// Text to place after the closing `]`.
|
||||
final String text;
|
||||
}
|
||||
|
||||
const _notSpacer = SpacerChannelNameParseResult(
|
||||
isSpacer: false,
|
||||
isValid: false,
|
||||
alignment: null,
|
||||
isRepeating: false,
|
||||
uniqueSuffix: '',
|
||||
text: '',
|
||||
specialType: null,
|
||||
isBlankSpacer: false,
|
||||
reason: 'not a spacer channel name',
|
||||
);
|
||||
|
||||
/// Return true when [name] begins with a valid bracketed spacer tag.
|
||||
bool isSpacerChannelName(String name) => parseSpacerChannelName(name).isSpacer;
|
||||
|
||||
/// Parse a TeamSpeak spacer channel name.
|
||||
///
|
||||
/// Supported tag form is `[?Spacer#]Text`, parsed case-insensitively.
|
||||
/// `?` may be `l`, `r`, or `c`; `*` may also appear in the tag to
|
||||
/// mark repeating spacer content. The suffix and text are preserved
|
||||
/// exactly as written.
|
||||
SpacerChannelNameParseResult parseSpacerChannelName(String name) {
|
||||
if (!name.startsWith('[')) return _notSpacer;
|
||||
|
||||
final close = name.indexOf(']');
|
||||
if (close < 0) {
|
||||
return _invalidSpacerName('missing closing bracket');
|
||||
}
|
||||
|
||||
final tag = name.substring(1, close);
|
||||
final text = name.substring(close + 1);
|
||||
final match = RegExp(
|
||||
r'^([lrc*]*)(spacer)(.*)$',
|
||||
caseSensitive: false,
|
||||
).firstMatch(tag);
|
||||
|
||||
if (match == null) {
|
||||
if (tag.toLowerCase().contains('spacer')) {
|
||||
return _invalidSpacerName('invalid spacer tag');
|
||||
}
|
||||
return _notSpacer;
|
||||
}
|
||||
|
||||
final flags = match.group(1) ?? '';
|
||||
final uniqueSuffix = match.group(3) ?? '';
|
||||
final alignmentFlags = flags
|
||||
.toLowerCase()
|
||||
.split('')
|
||||
.where((flag) => flag == 'l' || flag == 'r' || flag == 'c')
|
||||
.toList();
|
||||
final repeatingFlags = flags.split('').where((flag) => flag == '*').length;
|
||||
|
||||
if (alignmentFlags.length > 1) {
|
||||
return _invalidSpacerName('multiple alignment flags');
|
||||
}
|
||||
if (repeatingFlags > 1) {
|
||||
return _invalidSpacerName('multiple repeating flags');
|
||||
}
|
||||
|
||||
final alignmentFlag = alignmentFlags.isEmpty ? null : alignmentFlags.first;
|
||||
final alignment = switch (alignmentFlag) {
|
||||
'l' => SpacerAlignment.left,
|
||||
'r' => SpacerAlignment.right,
|
||||
'c' => SpacerAlignment.center,
|
||||
_ => null,
|
||||
};
|
||||
final specialType = _specialTypeForText(text);
|
||||
return SpacerChannelNameParseResult(
|
||||
isSpacer: true,
|
||||
isValid: true,
|
||||
alignment: alignment,
|
||||
isRepeating: repeatingFlags == 1,
|
||||
uniqueSuffix: uniqueSuffix,
|
||||
text: text,
|
||||
specialType: specialType,
|
||||
isBlankSpacer: alignment == SpacerAlignment.right && text == '.',
|
||||
);
|
||||
}
|
||||
|
||||
/// Format a TeamSpeak spacer channel name deterministically.
|
||||
///
|
||||
/// The output uses canonical `Spacer` casing and places `*` before
|
||||
/// the alignment flag when both are present.
|
||||
String formatSpacerChannelName(SpacerChannelNameFormatOptions options) {
|
||||
final repeatFlag = options.isRepeating ? '*' : '';
|
||||
final alignFlag = switch (options.alignment) {
|
||||
SpacerAlignment.left => 'l',
|
||||
SpacerAlignment.right => 'r',
|
||||
SpacerAlignment.center => 'c',
|
||||
null => '',
|
||||
};
|
||||
return '[$repeatFlag${alignFlag}Spacer${options.uniqueSuffix}]${options.text}';
|
||||
}
|
||||
|
||||
/// Convert a channel name into display text while preserving the
|
||||
/// underlying channel entity and behavior.
|
||||
String channelSpacerLabel(String raw, {int repeatColumns = 32}) {
|
||||
final parsed = parseSpacerChannelName(raw);
|
||||
if (!parsed.isValid) return raw;
|
||||
if (parsed.isBlankSpacer) return '';
|
||||
if (parsed.isRepeating) {
|
||||
return _repeatSpacerText(parsed.text, repeatColumns);
|
||||
}
|
||||
|
||||
return switch (parsed.specialType) {
|
||||
SpacerSpecialType.solidLine => '────────',
|
||||
SpacerSpecialType.dashLine => '╌╌╌╌╌╌╌╌',
|
||||
SpacerSpecialType.dotLine => '········',
|
||||
SpacerSpecialType.dashDotLine => '─╶─╶─╶─╶─╶─╶─╶─╶',
|
||||
SpacerSpecialType.dashDotDotLine => '─╶╶─╶╶─╶╶─╶╶',
|
||||
null => parsed.text,
|
||||
};
|
||||
}
|
||||
|
||||
SpacerChannelNameParseResult _invalidSpacerName(String reason) {
|
||||
return SpacerChannelNameParseResult(
|
||||
isSpacer: false,
|
||||
isValid: false,
|
||||
alignment: null,
|
||||
isRepeating: false,
|
||||
uniqueSuffix: '',
|
||||
text: '',
|
||||
specialType: null,
|
||||
isBlankSpacer: false,
|
||||
reason: reason,
|
||||
);
|
||||
}
|
||||
|
||||
SpacerSpecialType? _specialTypeForText(String text) {
|
||||
return switch (text) {
|
||||
'___' => SpacerSpecialType.solidLine,
|
||||
'---' => SpacerSpecialType.dashLine,
|
||||
'...' => SpacerSpecialType.dotLine,
|
||||
'-.-' => SpacerSpecialType.dashDotLine,
|
||||
'-..' => SpacerSpecialType.dashDotDotLine,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String _repeatSpacerText(String pattern, int repeatColumns) {
|
||||
if (pattern.isEmpty) return '────────';
|
||||
final buffer = StringBuffer();
|
||||
while (buffer.length < repeatColumns) {
|
||||
buffer.write(pattern);
|
||||
}
|
||||
return buffer.toString().substring(0, repeatColumns);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../design/chanora_tokens.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
|
||||
enum ConnectionPhase {
|
||||
/// App is disconnected, no error.
|
||||
idle,
|
||||
|
||||
/// Connecting to server.
|
||||
connecting,
|
||||
|
||||
/// Connected but still synchronizing snapshot.
|
||||
synchronizing,
|
||||
|
||||
/// Connected and ready.
|
||||
connected,
|
||||
|
||||
/// Connection lost, auto-reconnecting.
|
||||
reconnecting,
|
||||
|
||||
/// Explicitly disconnected by user.
|
||||
disconnected,
|
||||
}
|
||||
|
||||
extension ConnectionPhaseState on ConnectionPhase {
|
||||
bool get isServerReachable =>
|
||||
this == ConnectionPhase.connected ||
|
||||
this == ConnectionPhase.synchronizing ||
|
||||
this == ConnectionPhase.reconnecting;
|
||||
|
||||
bool get canOpenChat =>
|
||||
this == ConnectionPhase.connected ||
|
||||
this == ConnectionPhase.synchronizing;
|
||||
|
||||
bool get canDisconnect => canOpenChat;
|
||||
|
||||
ConnectionTokens tokens(ColorScheme colorScheme) {
|
||||
switch (this) {
|
||||
case ConnectionPhase.idle:
|
||||
case ConnectionPhase.disconnected:
|
||||
return ConnectionTokens.disconnected(colorScheme);
|
||||
case ConnectionPhase.connecting:
|
||||
return ConnectionTokens.connecting(colorScheme);
|
||||
case ConnectionPhase.synchronizing:
|
||||
return ConnectionTokens.synchronizing(colorScheme);
|
||||
case ConnectionPhase.connected:
|
||||
return ConnectionTokens.connected(colorScheme);
|
||||
case ConnectionPhase.reconnecting:
|
||||
return ConnectionTokens.reconnecting(colorScheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String connectionStatusText({
|
||||
required ConnectionPhase phase,
|
||||
required AppL10n l10n,
|
||||
String? serverName,
|
||||
String? lostReason,
|
||||
int? reconnectAttempt,
|
||||
int? reconnectDelay,
|
||||
}) {
|
||||
switch (phase) {
|
||||
case ConnectionPhase.idle:
|
||||
return l10n.statusIdle;
|
||||
case ConnectionPhase.connecting:
|
||||
return l10n.statusConnecting;
|
||||
case ConnectionPhase.synchronizing:
|
||||
return 'Synchronizing...';
|
||||
case ConnectionPhase.connected:
|
||||
return l10n.statusConnected(serverName ?? '');
|
||||
case ConnectionPhase.reconnecting:
|
||||
return reconnectAttempt != null
|
||||
? l10n.statusReconnecting(reconnectAttempt, reconnectDelay ?? 0)
|
||||
: l10n.statusConnectionLost(lostReason ?? '');
|
||||
case ConnectionPhase.disconnected:
|
||||
return l10n.statusIdle;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class LinkTrustService extends ChangeNotifier {
|
||||
static LinkTrustService? _instance;
|
||||
@@ -71,13 +69,12 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: remember,
|
||||
onChanged: (v) => setDialogState(() => remember = v ?? false),
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => remember = v ?? false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Flexible(
|
||||
child: Text('Trust all links from this domain'),
|
||||
),
|
||||
const Flexible(child: Text('Trust all links from this domain')),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
class OwnClientSnapshotState {
|
||||
const OwnClientSnapshotState({
|
||||
required this.channelId,
|
||||
required this.inputMuted,
|
||||
required this.outputMuted,
|
||||
required this.talkPowerOk,
|
||||
required this.talkPower,
|
||||
required this.talkPowerGranted,
|
||||
this.neededTalkPower,
|
||||
});
|
||||
|
||||
final BigInt channelId;
|
||||
final bool inputMuted;
|
||||
final bool outputMuted;
|
||||
final bool talkPowerOk;
|
||||
final int talkPower;
|
||||
final bool talkPowerGranted;
|
||||
final int? neededTalkPower;
|
||||
}
|
||||
|
||||
OwnClientSnapshotState? ownClientSnapshotState(rust.BridgeSnapshot snapshot) {
|
||||
for (final client in snapshot.clients) {
|
||||
if (client.id != snapshot.ownClientId) continue;
|
||||
final neededTalkPower = _channelById(
|
||||
snapshot,
|
||||
client.channel,
|
||||
)?.neededTalkPower;
|
||||
return OwnClientSnapshotState(
|
||||
channelId: client.channel,
|
||||
inputMuted: client.inputMuted,
|
||||
outputMuted: client.outputMuted,
|
||||
talkPowerOk: _talkPowerOk(client, neededTalkPower),
|
||||
talkPower: client.talkPower,
|
||||
talkPowerGranted: client.talkPowerGranted,
|
||||
neededTalkPower: neededTalkPower,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String snapshotChannelName(rust.BridgeSnapshot? snapshot, BigInt? channelId) {
|
||||
if (snapshot == null || channelId == null) return '';
|
||||
return _channelById(snapshot, channelId)?.name ?? '';
|
||||
}
|
||||
|
||||
int? snapshotNeededTalkPower(rust.BridgeSnapshot? snapshot, BigInt? channelId) {
|
||||
if (snapshot == null || channelId == null) return null;
|
||||
return _channelById(snapshot, channelId)?.neededTalkPower;
|
||||
}
|
||||
|
||||
rust.BridgeChannel? _channelById(
|
||||
rust.BridgeSnapshot snapshot,
|
||||
BigInt channelId,
|
||||
) {
|
||||
return snapshot.channels
|
||||
.where((channel) => channel.id == channelId)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
bool _talkPowerOk(rust.BridgeClient client, int? neededTalkPower) {
|
||||
if (client.talkPowerGranted) return true;
|
||||
return neededTalkPower == null || client.talkPower >= neededTalkPower;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
typedef Ts3ServerLinkHandler = Future<void> Function(Ts3ServerLink link);
|
||||
|
||||
class Ts3ServerLink {
|
||||
const Ts3ServerLink({
|
||||
required this.host,
|
||||
required this.hostWithPort,
|
||||
this.port,
|
||||
this.nickname,
|
||||
this.password,
|
||||
this.channel,
|
||||
this.cid,
|
||||
this.channelPassword,
|
||||
this.token,
|
||||
this.addBookmark,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final String hostWithPort;
|
||||
final int? port;
|
||||
final String? nickname;
|
||||
final String? password;
|
||||
final String? channel;
|
||||
final String? cid;
|
||||
final String? channelPassword;
|
||||
final String? token;
|
||||
final String? addBookmark;
|
||||
}
|
||||
|
||||
Ts3ServerLink? parseTs3ServerLink(String rawUrl) {
|
||||
final trimmed = rawUrl.trim();
|
||||
if (!trimmed.toLowerCase().startsWith('ts3server://')) return null;
|
||||
|
||||
var body = trimmed.substring('ts3server://'.length);
|
||||
body = body.replaceFirst(RegExp(r'/+$'), '');
|
||||
if (body.isEmpty) return null;
|
||||
|
||||
final split = _splitAuthorityAndQuery(body);
|
||||
final hostPart = Uri.decodeComponent(split.authority).trim();
|
||||
if (hostPart.isEmpty) return null;
|
||||
|
||||
final params = split.query == null
|
||||
? const <String, String>{}
|
||||
: Uri.splitQueryString(split.query!);
|
||||
final port = int.tryParse(params['port'] ?? '');
|
||||
final hostWithPort = port == null || _hasExplicitPort(hostPart)
|
||||
? hostPart
|
||||
: '$hostPart:$port';
|
||||
|
||||
return Ts3ServerLink(
|
||||
host: hostPart,
|
||||
hostWithPort: hostWithPort,
|
||||
port: port,
|
||||
nickname: _emptyToNull(params['nickname']),
|
||||
password: _emptyToNull(params['password']),
|
||||
channel: _emptyToNull(params['channel']),
|
||||
cid: _emptyToNull(params['cid']),
|
||||
channelPassword: _emptyToNull(params['channelpassword']),
|
||||
token: _emptyToNull(params['token']),
|
||||
addBookmark: _emptyToNull(params['addbookmark']),
|
||||
);
|
||||
}
|
||||
|
||||
({String authority, String? query}) _splitAuthorityAndQuery(String body) {
|
||||
final rawQueryIndex = body.indexOf('?');
|
||||
if (rawQueryIndex >= 0) {
|
||||
return (
|
||||
authority: body.substring(0, rawQueryIndex),
|
||||
query: body.substring(rawQueryIndex + 1),
|
||||
);
|
||||
}
|
||||
|
||||
final encodedQueryIndex = body.toLowerCase().indexOf('%3f');
|
||||
if (encodedQueryIndex >= 0) {
|
||||
return (
|
||||
authority: body.substring(0, encodedQueryIndex),
|
||||
query: Uri.decodeComponent(body.substring(encodedQueryIndex + 3)),
|
||||
);
|
||||
}
|
||||
|
||||
return (authority: body, query: null);
|
||||
}
|
||||
|
||||
bool _hasExplicitPort(String host) {
|
||||
final lastColon = host.lastIndexOf(':');
|
||||
if (lastColon <= 0 || lastColon == host.length - 1) return false;
|
||||
return int.tryParse(host.substring(lastColon + 1)) != null;
|
||||
}
|
||||
|
||||
String? _emptyToNull(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return value;
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class UiSettings {
|
||||
const UiSettings({
|
||||
this.host = '',
|
||||
this.nickname = '',
|
||||
this.showPokeDialogs = true,
|
||||
});
|
||||
const UiSettings({this.host = '', this.nickname = ''});
|
||||
|
||||
final String host;
|
||||
final String nickname;
|
||||
final bool showPokeDialogs;
|
||||
}
|
||||
|
||||
class UiPreferencesService {
|
||||
static const _hostKey = 'ui.host';
|
||||
static const _nicknameKey = 'ui.nickname';
|
||||
static const _showPokeDialogsKey = 'ui.show_poke_dialogs';
|
||||
static const _permissionsExplainedKey = 'perms_explained';
|
||||
|
||||
const UiPreferencesService();
|
||||
@@ -25,21 +19,13 @@ class UiPreferencesService {
|
||||
return UiSettings(
|
||||
host: prefs.getString(_hostKey) ?? '',
|
||||
nickname: prefs.getString(_nicknameKey) ?? '',
|
||||
showPokeDialogs: prefs.getBool(_showPokeDialogsKey) ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveSettings({
|
||||
String? host,
|
||||
String? nickname,
|
||||
bool? showPokeDialogs,
|
||||
}) async {
|
||||
Future<void> saveSettings({String? host, String? nickname}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (host != null) await prefs.setString(_hostKey, host);
|
||||
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
|
||||
if (showPokeDialogs != null) {
|
||||
await prefs.setBool(_showPokeDialogsKey, showPokeDialogs);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasExplainedPermissions() async {
|
||||
|
||||
Reference in New Issue
Block a user