feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
@@ -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 {