The famedly SDK only knows /sync, but its sync loop asks Client.sync for each response. WarpClient answers that with a sliding sync request and rewrites the response into the /sync shape (slidingToSyncJson), so decryption, the database, timelines and notifications work unchanged. - Two lists, rooms and direct chats (with all their members, so DM names come out right), over a window that starts at the 20 most recent rooms for a quick first screen and widens to every room (up to 1000). - The state the screens and encryption need, $LAZY members and $ME, and call memberships; timeline_limit 10, with prev_batch for history. - Extensions: to_device (its since rides in the token), e2ee, account data, receipts, typing. - Rooms the user left while the connection was open become leaves; invite_state and knock_state become invites and knocks. - The SDK's token is ss1|<pos>|<to-device since>. A classic token, none, or M_UNKNOWN_POS starts a new connection; a server without org.matrix.simplified_msc3575 gets plain /sync. Tested against Warp: first sync, encryption setup (account data and oneShotSync through the connection), live messages, unread counts and typing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.5 KiB
Dart
189 lines
6.5 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 '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,
|
|
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: ', '');
|
|
}
|