Found testing calls between two phones on the Compose call stack: - The famedly SDK keeps only "important" state in memory for a room that hasn't been opened, so call memberships of other rooms were invisible: no ring (it was cleared at once, the call looking empty), no room-list marker. org.matrix.msc3401.call.member is now important state. - The incoming-call card closed itself with Navigator.pop when the ring cleared, which popped the call screen pushed above it on Answer. The buttons now close the card first, and it closes itself only while it is the top route. Tested: a voice call in a room of two (ring, answer, both connected through LiveKit), and a video call in an encrypted room (to-device media keys, frame cryptors OK on both sides, each phone showing the other's video). The web client sees a phone's membership and rings for its notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
6.7 KiB
Dart
193 lines
6.7 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:flutter_vodozemac/flutter_vodozemac.dart' as vod;
|
|
import 'package:matrix/encryption.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:sqflite/sqflite.dart' as sqflite;
|
|
|
|
import 'calls.dart';
|
|
import 'sliding_sync.dart';
|
|
|
|
/// The name the session's device gets on the homeserver.
|
|
const deviceDisplayName = 'Warp Android';
|
|
|
|
const _secureStorage = FlutterSecureStorage();
|
|
|
|
/// Creates the one [Client] the app uses and restores its session, if any.
|
|
///
|
|
/// Sync state, rooms and the Olm/Megolm keys live in a SQLite database in the
|
|
/// app's private storage. The SDK also writes its credentials (the access
|
|
/// token and the cached secrets) to [FlutterSecureStorage], the Android
|
|
/// Keystore, and reads them from there first.
|
|
///
|
|
/// [startSync] is false when the app was started in the background to show a
|
|
/// push notification: that only needs the session, not a sync loop.
|
|
Future<Client> createClient({bool startSync = true}) async {
|
|
await vod.init();
|
|
|
|
final dir = await getApplicationSupportDirectory();
|
|
// The SDK's file store (attachments being sent, downloaded media) writes
|
|
// into this directory but doesn't create it.
|
|
final media = await Directory(p.join(dir.path, 'media')).create();
|
|
final database = await MatrixSdkDatabase.init(
|
|
'warp',
|
|
database: await sqflite.openDatabase(p.join(dir.path, 'warp.sqlite')),
|
|
fileStorageLocation: media.uri,
|
|
deleteFilesAfterDuration: const Duration(days: 30),
|
|
secureStorage: (
|
|
read: (key) => _secureStorage.read(key: key),
|
|
write: (key, value) => _secureStorage.write(key: key, value: value),
|
|
delete: (key) => _secureStorage.delete(key: key),
|
|
),
|
|
);
|
|
|
|
final client = WarpClient(
|
|
'Warp',
|
|
database: database,
|
|
verificationMethods: {
|
|
KeyVerificationMethod.emoji,
|
|
KeyVerificationMethod.numbers,
|
|
KeyVerificationMethod.qrShow,
|
|
KeyVerificationMethod.qrScan,
|
|
KeyVerificationMethod.reciprocate,
|
|
},
|
|
supportedLoginTypes: {AuthenticationTypes.password},
|
|
nativeImplementations: NativeImplementationsIsolate(
|
|
compute,
|
|
vodozemacInit: () => vod.init(),
|
|
),
|
|
logLevel: kReleaseMode ? Level.warning : Level.info,
|
|
// Kept in memory for every room, not just opened ones: the room list
|
|
// marks rooms with a call going on, and a ring needs to know who is in
|
|
// the call.
|
|
importantStateEvents: {'im.ponies.room_emotes', Calls.memberType},
|
|
// Refresh tokens: a soft logout (an expired access token) is answered by
|
|
// refreshing it rather than by signing the user out.
|
|
onSoftLogout: (client) => client.refreshAccessToken(),
|
|
);
|
|
client.backgroundSync = startSync;
|
|
await client.init(
|
|
waitForFirstSync: false,
|
|
waitUntilLoadCompletedLoaded: startSync,
|
|
);
|
|
return client;
|
|
}
|
|
|
|
/// What the user typed as their server, resolved to a homeserver.
|
|
///
|
|
/// Accepts a server name (`example.org`, looked up through
|
|
/// `/.well-known/matrix/client`), a homeserver address
|
|
/// (`https://matrix.example.org`) or, for development, a plain HTTP one
|
|
/// (`http://10.0.2.2:8008`, which the emulator sees as the host machine).
|
|
/// A bare `host:port` on a private address is tried over HTTPS first, then
|
|
/// over HTTP.
|
|
Future<void> resolveHomeserver(Client client, String input) async {
|
|
final text = input.trim();
|
|
if (text.isEmpty) throw const FormatException('Enter a server.');
|
|
|
|
final candidates = <Uri>[];
|
|
if (text.startsWith('http://') || text.startsWith('https://')) {
|
|
final uri = Uri.tryParse(text);
|
|
if (uri == null || uri.host.isEmpty) {
|
|
throw const FormatException("That isn't a server address.");
|
|
}
|
|
candidates.add(uri);
|
|
} else {
|
|
final https = Uri.tryParse('https://$text');
|
|
if (https == null || https.host.isEmpty) {
|
|
throw const FormatException("That isn't a server name.");
|
|
}
|
|
candidates.add(https);
|
|
if (_isLocal(https.host)) candidates.add(https.replace(scheme: 'http'));
|
|
}
|
|
|
|
Object? lastError;
|
|
for (final uri in candidates) {
|
|
try {
|
|
await client.checkHomeserver(
|
|
uri,
|
|
checkWellKnown: uri.scheme == 'https' && !uri.hasPort,
|
|
fetchAuthMetadata: false,
|
|
);
|
|
return;
|
|
} catch (e) {
|
|
lastError = e;
|
|
}
|
|
}
|
|
throw HomeserverException(text, lastError);
|
|
}
|
|
|
|
bool _isLocal(String host) =>
|
|
host == 'localhost' ||
|
|
host.endsWith('.localhost') ||
|
|
host.endsWith('.local') ||
|
|
host.startsWith('10.') ||
|
|
host.startsWith('192.168.') ||
|
|
host.startsWith('127.') ||
|
|
RegExp(r'^172\.(1[6-9]|2\d|3[01])\.').hasMatch(host);
|
|
|
|
class HomeserverException implements Exception {
|
|
HomeserverException(this.input, this.cause);
|
|
final String input;
|
|
final Object? cause;
|
|
|
|
@override
|
|
String toString() {
|
|
final cause = this.cause;
|
|
if (cause is BadServerLoginTypesException) {
|
|
return "$input doesn't offer sign-in with a password.";
|
|
}
|
|
return "Couldn't reach a Matrix server at $input.";
|
|
}
|
|
}
|
|
|
|
/// The server part of a user ID typed in full (`@alice:example.org`), so
|
|
/// signing in with one doesn't need the server typed separately.
|
|
String? serverOfUserId(String input) {
|
|
final text = input.trim();
|
|
if (!text.startsWith('@')) return null;
|
|
final i = text.indexOf(':');
|
|
if (i < 0 || i == text.length - 1) return null;
|
|
return text.substring(i + 1);
|
|
}
|
|
|
|
/// A human sentence for an error from the SDK or the homeserver.
|
|
String describeError(Object error) {
|
|
if (error is MatrixException) {
|
|
switch (error.error) {
|
|
case MatrixError.M_FORBIDDEN:
|
|
return error.errorMessage.isNotEmpty
|
|
? error.errorMessage
|
|
: "You aren't allowed to do that.";
|
|
case MatrixError.M_USER_IN_USE:
|
|
return 'That username is taken.';
|
|
case MatrixError.M_INVALID_USERNAME:
|
|
return "That username isn't allowed.";
|
|
case MatrixError.M_LIMIT_EXCEEDED:
|
|
return 'Too many attempts. Wait a moment and try again.';
|
|
case MatrixError.M_NOT_FOUND:
|
|
return error.errorMessage.isNotEmpty
|
|
? error.errorMessage
|
|
: "That doesn't exist.";
|
|
default:
|
|
return error.errorMessage.isNotEmpty
|
|
? error.errorMessage
|
|
: error.errcode;
|
|
}
|
|
}
|
|
if (error is HomeserverException || error is FormatException) {
|
|
return error is FormatException ? error.message : error.toString();
|
|
}
|
|
final text = error.toString();
|
|
if (text.contains('SocketException') ||
|
|
text.contains('ClientException') ||
|
|
text.contains('TimeoutException')) {
|
|
return "Couldn't reach the server. Check your connection.";
|
|
}
|
|
return text.replaceFirst('Exception: ', '');
|
|
}
|