add version to manual verifiy numbers

This commit is contained in:
otsmr 2026-09-15 23:23:17 +02:00
parent 3bc446553a
commit 5786effc5b
27 changed files with 1644 additions and 257 deletions

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch: {} workflow_dispatch: {}
push: push:
branches: branches:
- dev - disabled_dev
paths: paths:
- lib/**/*.dart - lib/**/*.dart

View file

@ -192,16 +192,18 @@ class _AppMainWidgetState extends State<AppMainWidget> {
HomeViewState.streamSharedLink.add(uri); HomeViewState.streamSharedLink.add(uri);
} }
// Subscribe to all events (initial link and further) Future<void> handleIncomingUrl(Uri uri) async {
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
if (!mounted) return; 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 (!await handleIntentUrl(context, uri)) {
if (uri.scheme.startsWith('http')) { if (uri.scheme.startsWith('http')) {
handleShareLink(uri); handleShareLink(uri);
} }
} }
}); }
// Subscribe to all events (initial link and further)
_deepLinkSub = AppLinks().uriLinkStream.listen(handleIncomingUrl);
void handleShareMedia(String path, MediaType type) { void handleShareMedia(String path, MediaType type) {
HomeViewState.pendingSharedMedia = (path, type); HomeViewState.pendingSharedMedia = (path, type);
@ -212,7 +214,7 @@ class _AppMainWidgetState extends State<AppMainWidget> {
_intentStreamSub = initIntentStreams( _intentStreamSub = initIntentStreams(
context, context,
handleShareLink, handleIncomingUrl,
handleShareMedia, handleShareMedia,
); );
} }

View file

@ -43,6 +43,11 @@ class RustSignal {
contactId: contactId, contactId: contactId,
); );
static Future<String?> getSafetyNumber({required PlatformInt64 contactId}) =>
RustLib.instance.api.crateBridgeWrapperSignalRustSignalGetSafetyNumber(
contactId: contactId,
);
static Future<Uint8List> getUserPublicKey() => static Future<Uint8List> getUserPublicKey() =>
RustLib.instance.api.crateBridgeWrapperSignalRustSignalGetUserPublicKey(); RustLib.instance.api.crateBridgeWrapperSignalRustSignalGetUserPublicKey();
@ -57,6 +62,14 @@ class RustSignal {
bundle: bundle, 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<void> resetContactSession({required PlatformInt64 contactId}) =>
RustLib.instance.api
.crateBridgeWrapperSignalRustSignalResetContactSession(
contactId: contactId,
);
@override @override
int get hashCode => 0; int get hashCode => 0;

View file

@ -87,7 +87,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0'; String get codegenVersion => '2.12.0';
@override @override
int get rustContentHash => -2068471902; int get rustContentHash => -1675944024;
static const kDefaultExternalLibraryLoaderConfig = static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig( ExternalLibraryLoaderConfig(
@ -699,6 +699,10 @@ abstract class RustLibApi extends BaseApi {
required PlatformInt64 contactId, required PlatformInt64 contactId,
}); });
Future<String?> crateBridgeWrapperSignalRustSignalGetSafetyNumber({
required PlatformInt64 contactId,
});
Future<Uint8List> crateBridgeWrapperSignalRustSignalGetUserPublicKey(); Future<Uint8List> crateBridgeWrapperSignalRustSignalGetUserPublicKey();
Future<void> crateBridgeWrapperSignalRustSignalProcessPrekeyBundle({ Future<void> crateBridgeWrapperSignalRustSignalProcessPrekeyBundle({
@ -707,6 +711,10 @@ abstract class RustLibApi extends BaseApi {
required FrbPreKeyBundle bundle, required FrbPreKeyBundle bundle,
}); });
Future<void> crateBridgeWrapperSignalRustSignalResetContactSession({
required PlatformInt64 contactId,
});
Future<List<Uint8List>> crateBridgeWrapperRustUtilsGenerateShares({ Future<List<Uint8List>> crateBridgeWrapperRustUtilsGenerateShares({
required List<int> secret, required List<int> secret,
required int total, required int total,
@ -6206,6 +6214,40 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["contactId"], argNames: ["contactId"],
); );
@override
Future<String?> 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 @override
Future<Uint8List> crateBridgeWrapperSignalRustSignalGetUserPublicKey() { Future<Uint8List> crateBridgeWrapperSignalRustSignalGetUserPublicKey() {
return handler.executeNormal( return handler.executeNormal(
@ -6215,7 +6257,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 159, funcId: 160,
port: port_, port: port_,
); );
}, },
@ -6253,7 +6295,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 160, funcId: 161,
port: port_, port: port_,
); );
}, },
@ -6276,6 +6318,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["name", "deviceId", "bundle"], argNames: ["name", "deviceId", "bundle"],
); );
@override
Future<void> 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 @override
Future<List<Uint8List>> crateBridgeWrapperRustUtilsGenerateShares({ Future<List<Uint8List>> crateBridgeWrapperRustUtilsGenerateShares({
required List<int> secret, required List<int> secret,
@ -6292,7 +6369,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 161, funcId: 163,
port: port_, port: port_,
); );
}, },
@ -6327,7 +6404,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 162, funcId: 164,
port: port_, port: port_,
); );
}, },
@ -6372,7 +6449,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 163, funcId: 165,
port: port_, port: port_,
); );
}, },
@ -6415,7 +6492,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 164, funcId: 166,
port: port_, port: port_,
); );
}, },
@ -6449,7 +6526,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 165, funcId: 167,
port: port_, port: port_,
); );
}, },
@ -6484,7 +6561,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 166, funcId: 168,
port: port_, port: port_,
); );
}, },
@ -6519,7 +6596,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 167, funcId: 169,
port: port_, port: port_,
); );
}, },
@ -6552,7 +6629,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi( return pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 168, funcId: 170,
)!; )!;
}, },
codec: SseCodec( codec: SseCodec(
@ -6592,7 +6669,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 169, funcId: 171,
port: port_, port: port_,
); );
}, },
@ -6637,7 +6714,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 170, funcId: 172,
port: port_, port: port_,
); );
}, },
@ -6667,7 +6744,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 171, funcId: 173,
port: port_, port: port_,
); );
}, },
@ -6700,7 +6777,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 172, funcId: 174,
port: port_, port: port_,
); );
}, },
@ -6735,7 +6812,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 173, funcId: 175,
port: port_, port: port_,
); );
}, },
@ -6774,7 +6851,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi( return pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 174, funcId: 176,
)!; )!;
}, },
codec: SseCodec( codec: SseCodec(

View file

@ -138,6 +138,10 @@ class UserConfig {
bool autoStoreAllSendUnlimitedMediaFiles; bool autoStoreAllSendUnlimitedMediaFiles;
bool typingIndicators; bool typingIndicators;
bool showRestoreFlame; 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; String? myBestFriendGroupId;
DateTime? signalLastSignedPreKeyUpdated; DateTime? signalLastSignedPreKeyUpdated;
DateTime? signalLastPqcPreKeysUploaded; DateTime? signalLastPqcPreKeysUploaded;

View file

@ -47,6 +47,8 @@ class Routes {
static const String settingsHelpFaq = '/settings/help/faq'; static const String settingsHelpFaq = '/settings/help/faq';
static const String settingsHelpFaqVerifyBadge = static const String settingsHelpFaqVerifyBadge =
'/settings/help/faq/verifybadge'; '/settings/help/faq/verifybadge';
static const String settingsHelpFaqManualVerification =
'/settings/help/faq/verifybadge/manual';
static const String settingsHelpContactUs = '/settings/help/contact_us'; static const String settingsHelpContactUs = '/settings/help/contact_us';
static const String settingsHelpNews = '/settings/help/news'; static const String settingsHelpNews = '/settings/help/news';
static const String settingsHelpDiagnostics = '/settings/help/diagnostics'; static const String settingsHelpDiagnostics = '/settings/help/diagnostics';

View file

@ -65,6 +65,7 @@ enum VerificationType {
migratedFromOldVersion, migratedFromOldVersion,
qrScanned, qrScanned,
link, link,
manual,
secretQrToken, secretQrToken,
contactSharedByVerified, contactSharedByVerified,
} }

View file

@ -968,6 +968,12 @@ abstract class AppLocalizations {
/// **'Verified via link.'** /// **'Verified via link.'**
String get verificationTypeLink; 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. /// No description provided for @verificationTypeMigratedFromOldVersion.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -998,6 +1004,42 @@ abstract class AppLocalizations {
/// **'Block'** /// **'Block'**
String get contactBlock; 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. /// No description provided for @contactBlockTitle.
/// ///
/// In en, this message translates to: /// 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!'** /// **'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; 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. /// No description provided for @startWithCameraOpen.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2870,6 +2954,60 @@ abstract class AppLocalizations {
/// **'A contact whose identity has *not* yet been verified.'** /// **'A contact whose identity has *not* yet been verified.'**
String get verificationBadgeRedDesc; 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. /// No description provided for @scanNow.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -481,6 +481,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get verificationTypeLink => 'Per Link verifiziert.'; String get verificationTypeLink => 'Per Link verifiziert.';
@override
String get verificationTypeManual => 'Sicherheitsnummer manuell verglichen.';
@override @override
String get verificationTypeMigratedFromOldVersion => String get verificationTypeMigratedFromOldVersion =>
'Von alter Version migriert'; 'Von alter Version migriert';
@ -497,6 +500,30 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contactBlock => 'Blockieren'; 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 @override
String contactBlockTitle(Object username) { String contactBlockTitle(Object username) {
return 'Blockiere $username'; return 'Blockiere $username';
@ -1504,6 +1531,31 @@ class AppLocalizationsDe extends AppLocalizations {
String get linkPubkeyDoesNotMatch => 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!'; '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 @override
String get startWithCameraOpen => 'Mit geöffneter Kamera starten'; String get startWithCameraOpen => 'Mit geöffneter Kamera starten';
@ -1626,6 +1678,41 @@ class AppLocalizationsDe extends AppLocalizations {
String get verificationBadgeRedDesc => String get verificationBadgeRedDesc =>
'Ein Kontakt, dessen Identität noch *nicht überprüft* wurde.'; '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 @override
String get scanNow => 'Jetzt scannen'; String get scanNow => 'Jetzt scannen';

View file

@ -478,6 +478,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get verificationTypeLink => 'Verified via link.'; String get verificationTypeLink => 'Verified via link.';
@override
String get verificationTypeManual => 'Safety number compared manually.';
@override @override
String get verificationTypeMigratedFromOldVersion => String get verificationTypeMigratedFromOldVersion =>
'Migrated from old version.'; 'Migrated from old version.';
@ -494,6 +497,30 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contactBlock => 'Block'; 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 @override
String contactBlockTitle(Object username) { String contactBlockTitle(Object username) {
return 'Block $username'; return 'Block $username';
@ -1492,6 +1519,31 @@ class AppLocalizationsEn extends AppLocalizations {
String get linkPubkeyDoesNotMatch => 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!'; '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 @override
String get startWithCameraOpen => 'Start with camera open'; String get startWithCameraOpen => 'Start with camera open';
@ -1612,6 +1664,40 @@ class AppLocalizationsEn extends AppLocalizations {
String get verificationBadgeRedDesc => String get verificationBadgeRedDesc =>
'A contact whose identity has *not* yet been verified.'; '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 @override
String get scanNow => 'Scan now'; String get scanNow => 'Scan now';

View file

@ -174,11 +174,32 @@
} }
}, },
"verificationTypeLink": "Per Link verifiziert.", "verificationTypeLink": "Per Link verifiziert.",
"verificationTypeManual": "Sicherheitsnummer manuell verglichen.",
"verificationTypeMigratedFromOldVersion": "Von alter Version migriert", "verificationTypeMigratedFromOldVersion": "Von alter Version migriert",
"contactViewMessage": "Nachricht", "contactViewMessage": "Nachricht",
"contactNickname": "Spitzname", "contactNickname": "Spitzname",
"contactNicknameNew": "Neuer Spitzname", "contactNicknameNew": "Neuer Spitzname",
"contactBlock": "Blockieren", "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", "contactRemove": "Kontakt löschen",
"contactRemoveTitle": "{username} 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.", "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.", "gotLinkFromFriend": "Ja, der Link kommt direkt von der Person.",
"couldNotVerifyUsername": "{username} konnte nicht verifiziert werden", "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!", "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", "startWithCameraOpen": "Mit geöffneter Kamera starten",
"showImagePreviewWhenSending": "Bildvorschau bei der Auswahl von Empfängern anzeigen", "showImagePreviewWhenSending": "Bildvorschau bei der Auswahl von Empfängern anzeigen",
"verifiedPublicKey": "Die Identität von {username} wurde erfolgreich überprüft.", "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.", "verificationBadgeGreenDesc": "Ein Kontakt, den du über den QR-code *persönlich verifiziert* hast.",
"verificationBadgeYellowDesc": "Ein Kontakt, der von mind. einem *deiner Kontakte verifiziert* wurde.", "verificationBadgeYellowDesc": "Ein Kontakt, der von mind. einem *deiner Kontakte verifiziert* wurde.",
"verificationBadgeRedDesc": "Ein Kontakt, dessen Identität noch *nicht überprüft* 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", "scanNow": "Jetzt scannen",
"qrScannerVerifyHint": "Zum Verifizieren muss die andere Person ihren QR-Code öffnen (Chat-Liste > QR-Code-Button unten rechts)", "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)", "qrScannerVerifyUserHint": "Zum Verifizieren muss {username} seinen QR-Code öffnen (Chat-Liste > QR-Code-Button unten rechts)",

View file

@ -170,11 +170,32 @@
} }
}, },
"verificationTypeLink": "Verified via link.", "verificationTypeLink": "Verified via link.",
"verificationTypeManual": "Safety number compared manually.",
"verificationTypeMigratedFromOldVersion": "Migrated from old version.", "verificationTypeMigratedFromOldVersion": "Migrated from old version.",
"contactViewMessage": "Message", "contactViewMessage": "Message",
"contactNickname": "Nickname", "contactNickname": "Nickname",
"contactNicknameNew": "New nickname", "contactNicknameNew": "New nickname",
"contactBlock": "Block", "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": "Block {username}",
"@contactBlockTitle": { "@contactBlockTitle": {
"placeholders": { "placeholders": {
@ -497,6 +518,13 @@
"gotLinkFromFriend": "Yes, I got the link from my friend!", "gotLinkFromFriend": "Yes, I got the link from my friend!",
"couldNotVerifyUsername": "Could not verify {username}", "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!", "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", "startWithCameraOpen": "Start with camera open",
"showImagePreviewWhenSending": "Display image preview when selecting recipients", "showImagePreviewWhenSending": "Display image preview when selecting recipients",
"verifiedPublicKey": "The identity of {username} has been successfully verified.", "verifiedPublicKey": "The identity of {username} has been successfully verified.",
@ -528,6 +556,36 @@
"verificationBadgeGreenDesc": "A contact you have *personally verified* using the QR code.", "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*.", "verificationBadgeYellowDesc": "A contact who has been verified by at least one of *your contacts*.",
"verificationBadgeRedDesc": "A contact whose identity has *not* yet been verified.", "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", "scanNow": "Scan now",
"qrScannerVerifyHint": "To verify, the other person must open their QR code (Chat Lists > QR code button at the bottom right)", "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)", "qrScannerVerifyUserHint": "To verify, {username} must open their QR code (Chat Lists > QR code button at the bottom right)",

View file

@ -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.view.dart';
import 'package:twonly/src/visual/views/settings/help/faq/verification_badge_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/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/help/news.view.dart';
import 'package:twonly/src/visual/views/settings/notification.view.dart'; import 'package:twonly/src/visual/views/settings/notification.view.dart';
import 'package:twonly/src/visual/views/settings/privacy.view.dart'; import 'package:twonly/src/visual/views/settings/privacy.view.dart';
@ -255,6 +256,14 @@ final routerProvider = GoRouter(
builder: (context, state) => VerificationBadeFaqView( builder: (context, state) => VerificationBadeFaqView(
contact: state.extra as Contact?, contact: state.extra as Contact?,
), ),
routes: [
GoRoute(
path: 'manual',
builder: (context, state) => ManualVerificationView(
contact: state.extra! as Contact,
),
),
],
), ),
], ],
), ),

View file

@ -9,20 +9,27 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/core/bridge/wrapper/signal.dart'; import 'package:twonly/core/bridge/wrapper/signal.dart';
import 'package:twonly/locator.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/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/mediafiles.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/services/passwordless_recovery.service.dart';
import 'package:twonly/src/utils/log.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/utils/qr.utils.dart'; import 'package:twonly/src/utils/qr.utils.dart';
import 'package:twonly/src/visual/components/alert.dialog.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_contact_via_qr_link.view.dart';
import 'package:twonly/src/visual/views/contact/add_new_contact.view.dart'; import 'package:twonly/src/visual/views/contact/add_new_contact.view.dart';
Future<bool> handleIntentUrl(BuildContext context, Uri uri) async { Future<bool> 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.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 // Check if this is the QR code link which was
// therefore scanned with the system camera // therefore scanned with the system camera
@ -37,37 +44,47 @@ Future<bool> handleIntentUrl(BuildContext context, Uri uri) async {
if (!context.mounted) return false; if (!context.mounted) return false;
if (result != null) { switch (result.status) {
final (profile, contact, verificationOk) = result; case QrCodeLinkStatus.newContact:
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 {
await context.navPush( await context.navPush(
AddContactViaQrLinkView( AddContactViaQrLinkView(
profile: profile, profile: result.profile!,
qrCodeLink: uri.toString(),
), ),
); );
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; return true;
} }
final publicKey = uri.hasFragment ? uri.fragment : null; final rawPathSegments = uri.pathSegments
final userPaths = uri.path.split('/'); .where((part) => part.isNotEmpty)
if (userPaths.length != 2) return false; .toList();
final username = userPaths[1]; 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; if (!context.mounted) return false;
@ -80,34 +97,74 @@ Future<bool> handleIntentUrl(BuildContext context, Uri uri) async {
'Opened via deep link!: username = $username public_key = ${uri.fragment}', '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<Contact> 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 (!context.mounted) return true;
if (contacts.isEmpty) { if (contacts.isEmpty) {
// User does not yet exists, making a request... // User does not yet exists, making a request...
Uint8List? publicKeyBytes;
if (publicKey != null) {
publicKeyBytes = base64Url.decode(publicKey);
}
await context.navPush( await context.navPush(
AddNewUserView( AddNewUserView(
username: username, username: username,
publicKey: publicKeyBytes, publicKey: receivedPublicKey,
), ),
); );
return true; return true;
} }
if (publicKey != null) {
try {
final contact = contacts.first; 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( final storedPublicKey = await RustSignal.getContactPublicKey(
contactId: contact.userId, contactId: contact.userId,
); );
final receivedPublicKey = base64Url.decode(publicKey); if (!context.mounted) return true;
if (storedPublicKey == null || if (storedPublicKey == null) {
receivedPublicKey.isEmpty || await showQrCodeVerificationFailure(
!context.mounted) { context,
QrCodeLinkResult(
status: QrCodeLinkStatus.missingSession,
contact: contact,
),
);
return true;
}
if (receivedPublicKey == null || receivedPublicKey.isEmpty) {
await showQrCodeVerificationFailure(
context,
QrCodeLinkResult(
status: QrCodeLinkStatus.invalid,
contact: contact,
),
);
return true; return true;
} }
if (storedPublicKey.equals(receivedPublicKey)) { if (storedPublicKey.equals(receivedPublicKey)) {
@ -127,39 +184,60 @@ Future<bool> handleIntentUrl(BuildContext context, Uri uri) async {
await context.push(Routes.profileContact(contact.userId)); await context.push(Routes.profileContact(contact.userId));
} }
} else { } else {
await _pubKeysDoNotMatch(context, contact.username); await showQrCodeVerificationFailure(
context,
QrCodeLinkResult(
status: QrCodeLinkStatus.keyMismatch,
contact: contact,
),
);
} }
} catch (e) { } catch (e) {
Log.warn(e); Log.warn(e);
if (context.mounted) {
await showQrCodeVerificationFailure(
context,
QrCodeLinkResult(
status: QrCodeLinkStatus.error,
contact: contact,
),
);
} }
} }
return true; return true;
} }
Future<void> _pubKeysDoNotMatch(BuildContext context, String username) async { Future<void> _showInvalidVerificationLink(BuildContext context) {
await showAlertDialog( return VerificationFailureDialog.show(
context, context,
context.lang.couldNotVerifyUsername(username), title: context.lang.verificationLinkInvalidTitle,
context.lang.linkPubkeyDoesNotMatch, message: context.lang.verificationLinkInvalid,
customCancel: '', );
}
Future<void> _showVerificationLinkError(BuildContext context) {
return VerificationFailureDialog.show(
context,
title: context.lang.verificationLinkErrorTitle,
message: context.lang.verificationLinkError,
); );
} }
StreamSubscription<List<SharedFile>> initIntentStreams( StreamSubscription<List<SharedFile>> initIntentStreams(
BuildContext context, BuildContext context,
void Function(Uri) onUrlCallBack, Future<void> Function(Uri) onUrlCallBack,
void Function(String, MediaType) onMediaCallBack, void Function(String, MediaType) onMediaCallBack,
) { ) {
FlutterSharingIntent.instance.getInitialSharing().then((f) { FlutterSharingIntent.instance.getInitialSharing().then((f) async {
if (!context.mounted) return; if (!context.mounted) return;
handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack); await handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack);
}); });
return FlutterSharingIntent.instance.getMediaStream().listen( return FlutterSharingIntent.instance.getMediaStream().listen(
(f) { (f) async {
if (!context.mounted) return; if (!context.mounted) return;
handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack); await handleIntentSharedFile(context, f, onUrlCallBack, onMediaCallBack);
}, },
// ignore: inference_failure_on_untyped_parameter // ignore: inference_failure_on_untyped_parameter
onError: (err) { onError: (err) {
@ -171,7 +249,7 @@ StreamSubscription<List<SharedFile>> initIntentStreams(
Future<void> handleIntentSharedFile( Future<void> handleIntentSharedFile(
BuildContext context, BuildContext context,
List<SharedFile> files, List<SharedFile> files,
void Function(Uri) onUrlCallBack, Future<void> Function(Uri) onUrlCallBack,
void Function(String, MediaType) onMediaCallBack, void Function(String, MediaType) onMediaCallBack,
) async { ) async {
for (final file in files) { for (final file in files) {
@ -189,7 +267,7 @@ Future<void> handleIntentSharedFile(
if (file.value?.startsWith('http') ?? false) { if (file.value?.startsWith('http') ?? false) {
final uri = Uri.parse(file.value!); final uri = Uri.parse(file.value!);
Log.info('Got link via handle intent share file: ${uri.scheme}'); Log.info('Got link via handle intent share file: ${uri.scheme}');
onUrlCallBack(uri); await onUrlCallBack(uri);
} }
case SharedMediaType.IMAGE: case SharedMediaType.IMAGE:
var type = MediaType.image; var type = MediaType.image;

View file

@ -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/services/key_verification.service.dart';
import 'package:twonly/src/utils/log.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 { class QrCodeUtils {
static String linkPrefix = 'https://me.twonly.eu/qr/#'; static String linkPrefix = 'https://me.twonly.eu/qr/#';
@ -45,8 +67,7 @@ class QrCodeUtils {
return link; return link;
} }
// returns: profile, NEW_USER=true/VERIFIED_USER=false, VERIFICATION_OK static Future<QrCodeLinkResult> handleQrCodeLink(
static Future<(PublicProfile, Contact?, bool)?> handleQrCodeLink(
String link, String link,
) async { ) async {
late PublicProfile profile; late PublicProfile profile;
@ -54,35 +75,53 @@ class QrCodeUtils {
try { try {
final bytes = base64Url.decode(link.replaceFirst(linkPrefix, '')); final bytes = base64Url.decode(link.replaceFirst(linkPrefix, ''));
final envelope = QREnvelope.fromBuffer(bytes); 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); profile = PublicProfile.fromBuffer(envelope.data);
} catch (e) { } catch (e) {
Log.error(e); Log.error(e);
return null; return const QrCodeLinkResult(status: QrCodeLinkStatus.invalid);
} }
try {
final contact = await twonlyDB.contactsDao.getContactById( final contact = await twonlyDB.contactsDao.getContactById(
profile.userId.toInt(), profile.userId.toInt(),
); );
if (contact == null) { if (profile.userId.toInt() == userService.currentUser.userId) {
if (profile.username == userService.currentUser.username) { return QrCodeLinkResult(
return null; status: QrCodeLinkStatus.ownProfile,
profile: profile,
);
} }
// NEW_USER
return (profile, null, false); if (contact == null) {
return QrCodeLinkResult(
status: QrCodeLinkStatus.newContact,
profile: profile,
);
} }
final storedPublicKey = await RustSignal.getContactPublicKey( final storedPublicKey = await RustSignal.getContactPublicKey(
contactId: contact.userId, contactId: contact.userId,
); );
if (storedPublicKey == null) return null; if (storedPublicKey == null) {
return QrCodeLinkResult(
final verificationOk = profile.publicIdentityKey.equals( status: QrCodeLinkStatus.missingSession,
storedPublicKey.toList(), 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(); var useSecretVerificationToken = profile.hasSecretVerificationToken();
if (profile.hasTimestamp()) { if (profile.hasTimestamp()) {
// Only notify the scanned user if the QR code was generated within the last 10 minutes. // Only notify the scanned user if the QR code was generated within the last 10 minutes.
@ -107,9 +146,18 @@ class QrCodeUtils {
contact.userId, contact.userId,
VerificationType.qrScanned, 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);
} }
} }

View file

@ -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<void> show(
BuildContext context, {
required String title,
required String message,
Contact? contact,
}) {
return showDialog<void>(
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<void> 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,
);
}

View file

@ -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/add_contact_dialog.comp.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/snackbar.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/components/verification_success_dialog.comp.dart';
import 'package:twonly/src/visual/helpers/screenshot.helper.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'; import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
@ -521,6 +522,7 @@ class MainCameraController {
Future<void> _processBarcode(InputImage inputImage) async { Future<void> _processBarcode(InputImage inputImage) async {
if (_isBusy) return; if (_isBusy) return;
_isBusy = true; _isBusy = true;
try {
final barcodes = await _barcodeScanner.processImage(inputImage); final barcodes = await _barcodeScanner.processImage(inputImage);
final controller = cameraController; final controller = cameraController;
if (inputImage.metadata?.size != null && if (inputImage.metadata?.size != null &&
@ -555,9 +557,9 @@ class MainCameraController {
if (_handledProfileLinks.contains(link)) continue; if (_handledProfileLinks.contains(link)) continue;
_handledProfileLinks.add(link); _handledProfileLinks.add(link);
final res = await QrCodeUtils.handleQrCodeLink(link); final result = await QrCodeUtils.handleQrCodeLink(link);
if (res == null) continue; final profile = result.profile;
final (profile, contact, verificationOk) = res; final contact = result.contact;
if (contact?.blocked ?? false) { if (contact?.blocked ?? false) {
await twonlyDB.contactsDao.updateContact( await twonlyDB.contactsDao.updateContact(
@ -566,9 +568,9 @@ class MainCameraController {
); );
} }
if (contact == null || contact.deletedByUser) { if (result.status == QrCodeLinkStatus.newContact) {
final context = cameraPreviewKey.currentContext; final context = cameraPreviewKey.currentContext;
if (context != null && context.mounted) { if (profile != null && context != null && context.mounted) {
unawaited(HapticFeedback.heavyImpact()); unawaited(HapticFeedback.heavyImpact());
final shouldRequest = await AddContactDialog.show( final shouldRequest = await AddContactDialog.show(
context, context,
@ -597,15 +599,24 @@ class MainCameraController {
continue; continue;
} }
if (contactsVerified[contact.userId] == null) { 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( contactsVerified[contact.userId] = ScannedVerifiedContact(
contact: contact, contact: contact,
verificationOk: verificationOk, verificationOk: true,
); );
unawaited(HapticFeedback.heavyImpact()); unawaited(HapticFeedback.heavyImpact());
final context = cameraPreviewKey.currentContext; final context = cameraPreviewKey.currentContext;
if (verificationOk && context != null && context.mounted) { if (context != null && context.mounted) {
await VerificationSuccessDialog.show(context, contact); await VerificationSuccessDialog.show(context, contact);
onVerificationSuccessDismissed?.call(contact); onVerificationSuccessDismissed?.call(contact);
} }
@ -625,9 +636,13 @@ class MainCameraController {
} }
} }
} }
} catch (e, stackTrace) {
Log.error('QR-code processing failed: $e\n$stackTrace');
} finally {
_isBusy = false; _isBusy = false;
setState?.call(); setState?.call();
} }
}
Future<void> _processFaces(InputImage inputImage) async { Future<void> _processFaces(InputImage inputImage) async {
if (_isBusyFaces) return; if (_isBusyFaces) return;

View file

@ -1,11 +1,6 @@
import 'dart:convert';
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';
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/model/protobuf/client/generated/qr.pb.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
@ -15,12 +10,10 @@ import 'package:twonly/src/visual/components/snackbar.dart';
class AddContactViaQrLinkView extends StatefulWidget { class AddContactViaQrLinkView extends StatefulWidget {
const AddContactViaQrLinkView({ const AddContactViaQrLinkView({
required this.profile, required this.profile,
this.qrCodeLink,
super.key, super.key,
}); });
final PublicProfile profile; final PublicProfile profile;
final String? qrCodeLink;
@override @override
State<AddContactViaQrLinkView> createState() => State<AddContactViaQrLinkView> createState() =>
@ -36,31 +29,15 @@ class _AddContactViaQrLinkViewState extends State<AddContactViaQrLinkView> {
}); });
try { try {
final userData = FrbUserData( final success = await addNewContactFromPublicProfile(widget.profile);
userId: widget.profile.userId.toInt(), if (!success) {
publicIdentityKey: Uint8List.fromList(widget.profile.publicIdentityKey), if (mounted) {
username: Uint8List.fromList(utf8.encode(widget.profile.username)), showSnackbar(
context,
context.lang.additionalUserAddError(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!);
} }
return;
} }
if (mounted) { if (mounted) {

View file

@ -4,6 +4,7 @@ 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';
import 'package:twonly/core/bridge/wrapper/signal.dart';
import 'package:twonly/locator.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/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
@ -38,6 +39,7 @@ class ContactView extends StatefulWidget {
class _ContactViewState extends State<ContactView> { class _ContactViewState extends State<ContactView> {
Contact? _contact; Contact? _contact;
List<GroupMember> _memberOfGroups = []; List<GroupMember> _memberOfGroups = [];
bool _isResettingSignalSession = false;
late StreamSubscription<Contact?> _streamContact; late StreamSubscription<Contact?> _streamContact;
late StreamSubscription<List<GroupMember>> _streamMemberOfGroups; late StreamSubscription<List<GroupMember>> _streamMemberOfGroups;
@ -138,6 +140,34 @@ class _ContactViewState extends State<ContactView> {
} }
} }
Future<void> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_contact == null) return Container(); if (_contact == null) return Container();
@ -285,6 +315,19 @@ class _ContactViewState extends State<ContactView> {
text: context.lang.contactBlock, text: context.lang.contactBlock,
onTap: () => handleUserBlockRequest(contact), 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( BetterListTile(
icon: FontAwesomeIcons.userMinus, icon: FontAwesomeIcons.userMinus,
iconSize: 16, iconSize: 16,

View file

@ -76,6 +76,7 @@ class _VerificationExpansionTileCompState
getContactDisplayName(widget.contact), getContactDisplayName(widget.contact),
), ),
VerificationType.link => context.lang.verificationTypeLink, VerificationType.link => context.lang.verificationTypeLink,
VerificationType.manual => context.lang.verificationTypeManual,
VerificationType.contactSharedByVerified => VerificationType.contactSharedByVerified =>
verifier != null verifier != null
? context.lang.contactVerifiedBy(getContactDisplayName(verifier)) ? context.lang.contactVerifiedBy(getContactDisplayName(verifier))

View file

@ -1,7 +1,11 @@
import 'package:flutter/material.dart'; 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/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/verification_badge_info.comp.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 { class VerificationBadeFaqView extends StatefulWidget {
const VerificationBadeFaqView({super.key, this.contact}); const VerificationBadeFaqView({super.key, this.contact});
@ -27,6 +31,31 @@ class _VerificationBadeFaqViewState extends State<VerificationBadeFaqView> {
displayButtons: true, displayButtons: true,
contact: widget.contact, 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),
),
],
),
),
),
),
],
], ],
), ),
); );

View file

@ -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<ManualVerificationView> createState() => _ManualVerificationViewState();
}
class _ManualVerificationViewState extends State<ManualVerificationView> {
late Future<String?> _safetyNumber;
bool _isSaving = false;
@override
void initState() {
super.initState();
_loadSafetyNumber();
}
void _loadSafetyNumber() {
_safetyNumber = RustSignal.getSafetyNumber(
contactId: widget.contact.userId,
);
}
Future<void> _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<String?>(
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 = <String>[
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),
),
],
),
),
),
);
}
}

View file

@ -4,8 +4,11 @@
*/ */
use crate::bridge::get_twonly_flutter; use crate::bridge::get_twonly_flutter;
use crate::context::Context;
use crate::error::{Result, TwonlyError}; use crate::error::{Result, TwonlyError};
use crate::services::contacts::ContactService;
use crate::signal::engine::{FrbPqcPreKey, FrbPreKeyBundle}; use crate::signal::engine::{FrbPqcPreKey, FrbPreKeyBundle};
use crate::signal::reset::SessionResetLimiter;
pub struct RustSignal {} pub struct RustSignal {}
@ -59,4 +62,34 @@ impl RustSignal {
.get_contact_identity_key(&contact_id.to_string()) .get_contact_identity_key(&contact_id.to_string())
.await .await
} }
pub async fn get_safety_number(contact_id: i64) -> Result<Option<String>> {
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
}
} }

View file

@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi, default_rust_auto_opaque = RustAutoOpaqueMoi,
); );
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; 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 // 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::SseCodec, _, _, _>(
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 = <i64>::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( fn wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, 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::SseCodec, _, _, _>(
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 = <i64>::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( fn wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, 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), 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), 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), 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), 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_process_prekey_bundle_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__rust_utils_generate_shares_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__rust_utils_recover_secret_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__webxdc__send_update_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__webxdc__serve_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__groups__update_chat_deletion_time_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__groups__update_group_name_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__webxdc__updates_after_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),
169 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), 168 => wire__crate__bridge__groups__update_group_name_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), 169 => wire__crate__bridge__webxdc__updates_after_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), 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_save_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_update_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!(), _ => unreachable!(),
} }
} }
@ -8013,12 +8095,12 @@ fn pde_ffi_dispatcher_sync_impl(
51 => { 51 => {
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len) 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, ptr,
rust_vec_len, rust_vec_len,
data_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!(), _ => unreachable!(),
} }
} }

View file

@ -8,10 +8,10 @@ use crate::error::{Result, TwonlyError};
use crate::user_config::UserConfig; use crate::user_config::UserConfig;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use libsignal_protocol::{ use libsignal_protocol::{
message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, GenericSignedPreKey, message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, Fingerprint,
IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId, KyberPreKeyStore, PreKeyBundle, GenericSignedPreKey, IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId,
PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, PublicKey, SessionStore, KyberPreKeyStore, PreKeyBundle, PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress,
SignalMessage, SignedPreKeyId, SignedPreKeyStore, Timestamp, PublicKey, SessionStore, SignalMessage, SignedPreKeyId, SignedPreKeyStore, Timestamp,
}; };
use std::sync::Arc; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@ -50,6 +50,29 @@ pub struct FrbPqcPreKey {
pub kyber_pre_key_signature: Vec<u8>, pub kyber_pre_key_signature: Vec<u8>,
} }
fn create_safety_number(
local_id: &str,
local_key_bytes: &[u8],
remote_id: &str,
remote_key_bytes: &[u8],
) -> Result<String> {
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 { impl RustSignalEngine {
pub async fn on_connected(&self, ctx: &Arc<crate::context::Context>) -> Result<()> { pub async fn on_connected(&self, ctx: &Arc<crate::context::Context>) -> Result<()> {
let base = UserConfig::load_required_from(ctx)?; let base = UserConfig::load_required_from(ctx)?;
@ -165,6 +188,26 @@ impl RustSignalEngine {
Ok(identity) Ok(identity)
} }
pub async fn get_safety_number(&self, contact_id: i64) -> Result<Option<String>> {
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)] #[cfg(test)]
fn generate_identity_key_pair() -> Result<Vec<u8>> { fn generate_identity_key_pair() -> Result<Vec<u8>> {
let mut csprng = rand::rngs::StdRng::from_os_rng(); let mut csprng = rand::rngs::StdRng::from_os_rng();
@ -678,6 +721,29 @@ mod tests {
use rand::rngs::StdRng; use rand::rngs::StdRng;
use tempfile::tempdir; 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) { async fn create_test_engine(name: &str) -> (RustSignalEngine, tempfile::TempDir) {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let db_path = dir.path().join(format!("{}.db", name)); let db_path = dir.path().join(format!("{}.db", name));

View file

@ -160,6 +160,16 @@ void main() {
expect(await twonlyDB.keyVerificationDao.isContactVerified(10), true); 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( test(
'isContactVerified returns false when no verification exists', 'isContactVerified returns false when no verification exists',
() async { () async {

View file

@ -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<void>.value();
callbackFinished = true;
},
(_, _) {},
);
expect(receivedUri, Uri.parse('https://me.twonly.eu/alice#public-key'));
expect(callbackFinished, isTrue);
});
}