diff --git a/.gitignore b/.gitignore index 10e2410d..cac9d13e 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,5 @@ devtools_options.yaml rust/target rust_dependencies/target fastlane/repo/status/running.json -.cache/ \ No newline at end of file +.cache/ +APKS/*.apk diff --git a/.gitmodules b/.gitmodules index 849ee556..a6249d58 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,7 +3,3 @@ path = dependencies # url = ssh://git@git.twonly.eu:22222/twonly/twonly-app-dependencies.git url = https://git.twonly.eu/twonly/twonly-app-dependencies.git -[submodule "lib/src/localization/translations"] - path = lib/src/localization/translations - # url = ssh://git@git.twonly.eu:22222/twonly/twonly-translations.git - url = https://git.twonly.eu/twonly/twonly-translations diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f375c1c..d97874aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.5.3 + +- Hotfix: Fixed stuck Signal sessions +- Improve: Gracefully upgrade all sessions to PQXDH + ## 0.5.2 - Hotfix: Fixed an infinite loop involving delivery receipts diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a5a88ff..881ef11f 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" + "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", + "version" : "11.3.2" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk", "state" : { - "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", - "version" : "12.14.0" + "revision" : "346daa9f46316aa372b35b317e18224acc2e9063", + "version" : "12.18.0" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", - "version" : "12.14.0" + "revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5", + "version" : "12.18.0" } }, { diff --git a/ios/build/.last_build_id b/ios/build/.last_build_id deleted file mode 100644 index 7769d20e..00000000 --- a/ios/build/.last_build_id +++ /dev/null @@ -1 +0,0 @@ -ce49e7d90cd902197f9a9cbc84219d23 \ No newline at end of file diff --git a/lib/core/bridge/wrapper/signal.dart b/lib/core/bridge/wrapper/signal.dart index 6aaf439a..c659e702 100644 --- a/lib/core/bridge/wrapper/signal.dart +++ b/lib/core/bridge/wrapper/signal.dart @@ -47,6 +47,14 @@ class RustSignal { bundle: bundle, ); + /// Removes the current v2 session for a contact. The Dart hotfix layer + /// immediately establishes its replacement from a freshly fetched bundle. + static Future resetContactSession({required PlatformInt64 contactId}) => + RustLib.instance.api + .crateBridgeWrapperSignalRustSignalResetContactSession( + contactId: contactId, + ); + @override int get hashCode => 0; diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index 67e16043..35bdf234 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -78,7 +78,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 1109927244; + int get rustContentHash => -2042422055; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -284,6 +284,10 @@ abstract class RustLibApi extends BaseApi { required FrbPreKeyBundle bundle, }); + Future crateBridgeWrapperSignalRustSignalResetContactSession({ + required PlatformInt64 contactId, + }); + Future> crateBridgeWrapperRustUtilsGenerateShares({ required List secret, required int total, @@ -1853,6 +1857,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["name", "deviceId", "bundle"], ); + @override + Future crateBridgeWrapperSignalRustSignalResetContactSession({ + required PlatformInt64 contactId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_i_64(contactId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 37, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateBridgeWrapperSignalRustSignalResetContactSessionConstMeta, + argValues: [contactId], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateBridgeWrapperSignalRustSignalResetContactSessionConstMeta => + const TaskConstMeta( + debugName: "rust_signal_reset_contact_session", + argNames: ["contactId"], + ); + @override Future> crateBridgeWrapperRustUtilsGenerateShares({ required List secret, @@ -1869,7 +1908,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 37, + funcId: 38, port: port_, ); }, @@ -1904,7 +1943,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 38, + funcId: 39, port: port_, ); }, @@ -1940,7 +1979,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 40, port: port_, ); }, @@ -1977,7 +2016,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 41, port: port_, ); }, @@ -2015,7 +2054,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 42, port: port_, ); }, @@ -2053,7 +2092,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 43, port: port_, ); }, @@ -2091,7 +2130,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 44, port: port_, ); }, @@ -2130,7 +2169,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 45, port: port_, ); }, @@ -2169,7 +2208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 46, port: port_, ); }, @@ -2214,7 +2253,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 47, port: port_, ); }, @@ -2266,7 +2305,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 48, port: port_, ); }, @@ -2307,7 +2346,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 49, port: port_, ); }, @@ -2345,7 +2384,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 50, port: port_, ); }, @@ -2383,7 +2422,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 50, + funcId: 51, port: port_, ); }, @@ -2421,7 +2460,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 51, + funcId: 52, port: port_, ); }, @@ -2459,7 +2498,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 53, port: port_, ); }, @@ -2501,7 +2540,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 54, port: port_, ); }, @@ -2541,7 +2580,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 55, port: port_, ); }, diff --git a/lib/src/database/daos/receipts.dao.dart b/lib/src/database/daos/receipts.dao.dart index daa68a97..189a89e4 100644 --- a/lib/src/database/daos/receipts.dao.dart +++ b/lib/src/database/daos/receipts.dao.dart @@ -150,6 +150,43 @@ class ReceiptsDao extends DatabaseAccessor with _$ReceiptsDaoMixin { .get(); } + /// Finds contacts for which repeated, unacknowledged message delivery is a + /// strong indication that only the outgoing Signal ratchet is broken. + Future> getContactsWithStuckSignalMessages({ + required int retryThreshold, + int minimumMessages = 2, + }) async { + final messageCount = countAll(); + final query = selectOnly(receipts) + ..addColumns([receipts.contactId, messageCount]) + ..where( + receipts.retryCount.isBiggerThanValue(retryThreshold) & + receipts.willBeRetriedByMediaUpload.equals(false), + ) + ..groupBy([receipts.contactId]); + + final rows = await query.get(); + return rows + .where((row) => (row.read(messageCount) ?? 0) >= minimumMessages) + .map((row) => row.read(receipts.contactId)!) + .toList(); + } + + /// Starts retry accounting again after establishing a fresh Signal session. + Future resetMessageRetryCountsForContact(int contactId) async { + await (update(receipts)..where( + (receipt) => + receipt.contactId.equals(contactId) & + receipt.willBeRetriedByMediaUpload.equals(false), + )) + .write( + const ReceiptsCompanion( + retryCount: Value(0), + lastRetry: Value(null), + ), + ); + } + Future> getReceiptsForMediaRetransmissions() async { final markedRetriesTime = clock.now().subtract( const Duration( diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 1f0b67fc..0ac28adc 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -635,13 +635,13 @@ abstract class AppLocalizations { /// No description provided for @settingsNotifyResetTitle. /// /// In en, this message translates to: - /// **'Didn\'t receive a test notification?'** + /// **'Reset notification tokens'** String get settingsNotifyResetTitle; /// No description provided for @settingsNotifyResetTitleSubtitle. /// /// In en, this message translates to: - /// **'If you haven\'t received any test notifications, click here to reset your notification tokens.'** + /// **'Reset your notification tokens if you have problems receiving push notifications.'** String get settingsNotifyResetTitleSubtitle; /// No description provided for @settingsNotifyResetTitleReset. @@ -956,6 +956,42 @@ abstract class AppLocalizations { /// **'Block'** String get contactBlock; + /// No description provided for @contactTroubleshoot. + /// + /// In en, this message translates to: + /// **'Troubleshoot connection'** + String get contactTroubleshoot; + + /// No description provided for @contactResetSessionTitle. + /// + /// In en, this message translates to: + /// **'Reset connection with {username}?'** + String contactResetSessionTitle(String username); + + /// No description provided for @contactResetSessionBody. + /// + /// In en, this message translates to: + /// **'twonly will remove the current encrypted connection and establish a new one. Messages currently in transit may be lost.'** + String get contactResetSessionBody; + + /// No description provided for @contactResetSessionConfirm. + /// + /// In en, this message translates to: + /// **'Reset connection'** + String get contactResetSessionConfirm; + + /// No description provided for @contactResetSessionSuccess. + /// + /// In en, this message translates to: + /// **'The encrypted connection with {username} was re-established.'** + String contactResetSessionSuccess(String username); + + /// No description provided for @contactResetSessionFailed. + /// + /// In en, this message translates to: + /// **'The connection could not be reset. Check your internet connection and try again.'** + String get contactResetSessionFailed; + /// No description provided for @contactBlockTitle. /// /// In en, this message translates to: @@ -1226,6 +1262,18 @@ abstract class AppLocalizations { /// **'You are part of the paid plan of {username}!'** String partOfPaidPlanOf(Object username); + /// No description provided for @partOfPaidPlanFrom. + /// + /// In en, this message translates to: + /// **'Part of the paid plan of'** + String get partOfPaidPlanFrom; + + /// No description provided for @subscriptionCurrentPlanBadge. + /// + /// In en, this message translates to: + /// **'Current'** + String get subscriptionCurrentPlanBadge; + /// No description provided for @year. /// /// In en, this message translates to: @@ -1253,67 +1301,67 @@ abstract class AppLocalizations { /// No description provided for @proFeature1. /// /// In en, this message translates to: - /// **'✓ Unlimited media file uploads'** + /// **'Unlimited media file uploads'** String get proFeature1; /// No description provided for @proFeature2. /// /// In en, this message translates to: - /// **'✓ 1 additional Plus user'** + /// **'1 additional Plus user'** String get proFeature2; /// No description provided for @proFeature3. /// /// In en, this message translates to: - /// **'✓ 25GB Memories storage'** + /// **'25GB Memories storage'** String get proFeature3; /// No description provided for @proFeature4. /// /// In en, this message translates to: - /// **'✓ Restore flames'** + /// **'Restore flames'** String get proFeature4; /// No description provided for @familyFeature1. /// /// In en, this message translates to: - /// **'✓ Unlimited media file uploads'** + /// **'Unlimited media file uploads'** String get familyFeature1; /// No description provided for @familyFeature2. /// /// In en, this message translates to: - /// **'✓ 4 additional Plus user'** + /// **'4 additional Plus user'** String get familyFeature2; /// No description provided for @familyFeature3. /// /// In en, this message translates to: - /// **'✓ 50GB Memories storage'** + /// **'50GB Memories storage'** String get familyFeature3; /// No description provided for @familyFeature4. /// /// In en, this message translates to: - /// **'✓ Support twonly'** + /// **'Restore flames'** String get familyFeature4; /// No description provided for @freeFeature1. /// /// In en, this message translates to: - /// **'✓ 10 Media file uploads per day'** + /// **'10 Media file uploads per day'** String get freeFeature1; /// No description provided for @plusFeature1. /// /// In en, this message translates to: - /// **'✓ Unlimited media file uploads'** + /// **'Unlimited media file uploads'** String get plusFeature1; /// No description provided for @plusFeature2. /// /// In en, this message translates to: - /// **'✓ Additional features (coming-soon)'** + /// **'Additional features (coming-soon)'** String get plusFeature2; /// No description provided for @manageAdditionalUsers. @@ -1838,6 +1886,12 @@ abstract class AppLocalizations { /// **'Edit history'** String get editHistory; + /// No description provided for @fileSize. + /// + /// In en, this message translates to: + /// **'File size'** + String get fileSize; + /// No description provided for @archivedChats. /// /// In en, this message translates to: @@ -2228,6 +2282,12 @@ abstract class AppLocalizations { /// **'Messages from other users.'** String get notificationCategoryMessageDesc; + /// No description provided for @notificationConnectionFallback. + /// + /// In en, this message translates to: + /// **'You may have new messages.'** + String get notificationConnectionFallback; + /// No description provided for @groupContextMenuDeleteGroup. /// /// In en, this message translates to: @@ -4537,6 +4597,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Exporting memories...'** String get memoriesExportingProgress; + + /// No description provided for @fileLimitReachedTitle. + /// + /// In en, this message translates to: + /// **'File too large to send'** + String get fileLimitReachedTitle; + + /// No description provided for @fileLimitReachedDetail. + /// + /// In en, this message translates to: + /// **'This file is {size}, but your plan allows at most {limit} per send.'** + String fileLimitReachedDetail(String size, String limit); + + /// No description provided for @fileLimitReachedDetailNoSize. + /// + /// In en, this message translates to: + /// **'Your plan allows at most {limit} per send.'** + String fileLimitReachedDetailNoSize(String limit); + + /// No description provided for @fileLimitReachedHint. + /// + /// In en, this message translates to: + /// **'Record a shorter video and send it again.'** + String get fileLimitReachedHint; + + /// No description provided for @fileLimitReachedHintFree. + /// + /// In en, this message translates to: + /// **'Record a shorter video, or upgrade your plan to send larger files.'** + String get fileLimitReachedHintFree; + + /// No description provided for @fileLimitReachedUpgrade. + /// + /// In en, this message translates to: + /// **'Upgrade plan'** + String get fileLimitReachedUpgrade; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 3a8fb0db..eefb8b3f 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -294,11 +294,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Um eine Testbenachrichtigung zu erhalten, klicke auf OK. Falls du die Testbenachrichtigung nicht erhältst, klicke bitte auf den neuen Menüpunkt, der nach dem Klicken auf „OK“ angezeigt wird.'; @override - String get settingsNotifyResetTitle => 'Keine Testbenachrichtigung erhalten?'; + String get settingsNotifyResetTitle => 'Benachrichtigungstoken zurücksetzen'; @override String get settingsNotifyResetTitleSubtitle => - 'Falls du keine Testbenachrichtigungen erhalten hast, klicke hier, um deine Benachrichtigungstoken zurückzusetzen.'; + 'Setze deine Benachrichtigungstoken zurück, wenn du Probleme beim Empfang von Push-Benachrichtigungen hast.'; @override String get settingsNotifyResetTitleReset => @@ -475,6 +475,30 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contactBlock => 'Blockieren'; + @override + String get contactTroubleshoot => 'Verbindungsprobleme beheben'; + + @override + String contactResetSessionTitle(String username) { + return 'Verbindung zu $username zurücksetzen?'; + } + + @override + String get contactResetSessionBody => + 'twonly löscht die aktuelle verschlüsselte Verbindung und baut sie anschließend neu auf. Nachrichten, die gerade übertragen werden, können dabei verloren gehen.'; + + @override + String get contactResetSessionConfirm => 'Verbindung zurücksetzen'; + + @override + String contactResetSessionSuccess(String username) { + return 'Die verschlüsselte Verbindung zu $username wurde neu aufgebaut.'; + } + + @override + String get contactResetSessionFailed => + 'Die Verbindung konnte nicht zurückgesetzt werden. Prüfe deine Internetverbindung und versuche es erneut.'; + @override String contactBlockTitle(Object username) { return 'Blockiere $username'; @@ -633,6 +657,12 @@ class AppLocalizationsDe extends AppLocalizations { return 'Du bist Teil des bezahlten Plans von $username!'; } + @override + String get partOfPaidPlanFrom => 'Teil des bezahlten Plans von'; + + @override + String get subscriptionCurrentPlanBadge => 'Aktuell'; + @override String get year => 'Jahr'; @@ -646,37 +676,37 @@ class AppLocalizationsDe extends AppLocalizations { String get monthly => 'Monatlich'; @override - String get proFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads'; + String get proFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; @override - String get proFeature2 => '✓ 1 zusätzlicher Plus Benutzer'; + String get proFeature2 => '1 zusätzlicher Plus Benutzer'; @override - String get proFeature3 => '✓ 25GB Memories Speicher'; + String get proFeature3 => '25GB Memories Speicher'; @override - String get proFeature4 => '✓ Flammen wiederherstellen'; + String get proFeature4 => 'Flammen wiederherstellen'; @override - String get familyFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads'; + String get familyFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; @override - String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer'; + String get familyFeature2 => '4 zusätzliche Plus Benutzer'; @override - String get familyFeature3 => '✓ 50GB Memories Speicher'; + String get familyFeature3 => '50GB Memories Speicher'; @override - String get familyFeature4 => '✓ Flammen wiederherstellen'; + String get familyFeature4 => 'Flammen wiederherstellen'; @override - String get freeFeature1 => '✓ 10 Medien-Datei-Uploads pro Tag'; + String get freeFeature1 => '10 Medien-Datei-Uploads pro Tag'; @override - String get plusFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads'; + String get plusFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; @override - String get plusFeature2 => '✓ Zusatzfunktionen (coming-soon)'; + String get plusFeature2 => 'Zusatzfunktionen (coming-soon)'; @override String get manageAdditionalUsers => 'Zusätzliche Benutzer verwalten'; @@ -983,6 +1013,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get editHistory => 'Bearbeitungshistorie'; + @override + String get fileSize => 'Dateigröße'; + @override String get archivedChats => 'Archivierte Chats'; @@ -1241,6 +1274,10 @@ class AppLocalizationsDe extends AppLocalizations { String get notificationCategoryMessageDesc => 'Nachrichten von anderen Benutzern.'; + @override + String get notificationConnectionFallback => + 'Du könntest neue Nachrichten haben.'; + @override String get groupContextMenuDeleteGroup => 'Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.'; @@ -2622,4 +2659,28 @@ class AppLocalizationsDe extends AppLocalizations { @override String get memoriesExportingProgress => 'Erinnerungen werden exportiert...'; + + @override + String get fileLimitReachedTitle => 'Datei zu groß zum Senden'; + + @override + String fileLimitReachedDetail(String size, String limit) { + return 'Diese Datei ist $size groß, dein Tarif erlaubt aber höchstens $limit pro Sendung.'; + } + + @override + String fileLimitReachedDetailNoSize(String limit) { + return 'Dein Tarif erlaubt höchstens $limit pro Sendung.'; + } + + @override + String get fileLimitReachedHint => + 'Nimm ein kürzeres Video auf und sende es erneut.'; + + @override + String get fileLimitReachedHintFree => + 'Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.'; + + @override + String get fileLimitReachedUpgrade => 'Tarif wechseln'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 90201697..b4737f77 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -290,11 +290,11 @@ class AppLocalizationsEn extends AppLocalizations { 'Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.'; @override - String get settingsNotifyResetTitle => 'Didn\'t receive a test notification?'; + String get settingsNotifyResetTitle => 'Reset notification tokens'; @override String get settingsNotifyResetTitleSubtitle => - 'If you haven\'t received any test notifications, click here to reset your notification tokens.'; + 'Reset your notification tokens if you have problems receiving push notifications.'; @override String get settingsNotifyResetTitleReset => @@ -471,6 +471,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contactBlock => 'Block'; + @override + String get contactTroubleshoot => 'Troubleshoot connection'; + + @override + String contactResetSessionTitle(String username) { + return 'Reset connection with $username?'; + } + + @override + String get contactResetSessionBody => + 'twonly will remove the current encrypted connection and establish a new one. Messages currently in transit may be lost.'; + + @override + String get contactResetSessionConfirm => 'Reset connection'; + + @override + String contactResetSessionSuccess(String username) { + return 'The encrypted connection with $username was re-established.'; + } + + @override + String get contactResetSessionFailed => + 'The connection could not be reset. Check your internet connection and try again.'; + @override String contactBlockTitle(Object username) { return 'Block $username'; @@ -628,6 +652,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'You are part of the paid plan of $username!'; } + @override + String get partOfPaidPlanFrom => 'Part of the paid plan of'; + + @override + String get subscriptionCurrentPlanBadge => 'Current'; + @override String get year => 'year'; @@ -641,37 +671,37 @@ class AppLocalizationsEn extends AppLocalizations { String get monthly => 'Monthly'; @override - String get proFeature1 => '✓ Unlimited media file uploads'; + String get proFeature1 => 'Unlimited media file uploads'; @override - String get proFeature2 => '✓ 1 additional Plus user'; + String get proFeature2 => '1 additional Plus user'; @override - String get proFeature3 => '✓ 25GB Memories storage'; + String get proFeature3 => '25GB Memories storage'; @override - String get proFeature4 => '✓ Restore flames'; + String get proFeature4 => 'Restore flames'; @override - String get familyFeature1 => '✓ Unlimited media file uploads'; + String get familyFeature1 => 'Unlimited media file uploads'; @override - String get familyFeature2 => '✓ 4 additional Plus user'; + String get familyFeature2 => '4 additional Plus user'; @override - String get familyFeature3 => '✓ 50GB Memories storage'; + String get familyFeature3 => '50GB Memories storage'; @override - String get familyFeature4 => '✓ Support twonly'; + String get familyFeature4 => 'Restore flames'; @override - String get freeFeature1 => '✓ 10 Media file uploads per day'; + String get freeFeature1 => '10 Media file uploads per day'; @override - String get plusFeature1 => '✓ Unlimited media file uploads'; + String get plusFeature1 => 'Unlimited media file uploads'; @override - String get plusFeature2 => '✓ Additional features (coming-soon)'; + String get plusFeature2 => 'Additional features (coming-soon)'; @override String get manageAdditionalUsers => 'Manage additional users'; @@ -976,6 +1006,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editHistory => 'Edit history'; + @override + String get fileSize => 'File size'; + @override String get archivedChats => 'Archived chats'; @@ -1233,6 +1266,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationCategoryMessageDesc => 'Messages from other users.'; + @override + String get notificationConnectionFallback => 'You may have new messages.'; + @override String get groupContextMenuDeleteGroup => 'This will permanently delete all messages in this chat.'; @@ -2597,4 +2633,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get memoriesExportingProgress => 'Exporting memories...'; + + @override + String get fileLimitReachedTitle => 'File too large to send'; + + @override + String fileLimitReachedDetail(String size, String limit) { + return 'This file is $size, but your plan allows at most $limit per send.'; + } + + @override + String fileLimitReachedDetailNoSize(String limit) { + return 'Your plan allows at most $limit per send.'; + } + + @override + String get fileLimitReachedHint => + 'Record a shorter video and send it again.'; + + @override + String get fileLimitReachedHintFree => + 'Record a shorter video, or upgrade your plan to send larger files.'; + + @override + String get fileLimitReachedUpgrade => 'Upgrade plan'; } diff --git a/lib/src/localization/translations b/lib/src/localization/translations deleted file mode 160000 index 50cc5cb6..00000000 --- a/lib/src/localization/translations +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 50cc5cb69dd91a5c626534fe4c015e50d2a0a486 diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb new file mode 100644 index 00000000..7c9a6c45 --- /dev/null +++ b/lib/src/localization/translations/de.arb @@ -0,0 +1,1017 @@ +{ + "@@locale": "de", + "registerSlogan": "Privat in Kontakt bleiben.", + "onboardingWelcomeTitle": "Willkommen bei twonly!", + "onboardingWelcomeBody": "Erlebe eine private und sichere Möglichkeit mit Freunden in Kontakt zu bleiben, indem du spontane Bilder teilst.", + "onboardingE2eTitle": "Unbekümmert teilen", + "onboardingE2eBody": "Genieße durch die Ende-zu-Ende-Verschlüsselung die Gewissheit, dass nur du und deine Freunde die geteilten Momente sehen können.", + "onboardingFocusTitle": "Fokussiere dich auf das Teilen von Momenten", + "onboardingFocusBody": "Verabschiede dich von süchtig machenden Funktionen! twonly wurde für das Teilen von Momenten ohne nutzlose Ablenkungen oder Werbung entwickelt.", + "onboardingNotProductTitle": "Du bist nicht das Produkt!", + "onboardingNotProductBody": "twonly wird durch ein optionales Abonnement finanziert. Deine Daten werden niemals verkauft.", + "registerUsernameSlogan": "Konto erstellen", + "registerUsernameDecoration": "Benutzername", + "registerUsernameLimits": "Mindestens 3 Zeichen.", + "registerProofOfWorkFailed": "Beim Captcha-Test gab es ein Problem. Bitte versuche es erneut.", + "registerSubmitButton": "Jetzt registrieren!", + "chatsTapToSend": "Klicke, um dein erstes Bild zu teilen.", + "cameraPreviewSendTo": "Senden an", + "shareImageTitle": "Teilen mit", + "shareImageBestFriends": "Beste Freunde", + "shareImagePinnedContacts": "Angeheftet", + "shareImagedEditorSendImage": "Senden", + "shareImagedEditorShareWith": "Teilen mit", + "shareImagedEditorSaveImage": "Speichern", + "shareImagedEditorSavedImage": "Gespeichert", + "shareImagedSelectAll": "Alle auswählen", + "shareImageAllUsers": "Alle Kontakte", + "shareImageSearchAllContacts": "Alle Kontakte durchsuchen", + "shareImageShowArchived": "Archivierte Benutzer anzeigen", + "startNewChatSearchHint": "Name, Benutzername oder Gruppenname", + "searchUsernameInput": "Benutzername", + "addFriendTitle": "Freunde hinzufügen", + "searchUsernameNotFound": "Benutzername nicht gefunden", + "searchUsernameNewFollowerTitle": "Offene Anfragen", + "searchUserNamePending": "Anfrage ausstehend", + "userFound": "{username} gefunden", + "userFoundBody": "Möchtest du dich mit {username} vernetzen?", + "@userFoundBody": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "chatListDetailInput": "Nachricht eingeben", + "userDeletedAccount": "Der Nutzer hat deren Konto gelöscht.", + "contextMenuUserProfile": "Userprofil", + "contextMenuArchiveUser": "Archivieren", + "contextMenuUndoArchiveUser": "Archivierung aufheben", + "startNewChatTitle": "Kontakt wählen", + "startNewChatNewContact": "Neuer Kontakt", + "contextMenuOpenChat": "Chat", + "contextMenuPin": "Anheften", + "contextMenuUnpin": "Lösen", + "contextMenuViewAgain": "Nochmal anschauen", + "mediaViewerAuthReason": "Bitte authentifiziere dich, um diesen twonly zu sehen!", + "mediaViewerTwonlyTapToOpen": "Tippe um den twonly zu öffnen!", + "messageSendState_Received": "Empfangen", + "messageSendState_Opened": "Geöffnet", + "messageSendState_Send": "Gesendet", + "messageSendState_Sending": "Wird gesendet", + "messageSendState_TapToLoad": "Tippe zum Laden", + "messageSendState_Loading": "Herunterladen", + "messageStoredInGallery": "Gespeichert", + "messageReopened": "Erneut geöffnet", + "imageEditorDrawOk": "Zeichnung machen", + "settingsTitle": "Einstellungen", + "settingsChats": "Chats", + "settingsStorageData": "Daten und Speicher", + "settingsStorageDataStoreInGTitle": "In der Galerie speichern", + "settingsStorageDataMediaAutoDownload": "Automatischer Mediendownload", + "settingsStorageDataAutoDownMobile": "Bei Nutzung mobiler Daten", + "settingsStorageDataAutoDownWifi": "Bei Nutzung von WLAN", + "settingsStorageManageTitle": "Speicher verwalten", + "settingsStorageUsed": "Speicherplatz belegt", + "settingsStorageImages": "Bilder", + "settingsStorageVideos": "Videos", + "settingsStorageGifs": "GIFs", + "settingsPreSelectedReactions": "Vorgewählte Reaktions-Emojis", + "settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle": "Duplikate als geöffnet markieren", + "settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle": "Wenn du die selbe Mediendatei in mehreren Chats erhältst, markiert das Öffnen einer Kopie alle anderen als geöffnet.", + "settingsPreSelectedReactionsError": "Es können maximal 12 Reaktionen ausgewählt werden.", + "settingsProfile": "Profil", + "settingsProfileCustomizeAvatar": "Avatar anpassen", + "settingsProfileEditDisplayName": "Anzeigename", + "settingsProfileEditDisplayNameNew": "Neuer Anzeigename", + "settingsAccount": "Konto", + "settingsSubscription": "Abonnement", + "settingsAppearance": "Erscheinungsbild", + "settingsPrivacy": "Datenschutz & Sicherheit", + "settingsPrivacyBlockUsers": "Kontakte blockieren", + "settingsPrivacyBlockUsersDesc": "Blockierte Kontakte können nicht mit dir kommunizieren. Du kannst einen blockierten Kontakt jederzeit wieder entsperren.", + "settingsPrivacyBlockUsersCount": "{len} Kontakt(e)", + "settingsNotification": "Benachrichtigung", + "settingsNotifyPermission": "Benachrichtigungsberechtigung", + "settingsNotifyPermissionDesc": "Systemeinstellungen öffnen, um Push-Benachrichtigungen zu erlauben.", + "settingsNotifyTroubleshooting": "Fehlersuche", + "settingsNotifyTroubleshootingDesc": "Hier klicken, wenn Probleme beim Empfang von Push-Benachrichtigungen auftreten.", + "settingsNotifyTroubleshootingNoProblem": "Kein Problem festgestellt", + "settingsNotifyTroubleshootingNoProblemDesc": "Um eine Testbenachrichtigung zu erhalten, klicke auf OK. Falls du die Testbenachrichtigung nicht erhältst, klicke bitte auf den neuen Menüpunkt, der nach dem Klicken auf „OK“ angezeigt wird.", + "settingsNotifyResetTitle": "Benachrichtigungstoken zurücksetzen", + "settingsNotifyResetTitleSubtitle": "Setze deine Benachrichtigungstoken zurück, wenn du Probleme beim Empfang von Push-Benachrichtigungen hast.", + "settingsNotifyResetTitleReset": "Deine Benachrichtigungstoken wurden zurückgesetzt.", + "settingsNotifyResetTitleResetDesc": "Sollte das Problem weiterhin bestehen, sende uns bitte dein Debug-Protokoll über „Einstellungen“ > „Hilfe“, damit wir das Problem untersuchen können.", + "settingsHelp": "Hilfe", + "settingsHelpFAQ": "FAQ", + "feedbackTooltip": "Feedback zur Verbesserung von twonly geben.", + "settingsHelpContactUs": "Kontaktiere uns", + "contactUsFaq": "FAQ schon gelesen?", + "contactUsEmojis": "Wie fühlst du dich? (optional)", + "contactUsSelectOption": "Bitte wähle eine Option", + "contactUsReason": "Sag uns, warum du uns kontaktierst", + "contactUsMessage": "Wenn du eine Antwort erhalten möchtest, füge bitte deine E-Mail-Adresse hinzu, damit wir dich kontaktieren können.", + "contactUsYourMessage": "Deine Nachricht", + "contactUsMessageTitle": "Erzähl uns, was los ist", + "contactUsReasonNotWorking": "Etwas funktioniert nicht", + "contactUsReasonFeatureRequest": "Funktionsanfrage", + "contactUsReasonQuestion": "Frage", + "contactUsReasonFeedback": "Feedback", + "contactUsReasonOther": "Sonstiges", + "contactUsIncludeLog": "Debug-Protokoll anhängen.", + "contactUsWhatsThat": "Was ist das?", + "contactUsLastWarning": "Dies sind die Informationen, die an uns gesendet werden. Bitte prüfe sie und klicke dann auf „Abschicken“.", + "contactUsSuccess": "Feedback erfolgreich übermittelt!", + "hideNewsIcon": "Neuigkeiten-Symbol ausblenden", + "settingsHelpDiagnostics": "Diagnoseprotokoll", + "settingsHelpVersion": "Version", + "settingsHelpLicenses": "Lizenzen (Source-Code)", + "settingsHelpCredits": "Lizenzen (Bilder)", + "settingsHelpImprint": "Impressum & Datenschutzrichtlinie", + "settingsHelpTerms": "Nutzungsbedingungen", + "settingsAppearanceTheme": "Theme", + "settingsAccountDeleteAccount": "Konto löschen", + "settingsAccountDeleteAccountNoBallance": "Wenn du dein Konto gelöscht hast, gibt es keinen Weg zurück.", + "settingsAccountDeleteModalTitle": "Bist du sicher?", + "settingsAccountDeleteModalBody": "Dein Konto wird gelöscht. Es gibt keine Möglichkeit, es wiederherzustellen.", + "contactVerifyNumberTitle": "Kontakte verifizieren", + "verifyUserIdentity": "Identität von {username} verifizieren", + "@verifyUserIdentity": { + "placeholders": { + "username": {} + } + }, + "contactVerifyNumberSubtitle": "Überprüfe die Identität deiner Kontakte, um sicherzugehen, dass du mit der richtigen Person schreibst.", + "inChatContactNotVerified": "Kontakt nicht verifiziert.", + "groupMembersNotVerified": "{count} Mitglieder sind nicht verifiziert.", + "@groupMembersNotVerified": { + "placeholders": { + "count": {} + } + }, + "userVerifiedTitle": "Kontakt verifiziert", + "scanUserQrCode": "QR-Code von {username} scannen", + "@scanUserQrCode": { + "placeholders": { + "username": {} + } + }, + "openOwnQrCode": "Eigenen QR-Code öffnen", + "contactVerifiedBy": "Verifiziert von {username}", + "contactSharedByUnknown": "Geteilt von einem verifizierten Kontakt (Benutzername nicht verfügbar)", + "verificationTypeQrScanned": "Du hast den QR-Code gescannt.", + "verificationTypeSecretQrToken": "{username} hat deinen QR-Code gescannt.", + "@verificationTypeSecretQrToken": { + "placeholders": { + "username": {} + } + }, + "verificationTypeLink": "Per Link verifiziert.", + "verificationTypeMigratedFromOldVersion": "Von alter Version migriert", + "contactViewMessage": "Nachricht", + "contactNickname": "Spitzname", + "contactNicknameNew": "Neuer Spitzname", + "contactBlock": "Blockieren", + "contactTroubleshoot": "Verbindungsprobleme beheben", + "contactResetSessionTitle": "Verbindung zu {username} zurücksetzen?", + "@contactResetSessionTitle": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "contactResetSessionBody": "twonly löscht die aktuelle verschlüsselte Verbindung und baut sie anschließend neu auf. Nachrichten, die gerade übertragen werden, können dabei verloren gehen.", + "contactResetSessionConfirm": "Verbindung zurücksetzen", + "contactResetSessionSuccess": "Die verschlüsselte Verbindung zu {username} wurde neu aufgebaut.", + "@contactResetSessionSuccess": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "contactResetSessionFailed": "Die Verbindung konnte nicht zurückgesetzt werden. Prüfe deine Internetverbindung und versuche es erneut.", + "contactRemove": "Kontakt löschen", + "contactRemoveTitle": "{username} löschen?", + "contactRemoveBody": "Den Kontakt dauerhaft entfernen. Wenn der Kontakt versucht, dir eine neue Nachricht zu senden, musst du den Kontakt erst wieder akzeptieren.", + "contactBlockTitle": "Blockiere {username}", + "contactBlockBody": "Ein blockierter Kontakt kann dir keine Nachrichten mehr senden, und deren Profil ist nicht mehr sichtbar. Um die Blockierung eines Kontakts aufzuheben, navigiere einfach zu Einstellungen > Datenschutz > Blockierte Kontakte.", + "undo": "Rückgängig", + "redo": "Wiederholen", + "next": "Weiter", + "finishSetup": "Setup abschließen", + "submit": "Abschicken", + "close": "Schließen", + "cancel": "Abbrechen", + "edit": "Bearbeiten", + "ok": "Ok", + "now": "Jetzt", + "you": "Du", + "minutesShort": "Min.", + "image": "Bild", + "video": "Video", + "react": "Reagieren", + "reply": "Antworten", + "copy": "Kopieren", + "delete": "Löschen", + "info": "Info", + "disable": "Deaktiviern", + "enable": "Aktivieren", + "switchFrontAndBackCamera": "Zwischen Front- und Rückkamera wechseln.", + "addTextItem": "Text", + "protectAsARealTwonly": "Als echtes twonly senden!", + "addDrawing": "Zeichnung", + "addEmoji": "Emoji", + "toggleFlashLight": "Taschenlampe umschalten", + "errorInternalError": "Der Server ist derzeit nicht verfügbar. Bitte versuche es später erneut.", + "errorInvalidInvitationCode": "Der von dir angegebene Einladungscode ist ungültig. Bitte überprüfe den Code und versuche es erneut.", + "errorUsernameAlreadyTaken": "Der Benutzername ist bereits vergeben.", + "errorUsernameNotValid": "Der von dir angegebene Benutzername entspricht nicht den erforderlichen Kriterien. Bitte wähle einen gültigen Benutzernamen.", + "upgradeToPaidPlanButton": "Auf {planId} upgraden{sufix}", + "partOfPaidPlanOf": "Du bist Teil des bezahlten Plans von {username}!", + "partOfPaidPlanFrom": "Teil des bezahlten Plans von", + "subscriptionCurrentPlanBadge": "Aktuell", + "errorNotEnoughCredit": "Du hast nicht genügend twonly-Guthaben.", + "errorPlanLimitReached": "Du hast das Limit deines Plans erreicht. Bitte upgrade deinen Plan.", + "errorPlanNotAllowed": "Dieses Feature ist in deinem aktuellen Plan nicht verfügbar.", + "errorVoucherInvalid": "Der eingegebene Gutschein-Code ist nicht gültig.", + "errorPlanUpgradeNotYearly": "Das Upgrade des Plans muss jährlich bezahlt werden, da der aktuelle Plan ebenfalls jährlich abgerechnet wird.", + "proFeature1": "Unbegrenzte Medien-Datei-Uploads", + "proFeature2": "1 zusätzlicher Plus Benutzer", + "proFeature3": "25GB Memories Speicher", + "proFeature4": "Flammen wiederherstellen", + "year": "Jahr", + "month": "Monat", + "yearly": "Jährlich", + "monthly": "Monatlich", + "familyFeature1": "Unbegrenzte Medien-Datei-Uploads", + "familyFeature2": "4 zusätzliche Plus Benutzer", + "familyFeature3": "50GB Memories Speicher", + "familyFeature4": "Flammen wiederherstellen", + "freeFeature1": "10 Medien-Datei-Uploads pro Tag", + "plusFeature1": "Unbegrenzte Medien-Datei-Uploads", + "plusFeature2": "Zusatzfunktionen (coming-soon)", + "manageAdditionalUsers": "Zusätzliche Benutzer verwalten", + "open": "Offene", + "buy": "Kaufen", + "autoRenewal": "Automatische Verlängerung", + "additionalUsersList": "Deine zusätzlichen Benutzer", + "galleryDelete": "Datei löschen", + "galleryExport": "In Galerie exportieren", + "galleryExportSuccess": "Erfolgreich in der Gallery gespeichert.", + "gallerySelectAll": "Alle auswählen", + "galleryDeselectAll": "Auswahl aufheben", + "galleryFavorite": "Als Favorit markieren", + "galleryUnfavorite": "Favorit entfernen", + "galleryCancel": "Abbrechen", + "memoriesEmpty": "Sobald du Bilder oder Videos speicherst, landen sie hier in deinen Erinnerungen.", + "memoriesDeleteSnackbarSuccess": "{count, plural, =1 {1 Element erfolgreich gelöscht} other {{count} Elemente erfolgreich gelöscht}}", + "deleteTitle": "Bist du dir sicher?", + "deleteOkBtnForAll": "Für alle löschen", + "deleteOkBtnForMe": "Für mich löschen", + "deleteOnlyForMe": "Nur für mich löschen", + "deleteImageTitle": "Bist du dir sicher?", + "deleteImageBody": "Das Bild wird unwiderruflich gelöscht.", + "deleteMemoriesBody": "{count, plural, =1 {Das Bild wird unwiderruflich gelöscht.} other {Die {count} Bilder werden unwiderruflich gelöscht.}}", + "@deleteMemoriesBody": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "deleteMemoriesLocalOnly": "Nur lokal", + "deleteMemoriesCompletely": "Komplett", + "memoriesSelectedCount": "{count, plural, =1 {1 Element} other {{count} Elemente}}", + "@memoriesSelectedCount": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "memoriesMenuSelectAll": "Alles auswählen", + "memoriesMenuDeselectAll": "Alle abwählen", + "memoriesMenuExport": "In Gallery exportieren", + "memoriesMenuFavorite": "Favorisieren", + "memoriesMenuDelete": "Löschen", + "memoriesMenuDeleteLocal": "Lokal löschen", + "backupPending": "Ausstehend", + "backupFailed": "Fehlgeschlagen", + "backupSuccess": "Erfolgreich", + "backupTwonlySafeDesc": "Sichere deine twonly-Identität, da dies die einzige Möglichkeit ist, dein Konto wiederherzustellen, wenn du die App deinstallierst oder dein Handy verlierst.", + "backupIdentityHeader": "Identität", + "backupArchiveHeader": "Kontakte & Nachrichten", + "backupLastBackupDate": "Letztes Backup", + "backupLastBackupSize": "Backup-Größe", + "backupLastBackupResult": "Ergebnis", + "backupNoPasswordRecovery": "Aufgrund des Sicherheitssystems von twonly gibt es (derzeit) keine Funktion zur Wiederherstellung des Passworts. Daher musst du dir dein Passwort merken oder, besser noch, aufschreiben.", + "backupInsecurePassword": "Unsicheres Passwort", + "backupInsecurePasswordDesc": "Das gewählte Passwort ist sehr unsicher und kann daher leicht von Angreifern erraten werden. Bitte wähle ein sicheres Passwort.", + "backupInsecurePasswordOk": "Trotzdem fortfahren", + "backupInsecurePasswordCancel": "Erneut versuchen", + "backupTwonlySafeLongDesc": "twonly hat keine zentralen Benutzerkonten. Während der Installation wird ein Schlüsselpaar erstellt, das aus einem öffentlichen und einem privaten Schlüssel besteht. Der private Schlüssel wird nur auf deinem Gerät gespeichert, um ihn vor unbefugtem Zugriff zu schützen. Der öffentliche Schlüssel wird auf den Server hochgeladen und mit deinem gewählten Benutzernamen verknüpft, damit andere dich finden können.\n\ntwonly Backup erstellt regelmäßig ein verschlüsseltes, anonymes Backup deines privaten Schlüssels zusammen mit deinen Kontakten und Einstellungen. Dein Benutzername und das gewählte Passwort reichen aus, um diese Daten auf einem anderen Gerät wiederherzustellen.", + "backupSelectStrongPassword": "Wähle ein sicheres Passwort. Es wird empfohlen, dieses über deinen bevorzugten Passwort-Manager zu generieren und zu speichern.", + "password": "Passwort", + "passwordRepeated": "Passwort wiederholen", + "passwordRepeatedNotEqual": "Passwörter stimmen nicht überein.", + "backupPasswordRequirement": "Das Passwort muss mindestens 10 Zeichen lang sein.", + "backupEnableBackup": "Passwort-Backup aktivieren", + "backupTwonlySaveNow": "Jetzt speichern", + "backupChangePassword": "Passwort ändern", + "backupChangePasswordAuthReason": "Backup-Passwort ändern", + "backupChangePasswordAuthFailed": "Du kannst dein Passwort nur ändern, wenn du dich authentifiziert hast!", + "backupFreeSpaceWithCloud": "Speicherplatz mit Cloud-Backup freigeben", + "backupMemoriesNotEnabled": "Nicht aktiviert", + "backupMemoriesUpgradeRequired": "Upgrade erforderlich", + "todayAt": "Heute um {time}", + "@todayAt": { + "placeholders": { + "time": {} + } + }, + "yesterdayAt": "Gestern um {time}", + "@yesterdayAt": { + "placeholders": { + "time": {} + } + }, + "inviteFriends": "Freunde einladen", + "inviteFriendsShareBtn": "Teilen", + "inviteFriendsShareText": "Wechseln wir zu twonly: {url}", + "appOutdated": "Deine Version von twonly ist veraltet.", + "appOutdatedBtn": "Jetzt aktualisieren.", + "doubleClickToReopen": "Doppelklicken zum\nerneuten Öffnen.", + "uploadLimitReached": "Das Upload-Limit wurde\nerreicht. Upgrade auf Pro\noder warte bis morgen.", + "fileLimitReached": "Maximale Dateigröße\nerreicht", + "retransmissionRequested": "Wird erneut versucht.", + "reportUserTitle": "Melde {username}", + "reportUserReason": "Meldegrund", + "reportUser": "Kontakt melden", + "newDeviceRegistered": "Du hast dich auf einem anderen Gerät angemeldet. Daher wurdest du hier abgemeldet.", + "tabToRemoveEmoji": "Tippen um zu entfernen", + "quotedMessageWasDeleted": "Die zitierte Nachricht wurde gelöscht.", + "messageWasDeleted": "Nachricht wurde gelöscht.", + "messageWasDeletedShort": "Gelöscht", + "sent": "Versendet", + "sentTo": "Zugestellt an", + "received": "Empfangen", + "opened": "Geöffnet", + "waitingForInternet": "Warten auf Internet", + "editHistory": "Bearbeitungshistorie", + "fileSize": "Dateigröße", + "archivedChats": "Archivierte Chats", + "durationShortSecond": "Sek.", + "durationShortMinute": "Min.", + "durationShortHour": "Std.", + "durationShortDays": "{count, plural, =1{1 Tag} other{{count} Tage}}", + "contacts": "Kontakte", + "groups": "Gruppen", + "newGroup": "Neue Gruppe", + "selectMembers": "Mitglieder auswählen", + "selectGroupName": "Gruppennamen wählen", + "groupNameInput": "Gruppennamen", + "groupMembers": "Mitglieder", + "createGroup": "Gruppe erstellen", + "addMember": "Mitglied hinzufügen", + "leaveGroup": "Gruppe verlassen", + "createContactRequest": "Kontaktanfrage erstellen", + "contactRequestSend": "Kontakanfrage gesendet", + "makeAdmin": "Zum Admin machen", + "removeAdmin": "Als Admin entfernen", + "removeFromGroup": "Aus Gruppe entfernen", + "admin": "Admin", + "revokeAdminRightsTitle": "Adminrechte von {username} entfernen?", + "revokeAdminRightsOkBtn": "Als Admin entfernen", + "makeAdminRightsTitle": "{username} zum Admin machen?", + "makeAdminRightsBody": "{username} wird diese Gruppe und ihre Mitglieder bearbeiten können.", + "makeAdminRightsOkBtn": "Zum Admin machen", + "updateGroup": "Gruppe aktualisieren", + "alreadyInGroup": "Bereits Mitglied", + "contactNotVerified": "Nicht verifiziert", + "removeContactFromGroupTitle": "{username} aus dieser Gruppe entfernen?", + "youChangedGroupName": "Du hast den Gruppennamen zu „{newGroupName}“ geändert.", + "makerChangedGroupName": "{maker} hat den Gruppennamen zu „{newGroupName}“ geändert.", + "youCreatedGroup": "Du hast die Gruppe erstellt.", + "makerCreatedGroup": "{maker} hat die Gruppe erstellt.", + "youRemovedMember": "Du hast {affected} aus der Gruppe entfernt.", + "makerRemovedMember": "{maker} hat {affected} aus der Gruppe entfernt.", + "youAddedMember": "Du hast {affected} zur Gruppe hinzugefügt.", + "makerAddedMember": "{maker} hat {affected} zur Gruppe hinzugefügt.", + "youMadeAdmin": "Du hast {affected} zum Administrator gemacht.", + "makerMadeAdmin": "{maker} hat {affected} zum Administrator gemacht.", + "youRevokedAdminRights": "Du hast {affectedR} die Administratorrechte entzogen.", + "makerRevokedAdminRights": "{maker} hat {affectedR} die Administratorrechte entzogen.", + "youLeftGroup": "Du hast die Gruppe verlassen.", + "makerLeftGroup": "{maker} hat die Gruppe verlassen.", + "groupActionYou": "dich", + "groupActionYour": "deine", + "settingsBackup": "Backup", + "twonlySafeRecoverTitle": "Backup wiederherstellen", + "twonlySafeRecoverBtn": "Backup wiederherstellen", + "notificationFillerIn": "in", + "notificationText": "hat eine Nachricht{inGroup} gesendet.", + "notificationTwonly": "hat ein twonly{inGroup} gesendet.", + "notificationVideo": "hat ein Video{inGroup} gesendet.", + "notificationImage": "hat ein Bild{inGroup} gesendet.", + "notificationAudio": "hat eine Sprachnachricht{inGroup} gesendet.", + "notificationAddedToGroup": "hat dich zu \"{groupname}\" hinzugefügt.", + "notificationContactRequest": "möchte sich mit dir vernetzen.", + "notificationAcceptRequest": "ist jetzt mit dir vernetzt.", + "notificationStoredMediaFile": "hat dein Bild gespeichert.", + "notificationReaction": "hat auf dein Bild reagiert.", + "notificationReopenedMedia": "hat dein Bild erneut geöffnet.", + "notificationReactionToVideo": "hat mit {reaction} auf dein Video reagiert.", + "notificationReactionToText": "hat mit {reaction} auf deine Nachricht reagiert.", + "notificationReactionToImage": "hat mit {reaction} auf dein Bild reagiert.", + "notificationReactionToAudio": "hat mit {reaction} auf deine Sprachnachricht reagiert.", + "notificationResponse": "hat dir{inGroup} geantwortet.", + "notificationCategoryMessageTitle": "Nachrichten", + "notificationCategoryMessageDesc": "Nachrichten von anderen Benutzern.", + "notificationConnectionFallback": "Du könntest neue Nachrichten haben.", + "groupContextMenuDeleteGroup": "Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.", + "groupYouAreNowLongerAMember": "Du bist nicht mehr Mitglied dieser Gruppe.", + "groupNetworkIssue": "Netzwerkproblem. Bitte probiere es später noch einmal.", + "leaveGroupSelectOtherAdminTitle": "Einen Admin auswählen", + "leaveGroupSelectOtherAdminBody": "Um die Gruppe zu verlassen, musst du zuerst einen neuen Administrator auswählen.", + "leaveGroupSureTitle": "Gruppe verlassen", + "leaveGroupSureBody": "Willst du die Gruppe wirklich verlassen?", + "leaveGroupSureOkBtn": "Gruppe verlassen", + "changeDisplayMaxTime": "Chats werden ab jetzt nach {time} gelöscht ({username}).", + "youChangedDisplayMaxTime": "Chats werden ab jetzt nach {time} gelöscht.", + "userGotReported": "Benutzer wurde gemeldet.", + "deleteChatAfter": "Chat löschen nach...", + "deleteChatAfterAnHour": "einer Stunde.", + "deleteChatAfterADay": "einem Tag.", + "deleteChatAfterAWeek": "einer Woche.", + "deleteChatAfterAMonth": "einem Monat.", + "deleteChatAfterAYear": "einem Jahr.", + "yourTwonlyScore": "Dein twonly-Score", + "registrationClosed": "Aufgrund des aktuell sehr hohen Aufkommens haben wir die Registrierung vorübergehend deaktiviert, damit der Dienst zuverlässig bleibt. Bitte versuche es in ein paar Tagen noch einmal.", + "dialogAskDeleteMediaFilePopTitle": "Bist du sicher, dass du dein Meisterwerk löschen möchtest?", + "dialogAskDeleteMediaFilePopDelete": "Löschen", + "allowErrorTracking": "Fehler und Crashes mit uns teilen", + "allowErrorTrackingSubtitle": "Wenn twonly abstürzt oder Fehler auftreten, werden diese automatisch an unsere selbst gehostete Glitchtip-Instanz gemeldet. Persönliche Daten wie Nachrichten oder Bilder werden niemals hochgeladen.", + "avatarSaveChanges": "Möchtest du die Änderungen speichern?", + "avatarSaveChangesStore": "Speichern", + "avatarSaveChangesDiscard": "Verwerfen", + "inProcess": "Wird verarbeitet", + "draftMessage": "Entwurf", + "voiceMessageSlideToCancel": "Zum Abbrechen ziehen", + "voiceMessageCancel": "Abbrechen", + "shareYourProfile": "Teile dein Profil", + "scanOtherProfile": "Scanne ein anderes Profil", + "openYourOwnQRcode": "Eigenen QR-Code öffnen", + "addContactQrSheetSubtext": "Lass einen Freund diesen QR-Code scannen, um dich hinzuzufügen", + "letUserScanQrCode": "Lass {username} diesen QR-Code scannen", + "@letUserScanQrCode": { + "placeholders": { + "username": {} + } + }, + "finishSetupCardTitle": "Profil vervollständigen", + "finishSetupCardDesc": "Du hast es fast geschafft! Schließe die Einrichtung deines Kontos ab, um twonly optimal zu nutzen.", + "finishSetupCardAction": "Setup fortsetzen", + "missingBackupCardTitle": "Backup einrichten", + "missingBackupCardDesc": "Wir haben den Backup-Mechanismus verbessert, weshalb du ihn erneut einrichten musst.", + "missingBackupCardAction": "Jetzt einrichten", + "onboardingFinishLater": "Später abschließen", + "onboardingProfileTitle": "Wähle deinen Look", + "onboardingProfileBody": "Wähle einen Avatar und einen Anzeigenamen, den deine Freunde sehen werden.", + "onboardingBackupBody": "Sichere deine twonly-Identität, da dies die einzige Möglichkeit ist, dein Konto wiederherzustellen, wenn du die App deinstallierst oder dein Handy verlierst.", + "onboardingUserDiscoveryShareFriends": "Freunde teilen", + "onboardingUserDiscoveryShareFriendsDesc": "Teile deinen Freunden mit, wen du kennst und wen du verifiziert hast. Freunde können von deiner Freundesliste *nur gemeinsame Freunde sehen*.", + "onboardingUserDiscoveryContactsVerifiedBadge": "Von Freunden verifizierte Kontakte erhalten einen Haken", + "onboardingUserDiscoveryWhoIsRequesting": "Erfahre, wer dich anfragt", + "userDiscoverySettingsManualApproval": "Vor jedem Teilen fragen", + "userDiscoverySettingsManualApprovalDesc": "Bevor einer deiner Freunde geteilt wird, wirst du jedes Mal gefragt.", + "onboardingUserDiscoveryLetFriendsFindYou": "Lass dich von deinen Freunden finden", + "onboardingUserDiscoveryBeRecommended": "Anderen vorgeschlagen werden", + "onboardingUserDiscoveryWhatOthersSee": "Was andere sehen werden", + "onboardingUserDiscoveryWhatYouSee": "Wenn du angefragt wirst, wirst du das sehen", + "onboardingAddContactsTitle": "Neue Kontakte hinzufügen", + "onboardingAddContactsAcceptDesc": "In twonly muss jeder Kontakt zuerst akzeptiert werden, bevor ihr kommunizieren könnt.", + "onboardingAddContactsMethodHeading": "Kontakte hinzufügen", + "onboardingAddContactsMethodScan": "Den QR-Code des Kontaktes scannen.", + "onboardingAddContactsMethodSearch": "Nach dem Benutzernamen suchen.", + "onboardingAddContactsMethodShare": "Einen Kontakt in den Chats teilen.", + "linkFromUsername": "Ist der Link von {username}?", + "linkFromUsernameLong": "Wenn du den Link von der Person direkt erhalten hast, kannst du den Kontakt als verifiziert markieren, da der öffentliche Schlüssel im Link mit dem bereits für diesen Benutzer gespeicherten öffentlichen Schlüssel übereinstimmt.", + "gotLinkFromFriend": "Ja, der Link kommt direkt von der Person.", + "couldNotVerifyUsername": "{username} konnte nicht verifiziert werden", + "linkPubkeyDoesNotMatch": "Der öffentliche Schlüssel im Link stimmt nicht mit dem für diesen Kontakt gespeicherten öffentlichen Schlüssel überein. Triff die Person persönlich und scanne den QR-Code direkt!", + "startWithCameraOpen": "Mit geöffneter Kamera starten", + "showImagePreviewWhenSending": "Bildvorschau bei der Auswahl von Empfängern anzeigen", + "verifiedPublicKey": "Die Identität von {username} wurde erfolgreich überprüft.", + "memoriesAYearAgo": "Vor einem Jahr", + "memoriesXYearsAgo": "Vor {years} Jahren", + "migrationOfMemories": "Migration von Mediendateien: {open} noch offen.", + "autoStoreAllSendUnlimitedMediaFiles": "Alle gesendeten Medien speichern", + "autoStoreAllSendUnlimitedMediaFilesSubtitle": "Wenn du diese Option aktivierst, werden alle Bilder, die du sendest, gespeichert, sofern sie mit einem unendlichen Countdown und nicht im twonly-Modus gesendet wurden.", + "termsOfService": "Allgemeine Geschäftsbedingungen", + "privacyPolicy": "Datenschutzerklärung", + "additionalUserAddError": "{username} konnte nicht hinzugefügt werden, bitte versuche es später noch einmal.", + "additionalUserAddErrorNotInFreePlan": "{username} hat bereits einen bezahlten Tarif und konnte daher nicht hinzugefügt werden.", + "additionalUserAddButton": "Zusätzlichen Benutzer hinzufügen ({used}/{limit})", + "additionalUserRemoveTitle": "Diesen zusätzlichen Benutzer entfernen", + "additionalUserRemoveDesc": "Der zusätzliche Nutzer wird nach der Entfernung automatisch auf den kostenlosen Tarif zurückgestuft und du kannst eine andere Person hinzufügen.", + "additionalUserSelectTitle": "Zusätzliche Benutzer auswählen", + "additionalUserSelectButton": "Benutzer auswählen ({used}/{limit})", + "storeAsDefault": "Als Standard speichern", + "deleteUserErrorMessage": "Du kannst den Kontakt erst löschen, wenn der direkte Chat gelöscht wurde und der Kontakt nicht mehr Mitglied einer Gruppe ist.", + "groupSizeLimitError": "Derzeit ist die Gruppengröße auf {size} Personen begrenzt!", + "authRequestReopenImage": "Um das Bild erneut zu öffnen, musst du dich authentifizieren.", + "shareContactsMenu": "Kontakt", + "shareContactsTitle": "Kontakte auswählen", + "shareContactsSubmit": "Jetzt teilen", + "updateTwonlyMessage": "Um diese Nachricht zu sehen, musst du twonly aktualisieren.", + "verificationBadgeNote": "Du kannst deine Freunde verifizieren, indem du deren öffentlichen QR-Code scannst. Klicke, um mehr zu erfahren.", + "verificationBadgeTitle": "Verifizierung", + "verificationBadgeGeneralDesc": "Der Haken gibt dir die Sicherheit, dass du mit der *richtigen Person* schreibst und deine *Nachrichten vertraulich* bleiben.", + "verificationBadgeGreenDesc": "Ein Kontakt, den du über den QR-code *persönlich verifiziert* hast.", + "verificationBadgeYellowDesc": "Ein Kontakt, der von mind. einem *deiner Kontakte verifiziert* wurde.", + "verificationBadgeRedDesc": "Ein Kontakt, dessen Identität noch *nicht überprüft* wurde.", + "scanNow": "Jetzt scannen", + "qrScannerVerifyHint": "Zum Verifizieren muss die andere Person ihren QR-Code öffnen (Chat-Liste > QR-Code-Button unten rechts)", + "qrScannerVerifyUserHint": "Zum Verifizieren muss {username} seinen QR-Code öffnen (Chat-Liste > QR-Code-Button unten rechts)", + "@qrScannerVerifyUserHint": { + "placeholders": { + "username": {} + } + }, + "openQrCode": "QR-Code öffnen", + "letFriendScanQrToVerify": "Lass einen Freund diesen QR-Code scannen, um euch zu verifizieren", + "deleteVerificationTitle": "Verifizierung löschen?", + "deleteVerificationBody": "Möchtest du diese Verifizierung wirklich löschen?", + "secretQrTokenVerifiedSnackbar": "{username} hat deinen QR-Code gescannt und ist nun verifiziert.", + "@secretQrTokenVerifiedSnackbar": { + "placeholders": { + "username": {} + } + }, + "askForFriendPromotionsPrompt": "Hilf {username}, bekannte Gesichter auf twonly zu finden, indem du gemeinsame Freunde teilst.", + "@askForFriendPromotionsPrompt": { + "placeholders": { + "username": {} + } + }, + "mutualGroupsTitle": "{count, plural, =1{1 gemeinsame Gruppe} other{{count} gemeinsame Gruppen}}", + "@mutualGroupsTitle": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "chatEntryFlameRestored": "{count} Flammen wiederhergestellt", + "requestedUserToastText": "{username} wurde erfolgreich angefragt.", + "profileYourQrCode": "Dein QR-Code", + "settingsScreenLock": "Bildschirmsperre", + "settingsScreenLockSubtitle": "Um twonly zu öffnen, wird die Entsperrfunktion deines Smartphones verwenden.", + "settingsScreenLockAuthMessageEnable": "Bildschirmsperre von twonly verwenden", + "settingsScreenLockAuthMessageDisable": "Bildschirmsperre von twonly deaktivieren.", + "unlockTwonly": "twonly entsperren", + "unlockTwonlyTryAgain": "Erneut versuchen", + "unlockTwonlyDesc": "Entsperre twonly über die Sperreinstellungen deines Handys", + "settingsTypingIndication": "Tipp-Indikatoren", + "settingsTypingIndicationSubtitle": "Bei deaktivierten Tipp-Indikatoren kannst du nicht sehen, wenn andere gerade eine Nachricht tippen.", + "contactActionBlock": "Blockieren", + "contactActionAccept": "Annehmen", + "userDiscoverySettingsMutualFriends": "Wähle aus, wie viele gemeinsame Freunde eine Person haben muss, damit du ihr vorgeschlagen wirst.", + "userDiscoverySettingsApply": "Änderungen übernehmen", + "userDiscoveryEnabledDisableWarning": "Wenn du das Feature „Gemeinsame Freunde“ deaktivierst, werden dir keine Vorschläge mehr angezeigt. Du teilst neuen Kontakten dann auch nicht mehr deine Freunde.", + "userDiscoveryEnabledChangeSettings": "Einstellungen ändern", + "userDiscoveryEnabledFaq": "In unserem FAQ erklären wir dir wie das Feature \"Gemeinsame Freunde\" funktioniert.", + "userDiscoveryDisabledIntro": "twonly kann *ohne Telefonnummer* oder den Zugriff auf dein Adressbuch verwendet werden. Stattdessen kannst du *deine Freunde über gemeinsame Freunde* finden.", + "userDiscoverySettingsTitle": "Gemeinsame Freunde", + "userDiscoveryFeatureOffers": "Dein Nutzen auf einen Blick", + "userDiscoveryDisabledLearnMore": "Mehr erfahren", + "userDiscoveryEnabledDialogTitle": "Wirklich deaktivieren?", + "userDiscoveryEnabledFriendsShared": "Freunde die du teilst", + "userDiscoveryEnabledFriendsSharedDesc": "Du teilst nur Freunde, die diese Funktion ebenfalls aktiviert haben und die den von dir festgelegten Schwellenwert erreicht haben.", + "userDiscoverySettingsCurrentlyDisabled": "Das Feature \"Gemeinsame Freunde\" ist derzeit deaktiviert.", + "userDiscoveryEnabledNoFriendsShared": "Bisher teilst du noch niemanden.", + "userDiscoveryActionDisable": "Deaktivieren", + "friendSuggestionsTitle": "Freundschaftsvorschläge", + "andWord": "und", + "friendSuggestionsFriendsWith": "Befreundet mit {friends}.", + "@friendSuggestionsFriendsWith": { + "placeholders": { + "friends": {} + } + }, + "friendSuggestionsGroupMemberIn": " Gruppenmitglied in {groups}.", + "@friendSuggestionsGroupMemberIn": { + "placeholders": { + "groups": {} + } + }, + "friendSuggestionsRequest": "Anfragen", + "friendSuggestionsAskFriend": "Deine Freunde fragen", + "askFriendsDialogTitle": "Nach {username} fragen", + "@askFriendsDialogTitle": { + "placeholders": { + "username": {} + } + }, + "askFriendsDialogDescription": "Wähle die Freunde aus, die du zu diesem Nutzer fragen möchtest:", + "askFriendsDialogConfirm": "Fragen", + "askFriendsDialogCancel": "Abbrechen", + "chatAskAFriendReceivedDescription": "Dein Freund hat diesen Nutzer als Vorschlag erhalten und möchte wissen, ob er diese Person kennt.", + "chatAskAFriendAddedDescription": "Du hast diesen Nutzer zu deinen Kontakten hinzugefügt.", + "chatAskAFriendHide": "Ausblenden", + "chatAskAFriendRequest": "Anfragen", + "chatAskAFriendUnknownUser": "Nutzer {userId}", + "@chatAskAFriendUnknownUser": { + "placeholders": { + "userId": {} + } + }, + "contactUserDiscoveryImagesLeft": "Es fehlen noch {imagesLeft} Bilder bis deine Freunde mit {username} geteilt werden.", + "@contactUserDiscoveryImagesLeft": { + "placeholders": { + "imagesLeft": {}, + "username": {} + } + }, + "userDiscoveryEnabledVersion": "Version: {version}", + "@userDiscoveryEnabledVersion": { + "placeholders": { + "version": {} + } + }, + "userDiscoveryEnabledYourVersion": "Deine Version: {version}", + "@userDiscoveryEnabledYourVersion": { + "placeholders": { + "version": {} + } + }, + "userDiscoveryEnabledStopSharing": "Nicht mehr teilen", + "userDiscoveryManualApprovalReachedThreshold": "Möchtes du {username} mit deinen Freunden teilen?", + "@userDiscoveryManualApprovalReachedThreshold": { + "placeholders": { + "username": {} + } + }, + "userDiscoveryManualApprovalHideContact": "Kontakt verbergen", + "userDiscoveryManualApprovalShareContact": "Kontakt teilen", + "onboardingSetupCompleteTitle": "Du bist startklar, {username}!", + "@onboardingSetupCompleteTitle": { + "placeholders": { + "username": {} + } + }, + "onboardingSetupCompleteDesc": "Du kannst jetzt deine Momente sicher mit deinen Freunden teilen, ohne Ablenkungen wie Werbung.", + "contactUserDiscoveryManualApprovalPending": "Der Kontakt wurde noch nicht manuell freigegeben.", + "contactUserDiscoveryManualApprovalApprove": "Freigeben", + "exampleUserName1": "max_mustermann", + "exampleUserName2": "erika_musterfrau", + "exampleUserName3": "hans", + "exampleUserName4": "petra", + "exampleUserName5": "klaus", + "exampleUserName6": "sabine", + "exampleUserName7": "stefan", + "exampleUserName8": "monika", + "exampleUserName9": "christian", + "exampleUserName10": "lena", + "exampleUserName11": "david", + "exampleJane": "erika", + "back": "Zurück", + "makerChangedUsername": "{maker} hat den Benutzernamen von {oldName} zu {newName} geändert.", + "@makerChangedUsername": { + "placeholders": { + "maker": {}, + "oldName": {}, + "newName": {} + } + }, + "makerChangedDisplayName": "{maker} hat den Anzeigenamen von {oldName} zu {newName} geändert.", + "@makerChangedDisplayName": { + "placeholders": { + "maker": {}, + "oldName": {}, + "newName": {} + } + }, + "recoverErrorNoInternet": "Keine Internetverbindung. Bitte überprüfe deine Netzwerkverbindung und versuche es erneut.", + "recoverErrorUsernameNotValid": "Der eingegebene Benutzername ist ungültig oder existiert nicht.", + "recoverErrorPasswordInvalid": "Das eingegebene Passwort ist falsch.", + "recoverErrorTryAgainLater": "Der Server ist derzeit nicht erreichbar. Bitte versuche es später erneut.", + "recoverErrorUnknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.", + "recoverSuccessTitle": "Backup erfolgreich wiederhergestellt.", + "recoverSuccessBody": "Klicke hier, um die App wieder zu öffnen", + "iosRecoveryWelcomeBack": "Willkommen zurück", + "iosRecoveryPrompt": "Wir haben eine zuvor gesicherte twonly-Identität auf diesem Gerät erkannt. Möchtest du deine Kontakte, Nachrichten und Einstellungen automatisch aus deinem Cloud-Archiv herunterladen und wiederherstellen?", + "iosRecoveryNoBackupFound": "Für dieses Gerät konnte kein Backup-Archiv vom Server abgerufen werden.\n\nFehler: {error}\n\nBitte fahre mit der Registrierung eines neuen twonly-Kontos fort.", + "@iosRecoveryNoBackupFound": { + "placeholders": { + "error": {} + } + }, + "registerNewAccount": "Neues Konto registrieren", + "tryRestoreAgain": "Wiederherstellung erneut versuchen", + "registeringNewAccount": "Neues Konto wird registriert", + "createShortcut": "Shortcut erstellen", + "editShortcut": "Shortcut bearbeiten", + "deleteShortcut": "Shortcut löschen", + "deleteShortcutBody": "Bist du sicher, dass du diesen Shortcut löschen möchtest?", + "updateShortcut": "Shortcut aktualisieren", + "selectEmoji": "Emoji auswählen", + "errorEmojiUsedOrInvalid": "Emoji wird bereits verwendet oder ist ungültig", + "subscriptionPledgeSecureTitle": "Secure by Design", + "subscriptionPledgeSecureDesc": "Deine Nachrichten und Bilder sind immer vollständig Ende-zu-Ende verschlüsselt.", + "subscriptionPledgeNoAdsTitle": "Keine Werbung oder Datenverkauf", + "subscriptionPledgeNoAdsDesc": "twonly wird niemals Werbung anzeigen oder deine privaten Daten verkaufen.", + "subscriptionPledgeSubtitle": "Keine Werbung. Volle Privatsphäre.", + "subscriptionManage": "Abonnement verwalten", + "dragToZoom": "Zum Zoomen ziehen", + "onboardingProfileSelectionTitle": "Wähle deinen Setup-Weg", + "onboardingProfileSelectionSubtitle": "Wähle aus, wie du deine Sicherheits- und Privatsphäre-Einstellungen konfigurieren möchtest.", + "onboardingProfileSelectionDefaultTitle": "Standard", + "onboardingProfileSelectionDefaultDesc": "Wendet sofort die empfohlenen Einstellungen an, damit du die App direkt nutzen kannst.", + "onboardingProfileSelectionDefaultBadge": "Schnelles Setup", + "onboardingProfileSelectionCustomizeTitle": "Anpassen", + "onboardingProfileSelectionCustomizeDesc": "Schritt-für-Schritt-Einrichtung, damit du selbst entscheiden kannst.", + "replyFlameRestored": "Flammen wiederhergestellt", + "replyAskAFriend": "Einen Freund fragen", + "unverifiedWarningButton": "Jetzt verifizieren", + "today": "Heute", + "yesterday": "Gestern", + "galleryDisableWarningTitle": "Galeriespeicherung deaktivieren?", + "galleryDisableWarningBody": "Wenn du dies deaktivierst, werden deine Mediendateien nicht in deiner Galerie gespeichert und könnten dauerhaft verloren gehen, wenn twonly deinstalliert wird oder ein Problem auftritt, da Mediendateien noch nicht in Backups enthalten sind.", + "galleryDisableWarningConfirm": "Deaktivieren", + "settingsStorageScanGalleryTitle": "Aus Galerie importieren", + "importGalleryDeselectAll": "Alle abwählen", + "importGallerySelectAll": "Alle auswählen", + "importGalleryPermissionRequired": "Zugriff auf deine Galerie ist erforderlich, um frühere twonly-Mediendateien zu importieren.", + "importGalleryPermissionError": "Beim Anfordern der Berechtigung ist ein Fehler aufgetreten: {error}", + "@importGalleryPermissionError": { + "placeholders": { + "error": {} + } + }, + "importGalleryLoadError": "Laden der Medien fehlgeschlagen: {error}", + "@importGalleryLoadError": { + "placeholders": { + "error": {} + } + }, + "importGalleryImportingOf": "{current} von {total} wird importiert...", + "@importGalleryImportingOf": { + "placeholders": { + "current": {}, + "total": {} + } + }, + "importGalleryStarting": "Import wird gestartet...", + "importGalleryComplete": "Import abgeschlossen: {imported} erfolgreich importiert und {failed} fehlgeschlagen.", + "@importGalleryComplete": { + "placeholders": { + "imported": {}, + "failed": {} + } + }, + "importGalleryGrantAccess": "Zugriff erlauben", + "importGalleryOpenSettings": "Einstellungen öffnen", + "importGalleryPermissionDenied": "Zugriff auf Galerie verweigert.", + "importGalleryTryAgain": "Erneut versuchen", + "importGalleryAlbumNotFound": "\"twonly\"-Album nicht gefunden", + "importGalleryAlbumNotFoundDesc": "Falls du dieses Album noch nicht hast, kannst du es auch erstellen, um Fotos in twonly zu importieren.", + "importGalleryNoImagesFound": "Keine Bilder gefunden", + "importGalleryNoImagesFoundDesc": "Es befinden sich keine Bilder auf deinem Gerät.", + "importGalleryFilterTwonly": "Nur das twonly-Album anzeigen", + "importGalleryRefresh": "Aktualisieren", + "importGallerySelectToImport": "Elemente zum Importieren auswählen", + "importGalleryImportCount": "{count, plural, =1{1 Element importieren} other{{count} Elemente importieren}}", + "@importGalleryImportCount": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "emptyChatListTitle": "Finde deinen ersten Freund", + "emptyChatListDesc": "Lass Freunde deinen QR-Code scannen oder teile dein Profil mit ihnen.", + "emptyChatListShareBtn": "Profil teilen", + "emptyChatListScanBtn": "QR-Code", + "emptyChatListAddUsernameBtn": "Per Benutzername", + "avatarCustomizeRandomize": "Zufällig", + "avatarCustomizeReset": "Zurücksetzen", + "passwordlessRecovery": "Vertraute Freunde", + "passwordlessRecoveryNotConfigured": "Nicht konfiguriert", + "passwordlessRecoveryTestPin": "PIN testen", + "passwordlessRecoveryTestPinTitle": "PIN testen", + "passwordlessRecoveryTestPinHint": "Gib deine PIN ein", + "passwordlessRecoveryTestPinCorrect": "PIN ist korrekt!", + "passwordlessRecoveryTestPinIncorrect": "Falsche PIN.", + "passwordlessRecoveryTest": "Testen", + "passwordlessRecoverySecondFactorNone": "Keiner", + "passwordlessRecoverySecondFactorEmailLabel": "E-Mail ({email})", + "@passwordlessRecoverySecondFactorEmailLabel": { + "placeholders": { + "email": {} + } + }, + "passwordlessRecoverySecondFactorPin": "PIN", + "passwordlessRecoverySecondFactorEmail": "E-Mail", + "passwordlessRecoveryModify": "Einstellungen bearbeiten", + "passwordlessRecoveryModifyDesc": "Aktualisiere deine Backupkonfiguration", + "passwordlessRecoverySecondFactor": "Zweiter Faktor", + "passwordlessRecoveryNoFriendsFound": "Keine Freunde gefunden.", + "passwordlessRecoveryActiveFriends": "Aktive Freunde", + "passwordlessRecoveryActiveFriendsDesc": "Diese Freunde nutzen twonly aktiv und können dir wahrscheinlich helfen, dein Konto wiederherzustellen.", + "passwordlessRecoveryInactiveFriends": "Inaktive Freunde", + "passwordlessRecoveryInactiveFriendsDesc": "Diese Freunde haben entweder ihren Teil noch nicht erhalten oder nutzen twonly nicht mehr aktiv, was bedeutet, dass sie möglicherweise nicht helfen können.", + "passwordlessRecoveryEnableSuccess": "Vertraute Freunde erfolgreich eingerichtet!", + "passwordlessRecoveryEnterPin": "Bitte gib eine PIN ein.", + "passwordlessRecoveryEnterEmail": "Bitte gib eine E-Mail-Adresse ein.", + "passwordlessRecoveryEnableBtn": "Vertraute Freunde aktivieren", + "passwordlessRecoveryRecoverBtn": "Mithilfe von vertrauten Freunden wiederherstellen", + "passwordlessRecoveryModifyBtn": "Vertraute Freunde bearbeiten", + "passwordlessRecoveryStatusEnabled": "Aktiviert • {count} Freunde", + "@passwordlessRecoveryStatusEnabled": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "passwordlessRecoveryInfoHowItWorks": "Wie es funktioniert", + "passwordlessRecoveryInfoHowItWorksDesc": "twonly funktioniert ohne Telefonnummern, weshalb wir auf einen dezentralen Wiederherstellungsmechanismus setzen. Mit Shamir's Secret Sharing wird deine kryptografische Identität in unabhängige Teile aufgeteilt und an deine Freunde verteilt. Um den Zugriff wiederherzustellen, muss eine vorher festgelegte Anzahl dieser Freunde ihre Teile kombinieren.", + "passwordlessRecoveryInfoWhySecondFactor": "Warum ein zweiter Faktor?", + "passwordlessRecoveryInfoWhySecondFactorDesc": "Der zweite Faktor (E-Mail oder PIN) ist ein wichtiger zusätzlicher Schutz. Selbst wenn sich deine Freunde heimlich zusammentun, können sie ohne diesen zweiten Faktor nicht auf dein Konto zugreifen.", + "passwordlessRecoveryInfoGotIt": "Verstanden", + "passwordlessRecoveryMethod": "Methode für den zweiten Faktor", + "passwordlessRecoveryMethodNoneDesc": "Ohne zweiten Faktor könnten sich deine Freunde absprechen, um dein Konto wiederherzustellen. Daher wird empfohlen, einen zweiten Faktor zu konfigurieren.", + "passwordlessRecoveryMethodPinHint": "PIN eingeben", + "passwordlessRecoveryMethodEmailHint": "Wiederherstellungs-E-Mail-Adresse eingeben", + "passwordlessRecoveryMethodEmailDesc": "Deine E-Mail-Adresse wird *niemals auf dem Server gespeichert* und nur im Falle einer Wiederherstellung an ihn gesendet.", + "passwordlessRecoveryThresholdDesc": "Um dein Konto wiederherzustellen, brauchst du {count} deiner ausgewählten Freunde.", + "@passwordlessRecoveryThresholdDesc": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "passwordlessRecoveryThresholdTitle": "Benötigte Freunde für das Backup", + "passwordlessRecoverySelectFriendsNeeded": "Freunde auswählen ({count} weitere benötigt)", + "@passwordlessRecoverySelectFriendsNeeded": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "passwordlessRecoverySelectFriends": "Freunde auswählen", + "passwordlessRecoveryTrustedFriends": "Kontowiederherstellung", + "passwordlessRecoveryDoneBtn": "Fertig ({count})", + "@passwordlessRecoveryDoneBtn": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "passwordlessRecoveryNoFriendsSelected": "Noch keine Freunde ausgewählt", + "passwordlessRecoverySubtitle": "Stelle deine Identität ohne Passwort wieder her.", + "recoverPasswordlessExplanation": "Wenn du die passwortlose Wiederherstellung aktiviert hast, kannst du dein Konto mithilfe deiner Freunde wiederherstellen.", + "recoverPasswordlessNotificationCardTitle": "Benachrichtigungen aktivieren", + "recoverPasswordlessNotificationCardSubtitle": "Lass dich benachrichtigen, wenn ein Freund dir geholfen hat, dein Konto wiederherzustellen.", + "recoverPasswordlessNotificationCardBtn": "Push-Benachrichtigungen aktivieren", + "recoverPasswordlessQrInstructions": "Lass deine Freunde den QR-Code scannen oder teile den Link.", + "recoverPasswordlessShareBtn": "Wiederherstellungs-Link teilen", + "recoverPasswordlessCopyBtn": "Kopieren", + "recoverPasswordlessCopiedSnackbar": "Link in die Zwischenablage kopiert!", + "passwordlessRecoveryNoShareStored": "Kein Wiederherstellungs-Teil für diesen Kontakt gespeichert.", + "passwordlessRecoveryEmailSent": "Wiederherstellungs-E-Mail gesendet!", + "passwordlessRecoveryShareSent": "Wiederherstellungs-Teil gesendet!", + "passwordlessRecoveryNetworkError": "Netzwerkfehler, bitte stelle sicher, dass du Internet hast", + "passwordlessRecoveryInvalidEmail": "Die E-Mail-Adresse ist ungültig.", + "passwordlessRecoveryResendEmail": "E-Mail erneut senden", + "passwordlessRecoveryHelpAFriend": "Einem Freund helfen", + "passwordlessRecoverySelectContactDesc": "Wähle den Kontakt aus, dem du helfen möchtest.", + "passwordlessRecoverySearchContacts": "Kontakte suchen...", + "passwordlessRecoveryNoContactsFound": "Keine Kontakte gefunden", + "passwordlessRecoveryCantHelpHim": "Du kannst dieser Person nicht helfen", + "passwordlessRecoveryDoesAskedYou": "Hat {username} dich gefragt?", + "@passwordlessRecoveryDoesAskedYou": { + "placeholders": { + "username": {} + } + }, + "passwordlessRecoveryVerifySourceDesc": "Bitte stelle sicher, dass diese Anfrage wirklich von dieser Person stammt. Im Zweifel frage kurz über einen anderen Kanal nach!", + "passwordlessRecoveryNo": "Nein", + "passwordlessRecoveryYes": "Ja", + "passwordlessRecoveryYesWithTimer": "Ja ({seconds}s)", + "@passwordlessRecoveryYesWithTimer": { + "placeholders": { + "seconds": {} + } + }, + "recoverPasswordlessSharesReceived": "{received} von {threshold} Freunden haben geholfen", + "@recoverPasswordlessSharesReceived": { + "placeholders": { + "received": {}, + "threshold": {} + } + }, + "recoverPasswordlessRecoverNowBtn": "Jetzt wiederherstellen", + "missingRecoveryContactsCardTitle": "Kontowiederherstellung", + "missingRecoveryContactsCardDesc": "Wähle Kontakte für die Kontowiederherstellung aus, die dir helfen, dein Konto ohne Passwort wiederherzustellen.", + "missingRecoveryContactsCardAction": "Kontakte auswählen", + "memoriesBackupLimitReached": "Cloud-Backup-Limit erreicht! Bitte aktualisiere dein Abonnement oder gib Speicherplatz frei.", + "settingsStorageLocal": "Lokaler Speicherplatz", + "settingsStorageNoCloudBackupTitle": "Kein Cloud-Backup", + "settingsStorageNoCloudBackupCard": "Sichere deine Erinnerungen in der Cloud, um lokalen Speicherplatz freizugeben und deine Bilder nicht zu verlieren. Alles Ende-zu-Ende verschlüsselt.", + "galleryActionSave": "Sichern", + "galleryActionExport": "Exportieren", + "galleryActionFavorite": "Favorit", + "galleryActionUnfavorite": "Favorit", + "galleryActionDelete": "Löschen", + "galleryActionShare": "Teilen", + "settingsStorageHidePromo": "Ausblenden", + "memoriesBackupTitle": "Memories Backup", + "settingsHelpNews": "Neuigkeiten", + "settingsStorageContents": "Speicherplatz freigeben", + "settingsStorageSortStorage": "Belegter Speicher", + "settingsStorageSortNewest": "Neueste", + "settingsStorageSortOldest": "Älteste", + "settingsStorageNoContents": "Keine Speicherinhalte gefunden.", + "settingsStorageCloudOnly": "Nur Cloud", + "settingsStorageLocalAndCloud": "Lokal & Cloud", + "settingsStorageLocalOnly": "Nur lokal", + "settingsStorageDisableBackupTitle": "Backup deaktivieren?", + "settingsStorageDisableBackupBody": "Dadurch werden alle gesicherten Erinnerungen aus der Cloud gelöscht. Es gibt {count, plural, =1 {*1 Bild*, das} other {*{count} Bilder*, die}} nur in der Cloud und nicht lokal gespeichert sind. Diese gehen *dauerhaft verloren*. Fortfahren?", + "@settingsStorageDisableBackupBody": { + "placeholders": { + "count": {} + } + }, + "settingsStorageDisableBackupBtn": "Deaktivieren", + "settingsStorageDisableBackupAction": "Cloud-Backup deaktivieren", + "settingsStorageSyncNow": "Jetzt synchronisieren", + "settingsStorageSyncUpToDate": "Alle Memories sind auf dem neuesten Stand.", + "restoreLostFlames": "Stelle deine {count} verlorenen Flammen wieder her", + "@restoreLostFlames": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "settingsShowRestoreFlameTitle": "Hinweis zur Flammen-Wiederherstellung anzeigen", + "contactLabelsTitle": "Kontaktlabels", + "contactLabelsSubtitleEmpty": "Keine Labels ausgewählt", + "contactLabelsMaxLimit": "Maximal 3 Labels pro Kontakt", + "createLabel": "Neues Label erstellen", + "editLabel": "Label bearbeiten", + "deleteLabel": "Label löschen", + "deleteLabelConfirmation": "Möchtest du dieses Label wirklich löschen? Es wird von allen Kontakten entfernt.", + "labelNameHint": "Label-Name", + "labelTextColor": "Textfarbe", + "labelBackgroundColor": "Hintergrundfarbe", + "customColor": "Eigene Farbe", + "hue": "Farbton", + "saturation": "Sättigung", + "brightness": "Helligkeit", + "settingsAppearancePrimaryColor": "Hauptfarbe", + "themeSystemDefault": "Systemstandard", + "themeLight": "Hell", + "themeDark": "Dunkel", + "backupRecoverySectionTitle": "Konto-Zugang sichern", + "backupRecoverySectionDescNone": "Du musst mindestens eine Wiederherstellungsoption auswählen, da dir sonst niemand helfen kann, falls du den Zugriff auf dein Gerät verlierst.", + "backupRecoverySectionDescSome": "Mit den untenstehenden Wiederherstellungsoptionen kannst du dein Konto wiederherstellen. Es wird empfohlen, beide Optionen zu aktivieren.", + "backupRecoveryOptionAFriends": "Über vertraute Freunde", + "backupRecoveryOptionAMicrocopy": "Wähle Freunde aus, die dir helfen, dein Konto wiederherzustellen.", + "backupRecoveryOptionBPassword": "Klassisches Passwort", + "backupRecoveryOptionBMicrocopy": "Lege ein Passwort für deinen Zugang fest.", + "backupCloudSectionTitle": "Deine Daten sichern", + "backupCloudSectionDesc": "Sichere deine Inhalte, damit nichts verloren geht.", + "backupCloudContactsMessages": "Kontakte & Nachrichten", + "backupCloudFreeActive": "Kostenlos aktiv", + "backupCloudImagesMedia": "Bilder & Medien", + "backupCloudProBadge": "Pro", + "memoriesAddingToFavorites": "Zu Favoriten hinzufügen...", + "memoriesRemovingFromFavorites": "Aus Favoriten entfernen...", + "memoriesDeletingProgress": "Erinnerungen werden gelöscht...", + "memoriesExportingProgress": "Erinnerungen werden exportiert...", + "fileLimitReachedTitle": "Datei zu groß zum Senden", + "fileLimitReachedDetail": "Diese Datei ist {size} groß, dein Tarif erlaubt aber höchstens {limit} pro Sendung.", + "@fileLimitReachedDetail": { + "placeholders": { + "size": { "type": "String" }, + "limit": { "type": "String" } + } + }, + "fileLimitReachedDetailNoSize": "Dein Tarif erlaubt höchstens {limit} pro Sendung.", + "@fileLimitReachedDetailNoSize": { + "placeholders": { + "limit": { "type": "String" } + } + }, + "fileLimitReachedHint": "Nimm ein kürzeres Video auf und sende es erneut.", + "fileLimitReachedHintFree": "Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.", + "fileLimitReachedUpgrade": "Tarif wechseln" +} diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb new file mode 100644 index 00000000..efc930d8 --- /dev/null +++ b/lib/src/localization/translations/en.arb @@ -0,0 +1,1027 @@ +{ + "@@locale": "en", + "registerSlogan": "Stay in touch privately.", + "onboardingWelcomeTitle": "Welcome to twonly!", + "onboardingWelcomeBody": "Experience a private and secure way to stay in touch with friends by sharing instant pictures.", + "onboardingE2eTitle": "Carefree sharing", + "onboardingE2eBody": "With end-to-end encryption, enjoy the peace of mind that only you and your friends can see the moments you share.", + "onboardingFocusTitle": "Focus on sharing moments", + "onboardingFocusBody": "Say goodbye to addictive features! twonly was created for sharing moments, free from useless distractions or ads.", + "onboardingNotProductTitle": "You are not the product!", + "onboardingNotProductBody": "twonly is financed by an optional subscription. Your data will never be sold.", + "registerUsernameSlogan": "Create your account", + "registerUsernameDecoration": "Username", + "registerUsernameLimits": "At least 3 characters.", + "registerProofOfWorkFailed": "There was an issue with the captcha test. Please try again.", + "registerSubmitButton": "Register now!", + "chatsTapToSend": "Click to send your first image", + "cameraPreviewSendTo": "Send to", + "shareImageTitle": "Share with", + "shareImageBestFriends": "Best friends", + "shareImagePinnedContacts": "Pinnded", + "shareImagedEditorSendImage": "Send", + "shareImagedEditorShareWith": "Share with", + "shareImagedEditorSaveImage": "Save", + "shareImagedEditorSavedImage": "Saved", + "shareImageSearchAllContacts": "Search all contacts", + "startNewChatSearchHint": "Name, username or groupname", + "shareImagedSelectAll": "Select all", + "startNewChatTitle": "Select Contact", + "startNewChatNewContact": "New Contact", + "shareImageAllUsers": "All contacts", + "shareImageShowArchived": "Show archived users", + "searchUsernameInput": "Username", + "addFriendTitle": "Add friends", + "searchUserNamePending": "Request pending", + "searchUsernameNotFound": "Username not found", + "searchUsernameNewFollowerTitle": "Open requests", + "chatListDetailInput": "Type a message", + "userDeletedAccount": "The user has deleted their account.", + "contextMenuUserProfile": "User profile", + "contextMenuArchiveUser": "Archive", + "contextMenuUndoArchiveUser": "Undo archiving", + "contextMenuOpenChat": "Open chat", + "contextMenuPin": "Pin", + "contextMenuUnpin": "Unpin", + "contextMenuViewAgain": "View again", + "mediaViewerAuthReason": "Please authenticate to see this twonly!", + "mediaViewerTwonlyTapToOpen": "Tap to open your twonly!", + "messageSendState_Received": "Received", + "messageSendState_Opened": "Opened", + "messageSendState_Send": "Sent", + "messageSendState_Sending": "Sending", + "messageSendState_TapToLoad": "Tap to load", + "messageSendState_Loading": "Downloading", + "messageStoredInGallery": "Stored in gallery", + "messageReopened": "Re-opened", + "imageEditorDrawOk": "Take drawing", + "settingsTitle": "Settings", + "settingsChats": "Chats", + "settingsPreSelectedReactions": "Preselected reaction emojis", + "settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle": "Mark duplicates as opened", + "settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle": "If you receive the same media in multiple chats, opening one marks all others as opened.", + "settingsPreSelectedReactionsError": "A maximum of 12 reactions can be selected.", + "settingsProfile": "Profile", + "settingsStorageData": "Data and storage", + "settingsStorageDataStoreInGTitle": "Store in Gallery", + "settingsStorageDataMediaAutoDownload": "Media auto-download", + "settingsStorageDataAutoDownMobile": "When using mobile data", + "settingsStorageDataAutoDownWifi": "When using WI-FI", + "settingsStorageManageTitle": "Manage storage", + "settingsStorageUsed": "Storage used", + "settingsStorageImages": "Images", + "settingsStorageVideos": "Videos", + "settingsStorageGifs": "GIFs", + "settingsProfileCustomizeAvatar": "Customize your avatar", + "settingsProfileEditDisplayName": "Displayname", + "settingsProfileEditDisplayNameNew": "New Displayname", + "settingsAccount": "Account", + "settingsSubscription": "Subscription", + "settingsAppearance": "Appearance", + "settingsPrivacy": "Privacy & Security", + "settingsPrivacyBlockUsers": "Block contacts", + "settingsPrivacyBlockUsersDesc": "Blocked contacts will not be able to communicate with you. You can unblock a blocked contact at any time.", + "settingsPrivacyBlockUsersCount": "{len} contact(s)", + "@settingsPrivacyBlockUsersCount": { + "placeholders": { + "len": {} + } + }, + "settingsNotification": "Notification", + "settingsNotifyPermission": "Notification permissions", + "settingsNotifyPermissionDesc": "Open system settings to allow push notifications.", + "settingsNotifyTroubleshooting": "Troubleshooting", + "settingsNotifyTroubleshootingDesc": "Click here if you have problems receiving push notifications.", + "settingsNotifyTroubleshootingNoProblem": "No problem detected", + "settingsNotifyTroubleshootingNoProblemDesc": "Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.", + "settingsNotifyResetTitle": "Reset notification tokens", + "settingsNotifyResetTitleSubtitle": "Reset your notification tokens if you have problems receiving push notifications.", + "settingsNotifyResetTitleReset": "Your notification tokens have been reset.", + "settingsNotifyResetTitleResetDesc": "If the problem persists, please send us your debug log via Settings > Help so we can investigate the issue.", + "settingsHelp": "Help", + "settingsHelpDiagnostics": "Diagnostic protocol", + "settingsHelpFAQ": "FAQ", + "feedbackTooltip": "Give Feedback to improve twonly.", + "settingsHelpContactUs": "Contact us", + "settingsHelpVersion": "Version", + "settingsHelpLicenses": "Licenses (Source-Code)", + "settingsHelpCredits": "Licenses (Images)", + "settingsHelpImprint": "Imprint & Privacy Policy", + "contactUsFaq": "Have you read our FAQ yet?", + "contactUsEmojis": "How do you feel? (optional)", + "contactUsSelectOption": "Please select an option", + "contactUsReason": "Tell us why you're reaching out", + "contactUsMessage": "If you want to receive an answer, please add your e-mail address so we can contact you.", + "contactUsYourMessage": "Your message", + "contactUsMessageTitle": "Tell us what's going on", + "contactUsReasonNotWorking": "Something's not working", + "contactUsReasonFeatureRequest": "Feature request", + "contactUsReasonQuestion": "Question", + "contactUsReasonFeedback": "Feedback", + "contactUsReasonOther": "Other", + "contactUsIncludeLog": "Include debug log", + "contactUsWhatsThat": "What's that?", + "contactUsLastWarning": "This are the information's which will be send to us. Please verify them and then press submit.", + "contactUsSuccess": "Feedback submitted successfully!", + "hideNewsIcon": "Hide News Icon", + "settingsHelpTerms": "Terms of Service", + "settingsAppearanceTheme": "Theme", + "settingsAccountDeleteAccount": "Delete account", + "settingsAccountDeleteAccountNoBallance": "Once you delete your account, there is no going back.", + "settingsAccountDeleteModalTitle": "Are you sure?", + "settingsAccountDeleteModalBody": "Your account will be deleted. There is no change to restore it.", + "contactVerifyNumberTitle": "Verify contacts", + "verifyUserIdentity": "Verify {username}'s identity", + "@verifyUserIdentity": { + "placeholders": { + "username": {} + } + }, + "contactVerifyNumberSubtitle": "Verify the identity of your contacts to make sure you are texting the right person.", + "inChatContactNotVerified": "Contact not verified.", + "groupMembersNotVerified": "{count} members are not verified.", + "@groupMembersNotVerified": { + "placeholders": { + "count": {} + } + }, + "userVerifiedTitle": "Contact verified", + "scanUserQrCode": "Scan {username}'s QR code", + "@scanUserQrCode": { + "placeholders": { + "username": {} + } + }, + "openOwnQrCode": "Open your own QR code", + "contactVerifiedBy": "Verified by {username}", + "contactSharedByUnknown": "Shared by a verified contact (username not available)", + "verificationTypeQrScanned": "You scanned their QR code.", + "verificationTypeSecretQrToken": "{username} has scanned your QR code.", + "@verificationTypeSecretQrToken": { + "placeholders": { + "username": {} + } + }, + "verificationTypeLink": "Verified via link.", + "verificationTypeMigratedFromOldVersion": "Migrated from old version.", + "contactViewMessage": "Message", + "contactNickname": "Nickname", + "contactNicknameNew": "New nickname", + "contactBlock": "Block", + "contactTroubleshoot": "Troubleshoot connection", + "contactResetSessionTitle": "Reset connection with {username}?", + "@contactResetSessionTitle": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "contactResetSessionBody": "twonly will remove the current encrypted connection and establish a new one. Messages currently in transit may be lost.", + "contactResetSessionConfirm": "Reset connection", + "contactResetSessionSuccess": "The encrypted connection with {username} was re-established.", + "@contactResetSessionSuccess": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "contactResetSessionFailed": "The connection could not be reset. Check your internet connection and try again.", + "contactBlockTitle": "Block {username}", + "@contactBlockTitle": { + "placeholders": { + "username": {} + } + }, + "contactBlockBody": "A blocked contact will no longer be able to send you messages and their profile will be hidden from view. To unblock a contact, simply navigate to Settings > Privacy > Blocked Contacts.", + "contactRemove": "Remove contact", + "contactRemoveTitle": "Remove {username}", + "contactRemoveBody": "Permanently remove the contact. If the contact tries to send you a new message, you will have to accept the contact again first.", + "undo": "Undo", + "redo": "Redo", + "next": "Next", + "finishSetup": "Complete setup", + "submit": "Submit", + "close": "Close", + "disable": "Disable", + "enable": "Enable", + "cancel": "Cancel", + "now": "Now", + "you": "You", + "minutesShort": "min.", + "image": "Image", + "video": "Video", + "react": "React", + "reply": "Reply", + "copy": "Copy", + "edit": "Edit", + "delete": "Delete", + "info": "Info", + "ok": "Ok", + "switchFrontAndBackCamera": "Switch between front and back camera.", + "addTextItem": "Text", + "protectAsARealTwonly": "Send as real twonly!", + "addDrawing": "Drawing", + "addEmoji": "Emoji", + "toggleFlashLight": "Toggle the flash light", + "userFound": "{username} found", + "userFoundBody": "Do you want to connect with {username}?", + "@userFoundBody": { + "placeholders": { + "username": { + "type": "String" + } + } + }, + "errorInternalError": "The server is currently not available. Please try again later.", + "errorInvalidInvitationCode": "The invitation code you provided is invalid. Please check the code and try again.", + "errorUsernameAlreadyTaken": "The username is already taken.", + "errorUsernameNotValid": "The username you provided does not meet the required criteria. Please choose a valid username.", + "errorNotEnoughCredit": "You do not have enough twonly-credit.", + "errorVoucherInvalid": "The voucher code you entered is not valid.", + "errorPlanLimitReached": "You have reached your plans limit. Please upgrade your plan.", + "errorPlanNotAllowed": "This feature is not available in your current plan.", + "errorPlanUpgradeNotYearly": "The plan upgrade must be paid for annually, as the current plan is also billed annually.", + "upgradeToPaidPlanButton": "Upgrade to {planId}{sufix}", + "partOfPaidPlanOf": "You are part of the paid plan of {username}!", + "partOfPaidPlanFrom": "Part of the paid plan of", + "subscriptionCurrentPlanBadge": "Current", + "year": "year", + "yearly": "Yearly", + "month": "month", + "monthly": "Monthly", + "proFeature1": "Unlimited media file uploads", + "proFeature2": "1 additional Plus user", + "proFeature3": "25GB Memories storage", + "proFeature4": "Restore flames", + "familyFeature1": "Unlimited media file uploads", + "familyFeature2": "4 additional Plus user", + "familyFeature3": "50GB Memories storage", + "familyFeature4": "Restore flames", + "freeFeature1": "10 Media file uploads per day", + "plusFeature1": "Unlimited media file uploads", + "plusFeature2": "Additional features (coming-soon)", + "manageAdditionalUsers": "Manage additional users", + "open": "Open", + "buy": "Buy", + "autoRenewal": "Auto renewal", + "additionalUsersList": "Your additional users", + "galleryDelete": "Delete file", + "galleryExport": "Export to gallery", + "galleryExportSuccess": "Successfully saved in the Gallery.", + "gallerySelectAll": "Select all", + "galleryDeselectAll": "Deselect all", + "galleryFavorite": "Favorite", + "galleryUnfavorite": "Unfavorite", + "galleryCancel": "Cancel", + "memoriesEmpty": "As soon as you save pictures or videos, they end up here in your memories.", + "deleteTitle": "Are you sure?", + "deleteOkBtnForAll": "Delete for all", + "memoriesDeleteSnackbarSuccess": "{count, plural, =1 {Deleted 1 item successfully} other {Deleted {count} items successfully}}", + "deleteOkBtnForMe": "Delete for me", + "deleteOnlyForMe": "Delete only for me", + "deleteImageTitle": "Are you sure?", + "deleteImageBody": "The image will be irrevocably deleted.", + "deleteMemoriesBody": "{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}", + "@deleteMemoriesBody": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "deleteMemoriesLocalOnly": "Local Only", + "deleteMemoriesCompletely": "Completely", + "memoriesSelectedCount": "{count, plural, =1 {1 Element} other {{count} Elements}}", + "@memoriesSelectedCount": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "memoriesMenuSelectAll": "Select all", + "memoriesMenuDeselectAll": "Deselect all", + "memoriesMenuExport": "Export to gallery", + "memoriesMenuFavorite": "Favorite", + "memoriesMenuDelete": "Delete completely", + "memoriesMenuDeleteLocal": "Delete locally", + "settingsBackup": "Backup", + "backupPending": "Pending", + "backupFailed": "Failed", + "backupSuccess": "Success", + "backupTwonlySafeDesc": "Back up your twonly identity, as this is the only way to restore your account if you uninstall the app or lose your phone.", + "backupNoPasswordRecovery": "Due to twonly's security system, there is (currently) no password recovery function. Therefore, you must remember your password or, better yet, write it down.", + "backupIdentityHeader": "Identity", + "backupArchiveHeader": "Contacts & Messages", + "backupLastBackupDate": "Last backup", + "backupLastBackupSize": "Backup size", + "backupLastBackupResult": "Result", + "backupInsecurePassword": "Insecure password", + "backupInsecurePasswordDesc": "The chosen password is very insecure and can therefore easily be guessed by attackers. Please choose a secure password.", + "backupInsecurePasswordOk": "Continue anyway", + "backupInsecurePasswordCancel": "Try again", + "backupTwonlySafeLongDesc": "twonly does not have any central user accounts. A key pair is created during installation, which consists of a public and a private key. The private key is only stored on your device to protect it from unauthorized access. The public key is uploaded to the server and linked to your chosen username so that others can find you.\n\ntwonly Backup regularly creates an encrypted, anonymous backup of your private key together with your contacts and settings. Your username and chosen password are enough to restore this data on another device.", + "backupSelectStrongPassword": "Select a secure password. It is recommended to generate and store it via your password manager of choice.", + "password": "Password", + "passwordRepeated": "Repeat password", + "passwordRepeatedNotEqual": "Passwords do not match.", + "backupPasswordRequirement": "Password must be at least 10 characters long.", + "backupEnableBackup": "Enable Password Backup", + "backupTwonlySaveNow": "Save now", + "backupChangePassword": "Change password", + "backupChangePasswordAuthReason": "Changing backup password", + "backupChangePasswordAuthFailed": "You can only change your password after you have authenticated!", + "backupFreeSpaceWithCloud": "Free Space with Cloud Backup", + "backupMemoriesNotEnabled": "Not enabled", + "backupMemoriesUpgradeRequired": "Upgrade required", + "todayAt": "Today at {time}", + "@todayAt": { + "placeholders": { + "time": {} + } + }, + "yesterdayAt": "Yesterday at {time}", + "@yesterdayAt": { + "placeholders": { + "time": {} + } + }, + "twonlySafeRecoverTitle": "Restore backup", + "twonlySafeRecoverBtn": "Restore backup", + "inviteFriends": "Invite your friends", + "inviteFriendsShareBtn": "Share", + "inviteFriendsShareText": "Let's switch to twonly: {url}", + "appOutdated": "Your version of twonly is out of date.", + "appOutdatedBtn": "Update Now", + "doubleClickToReopen": "Double-click\nto open again", + "uploadLimitReached": "The upload limit has\nbeen reached. Upgrade to Pro\nor wait until tomorrow.", + "fileLimitReached": "Maximum file size\nexceeded", + "retransmissionRequested": "Retransmission requested", + "reportUserTitle": "Report {username}", + "reportUserReason": "Reporting reason", + "reportUser": "Report contact", + "newDeviceRegistered": "You have logged in on another device. You have therefore been logged out here.", + "tabToRemoveEmoji": "Tab to remove", + "quotedMessageWasDeleted": "The quoted message has been deleted.", + "messageWasDeleted": "Message has been deleted.", + "messageWasDeletedShort": "Deleted", + "sent": "Delivered", + "sentTo": "Delivered to", + "received": "Received", + "opened": "Opened", + "waitingForInternet": "Waiting for internet", + "editHistory": "Edit history", + "fileSize": "File size", + "archivedChats": "Archived chats", + "durationShortSecond": "Sec.", + "durationShortMinute": "Min.", + "durationShortHour": "Hrs.", + "durationShortDays": "{count, plural, =1{1 Day} other{{count} Days}}", + "contacts": "Contacts", + "groups": "Groups", + "newGroup": "New group", + "selectMembers": "Select members", + "selectGroupName": "Select group name", + "groupNameInput": "Group name", + "groupMembers": "Members", + "addMember": "Add member", + "createGroup": "Create group", + "leaveGroup": "Leave group", + "createContactRequest": "Create contact request", + "contactRequestSend": "Contact request send", + "makeAdmin": "Make admin", + "removeAdmin": "Remove as admin", + "removeFromGroup": "Remove from group", + "admin": "Admin", + "revokeAdminRightsTitle": "Revoke {username}'s admin rights?", + "revokeAdminRightsOkBtn": "Remove as admin", + "makeAdminRightsTitle": "Make {username} an admin?", + "makeAdminRightsBody": "{username} will be able to edit this group and its members.", + "makeAdminRightsOkBtn": "Make admin", + "updateGroup": "Update group", + "alreadyInGroup": "Already in Group", + "contactNotVerified": "Not verified", + "removeContactFromGroupTitle": "Remove {username} from this group?", + "youChangedGroupName": "You have changed the group name to \"{newGroupName}\".", + "makerChangedGroupName": "{maker} has changed the group name to \"{newGroupName}\".", + "youCreatedGroup": "You have created the group.", + "makerCreatedGroup": "{maker} has created the group.", + "youRemovedMember": "You have removed {affected} from the group.", + "makerRemovedMember": "{maker} has removed {affected} from the group.", + "youAddedMember": "You have added {affected} to the group.", + "makerAddedMember": "{maker} has added {affected} to the group.", + "youMadeAdmin": "You made {affected} an admin.", + "makerMadeAdmin": "{maker} made {affected} an admin.", + "youRevokedAdminRights": "You revoked {affectedR}'s admin rights.", + "makerRevokedAdminRights": "{maker} revoked {affectedR}'s admin rights.", + "youLeftGroup": "You have left the group.", + "makerLeftGroup": "{maker} has left the group.", + "groupActionYou": "you", + "groupActionYour": "your", + "notificationFillerIn": "in", + "notificationText": "sent a message{inGroup}.", + "notificationTwonly": "sent a twonly{inGroup}.", + "notificationVideo": "sent a video{inGroup}.", + "notificationImage": "sent an image{inGroup}.", + "notificationAudio": "sent a voice message{inGroup}.", + "notificationAddedToGroup": "has added you to \"{groupname}\"", + "notificationContactRequest": "wants to connect with you.", + "notificationAcceptRequest": "is now connected with you.", + "notificationStoredMediaFile": "has stored your image.", + "notificationReaction": "has reacted to your image.", + "notificationReopenedMedia": "has reopened your image.", + "notificationReactionToVideo": "has reacted with {reaction} to your video.", + "notificationReactionToText": "has reacted with {reaction} to your message.", + "notificationReactionToImage": "has reacted with {reaction} to your image.", + "notificationReactionToAudio": "has reacted with {reaction} to your audio message.", + "notificationResponse": "has responded{inGroup}.", + "notificationCategoryMessageTitle": "Messages", + "notificationCategoryMessageDesc": "Messages from other users.", + "notificationConnectionFallback": "You may have new messages.", + "groupContextMenuDeleteGroup": "This will permanently delete all messages in this chat.", + "groupYouAreNowLongerAMember": "You are no longer part of this group.", + "groupNetworkIssue": "Network issue. Try again later.", + "leaveGroupSelectOtherAdminTitle": "Select another admin", + "leaveGroupSelectOtherAdminBody": "To leave the group, you must first select a new administrator.", + "leaveGroupSureTitle": "Leave group", + "leaveGroupSureBody": "Do you really want to leave the group?", + "leaveGroupSureOkBtn": "Leave group", + "changeDisplayMaxTime": "Chats will now be deleted after {time} ({username}).", + "youChangedDisplayMaxTime": "Chats will now be deleted after {time}.", + "userGotReported": "User has been reported.", + "deleteChatAfter": "Delete chat after...", + "deleteChatAfterAnHour": "one hour.", + "deleteChatAfterADay": "one day.", + "deleteChatAfterAWeek": "one week.", + "deleteChatAfterAMonth": "one month.", + "deleteChatAfterAYear": "one year.", + "yourTwonlyScore": "Your twonly-Score", + "registrationClosed": "Due to the current high volume of registrations, we have temporarily disabled registration to ensure that the service remains reliable. Please try again in a few days.", + "dialogAskDeleteMediaFilePopTitle": "Are you sure you want to delete your masterpiece?", + "dialogAskDeleteMediaFilePopDelete": "Delete", + "allowErrorTracking": "Share errors and crashes with us", + "allowErrorTrackingSubtitle": "If twonly crashes or errors occur, these are automatically reported to our self-hosted Glitchtip instance. Personal data such as messages or images are never uploaded.", + "avatarSaveChanges": "Would you like to save the changes?", + "avatarSaveChangesStore": "Save", + "avatarSaveChangesDiscard": "Discard", + "inProcess": "In process", + "draftMessage": "Draft", + "voiceMessageSlideToCancel": "Slide to cancel", + "voiceMessageCancel": "Cancel", + "shareYourProfile": "Share your profile", + "scanOtherProfile": "Scan other profile", + "openYourOwnQRcode": "Open your own QR code", + "addContactQrSheetSubtext": "Let a friend scan this QR code to add you", + "letUserScanQrCode": "Let {username} scan this QR code", + "@letUserScanQrCode": { + "placeholders": { + "username": {} + } + }, + "finishSetupCardTitle": "Complete your profile", + "finishSetupCardDesc": "You are almost there! Finish setting up your account to get the most out of twonly.", + "finishSetupCardAction": "Resume Setup", + "missingBackupCardTitle": "Setup backup", + "missingBackupCardDesc": "We have improved the backup mechanism, which requires you to set it up again.", + "missingBackupCardAction": "Set up now", + "onboardingFinishLater": "Finish later", + "onboardingProfileTitle": "Choose your look", + "onboardingProfileBody": "Select an avatar and a display name that friends will see.", + "onboardingBackupBody": "Back up your twonly identity, as this is the only way to restore your account if you uninstall the app or lose your phone.", + "onboardingUserDiscoveryShareFriends": "Share your friends", + "onboardingUserDiscoveryShareFriendsDesc": "Share with your friends who you know and who you have verified. Friends can *only see mutual friends* from your friend list. You can change your mind at *any time* or *hide specific people*.", + "onboardingUserDiscoveryContactsVerifiedBadge": "Contacts verified by your friends get a badge", + "onboardingUserDiscoveryWhoIsRequesting": "Be informed about who is requesting", + "userDiscoverySettingsManualApproval": "Ask every time before sharing", + "userDiscoverySettingsManualApprovalDesc": "Before one of your friends is shared, you will be asked every time.", + "onboardingUserDiscoveryLetFriendsFindYou": "Let your friends find you", + "onboardingUserDiscoveryBeRecommended": "Be recommended to others", + "onboardingUserDiscoveryWhatOthersSee": "What others will see", + "onboardingUserDiscoveryWhatYouSee": "If requested, that's what you will see", + "onboardingAddContactsTitle": "Add new contacts", + "onboardingAddContactsAcceptDesc": "In twonly, every contact must first be accepted before you can communicate.", + "onboardingAddContactsMethodHeading": "Add contacts", + "onboardingAddContactsMethodScan": "Scan the contact's QR code.", + "onboardingAddContactsMethodSearch": "Search for the username.", + "onboardingAddContactsMethodShare": "Share a contact in chats.", + "linkFromUsername": "Is the link from {username}?", + "linkFromUsernameLong": "If you received the link from your friend, you can mark the user as verified, as the public key in the link matches the public key already stored for that user?", + "gotLinkFromFriend": "Yes, I got the link from my friend!", + "couldNotVerifyUsername": "Could not verify {username}", + "linkPubkeyDoesNotMatch": "The public key in the link does not match the public key stored for this contact. Try to meet your friend in person and scan the QR code directly!", + "startWithCameraOpen": "Start with camera open", + "showImagePreviewWhenSending": "Display image preview when selecting recipients", + "verifiedPublicKey": "The identity of {username} has been successfully verified.", + "memoriesAYearAgo": "One year ago", + "memoriesXYearsAgo": "{years} years ago", + "migrationOfMemories": "Migration of media files: {open} still to be processed.", + "autoStoreAllSendUnlimitedMediaFiles": "Save all sent media", + "autoStoreAllSendUnlimitedMediaFilesSubtitle": "If you enable this option, all images you send will be saved as long as they were sent with an infinite countdown and not in twonly mode.", + "termsOfService": "Terms of service", + "privacyPolicy": "Privacy policy", + "additionalUserAddError": "{username} could not be added, please try again later.", + "additionalUserAddErrorNotInFreePlan": "{username} is already on a paid plan and therefore could not be added.", + "additionalUserAddButton": "Add additional user ({used}/{limit})", + "additionalUserRemoveTitle": "Remove this additional user", + "additionalUserRemoveDesc": "After removal, the additional user will automatically be downgraded to the free plan, and you can add another person.", + "additionalUserSelectTitle": "Select additional users", + "additionalUserSelectButton": "Select users ({used}/{limit})", + "storeAsDefault": "Store as default", + "deleteUserErrorMessage": "You can only delete the contact once the direct chat has been deleted and the contact is no longer a member of a group.", + "groupSizeLimitError": "Currently, group size is limited to {size} people!", + "authRequestReopenImage": "You must authenticate to reopen the image.", + "shareContactsMenu": "Contact", + "shareContactsTitle": "Select contacts", + "shareContactsSubmit": "Share now", + "updateTwonlyMessage": "To see this message, you need to update twonly.", + "verificationBadgeNote": "You can verify your friends by scanning their public QR code. Click to learn more.", + "verificationBadgeTitle": "Verification", + "verificationBadgeGeneralDesc": "The badge gives you the peace of mind that you are messaging the *right person* and your *messages remain confidential*.", + "verificationBadgeGreenDesc": "A contact you have *personally verified* using the QR code.", + "verificationBadgeYellowDesc": "A contact who has been verified by at least one of *your contacts*.", + "verificationBadgeRedDesc": "A contact whose identity has *not* yet been verified.", + "scanNow": "Scan now", + "qrScannerVerifyHint": "To verify, the other person must open their QR code (Chat Lists > QR code button at the bottom right)", + "qrScannerVerifyUserHint": "To verify, {username} must open their QR code (Chat Lists > QR code button at the bottom right)", + "@qrScannerVerifyUserHint": { + "placeholders": { + "username": {} + } + }, + "openQrCode": "Open QR code", + "letFriendScanQrToVerify": "Let a friend scan this QR code to verify you", + "deleteVerificationTitle": "Delete verification?", + "deleteVerificationBody": "Are you sure you want to delete this verification?", + "secretQrTokenVerifiedSnackbar": "{username} has scanned your QR code and is now verified.", + "@secretQrTokenVerifiedSnackbar": { + "placeholders": { + "username": {} + } + }, + "askForFriendPromotionsPrompt": "Help {username} find familiar faces on twonly by sharing mutual friends.", + "@askForFriendPromotionsPrompt": { + "placeholders": { + "username": {} + } + }, + "mutualGroupsTitle": "{count, plural, =1{1 mutual group} other{{count} mutual groups}}", + "@mutualGroupsTitle": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "chatEntryFlameRestored": "{count} flames restored", + "requestedUserToastText": "{username} was successfully requested.", + "profileYourQrCode": "Your QR code", + "settingsScreenLock": "Screen lock", + "settingsScreenLockSubtitle": "To open twonly, you'll need to use your smartphone's unlock feature.", + "settingsScreenLockAuthMessageEnable": "Use the screen lock from twonly.", + "settingsScreenLockAuthMessageDisable": "Disable the screen lock from twonly.", + "unlockTwonly": "Unlock twonly", + "unlockTwonlyTryAgain": "Try again", + "unlockTwonlyDesc": "Use your phone's unlock settings to unlock twonly", + "settingsTypingIndication": "Typing Indicators", + "settingsTypingIndicationSubtitle": "When the typing indicator is turned off, you can't see when others are typing a message.", + "contactActionBlock": "Block", + "contactActionAccept": "Accept", + "userDiscoverySettingsMutualFriends": "Choose how many mutual friends a person must have for you to be suggested to them.", + "userDiscoverySettingsApply": "Apply changes", + "userDiscoveryEnabledDisableWarning": "If you disable the \"Mutual Friends\" feature, you will no longer see suggestions. You will also stop sharing your friends with new contacts.", + "userDiscoveryEnabledChangeSettings": "Change settings", + "userDiscoveryEnabledFaq": "In our FAQ we explain how the \"Mutual Friends\" feature works.", + "userDiscoveryDisabledIntro": "twonly does *not* collect your phone number or needs access to your contacts. Instead, twonly can *find your friends through mutual friends*.", + "userDiscoverySettingsTitle": "Mutual Friends", + "userDiscoveryFeatureOffers": "Your benefits at a glance", + "userDiscoveryDisabledLearnMore": "Learn more", + "userDiscoveryEnabledDialogTitle": "Really disable?", + "userDiscoveryEnabledFriendsShared": "Friends you share", + "userDiscoveryEnabledFriendsSharedDesc": "You only share friends who have also activated this feature and who have reached the threshold you set.", + "userDiscoverySettingsCurrentlyDisabled": "The feature \"Mutual Friends\" is currently disabled.", + "userDiscoveryEnabledNoFriendsShared": "You are not sharing anyone yet.", + "userDiscoveryActionDisable": "Disable", + "friendSuggestionsTitle": "Friend suggestions", + "andWord": "and", + "friendSuggestionsFriendsWith": "Friends with {friends}.", + "@friendSuggestionsFriendsWith": { + "placeholders": { + "friends": {} + } + }, + "friendSuggestionsGroupMemberIn": " Group member in {groups}.", + "@friendSuggestionsGroupMemberIn": { + "placeholders": { + "groups": {} + } + }, + "friendSuggestionsRequest": "Request", + "friendSuggestionsAskFriend": "Ask your friends", + "askFriendsDialogTitle": "Ask about {username}", + "@askFriendsDialogTitle": { + "placeholders": { + "username": {} + } + }, + "askFriendsDialogDescription": "Select the friends you want to ask about this user:", + "askFriendsDialogConfirm": "Ask", + "askFriendsDialogCancel": "Cancel", + "chatAskAFriendReceivedDescription": "Your friend just got this as a suggestion and wants to know if he knows this person.", + "chatAskAFriendAddedDescription": "You have added this user to your contacts.", + "chatAskAFriendHide": "Hide", + "chatAskAFriendRequest": "Request", + "chatAskAFriendUnknownUser": "User {userId}", + "@chatAskAFriendUnknownUser": { + "placeholders": { + "userId": {} + } + }, + "contactUserDiscoveryImagesLeft": "{imagesLeft} more images are needed until your friends are shared with {username}.", + "@contactUserDiscoveryImagesLeft": { + "placeholders": { + "imagesLeft": {}, + "username": {} + } + }, + "userDiscoveryEnabledVersion": "Version: {version}", + "@userDiscoveryEnabledVersion": { + "placeholders": { + "version": {} + } + }, + "userDiscoveryEnabledYourVersion": "Your version: {version}", + "@userDiscoveryEnabledYourVersion": { + "placeholders": { + "version": {} + } + }, + "userDiscoveryEnabledStopSharing": "Stop sharing", + "userDiscoveryManualApprovalReachedThreshold": "Would you like to share {username} with your friends?", + "@userDiscoveryManualApprovalReachedThreshold": { + "placeholders": { + "username": {} + } + }, + "userDiscoveryManualApprovalHideContact": "Hide contact", + "userDiscoveryManualApprovalShareContact": "Share contact", + "onboardingSetupCompleteTitle": "You're all set, {username}!", + "@onboardingSetupCompleteTitle": { + "placeholders": { + "username": {} + } + }, + "onboardingSetupCompleteDesc": "You can now share your moments with your friends securely without distractions like ads.", + "contactUserDiscoveryManualApprovalPending": "Contact was not yet manually approved.", + "contactUserDiscoveryManualApprovalApprove": "Approve", + "exampleUserName1": "james", + "exampleUserName2": "mary", + "exampleUserName3": "john", + "exampleUserName4": "patricia", + "exampleUserName5": "robert", + "exampleUserName6": "jennifer", + "exampleUserName7": "michael", + "exampleUserName8": "linda", + "exampleUserName9": "william", + "exampleUserName10": "lena", + "exampleUserName11": "david", + "exampleJane": "jane", + "back": "Back", + "makerChangedUsername": "{maker} changed their username from {oldName} to {newName}.", + "@makerChangedUsername": { + "placeholders": { + "maker": {}, + "oldName": {}, + "newName": {} + } + }, + "makerChangedDisplayName": "{maker} changed their display name from {oldName} to {newName}.", + "@makerChangedDisplayName": { + "placeholders": { + "maker": {}, + "oldName": {}, + "newName": {} + } + }, + "recoverErrorNoInternet": "No internet connection. Please check your network and try again.", + "recoverErrorUsernameNotValid": "The username provided is not valid or does not exist.", + "recoverErrorPasswordInvalid": "The password provided is incorrect.", + "recoverErrorTryAgainLater": "The server is currently unavailable. Please try again later.", + "recoverErrorUnknown": "An unknown error occurred. Please try again.", + "recoverSuccessTitle": "Backup successfully recovered.", + "recoverSuccessBody": "Click here to open the app again", + "iosRecoveryWelcomeBack": "Welcome Back", + "iosRecoveryPrompt": "We detected a previously secured twonly identity on this device. Would you like to automatically download and restore your contacts, messages, and settings from your cloud archive?", + "iosRecoveryNoBackupFound": "No backup archive could be retrieved from the server for this device.\n\nError: {error}\n\nPlease proceed to register a new twonly account.", + "@iosRecoveryNoBackupFound": { + "placeholders": { + "error": {} + } + }, + "registerNewAccount": "Register New Account", + "tryRestoreAgain": "Try Restore Again", + "registeringNewAccount": "Registering new account", + "createShortcut": "Create shortcut", + "editShortcut": "Edit shortcut", + "deleteShortcut": "Delete shortcut", + "deleteShortcutBody": "Are you sure you want to delete this shortcut?", + "updateShortcut": "Update shortcut", + "selectEmoji": "Select Emoji", + "errorEmojiUsedOrInvalid": "Emoji already used or invalid", + "subscriptionPledgeSecureTitle": "Secure by Design", + "subscriptionPledgeSecureDesc": "Your messages and shared moments are fully end-to-end encrypted.", + "subscriptionPledgeNoAdsTitle": "No Ads or Data selling", + "subscriptionPledgeNoAdsDesc": "twonly will never show advertisements or sell your private data.", + "subscriptionPledgeSubtitle": "Zero ads. Total privacy.", + "subscriptionManage": "Manage subscription", + "dragToZoom": "Drag to Zoom", + "onboardingProfileSelectionTitle": "Choose your setup path", + "onboardingProfileSelectionSubtitle": "Choose how you want to configure your security and privacy settings.", + "onboardingProfileSelectionDefaultTitle": "Default", + "onboardingProfileSelectionDefaultDesc": "Instantly applies recommended settings so you can start using the app.", + "onboardingProfileSelectionDefaultBadge": "Fast Setup", + "onboardingProfileSelectionCustomizeTitle": "Customize", + "onboardingProfileSelectionCustomizeDesc": "Step-by-step setup so you can decide for yourself.", + "replyFlameRestored": "Flames restored", + "replyAskAFriend": "Ask a friend", + "unverifiedWarningButton": "Verify now", + "today": "Today", + "yesterday": "Yesterday", + "galleryDisableWarningTitle": "Disable gallery saving?", + "galleryDisableWarningBody": "If you disable this, your media files will not be saved to your gallery and could be permanently lost if twonly is removed or has an issue, as media files are not yet backed up.", + "galleryDisableWarningConfirm": "Disable", + "settingsStorageScanGalleryTitle": "Import from Gallery", + "importGalleryDeselectAll": "Deselect all", + "importGallerySelectAll": "Select all", + "importGalleryPermissionRequired": "Permission to access your gallery is required to import previous twonly media files.", + "importGalleryPermissionError": "An error occurred while requesting permission: {error}", + "@importGalleryPermissionError": { + "placeholders": { + "error": {} + } + }, + "importGalleryLoadError": "Failed to load assets: {error}", + "@importGalleryLoadError": { + "placeholders": { + "error": {} + } + }, + "importGalleryImportingOf": "Importing {current} of {total}...", + "@importGalleryImportingOf": { + "placeholders": { + "current": {}, + "total": {} + } + }, + "importGalleryStarting": "Starting import...", + "importGalleryComplete": "Import complete: {imported} successfully imported and {failed} failed.", + "@importGalleryComplete": { + "placeholders": { + "imported": {}, + "failed": {} + } + }, + "importGalleryGrantAccess": "Grant Access", + "importGalleryOpenSettings": "Open Settings", + "importGalleryPermissionDenied": "Permission to access gallery denied.", + "importGalleryTryAgain": "Try Again", + "importGalleryAlbumNotFound": "\"twonly\" album not found", + "importGalleryAlbumNotFoundDesc": "If you don't have this album yet, you can also create it to import photos into twonly.", + "importGalleryNoImagesFound": "No images found", + "importGalleryNoImagesFoundDesc": "There are no images on your device.", + "importGalleryFilterTwonly": "Only show the twonly-Album", + "importGalleryRefresh": "Refresh", + "importGallerySelectToImport": "Select items to import", + "importGalleryImportCount": "{count, plural, =1{Import 1 item} other{Import {count} items}}", + "@importGalleryImportCount": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "emptyChatListTitle": "Find your first friend", + "emptyChatListDesc": "Let friends scan your QR code, or share them your profile.", + "emptyChatListShareBtn": "Share your profile", + "emptyChatListScanBtn": "QR Code", + "emptyChatListAddUsernameBtn": "By Username", + "avatarCustomizeRandomize": "Randomize", + "avatarCustomizeReset": "Reset", + "passwordlessRecovery": "Trusted Friends", + "passwordlessRecoveryNotConfigured": "Not configured", + "passwordlessRecoveryTestPin": "Test PIN", + "passwordlessRecoveryTestPinTitle": "Test PIN", + "passwordlessRecoveryTestPinHint": "Enter your PIN", + "passwordlessRecoveryTestPinCorrect": "PIN is correct!", + "passwordlessRecoveryTestPinIncorrect": "Incorrect PIN.", + "passwordlessRecoveryTest": "Test", + "passwordlessRecoverySecondFactorNone": "None", + "passwordlessRecoverySecondFactorEmailLabel": "Email ({email})", + "@passwordlessRecoverySecondFactorEmailLabel": { + "placeholders": { + "email": {} + } + }, + "passwordlessRecoverySecondFactorPin": "PIN", + "passwordlessRecoverySecondFactorEmail": "Email", + "passwordlessRecoveryModify": "Modify Recovery Settings", + "passwordlessRecoveryModifyDesc": "Update your recovery configuration", + "passwordlessRecoverySecondFactor": "Second Factor", + "passwordlessRecoveryNoFriendsFound": "No trusted friends found.", + "passwordlessRecoveryActiveFriends": "Active Friends", + "passwordlessRecoveryActiveFriendsDesc": "These trusted friends are actively using twonly and can probably help you recover your account.", + "passwordlessRecoveryInactiveFriends": "Inactive Friends", + "passwordlessRecoveryInactiveFriendsDesc": "These friends either have not yet received their share or do not use twonly actively anymore, which could mean that they maybe cannot help.", + "passwordlessRecoveryEnableSuccess": "Trusted Friends successfully set up!", + "passwordlessRecoveryEnterPin": "Please enter a PIN.", + "passwordlessRecoveryEnterEmail": "Please enter an email address.", + "passwordlessRecoveryEnableBtn": "Enable Trusted Friends", + "passwordlessRecoveryRecoverBtn": "Recover via Trusted Friends", + "passwordlessRecoveryModifyBtn": "Modify Trusted Friends", + "passwordlessRecoveryStatusEnabled": "Enabled • {count} trusted friends", + "@passwordlessRecoveryStatusEnabled": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "passwordlessRecoveryInfoHowItWorks": "How it works", + "passwordlessRecoveryInfoHowItWorksDesc": "twonly operates without phone numbers, which is why we rely on a decentralized recovery mechanism. Using Shamir's Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined number of these friends must combine their shares.", + "passwordlessRecoveryInfoWhySecondFactor": "Why a Second Factor?", + "passwordlessRecoveryInfoWhySecondFactorDesc": "The second factor (Email or PIN) provides important additional protection. Even if your friends were to secretly team up, they still couldn't access your account without this second factor.", + "passwordlessRecoveryInfoGotIt": "Got it", + "passwordlessRecoveryMethod": "Second factor method", + "passwordlessRecoveryMethodNoneDesc": "Without second-factor, your friends could collaborate to recover your account. Therefore, it is recommended to configure a second-factor.", + "passwordlessRecoveryMethodPinHint": "Enter PIN", + "passwordlessRecoveryMethodEmailHint": "Enter recovery email address", + "passwordlessRecoveryMethodEmailDesc": "Your email address is *never stored on the server* and is only sent to it in the event of a recovery.", + "passwordlessRecoveryThresholdDesc": "To recover your account you need {count} of your selected trusted friends.", + "@passwordlessRecoveryThresholdDesc": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "passwordlessRecoveryThresholdTitle": "Required trusted friends for recovery", + "passwordlessRecoverySelectFriendsNeeded": "Select friends ({count} more needed)", + "@passwordlessRecoverySelectFriendsNeeded": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "passwordlessRecoverySelectFriends": "Select trusted friends", + "passwordlessRecoveryTrustedFriends": "Account recovery", + "passwordlessRecoveryDoneBtn": "Done ({count})", + "@passwordlessRecoveryDoneBtn": { + "placeholders": { + "count": { + "type": "num" + } + } + }, + "passwordlessRecoveryNoFriendsSelected": "No trusted friends selected yet", + "passwordlessRecoverySubtitle": "Recover your identity without a password.", + "recoverPasswordlessExplanation": "If enabled, you can recover your account by asking your selected friends to help you.", + "recoverPasswordlessNotificationCardTitle": "Enable notifications", + "recoverPasswordlessNotificationCardSubtitle": "Get notified when a friend has helped you recover your account.", + "recoverPasswordlessNotificationCardBtn": "Enable push notifications", + "recoverPasswordlessQrInstructions": "Let friends scan the QR code or share the link.", + "recoverPasswordlessShareBtn": "Share Recovery Link", + "recoverPasswordlessCopyBtn": "Copy", + "recoverPasswordlessCopiedSnackbar": "Link copied to clipboard!", + "passwordlessRecoveryNoShareStored": "No recovery share stored for this contact.", + "passwordlessRecoveryEmailSent": "Recovery email sent!", + "passwordlessRecoveryShareSent": "Recovery share sent!", + "passwordlessRecoveryNetworkError": "Network error, please ensure you have internet", + "passwordlessRecoveryInvalidEmail": "The email address is invalid.", + "passwordlessRecoveryResendEmail": "Resend recovery email", + "passwordlessRecoveryHelpAFriend": "Help a Friend", + "passwordlessRecoverySelectContactDesc": "Please select the contact who requested recovery below to send them their recovery key.", + "passwordlessRecoverySearchContacts": "Search contacts...", + "passwordlessRecoveryNoContactsFound": "No contacts found", + "passwordlessRecoveryCantHelpHim": "You can't help this person", + "passwordlessRecoveryDoesAskedYou": "Has {username} asked you?", + "@passwordlessRecoveryDoesAskedYou": { + "placeholders": { + "username": {} + } + }, + "passwordlessRecoveryVerifySourceDesc": "Please make sure this request really came from this person. If in doubt, ask them via another channel!", + "passwordlessRecoveryNo": "No", + "passwordlessRecoveryYes": "Yes", + "passwordlessRecoveryYesWithTimer": "Yes ({seconds}s)", + "@passwordlessRecoveryYesWithTimer": { + "placeholders": { + "seconds": {} + } + }, + "recoverPasswordlessSharesReceived": "{received} out of {threshold} friends have helped", + "@recoverPasswordlessSharesReceived": { + "placeholders": { + "received": {}, + "threshold": {} + } + }, + "recoverPasswordlessRecoverNowBtn": "Recover now", + "missingRecoveryContactsCardTitle": "Account Recovery", + "missingRecoveryContactsCardDesc": "Select Recovery Contacts to help you recover your account without requiring a password.", + "missingRecoveryContactsCardAction": "Select Contacts", + "memoriesBackupLimitReached": "Cloud backup limit reached! Please upgrade your plan or free up space.", + "settingsStorageLocal": "Local storage", + "settingsStorageNoCloudBackupTitle": "No Cloud Backup", + "settingsStorageNoCloudBackupCard": "Back up your memories to the cloud to free up local space and ensure you never lose your pictures. All end-to-end encrypted.", + "galleryActionSave": "Save", + "galleryActionExport": "Export", + "galleryActionFavorite": "Favorite", + "galleryActionUnfavorite": "Unfavorite", + "galleryActionDelete": "Delete", + "galleryActionShare": "Share", + "settingsStorageHidePromo": "Hide", + "memoriesBackupTitle": "Memories Backup", + "settingsHelpNews": "News", + "settingsStorageContents": "Free up space", + "settingsStorageSortStorage": "Occupied storage", + "settingsStorageSortNewest": "Newest", + "settingsStorageSortOldest": "Oldest", + "settingsStorageNoContents": "No storage contents found.", + "settingsStorageCloudOnly": "Cloud only", + "settingsStorageLocalAndCloud": "Local and cloud", + "settingsStorageLocalOnly": "Local only", + "settingsStorageDisableBackupTitle": "Disable Backup?", + "settingsStorageDisableBackupBody": "This will delete all backed up memories from the cloud. There {count, plural, =1 {is *1 image*} other {are *{count} images*}} only stored in the cloud that will be *permanently lost*. Are you sure you want to proceed?", + "@settingsStorageDisableBackupBody": { + "placeholders": { + "count": {} + } + }, + "settingsStorageDisableBackupBtn": "Disable", + "settingsStorageDisableBackupAction": "Disable Cloud Backup", + "settingsStorageSyncNow": "Sync now", + "settingsStorageSyncUpToDate": "All memories are up to date.", + "restoreLostFlames": "Restore your {count} lost flames", + "@restoreLostFlames": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "settingsShowRestoreFlameTitle": "Show flame restore warning", + "contactLabelsTitle": "Contact Labels", + "contactLabelsSubtitleEmpty": "No labels selected", + "contactLabelsMaxLimit": "Maximum 3 labels per contact", + "createLabel": "Create new label", + "editLabel": "Edit label", + "deleteLabel": "Delete label", + "deleteLabelConfirmation": "Are you sure you want to delete this label? It will be removed from all contacts.", + "labelNameHint": "Label name", + "labelTextColor": "Text color", + "labelBackgroundColor": "Background color", + "customColor": "Custom Color", + "hue": "Hue", + "saturation": "Saturation", + "brightness": "Brightness", + "settingsAppearancePrimaryColor": "Primary Color", + "themeSystemDefault": "System default", + "themeLight": "Light", + "themeDark": "Dark", + "backupRecoverySectionTitle": "Secure Account Access", + "backupRecoverySectionDescNone": "You must select at least one recovery option, as otherwise no one can help you if you lose access to your device.", + "backupRecoverySectionDescSome": "You can recover your account with the recovery options below. It is recommended to enable both options.", + "backupRecoveryOptionAFriends": "Via trusted friends", + "backupRecoveryOptionAMicrocopy": "Select friends who can help you recover your account.", + "backupRecoveryOptionBPassword": "Classic Password", + "backupRecoveryOptionBMicrocopy": "Set a password for your access.", + "backupCloudSectionTitle": "Backup Your Data", + "backupCloudSectionDesc": "Backup your content so nothing gets lost.", + "backupCloudContactsMessages": "Contacts & Messages", + "backupCloudFreeActive": "Free Active", + "backupCloudImagesMedia": "Images & Media", + "backupCloudProBadge": "Pro", + "memoriesAddingToFavorites": "Adding to favorites...", + "memoriesRemovingFromFavorites": "Removing from favorites...", + "memoriesDeletingProgress": "Deleting memories...", + "memoriesExportingProgress": "Exporting memories...", + "fileLimitReachedTitle": "File too large to send", + "fileLimitReachedDetail": "This file is {size}, but your plan allows at most {limit} per send.", + "@fileLimitReachedDetail": { + "placeholders": { + "size": { "type": "String" }, + "limit": { "type": "String" } + } + }, + "fileLimitReachedDetailNoSize": "Your plan allows at most {limit} per send.", + "@fileLimitReachedDetailNoSize": { + "placeholders": { + "limit": { "type": "String" } + } + }, + "fileLimitReachedHint": "Record a shorter video and send it again.", + "fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.", + "fileLimitReachedUpgrade": "Upgrade plan" +} diff --git a/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart b/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart index 51fb7fc9..f188949b 100644 --- a/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart +++ b/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart @@ -259,6 +259,7 @@ class Handshake_Register extends $pb.GeneratedMessage { $core.String? langCode, $fixnum.Int64? proofOfWork, $core.List<$core.int>? loginToken, + Handshake_InitialPqcKeys? initialPqcKeys, }) { final result = create(); if (username != null) result.username = username; @@ -273,6 +274,7 @@ class Handshake_Register extends $pb.GeneratedMessage { if (langCode != null) result.langCode = langCode; if (proofOfWork != null) result.proofOfWork = proofOfWork; if (loginToken != null) result.loginToken = loginToken; + if (initialPqcKeys != null) result.initialPqcKeys = initialPqcKeys; return result; } @@ -305,6 +307,8 @@ class Handshake_Register extends $pb.GeneratedMessage { ..aInt64(10, _omitFieldNames ? '' : 'proofOfWork') ..a<$core.List<$core.int>>( 11, _omitFieldNames ? '' : 'loginToken', $pb.PbFieldType.OY) + ..aOM(12, _omitFieldNames ? '' : 'initialPqcKeys', + subBuilder: Handshake_InitialPqcKeys.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -344,6 +348,8 @@ class Handshake_Register extends $pb.GeneratedMessage { @$pb.TagNumber(2) void clearInviteCode() => $_clearField(2); + /// Deprecated legacy Signal fields. The server ignores these fields; they + /// remain allocated only to avoid reusing their wire tags. @$pb.TagNumber(3) $core.List<$core.int> get publicIdentityKey => $_getN(2); @$pb.TagNumber(3) @@ -425,6 +431,287 @@ class Handshake_Register extends $pb.GeneratedMessage { $core.bool hasLoginToken() => $_has(10); @$pb.TagNumber(11) void clearLoginToken() => $_clearField(11); + + @$pb.TagNumber(12) + Handshake_InitialPqcKeys get initialPqcKeys => $_getN(11); + @$pb.TagNumber(12) + set initialPqcKeys(Handshake_InitialPqcKeys value) => $_setField(12, value); + @$pb.TagNumber(12) + $core.bool hasInitialPqcKeys() => $_has(11); + @$pb.TagNumber(12) + void clearInitialPqcKeys() => $_clearField(12); + @$pb.TagNumber(12) + Handshake_InitialPqcKeys ensureInitialPqcKeys() => $_ensure(11); +} + +class Handshake_InitialPqcKeys_PqcPreKey extends $pb.GeneratedMessage { + factory Handshake_InitialPqcKeys_PqcPreKey({ + $fixnum.Int64? eccPreKeyId, + $core.List<$core.int>? eccPreKey, + $fixnum.Int64? kyberPreKeyId, + $core.List<$core.int>? kyberPreKey, + $core.List<$core.int>? kyberPreKeySignature, + }) { + final result = create(); + if (eccPreKeyId != null) result.eccPreKeyId = eccPreKeyId; + if (eccPreKey != null) result.eccPreKey = eccPreKey; + if (kyberPreKeyId != null) result.kyberPreKeyId = kyberPreKeyId; + if (kyberPreKey != null) result.kyberPreKey = kyberPreKey; + if (kyberPreKeySignature != null) + result.kyberPreKeySignature = kyberPreKeySignature; + return result; + } + + Handshake_InitialPqcKeys_PqcPreKey._(); + + factory Handshake_InitialPqcKeys_PqcPreKey.fromBuffer( + $core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory Handshake_InitialPqcKeys_PqcPreKey.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'Handshake.InitialPqcKeys.PqcPreKey', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'client_to_server'), + createEmptyInstance: create) + ..aInt64(1, _omitFieldNames ? '' : 'eccPreKeyId') + ..a<$core.List<$core.int>>( + 2, _omitFieldNames ? '' : 'eccPreKey', $pb.PbFieldType.OY) + ..aInt64(3, _omitFieldNames ? '' : 'kyberPreKeyId') + ..a<$core.List<$core.int>>( + 4, _omitFieldNames ? '' : 'kyberPreKey', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>( + 5, _omitFieldNames ? '' : 'kyberPreKeySignature', $pb.PbFieldType.OY) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Handshake_InitialPqcKeys_PqcPreKey clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Handshake_InitialPqcKeys_PqcPreKey copyWith( + void Function(Handshake_InitialPqcKeys_PqcPreKey) updates) => + super.copyWith((message) => + updates(message as Handshake_InitialPqcKeys_PqcPreKey)) + as Handshake_InitialPqcKeys_PqcPreKey; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Handshake_InitialPqcKeys_PqcPreKey create() => + Handshake_InitialPqcKeys_PqcPreKey._(); + @$core.override + Handshake_InitialPqcKeys_PqcPreKey createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static Handshake_InitialPqcKeys_PqcPreKey getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor( + create); + static Handshake_InitialPqcKeys_PqcPreKey? _defaultInstance; + + @$pb.TagNumber(1) + $fixnum.Int64 get eccPreKeyId => $_getI64(0); + @$pb.TagNumber(1) + set eccPreKeyId($fixnum.Int64 value) => $_setInt64(0, value); + @$pb.TagNumber(1) + $core.bool hasEccPreKeyId() => $_has(0); + @$pb.TagNumber(1) + void clearEccPreKeyId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get eccPreKey => $_getN(1); + @$pb.TagNumber(2) + set eccPreKey($core.List<$core.int> value) => $_setBytes(1, value); + @$pb.TagNumber(2) + $core.bool hasEccPreKey() => $_has(1); + @$pb.TagNumber(2) + void clearEccPreKey() => $_clearField(2); + + @$pb.TagNumber(3) + $fixnum.Int64 get kyberPreKeyId => $_getI64(2); + @$pb.TagNumber(3) + set kyberPreKeyId($fixnum.Int64 value) => $_setInt64(2, value); + @$pb.TagNumber(3) + $core.bool hasKyberPreKeyId() => $_has(2); + @$pb.TagNumber(3) + void clearKyberPreKeyId() => $_clearField(3); + + @$pb.TagNumber(4) + $core.List<$core.int> get kyberPreKey => $_getN(3); + @$pb.TagNumber(4) + set kyberPreKey($core.List<$core.int> value) => $_setBytes(3, value); + @$pb.TagNumber(4) + $core.bool hasKyberPreKey() => $_has(3); + @$pb.TagNumber(4) + void clearKyberPreKey() => $_clearField(4); + + @$pb.TagNumber(5) + $core.List<$core.int> get kyberPreKeySignature => $_getN(4); + @$pb.TagNumber(5) + set kyberPreKeySignature($core.List<$core.int> value) => $_setBytes(4, value); + @$pb.TagNumber(5) + $core.bool hasKyberPreKeySignature() => $_has(4); + @$pb.TagNumber(5) + void clearKyberPreKeySignature() => $_clearField(5); +} + +class Handshake_InitialPqcKeys extends $pb.GeneratedMessage { + factory Handshake_InitialPqcKeys({ + $core.List<$core.int>? publicIdentityKey, + $fixnum.Int64? registrationId, + $fixnum.Int64? eccSignedPrekeyId, + $core.List<$core.int>? eccSignedPrekey, + $core.List<$core.int>? eccSignedPrekeySignature, + $fixnum.Int64? kyberSignedPrekeyId, + $core.List<$core.int>? kyberSignedPrekey, + $core.List<$core.int>? kyberSignedPrekeySignature, + $core.Iterable? prekeys, + }) { + final result = create(); + if (publicIdentityKey != null) result.publicIdentityKey = publicIdentityKey; + if (registrationId != null) result.registrationId = registrationId; + if (eccSignedPrekeyId != null) result.eccSignedPrekeyId = eccSignedPrekeyId; + if (eccSignedPrekey != null) result.eccSignedPrekey = eccSignedPrekey; + if (eccSignedPrekeySignature != null) + result.eccSignedPrekeySignature = eccSignedPrekeySignature; + if (kyberSignedPrekeyId != null) + result.kyberSignedPrekeyId = kyberSignedPrekeyId; + if (kyberSignedPrekey != null) result.kyberSignedPrekey = kyberSignedPrekey; + if (kyberSignedPrekeySignature != null) + result.kyberSignedPrekeySignature = kyberSignedPrekeySignature; + if (prekeys != null) result.prekeys.addAll(prekeys); + return result; + } + + Handshake_InitialPqcKeys._(); + + factory Handshake_InitialPqcKeys.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory Handshake_InitialPqcKeys.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'Handshake.InitialPqcKeys', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'client_to_server'), + createEmptyInstance: create) + ..a<$core.List<$core.int>>( + 1, _omitFieldNames ? '' : 'publicIdentityKey', $pb.PbFieldType.OY) + ..aInt64(2, _omitFieldNames ? '' : 'registrationId') + ..aInt64(3, _omitFieldNames ? '' : 'eccSignedPrekeyId') + ..a<$core.List<$core.int>>( + 4, _omitFieldNames ? '' : 'eccSignedPrekey', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>(5, + _omitFieldNames ? '' : 'eccSignedPrekeySignature', $pb.PbFieldType.OY) + ..aInt64(6, _omitFieldNames ? '' : 'kyberSignedPrekeyId') + ..a<$core.List<$core.int>>( + 7, _omitFieldNames ? '' : 'kyberSignedPrekey', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>(8, + _omitFieldNames ? '' : 'kyberSignedPrekeySignature', $pb.PbFieldType.OY) + ..pPM( + 9, _omitFieldNames ? '' : 'prekeys', + subBuilder: Handshake_InitialPqcKeys_PqcPreKey.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Handshake_InitialPqcKeys clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Handshake_InitialPqcKeys copyWith( + void Function(Handshake_InitialPqcKeys) updates) => + super.copyWith((message) => updates(message as Handshake_InitialPqcKeys)) + as Handshake_InitialPqcKeys; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Handshake_InitialPqcKeys create() => Handshake_InitialPqcKeys._(); + @$core.override + Handshake_InitialPqcKeys createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static Handshake_InitialPqcKeys getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static Handshake_InitialPqcKeys? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.int> get publicIdentityKey => $_getN(0); + @$pb.TagNumber(1) + set publicIdentityKey($core.List<$core.int> value) => $_setBytes(0, value); + @$pb.TagNumber(1) + $core.bool hasPublicIdentityKey() => $_has(0); + @$pb.TagNumber(1) + void clearPublicIdentityKey() => $_clearField(1); + + @$pb.TagNumber(2) + $fixnum.Int64 get registrationId => $_getI64(1); + @$pb.TagNumber(2) + set registrationId($fixnum.Int64 value) => $_setInt64(1, value); + @$pb.TagNumber(2) + $core.bool hasRegistrationId() => $_has(1); + @$pb.TagNumber(2) + void clearRegistrationId() => $_clearField(2); + + @$pb.TagNumber(3) + $fixnum.Int64 get eccSignedPrekeyId => $_getI64(2); + @$pb.TagNumber(3) + set eccSignedPrekeyId($fixnum.Int64 value) => $_setInt64(2, value); + @$pb.TagNumber(3) + $core.bool hasEccSignedPrekeyId() => $_has(2); + @$pb.TagNumber(3) + void clearEccSignedPrekeyId() => $_clearField(3); + + @$pb.TagNumber(4) + $core.List<$core.int> get eccSignedPrekey => $_getN(3); + @$pb.TagNumber(4) + set eccSignedPrekey($core.List<$core.int> value) => $_setBytes(3, value); + @$pb.TagNumber(4) + $core.bool hasEccSignedPrekey() => $_has(3); + @$pb.TagNumber(4) + void clearEccSignedPrekey() => $_clearField(4); + + @$pb.TagNumber(5) + $core.List<$core.int> get eccSignedPrekeySignature => $_getN(4); + @$pb.TagNumber(5) + set eccSignedPrekeySignature($core.List<$core.int> value) => + $_setBytes(4, value); + @$pb.TagNumber(5) + $core.bool hasEccSignedPrekeySignature() => $_has(4); + @$pb.TagNumber(5) + void clearEccSignedPrekeySignature() => $_clearField(5); + + @$pb.TagNumber(6) + $fixnum.Int64 get kyberSignedPrekeyId => $_getI64(5); + @$pb.TagNumber(6) + set kyberSignedPrekeyId($fixnum.Int64 value) => $_setInt64(5, value); + @$pb.TagNumber(6) + $core.bool hasKyberSignedPrekeyId() => $_has(5); + @$pb.TagNumber(6) + void clearKyberSignedPrekeyId() => $_clearField(6); + + @$pb.TagNumber(7) + $core.List<$core.int> get kyberSignedPrekey => $_getN(6); + @$pb.TagNumber(7) + set kyberSignedPrekey($core.List<$core.int> value) => $_setBytes(6, value); + @$pb.TagNumber(7) + $core.bool hasKyberSignedPrekey() => $_has(6); + @$pb.TagNumber(7) + void clearKyberSignedPrekey() => $_clearField(7); + + @$pb.TagNumber(8) + $core.List<$core.int> get kyberSignedPrekeySignature => $_getN(7); + @$pb.TagNumber(8) + set kyberSignedPrekeySignature($core.List<$core.int> value) => + $_setBytes(7, value); + @$pb.TagNumber(8) + $core.bool hasKyberSignedPrekeySignature() => $_has(7); + @$pb.TagNumber(8) + void clearKyberSignedPrekeySignature() => $_clearField(8); + + @$pb.TagNumber(9) + $pb.PbList get prekeys => $_getList(8); } class Handshake_GetAuthChallenge extends $pb.GeneratedMessage { @@ -602,6 +889,7 @@ class Handshake_Authenticate extends $pb.GeneratedMessage { $core.String? appVersion, $fixnum.Int64? deviceId, $core.bool? inBackground, + $core.bool? supportsMailboxV2, }) { final result = create(); if (userId != null) result.userId = userId; @@ -609,6 +897,7 @@ class Handshake_Authenticate extends $pb.GeneratedMessage { if (appVersion != null) result.appVersion = appVersion; if (deviceId != null) result.deviceId = deviceId; if (inBackground != null) result.inBackground = inBackground; + if (supportsMailboxV2 != null) result.supportsMailboxV2 = supportsMailboxV2; return result; } @@ -632,6 +921,7 @@ class Handshake_Authenticate extends $pb.GeneratedMessage { ..aOS(3, _omitFieldNames ? '' : 'appVersion') ..aInt64(4, _omitFieldNames ? '' : 'deviceId') ..aOB(5, _omitFieldNames ? '' : 'inBackground') + ..aOB(6, _omitFieldNames ? '' : 'supportsMailboxV2') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -698,6 +988,17 @@ class Handshake_Authenticate extends $pb.GeneratedMessage { $core.bool hasInBackground() => $_has(4); @$pb.TagNumber(5) void clearInBackground() => $_clearField(5); + + /// Set by clients that understand PendingMessagesV2 / RequestPendingMessages. + /// Absent or false means the legacy NewMessage(s) mailbox protocol is used. + @$pb.TagNumber(6) + $core.bool get supportsMailboxV2 => $_getBF(5); + @$pb.TagNumber(6) + set supportsMailboxV2($core.bool value) => $_setBool(5, value); + @$pb.TagNumber(6) + $core.bool hasSupportsMailboxV2() => $_has(5); + @$pb.TagNumber(6) + void clearSupportsMailboxV2() => $_clearField(6); } class Handshake_AuthenticateWithLoginToken extends $pb.GeneratedMessage { @@ -707,6 +1008,7 @@ class Handshake_AuthenticateWithLoginToken extends $pb.GeneratedMessage { $core.String? appVersion, $fixnum.Int64? deviceId, $core.bool? inBackground, + $core.bool? supportsMailboxV2, }) { final result = create(); if (userId != null) result.userId = userId; @@ -714,6 +1016,7 @@ class Handshake_AuthenticateWithLoginToken extends $pb.GeneratedMessage { if (appVersion != null) result.appVersion = appVersion; if (deviceId != null) result.deviceId = deviceId; if (inBackground != null) result.inBackground = inBackground; + if (supportsMailboxV2 != null) result.supportsMailboxV2 = supportsMailboxV2; return result; } @@ -738,6 +1041,7 @@ class Handshake_AuthenticateWithLoginToken extends $pb.GeneratedMessage { ..aOS(3, _omitFieldNames ? '' : 'appVersion') ..aInt64(4, _omitFieldNames ? '' : 'deviceId') ..aOB(5, _omitFieldNames ? '' : 'inBackground') + ..aOB(6, _omitFieldNames ? '' : 'supportsMailboxV2') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -807,6 +1111,16 @@ class Handshake_AuthenticateWithLoginToken extends $pb.GeneratedMessage { $core.bool hasInBackground() => $_has(4); @$pb.TagNumber(5) void clearInBackground() => $_clearField(5); + + /// See Authenticate.supports_mailbox_v2. + @$pb.TagNumber(6) + $core.bool get supportsMailboxV2 => $_getBF(5); + @$pb.TagNumber(6) + set supportsMailboxV2($core.bool value) => $_setBool(5, value); + @$pb.TagNumber(6) + $core.bool hasSupportsMailboxV2() => $_has(5); + @$pb.TagNumber(6) + void clearSupportsMailboxV2() => $_clearField(6); } class Handshake_GetServerKeyForPasswordLessRecovery @@ -1388,11 +1702,13 @@ class ApplicationData_TextMessage extends $pb.GeneratedMessage { $fixnum.Int64? userId, $core.List<$core.int>? body, $core.List<$core.int>? pushData, + $core.bool? wakeReceiver, }) { final result = create(); if (userId != null) result.userId = userId; if (body != null) result.body = body; if (pushData != null) result.pushData = pushData; + if (wakeReceiver != null) result.wakeReceiver = wakeReceiver; return result; } @@ -1415,6 +1731,7 @@ class ApplicationData_TextMessage extends $pb.GeneratedMessage { 3, _omitFieldNames ? '' : 'body', $pb.PbFieldType.OY) ..a<$core.List<$core.int>>( 4, _omitFieldNames ? '' : 'pushData', $pb.PbFieldType.OY) + ..aOB(5, _omitFieldNames ? '' : 'wakeReceiver') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1465,6 +1782,15 @@ class ApplicationData_TextMessage extends $pb.GeneratedMessage { $core.bool hasPushData() => $_has(2); @$pb.TagNumber(4) void clearPushData() => $_clearField(4); + + @$pb.TagNumber(5) + $core.bool get wakeReceiver => $_getBF(3); + @$pb.TagNumber(5) + set wakeReceiver($core.bool value) => $_setBool(3, value); + @$pb.TagNumber(5) + $core.bool hasWakeReceiver() => $_has(3); + @$pb.TagNumber(5) + void clearWakeReceiver() => $_clearField(5); } class ApplicationData_GetUserByUsername extends $pb.GeneratedMessage { @@ -2176,6 +2502,8 @@ class ApplicationData_UploadPqcPreKeys extends $pb.GeneratedMessage { $core.List<$core.int>? kyberSignedPrekey, $core.List<$core.int>? kyberSignedPrekeySignature, $core.Iterable? prekeys, + $core.List<$core.int>? publicIdentityKey, + $fixnum.Int64? registrationId, }) { final result = create(); if (eccSignedPrekeyId != null) result.eccSignedPrekeyId = eccSignedPrekeyId; @@ -2188,6 +2516,8 @@ class ApplicationData_UploadPqcPreKeys extends $pb.GeneratedMessage { if (kyberSignedPrekeySignature != null) result.kyberSignedPrekeySignature = kyberSignedPrekeySignature; if (prekeys != null) result.prekeys.addAll(prekeys); + if (publicIdentityKey != null) result.publicIdentityKey = publicIdentityKey; + if (registrationId != null) result.registrationId = registrationId; return result; } @@ -2218,6 +2548,9 @@ class ApplicationData_UploadPqcPreKeys extends $pb.GeneratedMessage { _omitFieldNames ? '' : 'kyberSignedPrekeySignature', $pb.PbFieldType.OY) ..pPM(7, _omitFieldNames ? '' : 'prekeys', subBuilder: ApplicationData_PqcPreKey.create) + ..a<$core.List<$core.int>>( + 8, _omitFieldNames ? '' : 'publicIdentityKey', $pb.PbFieldType.OY) + ..aInt64(9, _omitFieldNames ? '' : 'registrationId') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -2301,6 +2634,26 @@ class ApplicationData_UploadPqcPreKeys extends $pb.GeneratedMessage { @$pb.TagNumber(7) $pb.PbList get prekeys => $_getList(6); + + /// Required when uploading keys for the first time after a keyless + /// registration. Optional for later key refreshes. + @$pb.TagNumber(8) + $core.List<$core.int> get publicIdentityKey => $_getN(7); + @$pb.TagNumber(8) + set publicIdentityKey($core.List<$core.int> value) => $_setBytes(7, value); + @$pb.TagNumber(8) + $core.bool hasPublicIdentityKey() => $_has(7); + @$pb.TagNumber(8) + void clearPublicIdentityKey() => $_clearField(8); + + @$pb.TagNumber(9) + $fixnum.Int64 get registrationId => $_getI64(8); + @$pb.TagNumber(9) + set registrationId($fixnum.Int64 value) => $_setInt64(8, value); + @$pb.TagNumber(9) + $core.bool hasRegistrationId() => $_has(8); + @$pb.TagNumber(9) + void clearRegistrationId() => $_clearField(9); } class ApplicationData_DownloadDone extends $pb.GeneratedMessage { @@ -3301,6 +3654,52 @@ class ApplicationData_DisableMemoriesBackup extends $pb.GeneratedMessage { static ApplicationData_DisableMemoriesBackup? _defaultInstance; } +/// Asks the server to (re)start a mailbox drain for the authenticated user. +/// The server responds immediately; messages arrive as PendingMessagesV2. +class ApplicationData_RequestPendingMessages extends $pb.GeneratedMessage { + factory ApplicationData_RequestPendingMessages() => create(); + + ApplicationData_RequestPendingMessages._(); + + factory ApplicationData_RequestPendingMessages.fromBuffer( + $core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ApplicationData_RequestPendingMessages.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ApplicationData.RequestPendingMessages', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'client_to_server'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ApplicationData_RequestPendingMessages clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ApplicationData_RequestPendingMessages copyWith( + void Function(ApplicationData_RequestPendingMessages) updates) => + super.copyWith((message) => + updates(message as ApplicationData_RequestPendingMessages)) + as ApplicationData_RequestPendingMessages; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ApplicationData_RequestPendingMessages create() => + ApplicationData_RequestPendingMessages._(); + @$core.override + ApplicationData_RequestPendingMessages createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static ApplicationData_RequestPendingMessages getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor< + ApplicationData_RequestPendingMessages>(create); + static ApplicationData_RequestPendingMessages? _defaultInstance; +} + enum ApplicationData_ApplicationData { textMessage, getUserByUsername, @@ -3330,6 +3729,7 @@ enum ApplicationData_ApplicationData { deleteMemory, disableMemoriesBackup, uploadPqcPrekeys, + requestPendingMessages, notSet } @@ -3363,6 +3763,7 @@ class ApplicationData extends $pb.GeneratedMessage { ApplicationData_DeleteMemory? deleteMemory, ApplicationData_DisableMemoriesBackup? disableMemoriesBackup, ApplicationData_UploadPqcPreKeys? uploadPqcPrekeys, + ApplicationData_RequestPendingMessages? requestPendingMessages, }) { final result = create(); if (textMessage != null) result.textMessage = textMessage; @@ -3404,6 +3805,8 @@ class ApplicationData extends $pb.GeneratedMessage { if (disableMemoriesBackup != null) result.disableMemoriesBackup = disableMemoriesBackup; if (uploadPqcPrekeys != null) result.uploadPqcPrekeys = uploadPqcPrekeys; + if (requestPendingMessages != null) + result.requestPendingMessages = requestPendingMessages; return result; } @@ -3446,6 +3849,7 @@ class ApplicationData extends $pb.GeneratedMessage { 38: ApplicationData_ApplicationData.deleteMemory, 39: ApplicationData_ApplicationData.disableMemoriesBackup, 40: ApplicationData_ApplicationData.uploadPqcPrekeys, + 43: ApplicationData_ApplicationData.requestPendingMessages, 0: ApplicationData_ApplicationData.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -3481,7 +3885,8 @@ class ApplicationData extends $pb.GeneratedMessage { 37, 38, 39, - 40 + 40, + 43 ]) ..aOM(1, _omitFieldNames ? '' : 'textMessage', protoName: 'textMessage', @@ -3579,6 +3984,9 @@ class ApplicationData extends $pb.GeneratedMessage { ..aOM( 40, _omitFieldNames ? '' : 'uploadPqcPrekeys', subBuilder: ApplicationData_UploadPqcPreKeys.create) + ..aOM( + 43, _omitFieldNames ? '' : 'requestPendingMessages', + subBuilder: ApplicationData_RequestPendingMessages.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -3628,6 +4036,7 @@ class ApplicationData extends $pb.GeneratedMessage { @$pb.TagNumber(38) @$pb.TagNumber(39) @$pb.TagNumber(40) + @$pb.TagNumber(43) ApplicationData_ApplicationData whichApplicationData() => _ApplicationData_ApplicationDataByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @@ -3658,6 +4067,7 @@ class ApplicationData extends $pb.GeneratedMessage { @$pb.TagNumber(38) @$pb.TagNumber(39) @$pb.TagNumber(40) + @$pb.TagNumber(43) void clearApplicationData() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -4003,6 +4413,20 @@ class ApplicationData extends $pb.GeneratedMessage { void clearUploadPqcPrekeys() => $_clearField(40); @$pb.TagNumber(40) ApplicationData_UploadPqcPreKeys ensureUploadPqcPrekeys() => $_ensure(27); + + @$pb.TagNumber(43) + ApplicationData_RequestPendingMessages get requestPendingMessages => + $_getN(28); + @$pb.TagNumber(43) + set requestPendingMessages(ApplicationData_RequestPendingMessages value) => + $_setField(43, value); + @$pb.TagNumber(43) + $core.bool hasRequestPendingMessages() => $_has(28); + @$pb.TagNumber(43) + void clearRequestPendingMessages() => $_clearField(43); + @$pb.TagNumber(43) + ApplicationData_RequestPendingMessages ensureRequestPendingMessages() => + $_ensure(28); } class Response_PreKey extends $pb.GeneratedMessage { @@ -4173,18 +4597,84 @@ class Response_PqcPrekeys extends $pb.GeneratedMessage { $pb.PbList get prekeys => $_getList(0); } -enum Response_Ok_Ok { none, prekeys, prekeysPqc, notSet } +/// Response to a PendingMessagesV2 batch. Contains only the delivery IDs the +/// client has durably persisted; the server deletes exactly those rows. +class Response_AcknowledgedPendingMessages extends $pb.GeneratedMessage { + factory Response_AcknowledgedPendingMessages({ + $core.Iterable<$fixnum.Int64>? deliveryIds, + }) { + final result = create(); + if (deliveryIds != null) result.deliveryIds.addAll(deliveryIds); + return result; + } + + Response_AcknowledgedPendingMessages._(); + + factory Response_AcknowledgedPendingMessages.fromBuffer( + $core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory Response_AcknowledgedPendingMessages.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'Response.AcknowledgedPendingMessages', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'client_to_server'), + createEmptyInstance: create) + ..p<$fixnum.Int64>( + 1, _omitFieldNames ? '' : 'deliveryIds', $pb.PbFieldType.K6) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Response_AcknowledgedPendingMessages clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + Response_AcknowledgedPendingMessages copyWith( + void Function(Response_AcknowledgedPendingMessages) updates) => + super.copyWith((message) => + updates(message as Response_AcknowledgedPendingMessages)) + as Response_AcknowledgedPendingMessages; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Response_AcknowledgedPendingMessages create() => + Response_AcknowledgedPendingMessages._(); + @$core.override + Response_AcknowledgedPendingMessages createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static Response_AcknowledgedPendingMessages getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor< + Response_AcknowledgedPendingMessages>(create); + static Response_AcknowledgedPendingMessages? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList<$fixnum.Int64> get deliveryIds => $_getList(0); +} + +enum Response_Ok_Ok { + none, + prekeys, + prekeysPqc, + acknowledgedPendingMessages, + notSet +} class Response_Ok extends $pb.GeneratedMessage { factory Response_Ok({ $core.bool? none, Response_Prekeys? prekeys, Response_PqcPrekeys? prekeysPqc, + Response_AcknowledgedPendingMessages? acknowledgedPendingMessages, }) { final result = create(); if (none != null) result.none = none; if (prekeys != null) result.prekeys = prekeys; if (prekeysPqc != null) result.prekeysPqc = prekeysPqc; + if (acknowledgedPendingMessages != null) + result.acknowledgedPendingMessages = acknowledgedPendingMessages; return result; } @@ -4201,6 +4691,7 @@ class Response_Ok extends $pb.GeneratedMessage { 1: Response_Ok_Ok.none, 2: Response_Ok_Ok.prekeys, 3: Response_Ok_Ok.prekeysPqc, + 4: Response_Ok_Ok.acknowledgedPendingMessages, 0: Response_Ok_Ok.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -4208,12 +4699,15 @@ class Response_Ok extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'client_to_server'), createEmptyInstance: create) - ..oo(0, [1, 2, 3]) + ..oo(0, [1, 2, 3, 4]) ..aOB(1, _omitFieldNames ? '' : 'None', protoName: 'None') ..aOM(2, _omitFieldNames ? '' : 'prekeys', subBuilder: Response_Prekeys.create) ..aOM(3, _omitFieldNames ? '' : 'prekeysPqc', subBuilder: Response_PqcPrekeys.create) + ..aOM( + 4, _omitFieldNames ? '' : 'acknowledgedPendingMessages', + subBuilder: Response_AcknowledgedPendingMessages.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -4238,10 +4732,12 @@ class Response_Ok extends $pb.GeneratedMessage { @$pb.TagNumber(1) @$pb.TagNumber(2) @$pb.TagNumber(3) + @$pb.TagNumber(4) Response_Ok_Ok whichOk() => _Response_Ok_OkByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @$pb.TagNumber(2) @$pb.TagNumber(3) + @$pb.TagNumber(4) void clearOk() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -4274,6 +4770,20 @@ class Response_Ok extends $pb.GeneratedMessage { void clearPrekeysPqc() => $_clearField(3); @$pb.TagNumber(3) Response_PqcPrekeys ensurePrekeysPqc() => $_ensure(2); + + @$pb.TagNumber(4) + Response_AcknowledgedPendingMessages get acknowledgedPendingMessages => + $_getN(3); + @$pb.TagNumber(4) + set acknowledgedPendingMessages(Response_AcknowledgedPendingMessages value) => + $_setField(4, value); + @$pb.TagNumber(4) + $core.bool hasAcknowledgedPendingMessages() => $_has(3); + @$pb.TagNumber(4) + void clearAcknowledgedPendingMessages() => $_clearField(4); + @$pb.TagNumber(4) + Response_AcknowledgedPendingMessages ensureAcknowledgedPendingMessages() => + $_ensure(3); } enum Response_Response { ok, error, notSet } diff --git a/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart b/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart index a7aa3f2c..086055e1 100644 --- a/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart +++ b/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart @@ -183,6 +183,7 @@ const Handshake$json = { '3': [ Handshake_RequestPOW$json, Handshake_Register$json, + Handshake_InitialPqcKeys$json, Handshake_GetAuthChallenge$json, Handshake_GetUserIdByUsername$json, Handshake_GetAuthToken$json, @@ -221,18 +222,46 @@ const Handshake_Register$json = { '3': 3, '4': 1, '5': 12, - '10': 'publicIdentityKey' + '9': 1, + '10': 'publicIdentityKey', + '17': true + }, + { + '1': 'signed_prekey', + '3': 4, + '4': 1, + '5': 12, + '9': 2, + '10': 'signedPrekey', + '17': true }, - {'1': 'signed_prekey', '3': 4, '4': 1, '5': 12, '10': 'signedPrekey'}, { '1': 'signed_prekey_signature', '3': 5, '4': 1, '5': 12, - '10': 'signedPrekeySignature' + '9': 3, + '10': 'signedPrekeySignature', + '17': true + }, + { + '1': 'signed_prekey_id', + '3': 6, + '4': 1, + '5': 3, + '9': 4, + '10': 'signedPrekeyId', + '17': true + }, + { + '1': 'registration_id', + '3': 7, + '4': 1, + '5': 3, + '9': 5, + '10': 'registrationId', + '17': true }, - {'1': 'signed_prekey_id', '3': 6, '4': 1, '5': 3, '10': 'signedPrekeyId'}, - {'1': 'registration_id', '3': 7, '4': 1, '5': 3, '10': 'registrationId'}, {'1': 'is_ios', '3': 8, '4': 1, '5': 8, '10': 'isIos'}, {'1': 'lang_code', '3': 9, '4': 1, '5': 9, '10': 'langCode'}, {'1': 'proof_of_work', '3': 10, '4': 1, '5': 3, '10': 'proofOfWork'}, @@ -241,14 +270,114 @@ const Handshake_Register$json = { '3': 11, '4': 1, '5': 12, - '9': 1, + '9': 6, '10': 'loginToken', '17': true }, + { + '1': 'initial_pqc_keys', + '3': 12, + '4': 1, + '5': 11, + '6': '.client_to_server.Handshake.InitialPqcKeys', + '9': 7, + '10': 'initialPqcKeys', + '17': true + }, ], '8': [ {'1': '_invite_code'}, + {'1': '_public_identity_key'}, + {'1': '_signed_prekey'}, + {'1': '_signed_prekey_signature'}, + {'1': '_signed_prekey_id'}, + {'1': '_registration_id'}, {'1': '_login_token'}, + {'1': '_initial_pqc_keys'}, + ], +}; + +@$core.Deprecated('Use handshakeDescriptor instead') +const Handshake_InitialPqcKeys$json = { + '1': 'InitialPqcKeys', + '2': [ + { + '1': 'public_identity_key', + '3': 1, + '4': 1, + '5': 12, + '10': 'publicIdentityKey' + }, + {'1': 'registration_id', '3': 2, '4': 1, '5': 3, '10': 'registrationId'}, + { + '1': 'ecc_signed_prekey_id', + '3': 3, + '4': 1, + '5': 3, + '10': 'eccSignedPrekeyId' + }, + { + '1': 'ecc_signed_prekey', + '3': 4, + '4': 1, + '5': 12, + '10': 'eccSignedPrekey' + }, + { + '1': 'ecc_signed_prekey_signature', + '3': 5, + '4': 1, + '5': 12, + '10': 'eccSignedPrekeySignature' + }, + { + '1': 'kyber_signed_prekey_id', + '3': 6, + '4': 1, + '5': 3, + '10': 'kyberSignedPrekeyId' + }, + { + '1': 'kyber_signed_prekey', + '3': 7, + '4': 1, + '5': 12, + '10': 'kyberSignedPrekey' + }, + { + '1': 'kyber_signed_prekey_signature', + '3': 8, + '4': 1, + '5': 12, + '10': 'kyberSignedPrekeySignature' + }, + { + '1': 'prekeys', + '3': 9, + '4': 3, + '5': 11, + '6': '.client_to_server.Handshake.InitialPqcKeys.PqcPreKey', + '10': 'prekeys' + }, + ], + '3': [Handshake_InitialPqcKeys_PqcPreKey$json], +}; + +@$core.Deprecated('Use handshakeDescriptor instead') +const Handshake_InitialPqcKeys_PqcPreKey$json = { + '1': 'PqcPreKey', + '2': [ + {'1': 'ecc_pre_key_id', '3': 1, '4': 1, '5': 3, '10': 'eccPreKeyId'}, + {'1': 'ecc_pre_key', '3': 2, '4': 1, '5': 12, '10': 'eccPreKey'}, + {'1': 'kyber_pre_key_id', '3': 3, '4': 1, '5': 3, '10': 'kyberPreKeyId'}, + {'1': 'kyber_pre_key', '3': 4, '4': 1, '5': 12, '10': 'kyberPreKey'}, + { + '1': 'kyber_pre_key_signature', + '3': 5, + '4': 1, + '5': 12, + '10': 'kyberPreKeySignature' + }, ], }; @@ -307,11 +436,21 @@ const Handshake_Authenticate$json = { '10': 'inBackground', '17': true }, + { + '1': 'supports_mailbox_v2', + '3': 6, + '4': 1, + '5': 8, + '9': 3, + '10': 'supportsMailboxV2', + '17': true + }, ], '8': [ {'1': '_app_version'}, {'1': '_device_id'}, {'1': '_in_background'}, + {'1': '_supports_mailbox_v2'}, ], }; @@ -330,6 +469,18 @@ const Handshake_AuthenticateWithLoginToken$json = { {'1': 'app_version', '3': 3, '4': 1, '5': 9, '10': 'appVersion'}, {'1': 'device_id', '3': 4, '4': 1, '5': 3, '10': 'deviceId'}, {'1': 'in_background', '3': 5, '4': 1, '5': 8, '10': 'inBackground'}, + { + '1': 'supports_mailbox_v2', + '3': 6, + '4': 1, + '5': 8, + '9': 0, + '10': 'supportsMailboxV2', + '17': true + }, + ], + '8': [ + {'1': '_supports_mailbox_v2'}, ], }; @@ -443,39 +594,59 @@ final $typed_data.Uint8List handshakeDescriptor = $convert.base64Decode( 'ZmljYXRpb25IAFIgcmVnaXN0ZXJQYXNzd29yZGxlc3NOb3RpZmljYXRpb24SjQEKI2NoZWNrX2' 'Zvcl9wYXNzd29yZGxlc3Nfbm90aWZpY2F0aW9uGAogASgLMjwuY2xpZW50X3RvX3NlcnZlci5I' 'YW5kc2hha2UuQ2hlY2tGb3JQYXNzd29yZGxlc3NOb3RpZmljYXRpb25IAFIgY2hlY2tGb3JQYX' - 'Nzd29yZGxlc3NOb3RpZmljYXRpb24aDAoKUmVxdWVzdFBPVxrKAwoIUmVnaXN0ZXISGgoIdXNl' + 'Nzd29yZGxlc3NOb3RpZmljYXRpb24aDAoKUmVxdWVzdFBPVxrCBQoIUmVnaXN0ZXISGgoIdXNl' 'cm5hbWUYASABKAlSCHVzZXJuYW1lEiQKC2ludml0ZV9jb2RlGAIgASgJSABSCmludml0ZUNvZG' - 'WIAQESLgoTcHVibGljX2lkZW50aXR5X2tleRgDIAEoDFIRcHVibGljSWRlbnRpdHlLZXkSIwoN' - 'c2lnbmVkX3ByZWtleRgEIAEoDFIMc2lnbmVkUHJla2V5EjYKF3NpZ25lZF9wcmVrZXlfc2lnbm' - 'F0dXJlGAUgASgMUhVzaWduZWRQcmVrZXlTaWduYXR1cmUSKAoQc2lnbmVkX3ByZWtleV9pZBgG' - 'IAEoA1IOc2lnbmVkUHJla2V5SWQSJwoPcmVnaXN0cmF0aW9uX2lkGAcgASgDUg5yZWdpc3RyYX' - 'Rpb25JZBIVCgZpc19pb3MYCCABKAhSBWlzSW9zEhsKCWxhbmdfY29kZRgJIAEoCVIIbGFuZ0Nv' - 'ZGUSIgoNcHJvb2Zfb2Zfd29yaxgKIAEoA1ILcHJvb2ZPZldvcmsSJAoLbG9naW5fdG9rZW4YCy' - 'ABKAxIAVIKbG9naW5Ub2tlbogBAUIOCgxfaW52aXRlX2NvZGVCDgoMX2xvZ2luX3Rva2VuGhIK' - 'EEdldEF1dGhDaGFsbGVuZ2UaMQoTR2V0VXNlcklkQnlVc2VybmFtZRIaCgh1c2VybmFtZRgBIA' - 'EoCVIIdXNlcm5hbWUaQwoMR2V0QXV0aFRva2VuEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBIa' - 'CghyZXNwb25zZRgCIAEoDFIIcmVzcG9uc2Ua6AEKDEF1dGhlbnRpY2F0ZRIXCgd1c2VyX2lkGA' - 'EgASgDUgZ1c2VySWQSHQoKYXV0aF90b2tlbhgCIAEoDFIJYXV0aFRva2VuEiQKC2FwcF92ZXJz' - 'aW9uGAMgASgJSABSCmFwcFZlcnNpb26IAQESIAoJZGV2aWNlX2lkGAQgASgDSAFSCGRldmljZU' - 'lkiAEBEigKDWluX2JhY2tncm91bmQYBSABKAhIAlIMaW5CYWNrZ3JvdW5kiAEBQg4KDF9hcHBf' - 'dmVyc2lvbkIMCgpfZGV2aWNlX2lkQhAKDl9pbl9iYWNrZ3JvdW5kGsYBChpBdXRoZW50aWNhdG' - 'VXaXRoTG9naW5Ub2tlbhIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSLAoSc2VjcmV0X2xvZ2lu' - 'X3Rva2VuGAIgASgMUhBzZWNyZXRMb2dpblRva2VuEh8KC2FwcF92ZXJzaW9uGAMgASgJUgphcH' - 'BWZXJzaW9uEhsKCWRldmljZV9pZBgEIAEoA1IIZGV2aWNlSWQSIwoNaW5fYmFja2dyb3VuZBgF' - 'IAEoCFIMaW5CYWNrZ3JvdW5kGqUCCiNHZXRTZXJ2ZXJLZXlGb3JQYXNzd29yZExlc3NSZWNvdm' - 'VyeRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSMgoVc2VydmVyX2tleV9wcm90ZWN0aW9uGAIg' - 'ASgMUhNzZXJ2ZXJLZXlQcm90ZWN0aW9uEi0KEHBpbl91bmxvY2tfdG9rZW4YAyABKAxIAFIOcG' - 'luVW5sb2NrVG9rZW6IAQESMQoScGluX3Byb3RlY3Rpb25fa2V5GAQgASgMSAFSEHBpblByb3Rl' - 'Y3Rpb25LZXmIAQESGQoFZW1haWwYBSABKAlIAlIFZW1haWyIAQFCEwoRX3Bpbl91bmxvY2tfdG' - '9rZW5CFQoTX3Bpbl9wcm90ZWN0aW9uX2tleUIICgZfZW1haWwaywEKIFJlZ2lzdGVyUGFzc3dv' - 'cmRsZXNzTm90aWZpY2F0aW9uEicKD25vdGlmaWNhdGlvbl9pZBgBIAEoCVIObm90aWZpY2F0aW' - '9uSWQSLgoTZG93bmxvYWRfYXV0aF90b2tlbhgCIAEoDFIRZG93bmxvYWRBdXRoVG9rZW4SGwoJ' - 'bGFuZ19jb2RlGAMgASgJUghsYW5nQ29kZRIiCgpnb29nbGVfZmNtGAQgASgJSABSCWdvb2dsZU' - 'ZjbYgBAUINCgtfZ29vZ2xlX2ZjbRq8AQogQ2hlY2tGb3JQYXNzd29yZGxlc3NOb3RpZmljYXRp' - 'b24SJwoPbm90aWZpY2F0aW9uX2lkGAEgASgJUg5ub3RpZmljYXRpb25JZBIuChNkb3dubG9hZF' - '9hdXRoX3Rva2VuGAIgASgMUhFkb3dubG9hZEF1dGhUb2tlbhI/ChxhbHJlYWR5X3JlY2VpdmVk' - 'X21lc3NhZ2VfaWRzGAMgAygDUhlhbHJlYWR5UmVjZWl2ZWRNZXNzYWdlSWRzQgsKCUhhbmRzaG' - 'FrZQ=='); + 'WIAQESMwoTcHVibGljX2lkZW50aXR5X2tleRgDIAEoDEgBUhFwdWJsaWNJZGVudGl0eUtleYgB' + 'ARIoCg1zaWduZWRfcHJla2V5GAQgASgMSAJSDHNpZ25lZFByZWtleYgBARI7ChdzaWduZWRfcH' + 'Jla2V5X3NpZ25hdHVyZRgFIAEoDEgDUhVzaWduZWRQcmVrZXlTaWduYXR1cmWIAQESLQoQc2ln' + 'bmVkX3ByZWtleV9pZBgGIAEoA0gEUg5zaWduZWRQcmVrZXlJZIgBARIsCg9yZWdpc3RyYXRpb2' + '5faWQYByABKANIBVIOcmVnaXN0cmF0aW9uSWSIAQESFQoGaXNfaW9zGAggASgIUgVpc0lvcxIb' + 'CglsYW5nX2NvZGUYCSABKAlSCGxhbmdDb2RlEiIKDXByb29mX29mX3dvcmsYCiABKANSC3Byb2' + '9mT2ZXb3JrEiQKC2xvZ2luX3Rva2VuGAsgASgMSAZSCmxvZ2luVG9rZW6IAQESWQoQaW5pdGlh' + 'bF9wcWNfa2V5cxgMIAEoCzIqLmNsaWVudF90b19zZXJ2ZXIuSGFuZHNoYWtlLkluaXRpYWxQcW' + 'NLZXlzSAdSDmluaXRpYWxQcWNLZXlziAEBQg4KDF9pbnZpdGVfY29kZUIWChRfcHVibGljX2lk' + 'ZW50aXR5X2tleUIQCg5fc2lnbmVkX3ByZWtleUIaChhfc2lnbmVkX3ByZWtleV9zaWduYXR1cm' + 'VCEwoRX3NpZ25lZF9wcmVrZXlfaWRCEgoQX3JlZ2lzdHJhdGlvbl9pZEIOCgxfbG9naW5fdG9r' + 'ZW5CEwoRX2luaXRpYWxfcHFjX2tleXMa1AUKDkluaXRpYWxQcWNLZXlzEi4KE3B1YmxpY19pZG' + 'VudGl0eV9rZXkYASABKAxSEXB1YmxpY0lkZW50aXR5S2V5EicKD3JlZ2lzdHJhdGlvbl9pZBgC' + 'IAEoA1IOcmVnaXN0cmF0aW9uSWQSLwoUZWNjX3NpZ25lZF9wcmVrZXlfaWQYAyABKANSEWVjY1' + 'NpZ25lZFByZWtleUlkEioKEWVjY19zaWduZWRfcHJla2V5GAQgASgMUg9lY2NTaWduZWRQcmVr' + 'ZXkSPQobZWNjX3NpZ25lZF9wcmVrZXlfc2lnbmF0dXJlGAUgASgMUhhlY2NTaWduZWRQcmVrZX' + 'lTaWduYXR1cmUSMwoWa3liZXJfc2lnbmVkX3ByZWtleV9pZBgGIAEoA1ITa3liZXJTaWduZWRQ' + 'cmVrZXlJZBIuChNreWJlcl9zaWduZWRfcHJla2V5GAcgASgMUhFreWJlclNpZ25lZFByZWtleR' + 'JBCh1reWJlcl9zaWduZWRfcHJla2V5X3NpZ25hdHVyZRgIIAEoDFIaa3liZXJTaWduZWRQcmVr' + 'ZXlTaWduYXR1cmUSTgoHcHJla2V5cxgJIAMoCzI0LmNsaWVudF90b19zZXJ2ZXIuSGFuZHNoYW' + 'tlLkluaXRpYWxQcWNLZXlzLlBxY1ByZUtleVIHcHJla2V5cxrUAQoJUHFjUHJlS2V5EiMKDmVj' + 'Y19wcmVfa2V5X2lkGAEgASgDUgtlY2NQcmVLZXlJZBIeCgtlY2NfcHJlX2tleRgCIAEoDFIJZW' + 'NjUHJlS2V5EicKEGt5YmVyX3ByZV9rZXlfaWQYAyABKANSDWt5YmVyUHJlS2V5SWQSIgoNa3li' + 'ZXJfcHJlX2tleRgEIAEoDFILa3liZXJQcmVLZXkSNQoXa3liZXJfcHJlX2tleV9zaWduYXR1cm' + 'UYBSABKAxSFGt5YmVyUHJlS2V5U2lnbmF0dXJlGhIKEEdldEF1dGhDaGFsbGVuZ2UaMQoTR2V0' + 'VXNlcklkQnlVc2VybmFtZRIaCgh1c2VybmFtZRgBIAEoCVIIdXNlcm5hbWUaQwoMR2V0QXV0aF' + 'Rva2VuEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBIaCghyZXNwb25zZRgCIAEoDFIIcmVzcG9u' + 'c2UatQIKDEF1dGhlbnRpY2F0ZRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSHQoKYXV0aF90b2' + 'tlbhgCIAEoDFIJYXV0aFRva2VuEiQKC2FwcF92ZXJzaW9uGAMgASgJSABSCmFwcFZlcnNpb26I' + 'AQESIAoJZGV2aWNlX2lkGAQgASgDSAFSCGRldmljZUlkiAEBEigKDWluX2JhY2tncm91bmQYBS' + 'ABKAhIAlIMaW5CYWNrZ3JvdW5kiAEBEjMKE3N1cHBvcnRzX21haWxib3hfdjIYBiABKAhIA1IR' + 'c3VwcG9ydHNNYWlsYm94VjKIAQFCDgoMX2FwcF92ZXJzaW9uQgwKCl9kZXZpY2VfaWRCEAoOX2' + 'luX2JhY2tncm91bmRCFgoUX3N1cHBvcnRzX21haWxib3hfdjIakwIKGkF1dGhlbnRpY2F0ZVdp' + 'dGhMb2dpblRva2VuEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBIsChJzZWNyZXRfbG9naW5fdG' + '9rZW4YAiABKAxSEHNlY3JldExvZ2luVG9rZW4SHwoLYXBwX3ZlcnNpb24YAyABKAlSCmFwcFZl' + 'cnNpb24SGwoJZGV2aWNlX2lkGAQgASgDUghkZXZpY2VJZBIjCg1pbl9iYWNrZ3JvdW5kGAUgAS' + 'gIUgxpbkJhY2tncm91bmQSMwoTc3VwcG9ydHNfbWFpbGJveF92MhgGIAEoCEgAUhFzdXBwb3J0' + 'c01haWxib3hWMogBAUIWChRfc3VwcG9ydHNfbWFpbGJveF92MhqlAgojR2V0U2VydmVyS2V5Rm' + '9yUGFzc3dvcmRMZXNzUmVjb3ZlcnkSFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkEjIKFXNlcnZl' + 'cl9rZXlfcHJvdGVjdGlvbhgCIAEoDFITc2VydmVyS2V5UHJvdGVjdGlvbhItChBwaW5fdW5sb2' + 'NrX3Rva2VuGAMgASgMSABSDnBpblVubG9ja1Rva2VuiAEBEjEKEnBpbl9wcm90ZWN0aW9uX2tl' + 'eRgEIAEoDEgBUhBwaW5Qcm90ZWN0aW9uS2V5iAEBEhkKBWVtYWlsGAUgASgJSAJSBWVtYWlsiA' + 'EBQhMKEV9waW5fdW5sb2NrX3Rva2VuQhUKE19waW5fcHJvdGVjdGlvbl9rZXlCCAoGX2VtYWls' + 'GssBCiBSZWdpc3RlclBhc3N3b3JkbGVzc05vdGlmaWNhdGlvbhInCg9ub3RpZmljYXRpb25faW' + 'QYASABKAlSDm5vdGlmaWNhdGlvbklkEi4KE2Rvd25sb2FkX2F1dGhfdG9rZW4YAiABKAxSEWRv' + 'd25sb2FkQXV0aFRva2VuEhsKCWxhbmdfY29kZRgDIAEoCVIIbGFuZ0NvZGUSIgoKZ29vZ2xlX2' + 'ZjbRgEIAEoCUgAUglnb29nbGVGY22IAQFCDQoLX2dvb2dsZV9mY20avAEKIENoZWNrRm9yUGFz' + 'c3dvcmRsZXNzTm90aWZpY2F0aW9uEicKD25vdGlmaWNhdGlvbl9pZBgBIAEoCVIObm90aWZpY2' + 'F0aW9uSWQSLgoTZG93bmxvYWRfYXV0aF90b2tlbhgCIAEoDFIRZG93bmxvYWRBdXRoVG9rZW4S' + 'PwocYWxyZWFkeV9yZWNlaXZlZF9tZXNzYWdlX2lkcxgDIAMoA1IZYWxyZWFkeVJlY2VpdmVkTW' + 'Vzc2FnZUlkc0ILCglIYW5kc2hha2U='); @$core.Deprecated('Use applicationDataDescriptor instead') const ApplicationData$json = { @@ -733,6 +904,15 @@ const ApplicationData$json = { '9': 0, '10': 'uploadPqcPrekeys' }, + { + '1': 'request_pending_messages', + '3': 43, + '4': 1, + '5': 11, + '6': '.client_to_server.ApplicationData.RequestPendingMessages', + '9': 0, + '10': 'requestPendingMessages' + }, ], '3': [ ApplicationData_TextMessage$json, @@ -763,7 +943,8 @@ const ApplicationData$json = { ApplicationData_GetMemoriesUrl$json, ApplicationData_GetMemoriesUsage$json, ApplicationData_DeleteMemory$json, - ApplicationData_DisableMemoriesBackup$json + ApplicationData_DisableMemoriesBackup$json, + ApplicationData_RequestPendingMessages$json ], '8': [ {'1': 'ApplicationData'}, @@ -773,6 +954,8 @@ const ApplicationData$json = { {'1': 11, '2': 12}, {'1': 13, '2': 18}, {'1': 19, '2': 20}, + {'1': 41, '2': 42}, + {'1': 42, '2': 43}, ], }; @@ -791,6 +974,7 @@ const ApplicationData_TextMessage$json = { '10': 'pushData', '17': true }, + {'1': 'wake_receiver', '3': 5, '4': 1, '5': 8, '10': 'wakeReceiver'}, ], '8': [ {'1': '_push_data'}, @@ -951,6 +1135,28 @@ const ApplicationData_UploadPqcPreKeys$json = { '6': '.client_to_server.ApplicationData.PqcPreKey', '10': 'prekeys' }, + { + '1': 'public_identity_key', + '3': 8, + '4': 1, + '5': 12, + '9': 0, + '10': 'publicIdentityKey', + '17': true + }, + { + '1': 'registration_id', + '3': 9, + '4': 1, + '5': 3, + '9': 1, + '10': 'registrationId', + '17': true + }, + ], + '8': [ + {'1': '_public_identity_key'}, + {'1': '_registration_id'}, ], }; @@ -1108,6 +1314,11 @@ const ApplicationData_DisableMemoriesBackup$json = { '1': 'DisableMemoriesBackup', }; +@$core.Deprecated('Use applicationDataDescriptor instead') +const ApplicationData_RequestPendingMessages$json = { + '1': 'RequestPendingMessages', +}; + /// Descriptor for `ApplicationData`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List applicationDataDescriptor = $convert.base64Decode( 'Cg9BcHBsaWNhdGlvbkRhdGESUQoLdGV4dE1lc3NhZ2UYASABKAsyLS5jbGllbnRfdG9fc2Vydm' @@ -1161,49 +1372,55 @@ final $typed_data.Uint8List applicationDataDescriptor = $convert.base64Decode( 'c2VydmVyLkFwcGxpY2F0aW9uRGF0YS5EaXNhYmxlTWVtb3JpZXNCYWNrdXBIAFIVZGlzYWJsZU' '1lbW9yaWVzQmFja3VwEmIKEnVwbG9hZF9wcWNfcHJla2V5cxgoIAEoCzIyLmNsaWVudF90b19z' 'ZXJ2ZXIuQXBwbGljYXRpb25EYXRhLlVwbG9hZFBxY1ByZUtleXNIAFIQdXBsb2FkUHFjUHJla2' - 'V5cxpqCgtUZXh0TWVzc2FnZRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSEgoEYm9keRgDIAEo' - 'DFIEYm9keRIgCglwdXNoX2RhdGEYBCABKAxIAFIIcHVzaERhdGGIAQFCDAoKX3B1c2hfZGF0YR' - 'ovChFHZXRVc2VyQnlVc2VybmFtZRIaCgh1c2VybmFtZRgBIAEoCVIIdXNlcm5hbWUaLAoOQ2hh' - 'bmdlVXNlcm5hbWUSGgoIdXNlcm5hbWUYASABKAlSCHVzZXJuYW1lGjUKFFVwZGF0ZUdvb2dsZU' - 'ZjbVRva2VuEh0KCmdvb2dsZV9mY20YASABKAlSCWdvb2dsZUZjbRomCgtHZXRVc2VyQnlJZBIX' - 'Cgd1c2VyX2lkGAEgASgDUgZ1c2VySWQaEwoRR2V0QXZhaWxhYmxlUGxhbnMaFQoTR2V0Q3Vycm' - 'VudFBsYW5JbmZvcxovChRSZW1vdmVBZGRpdGlvbmFsVXNlchIXCgd1c2VyX2lkGAEgASgDUgZ1' - 'c2VySWQaLQoSR2V0UHJla2V5c0J5VXNlcklkEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBoyCh' - 'dHZXRTaWduZWRQcmVLZXlCeVVzZXJJZBIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQamwEKElVw' - 'ZGF0ZVNpZ25lZFByZUtleRIoChBzaWduZWRfcHJla2V5X2lkGAEgASgDUg5zaWduZWRQcmVrZX' - 'lJZBIjCg1zaWduZWRfcHJla2V5GAIgASgMUgxzaWduZWRQcmVrZXkSNgoXc2lnbmVkX3ByZWtl' - 'eV9zaWduYXR1cmUYAyABKAxSFXNpZ25lZFByZWtleVNpZ25hdHVyZRrUAQoJUHFjUHJlS2V5Ei' - 'MKDmVjY19wcmVfa2V5X2lkGAEgASgDUgtlY2NQcmVLZXlJZBIeCgtlY2NfcHJlX2tleRgCIAEo' - 'DFIJZWNjUHJlS2V5EicKEGt5YmVyX3ByZV9rZXlfaWQYAyABKANSDWt5YmVyUHJlS2V5SWQSIg' - 'oNa3liZXJfcHJlX2tleRgEIAEoDFILa3liZXJQcmVLZXkSNQoXa3liZXJfcHJlX2tleV9zaWdu' - 'YXR1cmUYBSABKAxSFGt5YmVyUHJlS2V5U2lnbmF0dXJlGp0DChBVcGxvYWRQcWNQcmVLZXlzEi' - '8KFGVjY19zaWduZWRfcHJla2V5X2lkGAEgASgDUhFlY2NTaWduZWRQcmVrZXlJZBIqChFlY2Nf' - 'c2lnbmVkX3ByZWtleRgCIAEoDFIPZWNjU2lnbmVkUHJla2V5Ej0KG2VjY19zaWduZWRfcHJla2' - 'V5X3NpZ25hdHVyZRgDIAEoDFIYZWNjU2lnbmVkUHJla2V5U2lnbmF0dXJlEjMKFmt5YmVyX3Np' - 'Z25lZF9wcmVrZXlfaWQYBCABKANSE2t5YmVyU2lnbmVkUHJla2V5SWQSLgoTa3liZXJfc2lnbm' - 'VkX3ByZWtleRgFIAEoDFIRa3liZXJTaWduZWRQcmVrZXkSQQoda3liZXJfc2lnbmVkX3ByZWtl' - 'eV9zaWduYXR1cmUYBiABKAxSGmt5YmVyU2lnbmVkUHJla2V5U2lnbmF0dXJlEkUKB3ByZWtleX' - 'MYByADKAsyKy5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5QcWNQcmVLZXlSB3By' - 'ZWtleXMaNQoMRG93bmxvYWREb25lEiUKDmRvd25sb2FkX3Rva2VuGAEgASgMUg1kb3dubG9hZF' - 'Rva2VuGk4KClJlcG9ydFVzZXISKAoQcmVwb3J0ZWRfdXNlcl9pZBgBIAEoA1IOcmVwb3J0ZWRV' - 'c2VySWQSFgoGcmVhc29uGAIgASgJUgZyZWFzb24acQoLSVBBUHVyY2hhc2USHQoKcHJvZHVjdF' - '9pZBgBIAEoCVIJcHJvZHVjdElkEhYKBnNvdXJjZRgCIAEoCVIGc291cmNlEisKEXZlcmlmaWNh' - 'dGlvbl9kYXRhGAMgASgJUhB2ZXJpZmljYXRpb25EYXRhGg8KDUlQQUZvcmNlQ2hlY2saDwoNRG' - 'VsZXRlQWNjb3VudBosChFBZGRBZGRpdGlvbmFsVXNlchIXCgd1c2VyX2lkGAEgASgDUgZ1c2Vy' - 'SWQaMAoNU2V0TG9naW5Ub2tlbhIfCgtsb2dpbl90b2tlbhgBIAEoDFIKbG9naW5Ub2tlbhqOAQ' - 'ocUmVnaXN0ZXJQYXNzd29yZExlc3NSZWNvdmVyeRIuChJlbmNyeXB0ZWRTZXJ2ZXJLZXkYASAB' - 'KAxSEmVuY3J5cHRlZFNlcnZlcktleRIrCg5waW5VbmxvY2tUb2tlbhgCIAEoDEgAUg5waW5Vbm' - 'xvY2tUb2tlbogBAUIRCg9fcGluVW5sb2NrVG9rZW4acAoYUGFzc3dvcmRsZXNzTm90aWZpY2F0' - 'aW9uEicKD25vdGlmaWNhdGlvbl9pZBgBIAEoCVIObm90aWZpY2F0aW9uSWQSKwoRZW5jcnlwdG' - 'VkX21lc3NhZ2UYAiABKAxSEGVuY3J5cHRlZE1lc3NhZ2UaawoVUmVxdWVzdE1lbW9yaWVzVXBs' - 'b2FkEhIKBHNpemUYASABKANSBHNpemUSIwoNb3JpZ2luYWxfZGF0ZRgCIAEoA1IMb3JpZ2luYW' - 'xEYXRlEhkKCG1lZGlhX2lkGAMgASgJUgdtZWRpYUlkGjIKFUNvbmZpcm1NZW1vcmllc1VwbG9h' - 'ZBIZCghtZWRpYV9pZBgBIAEoCVIHbWVkaWFJZBpICg9HZXRNZW1vcmllc0xpc3QSHwoLb2Zmc2' - 'V0X2RhdGUYASABKANSCm9mZnNldERhdGUSFAoFbGltaXQYAiABKANSBWxpbWl0GkkKDkdldE1l' - 'bW9yaWVzVXJsEhkKCG1lZGlhX2lkGAEgASgJUgdtZWRpYUlkEhwKCXRodW1ibmFpbBgCIAEoCF' - 'IJdGh1bWJuYWlsGhIKEEdldE1lbW9yaWVzVXNhZ2UaKQoMRGVsZXRlTWVtb3J5EhkKCG1lZGlh' - 'X2lkGAEgASgJUgdtZWRpYUlkGhcKFURpc2FibGVNZW1vcmllc0JhY2t1cEIRCg9BcHBsaWNhdG' - 'lvbkRhdGFKBAgJEApKBAgLEAxKBAgNEBJKBAgTEBQ='); + 'V5cxJ0ChhyZXF1ZXN0X3BlbmRpbmdfbWVzc2FnZXMYKyABKAsyOC5jbGllbnRfdG9fc2VydmVy' + 'LkFwcGxpY2F0aW9uRGF0YS5SZXF1ZXN0UGVuZGluZ01lc3NhZ2VzSABSFnJlcXVlc3RQZW5kaW' + '5nTWVzc2FnZXMajwEKC1RleHRNZXNzYWdlEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBISCgRi' + 'b2R5GAMgASgMUgRib2R5EiAKCXB1c2hfZGF0YRgEIAEoDEgAUghwdXNoRGF0YYgBARIjCg13YW' + 'tlX3JlY2VpdmVyGAUgASgIUgx3YWtlUmVjZWl2ZXJCDAoKX3B1c2hfZGF0YRovChFHZXRVc2Vy' + 'QnlVc2VybmFtZRIaCgh1c2VybmFtZRgBIAEoCVIIdXNlcm5hbWUaLAoOQ2hhbmdlVXNlcm5hbW' + 'USGgoIdXNlcm5hbWUYASABKAlSCHVzZXJuYW1lGjUKFFVwZGF0ZUdvb2dsZUZjbVRva2VuEh0K' + 'Cmdvb2dsZV9mY20YASABKAlSCWdvb2dsZUZjbRomCgtHZXRVc2VyQnlJZBIXCgd1c2VyX2lkGA' + 'EgASgDUgZ1c2VySWQaEwoRR2V0QXZhaWxhYmxlUGxhbnMaFQoTR2V0Q3VycmVudFBsYW5JbmZv' + 'cxovChRSZW1vdmVBZGRpdGlvbmFsVXNlchIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQaLQoSR2' + 'V0UHJla2V5c0J5VXNlcklkEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBoyChdHZXRTaWduZWRQ' + 'cmVLZXlCeVVzZXJJZBIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQamwEKElVwZGF0ZVNpZ25lZF' + 'ByZUtleRIoChBzaWduZWRfcHJla2V5X2lkGAEgASgDUg5zaWduZWRQcmVrZXlJZBIjCg1zaWdu' + 'ZWRfcHJla2V5GAIgASgMUgxzaWduZWRQcmVrZXkSNgoXc2lnbmVkX3ByZWtleV9zaWduYXR1cm' + 'UYAyABKAxSFXNpZ25lZFByZWtleVNpZ25hdHVyZRrUAQoJUHFjUHJlS2V5EiMKDmVjY19wcmVf' + 'a2V5X2lkGAEgASgDUgtlY2NQcmVLZXlJZBIeCgtlY2NfcHJlX2tleRgCIAEoDFIJZWNjUHJlS2' + 'V5EicKEGt5YmVyX3ByZV9rZXlfaWQYAyABKANSDWt5YmVyUHJlS2V5SWQSIgoNa3liZXJfcHJl' + 'X2tleRgEIAEoDFILa3liZXJQcmVLZXkSNQoXa3liZXJfcHJlX2tleV9zaWduYXR1cmUYBSABKA' + 'xSFGt5YmVyUHJlS2V5U2lnbmF0dXJlGqwEChBVcGxvYWRQcWNQcmVLZXlzEi8KFGVjY19zaWdu' + 'ZWRfcHJla2V5X2lkGAEgASgDUhFlY2NTaWduZWRQcmVrZXlJZBIqChFlY2Nfc2lnbmVkX3ByZW' + 'tleRgCIAEoDFIPZWNjU2lnbmVkUHJla2V5Ej0KG2VjY19zaWduZWRfcHJla2V5X3NpZ25hdHVy' + 'ZRgDIAEoDFIYZWNjU2lnbmVkUHJla2V5U2lnbmF0dXJlEjMKFmt5YmVyX3NpZ25lZF9wcmVrZX' + 'lfaWQYBCABKANSE2t5YmVyU2lnbmVkUHJla2V5SWQSLgoTa3liZXJfc2lnbmVkX3ByZWtleRgF' + 'IAEoDFIRa3liZXJTaWduZWRQcmVrZXkSQQoda3liZXJfc2lnbmVkX3ByZWtleV9zaWduYXR1cm' + 'UYBiABKAxSGmt5YmVyU2lnbmVkUHJla2V5U2lnbmF0dXJlEkUKB3ByZWtleXMYByADKAsyKy5j' + 'bGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5QcWNQcmVLZXlSB3ByZWtleXMSMwoTcH' + 'VibGljX2lkZW50aXR5X2tleRgIIAEoDEgAUhFwdWJsaWNJZGVudGl0eUtleYgBARIsCg9yZWdp' + 'c3RyYXRpb25faWQYCSABKANIAVIOcmVnaXN0cmF0aW9uSWSIAQFCFgoUX3B1YmxpY19pZGVudG' + 'l0eV9rZXlCEgoQX3JlZ2lzdHJhdGlvbl9pZBo1CgxEb3dubG9hZERvbmUSJQoOZG93bmxvYWRf' + 'dG9rZW4YASABKAxSDWRvd25sb2FkVG9rZW4aTgoKUmVwb3J0VXNlchIoChByZXBvcnRlZF91c2' + 'VyX2lkGAEgASgDUg5yZXBvcnRlZFVzZXJJZBIWCgZyZWFzb24YAiABKAlSBnJlYXNvbhpxCgtJ' + 'UEFQdXJjaGFzZRIdCgpwcm9kdWN0X2lkGAEgASgJUglwcm9kdWN0SWQSFgoGc291cmNlGAIgAS' + 'gJUgZzb3VyY2USKwoRdmVyaWZpY2F0aW9uX2RhdGEYAyABKAlSEHZlcmlmaWNhdGlvbkRhdGEa' + 'DwoNSVBBRm9yY2VDaGVjaxoPCg1EZWxldGVBY2NvdW50GiwKEUFkZEFkZGl0aW9uYWxVc2VyEh' + 'cKB3VzZXJfaWQYASABKANSBnVzZXJJZBowCg1TZXRMb2dpblRva2VuEh8KC2xvZ2luX3Rva2Vu' + 'GAEgASgMUgpsb2dpblRva2VuGo4BChxSZWdpc3RlclBhc3N3b3JkTGVzc1JlY292ZXJ5Ei4KEm' + 'VuY3J5cHRlZFNlcnZlcktleRgBIAEoDFISZW5jcnlwdGVkU2VydmVyS2V5EisKDnBpblVubG9j' + 'a1Rva2VuGAIgASgMSABSDnBpblVubG9ja1Rva2VuiAEBQhEKD19waW5VbmxvY2tUb2tlbhpwCh' + 'hQYXNzd29yZGxlc3NOb3RpZmljYXRpb24SJwoPbm90aWZpY2F0aW9uX2lkGAEgASgJUg5ub3Rp' + 'ZmljYXRpb25JZBIrChFlbmNyeXB0ZWRfbWVzc2FnZRgCIAEoDFIQZW5jcnlwdGVkTWVzc2FnZR' + 'prChVSZXF1ZXN0TWVtb3JpZXNVcGxvYWQSEgoEc2l6ZRgBIAEoA1IEc2l6ZRIjCg1vcmlnaW5h' + 'bF9kYXRlGAIgASgDUgxvcmlnaW5hbERhdGUSGQoIbWVkaWFfaWQYAyABKAlSB21lZGlhSWQaMg' + 'oVQ29uZmlybU1lbW9yaWVzVXBsb2FkEhkKCG1lZGlhX2lkGAEgASgJUgdtZWRpYUlkGkgKD0dl' + 'dE1lbW9yaWVzTGlzdBIfCgtvZmZzZXRfZGF0ZRgBIAEoA1IKb2Zmc2V0RGF0ZRIUCgVsaW1pdB' + 'gCIAEoA1IFbGltaXQaSQoOR2V0TWVtb3JpZXNVcmwSGQoIbWVkaWFfaWQYASABKAlSB21lZGlh' + 'SWQSHAoJdGh1bWJuYWlsGAIgASgIUgl0aHVtYm5haWwaEgoQR2V0TWVtb3JpZXNVc2FnZRopCg' + 'xEZWxldGVNZW1vcnkSGQoIbWVkaWFfaWQYASABKAlSB21lZGlhSWQaFwoVRGlzYWJsZU1lbW9y' + 'aWVzQmFja3VwGhgKFlJlcXVlc3RQZW5kaW5nTWVzc2FnZXNCEQoPQXBwbGljYXRpb25EYXRhSg' + 'QICRAKSgQICxAMSgQIDRASSgQIExAUSgQIKRAqSgQIKhAr'); @$core.Deprecated('Use responseDescriptor instead') const Response$json = { @@ -1232,6 +1449,7 @@ const Response$json = { Response_PreKey$json, Response_Prekeys$json, Response_PqcPrekeys$json, + Response_AcknowledgedPendingMessages$json, Response_Ok$json ], '8': [ @@ -1278,6 +1496,14 @@ const Response_PqcPrekeys$json = { ], }; +@$core.Deprecated('Use responseDescriptor instead') +const Response_AcknowledgedPendingMessages$json = { + '1': 'AcknowledgedPendingMessages', + '2': [ + {'1': 'delivery_ids', '3': 1, '4': 3, '5': 3, '10': 'deliveryIds'}, + ], +}; + @$core.Deprecated('Use responseDescriptor instead') const Response_Ok$json = { '1': 'Ok', @@ -1301,6 +1527,15 @@ const Response_Ok$json = { '9': 0, '10': 'prekeysPqc' }, + { + '1': 'acknowledged_pending_messages', + '3': 4, + '4': 1, + '5': 11, + '6': '.client_to_server.Response.AcknowledgedPendingMessages', + '9': 0, + '10': 'acknowledgedPendingMessages' + }, ], '8': [ {'1': 'Ok'}, @@ -1314,7 +1549,11 @@ final $typed_data.Uint8List responseDescriptor = $convert.base64Decode( 'Eg4KAmlkGAEgASgDUgJpZBIWCgZwcmVrZXkYAiABKAxSBnByZWtleRpGCgdQcmVrZXlzEjsKB3' 'ByZWtleXMYASADKAsyIS5jbGllbnRfdG9fc2VydmVyLlJlc3BvbnNlLlByZUtleVIHcHJla2V5' 'cxpTCgpQcWNQcmVrZXlzEkUKB3ByZWtleXMYASADKAsyKy5jbGllbnRfdG9fc2VydmVyLkFwcG' - 'xpY2F0aW9uRGF0YS5QcWNQcmVLZXlSB3ByZWtleXMaqgEKAk9rEhQKBE5vbmUYASABKAhIAFIE' - 'Tm9uZRI+CgdwcmVrZXlzGAIgASgLMiIuY2xpZW50X3RvX3NlcnZlci5SZXNwb25zZS5QcmVrZX' - 'lzSABSB3ByZWtleXMSSAoLcHJla2V5c19wcWMYAyABKAsyJS5jbGllbnRfdG9fc2VydmVyLlJl' - 'c3BvbnNlLlBxY1ByZWtleXNIAFIKcHJla2V5c1BxY0IECgJPa0IKCghSZXNwb25zZQ=='); + 'xpY2F0aW9uRGF0YS5QcWNQcmVLZXlSB3ByZWtleXMaQAobQWNrbm93bGVkZ2VkUGVuZGluZ01l' + 'c3NhZ2VzEiEKDGRlbGl2ZXJ5X2lkcxgBIAMoA1ILZGVsaXZlcnlJZHMaqAIKAk9rEhQKBE5vbm' + 'UYASABKAhIAFIETm9uZRI+CgdwcmVrZXlzGAIgASgLMiIuY2xpZW50X3RvX3NlcnZlci5SZXNw' + 'b25zZS5QcmVrZXlzSABSB3ByZWtleXMSSAoLcHJla2V5c19wcWMYAyABKAsyJS5jbGllbnRfdG' + '9fc2VydmVyLlJlc3BvbnNlLlBxY1ByZWtleXNIAFIKcHJla2V5c1BxYxJ8Ch1hY2tub3dsZWRn' + 'ZWRfcGVuZGluZ19tZXNzYWdlcxgEIAEoCzI2LmNsaWVudF90b19zZXJ2ZXIuUmVzcG9uc2UuQW' + 'Nrbm93bGVkZ2VkUGVuZGluZ01lc3NhZ2VzSABSG2Fja25vd2xlZGdlZFBlbmRpbmdNZXNzYWdl' + 'c0IECgJPa0IKCghSZXNwb25zZQ=='); diff --git a/lib/src/model/protobuf/api/websocket/server_to_client.pb.dart b/lib/src/model/protobuf/api/websocket/server_to_client.pb.dart index 0d1053aa..3bef4bef 100644 --- a/lib/src/model/protobuf/api/websocket/server_to_client.pb.dart +++ b/lib/src/model/protobuf/api/websocket/server_to_client.pb.dart @@ -96,6 +96,7 @@ enum V0_Kind { error, newMessages, requestNewPqcPreKeys, + mailboxDrained, notSet } @@ -108,6 +109,7 @@ class V0 extends $pb.GeneratedMessage { $0.ErrorCode? error, NewMessages? newMessages, $core.bool? requestNewPqcPreKeys, + $core.bool? mailboxDrained, }) { final result = create(); if (seq != null) result.seq = seq; @@ -118,6 +120,7 @@ class V0 extends $pb.GeneratedMessage { if (newMessages != null) result.newMessages = newMessages; if (requestNewPqcPreKeys != null) result.requestNewPqcPreKeys = requestNewPqcPreKeys; + if (mailboxDrained != null) result.mailboxDrained = mailboxDrained; return result; } @@ -137,6 +140,7 @@ class V0 extends $pb.GeneratedMessage { 6: V0_Kind.error, 7: V0_Kind.newMessages, 8: V0_Kind.requestNewPqcPreKeys, + 11: V0_Kind.mailboxDrained, 0: V0_Kind.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -144,7 +148,7 @@ class V0 extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'), createEmptyInstance: create) - ..oo(0, [2, 3, 4, 6, 7, 8]) + ..oo(0, [2, 3, 4, 6, 7, 8, 11]) ..a<$fixnum.Int64>(1, _omitFieldNames ? '' : 'seq', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..aOM(2, _omitFieldNames ? '' : 'response', @@ -159,6 +163,8 @@ class V0 extends $pb.GeneratedMessage { protoName: 'newMessages', subBuilder: NewMessages.create) ..aOB(8, _omitFieldNames ? '' : 'RequestNewPqcPreKeys', protoName: 'RequestNewPqcPreKeys') + ..aOB(11, _omitFieldNames ? '' : 'mailboxDrained', + protoName: 'mailboxDrained') ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -185,6 +191,7 @@ class V0 extends $pb.GeneratedMessage { @$pb.TagNumber(6) @$pb.TagNumber(7) @$pb.TagNumber(8) + @$pb.TagNumber(11) V0_Kind whichKind() => _V0_KindByTag[$_whichOneof(0)]!; @$pb.TagNumber(2) @$pb.TagNumber(3) @@ -192,6 +199,7 @@ class V0 extends $pb.GeneratedMessage { @$pb.TagNumber(6) @$pb.TagNumber(7) @$pb.TagNumber(8) + @$pb.TagNumber(11) void clearKind() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -262,6 +270,15 @@ class V0 extends $pb.GeneratedMessage { $core.bool hasRequestNewPqcPreKeys() => $_has(6); @$pb.TagNumber(8) void clearRequestNewPqcPreKeys() => $_clearField(8); + + @$pb.TagNumber(11) + $core.bool get mailboxDrained => $_getBF(7); + @$pb.TagNumber(11) + set mailboxDrained($core.bool value) => $_setBool(7, value); + @$pb.TagNumber(11) + $core.bool hasMailboxDrained() => $_has(7); + @$pb.TagNumber(11) + void clearMailboxDrained() => $_clearField(11); } class NewMessage extends $pb.GeneratedMessage { diff --git a/lib/src/model/protobuf/api/websocket/server_to_client.pbjson.dart b/lib/src/model/protobuf/api/websocket/server_to_client.pbjson.dart index 42b93650..e72ab18b 100644 --- a/lib/src/model/protobuf/api/websocket/server_to_client.pbjson.dart +++ b/lib/src/model/protobuf/api/websocket/server_to_client.pbjson.dart @@ -96,6 +96,14 @@ const V0$json = { '9': 0, '10': 'error' }, + { + '1': 'mailboxDrained', + '3': 11, + '4': 1, + '5': 8, + '9': 0, + '10': 'mailboxDrained' + }, ], '8': [ {'1': 'Kind'}, @@ -110,7 +118,8 @@ final $typed_data.Uint8List v0Descriptor = $convert.base64Decode( '0uc2VydmVyX3RvX2NsaWVudC5OZXdNZXNzYWdlc0gAUgtuZXdNZXNzYWdlcxIuChFSZXF1ZXN0' 'TmV3UHJlS2V5cxgEIAEoCEgAUhFSZXF1ZXN0TmV3UHJlS2V5cxI0ChRSZXF1ZXN0TmV3UHFjUH' 'JlS2V5cxgIIAEoCEgAUhRSZXF1ZXN0TmV3UHFjUHJlS2V5cxIoCgVlcnJvchgGIAEoDjIQLmVy' - 'cm9yLkVycm9yQ29kZUgAUgVlcnJvckIGCgRLaW5k'); + 'cm9yLkVycm9yQ29kZUgAUgVlcnJvchIoCg5tYWlsYm94RHJhaW5lZBgLIAEoCEgAUg5tYWlsYm' + '94RHJhaW5lZEIGCgRLaW5k'); @$core.Deprecated('Use newMessageDescriptor instead') const NewMessage$json = { diff --git a/lib/src/services/api/api.service.dart b/lib/src/services/api/api.service.dart index 917cf8c6..49b9a3d8 100644 --- a/lib/src/services/api/api.service.dart +++ b/lib/src/services/api/api.service.dart @@ -1016,6 +1016,8 @@ class ApiService { Uint8List kyberSignedPreKey, Uint8List kyberSignedPreKeySignature, List prekeys, + Uint8List publicIdentityKey, + int registrationId, ) async { final get = ApplicationData_UploadPqcPreKeys() ..eccSignedPrekeyId = Int64(eccSignedPreKeyId) @@ -1024,7 +1026,9 @@ class ApiService { ..kyberSignedPrekeyId = Int64(kyberSignedPreKeyId) ..kyberSignedPrekey = kyberSignedPreKey ..kyberSignedPrekeySignature = kyberSignedPreKeySignature - ..prekeys.addAll(prekeys); + ..prekeys.addAll(prekeys) + ..publicIdentityKey = publicIdentityKey + ..registrationId = Int64(registrationId); final appData = ApplicationData()..uploadPqcPrekeys = get; final req = createClientToServerFromApplicationData(appData); return sendRequestSync(req); diff --git a/lib/src/services/api/messages.api.dart b/lib/src/services/api/messages.api.dart index 9c2aa47d..c84c90dc 100644 --- a/lib/src/services/api/messages.api.dart +++ b/lib/src/services/api/messages.api.dart @@ -18,6 +18,7 @@ import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' import 'package:twonly/src/model/protobuf/client/generated/push_notification.pb.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart'; +import 'package:twonly/src/services/signal/protocol_state.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/services/user.service.dart' show UserService; import 'package:twonly/src/services/user_discovery.service.dart'; @@ -26,8 +27,47 @@ import 'package:twonly/src/utils/misc.dart'; final lockRetransmission = Mutex(); +const int _outOfSyncRetryThreshold = 10; + +Future _resetStuckOutgoingSignalSessions() async { + final contactIds = await twonlyDB.receiptsDao + .getContactsWithStuckSignalMessages( + retryThreshold: _outOfSyncRetryThreshold, + ); + + for (final contactId in contactIds) { + if (!shouldAttemptResync(contactId)) continue; + + Log.warn( + 'Detected an out-of-sync outgoing Signal session with $contactId: ' + 'multiple messages exceeded $_outOfSyncRetryThreshold retries.', + ); + + var reset = false; + try { + reset = await resetSignalSession(contactId); + } catch (error) { + Log.error( + 'Could not automatically reset session with $contactId: $error', + ); + } + + if (!reset) { + recordResyncAttempt(contactId, success: false); + continue; + } + + // Avoid resetting the same healthy replacement session on the next + // retransmission pass. Outstanding messages will now be encrypted again + // using the freshly established session. + await twonlyDB.receiptsDao.resetMessageRetryCountsForContact(contactId); + } +} + Future retransmitAllMessages() async { return lockRetransmission.protect(() async { + await _resetStuckOutgoingSignalSessions(); + final receipts = await twonlyDB.receiptsDao.getReceiptsForRetransmission(); if (receipts.isEmpty) return; @@ -159,36 +199,24 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({ .writeToBuffer(); } - if (message.type == pb.Message_Type.CIPHERTEXT) { - final encryptResult = await signalEncryptMessage( - receipt.contactId, - Uint8List.fromList(message.encryptedContent), - ); + if (message.type == pb.Message_Type.CIPHERTEXT || + message.type == pb.Message_Type.CIPHERTEXT_V2) { + final useV2 = contact.signalVersion == SignalVersion.v2; + final encryptResult = useV2 + ? await signalEncryptMessageV2( + receipt.contactId, + Uint8List.fromList(message.encryptedContent), + ) + : await signalEncryptMessage( + receipt.contactId, + Uint8List.fromList(message.encryptedContent), + ); + if (encryptResult == null) { Log.error( - '[${receipt.receiptId}] Could not encrypt the message for user ${receipt.contactId}. Aborting and trying again.', - ); - if (receipt.messageId != null) { - await twonlyDB.messagesDao.handleMessageAckByServer( - receipt.contactId, - receipt.messageId!, - clock.now(), - ); - } - await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); - return null; - } - message - ..encryptedContent = encryptResult.ciphertext - ..type = encryptResult.type; - } else if (message.type == pb.Message_Type.CIPHERTEXT_V2) { - final encryptResult = await signalEncryptMessageV2( - receipt.contactId, - Uint8List.fromList(message.encryptedContent), - ); - if (encryptResult == null) { - Log.error( - '[${receipt.receiptId}] Could not encrypt the message (V2) for user ${receipt.contactId}. Aborting and trying again.', + '[${receipt.receiptId}] Could not encrypt the message ' + '(${useV2 ? 'V2' : 'V1'}) for user ${receipt.contactId}. ' + 'Aborting and trying again.', ); if (receipt.messageId != null) { await twonlyDB.messagesDao.handleMessageAckByServer( @@ -200,6 +228,7 @@ Future<(Uint8List, Uint8List?)?> _tryToSendCompleteMessageInternal({ await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId); return null; } + message ..encryptedContent = encryptResult.ciphertext ..type = encryptResult.type; diff --git a/lib/src/services/api/server_messages.api.dart b/lib/src/services/api/server_messages.api.dart index 712f28b0..aaff31e8 100644 --- a/lib/src/services/api/server_messages.api.dart +++ b/lib/src/services/api/server_messages.api.dart @@ -39,8 +39,27 @@ import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; +const _signalUpgradeMailboxIdleDelay = Duration(milliseconds: 500); +Timer? _signalUpgradeMailboxIdleTimer; + +void _scheduleSignalUpgradeAfterMailboxIdle() { + _signalUpgradeMailboxIdleTimer?.cancel(); + _signalUpgradeMailboxIdleTimer = Timer(_signalUpgradeMailboxIdleDelay, () { + _signalUpgradeMailboxIdleTimer = null; + unawaited(upgradeSignalSessionsToV2()); + }); +} + +void _upgradeSignalSessionsAfterMailboxDrain() { + _signalUpgradeMailboxIdleTimer?.cancel(); + _signalUpgradeMailboxIdleTimer = null; + unawaited(upgradeSignalSessionsToV2()); +} + Future handleServerMessage(server.ServerToClient msg) async { Log.info('Processing a message from the server.'); + var receivedIncomingMessages = false; + var mailboxDrained = false; /// Returns means, that the server can delete the message from the server. final ok = client.Response_Ok()..none = true; @@ -54,6 +73,7 @@ Future handleServerMessage(server.ServerToClient msg) async { } else if (msg.v0.hasNewMessage()) { Log.info('Got 1 message from the server.'); await handleClient2ClientMessage(msg.v0.newMessage); + receivedIncomingMessages = true; } else if (msg.v0.hasNewMessages()) { Log.info( 'Got ${msg.v0.newMessages.newMessages.length} messages from the server.', @@ -69,6 +89,10 @@ Future handleServerMessage(server.ServerToClient msg) async { Log.error(e); } } + receivedIncomingMessages = true; + } else if (msg.v0.hasMailboxDrained()) { + Log.info('All pending messages have been downloaded.'); + mailboxDrained = true; } else { Log.error('Unknown server message: $msg'); } @@ -90,6 +114,15 @@ Future handleServerMessage(server.ServerToClient msg) async { } AppState.gotMessageFromServer = true; Log.info('All messages from the server processed.'); + + if (mailboxDrained) { + _upgradeSignalSessionsAfterMailboxDrain(); + } else if (receivedIncomingMessages) { + // Released servers do not send a mailbox-drained marker. They wait for + // this ACK before sending the next batch, so a short, resettable idle + // window approximates the queue boundary without blocking delivery. + _scheduleSignalUpgradeAfterMailboxIdle(); + } } DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1)); diff --git a/lib/src/services/signal/identity.signal.dart b/lib/src/services/signal/identity.signal.dart index 6e018a63..08a9a93b 100644 --- a/lib/src/services/signal/identity.signal.dart +++ b/lib/src/services/signal/identity.signal.dart @@ -60,6 +60,8 @@ class SignalIdentityService { bundle.kyberPreKeyPublic, bundle.kyberPreKeySignature, [], + bundle.identityKey, + bundle.registrationId, ); if (pqcRes.isError) { diff --git a/lib/src/services/signal/session.signal.dart b/lib/src/services/signal/session.signal.dart index 6a715e0b..9837fbe5 100644 --- a/lib/src/services/signal/session.signal.dart +++ b/lib/src/services/signal/session.signal.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:drift/drift.dart' as drift; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; +import 'package:mutex/mutex.dart'; import 'package:twonly/core/bridge/wrapper/signal.dart'; import 'package:twonly/core/signal/engine.dart'; import 'package:twonly/locator.dart'; @@ -13,12 +14,83 @@ import 'package:twonly/src/services/signal/protocol_state.signal.dart'; import 'package:twonly/src/services/signal/utils.signal.dart'; import 'package:twonly/src/utils/log.dart'; +final Mutex _upgradeSignalSessionsLock = Mutex(); + Future processSignalUserData(Response_UserData userData) async { return lockingSignalProtocol.protect(() async { return _processSignalUserData(userData); }); } +/// Upgrades every known v1 contact that currently publishes a PQXDH bundle. +/// +/// The legacy session is deliberately retained: messages that were already in +/// flight before the upgrade can still be decrypted, while newly queued +/// messages use the contact's updated v2 session. +Future upgradeSignalSessionsToV2() async { + if (_upgradeSignalSessionsLock.isLocked) return; + + try { + await _upgradeSignalSessionsLock.protect(() async { + final contacts = await twonlyDB.contactsDao.getAllContacts(); + final legacyContacts = contacts.where( + (contact) => + contact.signalVersion == SignalVersion.v1 && + !contact.accountDeleted, + ); + + for (final contact in legacyContacts) { + try { + final userData = await apiService.getUserById(contact.userId); + if (userData == null || !userData.hasPqcBundle()) continue; + + if (await processSignalUserData(userData)) { + Log.info('Upgraded Signal session with ${contact.userId} to v2.'); + } + } catch (error) { + // Migration is best-effort. A contact that is temporarily + // unavailable remains on v1 and is retried after a later message + // queue drain. + Log.warn( + 'Could not upgrade Signal session with ${contact.userId}: $error', + ); + } + } + }); + } catch (error) { + // This function is started without awaiting it from the message loop. + Log.warn('Could not start Signal session upgrades: $error'); + } +} + +/// Replaces the current session with a freshly downloaded pre-key bundle. +Future resetSignalSession(int contactId) async { + final userData = await apiService.getUserById(contactId); + if (userData == null) return false; + + return lockingSignalProtocol.protect(() async { + if (userData.hasPqcBundle()) { + await RustSignal.resetContactSession(contactId: contactId); + } else { + final signalStore = await getSignalStore(); + await signalStore?.deleteAllSessions(contactId.toString()); + } + final reset = await _processSignalUserData(userData); + if (reset) recordResyncAttempt(contactId, success: true); + return reset; + }); +} + +ECPublicKey _decodePublicKey(List bytes, String fieldName) { + if (bytes.length != 33 || bytes.first != Curve.djbType) { + throw InvalidKeyException( + '$fieldName must be a serialized 33-byte Curve25519 public key ' + '(received ${bytes.length} bytes)', + ); + } + return Curve.decodePoint(Uint8List.fromList(bytes), 0); +} + Future _processSignalUserData(Response_UserData userData) async { if (userData.hasPqcBundle()) { return _processSignalUserDataV2(userData); @@ -29,17 +101,20 @@ Future _processSignalUserData(Response_UserData userData) async { Future _processSignalUserDataV2(Response_UserData userData) async { try { final tempIdentityKey = IdentityKey( - Curve.decodePoint( - DjbECPublicKey( - Uint8List.fromList(userData.publicIdentityKey), - ).serialize(), - 1, - ), + _decodePublicKey(userData.publicIdentityKey, 'identity key'), ); - final signalStore = await getSignalStore(); - if (signalStore != null) { - final existingIdentity = await signalStore.getIdentity( + final contact = await twonlyDB.contactsDao.getContactById( + userData.userId.toInt(), + ); + + // Only the one-time V1 -> V2 migration needs to compare against the + // legacy Dart identity store. Established V2 contacts are authenticated + // by the identity retained in the Rust store; consulting the obsolete + // Dart store here can make a valid V2 reset fail on legacy/corrupt data. + if (contact?.signalVersion != SignalVersion.v2) { + final signalStore = await getSignalStore(); + final existingIdentity = await signalStore?.getIdentity( SignalProtocolAddress(userData.userId.toString(), defaultDeviceId), ); @@ -60,11 +135,9 @@ Future _processSignalUserDataV2(Response_UserData userData) async { ); } else if (userData.prekeys.isNotEmpty) { tempEccPreKeyId = userData.prekeys.first.id.toInt(); - tempEccPreKeyPublic = Curve.decodePoint( - DjbECPublicKey( - Uint8List.fromList(userData.prekeys.first.prekey), - ).serialize(), - 1, + tempEccPreKeyPublic = _decodePublicKey( + userData.prekeys.first.prekey, + 'prekey', ).serialize(); } @@ -102,9 +175,6 @@ Future _processSignalUserDataV2(Response_UserData userData) async { bundle: rustBundle, ); - final contact = await twonlyDB.contactsDao.getContactById( - userData.userId.toInt(), - ); if (contact != null && contact.signalVersion != SignalVersion.v2) { await twonlyDB.contactsDao.updateContact( contact.userId, @@ -120,64 +190,57 @@ Future _processSignalUserDataV2(Response_UserData userData) async { } Future _processSignalUserDataV1(Response_UserData userData) async { - final SignalProtocolStore? signalStore = await getSignalStore(); - - if (signalStore == null) { - return false; - } - - final targetAddress = getSignalAddress(userData.userId.toInt()); - - final sessionBuilder = SessionBuilder.fromSignalStore( - signalStore, - targetAddress, - ); - - ECPublicKey? tempPrePublicKey; - int? tempPreKeyId; - - if (userData.prekeys.isNotEmpty) { - tempPrePublicKey = Curve.decodePoint( - DjbECPublicKey( - Uint8List.fromList(userData.prekeys.first.prekey), - ).serialize(), - 1, - ); - tempPreKeyId = userData.prekeys.first.id.toInt(); - } - - final tempSignedPreKeyId = userData.signedPrekeyId.toInt(); - - final tempSignedPreKeyPublic = Curve.decodePoint( - DjbECPublicKey(Uint8List.fromList(userData.signedPrekey)).serialize(), - 1, - ); - - final tempSignedPreKeySignature = Uint8List.fromList( - userData.signedPrekeySignature, - ); - - final tempIdentityKey = IdentityKey( - Curve.decodePoint( - DjbECPublicKey( - Uint8List.fromList(userData.publicIdentityKey), - ).serialize(), - 1, - ), - ); - - final preKeyBundle = PreKeyBundle( - userData.registrationId.toInt(), - defaultDeviceId, - tempPreKeyId, - tempPrePublicKey, - tempSignedPreKeyId, - tempSignedPreKeyPublic, - tempSignedPreKeySignature, - tempIdentityKey, - ); - try { + final SignalProtocolStore? signalStore = await getSignalStore(); + + if (signalStore == null) { + return false; + } + + final targetAddress = getSignalAddress(userData.userId.toInt()); + + final sessionBuilder = SessionBuilder.fromSignalStore( + signalStore, + targetAddress, + ); + + ECPublicKey? tempPrePublicKey; + int? tempPreKeyId; + + if (userData.prekeys.isNotEmpty) { + tempPrePublicKey = _decodePublicKey( + userData.prekeys.first.prekey, + 'prekey', + ); + tempPreKeyId = userData.prekeys.first.id.toInt(); + } + + final tempSignedPreKeyId = userData.signedPrekeyId.toInt(); + + final tempSignedPreKeyPublic = _decodePublicKey( + userData.signedPrekey, + 'signed prekey', + ); + + final tempSignedPreKeySignature = Uint8List.fromList( + userData.signedPrekeySignature, + ); + + final tempIdentityKey = IdentityKey( + _decodePublicKey(userData.publicIdentityKey, 'identity key'), + ); + + final preKeyBundle = PreKeyBundle( + userData.registrationId.toInt(), + defaultDeviceId, + tempPreKeyId, + tempPrePublicKey, + tempSignedPreKeyId, + tempSignedPreKeyPublic, + tempSignedPreKeySignature, + tempIdentityKey, + ); + await sessionBuilder.processPreKeyBundle(preKeyBundle); return true; } catch (e) { diff --git a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart index 797f7a56..c06b9a99 100644 --- a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart @@ -127,8 +127,10 @@ class MainCameraController { try { await cameraControllerTemp?.stopImageStream(); // ignore: empty_catches - } catch (e) {} - await cameraControllerTemp?.dispose(); + } catch (e) { + } finally { + await cameraControllerTemp?.dispose(); + } }, ); initCameraStarted = false; diff --git a/lib/src/visual/views/contact/contact.view.dart b/lib/src/visual/views/contact/contact.view.dart index fe28593f..beb34996 100644 --- a/lib/src/visual/views/contact/contact.view.dart +++ b/lib/src/visual/views/contact/contact.view.dart @@ -10,6 +10,7 @@ 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/services/signal/session.signal.dart'; +import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; @@ -38,6 +39,7 @@ class ContactView extends StatefulWidget { class _ContactViewState extends State { Contact? _contact; List _memberOfGroups = []; + bool _isResettingSignalSession = false; late StreamSubscription _streamContact; late StreamSubscription> _streamMemberOfGroups; @@ -136,6 +138,37 @@ class _ContactViewState extends State { } } + Future handleResetSignalSession(Contact contact) async { + if (_isResettingSignalSession) return; + + final displayName = getContactDisplayName(contact); + final confirmed = await showAlertDialog( + context, + context.lang.contactResetSessionTitle(displayName), + context.lang.contactResetSessionBody, + customOk: context.lang.contactResetSessionConfirm, + ); + if (!confirmed || !mounted) return; + + setState(() => _isResettingSignalSession = true); + var success = false; + try { + success = await resetSignalSession(contact.userId); + } catch (error) { + Log.error('Could not reset Signal session: $error'); + } + if (!mounted) return; + + setState(() => _isResettingSignalSession = false); + showSnackbar( + context, + success + ? context.lang.contactResetSessionSuccess(displayName) + : context.lang.contactResetSessionFailed, + level: success ? SnackbarLevel.success : SnackbarLevel.error, + ); + } + @override Widget build(BuildContext context) { if (_contact == null) return Container(); @@ -268,6 +301,19 @@ class _ContactViewState extends State { text: context.lang.contactBlock, onTap: () => handleUserBlockRequest(contact), ), + BetterListTile( + icon: FontAwesomeIcons.screwdriverWrench, + text: context.lang.contactTroubleshoot, + trailing: _isResettingSignalSession + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + onTap: _isResettingSignalSession + ? null + : () => handleResetSignalSession(contact), + ), BetterListTile( icon: FontAwesomeIcons.userMinus, iconSize: 16, diff --git a/pubspec.yaml b/pubspec.yaml index 5e838714..1482b415 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.5.2+172 +version: 0.5.3+173 environment: sdk: ^3.11.0 diff --git a/rust/src/backup/backup_archive.rs b/rust/src/backup/backup_archive.rs index ca631223..faa41b9e 100644 --- a/rust/src/backup/backup_archive.rs +++ b/rust/src/backup/backup_archive.rs @@ -13,6 +13,65 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter}; pub(crate) struct BackupArchive {} impl BackupArchive { + async fn reset_restored_signal_sessions( + file_name: &str, + database_path: &Path, + encryption_key: Option<&str>, + ) -> Result<()> { + let table = match file_name { + "twonly.sqlite" => "signal_session_stores", + "rust_db.sqlite" => "signal_sessions", + _ => return Ok(()), + }; + + let database = + Database::new(&database_path.display().to_string(), encryption_key, false).await?; + + let table_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?)", + ) + .bind(table) + .fetch_one(&database.pool) + .await?; + + if table_exists { + match file_name { + "twonly.sqlite" => { + sqlx::query("DELETE FROM signal_session_stores") + .execute(&database.pool) + .await?; + } + "rust_db.sqlite" => { + sqlx::query("DELETE FROM signal_sessions") + .execute(&database.pool) + .await?; + } + _ => unreachable!(), + } + } + + // The hotfix chooses the encryption implementation from this column. + // A restored v2 marker without its restored session would make the + // first outgoing message fail before the normal migration can rebuild + // it. Mark contacts as v1; the post-download migration upgrades every + // peer that currently publishes a PQXDH bundle. + if file_name == "twonly.sqlite" { + let signal_version_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pragma_table_info('contacts') WHERE name = 'signal_version')", + ) + .fetch_one(&database.pool) + .await?; + if signal_version_exists { + sqlx::query("UPDATE contacts SET signal_version = 'v1'") + .execute(&database.pool) + .await?; + } + } + + database.pool.close().await; + Ok(()) + } + #[allow(clippy::type_complexity)] fn get_backup_files( ctx: &Context, @@ -147,9 +206,19 @@ impl BackupArchive { } } - for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx, &key_manager)? { + for (file_name, target_dir, is_db, mut encryption_key) in + Self::get_backup_files(ctx, &key_manager)? + { let src = restore_temp_dir.join(file_name); if src.exists() { + if is_db { + Self::reset_restored_signal_sessions( + file_name, + &src, + encryption_key.as_deref(), + ) + .await?; + } std::fs::create_dir_all(&target_dir)?; let dst = target_dir.join(file_name); if is_db { @@ -161,6 +230,7 @@ impl BackupArchive { std::fs::copy(src, dst)?; } + encryption_key.zeroize(); } std::fs::remove_dir_all(&restore_temp_dir)?; @@ -210,6 +280,18 @@ mod tests { ReceivedMessage::insert(&db.pool, 1, b"original message") .await .unwrap(); + sqlx::query( + "INSERT INTO signal_identities(name, identity_key, timestamp) VALUES('restored-peer', x'040506', 1)", + ) + .execute(&db.pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO signal_sessions(name, device_id, record_bytes) VALUES('restored-peer', 1, x'010203')", + ) + .execute(&db.pool) + .await + .unwrap(); // Add a file let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); @@ -217,6 +299,39 @@ mod tests { key_manager.main_key.get_login_token() }; + // The hotfix still carries the legacy Dart Signal store alongside the + // Rust PQXDH store. Both session stores are part of an archive. + { + let config = ctx.get_config().unwrap(); + let app_db_path = PathBuf::from(&config.database_dir).join("twonly.sqlite"); + let app_db = Database::new(&app_db_path.display().to_string(), None, false) + .await + .unwrap(); + sqlx::query( + "CREATE TABLE contacts(user_id INTEGER PRIMARY KEY, signal_version TEXT NOT NULL)", + ) + .execute(&app_db.pool) + .await + .unwrap(); + sqlx::query( + "CREATE TABLE signal_session_stores(device_id INTEGER NOT NULL, name TEXT NOT NULL, session_record BLOB NOT NULL, PRIMARY KEY(device_id, name))", + ) + .execute(&app_db.pool) + .await + .unwrap(); + sqlx::query("INSERT INTO contacts(user_id, signal_version) VALUES(7, 'v2')") + .execute(&app_db.pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO signal_session_stores(device_id, name, session_record) VALUES(1, '7', x'070809')", + ) + .execute(&app_db.pool) + .await + .unwrap(); + app_db.pool.close().await; + } + // 2. Create backup let backup_path = BackupArchive::create_backup(&ctx).await.unwrap(); assert!(backup_path.exists()); @@ -266,6 +381,44 @@ mod tests { assert_eq!(messages[0].sender_id, 1); assert_eq!(messages[0].content, b"original message"); + let session_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM signal_sessions") + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(session_count, 0, "restored PQXDH sessions must be reset"); + + let identity_key: Vec = sqlx::query_scalar( + "SELECT identity_key FROM signal_identities WHERE name = 'restored-peer'", + ) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!( + identity_key, + vec![4, 5, 6], + "trusted contact identities must survive session reset" + ); + + let app_db_path = PathBuf::from(&config.database_dir).join("twonly.sqlite"); + let app_db = Database::new(&app_db_path.display().to_string(), None, false) + .await + .unwrap(); + let legacy_session_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM signal_session_stores") + .fetch_one(&app_db.pool) + .await + .unwrap(); + assert_eq!( + legacy_session_count, 0, + "restored legacy Signal sessions must be reset" + ); + let signal_version: String = + sqlx::query_scalar("SELECT signal_version FROM contacts WHERE user_id = 7") + .fetch_one(&app_db.pool) + .await + .unwrap(); + assert_eq!(signal_version, "v1"); + let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); let config_content = std::fs::read_to_string(config_file).unwrap(); assert_eq!(config_content, "original config"); diff --git a/rust/src/bridge/wrapper/signal.rs b/rust/src/bridge/wrapper/signal.rs index 3c163117..61de6c81 100644 --- a/rust/src/bridge/wrapper/signal.rs +++ b/rust/src/bridge/wrapper/signal.rs @@ -29,6 +29,17 @@ impl RustSignal { engine.process_prekey_bundle(name, device_id, bundle).await } + /// Removes the current v2 session for a contact. The Dart hotfix layer + /// immediately establishes its replacement from a freshly fetched bundle. + pub async fn reset_contact_session(contact_id: i64) -> Result<()> { + let guard = get_twonly_flutter()?.signal_engine.lock().await; + let engine = guard.as_ref().ok_or(TwonlyError::Initialization)?; + engine + .reset_session(&contact_id.to_string(), 1, true) + .await?; + Ok(()) + } + pub async fn encrypt(name: String, device_id: u32, plaintext: Vec) -> Result> { let guard = get_twonly_flutter()?.signal_engine.lock().await; let engine = guard.as_ref().ok_or(TwonlyError::Initialization)?; diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index de359247..c072ac77 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -40,7 +40,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1109927244; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -2042422055; // Section: executor @@ -915,6 +915,46 @@ fn wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl( }, ) } +fn wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_signal_reset_contact_session", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_contact_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::wrapper::signal::RustSignal::reset_contact_session( + api_contact_id, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__wrapper__rust_utils_generate_shares_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2201,24 +2241,25 @@ fn pde_ffi_dispatcher_primary_impl( 34 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), 35 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), 36 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), -37 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -38 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -39 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_announced_user_by_public_id_impl(port, ptr, rust_vec_len, data_len), -40 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_config_impl(port, ptr, rust_vec_len, data_len), -41 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_contact_promotion_impl(port, ptr, rust_vec_len, data_len), -42 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_contact_version_impl(port, ptr, rust_vec_len, data_len), -43 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_other_promotions_by_public_id_impl(port, ptr, rust_vec_len, data_len), -44 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_own_promotions_after_version_impl(port, ptr, rust_vec_len, data_len), -45 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_share_for_contact_impl(port, ptr, rust_vec_len, data_len), -46 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_push_new_user_relation_impl(port, ptr, rust_vec_len, data_len), -47 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_push_own_promotion_and_clear_old_version_impl(port, ptr, rust_vec_len, data_len), -48 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_set_contact_version_impl(port, ptr, rust_vec_len, data_len), -49 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_set_shares_impl(port, ptr, rust_vec_len, data_len), -50 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_store_other_promotion_impl(port, ptr, rust_vec_len, data_len), -51 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_update_config_impl(port, ptr, rust_vec_len, data_len), -52 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_sign_data_impl(port, ptr, rust_vec_len, data_len), -53 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_verify_signature_impl(port, ptr, rust_vec_len, data_len), -54 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_verify_stored_pubkey_impl(port, ptr, rust_vec_len, data_len), +37 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len), +38 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +39 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +40 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_announced_user_by_public_id_impl(port, ptr, rust_vec_len, data_len), +41 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_config_impl(port, ptr, rust_vec_len, data_len), +42 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_contact_promotion_impl(port, ptr, rust_vec_len, data_len), +43 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_contact_version_impl(port, ptr, rust_vec_len, data_len), +44 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_other_promotions_by_public_id_impl(port, ptr, rust_vec_len, data_len), +45 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_own_promotions_after_version_impl(port, ptr, rust_vec_len, data_len), +46 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_share_for_contact_impl(port, ptr, rust_vec_len, data_len), +47 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_push_new_user_relation_impl(port, ptr, rust_vec_len, data_len), +48 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_push_own_promotion_and_clear_old_version_impl(port, ptr, rust_vec_len, data_len), +49 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_set_contact_version_impl(port, ptr, rust_vec_len, data_len), +50 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_set_shares_impl(port, ptr, rust_vec_len, data_len), +51 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_store_other_promotion_impl(port, ptr, rust_vec_len, data_len), +52 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_update_config_impl(port, ptr, rust_vec_len, data_len), +53 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_sign_data_impl(port, ptr, rust_vec_len, data_len), +54 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_verify_signature_impl(port, ptr, rust_vec_len, data_len), +55 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_verify_stored_pubkey_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/rust/src/signal/engine.rs b/rust/src/signal/engine.rs index debfa415..a282ce68 100644 --- a/rust/src/signal/engine.rs +++ b/rust/src/signal/engine.rs @@ -2,8 +2,8 @@ use crate::error::{Result, TwonlyError}; use libsignal_protocol::{ message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, GenericSignedPreKey, IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId, KyberPreKeyStore, PreKeyBundle, - PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, PublicKey, SignalMessage, - SignedPreKeyId, SignedPreKeyStore, Timestamp, + PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, PublicKey, SessionStore, + SignalMessage, SignedPreKeyId, SignedPreKeyStore, Timestamp, }; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -414,6 +414,58 @@ impl RustSignalEngine { Ok(()) } + /// Retires the session with a peer so a fresh one can replace it. + /// + /// A hard reset deletes the complete record, including archived states. + /// A soft reset archives only the current state so in-flight messages can + /// still be decrypted while a replacement session is established. + /// + /// Returns whether a session existed. + pub async fn reset_session(&self, name: &str, device_id: u32, hard: bool) -> Result { + let mut store_guard = self.store.lock().await; + let store = &mut *store_guard; + + let d_id = DeviceId::try_from(device_id) + .map_err(|_| TwonlyError::Generic(format!("Invalid device id: {}", device_id)))?; + let address = ProtocolAddress::new(name.to_owned(), d_id); + + if hard { + let deleted = + sqlx::query("DELETE FROM signal_sessions WHERE name = ? AND device_id = ?") + .bind(name) + .bind(device_id) + .execute(&store.pool) + .await? + .rows_affected(); + tracing::warn!(name, device_id, "deleted the signal session"); + return Ok(deleted != 0); + } + + let Some(mut record) = store + .session_store + .load_session(&address) + .assert_send() + .await + .map_err(|e| TwonlyError::Signal(e.to_string()))? + else { + return Ok(false); + }; + + record + .archive_current_state() + .map_err(|e| TwonlyError::Signal(e.to_string()))?; + + store + .session_store + .store_session(&address, &record) + .assert_send() + .await + .map_err(|e| TwonlyError::Signal(e.to_string()))?; + + tracing::warn!(name, device_id, "archived the signal session for a reset"); + Ok(true) + } + pub async fn encrypt_message( &self, name: String, @@ -618,4 +670,40 @@ mod tests { assert_eq!(plaintext.to_vec(), decrypted); } + + #[tokio::test] + async fn hard_reset_deletes_and_allows_rebuilding_a_contact_session() { + let (alice_engine, _alice_dir) = create_test_engine("alice").await; + let (bob_engine, _bob_dir) = create_test_engine("bob").await; + + alice_engine + .process_prekey_bundle( + "bob".to_string(), + 1, + bob_engine.generate_bundle().await.unwrap(), + ) + .await + .unwrap(); + + assert!(alice_engine.reset_session("bob", 1, true).await.unwrap()); + assert!(!alice_engine.reset_session("bob", 1, true).await.unwrap()); + assert!(alice_engine + .encrypt_message("bob".to_string(), 1, b"missing".to_vec()) + .await + .is_err()); + + alice_engine + .process_prekey_bundle( + "bob".to_string(), + 1, + bob_engine.generate_bundle().await.unwrap(), + ) + .await + .unwrap(); + + assert!(alice_engine + .encrypt_message("bob".to_string(), 1, b"rebuilt".to_vec()) + .await + .is_ok()); + } } diff --git a/test/model/mailbox_drained_proto_test.dart b/test/model/mailbox_drained_proto_test.dart new file mode 100644 index 00000000..c7eecfc3 --- /dev/null +++ b/test/model/mailbox_drained_proto_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'; + +void main() { + test('decodes the server mailbox-drained signal', () { + // ServerToClient.V0 (field 1) containing V0.mailboxDrained (field 11). + final message = ServerToClient.fromBuffer([0x0a, 0x02, 0x58, 0x01]); + + expect(message.v0.hasMailboxDrained(), isTrue); + expect(message.v0.mailboxDrained, isTrue); + expect(message.v0.whichKind(), V0_Kind.mailboxDrained); + }); +} diff --git a/test/model/pqc_upload_proto_test.dart b/test/model/pqc_upload_proto_test.dart new file mode 100644 index 00000000..4477b4cb --- /dev/null +++ b/test/model/pqc_upload_proto_test.dart @@ -0,0 +1,27 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart'; + +void main() { + test('PQC upload preserves migration identity fields on the wire', () { + final request = ApplicationData_UploadPqcPreKeys( + eccSignedPrekeyId: Int64(11), + eccSignedPrekey: [5, 6], + eccSignedPrekeySignature: [7, 8], + kyberSignedPrekeyId: Int64(12), + kyberSignedPrekey: [9, 10], + kyberSignedPrekeySignature: [11, 12], + publicIdentityKey: List.filled(33, 1), + registrationId: Int64(42), + ); + + final decoded = ApplicationData_UploadPqcPreKeys.fromBuffer( + request.writeToBuffer(), + ); + + expect(decoded.publicIdentityKey, hasLength(33)); + expect(decoded.registrationId, Int64(42)); + expect(decoded.hasPublicIdentityKey(), isTrue); + expect(decoded.hasRegistrationId(), isTrue); + }); +} diff --git a/test/services/receipts_out_of_sync_detection_test.dart b/test/services/receipts_out_of_sync_detection_test.dart new file mode 100644 index 00000000..1d88e4d7 --- /dev/null +++ b/test/services/receipts_out_of_sync_detection_test.dart @@ -0,0 +1,86 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/database/twonly.db.dart'; + +void main() { + late TwonlyDB db; + + setUp(() async { + db = TwonlyDB.forTesting( + DatabaseConnection( + NativeDatabase.memory(), + closeStreamsSynchronously: true, + ), + ); + + for (final contactId in [10, 11, 12]) { + await db.contactsDao.insertContact( + ContactsCompanion.insert( + userId: Value(contactId), + username: 'contact-$contactId', + ), + ); + } + }); + + tearDown(() => db.close()); + + Future addReceipt( + int contactId, + int retryCount, { + bool mediaUpload = false, + }) async { + await db.receiptsDao.insertReceipt( + ReceiptsCompanion( + contactId: Value(contactId), + message: Value(Uint8List.fromList([contactId])), + retryCount: Value(retryCount), + willBeRetriedByMediaUpload: Value(mediaUpload), + ), + ); + } + + test( + 'detects multiple message retries above the ratchet threshold', + () async { + await addReceipt(10, 21); + await addReceipt(10, 30); + await addReceipt(11, 21); + await addReceipt(11, 20); + await addReceipt(12, 25, mediaUpload: true); + await addReceipt(12, 25, mediaUpload: true); + + final contactIds = await db.receiptsDao + .getContactsWithStuckSignalMessages( + retryThreshold: 20, + ); + + expect(contactIds, [10]); + }, + ); + + test('clears message counters after a successful session reset', () async { + await addReceipt(10, 21); + await addReceipt(10, 30); + await addReceipt(10, 25, mediaUpload: true); + + await db.receiptsDao.resetMessageRetryCountsForContact(10); + + final receipts = await (db.select( + db.receipts, + )..where((receipt) => receipt.contactId.equals(10))).get(); + final messageReceipts = receipts.where( + (receipt) => !receipt.willBeRetriedByMediaUpload, + ); + final mediaReceipt = receipts.singleWhere( + (receipt) => receipt.willBeRetriedByMediaUpload, + ); + + expect( + messageReceipts.map((receipt) => receipt.retryCount), + everyElement(0), + ); + expect(mediaReceipt.retryCount, 25); + }); +}