import "dart:async"; import "dart:ffi"; import "dart:io"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:ffi/ffi.dart"; import "package:flutter/cupertino.dart"; import "package:intl/intl.dart"; import "package:nexus/controllers/accounts.dart"; import "package:nexus/controllers/active_account.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/helpers/extensions/event_id_is_fake.dart"; import "package:nexus/helpers/extensions/thread_events.dart"; import "package:nexus/main.dart"; import "package:nexus/models/content/bot_command_description.dart"; import "package:nexus/models/account.dart"; import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/event_context.dart"; import "package:nexus/models/mentions_response.dart"; import "package:nexus/models/gomuks_config.dart"; import "package:nexus/models/gomuks_exception.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/paginate.dart"; import "package:nexus/models/paginate_manual.dart"; import "package:nexus/models/requests/create_room.dart"; import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/get_event_context.dart"; import "package:nexus/models/requests/get_mentions.dart"; import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_specific_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/get_room_directory.dart"; import "package:nexus/models/requests/get_threads.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; import "package:nexus/models/push_notification.dart"; import "package:nexus/models/recovery_key.dart"; import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/register_client.dart"; import "package:nexus/models/requests/paginate.dart"; import "package:nexus/models/requests/paginate_manual.dart"; import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/report.dart"; import "package:nexus/models/requests/search.dart"; import "package:nexus/models/requests/send_event.dart"; import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/set_account_data.dart"; import "package:nexus/models/requests/set_membership.dart"; import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/update_push_rule.dart"; import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/room_directory.dart"; import "package:nexus/models/room_summary.dart"; import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/spec_versions_response.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:path/path.dart"; import "package:path_provider/path_provider.dart"; class ClientController(final String? accountId) extends AsyncNotifier { @override Future build() async { final String account = accountId ?? ref.watch(ActiveAccountController.provider); if (accountId == null && !isInBackground) await _deleteRemovedAccounts(); final environment = await _environment(account); debugPrint("Setting Gomuks env for $account..."); using((arena) { for (final MapEntry(:key, :value) in environment.entries) { GomuksSetEnv( key.toNativeUtf8(allocator: arena).cast(), value.toNativeUtf8(allocator: arena).cast(), ); } }, malloc); debugPrint("Initializing Gomuks..."); final config = GomuksConfig( matrix: .new( initialDeviceDisplayName: "Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}", ), ).toJson(); final handle = using( (arena) => GomuksInit(config.toGomuksBufferPtr(arena).ref), malloc, ); if (!ref.mounted) { GomuksDestroy(handle); return Completer().future; } ref.onDispose(() => GomuksDestroy(handle)); if (accountId != null) { await _callGomuksMethod(handle, {}, GomuksHandlePushAsync); } return handle; } static Future<({Directory data, Directory cache})> _accountsDirectories() async => ( data: Directory( join((await getApplicationSupportDirectory()).path, "accounts"), ), cache: Directory( join((await getApplicationCacheDirectory()).path, "accounts"), ), ); static Future> _environment(String accountId) async { if (accountId == Account.legacyId) { // Keep the directories gomuks used before account switching existed final isMobile = Platform.isAndroid || Platform.isIOS; return .new({ "GOMUKS_ROOT": isMobile ? (await getApplicationSupportDirectory()).path : Platform.environment["GOMUKS_ROOT"] ?? "", "GOMUKS_CACHE_HOME": isMobile ? (await getApplicationCacheDirectory()).path : Platform.environment["GOMUKS_CACHE_HOME"] ?? "", }); } final (:data, :cache) = await _accountsDirectories(); return .new({ "GOMUKS_ROOT": join(data.path, accountId), "GOMUKS_CACHE_HOME": join(cache.path, accountId), }); } // Can't be done on removal, since the old container still has them open Future _deleteRemovedAccounts() async { final ids = (await ref.read(AccountsController.provider.future)) .map((account) => account.id) .toISet(); final (:data, :cache) = await _accountsDirectories(); final removed = await Stream.fromIterable([data, cache]) .where((directory) => directory.existsSync()) .asyncExpand((directory) => directory.list()) .where((entry) => !ids.contains(basename(entry.path))) .toList(); await Future.wait(removed.map((entry) => entry.delete(recursive: true))); } /// Calls gomuks with [data] as JSON, returning its decoded response. Future callGomuksMethod( Map data, void Function( int handle, GomuksOwnedBuffer data, Pointer> callback, ) call, ) async => _callGomuksMethod(await future, data, call); static Future _callGomuksMethod( int handle, Map data, void Function( int handle, GomuksOwnedBuffer data, Pointer> callback, ) call, ) { final buffer = data.toGomuksOwnedBuffer(); final completer = Completer(); late final NativeCallable callable; callable = .listener((GomuksResponse response) { callable.close(); try { final json = response.buf.toJsonAndFree(); if (response.command.cast().toDartString() == "error") { throw GomuksException(json.toString()); } completer.complete(json); } catch (error, stackTrace) { completer.completeError(error, stackTrace); } }); try { call(handle, buffer, callable.nativeFunction); } catch (_) { // gomuks never got the call, so it won't respond or free the buffer callable.close(); GomuksFreeBuffer(buffer); rethrow; } return completer.future; } Future handlePush(Map data) async { final response = await callGomuksMethod(data, GomuksHandlePushAsync); return response?["notification"] == null ? null : .fromJson(response["notification"]); } /// Sends [command] to gomuks, returning its decoded response. Overridable so /// that tests can answer commands without gomuks. @protected Future sendCommand( String command, [ Map data = const {}, ]) => callGomuksMethod(data, (handle, data, callback) { // gomuks copies the command name before returning using( (arena) => GomuksSubmitCommandAsync( handle, command.toNativeUtf8(allocator: arena).cast(), data, callback, ), malloc, ); }); Future redactEvent(RedactEventRequest report) => sendCommand("redact_event", report.toJson()); /// Null for gomuks' own commands that don't reply. Future sendMessage(SendMessageRequest request) async => switch (await sendCommand("send_message", request.toJson())) { final Map json => .fromJson(json), _ => null, }; /// The MSC4391 definitions of gomuks' own commands. Future> getCommandDefinitions() async => switch (await sendCommand("get_command_definitions")) { final List definitions => .new( definitions.whereType>().map( BotCommandDescriptionContent.fromJson, ), ), _ => const .empty(), }; Future sendEvent(SendEventRequest request) async => Event.fromJson(await sendCommand("send_event", request.toJson())); Future setState(SetStateRequest request) async => await sendCommand("set_state", request.toJson()); Future pauseSync() => sendCommand("pause_sync"); Future resumeSync() => sendCommand("resume_sync"); Future verify(String recoveryKey) => sendCommand("verify", {"recovery_key": recoveryKey}); Future generateRecoveryKey(String passphrase) async => .fromJson( await sendCommand("generate_recovery_key", {"passphrase": passphrase}), ); Future resetEncryption(RecoveryKey key) => sendCommand("reset_encryption", key.toJson()); Future getCrossSigningResetUrl(Uri homeserver) async { const action = "org.matrix.cross_signing_reset"; final oauth = (await sendCommand("get_login_flows", { "homeserver_url": homeserver.toString(), }))["oauth"]; if (oauth?["account_management_uri"] case final String uri when (oauth["account_management_actions_supported"] as List?)?.contains( action, ) ?? false) { final parsed = Uri.parse(uri); return parsed.replace( queryParameters: {...parsed.queryParameters, "action": action}, ); } return null; } Future joinRoom(JoinRoomRequest request) async => (await sendCommand("join_room", request.toJson()))["room_id"]; Future knockRoom(JoinRoomRequest request) async => (await sendCommand("knock_room", request.toJson()))["room_id"]; Future createRoom(CreateRoomRequest request) async => (await sendCommand("create_room", request.toJson()))["room_id"]; Future getSpaceHierarchy( GetSpaceHierarchyRequest request, ) async => .fromJson(await sendCommand("get_space_hierarchy", request.toJson())); Future getRoomDirectory( GetRoomDirectoryRequest request, ) async => .fromJson(await sendCommand("get_room_directory", request.toJson())); Future getRoomSummary(JoinRoomRequest request) async => .fromJson(await sendCommand("get_room_summary", request.toJson())); Future leaveRoom(String roomId) => sendCommand("leave_room", {"room_id": roomId}); Future> getRoomState(GetRoomStateRequest request) async { Future getState(GetRoomStateRequest request) async => (await sendCommand("get_room_state", request.toJson())) as List?; final response = await getState(request); return .new( (response ?? await getState(request.copyWith(refetch: true)) ?? []).map( (event) => .fromJson(event), ), ); } Future> getSpecificRoomState( GetSpecificRoomStateRequest request, ) async => .new( ((await sendCommand("get_specific_room_state", request.toJson())) as List? ?? []) .map((event) => .fromJson(event)), ); Future?> getRelatedEvents( GetRelatedEventsRequest request, ) async { final response = (await sendCommand("get_related_events", request.toJson())) as List?; return .new(response?.map((event) => .fromJson(event))); } Future getMentions(GetMentionsRequest request) async => .fromJson(await sendCommand("get_mentions", request.toJson())); Future getEvent(GetEventRequest request) async { final json = await sendCommand("get_event", request.toJson()); return json == null ? null : .fromJson(json); } Future getUrlPreview(Uri url) async => .fromJson(await sendCommand("get_url_preview", {"url": url.toString()})); Future paginate(PaginateRequest request) async => .fromJson(await sendCommand("paginate", request.toJson())); Future getThreads(GetThreadsRequest request) async => .fromJson(await sendCommand("get_threads", request.toJson())); Future paginateManual(PaginateManualRequest request) async => .fromJson(await sendCommand("paginate_manual", request.toJson())); Future searchLocal(SearchRequest request) async => .fromJson(await sendCommand("search_local", request.toJson())); Future searchServer(SearchRequest request) async => .fromJson(await sendCommand("search_server", request.toJson())); Future getEventContext(GetEventContextRequest request) async => .fromJson(await sendCommand("get_event_context", request.toJson())); Future getProfile(String userId) async { try { return .fromJson(await sendCommand("get_profile", {"user_id": userId})); } catch (_) { return .new(profile: .new(id: userId)); } } Future setProfileField(String field, Object? value) => sendCommand( "set_profile_field", { "field": field, // gomuks deletes the field when the value is omitted, not when it's null "value": ?value, }, ); Future reportEvent(ReportRequest request) => sendCommand("report_event", request.toJson()); Future setMembership(SetMembershipRequest request) => sendCommand("set_membership", request.toJson()); Future setAccountData(SetAccountDataRequest request) => sendCommand("set_account_data", request.toJson()); Future updatePushRule(UpdatePushRuleRequest request) => sendCommand("update_push_rule", request.toJson()); Future registerPusher(RegisterPusherRequest request) => sendCommand("register_homeserver_push", request.toJson()); Future deregisterPusher(DeregisterPusherRequest request) => sendCommand("register_homeserver_push", { ...request.toJson(), "kind": null, }); Future uploadMedia(UploadMediaRequest request) async => .fromJson(await sendCommand("upload_media", request.toJson())); Future downloadMedia(DownloadMediaRequest request) async { if (accountId == null && ref.read(ClientStateController.provider)?.isLoggedIn != true) { throw StateError("Can't download media while logged out"); } return .new( (await sendCommand("download_media", request.toJson()))["path"], ); } Future logout() => sendCommand("logout"); Future setTyping(String roomId, Duration timeout) => sendCommand( "set_typing", {"room_id": roomId, "timeout": timeout.inMilliseconds}, ); Future markRead(Room room, {String? threadRoot}) async { if (room.metadata == null) return; final event = switch (threadRoot) { final threadRoot? => [ ?room.latestThreadEvent(threadRoot), ...room.threadEvents(threadRoot).reversed, ], null => room.timeline.entries .toIList() .sort((a, b) => b.key.compareTo(a.key)) .map((entry) => entry.value) .nonNulls .map((rowId) => room.events[rowId]) .nonNulls, }.where((event) => !event.eventId.isFake).firstOrNull; if (event == null) return; // Private until settings have loaded, in case the user turned sending off final sendPublic = ref.read(SettingsController.provider).value?.readReceipts == .showAndSend; await sendCommand("mark_read", { "room_id": room.metadata!.id, "receipt_type": sendPublic ? "m.read" : "m.read.private", "event_id": event.eventId, "thread_id": ?threadRoot, }); } Future registerClient(OAuthRegisterClientRequest request) async => (await sendCommand( "oauth_register_client", request.toJson(), ))["client_id"]; Future getAuthUrl(OAuthGetAuthUrl request) async => .fromJson( await sendCommand("oauth_get_authorization_url", request.toJson()), ); Future exchangeToken(OAuthExchangeTokenRequest request) async => await sendCommand("oauth_exchange_token", request.toJson()); Future> getMutualRooms(String userId) async => IList( ((await sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"] as List?) ?.cast(), ); Future> getLocalAliases(String roomId) async => IList( ((await sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"] as List?) ?.cast(), ); Future createAlias(String roomId, String alias) => sendCommand("create_alias", {"room_id": roomId, "alias": alias}); Future deleteAlias(String alias) => sendCommand("delete_alias", {"alias": alias}); Future getRoomPublished(String roomId) async => (await sendCommand("get_room_visibility", { "room_id": roomId, }))["visibility"] == "public"; Future setRoomPublished(String roomId, bool published) => sendCommand( "publish_room", {"room_id": roomId, "visibility": published ? "public" : "private"}, ); Future upgradeRoom( String roomId, String version, { ISet additionalCreators = const .empty(), }) async => (await sendCommand("upgrade_room", { "room_id": roomId, "new_version": version, if (additionalCreators.isNotEmpty) "additional_creators": additionalCreators.toList(), }))["replacement_room"]; /// The room [alias] points to, or null if it doesn't exist. Future resolveAlias(String alias) async { try { return (await sendCommand("resolve_alias", {"alias": alias}))["room_id"]; } on GomuksException catch (error) { if (error.statusCode == 404) return null; rethrow; } } Future getSpecVersions() async => .fromJson(await sendCommand("get_versions")); Future getCapabilities() async => .fromJson((await sendCommand("get_capabilities"))["capabilities"]); Future discoverHomeserver(Uri homeserver) async { try { final response = await sendCommand("discover_homeserver", { "user_id": "@fake-user:${homeserver.authority}", }); return Uri.parse(response["m.homeserver"]?["base_url"]); } catch (error) { return null; } } static final provider = AsyncNotifierProvider( () => ClientController(null), ); /// The instance for an account other than the active one. static final forAccount = AsyncNotifierProvider.family( ClientController.new, ); }