diff --git a/.github/workflows/dev_github.yml b/.github/workflows/dev_github.yml index e94dd0a8..aed7a46a 100644 --- a/.github/workflows/dev_github.yml +++ b/.github/workflows/dev_github.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: {} push: branches: - - dev + - disabled_dev paths: - lib/**/*.dart diff --git a/lib/app.dart b/lib/app.dart index 4fdd460e..00a8bb7e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -192,16 +192,18 @@ class _AppMainWidgetState extends State { HomeViewState.streamSharedLink.add(uri); } - // Subscribe to all events (initial link and further) - _deepLinkSub = AppLinks().uriLinkStream.listen((uri) async { + Future handleIncomingUrl(Uri uri) async { if (!mounted) return; - Log.info('Got link via app links: ${uri.scheme}'); + Log.info('Handling incoming link: ${uri.scheme}://${uri.host}'); if (!await handleIntentUrl(context, uri)) { if (uri.scheme.startsWith('http')) { handleShareLink(uri); } } - }); + } + + // Subscribe to all events (initial link and further) + _deepLinkSub = AppLinks().uriLinkStream.listen(handleIncomingUrl); void handleShareMedia(String path, MediaType type) { HomeViewState.pendingSharedMedia = (path, type); @@ -212,7 +214,7 @@ class _AppMainWidgetState extends State { _intentStreamSub = initIntentStreams( context, - handleShareLink, + handleIncomingUrl, handleShareMedia, ); } diff --git a/lib/core/bridge/wrapper/signal.dart b/lib/core/bridge/wrapper/signal.dart index 9979fd0f..f7f36827 100644 --- a/lib/core/bridge/wrapper/signal.dart +++ b/lib/core/bridge/wrapper/signal.dart @@ -43,6 +43,11 @@ class RustSignal { contactId: contactId, ); + static Future getSafetyNumber({required PlatformInt64 contactId}) => + RustLib.instance.api.crateBridgeWrapperSignalRustSignalGetSafetyNumber( + contactId: contactId, + ); + static Future getUserPublicKey() => RustLib.instance.api.crateBridgeWrapperSignalRustSignalGetUserPublicKey(); @@ -57,6 +62,14 @@ class RustSignal { bundle: bundle, ); + /// Removes the current session with a contact and immediately establishes + /// a fresh one while requiring the contact's stored identity key to match. + static Future resetContactSession({required PlatformInt64 contactId}) => + RustLib.instance.api + .crateBridgeWrapperSignalRustSignalResetContactSession( + contactId: contactId, + ); + @override int get hashCode => 0; diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index e7fc4383..89416fa6 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -87,7 +87,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -2068471902; + int get rustContentHash => -1675944024; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -699,6 +699,10 @@ abstract class RustLibApi extends BaseApi { required PlatformInt64 contactId, }); + Future crateBridgeWrapperSignalRustSignalGetSafetyNumber({ + required PlatformInt64 contactId, + }); + Future crateBridgeWrapperSignalRustSignalGetUserPublicKey(); Future crateBridgeWrapperSignalRustSignalProcessPrekeyBundle({ @@ -707,6 +711,10 @@ abstract class RustLibApi extends BaseApi { required FrbPreKeyBundle bundle, }); + Future crateBridgeWrapperSignalRustSignalResetContactSession({ + required PlatformInt64 contactId, + }); + Future> crateBridgeWrapperRustUtilsGenerateShares({ required List secret, required int total, @@ -6206,6 +6214,40 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["contactId"], ); + @override + Future crateBridgeWrapperSignalRustSignalGetSafetyNumber({ + required PlatformInt64 contactId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_i_64(contactId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 159, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWrapperSignalRustSignalGetSafetyNumberConstMeta, + argValues: [contactId], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateBridgeWrapperSignalRustSignalGetSafetyNumberConstMeta => + const TaskConstMeta( + debugName: "rust_signal_get_safety_number", + argNames: ["contactId"], + ); + @override Future crateBridgeWrapperSignalRustSignalGetUserPublicKey() { return handler.executeNormal( @@ -6215,7 +6257,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 159, + funcId: 160, port: port_, ); }, @@ -6253,7 +6295,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 160, + funcId: 161, port: port_, ); }, @@ -6276,6 +6318,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["name", "deviceId", "bundle"], ); + @override + Future crateBridgeWrapperSignalRustSignalResetContactSession({ + required PlatformInt64 contactId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_i_64(contactId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 162, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateBridgeWrapperSignalRustSignalResetContactSessionConstMeta, + argValues: [contactId], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateBridgeWrapperSignalRustSignalResetContactSessionConstMeta => + const TaskConstMeta( + debugName: "rust_signal_reset_contact_session", + argNames: ["contactId"], + ); + @override Future> crateBridgeWrapperRustUtilsGenerateShares({ required List secret, @@ -6292,7 +6369,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 161, + funcId: 163, port: port_, ); }, @@ -6327,7 +6404,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 162, + funcId: 164, port: port_, ); }, @@ -6372,7 +6449,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 163, + funcId: 165, port: port_, ); }, @@ -6415,7 +6492,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 164, + funcId: 166, port: port_, ); }, @@ -6449,7 +6526,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 165, + funcId: 167, port: port_, ); }, @@ -6484,7 +6561,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 166, + funcId: 168, port: port_, ); }, @@ -6519,7 +6596,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 167, + funcId: 169, port: port_, ); }, @@ -6552,7 +6629,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 168, + funcId: 170, )!; }, codec: SseCodec( @@ -6592,7 +6669,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 169, + funcId: 171, port: port_, ); }, @@ -6637,7 +6714,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 170, + funcId: 172, port: port_, ); }, @@ -6667,7 +6744,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 171, + funcId: 173, port: port_, ); }, @@ -6700,7 +6777,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 172, + funcId: 174, port: port_, ); }, @@ -6735,7 +6812,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 173, + funcId: 175, port: port_, ); }, @@ -6774,7 +6851,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 174, + funcId: 176, )!; }, codec: SseCodec( diff --git a/lib/core/user_config.dart b/lib/core/user_config.dart index 8a0b14c3..7f1005cd 100644 --- a/lib/core/user_config.dart +++ b/lib/core/user_config.dart @@ -138,6 +138,10 @@ class UserConfig { bool autoStoreAllSendUnlimitedMediaFiles; bool typingIndicators; bool showRestoreFlame; + + /// Unused: the best friend is derived from `total_media_counter` whenever + /// it is needed (`Group::best_friend_group_id`). Kept so configurations + /// written by older versions still deserialize. String? myBestFriendGroupId; DateTime? signalLastSignedPreKeyUpdated; DateTime? signalLastPqcPreKeysUploaded; diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index 3113d38a..e4a91c1a 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -47,6 +47,8 @@ class Routes { static const String settingsHelpFaq = '/settings/help/faq'; static const String settingsHelpFaqVerifyBadge = '/settings/help/faq/verifybadge'; + static const String settingsHelpFaqManualVerification = + '/settings/help/faq/verifybadge/manual'; static const String settingsHelpContactUs = '/settings/help/contact_us'; static const String settingsHelpNews = '/settings/help/news'; static const String settingsHelpDiagnostics = '/settings/help/diagnostics'; diff --git a/lib/src/database/tables/contacts.table.dart b/lib/src/database/tables/contacts.table.dart index e83a4812..8976a5a1 100644 --- a/lib/src/database/tables/contacts.table.dart +++ b/lib/src/database/tables/contacts.table.dart @@ -65,6 +65,7 @@ enum VerificationType { migratedFromOldVersion, qrScanned, link, + manual, secretQrToken, contactSharedByVerified, } diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 453a9384..32714005 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -968,6 +968,12 @@ abstract class AppLocalizations { /// **'Verified via link.'** String get verificationTypeLink; + /// No description provided for @verificationTypeManual. + /// + /// In en, this message translates to: + /// **'Safety number compared manually.'** + String get verificationTypeManual; + /// No description provided for @verificationTypeMigratedFromOldVersion. /// /// In en, this message translates to: @@ -998,6 +1004,42 @@ abstract class AppLocalizations { /// **'Block'** String get contactBlock; + /// No description provided for @contactTroubleshoot. + /// + /// In en, this message translates to: + /// **'Troubleshoot connection'** + String get contactTroubleshoot; + + /// No description provided for @contactResetSessionTitle. + /// + /// In en, this message translates to: + /// **'Reset connection with {username}?'** + String contactResetSessionTitle(String username); + + /// No description provided for @contactResetSessionBody. + /// + /// In en, this message translates to: + /// **'twonly will remove the current encrypted connection and establish a new one. Messages currently in transit may be lost.'** + String get contactResetSessionBody; + + /// No description provided for @contactResetSessionConfirm. + /// + /// In en, this message translates to: + /// **'Reset connection'** + String get contactResetSessionConfirm; + + /// No description provided for @contactResetSessionSuccess. + /// + /// In en, this message translates to: + /// **'The encrypted connection with {username} was re-established.'** + String contactResetSessionSuccess(String username); + + /// No description provided for @contactResetSessionFailed. + /// + /// In en, this message translates to: + /// **'The connection could not be reset. Check your internet connection and try again.'** + String get contactResetSessionFailed; + /// No description provided for @contactBlockTitle. /// /// In en, this message translates to: @@ -2684,6 +2726,48 @@ abstract class AppLocalizations { /// **'The public key in the link does not match the public key stored for this contact. Try to meet your friend in person and scan the QR code directly!'** String get linkPubkeyDoesNotMatch; + /// No description provided for @verificationMissingSession. + /// + /// In en, this message translates to: + /// **'There is no secure session stored for this contact yet. Exchange a message first, then ask the person to share a new verification link.'** + String get verificationMissingSession; + + /// No description provided for @verificationLinkInvalidTitle. + /// + /// In en, this message translates to: + /// **'Invalid verification link'** + String get verificationLinkInvalidTitle; + + /// No description provided for @verificationLinkInvalid. + /// + /// In en, this message translates to: + /// **'The verification link is incomplete or invalid. Ask the person to share it again from their twonly profile.'** + String get verificationLinkInvalid; + + /// No description provided for @verificationLinkErrorTitle. + /// + /// In en, this message translates to: + /// **'Verification unavailable'** + String get verificationLinkErrorTitle; + + /// No description provided for @verificationLinkError. + /// + /// In en, this message translates to: + /// **'twonly could not check this verification link. Please try again.'** + String get verificationLinkError; + + /// No description provided for @verificationOwnProfileTitle. + /// + /// In en, this message translates to: + /// **'This is your profile'** + String get verificationOwnProfileTitle; + + /// No description provided for @verificationOwnProfile. + /// + /// In en, this message translates to: + /// **'You scanned your own verification code.'** + String get verificationOwnProfile; + /// No description provided for @startWithCameraOpen. /// /// In en, this message translates to: @@ -2870,6 +2954,60 @@ abstract class AppLocalizations { /// **'A contact whose identity has *not* yet been verified.'** String get verificationBadgeRedDesc; + /// No description provided for @manualVerificationOpen. + /// + /// In en, this message translates to: + /// **'Safety numbers'** + String get manualVerificationOpen; + + /// No description provided for @manualVerificationTitle. + /// + /// In en, this message translates to: + /// **'Compare safety number'** + String get manualVerificationTitle; + + /// No description provided for @manualVerificationMarkVerified. + /// + /// In en, this message translates to: + /// **'Mark as verified'** + String get manualVerificationMarkVerified; + + /// No description provided for @manualVerificationDescription. + /// + /// In en, this message translates to: + /// **'Compare this safety number with {username}. If both devices show the same number, your end-to-end encryption is verified.'** + String manualVerificationDescription(String username); + + /// No description provided for @manualVerificationMissingKeyTitle. + /// + /// In en, this message translates to: + /// **'No safety number available'** + String get manualVerificationMissingKeyTitle; + + /// No description provided for @manualVerificationMissingKeyDescription. + /// + /// In en, this message translates to: + /// **'There is no Signal session with {username} yet. Exchange a message first, then try again.'** + String manualVerificationMissingKeyDescription(String username); + + /// No description provided for @manualVerificationLoadFailed. + /// + /// In en, this message translates to: + /// **'The safety number could not be loaded.'** + String get manualVerificationLoadFailed; + + /// No description provided for @manualVerificationRetry. + /// + /// In en, this message translates to: + /// **'Try again'** + String get manualVerificationRetry; + + /// No description provided for @manualVerificationSuccess. + /// + /// In en, this message translates to: + /// **'You compared the safety number with {username} and confirmed their identity.'** + String manualVerificationSuccess(String username); + /// No description provided for @scanNow. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index e7e94630..bf76487c 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -481,6 +481,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get verificationTypeLink => 'Per Link verifiziert.'; + @override + String get verificationTypeManual => 'Sicherheitsnummer manuell verglichen.'; + @override String get verificationTypeMigratedFromOldVersion => 'Von alter Version migriert'; @@ -497,6 +500,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contactBlock => 'Blockieren'; + @override + String get contactTroubleshoot => 'Verbindungsprobleme beheben'; + + @override + String contactResetSessionTitle(String username) { + return 'Verbindung zu $username zurücksetzen?'; + } + + @override + String get contactResetSessionBody => + 'twonly löscht die aktuelle verschlüsselte Verbindung und baut sie anschließend neu auf. Nachrichten, die gerade übertragen werden, können dabei verloren gehen.'; + + @override + String get contactResetSessionConfirm => 'Verbindung zurücksetzen'; + + @override + String contactResetSessionSuccess(String username) { + return 'Die verschlüsselte Verbindung zu $username wurde neu aufgebaut.'; + } + + @override + String get contactResetSessionFailed => + 'Die Verbindung konnte nicht zurückgesetzt werden. Prüfe deine Internetverbindung und versuche es erneut.'; + @override String contactBlockTitle(Object username) { return 'Blockiere $username'; @@ -1504,6 +1531,31 @@ class AppLocalizationsDe extends AppLocalizations { String get linkPubkeyDoesNotMatch => 'Der öffentliche Schlüssel im Link stimmt nicht mit dem für diesen Kontakt gespeicherten öffentlichen Schlüssel überein. Triff die Person persönlich und scanne den QR-Code direkt!'; + @override + String get verificationMissingSession => + 'Für diesen Kontakt ist noch keine sichere Sitzung gespeichert. Tauscht zuerst eine Nachricht aus und lass dir danach einen neuen Verifizierungslink senden.'; + + @override + String get verificationLinkInvalidTitle => 'Ungültiger Verifizierungslink'; + + @override + String get verificationLinkInvalid => + 'Der Verifizierungslink ist unvollständig oder ungültig. Bitte die Person, ihn erneut aus ihrem twonly-Profil zu teilen.'; + + @override + String get verificationLinkErrorTitle => 'Verifizierung nicht möglich'; + + @override + String get verificationLinkError => + 'twonly konnte diesen Verifizierungslink nicht prüfen. Versuche es bitte erneut.'; + + @override + String get verificationOwnProfileTitle => 'Das ist dein Profil'; + + @override + String get verificationOwnProfile => + 'Du hast deinen eigenen Verifizierungscode gescannt.'; + @override String get startWithCameraOpen => 'Mit geöffneter Kamera starten'; @@ -1626,6 +1678,41 @@ class AppLocalizationsDe extends AppLocalizations { String get verificationBadgeRedDesc => 'Ein Kontakt, dessen Identität noch *nicht überprüft* wurde.'; + @override + String get manualVerificationOpen => 'Sicherheitsnummern'; + + @override + String get manualVerificationTitle => 'Sicherheitsnummer vergleichen'; + + @override + String get manualVerificationMarkVerified => 'Als verifiziert markieren'; + + @override + String manualVerificationDescription(String username) { + return 'Vergleiche diese Sicherheitsnummer mit $username. Wenn auf beiden Geräten dieselbe Nummer angezeigt wird, ist eure Ende-zu-Ende-Verschlüsselung verifiziert.'; + } + + @override + String get manualVerificationMissingKeyTitle => + 'Keine Sicherheitsnummer verfügbar'; + + @override + String manualVerificationMissingKeyDescription(String username) { + return 'Mit $username besteht noch keine Signal-Sitzung. Tauscht zuerst eine Nachricht aus und versucht es danach erneut.'; + } + + @override + String get manualVerificationLoadFailed => + 'Die Sicherheitsnummer konnte nicht geladen werden.'; + + @override + String get manualVerificationRetry => 'Erneut versuchen'; + + @override + String manualVerificationSuccess(String username) { + return 'Du hast die Sicherheitsnummer mit $username verglichen und die Identität bestätigt.'; + } + @override String get scanNow => 'Jetzt scannen'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index d1fa98b5..37ee164d 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -478,6 +478,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get verificationTypeLink => 'Verified via link.'; + @override + String get verificationTypeManual => 'Safety number compared manually.'; + @override String get verificationTypeMigratedFromOldVersion => 'Migrated from old version.'; @@ -494,6 +497,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contactBlock => 'Block'; + @override + String get contactTroubleshoot => 'Troubleshoot connection'; + + @override + String contactResetSessionTitle(String username) { + return 'Reset connection with $username?'; + } + + @override + String get contactResetSessionBody => + 'twonly will remove the current encrypted connection and establish a new one. Messages currently in transit may be lost.'; + + @override + String get contactResetSessionConfirm => 'Reset connection'; + + @override + String contactResetSessionSuccess(String username) { + return 'The encrypted connection with $username was re-established.'; + } + + @override + String get contactResetSessionFailed => + 'The connection could not be reset. Check your internet connection and try again.'; + @override String contactBlockTitle(Object username) { return 'Block $username'; @@ -1492,6 +1519,31 @@ class AppLocalizationsEn extends AppLocalizations { String get linkPubkeyDoesNotMatch => 'The public key in the link does not match the public key stored for this contact. Try to meet your friend in person and scan the QR code directly!'; + @override + String get verificationMissingSession => + 'There is no secure session stored for this contact yet. Exchange a message first, then ask the person to share a new verification link.'; + + @override + String get verificationLinkInvalidTitle => 'Invalid verification link'; + + @override + String get verificationLinkInvalid => + 'The verification link is incomplete or invalid. Ask the person to share it again from their twonly profile.'; + + @override + String get verificationLinkErrorTitle => 'Verification unavailable'; + + @override + String get verificationLinkError => + 'twonly could not check this verification link. Please try again.'; + + @override + String get verificationOwnProfileTitle => 'This is your profile'; + + @override + String get verificationOwnProfile => + 'You scanned your own verification code.'; + @override String get startWithCameraOpen => 'Start with camera open'; @@ -1612,6 +1664,40 @@ class AppLocalizationsEn extends AppLocalizations { String get verificationBadgeRedDesc => 'A contact whose identity has *not* yet been verified.'; + @override + String get manualVerificationOpen => 'Safety numbers'; + + @override + String get manualVerificationTitle => 'Compare safety number'; + + @override + String get manualVerificationMarkVerified => 'Mark as verified'; + + @override + String manualVerificationDescription(String username) { + return 'Compare this safety number with $username. If both devices show the same number, your end-to-end encryption is verified.'; + } + + @override + String get manualVerificationMissingKeyTitle => 'No safety number available'; + + @override + String manualVerificationMissingKeyDescription(String username) { + return 'There is no Signal session with $username yet. Exchange a message first, then try again.'; + } + + @override + String get manualVerificationLoadFailed => + 'The safety number could not be loaded.'; + + @override + String get manualVerificationRetry => 'Try again'; + + @override + String manualVerificationSuccess(String username) { + return 'You compared the safety number with $username and confirmed their identity.'; + } + @override String get scanNow => 'Scan now'; diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index f0751d51..43ebbabf 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -174,11 +174,32 @@ } }, "verificationTypeLink": "Per Link verifiziert.", + "verificationTypeManual": "Sicherheitsnummer manuell verglichen.", "verificationTypeMigratedFromOldVersion": "Von alter Version migriert", "contactViewMessage": "Nachricht", "contactNickname": "Spitzname", "contactNicknameNew": "Neuer Spitzname", "contactBlock": "Blockieren", + "contactTroubleshoot": "Verbindungsprobleme beheben", + "contactResetSessionTitle": "Verbindung zu {username} zurücksetzen?", + "@contactResetSessionTitle": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "contactResetSessionBody": "twonly löscht die aktuelle verschlüsselte Verbindung und baut sie anschließend neu auf. Nachrichten, die gerade übertragen werden, können dabei verloren gehen.", + "contactResetSessionConfirm": "Verbindung zurücksetzen", + "contactResetSessionSuccess": "Die verschlüsselte Verbindung zu {username} wurde neu aufgebaut.", + "@contactResetSessionSuccess": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "contactResetSessionFailed": "Die Verbindung konnte nicht zurückgesetzt werden. Prüfe deine Internetverbindung und versuche es erneut.", "contactRemove": "Kontakt löschen", "contactRemoveTitle": "{username} löschen?", "contactRemoveBody": "Den Kontakt dauerhaft entfernen. Wenn der Kontakt versucht, dir eine neue Nachricht zu senden, musst du den Kontakt erst wieder akzeptieren.", @@ -487,6 +508,13 @@ "gotLinkFromFriend": "Ja, der Link kommt direkt von der Person.", "couldNotVerifyUsername": "{username} konnte nicht verifiziert werden", "linkPubkeyDoesNotMatch": "Der öffentliche Schlüssel im Link stimmt nicht mit dem für diesen Kontakt gespeicherten öffentlichen Schlüssel überein. Triff die Person persönlich und scanne den QR-Code direkt!", + "verificationMissingSession": "Für diesen Kontakt ist noch keine sichere Sitzung gespeichert. Tauscht zuerst eine Nachricht aus und lass dir danach einen neuen Verifizierungslink senden.", + "verificationLinkInvalidTitle": "Ungültiger Verifizierungslink", + "verificationLinkInvalid": "Der Verifizierungslink ist unvollständig oder ungültig. Bitte die Person, ihn erneut aus ihrem twonly-Profil zu teilen.", + "verificationLinkErrorTitle": "Verifizierung nicht möglich", + "verificationLinkError": "twonly konnte diesen Verifizierungslink nicht prüfen. Versuche es bitte erneut.", + "verificationOwnProfileTitle": "Das ist dein Profil", + "verificationOwnProfile": "Du hast deinen eigenen Verifizierungscode gescannt.", "startWithCameraOpen": "Mit geöffneter Kamera starten", "showImagePreviewWhenSending": "Bildvorschau bei der Auswahl von Empfängern anzeigen", "verifiedPublicKey": "Die Identität von {username} wurde erfolgreich überprüft.", @@ -518,6 +546,36 @@ "verificationBadgeGreenDesc": "Ein Kontakt, den du über den QR-code *persönlich verifiziert* hast.", "verificationBadgeYellowDesc": "Ein Kontakt, der von mind. einem *deiner Kontakte verifiziert* wurde.", "verificationBadgeRedDesc": "Ein Kontakt, dessen Identität noch *nicht überprüft* wurde.", + "manualVerificationOpen": "Sicherheitsnummern", + "manualVerificationTitle": "Sicherheitsnummer vergleichen", + "manualVerificationMarkVerified": "Als verifiziert markieren", + "manualVerificationDescription": "Vergleiche diese Sicherheitsnummer mit {username}. Wenn auf beiden Geräten dieselbe Nummer angezeigt wird, ist eure Ende-zu-Ende-Verschlüsselung verifiziert.", + "@manualVerificationDescription": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "manualVerificationMissingKeyTitle": "Keine Sicherheitsnummer verfügbar", + "manualVerificationMissingKeyDescription": "Mit {username} besteht noch keine Signal-Sitzung. Tauscht zuerst eine Nachricht aus und versucht es danach erneut.", + "@manualVerificationMissingKeyDescription": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "manualVerificationLoadFailed": "Die Sicherheitsnummer konnte nicht geladen werden.", + "manualVerificationRetry": "Erneut versuchen", + "manualVerificationSuccess": "Du hast die Sicherheitsnummer mit {username} verglichen und die Identität bestätigt.", + "@manualVerificationSuccess": { + "placeholders": { + "username": { + "type": "String" + } + } + }, "scanNow": "Jetzt scannen", "qrScannerVerifyHint": "Zum Verifizieren muss die andere Person ihren QR-Code öffnen (Chat-Liste > QR-Code-Button unten rechts)", "qrScannerVerifyUserHint": "Zum Verifizieren muss {username} seinen QR-Code öffnen (Chat-Liste > QR-Code-Button unten rechts)", diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index 3b07dcbd..0a4108a2 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -170,11 +170,32 @@ } }, "verificationTypeLink": "Verified via link.", + "verificationTypeManual": "Safety number compared manually.", "verificationTypeMigratedFromOldVersion": "Migrated from old version.", "contactViewMessage": "Message", "contactNickname": "Nickname", "contactNicknameNew": "New nickname", "contactBlock": "Block", + "contactTroubleshoot": "Troubleshoot connection", + "contactResetSessionTitle": "Reset connection with {username}?", + "@contactResetSessionTitle": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "contactResetSessionBody": "twonly will remove the current encrypted connection and establish a new one. Messages currently in transit may be lost.", + "contactResetSessionConfirm": "Reset connection", + "contactResetSessionSuccess": "The encrypted connection with {username} was re-established.", + "@contactResetSessionSuccess": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "contactResetSessionFailed": "The connection could not be reset. Check your internet connection and try again.", "contactBlockTitle": "Block {username}", "@contactBlockTitle": { "placeholders": { @@ -497,6 +518,13 @@ "gotLinkFromFriend": "Yes, I got the link from my friend!", "couldNotVerifyUsername": "Could not verify {username}", "linkPubkeyDoesNotMatch": "The public key in the link does not match the public key stored for this contact. Try to meet your friend in person and scan the QR code directly!", + "verificationMissingSession": "There is no secure session stored for this contact yet. Exchange a message first, then ask the person to share a new verification link.", + "verificationLinkInvalidTitle": "Invalid verification link", + "verificationLinkInvalid": "The verification link is incomplete or invalid. Ask the person to share it again from their twonly profile.", + "verificationLinkErrorTitle": "Verification unavailable", + "verificationLinkError": "twonly could not check this verification link. Please try again.", + "verificationOwnProfileTitle": "This is your profile", + "verificationOwnProfile": "You scanned your own verification code.", "startWithCameraOpen": "Start with camera open", "showImagePreviewWhenSending": "Display image preview when selecting recipients", "verifiedPublicKey": "The identity of {username} has been successfully verified.", @@ -528,6 +556,36 @@ "verificationBadgeGreenDesc": "A contact you have *personally verified* using the QR code.", "verificationBadgeYellowDesc": "A contact who has been verified by at least one of *your contacts*.", "verificationBadgeRedDesc": "A contact whose identity has *not* yet been verified.", + "manualVerificationOpen": "Safety numbers", + "manualVerificationTitle": "Compare safety number", + "manualVerificationMarkVerified": "Mark as verified", + "manualVerificationDescription": "Compare this safety number with {username}. If both devices show the same number, your end-to-end encryption is verified.", + "@manualVerificationDescription": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "manualVerificationMissingKeyTitle": "No safety number available", + "manualVerificationMissingKeyDescription": "There is no Signal session with {username} yet. Exchange a message first, then try again.", + "@manualVerificationMissingKeyDescription": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "manualVerificationLoadFailed": "The safety number could not be loaded.", + "manualVerificationRetry": "Try again", + "manualVerificationSuccess": "You compared the safety number with {username} and confirmed their identity.", + "@manualVerificationSuccess": { + "placeholders": { + "username": { + "type": "String" + } + } + }, "scanNow": "Scan now", "qrScannerVerifyHint": "To verify, the other person must open their QR code (Chat Lists > QR code button at the bottom right)", "qrScannerVerifyUserHint": "To verify, {username} must open their QR code (Chat Lists > QR code button at the bottom right)", diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index 5958a28e..6e75c3c9 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -37,6 +37,7 @@ import 'package:twonly/src/visual/views/settings/help/diagnostics.view.dart'; import 'package:twonly/src/visual/views/settings/help/faq.view.dart'; import 'package:twonly/src/visual/views/settings/help/faq/verification_badge_faq.view.dart'; import 'package:twonly/src/visual/views/settings/help/help.view.dart'; +import 'package:twonly/src/visual/views/settings/help/manual_verification.view.dart'; import 'package:twonly/src/visual/views/settings/help/news.view.dart'; import 'package:twonly/src/visual/views/settings/notification.view.dart'; import 'package:twonly/src/visual/views/settings/privacy.view.dart'; @@ -255,6 +256,14 @@ final routerProvider = GoRouter( builder: (context, state) => VerificationBadeFaqView( contact: state.extra as Contact?, ), + routes: [ + GoRoute( + path: 'manual', + builder: (context, state) => ManualVerificationView( + contact: state.extra! as Contact, + ), + ), + ], ), ], ), diff --git a/lib/src/services/intent/links.intent.dart b/lib/src/services/intent/links.intent.dart index 733dcd47..fdbdcc0b 100644 --- a/lib/src/services/intent/links.intent.dart +++ b/lib/src/services/intent/links.intent.dart @@ -9,20 +9,27 @@ import 'package:go_router/go_router.dart'; import 'package:twonly/core/bridge/wrapper/signal.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/passwordless_recovery.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/qr.utils.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; +import 'package:twonly/src/visual/components/verification_failure_dialog.comp.dart'; +import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart'; import 'package:twonly/src/visual/views/contact/add_contact_via_qr_link.view.dart'; import 'package:twonly/src/visual/views/contact/add_new_contact.view.dart'; Future handleIntentUrl(BuildContext context, Uri uri) async { - if (!uri.scheme.startsWith('http')) return false; + if (uri.scheme != 'http' && uri.scheme != 'https') return false; if (uri.host != 'me.twonly.eu') return false; - if (uri.hasEmptyPath) return false; + if (uri.hasEmptyPath) { + if (context.mounted) await _showInvalidVerificationLink(context); + return true; + } // Check if this is the QR code link which was // therefore scanned with the system camera @@ -37,37 +44,47 @@ Future handleIntentUrl(BuildContext context, Uri uri) async { if (!context.mounted) return false; - if (result != null) { - final (profile, contact, verificationOk) = result; - - if (profile.username == userService.currentUser.username) { - await context.push(Routes.settingsPublicProfile); - return true; - } - - if (contact != null) { - if (verificationOk) { - await context.push(Routes.profileContact(contact.userId)); - } else { - await _pubKeysDoNotMatch(context, contact.username); - } - } else { + switch (result.status) { + case QrCodeLinkStatus.newContact: await context.navPush( AddContactViaQrLinkView( - profile: profile, - qrCodeLink: uri.toString(), + profile: result.profile!, ), ); - } + case QrCodeLinkStatus.verified: + final contact = result.contact!; + await VerificationSuccessDialog.show(context, contact); + if (context.mounted) { + await context.push(Routes.profileContact(contact.userId)); + } + case QrCodeLinkStatus.ownProfile: + case QrCodeLinkStatus.invalid: + case QrCodeLinkStatus.keyMismatch: + case QrCodeLinkStatus.missingSession: + case QrCodeLinkStatus.error: + await showQrCodeVerificationFailure(context, result); } return true; } - final publicKey = uri.hasFragment ? uri.fragment : null; - final userPaths = uri.path.split('/'); - if (userPaths.length != 2) return false; - final username = userPaths[1]; + final rawPathSegments = uri.pathSegments + .where((part) => part.isNotEmpty) + .toList(); + if (rawPathSegments.firstOrNull == 'qr' || + rawPathSegments.firstOrNull == 'r') { + if (context.mounted) await _showInvalidVerificationLink(context); + return true; + } + + final publicKey = uri.hasFragment && uri.fragment.isNotEmpty + ? uri.fragment + : null; + if (rawPathSegments.length != 1) { + if (context.mounted) await _showInvalidVerificationLink(context); + return true; + } + final username = rawPathSegments.single; if (!context.mounted) return false; @@ -80,86 +97,147 @@ Future handleIntentUrl(BuildContext context, Uri uri) async { 'Opened via deep link!: username = $username public_key = ${uri.fragment}', ); - final contacts = await twonlyDB.contactsDao.getContactsByUsername(username); + Uint8List? receivedPublicKey; + if (publicKey != null) { + try { + receivedPublicKey = base64Url.decode(publicKey); + } catch (e) { + Log.warn('Invalid public key in verification link: $e'); + await _showInvalidVerificationLink(context); + return true; + } + } + + late final List contacts; + try { + contacts = await twonlyDB.contactsDao.getContactsByUsername(username); + } catch (e) { + Log.warn('Could not load contact for verification link: $e'); + if (context.mounted) await _showVerificationLinkError(context); + return true; + } if (!context.mounted) return true; if (contacts.isEmpty) { // User does not yet exists, making a request... - Uint8List? publicKeyBytes; - if (publicKey != null) { - publicKeyBytes = base64Url.decode(publicKey); - } await context.navPush( AddNewUserView( username: username, - publicKey: publicKeyBytes, + publicKey: receivedPublicKey, ), ); return true; } - if (publicKey != null) { - try { - final contact = contacts.first; - final storedPublicKey = await RustSignal.getContactPublicKey( - contactId: contact.userId, + final contact = contacts.first; + if (publicKey == null) { + await VerificationFailureDialog.show( + context, + title: context.lang.couldNotVerifyUsername( + getContactDisplayName(contact), + ), + message: context.lang.verificationLinkInvalid, + contact: contact, + ); + return true; + } + + try { + final storedPublicKey = await RustSignal.getContactPublicKey( + contactId: contact.userId, + ); + if (!context.mounted) return true; + if (storedPublicKey == null) { + await showQrCodeVerificationFailure( + context, + QrCodeLinkResult( + status: QrCodeLinkStatus.missingSession, + contact: contact, + ), ); - final receivedPublicKey = base64Url.decode(publicKey); - if (storedPublicKey == null || - receivedPublicKey.isEmpty || - !context.mounted) { - return true; - } - if (storedPublicKey.equals(receivedPublicKey)) { - final markAsVerified = await showAlertDialog( - context, - context.lang.linkFromUsername(contact.username), - context.lang.linkFromUsernameLong, - customOk: context.lang.gotLinkFromFriend, + return true; + } + if (receivedPublicKey == null || receivedPublicKey.isEmpty) { + await showQrCodeVerificationFailure( + context, + QrCodeLinkResult( + status: QrCodeLinkStatus.invalid, + contact: contact, + ), + ); + return true; + } + if (storedPublicKey.equals(receivedPublicKey)) { + final markAsVerified = await showAlertDialog( + context, + context.lang.linkFromUsername(contact.username), + context.lang.linkFromUsernameLong, + customOk: context.lang.gotLinkFromFriend, + ); + if (markAsVerified) { + await twonlyDB.keyVerificationDao.addKeyVerification( + contact.userId, + VerificationType.link, ); - if (markAsVerified) { - await twonlyDB.keyVerificationDao.addKeyVerification( - contact.userId, - VerificationType.link, - ); - } - if (context.mounted) { - await context.push(Routes.profileContact(contact.userId)); - } - } else { - await _pubKeysDoNotMatch(context, contact.username); } - } catch (e) { - Log.warn(e); + if (context.mounted) { + await context.push(Routes.profileContact(contact.userId)); + } + } else { + await showQrCodeVerificationFailure( + context, + QrCodeLinkResult( + status: QrCodeLinkStatus.keyMismatch, + contact: contact, + ), + ); + } + } catch (e) { + Log.warn(e); + if (context.mounted) { + await showQrCodeVerificationFailure( + context, + QrCodeLinkResult( + status: QrCodeLinkStatus.error, + contact: contact, + ), + ); } } return true; } -Future _pubKeysDoNotMatch(BuildContext context, String username) async { - await showAlertDialog( +Future _showInvalidVerificationLink(BuildContext context) { + return VerificationFailureDialog.show( context, - context.lang.couldNotVerifyUsername(username), - context.lang.linkPubkeyDoesNotMatch, - customCancel: '', + title: context.lang.verificationLinkInvalidTitle, + message: context.lang.verificationLinkInvalid, + ); +} + +Future _showVerificationLinkError(BuildContext context) { + return VerificationFailureDialog.show( + context, + title: context.lang.verificationLinkErrorTitle, + message: context.lang.verificationLinkError, ); } StreamSubscription> initIntentStreams( BuildContext context, - void Function(Uri) onUrlCallBack, + Future Function(Uri) onUrlCallBack, void Function(String, MediaType) onMediaCallBack, ) { - FlutterSharingIntent.instance.getInitialSharing().then((f) { + FlutterSharingIntent.instance.getInitialSharing().then((f) async { if (!context.mounted) return; - handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack); + await handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack); }); return FlutterSharingIntent.instance.getMediaStream().listen( - (f) { + (f) async { if (!context.mounted) return; - handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack); + await handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack); }, // ignore: inference_failure_on_untyped_parameter onError: (err) { @@ -171,7 +249,7 @@ StreamSubscription> initIntentStreams( Future handleIntentSharedFile( BuildContext context, List files, - void Function(Uri) onUrlCallBack, + Future Function(Uri) onUrlCallBack, void Function(String, MediaType) onMediaCallBack, ) async { for (final file in files) { @@ -189,7 +267,7 @@ Future handleIntentSharedFile( if (file.value?.startsWith('http') ?? false) { final uri = Uri.parse(file.value!); Log.info('Got link via handle intent share file: ${uri.scheme}'); - onUrlCallBack(uri); + await onUrlCallBack(uri); } case SharedMediaType.IMAGE: var type = MediaType.image; diff --git a/lib/src/utils/qr.utils.dart b/lib/src/utils/qr.utils.dart index 04e5738b..77f11821 100644 --- a/lib/src/utils/qr.utils.dart +++ b/lib/src/utils/qr.utils.dart @@ -14,6 +14,28 @@ import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/utils/log.dart'; +enum QrCodeLinkStatus { + invalid, + ownProfile, + newContact, + verified, + keyMismatch, + missingSession, + error, +} + +class QrCodeLinkResult { + const QrCodeLinkResult({ + required this.status, + this.profile, + this.contact, + }); + + final QrCodeLinkStatus status; + final PublicProfile? profile; + final Contact? contact; +} + class QrCodeUtils { static String linkPrefix = 'https://me.twonly.eu/qr/#'; @@ -45,8 +67,7 @@ class QrCodeUtils { return link; } - // returns: profile, NEW_USER=true/VERIFIED_USER=false, VERIFICATION_OK - static Future<(PublicProfile, Contact?, bool)?> handleQrCodeLink( + static Future handleQrCodeLink( String link, ) async { late PublicProfile profile; @@ -54,35 +75,53 @@ class QrCodeUtils { try { final bytes = base64Url.decode(link.replaceFirst(linkPrefix, '')); final envelope = QREnvelope.fromBuffer(bytes); - if (envelope.type != QREnvelope_Type.PUBLIC_PROFILE) return null; + if (envelope.type != QREnvelope_Type.PUBLIC_PROFILE) { + return const QrCodeLinkResult(status: QrCodeLinkStatus.invalid); + } profile = PublicProfile.fromBuffer(envelope.data); } catch (e) { Log.error(e); - return null; + return const QrCodeLinkResult(status: QrCodeLinkStatus.invalid); } - final contact = await twonlyDB.contactsDao.getContactById( - profile.userId.toInt(), - ); + try { + final contact = await twonlyDB.contactsDao.getContactById( + profile.userId.toInt(), + ); - if (contact == null) { - if (profile.username == userService.currentUser.username) { - return null; + if (profile.userId.toInt() == userService.currentUser.userId) { + return QrCodeLinkResult( + status: QrCodeLinkStatus.ownProfile, + profile: profile, + ); } - // NEW_USER - return (profile, null, false); - } - final storedPublicKey = await RustSignal.getContactPublicKey( - contactId: contact.userId, - ); - if (storedPublicKey == null) return null; + if (contact == null) { + return QrCodeLinkResult( + status: QrCodeLinkStatus.newContact, + profile: profile, + ); + } - final verificationOk = profile.publicIdentityKey.equals( - storedPublicKey.toList(), - ); + final storedPublicKey = await RustSignal.getContactPublicKey( + contactId: contact.userId, + ); + if (storedPublicKey == null) { + return QrCodeLinkResult( + status: QrCodeLinkStatus.missingSession, + profile: profile, + contact: contact, + ); + } + + if (!profile.publicIdentityKey.equals(storedPublicKey.toList())) { + return QrCodeLinkResult( + status: QrCodeLinkStatus.keyMismatch, + profile: profile, + contact: contact, + ); + } - if (verificationOk) { var useSecretVerificationToken = profile.hasSecretVerificationToken(); if (profile.hasTimestamp()) { // Only notify the scanned user if the QR code was generated within the last 10 minutes. @@ -107,9 +146,18 @@ class QrCodeUtils { contact.userId, VerificationType.qrScanned, ); + return QrCodeLinkResult( + status: QrCodeLinkStatus.verified, + profile: profile, + contact: contact, + ); + } catch (e) { + Log.error('Failed to verify QR-code profile: $e'); + return QrCodeLinkResult( + status: QrCodeLinkStatus.error, + profile: profile, + ); } - - return (profile, contact, verificationOk); } } diff --git a/lib/src/visual/components/verification_failure_dialog.comp.dart b/lib/src/visual/components/verification_failure_dialog.comp.dart new file mode 100644 index 00000000..f749ff05 --- /dev/null +++ b/lib/src/visual/components/verification_failure_dialog.comp.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/utils/qr.utils.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; + +class VerificationFailureDialog extends StatelessWidget { + const VerificationFailureDialog({ + required this.title, + required this.message, + this.contact, + super.key, + }); + + final String title; + final String message; + final Contact? contact; + + static Future show( + BuildContext context, { + required String title, + required String message, + Contact? contact, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => VerificationFailureDialog( + title: title, + message: message, + contact: contact, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + backgroundColor: context.color.surface, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (contact != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AvatarIcon(contactId: contact!.userId, fontSize: 16), + const SizedBox(width: 12), + Flexible( + child: Text( + getContactDisplayName(contact!), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.color.errorContainer, + ), + child: Icon( + Icons.gpp_bad_outlined, + size: 52, + color: context.color.onErrorContainer, + ), + ), + const SizedBox(height: 24), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: context.color.onSurface.withValues(alpha: 0.72), + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + child: MyButton( + onPressed: () => Navigator.pop(context), + child: Text(context.lang.close), + ), + ), + ], + ), + ), + ); + } +} + +Future showQrCodeVerificationFailure( + BuildContext context, + QrCodeLinkResult result, +) { + final contact = result.contact; + final title = contact == null + ? switch (result.status) { + QrCodeLinkStatus.invalid => context.lang.verificationLinkInvalidTitle, + QrCodeLinkStatus.ownProfile => + context.lang.verificationOwnProfileTitle, + _ => context.lang.verificationLinkErrorTitle, + } + : context.lang.couldNotVerifyUsername(getContactDisplayName(contact)); + + final message = switch (result.status) { + QrCodeLinkStatus.keyMismatch => context.lang.linkPubkeyDoesNotMatch, + QrCodeLinkStatus.missingSession => context.lang.verificationMissingSession, + QrCodeLinkStatus.invalid => context.lang.verificationLinkInvalid, + QrCodeLinkStatus.ownProfile => context.lang.verificationOwnProfile, + _ => context.lang.verificationLinkError, + }; + + return VerificationFailureDialog.show( + context, + title: title, + message: message, + contact: contact, + ); +} diff --git a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart index b5e0206a..d4df77c8 100644 --- a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart @@ -22,6 +22,7 @@ import 'package:twonly/src/utils/qr.utils.dart'; import 'package:twonly/src/visual/components/add_contact_dialog.comp.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; +import 'package:twonly/src/visual/components/verification_failure_dialog.comp.dart'; import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart'; @@ -521,112 +522,126 @@ class MainCameraController { Future _processBarcode(InputImage inputImage) async { if (_isBusy) return; _isBusy = true; - final barcodes = await _barcodeScanner.processImage(inputImage); - final controller = cameraController; - if (inputImage.metadata?.size != null && - inputImage.metadata?.rotation != null && - controller != null) { - final painter = BarcodeDetectorPainter( - barcodes, - inputImage.metadata!.size, - inputImage.metadata!.rotation, - controller.description.lensDirection, - ); - qrCodePain = CustomPaint(painter: painter); + try { + final barcodes = await _barcodeScanner.processImage(inputImage); + final controller = cameraController; + if (inputImage.metadata?.size != null && + inputImage.metadata?.rotation != null && + controller != null) { + final painter = BarcodeDetectorPainter( + barcodes, + inputImage.metadata!.size, + inputImage.metadata!.rotation, + controller.description.lensDirection, + ); + qrCodePain = CustomPaint(painter: painter); - if (barcodes.isEmpty && timeSharedLinkWasSetWithQr != null) { - if (timeSharedLinkWasSetWithQr!.isAfter( - DateTime.now().subtract(const Duration(seconds: 2)), - )) { - setSharedLinkForPreview(null); - } - } - - for (final barcode in barcodes) { - if (barcode.displayValue == null) continue; - final link = barcode.displayValue!; - - if (link.startsWith(PasswordlessRecoveryService.linkPrefix)) { - await PasswordlessRecoveryService.handleRecoveryLink(link); - continue; + if (barcodes.isEmpty && timeSharedLinkWasSetWithQr != null) { + if (timeSharedLinkWasSetWithQr!.isAfter( + DateTime.now().subtract(const Duration(seconds: 2)), + )) { + setSharedLinkForPreview(null); + } } - if (link.startsWith(QrCodeUtils.linkPrefix)) { - if (_handledProfileLinks.contains(link)) continue; - _handledProfileLinks.add(link); + for (final barcode in barcodes) { + if (barcode.displayValue == null) continue; + final link = barcode.displayValue!; - final res = await QrCodeUtils.handleQrCodeLink(link); - if (res == null) continue; - final (profile, contact, verificationOk) = res; - - if (contact?.blocked ?? false) { - await twonlyDB.contactsDao.updateContact( - contact!.userId, - const ContactsCompanion(blocked: Value(false)), - ); + if (link.startsWith(PasswordlessRecoveryService.linkPrefix)) { + await PasswordlessRecoveryService.handleRecoveryLink(link); + continue; } - if (contact == null || contact.deletedByUser) { - final context = cameraPreviewKey.currentContext; - if (context != null && context.mounted) { - unawaited(HapticFeedback.heavyImpact()); - final shouldRequest = await AddContactDialog.show( - context, - profile.username, + if (link.startsWith(QrCodeUtils.linkPrefix)) { + if (_handledProfileLinks.contains(link)) continue; + _handledProfileLinks.add(link); + + final result = await QrCodeUtils.handleQrCodeLink(link); + final profile = result.profile; + final contact = result.contact; + + if (contact?.blocked ?? false) { + await twonlyDB.contactsDao.updateContact( + contact!.userId, + const ContactsCompanion(blocked: Value(false)), ); - if (shouldRequest == true && context.mounted) { - final success = await addNewContactFromPublicProfile(profile); - if (context.mounted) { - if (success) { - showSnackbar( - context, - context.lang.requestedUserToastText(profile.username), - level: SnackbarLevel.success, - ); - } else { - await showAlertDialog( - context, - context.lang.addFriendTitle, - context.lang.additionalUserAddError(profile.username), - customCancel: '', - ); + } + + if (result.status == QrCodeLinkStatus.newContact) { + final context = cameraPreviewKey.currentContext; + if (profile != null && context != null && context.mounted) { + unawaited(HapticFeedback.heavyImpact()); + final shouldRequest = await AddContactDialog.show( + context, + profile.username, + ); + if (shouldRequest == true && context.mounted) { + final success = await addNewContactFromPublicProfile(profile); + if (context.mounted) { + if (success) { + showSnackbar( + context, + context.lang.requestedUserToastText(profile.username), + level: SnackbarLevel.success, + ); + } else { + await showAlertDialog( + context, + context.lang.addFriendTitle, + context.lang.additionalUserAddError(profile.username), + customCancel: '', + ); + } } } } + continue; + } + + if (result.status != QrCodeLinkStatus.verified) { + final context = cameraPreviewKey.currentContext; + if (context != null && context.mounted) { + unawaited(HapticFeedback.heavyImpact()); + await showQrCodeVerificationFailure(context, result); + } + continue; + } + + if (contact != null && contactsVerified[contact.userId] == null) { + contactsVerified[contact.userId] = ScannedVerifiedContact( + contact: contact, + verificationOk: true, + ); + + unawaited(HapticFeedback.heavyImpact()); + final context = cameraPreviewKey.currentContext; + if (context != null && context.mounted) { + await VerificationSuccessDialog.show(context, contact); + onVerificationSuccessDismissed?.call(contact); + } } continue; } - if (contactsVerified[contact.userId] == null) { - contactsVerified[contact.userId] = ScannedVerifiedContact( - contact: contact, - verificationOk: verificationOk, - ); - - unawaited(HapticFeedback.heavyImpact()); - final context = cameraPreviewKey.currentContext; - if (verificationOk && context != null && context.mounted) { - await VerificationSuccessDialog.show(context, contact); - onVerificationSuccessDismissed?.call(contact); + if (link.startsWith('http://') || link.startsWith('https://')) { + scannedUrl = link; + if (sharedLinkForPreview == null) { + timeSharedLinkWasSetWithQr = clock.now(); + setSharedLinkForPreview( + Uri.parse(scannedUrl!), + generatePreview: false, + ); } } - continue; - } - - if (link.startsWith('http://') || link.startsWith('https://')) { - scannedUrl = link; - if (sharedLinkForPreview == null) { - timeSharedLinkWasSetWithQr = clock.now(); - setSharedLinkForPreview( - Uri.parse(scannedUrl!), - generatePreview: false, - ); - } } } + } catch (e, stackTrace) { + Log.error('QR-code processing failed: $e\n$stackTrace'); + } finally { + _isBusy = false; + setState?.call(); } - _isBusy = false; - setState?.call(); } Future _processFaces(InputImage inputImage) async { diff --git a/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart b/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart index e333735e..4dd371a0 100644 --- a/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart +++ b/lib/src/visual/views/contact/add_contact_via_qr_link.view.dart @@ -1,11 +1,6 @@ -import 'dart:convert'; - -import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -15,12 +10,10 @@ import 'package:twonly/src/visual/components/snackbar.dart'; class AddContactViaQrLinkView extends StatefulWidget { const AddContactViaQrLinkView({ required this.profile, - this.qrCodeLink, super.key, }); final PublicProfile profile; - final String? qrCodeLink; @override State createState() => @@ -36,31 +29,15 @@ class _AddContactViaQrLinkViewState extends State { }); try { - final userData = FrbUserData( - userId: widget.profile.userId.toInt(), - publicIdentityKey: Uint8List.fromList(widget.profile.publicIdentityKey), - username: Uint8List.fromList(utf8.encode(widget.profile.username)), - ); - - final added = await twonlyDB.contactsDao.insertOnConflictUpdate( - ContactsCompanion( - username: Value(widget.profile.username), - userId: Value(widget.profile.userId.toInt()), - requested: const Value(false), - blocked: const Value(false), - deletedByUser: const Value(false), - ), - ); - - if (added > 0) { - await RustApi.tryRequestContactById( - contactId: userData.userId, - expectedPublicKey: userData.publicIdentityKey, - ); - if (widget.qrCodeLink != null) { - // As the user does now exist he can now be marked as verified - await QrCodeUtils.handleQrCodeLink(widget.qrCodeLink!); + final success = await addNewContactFromPublicProfile(widget.profile); + if (!success) { + if (mounted) { + showSnackbar( + context, + context.lang.additionalUserAddError(widget.profile.username), + ); } + return; } if (mounted) { diff --git a/lib/src/visual/views/contact/contact.view.dart b/lib/src/visual/views/contact/contact.view.dart index 2e10af78..af6f0eb4 100644 --- a/lib/src/visual/views/contact/contact.view.dart +++ b/lib/src/visual/views/contact/contact.view.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; +import 'package:twonly/core/bridge/wrapper/signal.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; @@ -38,6 +39,7 @@ class ContactView extends StatefulWidget { class _ContactViewState extends State { Contact? _contact; List _memberOfGroups = []; + bool _isResettingSignalSession = false; late StreamSubscription _streamContact; late StreamSubscription> _streamMemberOfGroups; @@ -138,6 +140,34 @@ class _ContactViewState extends State { } } + Future handleResetSignalSession(Contact contact) async { + if (_isResettingSignalSession) return; + + final displayName = getContactDisplayName(contact); + final confirmed = await showAlertDialog( + context, + context.lang.contactResetSessionTitle(displayName), + context.lang.contactResetSessionBody, + customOk: context.lang.contactResetSessionConfirm, + ); + if (!confirmed || !mounted) return; + + setState(() => _isResettingSignalSession = true); + final result = await rustApiResult( + RustSignal.resetContactSession(contactId: contact.userId), + ); + if (!mounted) return; + + setState(() => _isResettingSignalSession = false); + showSnackbar( + context, + result.isSuccess + ? context.lang.contactResetSessionSuccess(displayName) + : context.lang.contactResetSessionFailed, + level: result.isSuccess ? SnackbarLevel.success : SnackbarLevel.error, + ); + } + @override Widget build(BuildContext context) { if (_contact == null) return Container(); @@ -285,6 +315,19 @@ class _ContactViewState extends State { text: context.lang.contactBlock, onTap: () => handleUserBlockRequest(contact), ), + BetterListTile( + icon: FontAwesomeIcons.screwdriverWrench, + text: context.lang.contactTroubleshoot, + trailing: _isResettingSignalSession + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + onTap: _isResettingSignalSession + ? null + : () => handleResetSignalSession(contact), + ), BetterListTile( icon: FontAwesomeIcons.userMinus, iconSize: 16, diff --git a/lib/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart b/lib/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart index fc91aea1..e5e0f46f 100644 --- a/lib/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart +++ b/lib/src/visual/views/contact/contact_components/verification_expansion_tile.comp.dart @@ -76,6 +76,7 @@ class _VerificationExpansionTileCompState getContactDisplayName(widget.contact), ), VerificationType.link => context.lang.verificationTypeLink, + VerificationType.manual => context.lang.verificationTypeManual, VerificationType.contactSharedByVerified => verifier != null ? context.lang.contactVerifiedBy(getContactDisplayName(verifier)) diff --git a/lib/src/visual/views/settings/help/faq/verification_badge_faq.view.dart b/lib/src/visual/views/settings/help/faq/verification_badge_faq.view.dart index 1970ee57..e40a9f52 100644 --- a/lib/src/visual/views/settings/help/faq/verification_badge_faq.view.dart +++ b/lib/src/visual/views/settings/help/faq/verification_badge_faq.view.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/verification_badge_info.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; class VerificationBadeFaqView extends StatefulWidget { const VerificationBadeFaqView({super.key, this.contact}); @@ -27,6 +31,31 @@ class _VerificationBadeFaqViewState extends State { displayButtons: true, contact: widget.contact, ), + if (widget.contact != null) ...[ + const SizedBox(height: 24), + Center( + child: IntrinsicWidth( + child: MyButton( + variant: MyButtonVariant.secondaryMiddle, + onPressed: () => context.push( + Routes.settingsHelpFaqManualVerification, + extra: widget.contact, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const FaIcon(FontAwesomeIcons.fingerprint, size: 20), + const SizedBox(width: 10), + Flexible( + child: Text(context.lang.manualVerificationOpen), + ), + ], + ), + ), + ), + ), + ], ], ), ); diff --git a/lib/src/visual/views/settings/help/manual_verification.view.dart b/lib/src/visual/views/settings/help/manual_verification.view.dart new file mode 100644 index 00000000..b41c7f6b --- /dev/null +++ b/lib/src/visual/views/settings/help/manual_verification.view.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:twonly/core/bridge/wrapper/signal.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; + +class ManualVerificationView extends StatefulWidget { + const ManualVerificationView({required this.contact, super.key}); + + final Contact contact; + + @override + State createState() => _ManualVerificationViewState(); +} + +class _ManualVerificationViewState extends State { + late Future _safetyNumber; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadSafetyNumber(); + } + + void _loadSafetyNumber() { + _safetyNumber = RustSignal.getSafetyNumber( + contactId: widget.contact.userId, + ); + } + + Future _markAsVerified() async { + if (_isSaving) return; + setState(() => _isSaving = true); + + await twonlyDB.keyVerificationDao.addKeyVerification( + widget.contact.userId, + VerificationType.manual, + ); + + if (!mounted) return; + final displayName = getContactDisplayName(widget.contact); + await VerificationSuccessDialog.show( + context, + widget.contact, + message: context.lang.manualVerificationSuccess(displayName), + ); + if (mounted) context.pop(); + } + + @override + Widget build(BuildContext context) { + final displayName = getContactDisplayName(widget.contact); + + return Scaffold( + appBar: AppBar( + title: Text(context.lang.manualVerificationTitle), + ), + body: SafeArea( + child: FutureBuilder( + future: _safetyNumber, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return _SafetyNumberError( + message: context.lang.manualVerificationLoadFailed, + onRetry: () => setState(_loadSafetyNumber), + ); + } + + final safetyNumber = snapshot.data; + if (safetyNumber == null) { + return _SafetyNumberError( + title: context.lang.manualVerificationMissingKeyTitle, + message: context.lang.manualVerificationMissingKeyDescription( + displayName, + ), + onRetry: () => setState(_loadSafetyNumber), + ); + } + + return _SafetyNumberContent( + safetyNumber: safetyNumber, + description: context.lang.manualVerificationDescription( + displayName, + ), + isSaving: _isSaving, + onVerify: _markAsVerified, + ); + }, + ), + ), + ); + } +} + +class _SafetyNumberContent extends StatelessWidget { + const _SafetyNumberContent({ + required this.safetyNumber, + required this.description, + required this.isSaving, + required this.onVerify, + }); + + final String safetyNumber; + final String description; + final bool isSaving; + final VoidCallback onVerify; + + @override + Widget build(BuildContext context) { + final groups = [ + for (var offset = 0; offset < safetyNumber.length; offset += 5) + safetyNumber.substring(offset, offset + 5), + ]; + + return ListView( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 40), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: DecoratedBox( + decoration: BoxDecoration( + color: context.color.primaryContainer, + borderRadius: BorderRadius.circular(28), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 32, 20, 20), + child: Column( + children: [ + FaIcon( + FontAwesomeIcons.fingerprint, + size: 42, + color: context.color.onPrimaryContainer, + ), + const SizedBox(height: 28), + for (var row = 0; row < 3; row++) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final group in groups.skip(row * 4).take(4)) + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + group, + textAlign: TextAlign.center, + style: TextStyle( + color: context.color.onPrimaryContainer, + fontFamily: 'monospace', + fontSize: 15, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + height: 1.45, + ), + ), + ), + ), + ], + ), + if (row < 2) const SizedBox(height: 4), + ], + const SizedBox(height: 28), + IntrinsicWidth( + child: MyButton( + variant: MyButtonVariant.primaryMiddle, + onPressed: isSaving ? null : onVerify, + child: isSaving + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text( + context.lang.manualVerificationMarkVerified, + ), + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 28), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Text( + description, + textAlign: TextAlign.center, + style: TextStyle( + color: context.color.onSurfaceVariant, + fontSize: 14, + height: 1.45, + ), + ), + ), + ), + ], + ); + } +} + +class _SafetyNumberError extends StatelessWidget { + const _SafetyNumberError({ + required this.message, + required this.onRetry, + this.title, + }); + + final String? title; + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.key_off_rounded, + size: 54, + color: context.color.onSurfaceVariant, + ), + if (title != null) ...[ + const SizedBox(height: 20), + Text( + title!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: context.color.onSurfaceVariant, + fontSize: 16, + height: 1.45, + ), + ), + const SizedBox(height: 28), + MyButton( + variant: MyButtonVariant.secondaryMiddle, + onPressed: onRetry, + child: Text(context.lang.manualVerificationRetry), + ), + ], + ), + ), + ), + ); + } +} diff --git a/rust/src/bridge/wrapper/signal.rs b/rust/src/bridge/wrapper/signal.rs index 11dd0a4d..b647ef79 100644 --- a/rust/src/bridge/wrapper/signal.rs +++ b/rust/src/bridge/wrapper/signal.rs @@ -4,8 +4,11 @@ */ use crate::bridge::get_twonly_flutter; +use crate::context::Context; use crate::error::{Result, TwonlyError}; +use crate::services::contacts::ContactService; use crate::signal::engine::{FrbPqcPreKey, FrbPreKeyBundle}; +use crate::signal::reset::SessionResetLimiter; pub struct RustSignal {} @@ -59,4 +62,34 @@ impl RustSignal { .get_contact_identity_key(&contact_id.to_string()) .await } + + pub async fn get_safety_number(contact_id: i64) -> Result> { + let guard = get_twonly_flutter()?.signal_engine.lock().await; + let engine = guard.as_ref().ok_or(TwonlyError::Initialization)?; + engine.get_safety_number(contact_id).await + } + + /// Removes the current session with a contact and immediately establishes + /// a fresh one while requiring the contact's stored identity key to match. + pub async fn reset_contact_session(contact_id: i64) -> Result<()> { + let ctx = Context::get_static()?; + let expected_public_key = { + let guard = ctx.signal_engine.lock().await; + let engine = guard.as_ref().ok_or(TwonlyError::Initialization)?; + let public_key = engine + .get_contact_identity_key(&contact_id.to_string()) + .await?; + engine + .reset_session(&contact_id.to_string(), 1, true) + .await?; + public_key + }; + + ContactService::new(ctx) + .establish_signal_session(contact_id, expected_public_key) + .await?; + + let database = ctx.rust_db.read().await.clone(); + SessionResetLimiter::clear(&database.pool, &contact_id.to_string(), 1).await + } } diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 9c1ef61c..aff6f3ca 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -2068471902; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1675944024; // Section: executor @@ -5735,6 +5735,46 @@ fn wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl }, ) } +fn wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_signal_get_safety_number", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_contact_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::wrapper::signal::RustSignal::get_safety_number( + api_contact_id, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -5817,6 +5857,46 @@ fn wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl( }, ) } +fn wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_signal_reset_contact_session", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_contact_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::wrapper::signal::RustSignal::reset_contact_session( + api_contact_id, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__wrapper__rust_utils_generate_shares_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -7982,20 +8062,22 @@ fn pde_ffi_dispatcher_primary_impl( 156 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), 157 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), 158 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), -159 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), -160 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), -161 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -162 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -163 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len), -164 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len), -165 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), -166 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), -167 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len), -169 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), -170 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), -171 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), -172 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), -173 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), +159 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len), +160 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), +161 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), +162 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len), +163 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +164 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +165 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len), +166 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len), +167 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), +168 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), +169 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len), +171 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), +172 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), +173 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), +174 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), +175 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -8013,12 +8095,12 @@ fn pde_ffi_dispatcher_sync_impl( 51 => { wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len) } - 168 => wire__crate__bridge__user_config__user_config_api_clone_impl( + 170 => wire__crate__bridge__user_config__user_config_api_clone_impl( ptr, rust_vec_len, data_len, ), - 174 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), + 176 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/rust/src/signal/engine.rs b/rust/src/signal/engine.rs index 70181c58..46e5739d 100644 --- a/rust/src/signal/engine.rs +++ b/rust/src/signal/engine.rs @@ -8,10 +8,10 @@ use crate::error::{Result, TwonlyError}; use crate::user_config::UserConfig; use chrono::{Duration, Utc}; use libsignal_protocol::{ - message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, GenericSignedPreKey, - IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId, KyberPreKeyStore, PreKeyBundle, - PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, PublicKey, SessionStore, - SignalMessage, SignedPreKeyId, SignedPreKeyStore, Timestamp, + message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, Fingerprint, + GenericSignedPreKey, IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId, + KyberPreKeyStore, PreKeyBundle, PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, + PublicKey, SessionStore, SignalMessage, SignedPreKeyId, SignedPreKeyStore, Timestamp, }; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -50,6 +50,29 @@ pub struct FrbPqcPreKey { pub kyber_pre_key_signature: Vec, } +fn create_safety_number( + local_id: &str, + local_key_bytes: &[u8], + remote_id: &str, + remote_key_bytes: &[u8], +) -> Result { + let local_key = IdentityKey::decode(local_key_bytes) + .map_err(|error| TwonlyError::Signal(error.to_string()))?; + let remote_key = IdentityKey::decode(remote_key_bytes) + .map_err(|error| TwonlyError::Signal(error.to_string()))?; + + Fingerprint::new( + 2, + 5_200, + local_id.as_bytes(), + &local_key, + remote_id.as_bytes(), + &remote_key, + ) + .and_then(|fingerprint| fingerprint.display_string()) + .map_err(|error| TwonlyError::Signal(error.to_string())) +} + impl RustSignalEngine { pub async fn on_connected(&self, ctx: &Arc) -> Result<()> { let base = UserConfig::load_required_from(ctx)?; @@ -165,6 +188,26 @@ impl RustSignalEngine { Ok(identity) } + pub async fn get_safety_number(&self, contact_id: i64) -> Result> { + let Some(contact_key_bytes) = self + .get_contact_identity_key(&contact_id.to_string()) + .await? + else { + return Ok(None); + }; + + // Signal's standard 60-digit numeric fingerprint. Both participants + // derive the same display string because libsignal sorts the two + // independently generated fingerprint halves before displaying them. + create_safety_number( + &self.local_name, + &self.get_identity_key().await?, + &contact_id.to_string(), + &contact_key_bytes, + ) + .map(Some) + } + #[cfg(test)] fn generate_identity_key_pair() -> Result> { let mut csprng = rand::rngs::StdRng::from_os_rng(); @@ -678,6 +721,29 @@ mod tests { use rand::rngs::StdRng; use tempfile::tempdir; + #[test] + fn safety_number_matches_signal_format_and_is_symmetric() { + let alice_key = + hex::decode("0506863bc66d02b40d27b8d49ca7c09e9239236f9d7d25d6fcca5ce13c7064d868") + .unwrap(); + let bob_key = + hex::decode("05f781b6fb32fed9ba1cf2de978d4d5da28dc34046ae814402b5c0dbd96fda907b") + .unwrap(); + + let alice = + create_safety_number("+14152222222", &alice_key, "+14153333333", &bob_key).unwrap(); + let bob = + create_safety_number("+14153333333", &bob_key, "+14152222222", &alice_key).unwrap(); + + assert_eq!(alice, bob); + assert_eq!( + alice, + "300354477692869396892869876765458257569162576843440918079131" + ); + assert_eq!(alice.len(), 60); + assert!(alice.bytes().all(|byte| byte.is_ascii_digit())); + } + async fn create_test_engine(name: &str) -> (RustSignalEngine, tempfile::TempDir) { let dir = tempdir().unwrap(); let db_path = dir.path().join(format!("{}.db", name)); diff --git a/test/services/key_verification_service_test.dart b/test/services/key_verification_service_test.dart index 99cb75fd..f472cfb6 100644 --- a/test/services/key_verification_service_test.dart +++ b/test/services/key_verification_service_test.dart @@ -160,6 +160,16 @@ void main() { expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true); }); + test('isContactVerified returns true for manual comparison', () async { + await insertContact(10); + await addDirectVerification(10, VerificationType.manual); + + final verifications = await twonlyDB.keyVerificationDao + .getContactVerification(10); + expect(verifications.single.type, VerificationType.manual); + expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true); + }); + test( 'isContactVerified returns false when no verification exists', () async { diff --git a/test/services/verification_link_handling_test.dart b/test/services/verification_link_handling_test.dart new file mode 100644 index 00000000..d274f1f3 --- /dev/null +++ b/test/services/verification_link_handling_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_sharing_intent/model/sharing_file.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/services/intent/links.intent.dart'; +import 'package:twonly/src/utils/qr.utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('invalid QR profile links return a visible failure status', () async { + final result = await QrCodeUtils.handleQrCodeLink( + '${QrCodeUtils.linkPrefix}not-a-valid-profile', + ); + + expect(result.status, QrCodeLinkStatus.invalid); + expect(result.profile, isNull); + expect(result.contact, isNull); + }); + + testWidgets('shared URLs are awaited by the common URL handler', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (builderContext) { + context = builderContext; + return const SizedBox.shrink(); + }, + ), + ), + ); + + Uri? receivedUri; + var callbackFinished = false; + await handleIntentSharedFile( + context, + [ + SharedFile( + value: 'https://me.twonly.eu/alice#public-key', + type: SharedMediaType.URL, + ), + ], + (uri) async { + receivedUri = uri; + await Future.value(); + callbackFinished = true; + }, + (_, _) {}, + ); + + expect(receivedUri, Uri.parse('https://me.twonly.eu/alice#public-key')); + expect(callbackFinished, isTrue); + }); +}