566 lines
20 KiB
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,
|
|
);
|
|
}
|