diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index 6a4d7de..a84e62f 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -38,10 +38,21 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { } Future> getGroupMembers(String groupId) async { - return (select(groupMembers)..where((t) => t.groupId.equals(groupId))) + return (select(groupMembers) + ..where( + (t) => + t.groupId.equals(groupId) & + t.memberState.equals(MemberState.leftGroup.name).not(), + )) .get(); } + Future getGroupMemberByPublicKey(Uint8List publicKey) async { + return (select(groupMembers) + ..where((t) => t.groupPublicKey.equals(publicKey))) + .getSingleOrNull(); + } + Future createNewGroup(GroupsCompanion group) async { return _insertGroup(group); } diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 084ed87..7a5b7cc 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -797,5 +797,12 @@ "notificationTitleUnknownUser": "Jemand", "notificationCategoryMessageTitle": "Nachrichten", "notificationCategoryMessageDesc": "Nachrichten von anderen Benutzern.", - "groupContextMenuDeleteGroup": "Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht." + "groupContextMenuDeleteGroup": "Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.", + "groupYouAreNowLongerAMember": "Du bist nicht mehr Mitglied dieser Gruppe.", + "groupNetworkIssue": "Netzwerkproblem. Bitte probiere es später noch einmal.", + "leaveGroupSelectOtherAdminTitle": "Einen Admin auswählen", + "leaveGroupSelectOtherAdminBody": "Um die Gruppe zu verlassen, musst du zuerst einen neuen Administrator auswählen.", + "leaveGroupSureTitle": "Gruppe verlassen", + "leaveGroupSureBody": "Willst du die Gruppe wirklich verlassen?", + "leaveGroupSureOkBtn": "Gruppe verlassen" } \ No newline at end of file diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index ba4a6da..1ea6317 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -575,5 +575,12 @@ "notificationTitleUnknownUser": "Someone", "notificationCategoryMessageTitle": "Messages", "notificationCategoryMessageDesc": "Messages from other users.", - "groupContextMenuDeleteGroup": "This will permanently delete all messages in this chat." + "groupContextMenuDeleteGroup": "This will permanently delete all messages in this chat.", + "groupYouAreNowLongerAMember": "You are no longer part of this group.", + "groupNetworkIssue": "Network issue. Try again later.", + "leaveGroupSelectOtherAdminTitle": "Select another admin", + "leaveGroupSelectOtherAdminBody": "To leave the group, you must first select a new administrator.", + "leaveGroupSureTitle": "Leave group", + "leaveGroupSureBody": "Do you really want to leave the group?", + "leaveGroupSureOkBtn": "Leave group" } \ No newline at end of file diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 0a8a138..2184042 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -2557,6 +2557,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'This will permanently delete all messages in this chat.'** String get groupContextMenuDeleteGroup; + + /// No description provided for @groupYouAreNowLongerAMember. + /// + /// In en, this message translates to: + /// **'You are no longer part of this group.'** + String get groupYouAreNowLongerAMember; + + /// No description provided for @groupNetworkIssue. + /// + /// In en, this message translates to: + /// **'Network issue. Try again later.'** + String get groupNetworkIssue; + + /// No description provided for @leaveGroupSelectOtherAdminTitle. + /// + /// In en, this message translates to: + /// **'Select another admin'** + String get leaveGroupSelectOtherAdminTitle; + + /// No description provided for @leaveGroupSelectOtherAdminBody. + /// + /// In en, this message translates to: + /// **'To leave the group, you must first select a new administrator.'** + String get leaveGroupSelectOtherAdminBody; + + /// No description provided for @leaveGroupSureTitle. + /// + /// In en, this message translates to: + /// **'Leave group'** + String get leaveGroupSureTitle; + + /// No description provided for @leaveGroupSureBody. + /// + /// In en, this message translates to: + /// **'Do you really want to leave the group?'** + String get leaveGroupSureBody; + + /// No description provided for @leaveGroupSureOkBtn. + /// + /// In en, this message translates to: + /// **'Leave group'** + String get leaveGroupSureOkBtn; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 3d67b92..01d99e2 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1397,4 +1397,28 @@ class AppLocalizationsDe extends AppLocalizations { @override String get groupContextMenuDeleteGroup => 'Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.'; + + @override + String get groupYouAreNowLongerAMember => + 'Du bist nicht mehr Mitglied dieser Gruppe.'; + + @override + String get groupNetworkIssue => + 'Netzwerkproblem. Bitte probiere es später noch einmal.'; + + @override + String get leaveGroupSelectOtherAdminTitle => 'Einen Admin auswählen'; + + @override + String get leaveGroupSelectOtherAdminBody => + 'Um die Gruppe zu verlassen, musst du zuerst einen neuen Administrator auswählen.'; + + @override + String get leaveGroupSureTitle => 'Gruppe verlassen'; + + @override + String get leaveGroupSureBody => 'Willst du die Gruppe wirklich verlassen?'; + + @override + String get leaveGroupSureOkBtn => 'Gruppe verlassen'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index c0cb321..ddbfafa 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1389,4 +1389,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get groupContextMenuDeleteGroup => 'This will permanently delete all messages in this chat.'; + + @override + String get groupYouAreNowLongerAMember => + 'You are no longer part of this group.'; + + @override + String get groupNetworkIssue => 'Network issue. Try again later.'; + + @override + String get leaveGroupSelectOtherAdminTitle => 'Select another admin'; + + @override + String get leaveGroupSelectOtherAdminBody => + 'To leave the group, you must first select a new administrator.'; + + @override + String get leaveGroupSureTitle => 'Leave group'; + + @override + String get leaveGroupSureBody => 'Do you really want to leave the group?'; + + @override + String get leaveGroupSureOkBtn => 'Leave group'; } diff --git a/lib/src/model/protobuf/api/http/http_requests.pb.dart b/lib/src/model/protobuf/api/http/http_requests.pb.dart index 132252d..cb5f517 100644 --- a/lib/src/model/protobuf/api/http/http_requests.pb.dart +++ b/lib/src/model/protobuf/api/http/http_requests.pb.dart @@ -373,10 +373,10 @@ class NewGroupState extends $pb.GeneratedMessage { factory NewGroupState.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'NewGroupState', package: const $pb.PackageName(_omitMessageNames ? '' : 'http_requests'), createEmptyInstance: create) - ..aOS(1, _omitFieldNames ? '' : 'groupId', protoName: 'groupId') - ..a<$fixnum.Int64>(2, _omitFieldNames ? '' : 'versionId', $pb.PbFieldType.OU6, protoName: 'versionId', defaultOrMaker: $fixnum.Int64.ZERO) - ..a<$core.List<$core.int>>(4, _omitFieldNames ? '' : 'encryptedGroupState', $pb.PbFieldType.OY) - ..a<$core.List<$core.int>>(5, _omitFieldNames ? '' : 'publicKey', $pb.PbFieldType.OY) + ..aOS(1, _omitFieldNames ? '' : 'groupId') + ..a<$fixnum.Int64>(2, _omitFieldNames ? '' : 'versionId', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'encryptedGroupState', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>(4, _omitFieldNames ? '' : 'publicKey', $pb.PbFieldType.OY) ..hasRequiredFields = false ; @@ -419,29 +419,202 @@ class NewGroupState extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearVersionId() => clearField(2); - @$pb.TagNumber(4) + @$pb.TagNumber(3) $core.List<$core.int> get encryptedGroupState => $_getN(2); - @$pb.TagNumber(4) + @$pb.TagNumber(3) set encryptedGroupState($core.List<$core.int> v) { $_setBytes(2, v); } - @$pb.TagNumber(4) + @$pb.TagNumber(3) $core.bool hasEncryptedGroupState() => $_has(2); - @$pb.TagNumber(4) - void clearEncryptedGroupState() => clearField(4); + @$pb.TagNumber(3) + void clearEncryptedGroupState() => clearField(3); - @$pb.TagNumber(5) + @$pb.TagNumber(4) $core.List<$core.int> get publicKey => $_getN(3); - @$pb.TagNumber(5) + @$pb.TagNumber(4) set publicKey($core.List<$core.int> v) { $_setBytes(3, v); } - @$pb.TagNumber(5) + @$pb.TagNumber(4) $core.bool hasPublicKey() => $_has(3); - @$pb.TagNumber(5) - void clearPublicKey() => clearField(5); + @$pb.TagNumber(4) + void clearPublicKey() => clearField(4); +} + +class AppendGroupState_AppendTBS extends $pb.GeneratedMessage { + factory AppendGroupState_AppendTBS({ + $core.List<$core.int>? encryptedGroupStateAppend, + $core.List<$core.int>? publicKey, + $core.String? groupId, + $core.List<$core.int>? nonce, + }) { + final $result = create(); + if (encryptedGroupStateAppend != null) { + $result.encryptedGroupStateAppend = encryptedGroupStateAppend; + } + if (publicKey != null) { + $result.publicKey = publicKey; + } + if (groupId != null) { + $result.groupId = groupId; + } + if (nonce != null) { + $result.nonce = nonce; + } + return $result; + } + AppendGroupState_AppendTBS._() : super(); + factory AppendGroupState_AppendTBS.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory AppendGroupState_AppendTBS.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AppendGroupState.AppendTBS', package: const $pb.PackageName(_omitMessageNames ? '' : 'http_requests'), createEmptyInstance: create) + ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'encryptedGroupStateAppend', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'publicKey', $pb.PbFieldType.OY) + ..aOS(3, _omitFieldNames ? '' : 'groupId') + ..a<$core.List<$core.int>>(4, _omitFieldNames ? '' : 'nonce', $pb.PbFieldType.OY) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + AppendGroupState_AppendTBS clone() => AppendGroupState_AppendTBS()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + AppendGroupState_AppendTBS copyWith(void Function(AppendGroupState_AppendTBS) updates) => super.copyWith((message) => updates(message as AppendGroupState_AppendTBS)) as AppendGroupState_AppendTBS; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static AppendGroupState_AppendTBS create() => AppendGroupState_AppendTBS._(); + AppendGroupState_AppendTBS createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static AppendGroupState_AppendTBS getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static AppendGroupState_AppendTBS? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.int> get encryptedGroupStateAppend => $_getN(0); + @$pb.TagNumber(1) + set encryptedGroupStateAppend($core.List<$core.int> v) { $_setBytes(0, v); } + @$pb.TagNumber(1) + $core.bool hasEncryptedGroupStateAppend() => $_has(0); + @$pb.TagNumber(1) + void clearEncryptedGroupStateAppend() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get publicKey => $_getN(1); + @$pb.TagNumber(2) + set publicKey($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasPublicKey() => $_has(1); + @$pb.TagNumber(2) + void clearPublicKey() => clearField(2); + + @$pb.TagNumber(3) + $core.String get groupId => $_getSZ(2); + @$pb.TagNumber(3) + set groupId($core.String v) { $_setString(2, v); } + @$pb.TagNumber(3) + $core.bool hasGroupId() => $_has(2); + @$pb.TagNumber(3) + void clearGroupId() => clearField(3); + + @$pb.TagNumber(4) + $core.List<$core.int> get nonce => $_getN(3); + @$pb.TagNumber(4) + set nonce($core.List<$core.int> v) { $_setBytes(3, v); } + @$pb.TagNumber(4) + $core.bool hasNonce() => $_has(3); + @$pb.TagNumber(4) + void clearNonce() => clearField(4); +} + +class AppendGroupState extends $pb.GeneratedMessage { + factory AppendGroupState({ + $core.List<$core.int>? signature, + AppendGroupState_AppendTBS? appendTBS, + $fixnum.Int64? versionId, + }) { + final $result = create(); + if (signature != null) { + $result.signature = signature; + } + if (appendTBS != null) { + $result.appendTBS = appendTBS; + } + if (versionId != null) { + $result.versionId = versionId; + } + return $result; + } + AppendGroupState._() : super(); + factory AppendGroupState.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory AppendGroupState.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AppendGroupState', package: const $pb.PackageName(_omitMessageNames ? '' : 'http_requests'), createEmptyInstance: create) + ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'signature', $pb.PbFieldType.OY) + ..aOM(2, _omitFieldNames ? '' : 'appendTBS', protoName: 'appendTBS', subBuilder: AppendGroupState_AppendTBS.create) + ..a<$fixnum.Int64>(3, _omitFieldNames ? '' : 'versionId', $pb.PbFieldType.OU6, protoName: 'versionId', defaultOrMaker: $fixnum.Int64.ZERO) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + AppendGroupState clone() => AppendGroupState()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + AppendGroupState copyWith(void Function(AppendGroupState) updates) => super.copyWith((message) => updates(message as AppendGroupState)) as AppendGroupState; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static AppendGroupState create() => AppendGroupState._(); + AppendGroupState createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static AppendGroupState getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static AppendGroupState? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.int> get signature => $_getN(0); + @$pb.TagNumber(1) + set signature($core.List<$core.int> v) { $_setBytes(0, v); } + @$pb.TagNumber(1) + $core.bool hasSignature() => $_has(0); + @$pb.TagNumber(1) + void clearSignature() => clearField(1); + + @$pb.TagNumber(2) + AppendGroupState_AppendTBS get appendTBS => $_getN(1); + @$pb.TagNumber(2) + set appendTBS(AppendGroupState_AppendTBS v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasAppendTBS() => $_has(1); + @$pb.TagNumber(2) + void clearAppendTBS() => clearField(2); + @$pb.TagNumber(2) + AppendGroupState_AppendTBS ensureAppendTBS() => $_ensure(1); + + @$pb.TagNumber(3) + $fixnum.Int64 get versionId => $_getI64(2); + @$pb.TagNumber(3) + set versionId($fixnum.Int64 v) { $_setInt64(2, v); } + @$pb.TagNumber(3) + $core.bool hasVersionId() => $_has(2); + @$pb.TagNumber(3) + void clearVersionId() => clearField(3); } class GroupState extends $pb.GeneratedMessage { factory GroupState({ $fixnum.Int64? versionId, $core.List<$core.int>? encryptedGroupState, + $core.Iterable? appendedGroupStates, }) { final $result = create(); if (versionId != null) { @@ -450,6 +623,9 @@ class GroupState extends $pb.GeneratedMessage { if (encryptedGroupState != null) { $result.encryptedGroupState = encryptedGroupState; } + if (appendedGroupStates != null) { + $result.appendedGroupStates.addAll(appendedGroupStates); + } return $result; } GroupState._() : super(); @@ -457,8 +633,9 @@ class GroupState extends $pb.GeneratedMessage { factory GroupState.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GroupState', package: const $pb.PackageName(_omitMessageNames ? '' : 'http_requests'), createEmptyInstance: create) - ..a<$fixnum.Int64>(1, _omitFieldNames ? '' : 'versionId', $pb.PbFieldType.OU6, protoName: 'versionId', defaultOrMaker: $fixnum.Int64.ZERO) - ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'encryptedGroupState', $pb.PbFieldType.OY) + ..a<$fixnum.Int64>(1, _omitFieldNames ? '' : 'versionId', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'encryptedGroupState', $pb.PbFieldType.OY) + ..pc(3, _omitFieldNames ? '' : 'appendedGroupStates', $pb.PbFieldType.PM, subBuilder: AppendGroupState.create) ..hasRequiredFields = false ; @@ -492,14 +669,62 @@ class GroupState extends $pb.GeneratedMessage { @$pb.TagNumber(1) void clearVersionId() => clearField(1); - @$pb.TagNumber(3) + @$pb.TagNumber(2) $core.List<$core.int> get encryptedGroupState => $_getN(1); - @$pb.TagNumber(3) + @$pb.TagNumber(2) set encryptedGroupState($core.List<$core.int> v) { $_setBytes(1, v); } - @$pb.TagNumber(3) + @$pb.TagNumber(2) $core.bool hasEncryptedGroupState() => $_has(1); + @$pb.TagNumber(2) + void clearEncryptedGroupState() => clearField(2); + @$pb.TagNumber(3) - void clearEncryptedGroupState() => clearField(3); + $core.List get appendedGroupStates => $_getList(2); +} + +/// this is just a database helper to store multiple appends +class AppendGroupStateHelper extends $pb.GeneratedMessage { + factory AppendGroupStateHelper({ + $core.Iterable? appendedGroupStates, + }) { + final $result = create(); + if (appendedGroupStates != null) { + $result.appendedGroupStates.addAll(appendedGroupStates); + } + return $result; + } + AppendGroupStateHelper._() : super(); + factory AppendGroupStateHelper.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory AppendGroupStateHelper.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'AppendGroupStateHelper', package: const $pb.PackageName(_omitMessageNames ? '' : 'http_requests'), createEmptyInstance: create) + ..pc(1, _omitFieldNames ? '' : 'appendedGroupStates', $pb.PbFieldType.PM, subBuilder: AppendGroupState.create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + AppendGroupStateHelper clone() => AppendGroupStateHelper()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + AppendGroupStateHelper copyWith(void Function(AppendGroupStateHelper) updates) => super.copyWith((message) => updates(message as AppendGroupStateHelper)) as AppendGroupStateHelper; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static AppendGroupStateHelper create() => AppendGroupStateHelper._(); + AppendGroupStateHelper createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static AppendGroupStateHelper getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static AppendGroupStateHelper? _defaultInstance; + + @$pb.TagNumber(1) + $core.List get appendedGroupStates => $_getList(0); } diff --git a/lib/src/model/protobuf/api/http/http_requests.pbjson.dart b/lib/src/model/protobuf/api/http/http_requests.pbjson.dart index be8ef01..c93b93c 100644 --- a/lib/src/model/protobuf/api/http/http_requests.pbjson.dart +++ b/lib/src/model/protobuf/api/http/http_requests.pbjson.dart @@ -89,30 +89,77 @@ final $typed_data.Uint8List updateGroupStateDescriptor = $convert.base64Decode( const NewGroupState$json = { '1': 'NewGroupState', '2': [ - {'1': 'groupId', '3': 1, '4': 1, '5': 9, '10': 'groupId'}, - {'1': 'versionId', '3': 2, '4': 1, '5': 4, '10': 'versionId'}, - {'1': 'encrypted_group_state', '3': 4, '4': 1, '5': 12, '10': 'encryptedGroupState'}, - {'1': 'public_key', '3': 5, '4': 1, '5': 12, '10': 'publicKey'}, + {'1': 'group_id', '3': 1, '4': 1, '5': 9, '10': 'groupId'}, + {'1': 'version_id', '3': 2, '4': 1, '5': 4, '10': 'versionId'}, + {'1': 'encrypted_group_state', '3': 3, '4': 1, '5': 12, '10': 'encryptedGroupState'}, + {'1': 'public_key', '3': 4, '4': 1, '5': 12, '10': 'publicKey'}, ], }; /// Descriptor for `NewGroupState`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List newGroupStateDescriptor = $convert.base64Decode( - 'Cg1OZXdHcm91cFN0YXRlEhgKB2dyb3VwSWQYASABKAlSB2dyb3VwSWQSHAoJdmVyc2lvbklkGA' - 'IgASgEUgl2ZXJzaW9uSWQSMgoVZW5jcnlwdGVkX2dyb3VwX3N0YXRlGAQgASgMUhNlbmNyeXB0' - 'ZWRHcm91cFN0YXRlEh0KCnB1YmxpY19rZXkYBSABKAxSCXB1YmxpY0tleQ=='); + 'Cg1OZXdHcm91cFN0YXRlEhkKCGdyb3VwX2lkGAEgASgJUgdncm91cElkEh0KCnZlcnNpb25faW' + 'QYAiABKARSCXZlcnNpb25JZBIyChVlbmNyeXB0ZWRfZ3JvdXBfc3RhdGUYAyABKAxSE2VuY3J5' + 'cHRlZEdyb3VwU3RhdGUSHQoKcHVibGljX2tleRgEIAEoDFIJcHVibGljS2V5'); + +@$core.Deprecated('Use appendGroupStateDescriptor instead') +const AppendGroupState$json = { + '1': 'AppendGroupState', + '2': [ + {'1': 'signature', '3': 1, '4': 1, '5': 12, '10': 'signature'}, + {'1': 'appendTBS', '3': 2, '4': 1, '5': 11, '6': '.http_requests.AppendGroupState.AppendTBS', '10': 'appendTBS'}, + {'1': 'versionId', '3': 3, '4': 1, '5': 4, '10': 'versionId'}, + ], + '3': [AppendGroupState_AppendTBS$json], +}; + +@$core.Deprecated('Use appendGroupStateDescriptor instead') +const AppendGroupState_AppendTBS$json = { + '1': 'AppendTBS', + '2': [ + {'1': 'encrypted_group_state_append', '3': 1, '4': 1, '5': 12, '10': 'encryptedGroupStateAppend'}, + {'1': 'public_key', '3': 2, '4': 1, '5': 12, '10': 'publicKey'}, + {'1': 'group_id', '3': 3, '4': 1, '5': 9, '10': 'groupId'}, + {'1': 'nonce', '3': 4, '4': 1, '5': 12, '10': 'nonce'}, + ], +}; + +/// Descriptor for `AppendGroupState`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List appendGroupStateDescriptor = $convert.base64Decode( + 'ChBBcHBlbmRHcm91cFN0YXRlEhwKCXNpZ25hdHVyZRgBIAEoDFIJc2lnbmF0dXJlEkcKCWFwcG' + 'VuZFRCUxgCIAEoCzIpLmh0dHBfcmVxdWVzdHMuQXBwZW5kR3JvdXBTdGF0ZS5BcHBlbmRUQlNS' + 'CWFwcGVuZFRCUxIcCgl2ZXJzaW9uSWQYAyABKARSCXZlcnNpb25JZBqcAQoJQXBwZW5kVEJTEj' + '8KHGVuY3J5cHRlZF9ncm91cF9zdGF0ZV9hcHBlbmQYASABKAxSGWVuY3J5cHRlZEdyb3VwU3Rh' + 'dGVBcHBlbmQSHQoKcHVibGljX2tleRgCIAEoDFIJcHVibGljS2V5EhkKCGdyb3VwX2lkGAMgAS' + 'gJUgdncm91cElkEhQKBW5vbmNlGAQgASgMUgVub25jZQ=='); @$core.Deprecated('Use groupStateDescriptor instead') const GroupState$json = { '1': 'GroupState', '2': [ - {'1': 'versionId', '3': 1, '4': 1, '5': 4, '10': 'versionId'}, - {'1': 'encrypted_group_state', '3': 3, '4': 1, '5': 12, '10': 'encryptedGroupState'}, + {'1': 'version_id', '3': 1, '4': 1, '5': 4, '10': 'versionId'}, + {'1': 'encrypted_group_state', '3': 2, '4': 1, '5': 12, '10': 'encryptedGroupState'}, + {'1': 'appended_group_states', '3': 3, '4': 3, '5': 11, '6': '.http_requests.AppendGroupState', '10': 'appendedGroupStates'}, ], }; /// Descriptor for `GroupState`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List groupStateDescriptor = $convert.base64Decode( - 'CgpHcm91cFN0YXRlEhwKCXZlcnNpb25JZBgBIAEoBFIJdmVyc2lvbklkEjIKFWVuY3J5cHRlZF' - '9ncm91cF9zdGF0ZRgDIAEoDFITZW5jcnlwdGVkR3JvdXBTdGF0ZQ=='); + 'CgpHcm91cFN0YXRlEh0KCnZlcnNpb25faWQYASABKARSCXZlcnNpb25JZBIyChVlbmNyeXB0ZW' + 'RfZ3JvdXBfc3RhdGUYAiABKAxSE2VuY3J5cHRlZEdyb3VwU3RhdGUSUwoVYXBwZW5kZWRfZ3Jv' + 'dXBfc3RhdGVzGAMgAygLMh8uaHR0cF9yZXF1ZXN0cy5BcHBlbmRHcm91cFN0YXRlUhNhcHBlbm' + 'RlZEdyb3VwU3RhdGVz'); + +@$core.Deprecated('Use appendGroupStateHelperDescriptor instead') +const AppendGroupStateHelper$json = { + '1': 'AppendGroupStateHelper', + '2': [ + {'1': 'appended_group_states', '3': 1, '4': 3, '5': 11, '6': '.http_requests.AppendGroupState', '10': 'appendedGroupStates'}, + ], +}; + +/// Descriptor for `AppendGroupStateHelper`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List appendGroupStateHelperDescriptor = $convert.base64Decode( + 'ChZBcHBlbmRHcm91cFN0YXRlSGVscGVyElMKFWFwcGVuZGVkX2dyb3VwX3N0YXRlcxgBIAMoCz' + 'IfLmh0dHBfcmVxdWVzdHMuQXBwZW5kR3JvdXBTdGF0ZVITYXBwZW5kZWRHcm91cFN0YXRlcw=='); diff --git a/lib/src/model/protobuf/client/generated/groups.pb.dart b/lib/src/model/protobuf/client/generated/groups.pb.dart index e3d50f5..1be9b98 100644 --- a/lib/src/model/protobuf/client/generated/groups.pb.dart +++ b/lib/src/model/protobuf/client/generated/groups.pb.dart @@ -14,6 +14,10 @@ import 'dart:core' as $core; import 'package:fixnum/fixnum.dart' as $fixnum; import 'package:protobuf/protobuf.dart' as $pb; +import 'groups.pbenum.dart'; + +export 'groups.pbenum.dart'; + /// Stored encrypted on the server in the members columns. class EncryptedGroupState extends $pb.GeneratedMessage { factory EncryptedGroupState({ @@ -109,6 +113,56 @@ class EncryptedGroupState extends $pb.GeneratedMessage { void clearPadding() => clearField(5); } +class EncryptedAppendedGroupState extends $pb.GeneratedMessage { + factory EncryptedAppendedGroupState({ + EncryptedAppendedGroupState_Type? type, + }) { + final $result = create(); + if (type != null) { + $result.type = type; + } + return $result; + } + EncryptedAppendedGroupState._() : super(); + factory EncryptedAppendedGroupState.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory EncryptedAppendedGroupState.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'EncryptedAppendedGroupState', createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'type', $pb.PbFieldType.OE, defaultOrMaker: EncryptedAppendedGroupState_Type.LEFT_GROUP, valueOf: EncryptedAppendedGroupState_Type.valueOf, enumValues: EncryptedAppendedGroupState_Type.values) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + EncryptedAppendedGroupState clone() => EncryptedAppendedGroupState()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + EncryptedAppendedGroupState copyWith(void Function(EncryptedAppendedGroupState) updates) => super.copyWith((message) => updates(message as EncryptedAppendedGroupState)) as EncryptedAppendedGroupState; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EncryptedAppendedGroupState create() => EncryptedAppendedGroupState._(); + EncryptedAppendedGroupState createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static EncryptedAppendedGroupState getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static EncryptedAppendedGroupState? _defaultInstance; + + @$pb.TagNumber(1) + EncryptedAppendedGroupState_Type get type => $_getN(0); + @$pb.TagNumber(1) + set type(EncryptedAppendedGroupState_Type v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasType() => $_has(0); + @$pb.TagNumber(1) + void clearType() => clearField(1); +} + class EncryptedGroupStateEnvelop extends $pb.GeneratedMessage { factory EncryptedGroupStateEnvelop({ $core.List<$core.int>? nonce, diff --git a/lib/src/model/protobuf/client/generated/groups.pbenum.dart b/lib/src/model/protobuf/client/generated/groups.pbenum.dart index c03fb19..69a0e68 100644 --- a/lib/src/model/protobuf/client/generated/groups.pbenum.dart +++ b/lib/src/model/protobuf/client/generated/groups.pbenum.dart @@ -9,3 +9,22 @@ // ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: unnecessary_import, unnecessary_this, unused_import +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +class EncryptedAppendedGroupState_Type extends $pb.ProtobufEnum { + static const EncryptedAppendedGroupState_Type LEFT_GROUP = EncryptedAppendedGroupState_Type._(0, _omitEnumNames ? '' : 'LEFT_GROUP'); + + static const $core.List values = [ + LEFT_GROUP, + ]; + + static final $core.Map<$core.int, EncryptedAppendedGroupState_Type> _byValue = $pb.ProtobufEnum.initByValue(values); + static EncryptedAppendedGroupState_Type? valueOf($core.int value) => _byValue[value]; + + const EncryptedAppendedGroupState_Type._($core.int v, $core.String n) : super(v, n); +} + + +const _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names'); diff --git a/lib/src/model/protobuf/client/generated/groups.pbjson.dart b/lib/src/model/protobuf/client/generated/groups.pbjson.dart index 97fd3f8..1d81d71 100644 --- a/lib/src/model/protobuf/client/generated/groups.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/groups.pbjson.dart @@ -36,6 +36,28 @@ final $typed_data.Uint8List encryptedGroupStateDescriptor = $convert.base64Decod 'VzQWZ0ZXJNaWxsaXNlY29uZHOIAQESGAoHcGFkZGluZxgFIAEoDFIHcGFkZGluZ0IiCiBfZGVs' 'ZXRlTWVzc2FnZXNBZnRlck1pbGxpc2Vjb25kcw=='); +@$core.Deprecated('Use encryptedAppendedGroupStateDescriptor instead') +const EncryptedAppendedGroupState$json = { + '1': 'EncryptedAppendedGroupState', + '2': [ + {'1': 'type', '3': 1, '4': 1, '5': 14, '6': '.EncryptedAppendedGroupState.Type', '10': 'type'}, + ], + '4': [EncryptedAppendedGroupState_Type$json], +}; + +@$core.Deprecated('Use encryptedAppendedGroupStateDescriptor instead') +const EncryptedAppendedGroupState_Type$json = { + '1': 'Type', + '2': [ + {'1': 'LEFT_GROUP', '2': 0}, + ], +}; + +/// Descriptor for `EncryptedAppendedGroupState`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List encryptedAppendedGroupStateDescriptor = $convert.base64Decode( + 'ChtFbmNyeXB0ZWRBcHBlbmRlZEdyb3VwU3RhdGUSNQoEdHlwZRgBIAEoDjIhLkVuY3J5cHRlZE' + 'FwcGVuZGVkR3JvdXBTdGF0ZS5UeXBlUgR0eXBlIhYKBFR5cGUSDgoKTEVGVF9HUk9VUBAA'); + @$core.Deprecated('Use encryptedGroupStateEnvelopDescriptor instead') const EncryptedGroupStateEnvelop$json = { '1': 'EncryptedGroupStateEnvelop', diff --git a/lib/src/model/protobuf/client/groups.proto b/lib/src/model/protobuf/client/groups.proto index 813563c..268ad1f 100644 --- a/lib/src/model/protobuf/client/groups.proto +++ b/lib/src/model/protobuf/client/groups.proto @@ -9,6 +9,13 @@ message EncryptedGroupState { bytes padding = 5; } +message EncryptedAppendedGroupState { + enum Type { + LEFT_GROUP = 0; + } + Type type = 1; +} + message EncryptedGroupStateEnvelop { bytes nonce = 1; bytes encryptedGroupState = 2; diff --git a/lib/src/services/api/client2client/groups.c2c.dart b/lib/src/services/api/client2client/groups.c2c.dart index e0c0b5f..ca5b906 100644 --- a/lib/src/services/api/client2client/groups.c2c.dart +++ b/lib/src/services/api/client2client/groups.c2c.dart @@ -57,6 +57,7 @@ Future handleGroupCreate( groupName: const Value(''), joinedGroup: const Value(false), leftGroup: const Value(false), + deletedContent: const Value(false), ), ); } diff --git a/lib/src/services/group.services.dart b/lib/src/services/group.services.dart index c034afb..3c3f5de 100644 --- a/lib/src/services/group.services.dart +++ b/lib/src/services/group.services.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; -import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:drift/drift.dart' show Value; import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; import 'package:hashlib/random.dart'; import 'package:http/http.dart' as http; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; @@ -170,32 +170,13 @@ Future fetchMissingGroupPublicKey() async { } } -Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async { - if (group.leftGroup) { - Log.error( - 'Could not refresh group state, as user is no longer part of the group', - ); - return null; - } +Future?> _decryptEnvelop( + Group group, + List encryptedGroupState, +) async { try { - var isSuccess = true; - - final response = await http - .get( - Uri.parse('${getGroupStateUrl()}/${group.groupId}'), - ) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - Log.error( - 'Could not load group state. Got status code ${response.statusCode} from server.', - ); - return null; - } - - final groupStateServer = GroupState.fromBuffer(response.bodyBytes); final envelope = EncryptedGroupStateEnvelop.fromBuffer( - groupStateServer.encryptedGroupState, + encryptedGroupState, ); final chacha20 = FlutterChacha20.poly1305Aead(); @@ -210,19 +191,111 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async { secretKey: SecretKey(group.stateEncryptionKey!), ); + return encryptedGroupStateRaw; + } catch (e) { + Log.error(e); + return null; + } +} + +Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async { + try { + var isSuccess = true; + + final response = await http + .get( + Uri.parse('${getGroupStateUrl()}/${group.groupId}'), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + if (response.statusCode == 404) { + // group does not exists any more. + await twonlyDB.groupsDao.updateGroup( + group.groupId, + const GroupsCompanion( + leftGroup: Value(true), + ), + ); + } + Log.error( + 'Could not load group state. Got status code ${response.statusCode} from server.', + ); + return null; + } + + final groupStateServer = GroupState.fromBuffer(response.bodyBytes); + + final encryptedStateRaw = + await _decryptEnvelop(group, groupStateServer.encryptedGroupState); + if (encryptedStateRaw == null) return null; + final encryptedGroupState = - EncryptedGroupState.fromBuffer(encryptedGroupStateRaw); + EncryptedGroupState.fromBuffer(encryptedStateRaw); if (group.stateVersionId >= groupStateServer.versionId.toInt()) { Log.info( 'Group ${group.groupId} has already newest group state from the server!', ); - // return (groupStateServer.versionId.toInt(), encryptedGroupState); } - if (!encryptedGroupState.memberIds.contains(Int64(gUser.userId))) { + final memberIds = List.from(encryptedGroupState.memberIds); + final adminIds = List.from(encryptedGroupState.adminIds); + + for (final appendedState in groupStateServer.appendedGroupStates) { + final identityKey = IdentityKey.fromBytes( + Uint8List.fromList(appendedState.appendTBS.publicKey), + 0, + ); + + final valid = Curve.verifySignature( + identityKey.publicKey, + appendedState.appendTBS.writeToBuffer(), + Uint8List.fromList(appendedState.signature), + ); + + if (!valid) { + Log.error('Invalid signature for the appendedState'); + continue; + } + + final encryptedStateRaw = await _decryptEnvelop( + group, + appendedState.appendTBS.encryptedGroupStateAppend, + ); + if (encryptedStateRaw == null) continue; + + final appended = + EncryptedAppendedGroupState.fromBuffer(encryptedStateRaw); + if (appended.type == EncryptedAppendedGroupState_Type.LEFT_GROUP) { + final keyPair = + IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!); + + final appendedPubKey = appendedState.appendTBS.publicKey; + final myPubKey = keyPair.getPublicKey().serialize().toList(); + + if (listEquals(appendedPubKey, myPubKey)) { + adminIds.remove(Int64(gUser.userId)); + memberIds + .remove(Int64(gUser.userId)); // -> Will remove the user later... + } else { + Log.info('A non admin left the group!!!'); + + final member = await twonlyDB.groupsDao + .getGroupMemberByPublicKey(Uint8List.fromList(appendedPubKey)); + if (member == null) { + Log.error('Member is already not in this group...'); + continue; + } + adminIds.remove(Int64(member.contactId)); + memberIds.remove(Int64(member.contactId)); + } + } + } + + if (!memberIds.contains(Int64(gUser.userId))) { // OH no, I am no longer a member of this group... - // -> + // Return from the group... await twonlyDB.groupsDao.updateGroup( group.groupId, const GroupsCompanion( @@ -232,9 +305,41 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async { return (groupStateServer.versionId.toInt(), encryptedGroupState); } - final isGroupAdmin = encryptedGroupState.adminIds - .firstWhereOrNull((t) => t.toInt() == gUser.userId) != - null; + final isGroupAdmin = + adminIds.firstWhereOrNull((t) => t.toInt() == gUser.userId) != null; + + if (!listEquals(memberIds, encryptedGroupState.memberIds)) { + if (isGroupAdmin) { + try { + // this removes the appended_group_state from the server and merges the changes into the main group state + final newState = EncryptedGroupState( + groupName: encryptedGroupState.groupName, + deleteMessagesAfterMilliseconds: + encryptedGroupState.deleteMessagesAfterMilliseconds, + memberIds: memberIds, + adminIds: adminIds, + padding: List.generate(Random().nextInt(80), (_) => 0), + ); + // send new state to the server + if (!await _updateGroupState( + group, + newState, + versionId: groupStateServer.versionId.toInt() + 1, + )) { + // could not update the group state... + Log.error('Update the state to remove the appended state...'); + return null; + } + // the state is now updated and the appended_group_state should be removed on the server, so just call this + // function again, to sync the local database + return fetchGroupState(group); + } catch (e) { + Log.error(e); + return null; + } + } + // in case this is not an admin, just work with the new memberIds and adminIds... + } await twonlyDB.groupsDao.updateGroup( group.groupId, @@ -251,7 +356,7 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async { await twonlyDB.groupsDao.getGroupMembers(group.groupId); // First find and insert NEW members - for (final memberId in encryptedGroupState.memberIds) { + for (final memberId in memberIds) { if (memberId == Int64(gUser.userId)) { continue; } @@ -313,7 +418,7 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async { MemberState? newMemberState; - if (encryptedGroupState.adminIds.contains(Int64(member.contactId))) { + if (adminIds.contains(Int64(member.contactId))) { if (member.memberState == MemberState.normal) { // user was promoted newMemberState = MemberState.admin; @@ -376,6 +481,7 @@ Future _updateGroupState( EncryptedGroupState state, { Uint8List? addAdmin, Uint8List? removeAdmin, + int? versionId, }) async { final chacha20 = FlutterChacha20.poly1305Aead(); final encryptionNonce = chacha20.newNonce(); @@ -397,26 +503,14 @@ Future _updateGroupState( final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!); - final publicKey = uint8ListToHex(keyPair.getPublicKey().serialize()); - - final responseNonce = await http - .get( - Uri.parse('${getGroupChallengeUrl()}/$publicKey'), - ) - .timeout(const Duration(seconds: 10)); - - if (responseNonce.statusCode != 200) { - Log.error( - 'Could not load nonce. Got status code ${responseNonce.statusCode} from server.', - ); - return false; - } + final nonce = await getNonce(keyPair.getPublicKey().serialize()); + if (nonce == null) return false; final updateTBS = UpdateGroupState_UpdateTBS( - versionId: Int64(group.stateVersionId + 1), + versionId: Int64(versionId ?? group.stateVersionId + 1), encryptedGroupState: encryptedGroupState.writeToBuffer(), publicKey: keyPair.getPublicKey().serialize(), - nonce: responseNonce.bodyBytes, + nonce: nonce, addAdmin: addAdmin, removeAdmin: removeAdmin, ); @@ -452,7 +546,7 @@ Future _updateGroupState( Future manageAdminState( Group group, - GroupMember member, + Uint8List groupPublicKey, int contactId, bool remove, ) async { @@ -469,7 +563,7 @@ Future manageAdminState( if (remove) { if (state.adminIds.contains(userId)) { state.adminIds.remove(userId); - removeAdmin = member.groupPublicKey; + removeAdmin = groupPublicKey; } else { Log.info('User was already removed as admin.'); return true; @@ -477,7 +571,7 @@ Future manageAdminState( } else { if (!state.adminIds.contains(userId)) { state.adminIds.add(userId); - addAdmin = member.groupPublicKey; + addAdmin = groupPublicKey; } else { Log.info('User is already admin.'); return true; @@ -524,7 +618,7 @@ Future manageAdminState( return (await fetchGroupState(group)) != null; } -Future updateGroupeName(Group group, String groupName) async { +Future updateGroupName(Group group, String groupName) async { // ensure the latest state is used final currentState = await fetchGroupState(group); if (currentState == null) return false; @@ -624,7 +718,7 @@ Future addNewGroupMembers( Future removeMemberFromGroup( Group group, - GroupMember member, + Uint8List groupPublicKey, int removeContactId, ) async { // ensure the latest state is used @@ -642,16 +736,16 @@ Future removeMemberFromGroup( return true; } if (adminIdSet.contains(contactId)) { - if (member.groupPublicKey == null) { - // If the admin public key is not removed, that the user could potentially still update the group state. So only - // allow the user removal, if this key is known. It is better the users can not remove the other user, then - // the he can but the other user, could still update the group state. - Log.error( - 'Could not remove user. User is admin, but groupPublicKey is unknown.', - ); - return false; - } - removeAdmin = member.groupPublicKey; + // if (member.groupPublicKey == null) { + // // If the admin public key is not removed, that the user could potentially still update the group state. So only + // // allow the user removal, if this key is known. It is better the users can not remove the other user, then + // // the he can but the other user, could still update the group state. + // Log.error( + // 'Could not remove user. User is admin, but groupPublicKey is unknown.', + // ); + // return false; + // } + removeAdmin = groupPublicKey; } membersIdSet.remove(contactId); @@ -684,10 +778,126 @@ Future removeMemberFromGroup( GroupHistoriesCompanion( groupId: Value(group.groupId), type: const Value(GroupActionType.removedMember), - affectedContactId: Value(removeContactId), + affectedContactId: Value( + removeContactId == gUser.userId ? null : removeContactId, + ), ), ); // Updates the groupMembers table :) return (await fetchGroupState(group)) != null; } + +Future getNonce(Uint8List publicKey) async { + final publicKeyHex = uint8ListToHex(publicKey); + + final responseNonce = await http + .get( + Uri.parse('${getGroupChallengeUrl()}/$publicKeyHex'), + ) + .timeout(const Duration(seconds: 10)); + + if (responseNonce.statusCode != 200) { + Log.error( + 'Could not load nonce. Got status code ${responseNonce.statusCode} from server.', + ); + return null; + } + return responseNonce.bodyBytes; +} + +Future leaveAsNonAdminFromGroup(Group group) async { + final currentState = await fetchGroupState(group); + if (currentState == null) { + Log.error('Could not load current state'); + return false; + } + + final (version, _) = currentState; + if (group.stateVersionId != version) { + Log.error('Version is not valid. Just retry.'); + return false; + } + + final chacha20 = FlutterChacha20.poly1305Aead(); + final encryptionNonce = chacha20.newNonce(); + + final state = EncryptedAppendedGroupState( + type: EncryptedAppendedGroupState_Type.LEFT_GROUP, + ); + + final secretBox = await chacha20.encrypt( + state.writeToBuffer(), + secretKey: SecretKey(group.stateEncryptionKey!), + nonce: encryptionNonce, + ); + + final encryptedGroupStateAppend = EncryptedGroupStateEnvelop( + nonce: encryptionNonce, + encryptedGroupState: secretBox.cipherText, + mac: secretBox.mac.bytes, + ); + + { + // Upload the group state, if this fails, the group can not be created. + + final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!); + + final nonce = await getNonce(keyPair.getPublicKey().serialize()); + if (nonce == null) return false; + + final appendTBS = AppendGroupState_AppendTBS( + publicKey: keyPair.getPublicKey().serialize(), + encryptedGroupStateAppend: encryptedGroupStateAppend.writeToBuffer(), + groupId: group.groupId, + nonce: nonce, + ); + + final random = getRandomUint8List(32); + final signature = sign( + keyPair.getPrivateKey().serialize(), + appendTBS.writeToBuffer(), + random, + ); + + final newGroupState = AppendGroupState( + versionId: Int64(group.stateVersionId + 1), + appendTBS: appendTBS, + signature: signature, + ); + + final response = await http + .post( + Uri.parse('${getGroupStateUrl()}/append'), + body: newGroupState.writeToBuffer(), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + Log.error( + 'Could not patch group state. Got status code ${response.statusCode} from server.', + ); + return false; + } + } + const groupActionType = GroupActionType.leftGroup; + await sendCipherTextToGroup( + group.groupId, + EncryptedContent( + groupUpdate: EncryptedContent_GroupUpdate( + groupActionType: groupActionType.name, + affectedContactId: Int64(gUser.userId), + ), + ), + ); + + await twonlyDB.groupsDao.insertGroupAction( + GroupHistoriesCompanion( + groupId: Value(group.groupId), + type: const Value(groupActionType), + ), + ); + + // Updates the table :) + return (await fetchGroupState(group)) != null; +} diff --git a/lib/src/views/components/better_list_title.dart b/lib/src/views/components/better_list_title.dart index 50fb542..45aadfa 100644 --- a/lib/src/views/components/better_list_title.dart +++ b/lib/src/views/components/better_list_title.dart @@ -20,7 +20,7 @@ class BetterListTile extends StatelessWidget { final String text; final Widget? subtitle; final Color? color; - final VoidCallback onTap; + final VoidCallback? onTap; final double iconSize; final EdgeInsets? padding; diff --git a/lib/src/views/groups/group.view.dart b/lib/src/views/groups/group.view.dart index a16a2e7..851d5ca 100644 --- a/lib/src/views/groups/group.view.dart +++ b/lib/src/views/groups/group.view.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/group.services.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/better_list_title.dart'; import 'package:twonly/src/views/components/verified_shield.dart'; @@ -74,7 +76,7 @@ class _GroupViewState extends State { newGroupName != null && newGroupName != '' && newGroupName != group.groupName) { - if (!await updateGroupeName(group, newGroupName)) { + if (!await updateGroupName(group, newGroupName)) { if (mounted) { showNetworkIssue(context); } @@ -97,6 +99,60 @@ class _GroupViewState extends State { } } + Future _leaveGroup() async { + final ok = await showAlertDialog( + context, + context.lang.leaveGroupSureTitle, + context.lang.leaveGroupSureBody, + customOk: context.lang.leaveGroupSureOkBtn, + ); + if (!ok) return; + + // 1. Check if I am the only admin, while there are still normal members + // -> ERROR first select new admin + + if (members.isNotEmpty) { + // In case there are other members, check that there is at least one other admin before I leave the group. + + if (group.isGroupAdmin) { + if (!members.any((m) => m.$2.memberState == MemberState.admin)) { + if (!mounted) return; + await showAlertDialog( + context, + context.lang.leaveGroupSelectOtherAdminTitle, + context.lang.leaveGroupSelectOtherAdminBody, + customCancel: '', + ); + return; + } + } + } + + late bool success; + + if (group.isGroupAdmin) { + // Current user is a admin, to the state can be updated by the user him self. + final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!); + success = await removeMemberFromGroup( + group, + keyPair.getPublicKey().serialize(), + gUser.userId, + ); + } else { + success = await leaveAsNonAdminFromGroup(group); + } + + if (!success) { + if (mounted) { + showNetworkIssue(context); + return; + } + } + // If not admin -> append to the server state + + // -> Inform the other users + } + @override Widget build(BuildContext context) { return Scaffold( @@ -126,7 +182,7 @@ class _GroupViewState extends State { ], ), const SizedBox(height: 50), - if (group.isGroupAdmin) + if (group.isGroupAdmin && !group.leftGroup) BetterListTile( icon: FontAwesomeIcons.pencil, text: context.lang.groupNameInput, @@ -145,7 +201,7 @@ class _GroupViewState extends State { ), ), ), - if (group.isGroupAdmin) + if (group.isGroupAdmin && !group.leftGroup) BetterListTile( icon: FontAwesomeIcons.plus, text: context.lang.addMember, @@ -197,12 +253,25 @@ class _GroupViewState extends State { const SizedBox(height: 10), const Divider(), const SizedBox(height: 10), - BetterListTile( - icon: FontAwesomeIcons.rightFromBracket, - color: Colors.red, - text: context.lang.leaveGroup, - onTap: () => {}, - ), + if (!group.leftGroup) + BetterListTile( + icon: FontAwesomeIcons.rightFromBracket, + color: Colors.red, + text: context.lang.leaveGroup, + onTap: _leaveGroup, + ) + else + ListTile( + title: Padding( + padding: const EdgeInsets.only(left: 17), + child: Text( + context.lang.groupYouAreNowLongerAMember, + style: const TextStyle( + fontSize: 14, + ), + ), + ), + ), ], ), ); @@ -246,9 +315,9 @@ Future showGroupNameChangeDialog( void showNetworkIssue(BuildContext context) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Network issue. Try again later.'), - duration: Duration(seconds: 3), + SnackBar( + content: Text(context.lang.groupNetworkIssue), + duration: const Duration(seconds: 3), ), ); } diff --git a/lib/src/views/groups/group_member.context.dart b/lib/src/views/groups/group_member.context.dart index 08084e1..687401f 100644 --- a/lib/src/views/groups/group_member.context.dart +++ b/lib/src/views/groups/group_member.context.dart @@ -37,7 +37,7 @@ class GroupMemberContextMenu extends StatelessWidget { if (ok) { if (!await manageAdminState( group, - member, + member.groupPublicKey!, contact.userId, false, )) { @@ -58,7 +58,7 @@ class GroupMemberContextMenu extends StatelessWidget { if (ok) { if (!await manageAdminState( group, - member, + member.groupPublicKey!, contact.userId, true, )) { @@ -78,7 +78,7 @@ class GroupMemberContextMenu extends StatelessWidget { if (ok) { if (!await removeMemberFromGroup( group, - member, + member.groupPublicKey!, contact.userId, )) { if (context.mounted) { @@ -159,7 +159,7 @@ class GroupMemberContextMenu extends StatelessWidget { onTap: () => _removeContactAsAdmin(context), icon: FontAwesomeIcons.key, ), - if (group.isGroupAdmin) + if (group.isGroupAdmin && member.groupPublicKey != null) ContextMenuItem( title: context.lang.removeFromGroup, onTap: () => _removeContactFromGroup(context), diff --git a/test/unit_test.dart b/test/unit_test.dart index dafea36..bf6ffea 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,6 +1,9 @@ import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:hashlib/random.dart'; +import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; +import 'package:libsignal_protocol_dart/src/ecc/ed25519.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/pow.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; @@ -60,5 +63,20 @@ void main() { test('Reject values > 0x7fffffff', () { expect(() => getUUIDforDirectChat(0x80000000, 0), throwsArgumentError); }); + + test('sign and verify', () { + final keyPair = generateIdentityKeyPair(); + final message = Uint8List(10); + + final random = getRandomUint8List(32); + + final signature = + sign(keyPair.getPrivateKey().serialize(), message, random); + + expect( + verifySig(keyPair.getPublicKey().serialize(), message, signature), + true, + ); + }); }); }