nexus/lib/controllers/client.dart

566 lines
20 KiB
Dart

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<int> {
@override
Future<int> 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<int>().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<IMap<String, String>> _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<void> _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<dynamic> callGomuksMethod(
Map<String, dynamic> data,
void Function(
int handle,
GomuksOwnedBuffer data,
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) async => _callGomuksMethod(await future, data, call);
static Future<dynamic> _callGomuksMethod(
int handle,
Map<String, dynamic> data,
void Function(
int handle,
GomuksOwnedBuffer data,
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) {
final buffer = data.toGomuksOwnedBuffer();
final completer = Completer<dynamic>();
late final NativeCallable<Void Function(GomuksResponse)> callable;
callable = .listener((GomuksResponse response) {
callable.close();
try {
final json = response.buf.toJsonAndFree();
if (response.command.cast<Utf8>().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<PushNotification?> handlePush(Map<String, dynamic> 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<dynamic> sendCommand(
String command, [
Map<String, dynamic> 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<void> redactEvent(RedactEventRequest report) =>
sendCommand("redact_event", report.toJson());
/// Null for gomuks' own commands that don't reply.
Future<Event?> sendMessage(SendMessageRequest request) async =>
switch (await sendCommand("send_message", request.toJson())) {
final Map<String, dynamic> json => .fromJson(json),
_ => null,
};
/// The MSC4391 definitions of gomuks' own commands.
Future<IList<BotCommandDescriptionContent>> getCommandDefinitions() async =>
switch (await sendCommand("get_command_definitions")) {
final List definitions => .new(
definitions.whereType<Map<String, dynamic>>().map(
BotCommandDescriptionContent.fromJson,
),
),
_ => const .empty(),
};
Future<Event> sendEvent(SendEventRequest request) async =>
Event.fromJson(await sendCommand("send_event", request.toJson()));
Future<String?> setState(SetStateRequest request) async =>
await sendCommand("set_state", request.toJson());
Future<void> pauseSync() => sendCommand("pause_sync");
Future<void> resumeSync() => sendCommand("resume_sync");
Future<void> verify(String recoveryKey) =>
sendCommand("verify", {"recovery_key": recoveryKey});
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
await sendCommand("generate_recovery_key", {"passphrase": passphrase}),
);
Future<void> resetEncryption(RecoveryKey key) =>
sendCommand("reset_encryption", key.toJson());
Future<Uri?> 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<String> joinRoom(JoinRoomRequest request) async =>
(await sendCommand("join_room", request.toJson()))["room_id"];
Future<String> knockRoom(JoinRoomRequest request) async =>
(await sendCommand("knock_room", request.toJson()))["room_id"];
Future<String> createRoom(CreateRoomRequest request) async =>
(await sendCommand("create_room", request.toJson()))["room_id"];
Future<SpaceHierarchy> getSpaceHierarchy(
GetSpaceHierarchyRequest request,
) async =>
.fromJson(await sendCommand("get_space_hierarchy", request.toJson()));
Future<RoomDirectory> getRoomDirectory(
GetRoomDirectoryRequest request,
) async =>
.fromJson(await sendCommand("get_room_directory", request.toJson()));
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
.fromJson(await sendCommand("get_room_summary", request.toJson()));
Future<void> leaveRoom(String roomId) =>
sendCommand("leave_room", {"room_id": roomId});
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
Future<List?> 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<IList<Event>> getSpecificRoomState(
GetSpecificRoomStateRequest request,
) async => .new(
((await sendCommand("get_specific_room_state", request.toJson()))
as List? ??
[])
.map((event) => .fromJson(event)),
);
Future<IList<Event>?> getRelatedEvents(
GetRelatedEventsRequest request,
) async {
final response =
(await sendCommand("get_related_events", request.toJson())) as List?;
return .new(response?.map((event) => .fromJson(event)));
}
Future<MentionsResponse> getMentions(GetMentionsRequest request) async =>
.fromJson(await sendCommand("get_mentions", request.toJson()));
Future<Event?> getEvent(GetEventRequest request) async {
final json = await sendCommand("get_event", request.toJson());
return json == null ? null : .fromJson(json);
}
Future<OpenGraphData> getUrlPreview(Uri url) async =>
.fromJson(await sendCommand("get_url_preview", {"url": url.toString()}));
Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await sendCommand("paginate", request.toJson()));
Future<PaginateManual> getThreads(GetThreadsRequest request) async =>
.fromJson(await sendCommand("get_threads", request.toJson()));
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await sendCommand("paginate_manual", request.toJson()));
Future<PaginateManual> searchLocal(SearchRequest request) async =>
.fromJson(await sendCommand("search_local", request.toJson()));
Future<PaginateManual> searchServer(SearchRequest request) async =>
.fromJson(await sendCommand("search_server", request.toJson()));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await sendCommand("get_event_context", request.toJson()));
Future<ProfileResponse> getProfile(String userId) async {
try {
return .fromJson(await sendCommand("get_profile", {"user_id": userId}));
} catch (_) {
return .new(profile: .new(id: userId));
}
}
Future<void> 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<void> reportEvent(ReportRequest request) =>
sendCommand("report_event", request.toJson());
Future<void> setMembership(SetMembershipRequest request) =>
sendCommand("set_membership", request.toJson());
Future<void> setAccountData(SetAccountDataRequest request) =>
sendCommand("set_account_data", request.toJson());
Future<void> updatePushRule(UpdatePushRuleRequest request) =>
sendCommand("update_push_rule", request.toJson());
Future<void> registerPusher(RegisterPusherRequest request) =>
sendCommand("register_homeserver_push", request.toJson());
Future<void> deregisterPusher(DeregisterPusherRequest request) =>
sendCommand("register_homeserver_push", {
...request.toJson(),
"kind": null,
});
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
.fromJson(await sendCommand("upload_media", request.toJson()));
Future<File> 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<void> logout() => sendCommand("logout");
Future<void> setTyping(String roomId, Duration timeout) => sendCommand(
"set_typing",
{"room_id": roomId, "timeout": timeout.inMilliseconds},
);
Future<void> 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<String> registerClient(OAuthRegisterClientRequest request) async =>
(await sendCommand(
"oauth_register_client",
request.toJson(),
))["client_id"];
Future<OAuthAuthCodeResponse> getAuthUrl(OAuthGetAuthUrl request) async =>
.fromJson(
await sendCommand("oauth_get_authorization_url", request.toJson()),
);
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
await sendCommand("oauth_exchange_token", request.toJson());
Future<IList<String>> getMutualRooms(String userId) async => IList(
((await sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"]
as List?)
?.cast<String>(),
);
Future<IList<String>> getLocalAliases(String roomId) async => IList(
((await sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"]
as List?)
?.cast<String>(),
);
Future<void> createAlias(String roomId, String alias) =>
sendCommand("create_alias", {"room_id": roomId, "alias": alias});
Future<void> deleteAlias(String alias) =>
sendCommand("delete_alias", {"alias": alias});
Future<bool> getRoomPublished(String roomId) async =>
(await sendCommand("get_room_visibility", {
"room_id": roomId,
}))["visibility"] ==
"public";
Future<void> setRoomPublished(String roomId, bool published) => sendCommand(
"publish_room",
{"room_id": roomId, "visibility": published ? "public" : "private"},
);
Future<String> upgradeRoom(
String roomId,
String version, {
ISet<String> 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<String?> 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<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await sendCommand("get_versions"));
Future<Capabilities> getCapabilities() async =>
.fromJson((await sendCommand("get_capabilities"))["capabilities"]);
Future<Uri?> 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, int>(
() => ClientController(null),
);
/// The instance for an account other than the active one.
static final forAccount =
AsyncNotifierProvider.family<ClientController, int, String>(
ClientController.new,
);
}