Files
Warp/apps/mobile/lib/matrix/client.dart
T
tajniak81andClaude Opus 5.5 7d13d4fcfd Mobile: Simplified Sliding Sync (MSC4186)
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>
2026-10-18 23:22:01 +02:00

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: ', '');
}