leave groups works now

This commit is contained in:
otsmr 2025-11-03 22:58:58 +01:00
parent 0dc8c87732
commit 2a3152e707
18 changed files with 903 additions and 117 deletions

View file

@ -38,10 +38,21 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
} }
Future<List<GroupMember>> getGroupMembers(String groupId) async { Future<List<GroupMember>> 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(); .get();
} }
Future<GroupMember?> getGroupMemberByPublicKey(Uint8List publicKey) async {
return (select(groupMembers)
..where((t) => t.groupPublicKey.equals(publicKey)))
.getSingleOrNull();
}
Future<Group?> createNewGroup(GroupsCompanion group) async { Future<Group?> createNewGroup(GroupsCompanion group) async {
return _insertGroup(group); return _insertGroup(group);
} }

View file

@ -797,5 +797,12 @@
"notificationTitleUnknownUser": "Jemand", "notificationTitleUnknownUser": "Jemand",
"notificationCategoryMessageTitle": "Nachrichten", "notificationCategoryMessageTitle": "Nachrichten",
"notificationCategoryMessageDesc": "Nachrichten von anderen Benutzern.", "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"
} }

View file

@ -575,5 +575,12 @@
"notificationTitleUnknownUser": "Someone", "notificationTitleUnknownUser": "Someone",
"notificationCategoryMessageTitle": "Messages", "notificationCategoryMessageTitle": "Messages",
"notificationCategoryMessageDesc": "Messages from other users.", "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"
} }

View file

@ -2557,6 +2557,48 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'This will permanently delete all messages in this chat.'** /// **'This will permanently delete all messages in this chat.'**
String get groupContextMenuDeleteGroup; 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 class _AppLocalizationsDelegate

View file

@ -1397,4 +1397,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get groupContextMenuDeleteGroup => String get groupContextMenuDeleteGroup =>
'Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.'; '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';
} }

View file

@ -1389,4 +1389,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get groupContextMenuDeleteGroup => String get groupContextMenuDeleteGroup =>
'This will permanently delete all messages in this chat.'; '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';
} }

View file

@ -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); 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) static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'NewGroupState', package: const $pb.PackageName(_omitMessageNames ? '' : 'http_requests'), createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'groupId', protoName: 'groupId') ..aOS(1, _omitFieldNames ? '' : 'groupId')
..a<$fixnum.Int64>(2, _omitFieldNames ? '' : 'versionId', $pb.PbFieldType.OU6, protoName: 'versionId', defaultOrMaker: $fixnum.Int64.ZERO) ..a<$fixnum.Int64>(2, _omitFieldNames ? '' : 'versionId', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO)
..a<$core.List<$core.int>>(4, _omitFieldNames ? '' : 'encryptedGroupState', $pb.PbFieldType.OY) ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'encryptedGroupState', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(5, _omitFieldNames ? '' : 'publicKey', $pb.PbFieldType.OY) ..a<$core.List<$core.int>>(4, _omitFieldNames ? '' : 'publicKey', $pb.PbFieldType.OY)
..hasRequiredFields = false ..hasRequiredFields = false
; ;
@ -419,29 +419,202 @@ class NewGroupState extends $pb.GeneratedMessage {
@$pb.TagNumber(2) @$pb.TagNumber(2)
void clearVersionId() => clearField(2); void clearVersionId() => clearField(2);
@$pb.TagNumber(4) @$pb.TagNumber(3)
$core.List<$core.int> get encryptedGroupState => $_getN(2); $core.List<$core.int> get encryptedGroupState => $_getN(2);
@$pb.TagNumber(4) @$pb.TagNumber(3)
set encryptedGroupState($core.List<$core.int> v) { $_setBytes(2, v); } set encryptedGroupState($core.List<$core.int> v) { $_setBytes(2, v); }
@$pb.TagNumber(4) @$pb.TagNumber(3)
$core.bool hasEncryptedGroupState() => $_has(2); $core.bool hasEncryptedGroupState() => $_has(2);
@$pb.TagNumber(4) @$pb.TagNumber(3)
void clearEncryptedGroupState() => clearField(4); void clearEncryptedGroupState() => clearField(3);
@$pb.TagNumber(5) @$pb.TagNumber(4)
$core.List<$core.int> get publicKey => $_getN(3); $core.List<$core.int> get publicKey => $_getN(3);
@$pb.TagNumber(5) @$pb.TagNumber(4)
set publicKey($core.List<$core.int> v) { $_setBytes(3, v); } set publicKey($core.List<$core.int> v) { $_setBytes(3, v); }
@$pb.TagNumber(5) @$pb.TagNumber(4)
$core.bool hasPublicKey() => $_has(3); $core.bool hasPublicKey() => $_has(3);
@$pb.TagNumber(5) @$pb.TagNumber(4)
void clearPublicKey() => clearField(5); 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<AppendGroupState_AppendTBS> createRepeated() => $pb.PbList<AppendGroupState_AppendTBS>();
@$core.pragma('dart2js:noInline')
static AppendGroupState_AppendTBS getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<AppendGroupState_AppendTBS>(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<AppendGroupState_AppendTBS>(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<AppendGroupState> createRepeated() => $pb.PbList<AppendGroupState>();
@$core.pragma('dart2js:noInline')
static AppendGroupState getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<AppendGroupState>(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 { class GroupState extends $pb.GeneratedMessage {
factory GroupState({ factory GroupState({
$fixnum.Int64? versionId, $fixnum.Int64? versionId,
$core.List<$core.int>? encryptedGroupState, $core.List<$core.int>? encryptedGroupState,
$core.Iterable<AppendGroupState>? appendedGroupStates,
}) { }) {
final $result = create(); final $result = create();
if (versionId != null) { if (versionId != null) {
@ -450,6 +623,9 @@ class GroupState extends $pb.GeneratedMessage {
if (encryptedGroupState != null) { if (encryptedGroupState != null) {
$result.encryptedGroupState = encryptedGroupState; $result.encryptedGroupState = encryptedGroupState;
} }
if (appendedGroupStates != null) {
$result.appendedGroupStates.addAll(appendedGroupStates);
}
return $result; return $result;
} }
GroupState._() : super(); 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); 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) 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<$fixnum.Int64>(1, _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>>(2, _omitFieldNames ? '' : 'encryptedGroupState', $pb.PbFieldType.OY)
..pc<AppendGroupState>(3, _omitFieldNames ? '' : 'appendedGroupStates', $pb.PbFieldType.PM, subBuilder: AppendGroupState.create)
..hasRequiredFields = false ..hasRequiredFields = false
; ;
@ -492,14 +669,62 @@ class GroupState extends $pb.GeneratedMessage {
@$pb.TagNumber(1) @$pb.TagNumber(1)
void clearVersionId() => clearField(1); void clearVersionId() => clearField(1);
@$pb.TagNumber(3) @$pb.TagNumber(2)
$core.List<$core.int> get encryptedGroupState => $_getN(1); $core.List<$core.int> get encryptedGroupState => $_getN(1);
@$pb.TagNumber(3) @$pb.TagNumber(2)
set encryptedGroupState($core.List<$core.int> v) { $_setBytes(1, v); } set encryptedGroupState($core.List<$core.int> v) { $_setBytes(1, v); }
@$pb.TagNumber(3) @$pb.TagNumber(2)
$core.bool hasEncryptedGroupState() => $_has(1); $core.bool hasEncryptedGroupState() => $_has(1);
@$pb.TagNumber(2)
void clearEncryptedGroupState() => clearField(2);
@$pb.TagNumber(3) @$pb.TagNumber(3)
void clearEncryptedGroupState() => clearField(3); $core.List<AppendGroupState> get appendedGroupStates => $_getList(2);
}
/// this is just a database helper to store multiple appends
class AppendGroupStateHelper extends $pb.GeneratedMessage {
factory AppendGroupStateHelper({
$core.Iterable<AppendGroupState>? 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<AppendGroupState>(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<AppendGroupStateHelper> createRepeated() => $pb.PbList<AppendGroupStateHelper>();
@$core.pragma('dart2js:noInline')
static AppendGroupStateHelper getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<AppendGroupStateHelper>(create);
static AppendGroupStateHelper? _defaultInstance;
@$pb.TagNumber(1)
$core.List<AppendGroupState> get appendedGroupStates => $_getList(0);
} }

View file

@ -89,30 +89,77 @@ final $typed_data.Uint8List updateGroupStateDescriptor = $convert.base64Decode(
const NewGroupState$json = { const NewGroupState$json = {
'1': 'NewGroupState', '1': 'NewGroupState',
'2': [ '2': [
{'1': 'groupId', '3': 1, '4': 1, '5': 9, '10': 'groupId'}, {'1': 'group_id', '3': 1, '4': 1, '5': 9, '10': 'groupId'},
{'1': 'versionId', '3': 2, '4': 1, '5': 4, '10': 'versionId'}, {'1': 'version_id', '3': 2, '4': 1, '5': 4, '10': 'versionId'},
{'1': 'encrypted_group_state', '3': 4, '4': 1, '5': 12, '10': 'encryptedGroupState'}, {'1': 'encrypted_group_state', '3': 3, '4': 1, '5': 12, '10': 'encryptedGroupState'},
{'1': 'public_key', '3': 5, '4': 1, '5': 12, '10': 'publicKey'}, {'1': 'public_key', '3': 4, '4': 1, '5': 12, '10': 'publicKey'},
], ],
}; };
/// Descriptor for `NewGroupState`. Decode as a `google.protobuf.DescriptorProto`. /// Descriptor for `NewGroupState`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List newGroupStateDescriptor = $convert.base64Decode( final $typed_data.Uint8List newGroupStateDescriptor = $convert.base64Decode(
'Cg1OZXdHcm91cFN0YXRlEhgKB2dyb3VwSWQYASABKAlSB2dyb3VwSWQSHAoJdmVyc2lvbklkGA' 'Cg1OZXdHcm91cFN0YXRlEhkKCGdyb3VwX2lkGAEgASgJUgdncm91cElkEh0KCnZlcnNpb25faW'
'IgASgEUgl2ZXJzaW9uSWQSMgoVZW5jcnlwdGVkX2dyb3VwX3N0YXRlGAQgASgMUhNlbmNyeXB0' 'QYAiABKARSCXZlcnNpb25JZBIyChVlbmNyeXB0ZWRfZ3JvdXBfc3RhdGUYAyABKAxSE2VuY3J5'
'ZWRHcm91cFN0YXRlEh0KCnB1YmxpY19rZXkYBSABKAxSCXB1YmxpY0tleQ=='); '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') @$core.Deprecated('Use groupStateDescriptor instead')
const GroupState$json = { const GroupState$json = {
'1': 'GroupState', '1': 'GroupState',
'2': [ '2': [
{'1': 'versionId', '3': 1, '4': 1, '5': 4, '10': 'versionId'}, {'1': 'version_id', '3': 1, '4': 1, '5': 4, '10': 'versionId'},
{'1': 'encrypted_group_state', '3': 3, '4': 1, '5': 12, '10': 'encryptedGroupState'}, {'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`. /// Descriptor for `GroupState`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List groupStateDescriptor = $convert.base64Decode( final $typed_data.Uint8List groupStateDescriptor = $convert.base64Decode(
'CgpHcm91cFN0YXRlEhwKCXZlcnNpb25JZBgBIAEoBFIJdmVyc2lvbklkEjIKFWVuY3J5cHRlZF' 'CgpHcm91cFN0YXRlEh0KCnZlcnNpb25faWQYASABKARSCXZlcnNpb25JZBIyChVlbmNyeXB0ZW'
'9ncm91cF9zdGF0ZRgDIAEoDFITZW5jcnlwdGVkR3JvdXBTdGF0ZQ=='); '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==');

View file

@ -14,6 +14,10 @@ import 'dart:core' as $core;
import 'package:fixnum/fixnum.dart' as $fixnum; import 'package:fixnum/fixnum.dart' as $fixnum;
import 'package:protobuf/protobuf.dart' as $pb; import 'package:protobuf/protobuf.dart' as $pb;
import 'groups.pbenum.dart';
export 'groups.pbenum.dart';
/// Stored encrypted on the server in the members columns. /// Stored encrypted on the server in the members columns.
class EncryptedGroupState extends $pb.GeneratedMessage { class EncryptedGroupState extends $pb.GeneratedMessage {
factory EncryptedGroupState({ factory EncryptedGroupState({
@ -109,6 +113,56 @@ class EncryptedGroupState extends $pb.GeneratedMessage {
void clearPadding() => clearField(5); 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<EncryptedAppendedGroupState_Type>(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<EncryptedAppendedGroupState> createRepeated() => $pb.PbList<EncryptedAppendedGroupState>();
@$core.pragma('dart2js:noInline')
static EncryptedAppendedGroupState getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<EncryptedAppendedGroupState>(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 { class EncryptedGroupStateEnvelop extends $pb.GeneratedMessage {
factory EncryptedGroupStateEnvelop({ factory EncryptedGroupStateEnvelop({
$core.List<$core.int>? nonce, $core.List<$core.int>? nonce,

View file

@ -9,3 +9,22 @@
// ignore_for_file: non_constant_identifier_names, prefer_final_fields // ignore_for_file: non_constant_identifier_names, prefer_final_fields
// ignore_for_file: unnecessary_import, unnecessary_this, unused_import // 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<EncryptedAppendedGroupState_Type> values = <EncryptedAppendedGroupState_Type> [
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');

View file

@ -36,6 +36,28 @@ final $typed_data.Uint8List encryptedGroupStateDescriptor = $convert.base64Decod
'VzQWZ0ZXJNaWxsaXNlY29uZHOIAQESGAoHcGFkZGluZxgFIAEoDFIHcGFkZGluZ0IiCiBfZGVs' 'VzQWZ0ZXJNaWxsaXNlY29uZHOIAQESGAoHcGFkZGluZxgFIAEoDFIHcGFkZGluZ0IiCiBfZGVs'
'ZXRlTWVzc2FnZXNBZnRlck1pbGxpc2Vjb25kcw=='); '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') @$core.Deprecated('Use encryptedGroupStateEnvelopDescriptor instead')
const EncryptedGroupStateEnvelop$json = { const EncryptedGroupStateEnvelop$json = {
'1': 'EncryptedGroupStateEnvelop', '1': 'EncryptedGroupStateEnvelop',

View file

@ -9,6 +9,13 @@ message EncryptedGroupState {
bytes padding = 5; bytes padding = 5;
} }
message EncryptedAppendedGroupState {
enum Type {
LEFT_GROUP = 0;
}
Type type = 1;
}
message EncryptedGroupStateEnvelop { message EncryptedGroupStateEnvelop {
bytes nonce = 1; bytes nonce = 1;
bytes encryptedGroupState = 2; bytes encryptedGroupState = 2;

View file

@ -57,6 +57,7 @@ Future<void> handleGroupCreate(
groupName: const Value(''), groupName: const Value(''),
joinedGroup: const Value(false), joinedGroup: const Value(false),
leftGroup: const Value(false), leftGroup: const Value(false),
deletedContent: const Value(false),
), ),
); );
} }

View file

@ -1,12 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.dart';
import 'package:hashlib/random.dart'; import 'package:hashlib/random.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
@ -170,32 +170,13 @@ Future<void> fetchMissingGroupPublicKey() async {
} }
} }
Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async { Future<List<int>?> _decryptEnvelop(
if (group.leftGroup) { Group group,
Log.error( List<int> encryptedGroupState,
'Could not refresh group state, as user is no longer part of the group', ) async {
);
return null;
}
try { 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( final envelope = EncryptedGroupStateEnvelop.fromBuffer(
groupStateServer.encryptedGroupState, encryptedGroupState,
); );
final chacha20 = FlutterChacha20.poly1305Aead(); final chacha20 = FlutterChacha20.poly1305Aead();
@ -210,19 +191,111 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async {
secretKey: SecretKey(group.stateEncryptionKey!), 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 = final encryptedGroupState =
EncryptedGroupState.fromBuffer(encryptedGroupStateRaw); EncryptedGroupState.fromBuffer(encryptedStateRaw);
if (group.stateVersionId >= groupStateServer.versionId.toInt()) { if (group.stateVersionId >= groupStateServer.versionId.toInt()) {
Log.info( Log.info(
'Group ${group.groupId} has already newest group state from the server!', '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<Int64>.from(encryptedGroupState.memberIds);
final adminIds = List<Int64>.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... // OH no, I am no longer a member of this group...
// -> // Return from the group...
await twonlyDB.groupsDao.updateGroup( await twonlyDB.groupsDao.updateGroup(
group.groupId, group.groupId,
const GroupsCompanion( const GroupsCompanion(
@ -232,9 +305,41 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async {
return (groupStateServer.versionId.toInt(), encryptedGroupState); return (groupStateServer.versionId.toInt(), encryptedGroupState);
} }
final isGroupAdmin = encryptedGroupState.adminIds final isGroupAdmin =
.firstWhereOrNull((t) => t.toInt() == gUser.userId) != adminIds.firstWhereOrNull((t) => t.toInt() == gUser.userId) != null;
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<int>.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( await twonlyDB.groupsDao.updateGroup(
group.groupId, group.groupId,
@ -251,7 +356,7 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async {
await twonlyDB.groupsDao.getGroupMembers(group.groupId); await twonlyDB.groupsDao.getGroupMembers(group.groupId);
// First find and insert NEW members // First find and insert NEW members
for (final memberId in encryptedGroupState.memberIds) { for (final memberId in memberIds) {
if (memberId == Int64(gUser.userId)) { if (memberId == Int64(gUser.userId)) {
continue; continue;
} }
@ -313,7 +418,7 @@ Future<(int, EncryptedGroupState)?> fetchGroupState(Group group) async {
MemberState? newMemberState; MemberState? newMemberState;
if (encryptedGroupState.adminIds.contains(Int64(member.contactId))) { if (adminIds.contains(Int64(member.contactId))) {
if (member.memberState == MemberState.normal) { if (member.memberState == MemberState.normal) {
// user was promoted // user was promoted
newMemberState = MemberState.admin; newMemberState = MemberState.admin;
@ -376,6 +481,7 @@ Future<bool> _updateGroupState(
EncryptedGroupState state, { EncryptedGroupState state, {
Uint8List? addAdmin, Uint8List? addAdmin,
Uint8List? removeAdmin, Uint8List? removeAdmin,
int? versionId,
}) async { }) async {
final chacha20 = FlutterChacha20.poly1305Aead(); final chacha20 = FlutterChacha20.poly1305Aead();
final encryptionNonce = chacha20.newNonce(); final encryptionNonce = chacha20.newNonce();
@ -397,26 +503,14 @@ Future<bool> _updateGroupState(
final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!); final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!);
final publicKey = uint8ListToHex(keyPair.getPublicKey().serialize()); final nonce = await getNonce(keyPair.getPublicKey().serialize());
if (nonce == null) return false;
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 updateTBS = UpdateGroupState_UpdateTBS( final updateTBS = UpdateGroupState_UpdateTBS(
versionId: Int64(group.stateVersionId + 1), versionId: Int64(versionId ?? group.stateVersionId + 1),
encryptedGroupState: encryptedGroupState.writeToBuffer(), encryptedGroupState: encryptedGroupState.writeToBuffer(),
publicKey: keyPair.getPublicKey().serialize(), publicKey: keyPair.getPublicKey().serialize(),
nonce: responseNonce.bodyBytes, nonce: nonce,
addAdmin: addAdmin, addAdmin: addAdmin,
removeAdmin: removeAdmin, removeAdmin: removeAdmin,
); );
@ -452,7 +546,7 @@ Future<bool> _updateGroupState(
Future<bool> manageAdminState( Future<bool> manageAdminState(
Group group, Group group,
GroupMember member, Uint8List groupPublicKey,
int contactId, int contactId,
bool remove, bool remove,
) async { ) async {
@ -469,7 +563,7 @@ Future<bool> manageAdminState(
if (remove) { if (remove) {
if (state.adminIds.contains(userId)) { if (state.adminIds.contains(userId)) {
state.adminIds.remove(userId); state.adminIds.remove(userId);
removeAdmin = member.groupPublicKey; removeAdmin = groupPublicKey;
} else { } else {
Log.info('User was already removed as admin.'); Log.info('User was already removed as admin.');
return true; return true;
@ -477,7 +571,7 @@ Future<bool> manageAdminState(
} else { } else {
if (!state.adminIds.contains(userId)) { if (!state.adminIds.contains(userId)) {
state.adminIds.add(userId); state.adminIds.add(userId);
addAdmin = member.groupPublicKey; addAdmin = groupPublicKey;
} else { } else {
Log.info('User is already admin.'); Log.info('User is already admin.');
return true; return true;
@ -524,7 +618,7 @@ Future<bool> manageAdminState(
return (await fetchGroupState(group)) != null; return (await fetchGroupState(group)) != null;
} }
Future<bool> updateGroupeName(Group group, String groupName) async { Future<bool> updateGroupName(Group group, String groupName) async {
// ensure the latest state is used // ensure the latest state is used
final currentState = await fetchGroupState(group); final currentState = await fetchGroupState(group);
if (currentState == null) return false; if (currentState == null) return false;
@ -624,7 +718,7 @@ Future<bool> addNewGroupMembers(
Future<bool> removeMemberFromGroup( Future<bool> removeMemberFromGroup(
Group group, Group group,
GroupMember member, Uint8List groupPublicKey,
int removeContactId, int removeContactId,
) async { ) async {
// ensure the latest state is used // ensure the latest state is used
@ -642,16 +736,16 @@ Future<bool> removeMemberFromGroup(
return true; return true;
} }
if (adminIdSet.contains(contactId)) { if (adminIdSet.contains(contactId)) {
if (member.groupPublicKey == null) { // if (member.groupPublicKey == null) {
// If the admin public key is not removed, that the user could potentially still update the group state. So only // // 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 // // 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. // // the he can but the other user, could still update the group state.
Log.error( // Log.error(
'Could not remove user. User is admin, but groupPublicKey is unknown.', // 'Could not remove user. User is admin, but groupPublicKey is unknown.',
); // );
return false; // return false;
} // }
removeAdmin = member.groupPublicKey; removeAdmin = groupPublicKey;
} }
membersIdSet.remove(contactId); membersIdSet.remove(contactId);
@ -684,10 +778,126 @@ Future<bool> removeMemberFromGroup(
GroupHistoriesCompanion( GroupHistoriesCompanion(
groupId: Value(group.groupId), groupId: Value(group.groupId),
type: const Value(GroupActionType.removedMember), type: const Value(GroupActionType.removedMember),
affectedContactId: Value(removeContactId), affectedContactId: Value(
removeContactId == gUser.userId ? null : removeContactId,
),
), ),
); );
// Updates the groupMembers table :) // Updates the groupMembers table :)
return (await fetchGroupState(group)) != null; return (await fetchGroupState(group)) != null;
} }
Future<Uint8List?> 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<bool> 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;
}

View file

@ -20,7 +20,7 @@ class BetterListTile extends StatelessWidget {
final String text; final String text;
final Widget? subtitle; final Widget? subtitle;
final Color? color; final Color? color;
final VoidCallback onTap; final VoidCallback? onTap;
final double iconSize; final double iconSize;
final EdgeInsets? padding; final EdgeInsets? padding;

View file

@ -1,12 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/group.services.dart'; import 'package:twonly/src/services/group.services.dart';
import 'package:twonly/src/utils/misc.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/avatar_icon.component.dart';
import 'package:twonly/src/views/components/better_list_title.dart'; import 'package:twonly/src/views/components/better_list_title.dart';
import 'package:twonly/src/views/components/verified_shield.dart'; import 'package:twonly/src/views/components/verified_shield.dart';
@ -74,7 +76,7 @@ class _GroupViewState extends State<GroupView> {
newGroupName != null && newGroupName != null &&
newGroupName != '' && newGroupName != '' &&
newGroupName != group.groupName) { newGroupName != group.groupName) {
if (!await updateGroupeName(group, newGroupName)) { if (!await updateGroupName(group, newGroupName)) {
if (mounted) { if (mounted) {
showNetworkIssue(context); showNetworkIssue(context);
} }
@ -97,6 +99,60 @@ class _GroupViewState extends State<GroupView> {
} }
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -126,7 +182,7 @@ class _GroupViewState extends State<GroupView> {
], ],
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
if (group.isGroupAdmin) if (group.isGroupAdmin && !group.leftGroup)
BetterListTile( BetterListTile(
icon: FontAwesomeIcons.pencil, icon: FontAwesomeIcons.pencil,
text: context.lang.groupNameInput, text: context.lang.groupNameInput,
@ -145,7 +201,7 @@ class _GroupViewState extends State<GroupView> {
), ),
), ),
), ),
if (group.isGroupAdmin) if (group.isGroupAdmin && !group.leftGroup)
BetterListTile( BetterListTile(
icon: FontAwesomeIcons.plus, icon: FontAwesomeIcons.plus,
text: context.lang.addMember, text: context.lang.addMember,
@ -197,12 +253,25 @@ class _GroupViewState extends State<GroupView> {
const SizedBox(height: 10), const SizedBox(height: 10),
const Divider(), const Divider(),
const SizedBox(height: 10), const SizedBox(height: 10),
BetterListTile( if (!group.leftGroup)
icon: FontAwesomeIcons.rightFromBracket, BetterListTile(
color: Colors.red, icon: FontAwesomeIcons.rightFromBracket,
text: context.lang.leaveGroup, color: Colors.red,
onTap: () => {}, 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<String?> showGroupNameChangeDialog(
void showNetworkIssue(BuildContext context) { void showNetworkIssue(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Network issue. Try again later.'), content: Text(context.lang.groupNetworkIssue),
duration: Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );
} }

View file

@ -37,7 +37,7 @@ class GroupMemberContextMenu extends StatelessWidget {
if (ok) { if (ok) {
if (!await manageAdminState( if (!await manageAdminState(
group, group,
member, member.groupPublicKey!,
contact.userId, contact.userId,
false, false,
)) { )) {
@ -58,7 +58,7 @@ class GroupMemberContextMenu extends StatelessWidget {
if (ok) { if (ok) {
if (!await manageAdminState( if (!await manageAdminState(
group, group,
member, member.groupPublicKey!,
contact.userId, contact.userId,
true, true,
)) { )) {
@ -78,7 +78,7 @@ class GroupMemberContextMenu extends StatelessWidget {
if (ok) { if (ok) {
if (!await removeMemberFromGroup( if (!await removeMemberFromGroup(
group, group,
member, member.groupPublicKey!,
contact.userId, contact.userId,
)) { )) {
if (context.mounted) { if (context.mounted) {
@ -159,7 +159,7 @@ class GroupMemberContextMenu extends StatelessWidget {
onTap: () => _removeContactAsAdmin(context), onTap: () => _removeContactAsAdmin(context),
icon: FontAwesomeIcons.key, icon: FontAwesomeIcons.key,
), ),
if (group.isGroupAdmin) if (group.isGroupAdmin && member.groupPublicKey != null)
ContextMenuItem( ContextMenuItem(
title: context.lang.removeFromGroup, title: context.lang.removeFromGroup,
onTap: () => _removeContactFromGroup(context), onTap: () => _removeContactFromGroup(context),

View file

@ -1,6 +1,9 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hashlib/random.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/misc.dart';
import 'package:twonly/src/utils/pow.dart'; import 'package:twonly/src/utils/pow.dart';
import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/animate_icon.dart';
@ -60,5 +63,20 @@ void main() {
test('Reject values > 0x7fffffff', () { test('Reject values > 0x7fffffff', () {
expect(() => getUUIDforDirectChat(0x80000000, 0), throwsArgumentError); 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,
);
});
}); });
} }