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 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 resolveHomeserver(Client client, String input) async { final text = input.trim(); if (text.isEmpty) throw const FormatException('Enter a server.'); final candidates = []; 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: ', ''); }