Files
Warp/apps/mobile/lib/matrix/client.dart
T
tajniak81andClaude Opus 5.5 0fc8ee809d Mobile: QR-code verification
When both sides support it, a verification offers QR codes first: this
device shows its code (in a white quiet zone, whatever the theme) and can
scan the other's with the camera (mobile_scanner; only codes that start
with MATRIX count). The side whose code was scanned confirms the other
saw it succeed. Compare emoji stays available, and is used directly when
either side has no QR support.

Adds the CAMERA permission (camera hardware not required).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:16:27 +02:00

187 lines
6.4 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;
/// 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 = Client(
'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,
importantStateEvents: {'im.ponies.room_emotes'},
// 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: ', '');
}