Merge pull request #405 from twonlyapp/dev
Some checks failed
Publish on Github / build_and_publish (push) Has been cancelled

- Improved: Make contact avatars clickable
- Fix: Messages occasionally not received until app restart
- Fix: Complete setup would sometimes get stuck
This commit is contained in:
Tobi 2026-05-05 11:06:13 +02:00 committed by GitHub
commit d86252d800
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 920 additions and 461 deletions

View file

@ -1,5 +1,11 @@
# Changelog # Changelog
## 0.2.9
- Improved: Make contact avatars clickable
- Fix: Messages occasionally not received until app restart
- Fix: Complete setup would sometimes get stuck
## 0.2.8 ## 0.2.8
- Fix: App did not launch sometimes on Android - Fix: App did not launch sometimes on Android

View file

@ -193,6 +193,21 @@ Future<void> postStartupTasks() async {
unawaited(finishStartedPreprocessing()); unawaited(finishStartedPreprocessing());
unawaited(createPushAvatars()); unawaited(createPushAvatars());
if (userService.currentUser.userDiscoveryInitializationError) {
unawaited(() async {
try {
await UserDiscoveryService.initializeOrUpdate(
threshold: userService.currentUser.userDiscoveryThreshold,
sharePromotion: userService.currentUser.userDiscoverySharePromotion,
);
} catch (e) {
Log.error(
'Failed to retry UserDiscovery initialization on startup: $e',
);
}
}());
}
await Future.delayed(const Duration(seconds: 10)); await Future.delayed(const Duration(seconds: 10));
unawaited(initializeBackgroundTaskManager()); unawaited(initializeBackgroundTaskManager());
// 3. Delayed tasks (Wait for app to settle) // 3. Delayed tasks (Wait for app to settle)

View file

@ -16,8 +16,12 @@ class UserDiscoveryCallbacks {
static Future<Uint8List?> signData( static Future<Uint8List?> signData(
Uint8List inputData, Uint8List inputData,
) async { ) async {
Log.info('UserDiscoveryCallbacks: signData started');
var privKey = (await getSignalIdentityKeyPair())?.getPrivateKey(); var privKey = (await getSignalIdentityKeyPair())?.getPrivateKey();
if (privKey == null) return null; if (privKey == null) {
Log.error('UserDiscoveryCallbacks: signData failed, privKey is null');
return null;
}
final random = getRandomUint8List(32); final random = getRandomUint8List(32);
final signature = sign( final signature = sign(
privKey.serialize(), privKey.serialize(),
@ -25,6 +29,7 @@ class UserDiscoveryCallbacks {
random, random,
); );
privKey = null; privKey = null;
Log.info('UserDiscoveryCallbacks: signData finished');
return signature; return signature;
} }

View file

@ -332,4 +332,18 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
return query.map((row) => row.readTable(groups)).watch(); return query.map((row) => row.readTable(groups)).watch();
} }
Future<List<Group>> getGroupsForMember(int contactId) {
final query =
select(groups).join([
innerJoin(
groupMembers,
groupMembers.groupId.equalsExp(groups.groupId),
),
])..where(
groupMembers.contactId.equals(contactId),
);
return query.map((row) => row.readTable(groups)).get();
}
} }

View file

@ -103,6 +103,25 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
}); });
} }
Future<int> getTransferredTrustVerificationsCount() async {
final kv = keyVerifications;
final ur = userDiscoveryUserRelations;
final query = selectOnly(ur, distinct: true)
..addColumns([ur.announcedUserId])
..join([
innerJoin(contacts, contacts.userId.equalsExp(ur.fromContactId)),
innerJoin(kv, kv.contactId.equalsExp(ur.fromContactId)),
])
..where(
ur.publicKeyVerifiedTimestamp.isNotNull() &
ur.announcedUserId.equalsExp(ur.fromContactId).not(),
);
final rows = await query.get();
return rows.length;
}
Stream<VerificationStatus> watchAllGroupMembersVerified(String groupId) { Stream<VerificationStatus> watchAllGroupMembersVerified(String groupId) {
final gm = groupMembers; final gm = groupMembers;
final directKv = alias(keyVerifications, 'directKv'); final directKv = alias(keyVerifications, 'directKv');

View file

@ -83,6 +83,8 @@ enum GroupActionType {
demoteToMember, demoteToMember,
updatedGroupName, updatedGroupName,
changeDisplayMaxTime, changeDisplayMaxTime,
updatedContactUsername,
updatedContactDisplayName,
} }
@DataClassName('GroupHistory') @DataClassName('GroupHistory')

View file

@ -2965,6 +2965,102 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Approve'** /// **'Approve'**
String get contactUserDiscoveryManualApprovalApprove; String get contactUserDiscoveryManualApprovalApprove;
/// No description provided for @exampleUserName1.
///
/// In en, this message translates to:
/// **'james'**
String get exampleUserName1;
/// No description provided for @exampleUserName2.
///
/// In en, this message translates to:
/// **'mary'**
String get exampleUserName2;
/// No description provided for @exampleUserName3.
///
/// In en, this message translates to:
/// **'john'**
String get exampleUserName3;
/// No description provided for @exampleUserName4.
///
/// In en, this message translates to:
/// **'patricia'**
String get exampleUserName4;
/// No description provided for @exampleUserName5.
///
/// In en, this message translates to:
/// **'robert'**
String get exampleUserName5;
/// No description provided for @exampleUserName6.
///
/// In en, this message translates to:
/// **'jennifer'**
String get exampleUserName6;
/// No description provided for @exampleUserName7.
///
/// In en, this message translates to:
/// **'michael'**
String get exampleUserName7;
/// No description provided for @exampleUserName8.
///
/// In en, this message translates to:
/// **'linda'**
String get exampleUserName8;
/// No description provided for @exampleUserName9.
///
/// In en, this message translates to:
/// **'william'**
String get exampleUserName9;
/// No description provided for @exampleUserName10.
///
/// In en, this message translates to:
/// **'lena'**
String get exampleUserName10;
/// No description provided for @exampleUserName11.
///
/// In en, this message translates to:
/// **'david'**
String get exampleUserName11;
/// No description provided for @exampleJane.
///
/// In en, this message translates to:
/// **'jane'**
String get exampleJane;
/// No description provided for @back.
///
/// In en, this message translates to:
/// **'Back'**
String get back;
/// No description provided for @onboardingExampleLabel.
///
/// In en, this message translates to:
/// **'Example'**
String get onboardingExampleLabel;
/// No description provided for @makerChangedUsername.
///
/// In en, this message translates to:
/// **'{maker} changed their username from {oldName} to {newName}.'**
String makerChangedUsername(Object maker, Object oldName, Object newName);
/// No description provided for @makerChangedDisplayName.
///
/// In en, this message translates to:
/// **'{maker} changed their display name from {oldName} to {newName}.'**
String makerChangedDisplayName(Object maker, Object oldName, Object newName);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -1669,4 +1669,56 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contactUserDiscoveryManualApprovalApprove => 'Freigeben'; String get contactUserDiscoveryManualApprovalApprove => 'Freigeben';
@override
String get exampleUserName1 => 'max_mustermann';
@override
String get exampleUserName2 => 'erika_musterfrau';
@override
String get exampleUserName3 => 'hans';
@override
String get exampleUserName4 => 'petra';
@override
String get exampleUserName5 => 'klaus';
@override
String get exampleUserName6 => 'sabine';
@override
String get exampleUserName7 => 'stefan';
@override
String get exampleUserName8 => 'monika';
@override
String get exampleUserName9 => 'christian';
@override
String get exampleUserName10 => 'lena';
@override
String get exampleUserName11 => 'david';
@override
String get exampleJane => 'erika';
@override
String get back => 'Zurück';
@override
String get onboardingExampleLabel => 'Beispiel';
@override
String makerChangedUsername(Object maker, Object oldName, Object newName) {
return '$maker hat seinen Benutzernamen von $oldName zu $newName geändert.';
}
@override
String makerChangedDisplayName(Object maker, Object oldName, Object newName) {
return '$maker hat seinen Anzeigenamen von $oldName zu $newName geändert.';
}
} }

View file

@ -1654,4 +1654,56 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contactUserDiscoveryManualApprovalApprove => 'Approve'; String get contactUserDiscoveryManualApprovalApprove => 'Approve';
@override
String get exampleUserName1 => 'james';
@override
String get exampleUserName2 => 'mary';
@override
String get exampleUserName3 => 'john';
@override
String get exampleUserName4 => 'patricia';
@override
String get exampleUserName5 => 'robert';
@override
String get exampleUserName6 => 'jennifer';
@override
String get exampleUserName7 => 'michael';
@override
String get exampleUserName8 => 'linda';
@override
String get exampleUserName9 => 'william';
@override
String get exampleUserName10 => 'lena';
@override
String get exampleUserName11 => 'david';
@override
String get exampleJane => 'jane';
@override
String get back => 'Back';
@override
String get onboardingExampleLabel => 'Example';
@override
String makerChangedUsername(Object maker, Object oldName, Object newName) {
return '$maker changed their username from $oldName to $newName.';
}
@override
String makerChangedDisplayName(Object maker, Object oldName, Object newName) {
return '$maker changed their display name from $oldName to $newName.';
}
} }

@ -1 +1 @@
Subproject commit 03bf220400bf35002c77e153768bd0f963a97d89 Subproject commit 781626f66c5f992ffad861abb7b4937f82319392

View file

@ -109,6 +109,9 @@ class UserData {
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
bool userDiscoverySharePromotion = true; bool userDiscoverySharePromotion = true;
@JsonKey(defaultValue: false)
bool userDiscoveryInitializationError = false;
// -- Custom DATA -- // -- Custom DATA --
@JsonKey(defaultValue: 100_000) @JsonKey(defaultValue: 100_000)

View file

@ -145,6 +145,7 @@ class ApiService {
} }
Future<void> onClosed() async { Future<void> onClosed() async {
Log.info('websocket connection closed');
_channel = null; _channel = null;
isAuthenticated = false; isAuthenticated = false;
_connectionStateController.add(false); _connectionStateController.add(false);
@ -249,7 +250,7 @@ class ApiService {
completer.complete(msg); completer.complete(msg);
} }
} else { } else {
await handleServerMessage(msg); unawaited(handleServerMessage(msg));
} }
} catch (e) { } catch (e) {
Log.error('Error parsing the servers message: $e'); Log.error('Error parsing the servers message: $e');

View file

@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.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/twonly.db.dart' hide Message; import 'package:twonly/src/database/twonly.db.dart' hide Message;
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/api/messages.api.dart';
@ -126,6 +127,44 @@ Future<void> handleContactUpdate(
if (contactUpdate.hasDisplayName() && if (contactUpdate.hasDisplayName() &&
contactUpdate.hasUsername() && contactUpdate.hasUsername() &&
senderProfileCounter != null) { senderProfileCounter != null) {
final contact = await twonlyDB.contactsDao
.getContactByUserId(fromUserId)
.getSingleOrNull();
if (contact != null) {
final sharedGroups = await twonlyDB.groupsDao.getGroupsForMember(
fromUserId,
);
if (contact.username != contactUpdate.username) {
for (final group in sharedGroups) {
await twonlyDB.groupsDao.insertGroupAction(
GroupHistoriesCompanion(
groupId: Value(group.groupId),
type: const Value(GroupActionType.updatedContactUsername),
contactId: Value(fromUserId),
oldGroupName: Value('@${contact.username}'),
newGroupName: Value('@${contactUpdate.username}'),
),
);
}
}
if (contact.displayName != contactUpdate.displayName) {
for (final group in sharedGroups) {
await twonlyDB.groupsDao.insertGroupAction(
GroupHistoriesCompanion(
groupId: Value(group.groupId),
type: const Value(GroupActionType.updatedContactDisplayName),
contactId: Value(fromUserId),
oldGroupName: Value(contact.displayName ?? ''),
newGroupName: Value(contactUpdate.displayName),
),
);
}
}
}
await twonlyDB.contactsDao.updateContact( await twonlyDB.contactsDao.updateContact(
fromUserId, fromUserId,
ContactsCompanion( ContactsCompanion(

View file

@ -179,6 +179,8 @@ Future<void> handleGroupUpdate(
), ),
); );
case GroupActionType.createdGroup: case GroupActionType.createdGroup:
case GroupActionType.updatedContactUsername:
case GroupActionType.updatedContactDisplayName:
break; break;
} }
} }

View file

@ -67,6 +67,7 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
Receipt? receipt, Receipt? receipt,
bool onlyReturnEncryptedData = false, bool onlyReturnEncryptedData = false,
bool blocking = true, bool blocking = true,
bool useLock = true,
}) async { }) async {
try { try {
if (receiptId == null && receipt == null) return null; if (receiptId == null && receipt == null) return null;
@ -132,6 +133,7 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
final cipherText = await signalEncryptMessage( final cipherText = await signalEncryptMessage(
receipt.contactId, receipt.contactId,
Uint8List.fromList(message.encryptedContent), Uint8List.fromList(message.encryptedContent),
useLock: useLock,
); );
if (cipherText == null) { if (cipherText == null) {
Log.error('Could not encrypt the message. Aborting and trying again.'); Log.error('Could not encrypt the message. Aborting and trying again.');
@ -336,6 +338,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
bool blocking = true, bool blocking = true,
String? messageId, String? messageId,
bool onlySendIfNoReceiptsAreOpen = false, bool onlySendIfNoReceiptsAreOpen = false,
bool useLock = true,
}) async { }) async {
if (onlySendIfNoReceiptsAreOpen) { if (onlySendIfNoReceiptsAreOpen) {
final openReceipts = await twonlyDB.receiptsDao.getReceiptCountForContact( final openReceipts = await twonlyDB.receiptsDao.getReceiptCountForContact(
@ -397,6 +400,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
receipt: receipt, receipt: receipt,
onlyReturnEncryptedData: onlyReturnEncryptedData, onlyReturnEncryptedData: onlyReturnEncryptedData,
blocking: blocking, blocking: blocking,
useLock: useLock,
); );
if (!blocking) { if (!blocking) {
return null; return null;

View file

@ -40,6 +40,8 @@ final lockHandleServerMessage = Mutex();
Future<void> handleServerMessage(server.ServerToClient msg) async { Future<void> handleServerMessage(server.ServerToClient msg) async {
return lockHandleServerMessage.protect(() async { return lockHandleServerMessage.protect(() async {
Log.info('Processing a message from the server.');
/// Returns means, that the server can delete the message from the server. /// Returns means, that the server can delete the message from the server.
final ok = client.Response_Ok()..none = true; final ok = client.Response_Ok()..none = true;
var response = client.Response()..ok = ok; var response = client.Response()..ok = ok;
@ -48,8 +50,12 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
if (msg.v0.hasRequestNewPreKeys()) { if (msg.v0.hasRequestNewPreKeys()) {
response = await handleRequestNewPreKey(); response = await handleRequestNewPreKey();
} else if (msg.v0.hasNewMessage()) { } else if (msg.v0.hasNewMessage()) {
Log.info('Got 1 message from the server.');
await handleClient2ClientMessage(msg.v0.newMessage); await handleClient2ClientMessage(msg.v0.newMessage);
} else if (msg.v0.hasNewMessages()) { } else if (msg.v0.hasNewMessages()) {
Log.info(
'Got ${msg.v0.newMessages.newMessages.length} messages from the server.',
);
for (final newMessage in msg.v0.newMessages.newMessages) { for (final newMessage in msg.v0.newMessages.newMessages) {
try { try {
await handleClient2ClientMessage(newMessage); await handleClient2ClientMessage(newMessage);
@ -70,13 +76,12 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
await apiService.sendResponse(ClientToServer()..v0 = v0); await apiService.sendResponse(ClientToServer()..v0 = v0);
AppState.gotMessageFromServer = true; AppState.gotMessageFromServer = true;
Log.info('Message from server proccessed.');
}); });
} }
DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1)); DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1));
Mutex protectReceiptCheck = Mutex();
Future<void> handleClient2ClientMessage(NewMessage newMessage) async { Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
final body = Uint8List.fromList(newMessage.body); final body = Uint8List.fromList(newMessage.body);
final fromUserId = newMessage.fromUserId.toInt(); final fromUserId = newMessage.fromUserId.toInt();
@ -84,15 +89,15 @@ Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
final message = Message.fromBuffer(body); final message = Message.fromBuffer(body);
final receiptId = message.receiptId; final receiptId = message.receiptId;
final isDuplicated = await protectReceiptCheck.protect(() async { if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) {
if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) { return;
return true; }
}
await twonlyDB.receiptsDao.gotReceipt(receiptId);
return false;
});
if (isDuplicated) { try {
await twonlyDB.receiptsDao.gotReceipt(receiptId);
Log.info('Got a message with receiptId $receiptId');
} catch (e) {
Log.error(e);
return; return;
} }

View file

@ -86,6 +86,7 @@ Future<bool> initBackgroundExecution() async {
final Mutex _keyValueMutex = Mutex(); final Mutex _keyValueMutex = Mutex();
// ignore: unreachable_from_main
Future<void> handlePeriodicTask({int lastExecutionInSecondsLimit = 120}) async { Future<void> handlePeriodicTask({int lastExecutionInSecondsLimit = 120}) async {
final shouldBeExecuted = await exclusiveAccess( final shouldBeExecuted = await exclusiveAccess(
lockName: 'periodic_task', lockName: 'periodic_task',

View file

@ -77,7 +77,8 @@ Future<void> compressAndOverlayVideo(MediaFileService media) async {
VideoSegment(video: EditorVideo.file(media.originalPath)), VideoSegment(video: EditorVideo.file(media.originalPath)),
], ],
imageLayers: [ imageLayers: [
ImageLayer(image: EditorLayerImage.file(media.overlayImagePath)), if (media.overlayImagePath.existsSync())
ImageLayer(image: EditorLayerImage.file(media.overlayImagePath)),
], ],
enableAudio: !media.removeAudio, enableAudio: !media.removeAudio,
); );

View file

@ -97,7 +97,7 @@ class MediaFileService {
final group = await twonlyDB.groupsDao.getGroup( final group = await twonlyDB.groupsDao.getGroup(
message.groupId, message.groupId,
); );
if (group != null) { if (group != null && !group.isDirectChat) {
delete = false; delete = false;
} }
} }

View file

@ -11,20 +11,31 @@ import 'package:twonly/src/services/signal/utils.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
Future<CiphertextMessage?> signalEncryptMessage( Future<CiphertextMessage?> signalEncryptMessage(
int target,
Uint8List plaintextContent, {
bool useLock = true,
}) async {
if (useLock) {
return lockingSignalProtocol.protect<CiphertextMessage?>(() async {
return _signalEncryptMessage(target, plaintextContent);
});
}
return _signalEncryptMessage(target, plaintextContent);
}
Future<CiphertextMessage?> _signalEncryptMessage(
int target, int target,
Uint8List plaintextContent, Uint8List plaintextContent,
) async { ) async {
return lockingSignalProtocol.protect<CiphertextMessage?>(() async { try {
try { final signalStore = (await getSignalStore())!;
final signalStore = (await getSignalStore())!; final address = getSignalAddress(target);
final address = getSignalAddress(target); final session = SessionCipher.fromStore(signalStore, address);
final session = SessionCipher.fromStore(signalStore, address); return await session.encrypt(plaintextContent);
return await session.encrypt(plaintextContent); } catch (e) {
} catch (e) { Log.error(e.toString());
Log.error(e.toString()); return null;
return null; }
}
});
} }
Future<(EncryptedContent?, PlaintextContent_DecryptionErrorMessage_Type?)> Future<(EncryptedContent?, PlaintextContent_DecryptionErrorMessage_Type?)>
@ -67,8 +78,9 @@ signalDecryptMessage(
Log.info(e.toString()); Log.info(e.toString());
return (null, null); return (null, null);
} on InvalidMessageException catch (e) { } on InvalidMessageException catch (e) {
Log.warn(e);
if (!resyncedUsers.contains(fromUserId)) { if (!resyncedUsers.contains(fromUserId)) {
if (await handleSessionResync(fromUserId)) { if (await handleSessionResync(fromUserId, useLock: false)) {
// This flag prevents from resyncing the session the client received multiple new // This flag prevents from resyncing the session the client received multiple new
// messages from the server he could not decrypt // messages from the server he could not decrypt
resyncedUsers.add(fromUserId); resyncedUsers.add(fromUserId);
@ -81,10 +93,10 @@ signalDecryptMessage(
type: EncryptedContent_ErrorMessages_Type.SESSION_OUT_OF_SYNC, type: EncryptedContent_ErrorMessages_Type.SESSION_OUT_OF_SYNC,
), ),
), ),
useLock: false,
); );
} }
} }
Log.warn(e);
return (null, PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN); return (null, PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN);
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);

View file

@ -91,9 +91,14 @@ Future<SignalIdentity?> getSignalIdentity() async {
} }
Future<Uint8List> getUserPublicKey() async { Future<Uint8List> getUserPublicKey() async {
Log.info('getUserPublicKey: getting identity');
final signalIdentity = (await getSignalIdentity())!; final signalIdentity = (await getSignalIdentity())!;
Log.info('getUserPublicKey: getting signal store');
final signalStore = await getSignalStoreFromIdentity(signalIdentity); final signalStore = await getSignalStoreFromIdentity(signalIdentity);
return (await signalStore.getIdentityKeyPair()).getPublicKey().serialize(); Log.info('getUserPublicKey: getting key pair');
final keyPair = await signalStore.getIdentityKeyPair();
Log.info('getUserPublicKey: serializing public key');
return keyPair.getPublicKey().serialize();
} }
Future<void> createIfNotExistsSignalIdentity() async { Future<void> createIfNotExistsSignalIdentity() async {

View file

@ -8,73 +8,83 @@ import 'package:twonly/src/services/signal/protocol_state.signal.dart';
import 'package:twonly/src/services/signal/utils.signal.dart'; import 'package:twonly/src/services/signal/utils.signal.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
Future<bool> processSignalUserData(Response_UserData userData) async { Future<bool> processSignalUserData(
return lockingSignalProtocol.protect(() async { Response_UserData userData, {
final SignalProtocolStore? signalStore = await getSignalStore(); bool useLock = true,
}) async {
if (useLock) {
return lockingSignalProtocol.protect(() async {
return _processSignalUserData(userData);
});
}
return _processSignalUserData(userData);
}
if (signalStore == null) { Future<bool> _processSignalUserData(Response_UserData userData) async {
return false; final SignalProtocolStore? signalStore = await getSignalStore();
}
final targetAddress = getSignalAddress(userData.userId.toInt()); if (signalStore == null) {
return false;
}
final sessionBuilder = SessionBuilder.fromSignalStore( final targetAddress = getSignalAddress(userData.userId.toInt());
signalStore,
targetAddress,
);
ECPublicKey? tempPrePublicKey; final sessionBuilder = SessionBuilder.fromSignalStore(
int? tempPreKeyId; signalStore,
targetAddress,
);
if (userData.prekeys.isNotEmpty) { ECPublicKey? tempPrePublicKey;
tempPrePublicKey = Curve.decodePoint( int? tempPreKeyId;
DjbECPublicKey(
Uint8List.fromList(userData.prekeys.first.prekey),
).serialize(),
1,
);
tempPreKeyId = userData.prekeys.first.id.toInt();
}
final tempSignedPreKeyId = userData.signedPrekeyId.toInt(); if (userData.prekeys.isNotEmpty) {
tempPrePublicKey = Curve.decodePoint(
final tempSignedPreKeyPublic = Curve.decodePoint( DjbECPublicKey(
DjbECPublicKey(Uint8List.fromList(userData.signedPrekey)).serialize(), Uint8List.fromList(userData.prekeys.first.prekey),
).serialize(),
1, 1,
); );
tempPreKeyId = userData.prekeys.first.id.toInt();
}
final tempSignedPreKeySignature = Uint8List.fromList( final tempSignedPreKeyId = userData.signedPrekeyId.toInt();
userData.signedPrekeySignature,
);
final tempIdentityKey = IdentityKey( final tempSignedPreKeyPublic = Curve.decodePoint(
Curve.decodePoint( DjbECPublicKey(Uint8List.fromList(userData.signedPrekey)).serialize(),
DjbECPublicKey( 1,
Uint8List.fromList(userData.publicIdentityKey), );
).serialize(),
1,
),
);
final preKeyBundle = PreKeyBundle( final tempSignedPreKeySignature = Uint8List.fromList(
userData.registrationId.toInt(), userData.signedPrekeySignature,
defaultDeviceId, );
tempPreKeyId,
tempPrePublicKey,
tempSignedPreKeyId,
tempSignedPreKeyPublic,
tempSignedPreKeySignature,
tempIdentityKey,
);
try { final tempIdentityKey = IdentityKey(
await sessionBuilder.processPreKeyBundle(preKeyBundle); Curve.decodePoint(
return true; DjbECPublicKey(
} catch (e) { Uint8List.fromList(userData.publicIdentityKey),
Log.error('could not process pre key bundle: $e'); ).serialize(),
return false; 1,
} ),
}); );
final preKeyBundle = PreKeyBundle(
userData.registrationId.toInt(),
defaultDeviceId,
tempPreKeyId,
tempPrePublicKey,
tempSignedPreKeyId,
tempSignedPreKeyPublic,
tempSignedPreKeySignature,
tempIdentityKey,
);
try {
await sessionBuilder.processPreKeyBundle(preKeyBundle);
return true;
} catch (e) {
Log.error('could not process pre key bundle: $e');
return false;
}
} }
Future<Uint8List?> getPublicKeyFromContact(int contactId) async { Future<Uint8List?> getPublicKeyFromContact(int contactId) async {
@ -96,11 +106,14 @@ Future<Uint8List?> getPublicKeyFromContact(int contactId) async {
} }
} }
Future<bool> handleSessionResync(int fromUserId) async { Future<bool> handleSessionResync(
int fromUserId, {
bool useLock = true,
}) async {
final userData = await apiService.getUserById(fromUserId); final userData = await apiService.getUserById(fromUserId);
if (userData != null) { if (userData != null) {
Log.info('Got new session data from the server to re-sync the session'); Log.info('Got new session data from the server to re-sync the session');
return processSignalUserData(userData); return processSignalUserData(userData, useLock: useLock);
} }
Log.info('Could not download userdata from the server.'); Log.info('Could not download userdata from the server.');
return false; return false;

View file

@ -75,22 +75,27 @@ class UserDiscoveryService {
required int threshold, required int threshold,
required bool sharePromotion, required bool sharePromotion,
}) async { }) async {
try { Log.info('UserDiscoveryService: initializeOrUpdate started');
await FlutterUserDiscovery.initializeOrUpdate( final userId = userService.currentUser.userId;
threshold: threshold, final publicKey = await getUserPublicKey();
userId: userService.currentUser.userId, Log.info('UserDiscoveryService: initializing Rust bridge');
publicKey: await getUserPublicKey(), await FlutterUserDiscovery.initializeOrUpdate(
sharePromotion: sharePromotion, threshold: threshold,
); userId: userId,
await UserService.update( publicKey: publicKey,
(u) => u sharePromotion: sharePromotion,
..isUserDiscoveryEnabled = true ).timeout(const Duration(seconds: 8));
..userDiscoverySharePromotion = sharePromotion Log.info(
..userDiscoveryThreshold = threshold, 'UserDiscoveryService: Rust bridge initialized, updating UserService',
); );
} catch (e) { await UserService.update(
Log.error(e); (u) => u
} ..isUserDiscoveryEnabled = true
..userDiscoverySharePromotion = sharePromotion
..userDiscoveryThreshold = threshold
..userDiscoveryInitializationError = false,
);
Log.info('UserDiscoveryService: initializeOrUpdate finished');
} }
static Future<Uint8List?> getCurrentVersion() async { static Future<Uint8List?> getCurrentVersion() async {

View file

@ -46,6 +46,8 @@ Future<void> handleUserStudyUpload() async {
final contacts = await twonlyDB.contactsDao.getAllContacts(); final contacts = await twonlyDB.contactsDao.getAllContacts();
final verifications = await twonlyDB.keyVerificationDao final verifications = await twonlyDB.keyVerificationDao
.getFirstVerificationTypeByContacts(); .getFirstVerificationTypeByContacts();
final udVerifiedByContactsCount = await twonlyDB.keyVerificationDao
.getTransferredTrustVerificationsCount();
final udFriendsShared = await twonlyDB.contactsDao final udFriendsShared = await twonlyDB.contactsDao
.getContactsAnnouncedViaUserDiscovery(); .getContactsAnnouncedViaUserDiscovery();
@ -81,6 +83,7 @@ Future<void> handleUserStudyUpload() async {
'user_study_count_new_friends_via_suggestion': 'user_study_count_new_friends_via_suggestion':
userService.currentUser.userStudyCountNewFriendsViaSuggestion, userService.currentUser.userStudyCountNewFriendsViaSuggestion,
'user_discovery_count_verified_by_contacts': udVerifiedByContactsCount,
'accepted_contacts': contacts.where((c) => c.accepted).length, 'accepted_contacts': contacts.where((c) => c.accepted).length,
'verified_contacts': verifications.length, 'verified_contacts': verifications.length,

View file

@ -5,7 +5,9 @@ final ThemeData darkTheme = ThemeData.dark().copyWith(
brightness: Brightness.dark, brightness: Brightness.dark,
seedColor: const Color(0xFF57CC99), seedColor: const Color(0xFF57CC99),
surface: const Color.fromARGB(255, 20, 18, 23), surface: const Color.fromARGB(255, 20, 18, 23),
surfaceContainer: const Color.fromARGB(255, 33, 30, 39), surfaceContainer: const Color.fromARGB(255, 45, 41, 54),
surfaceContainerLow: const Color.fromARGB(255, 38, 34, 45),
surfaceContainerHigh: const Color.fromARGB(255, 52, 48, 62),
), ),
inputDecorationTheme: const InputDecorationTheme( inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(), border: OutlineInputBorder(),

View file

@ -77,7 +77,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
contactSub?.cancel(); contactSub?.cancel();
groupActionsSub?.cancel(); groupActionsSub?.cancel();
_nextTypingIndicator?.cancel(); _nextTypingIndicator?.cancel();
textFieldFocus?.dispose();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@ -108,7 +107,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
}); });
protectMessageUpdating.protect(() async { protectMessageUpdating.protect(() async {
if (groupActionsSub == null && !newGroup.isDirectChat) { if (groupActionsSub == null) {
final actionsStream = twonlyDB.groupsDao.watchGroupActions( final actionsStream = twonlyDB.groupsDao.watchGroupActions(
newGroup.groupId, newGroup.groupId,
); );

View file

@ -105,6 +105,24 @@ class _ChatGroupActionState extends State<ChatGroupAction> {
text = (contact == null) text = (contact == null)
? context.lang.youLeftGroup ? context.lang.youLeftGroup
: context.lang.makerLeftGroup(maker); : context.lang.makerLeftGroup(maker);
case GroupActionType.updatedContactUsername:
if (contact != null) {
icon = FontAwesomeIcons.userPen;
text = context.lang.makerChangedUsername(
maker,
widget.action.oldGroupName!,
widget.action.newGroupName!,
);
}
case GroupActionType.updatedContactDisplayName:
if (contact != null) {
icon = FontAwesomeIcons.userPen;
text = context.lang.makerChangedDisplayName(
maker,
widget.action.oldGroupName!,
widget.action.newGroupName!,
);
}
} }
// switch (widget.action.type) { // switch (widget.action.type) {

View file

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart' hide Column;
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:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -180,9 +180,31 @@ class _ContactViewState extends State<ContactView> {
body: ListView( body: ListView(
key: ValueKey(contact.userId), key: ValueKey(contact.userId),
children: [ children: [
Padding( Center(
padding: const EdgeInsets.all(10), child: GestureDetector(
child: AvatarIcon(contactId: contact.userId, fontSize: 30), onTap: () {
// ignore: inference_failure_on_function_invocation
showDialog(
context: context,
builder: (context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AvatarIcon(contactId: contact.userId, fontSize: 200),
],
),
);
},
);
},
child: Padding(
padding: const EdgeInsets.all(10),
child: AvatarIcon(contactId: contact.userId, fontSize: 30),
),
),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -331,7 +353,9 @@ class _ContactViewState extends State<ContactView> {
), ),
); );
}, },
child: Text(context.lang.contactUserDiscoveryManualApprovalApprove), child: Text(
context.lang.contactUserDiscoveryManualApprovalApprove,
),
), ),
) )
else else

View file

@ -43,6 +43,14 @@ extension SetupPagesExtension on SetupPages {
} }
return null; return null;
} }
SetupPages? previous() {
final prevIndex = index - 1;
if (prevIndex >= 0) {
return SetupPages.values[prevIndex];
}
return null;
}
} }
class SetupView extends StatefulWidget { class SetupView extends StatefulWidget {
@ -119,31 +127,51 @@ class _SetupViewState extends State<SetupView> {
), ),
body: ListView( body: ListView(
key: ValueKey(currentPage.name), key: ValueKey(currentPage.name),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 30), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
children: [ children: [
_buildPage(currentPage, state), _buildPage(currentPage, state),
if (!currentPage.isLast) if (currentPage.index > 0 || !currentPage.isLast)
SizedBox( Padding(
height: 50, padding: const EdgeInsets.only(top: 8),
child: Center( child: Row(
child: TextButton( mainAxisAlignment: MainAxisAlignment.center,
onPressed: () async { children: [
await UserService.update( if (currentPage.index > 0)
(u) => u.skipSetupPages = true, TextButton(
); onPressed: () async {
widget.onUpdate?.call(); await UserService.update((u) {
}, u.currentSetupPage = currentPage.previous()?.name;
child: Text( });
context.lang.onboardingFinishLater, },
style: TextStyle( child: Text(
color: context.color.primary, context.lang.back,
fontWeight: FontWeight.bold, style: TextStyle(
color: context.color.primary,
fontWeight: FontWeight.bold,
),
),
), ),
), if (currentPage.index > 0 && !currentPage.isLast)
), const SizedBox(width: 24),
if (!currentPage.isLast)
TextButton(
onPressed: () async {
await UserService.update(
(u) => u.skipSetupPages = true,
);
widget.onUpdate?.call();
},
child: Text(
context.lang.onboardingFinishLater,
style: TextStyle(
color: context.color.primary,
fontWeight: FontWeight.bold,
),
),
),
],
), ),
), ),
const SizedBox(height: 60),
], ],
), ),
); );

View file

@ -65,7 +65,7 @@ class AddNewContactsPage extends StatelessWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 40), padding: const EdgeInsets.symmetric(horizontal: 35),
child: Column( child: Column(
children: [ children: [

View file

@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
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:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/backup/common.backup.dart'; import 'package:twonly/src/services/backup/common.backup.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
@ -24,19 +23,6 @@ class _BackupSetupPageState extends State<BackupSetupPage> {
final TextEditingController passwordCtrl = TextEditingController(); final TextEditingController passwordCtrl = TextEditingController();
final TextEditingController repeatedPasswordCtrl = TextEditingController(); final TextEditingController repeatedPasswordCtrl = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (userService.currentUser.twonlySafeBackup != null) {
// twonly safe is already configured...
UserService.update((user) {
user.currentSetupPage = SetupPages.backup.next()?.name;
});
}
});
}
Future<bool> onPressedEnableTwonlySafe() async { Future<bool> onPressedEnableTwonlySafe() async {
setState(() { setState(() {
isLoading = true; isLoading = true;

View file

@ -8,53 +8,101 @@ class MockContactRequestActionsComp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return IgnorePointer(
// width: 125, child: SizedBox(
// width: 125,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 4),
SizedBox(
height: 20,
// width: 45,
child: FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.only(right: 2, left: 4),
backgroundColor: context.color.surfaceContainerHigh,
foregroundColor: context.color.onSurface,
),
onPressed: () {},
child: Row(
children: [
const Icon(
Icons.person_off_rounded,
color: Color.fromARGB(164, 244, 67, 54),
size: 12,
),
Text(
context.lang.contactActionBlock,
style: const TextStyle(fontSize: 8),
),
],
),
),
),
const SizedBox(width: 6),
SizedBox(
height: 20,
// width: 50,
child: FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.only(right: 2, left: 4),
backgroundColor: context.color.surfaceContainerHigh,
foregroundColor: context.color.onSurface,
),
onPressed: () {},
child: Row(
children: [
const Icon(Icons.check, color: Colors.green, size: 12),
Text(
context.lang.contactActionAccept,
style: const TextStyle(fontSize: 8),
),
],
),
),
),
IconButton(
style: IconButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 2),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
constraints: const BoxConstraints(),
icon: const Icon(Icons.close, size: 12),
onPressed: () {},
),
],
),
),
);
}
}
class MockContactSuggestedActionsComp extends StatelessWidget {
const MockContactSuggestedActionsComp({super.key});
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(width: 4), const SizedBox(width: 4),
SizedBox( SizedBox(
height: 20, height: 20,
// width: 45,
child: FilledButton( child: FilledButton(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
padding: const EdgeInsets.only(right: 2, left: 4), padding: const EdgeInsets.only(right: 8, left: 4),
backgroundColor: context.color.surfaceContainerHigh, ).merge(secondaryGreyButtonStyle(context)),
foregroundColor: context.color.onSurface,
),
onPressed: () {}, onPressed: () {},
child: Row( child: Row(
children: [ children: [
const Icon( const Padding(
Icons.person_off_rounded, padding: EdgeInsets.symmetric(horizontal: 6),
color: Color.fromARGB(164, 244, 67, 54), child: FaIcon(FontAwesomeIcons.userPlus, size: 10),
size: 12,
), ),
Text( Text(
context.lang.contactActionBlock, context.lang.friendSuggestionsRequest,
style: const TextStyle(fontSize: 8),
),
],
),
),
),
const SizedBox(width: 6),
SizedBox(
height: 20,
// width: 50,
child: FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.only(right: 2, left: 4),
backgroundColor: context.color.surfaceContainerHigh,
foregroundColor: context.color.onSurface,
),
onPressed: () {},
child: Row(
children: [
const Icon(Icons.check, color: Colors.green, size: 12),
Text(
context.lang.contactActionAccept,
style: const TextStyle(fontSize: 8), style: const TextStyle(fontSize: 8),
), ),
], ],
@ -75,47 +123,3 @@ class MockContactRequestActionsComp extends StatelessWidget {
); );
} }
} }
class MockContactSuggestedActionsComp extends StatelessWidget {
const MockContactSuggestedActionsComp({super.key});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 4),
SizedBox(
height: 20,
child: FilledButton(
style: FilledButton.styleFrom(
padding: const EdgeInsets.only(right: 8, left: 4),
).merge(secondaryGreyButtonStyle(context)),
onPressed: () {},
child: Row(
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: FaIcon(FontAwesomeIcons.userPlus, size: 10),
),
Text(
context.lang.friendSuggestionsRequest,
style: const TextStyle(fontSize: 8),
),
],
),
),
),
IconButton(
style: IconButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 2),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
constraints: const BoxConstraints(),
icon: const Icon(Icons.close, size: 12),
onPressed: () {},
),
],
);
}
}

View file

@ -22,7 +22,7 @@ class NextButtonComp extends StatelessWidget {
userService.currentUser.currentSetupPage, userService.currentUser.currentSetupPage,
); );
return ElevatedButton( return ElevatedButton(
onPressed: canSubmit onPressed: (canSubmit && !isLoading)
? () async { ? () async {
if (onPressed != null) { if (onPressed != null) {
final error = await onPressed?.call(); final error = await onPressed?.call();

View file

@ -1,9 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart';
import 'package:twonly/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart';
@ -17,20 +15,7 @@ class LetYourFriendsFindYou extends StatefulWidget {
} }
class _LetYourFriendsFindYouState extends State<LetYourFriendsFindYou> { class _LetYourFriendsFindYouState extends State<LetYourFriendsFindYou> {
@override bool _isLoading = false;
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (userService.currentUser.isUserDiscoveryEnabled &&
userService.currentUser.userDiscoverySharePromotion) {
// feature is already configured...
UserService.update((user) {
user.currentSetupPage = SetupPages.letYourFriendsFindYou.next()?.name;
});
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -77,8 +62,19 @@ class _LetYourFriendsFindYouState extends State<LetYourFriendsFindYou> {
), ),
const SizedBox(height: 50), const SizedBox(height: 50),
NextButtonComp( NextButtonComp(
isLoading: _isLoading,
onPressed: () async { onPressed: () async {
return !(await widget.state.initializeOrUpdate()); setState(() {
_isLoading = true;
});
try {
final result = await widget.state.initializeOrUpdate();
return !result;
} finally {
setState(() {
_isLoading = false;
});
}
}, },
), ),
], ],

View file

@ -1,7 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/next_button.comp.dart';
import 'package:twonly/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart'; import 'package:twonly/src/visual/views/settings/privacy/user_discovery/components/user_discovery_setup.comp.dart';
@ -16,20 +13,6 @@ class ShareYourFriendsSetupPage extends StatefulWidget {
} }
class _ShareYourFriendsSetupPageState extends State<ShareYourFriendsSetupPage> { class _ShareYourFriendsSetupPageState extends State<ShareYourFriendsSetupPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (userService.currentUser.isUserDiscoveryEnabled) {
// feature is already configured...
UserService.update((user) {
user.currentSetupPage = SetupPages.shareYourFriends.next()?.name;
});
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
@ -41,11 +24,7 @@ class _ShareYourFriendsSetupPageState extends State<ShareYourFriendsSetupPage> {
showOnlySpecificPage: UserDiscoveryPages.shareYourFriends, showOnlySpecificPage: UserDiscoveryPages.shareYourFriends,
), ),
const SizedBox(height: 60), const SizedBox(height: 60),
const NextButtonComp( const NextButtonComp(),
// onPressed: () async {
// return !(await widget.state.initializeOrUpdate());
// },
),
], ],
), ),
); );

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/services/user_discovery.service.dart'; import 'package:twonly/src/services/user_discovery.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart';
@ -9,18 +10,18 @@ import 'package:twonly/src/visual/views/contact/add_new_contact_components/frien
import 'package:twonly/src/visual/views/onboarding/setup/components/mock_contact_request_actions.comp.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/mock_contact_request_actions.comp.dart';
import 'package:twonly/src/visual/views/onboarding/setup/components/setup_switch_card.comp.dart'; import 'package:twonly/src/visual/views/onboarding/setup/components/setup_switch_card.comp.dart';
const exampleUsers = [ List<String> getExampleUsers(BuildContext context) => [
'james', context.lang.exampleUserName1,
'mary', context.lang.exampleUserName2,
'john', context.lang.exampleUserName3,
'patricia', context.lang.exampleUserName4,
'robert', context.lang.exampleUserName5,
'jennifer', context.lang.exampleUserName6,
'michael', context.lang.exampleUserName7,
'linda', context.lang.exampleUserName8,
'william', context.lang.exampleUserName9,
'lena', context.lang.exampleUserName10,
'david', context.lang.exampleUserName11,
]; ];
class UserDiscoverySetupState { class UserDiscoverySetupState {
@ -52,21 +53,39 @@ class UserDiscoverySetupState {
} }
Future<bool> initializeOrUpdate() async { Future<bool> initializeOrUpdate() async {
if (isUserDiscoveryEnabled) { try {
await UserDiscoveryService.initializeOrUpdate( Log.info('UserDiscoverySetupState: initializeOrUpdate started');
threshold: threshold, var hasError = false;
sharePromotion: sharePromotion, if (isUserDiscoveryEnabled) {
); Log.info('UserDiscoverySetupState: initializing UserDiscoveryService');
try {
await UserDiscoveryService.initializeOrUpdate(
threshold: threshold,
sharePromotion: sharePromotion,
);
} catch (e) {
Log.error(
'UserDiscoverySetupState: UserDiscoveryService failed or timed out: $e',
);
hasError = true;
}
}
Log.info('UserDiscoverySetupState: updating UserService');
await UserService.update((u) {
u
..isUserDiscoveryEnabled = isUserDiscoveryEnabled
..requiredSendImages = requiredSendImages
..userDiscoveryRequiresManualApproval = isManualApprovalEnabled
..userDiscoveryInitializationError = hasError;
});
Log.info('UserDiscoverySetupState: initializeOrUpdate finished');
return true;
} catch (e) {
Log.error('UserDiscoverySetupState: initializeOrUpdate failed: $e');
return false;
} }
await UserService.update((u) {
u
..isUserDiscoveryEnabled = isUserDiscoveryEnabled
..requiredSendImages = requiredSendImages
..userDiscoveryRequiresManualApproval = isManualApprovalEnabled;
});
return true;
} }
} }
@ -107,15 +126,12 @@ class UserDiscoverySetupComp extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), const SizedBox(height: 24),
SetupSwitchCard( SetupSwitchCard(
value: state.isUserDiscoveryEnabled, value: state.isUserDiscoveryEnabled,
onChanged: (val) => state.update(() { onChanged: (val) => state.update(() {
state.isUserDiscoveryEnabled = val; state.isUserDiscoveryEnabled = val;
if (!val) {
state.sharePromotion = false;
}
}), }),
title: context.lang.onboardingUserDiscoveryShareFriends, title: context.lang.onboardingUserDiscoveryShareFriends,
expandedChild: Column( expandedChild: Column(
@ -143,48 +159,10 @@ class UserDiscoverySetupComp extends StatelessWidget {
), ),
), ),
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.only(bottom: 8),
child: Divider(), child: Divider(),
), ),
Text( const _ExampleLabel(),
context.lang.onboardingUserDiscoveryContactsVerifiedBadge,
style: TextStyle(
color: context.color.onSurfaceVariant,
fontSize: 12,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Center(
child: Container(
width: 100,
height: 40,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey, width: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AvatarIcon(fontSize: 12),
SizedBox(width: 5),
Text(
'jane',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
SizedBox(width: 5),
VerificationBadgeComp(
isVerifiedByTransferredTrust: true,
size: 14,
clickable: false,
),
],
),
),
),
const SizedBox(height: 24),
Text( Text(
context.lang.onboardingUserDiscoveryWhoIsRequesting, context.lang.onboardingUserDiscoveryWhoIsRequesting,
style: TextStyle( style: TextStyle(
@ -214,9 +192,9 @@ class UserDiscoverySetupComp extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( Text(
'jane', context.lang.exampleJane,
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 13, fontSize: 13,
), ),
@ -226,8 +204,8 @@ class UserDiscoverySetupComp extends StatelessWidget {
children: buildFriendsListTextString( children: buildFriendsListTextString(
context, context,
[ [
'mary', context.lang.exampleUserName2,
'james', context.lang.exampleUserName1,
], ],
), ),
style: const TextStyle(fontSize: 10), style: const TextStyle(fontSize: 10),
@ -241,6 +219,47 @@ class UserDiscoverySetupComp extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: 24),
Text(
context.lang.onboardingUserDiscoveryContactsVerifiedBadge,
style: TextStyle(
color: context.color.onSurfaceVariant,
fontSize: 12,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Center(
child: Container(
width: 100,
height: 40,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey, width: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AvatarIcon(fontSize: 12),
const SizedBox(width: 5),
Text(
context.lang.exampleJane,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 5),
const VerificationBadgeComp(
isVerifiedByTransferredTrust: true,
size: 14,
clickable: false,
),
],
),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
), ),
@ -275,18 +294,15 @@ class UserDiscoverySetupComp extends StatelessWidget {
SetupSwitchCard( SetupSwitchCard(
value: state.sharePromotion, value: state.sharePromotion,
onChanged: (val) => state.update(() { onChanged: (val) => state.update(() {
if (val) {
state.isUserDiscoveryEnabled = true;
}
state.sharePromotion = val; state.sharePromotion = val;
}), }),
title: context.lang.onboardingUserDiscoveryBeRecommended, title: context.lang.onboardingUserDiscoveryBeRecommended,
expandedChild: Padding( expandedChild: Column(
padding: const EdgeInsets.all(12), crossAxisAlignment: CrossAxisAlignment.stretch,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.stretch, Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 16),
Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
@ -319,121 +335,128 @@ class UserDiscoverySetupComp extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16), ),
Text( const Padding(
context.lang.onboardingUserDiscoveryWhatOthersSee, padding: EdgeInsets.only(bottom: 8, top: 8),
style: TextStyle( child: Divider(),
color: context.color.onSurfaceVariant, ),
fontSize: 12, const _ExampleLabel(),
), Text(
textAlign: TextAlign.center, context.lang.onboardingUserDiscoveryWhatOthersSee,
style: TextStyle(
color: context.color.onSurfaceVariant,
fontSize: 12,
), ),
const SizedBox(height: 16), textAlign: TextAlign.center,
Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 16), const SizedBox(height: 16),
child: Container( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 16),
horizontal: 6, child: Container(
vertical: 3, padding: const EdgeInsets.symmetric(
), horizontal: 6,
decoration: BoxDecoration( vertical: 3,
border: Border.all(color: Colors.grey, width: 0.5), ),
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
), border: Border.all(color: Colors.grey, width: 0.5),
child: Row( borderRadius: BorderRadius.circular(12),
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ child: Row(
const AvatarIcon(fontSize: 14), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(width: 5), children: [
Expanded( const AvatarIcon(fontSize: 14),
child: Column( const SizedBox(width: 5),
crossAxisAlignment: CrossAxisAlignment.start, Expanded(
children: [ child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
userService.currentUser.username, children: [
style: const TextStyle( Text(
fontWeight: FontWeight.bold, userService.currentUser.username,
), style: const TextStyle(
fontWeight: FontWeight.bold,
), ),
RichText( ),
text: TextSpan( RichText(
children: buildFriendsListTextString( text: TextSpan(
context, children: buildFriendsListTextString(
exampleUsers.sublist( context,
0, getExampleUsers(context).sublist(
state.threshold, 0,
), state.threshold,
), ),
style: const TextStyle(fontSize: 11),
), ),
style: const TextStyle(fontSize: 11),
), ),
], ),
), ],
), ),
const MockContactSuggestedActionsComp(), ),
], const MockContactSuggestedActionsComp(),
), ],
), ),
), ),
const SizedBox(height: 16), ),
Text( const SizedBox(height: 16),
context.lang.onboardingUserDiscoveryWhatYouSee, Text(
style: TextStyle( context.lang.onboardingUserDiscoveryWhatYouSee,
color: context.color.onSurfaceVariant, style: TextStyle(
fontSize: 12, color: context.color.onSurfaceVariant,
), fontSize: 12,
textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), textAlign: TextAlign.center,
Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 16), const SizedBox(height: 16),
child: Container( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, horizontal: 16,
vertical: 3, ),
), child: Container(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(
border: Border.all(color: Colors.grey, width: 0.5), horizontal: 6,
borderRadius: BorderRadius.circular(12), vertical: 3,
), ),
child: Row( decoration: BoxDecoration(
mainAxisAlignment: MainAxisAlignment.center, border: Border.all(color: Colors.grey, width: 0.5),
children: [ borderRadius: BorderRadius.circular(12),
const AvatarIcon(fontSize: 14), ),
const SizedBox(width: 5), child: Row(
Expanded( mainAxisAlignment: MainAxisAlignment.center,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, const AvatarIcon(fontSize: 14),
children: [ const SizedBox(width: 5),
const Text( Expanded(
'jane', child: Column(
style: TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.bold, children: [
fontSize: 13, Text(
), context.lang.exampleJane,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
), ),
RichText( ),
text: TextSpan( RichText(
children: buildFriendsListTextString( text: TextSpan(
context, children: buildFriendsListTextString(
exampleUsers.sublist( context,
0, getExampleUsers(context).sublist(
state.threshold, 0,
), state.threshold,
), ),
style: const TextStyle(fontSize: 10),
), ),
style: const TextStyle(fontSize: 10),
), ),
], ),
), ],
), ),
const MockContactRequestActionsComp(), ),
], const MockContactRequestActionsComp(),
), ],
), ),
), ),
], ),
), const SizedBox(height: 16),
],
), ),
), ),
], ],
@ -442,3 +465,28 @@ class UserDiscoverySetupComp extends StatelessWidget {
); );
} }
} }
class _ExampleLabel extends StatelessWidget {
const _ExampleLabel();
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey, width: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Text(
context.lang.onboardingExampleLabel,
style: const TextStyle(fontSize: 10),
),
),
),
);
}
}

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.2.8+117 version: 0.2.9+118
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0

View file

@ -46,7 +46,7 @@ impl UserDiscoveryUtils for UserDiscoveryUtilsFlutter {
impl UserDiscoveryStore for UserDiscoveryStoreFlutter { impl UserDiscoveryStore for UserDiscoveryStoreFlutter {
async fn get_config(&self) -> Result<String> { async fn get_config(&self) -> Result<String> {
let ws = get_twonly_flutter().unwrap(); let ws = get_twonly_flutter()?;
let config_path = let config_path =
PathBuf::from(&ws.config.data_directory).join("user_discovery_config.json"); PathBuf::from(&ws.config.data_directory).join("user_discovery_config.json");
@ -59,7 +59,7 @@ impl UserDiscoveryStore for UserDiscoveryStoreFlutter {
async fn update_config(&self, update: String) -> Result<()> { async fn update_config(&self, update: String) -> Result<()> {
tracing::debug!("Updating configuration file."); tracing::debug!("Updating configuration file.");
let ws = get_twonly_flutter().unwrap(); let ws = get_twonly_flutter()?;
let config_path = let config_path =
PathBuf::from(&ws.config.data_directory).join("user_discovery_config.json"); PathBuf::from(&ws.config.data_directory).join("user_discovery_config.json");
std::fs::write(config_path, &update)?; std::fs::write(config_path, &update)?;

View file

@ -10,12 +10,16 @@ impl FlutterUserDiscovery {
public_key: Vec<u8>, public_key: Vec<u8>,
share_promotion: bool, share_promotion: bool,
) -> Result<()> { ) -> Result<()> {
Ok(get_twonly_flutter()? tracing::info!("Rust bridge: initialize_or_update started");
.user_discovery let twonly = get_twonly_flutter()?;
.get() tracing::info!("Rust bridge: getting user_discovery lock");
.await let user_discovery = twonly.user_discovery.get().await;
tracing::info!("Rust bridge: calling initialize_or_update on protocols");
let res = user_discovery
.initialize_or_update(threshold, user_id, public_key, share_promotion) .initialize_or_update(threshold, user_id, public_key, share_promotion)
.await?) .await;
tracing::info!("Rust bridge: initialize_or_update on protocols finished");
Ok(res?)
} }
pub async fn get_current_version() -> Result<Vec<u8>> { pub async fn get_current_version() -> Result<Vec<u8>> {

View file

@ -91,8 +91,8 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
public_key: Vec<u8>, public_key: Vec<u8>,
share_promotion: bool, share_promotion: bool,
) -> Result<()> { ) -> Result<()> {
let config_lock = self.config_lock.lock().await; tracing::info!("Protocols: initialize_or_update started, getting config from store");
let mut config = match self.store.get_config().await { let config = match self.store.get_config().await {
Ok(config) => { Ok(config) => {
let mut config: UserDiscoveryConfig = serde_json::from_str(&config)?; let mut config: UserDiscoveryConfig = serde_json::from_str(&config)?;
config.threshold = threshold; config.threshold = threshold;
@ -113,23 +113,39 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
public_key, public_key,
}; };
tracing::info!("Protocols: signing data");
let signature = self.utils.sign_data(&signed_data.encode_to_vec()).await?; let signature = self.utils.sign_data(&signed_data.encode_to_vec()).await?;
debug_assert_eq!(threshold, config.threshold); debug_assert_eq!(threshold, config.threshold);
tracing::info!("Protocols: setting up announcements");
let verification_shares = self let verification_shares = self
.setup_announcements(&config, signed_data, signature) .setup_announcements(&config, signed_data, signature)
.await?; .await?;
debug_assert_eq!(verification_shares.len(), threshold as usize - 1); debug_assert_eq!(verification_shares.len(), threshold as usize - 1);
config.public_id = public_id; tracing::info!("Protocols: updating config in store");
config.announcement_version += 1;
config.verification_shares = verification_shares;
config.share_promotion = share_promotion;
self.update_config(config, config_lock).await?; let config_lock = self.config_lock.lock().await;
let mut final_config = match self.store.get_config().await {
Ok(c) => serde_json::from_str(&c)?,
Err(_) => UserDiscoveryConfig {
threshold,
user_id,
..Default::default()
},
};
final_config.public_id = public_id;
final_config.announcement_version += 1;
final_config.verification_shares = verification_shares;
final_config.share_promotion = share_promotion;
final_config.threshold = threshold;
self.update_config(final_config, config_lock).await?;
tracing::info!("Protocols: initialize_or_update finished");
Ok(()) Ok(())
} }