Merge pull request #419 from twonlyapp/dev

- Improved: Design of some UI components
- Improved: Memories viewer shows state for batch operations and has improved performance
- Fix: Issue with background notifications on Android
- Fix: Changed minimum threshold for the user discovery to 3
- Fix: Multiple UI issues
- Fix: Auto-detect if FCM token does not work and trigger a reset
This commit is contained in:
Tobi 2026-06-10 22:42:01 +02:00 committed by GitHub
commit 7cb1e31e0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 599 additions and 2591 deletions

View file

@ -1,13 +1,12 @@
# Changelog # Changelog
## 0.2.30 ## 0.3.0
- Fix: Changed minimum threshold for the user discovery to 3
## 0.2.28
- Improved: Design of some UI components - Improved: Design of some UI components
- Improved: Memories viewer shows state for batch operations and has improved performance - Improved: Memories viewer shows state for batch operations and has improved performance
- Fix: Issue with background notifications on Android
- Fix: Changed minimum threshold for the user discovery to 3
- Fix: Multiple UI issues
- Fix: Auto-detect if FCM token does not work and trigger a reset - Fix: Auto-detect if FCM token does not work and trigger a reset
## 0.2.26 ## 0.2.26

View file

@ -33,7 +33,7 @@ platform :android do
# Load release notes from CHANGELOG.md # Load release notes from CHANGELOG.md
changelog_path = File.expand_path("../CHANGELOG.md", __dir__) changelog_path = File.expand_path("../CHANGELOG.md", __dir__)
release_notes = "Automated local release via Fastlane" release_notes = "Small bug fixes."
if File.exist?(changelog_path) if File.exist?(changelog_path)
changelog_content = File.read(changelog_path) changelog_content = File.read(changelog_path)
escaped_version = Regexp.escape(version) escaped_version = Regexp.escape(version)
@ -122,6 +122,11 @@ platform :android do
UI.message("Starting F-Droid deployment...") UI.message("Starting F-Droid deployment...")
FileUtils.mkdir_p(fdroid_repo_dir) FileUtils.mkdir_p(fdroid_repo_dir)
# Delete all APK files in the directory
sh("rm -f #{fdroid_repo_dir}/*.apk")
UI.message("All APK files deleted.")
apks.each do |apk_path| apks.each do |apk_path|
basename = File.basename(apk_path) basename = File.basename(apk_path)
new_name = "eu.twonly_v#{version}-#{basename}" new_name = "eu.twonly_v#{version}-#{basename}"

View file

@ -76,7 +76,7 @@ void main() async {
unawaited(StartupGuard.markAppStartup()); unawaited(StartupGuard.markAppStartup());
var storageError = await twonlyMinimumInitialization(); var storageError = await twonlyMinimumInitialization();
await initFCMService(); await FcmNotificationService.initStartup();
var userExists = false; var userExists = false;
@ -109,6 +109,8 @@ void main() async {
unawaited(initFileDownloader()); unawaited(initFileDownloader());
if (userExists) { if (userExists) {
unawaited(FcmNotificationService.initAfterUserLoaded());
if (userService.currentUser.allowErrorTrackingViaSentry) { if (userService.currentUser.allowErrorTrackingViaSentry) {
AppState.allowErrorTrackingViaSentry = true; AppState.allowErrorTrackingViaSentry = true;
await SentryFlutter.init( await SentryFlutter.init(

View file

@ -140,18 +140,6 @@ abstract class AppLocalizations {
/// **'Say goodbye to addictive features! twonly was created for sharing moments, free from useless distractions or ads.'** /// **'Say goodbye to addictive features! twonly was created for sharing moments, free from useless distractions or ads.'**
String get onboardingFocusBody; String get onboardingFocusBody;
/// No description provided for @onboardingSendTwonliesTitle.
///
/// In en, this message translates to:
/// **'Send twonlies'**
String get onboardingSendTwonliesTitle;
/// No description provided for @onboardingSendTwonliesBody.
///
/// In en, this message translates to:
/// **'Share moments securely with your partner. twonly ensures that only your partner can open it, keeping your moments with your partner a two(o)nly thing!'**
String get onboardingSendTwonliesBody;
/// No description provided for @onboardingNotProductTitle. /// No description provided for @onboardingNotProductTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -164,12 +152,6 @@ abstract class AppLocalizations {
/// **'twonly is financed by donations and an optional subscription. Your data will never be sold.'** /// **'twonly is financed by donations and an optional subscription. Your data will never be sold.'**
String get onboardingNotProductBody; String get onboardingNotProductBody;
/// No description provided for @onboardingGetStartedTitle.
///
/// In en, this message translates to:
/// **'Let\'s go!'**
String get onboardingGetStartedTitle;
/// No description provided for @registerUsernameSlogan. /// No description provided for @registerUsernameSlogan.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -320,24 +302,12 @@ abstract class AppLocalizations {
/// **'Username not found'** /// **'Username not found'**
String get searchUsernameNotFound; String get searchUsernameNotFound;
/// No description provided for @searchUsernameNotFoundBody.
///
/// In en, this message translates to:
/// **'There is no user with the username \"{username}\" registered'**
String searchUsernameNotFoundBody(Object username);
/// No description provided for @searchUsernameNewFollowerTitle. /// No description provided for @searchUsernameNewFollowerTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Open requests'** /// **'Open requests'**
String get searchUsernameNewFollowerTitle; String get searchUsernameNewFollowerTitle;
/// No description provided for @chatListViewSearchUserNameBtn.
///
/// In en, this message translates to:
/// **'Add your first twonly contact!'**
String get chatListViewSearchUserNameBtn;
/// No description provided for @chatListDetailInput. /// No description provided for @chatListDetailInput.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -512,12 +482,6 @@ abstract class AppLocalizations {
/// **'Store in Gallery'** /// **'Store in Gallery'**
String get settingsStorageDataStoreInGTitle; String get settingsStorageDataStoreInGTitle;
/// No description provided for @settingsStorageDataStoreInGSubtitle.
///
/// In en, this message translates to:
/// **'Store saved images additional in the systems gallery.'**
String get settingsStorageDataStoreInGSubtitle;
/// No description provided for @settingsStorageDataMediaAutoDownload. /// No description provided for @settingsStorageDataMediaAutoDownload.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -611,13 +575,13 @@ abstract class AppLocalizations {
/// No description provided for @settingsPrivacyBlockUsers. /// No description provided for @settingsPrivacyBlockUsers.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Block users'** /// **'Block contacts'**
String get settingsPrivacyBlockUsers; String get settingsPrivacyBlockUsers;
/// No description provided for @settingsPrivacyBlockUsersDesc. /// No description provided for @settingsPrivacyBlockUsersDesc.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Blocked users will not be able to communicate with you. You can unblock a blocked user at any time.'** /// **'Blocked contacts will not be able to communicate with you. You can unblock a blocked contact at any time.'**
String get settingsPrivacyBlockUsersDesc; String get settingsPrivacyBlockUsersDesc;
/// No description provided for @settingsPrivacyBlockUsersCount. /// No description provided for @settingsPrivacyBlockUsersCount.
@ -632,12 +596,6 @@ abstract class AppLocalizations {
/// **'Security Profile'** /// **'Security Profile'**
String get settingsPrivacyProfileSelectionTitle; String get settingsPrivacyProfileSelectionTitle;
/// No description provided for @settingsPrivacyProfileSelectionDesc.
///
/// In en, this message translates to:
/// **'Choose your setup path and security configuration'**
String get settingsPrivacyProfileSelectionDesc;
/// No description provided for @securityProfileTitle. /// No description provided for @securityProfileTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -935,13 +893,19 @@ abstract class AppLocalizations {
/// No description provided for @contactVerifyNumberTitle. /// No description provided for @contactVerifyNumberTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Verify contact'** /// **'Verify contacts'**
String get contactVerifyNumberTitle; String get contactVerifyNumberTitle;
/// No description provided for @contactVerifyNumberSubtitle.
///
/// In en, this message translates to:
/// **'Verify the identity of your contacts to make sure you are texting the right person.'**
String get contactVerifyNumberSubtitle;
/// No description provided for @userVerifiedTitle. /// No description provided for @userVerifiedTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'User verified'** /// **'Contact verified'**
String get userVerifiedTitle; String get userVerifiedTitle;
/// No description provided for @contactVerifiedBy. /// No description provided for @contactVerifiedBy.
@ -1013,13 +977,13 @@ abstract class AppLocalizations {
/// No description provided for @contactBlockBody. /// No description provided for @contactBlockBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'A blocked user will no longer be able to send you messages and their profile will be hidden from view. To unblock a user, simply navigate to Settings > Privacy > Blocked Users.'** /// **'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.'**
String get contactBlockBody; String get contactBlockBody;
/// No description provided for @contactRemove. /// No description provided for @contactRemove.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Remove user'** /// **'Remove contact'**
String get contactRemove; String get contactRemove;
/// No description provided for @contactRemoveTitle. /// No description provided for @contactRemoveTitle.
@ -1031,7 +995,7 @@ abstract class AppLocalizations {
/// No description provided for @contactRemoveBody. /// No description provided for @contactRemoveBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Permanently remove the user. If the user tries to send you a new message, you will have to accept the user again first.'** /// **'Permanently remove the contact. If the contact tries to send you a new message, you will have to accept the contact again first.'**
String get contactRemoveBody; String get contactRemoveBody;
/// No description provided for @undo. /// No description provided for @undo.
@ -1721,7 +1685,7 @@ abstract class AppLocalizations {
/// No description provided for @reportUser. /// No description provided for @reportUser.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Report user'** /// **'Report contact'**
String get reportUser; String get reportUser;
/// No description provided for @newDeviceRegistered. /// No description provided for @newDeviceRegistered.
@ -2360,18 +2324,6 @@ abstract class AppLocalizations {
/// **'Draft'** /// **'Draft'**
String get draftMessage; String get draftMessage;
/// No description provided for @exportMemories.
///
/// In en, this message translates to:
/// **'Export memories (Beta)'**
String get exportMemories;
/// No description provided for @importMemories.
///
/// In en, this message translates to:
/// **'Import memories (Beta)'**
String get importMemories;
/// No description provided for @voiceMessageSlideToCancel. /// No description provided for @voiceMessageSlideToCancel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2771,7 +2723,7 @@ abstract class AppLocalizations {
/// No description provided for @verificationBadgeGreenDesc. /// No description provided for @verificationBadgeGreenDesc.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'A contact you have *personally* verified.'** /// **'A contact you have *personally verified* using the QR code.'**
String get verificationBadgeGreenDesc; String get verificationBadgeGreenDesc;
/// No description provided for @verificationBadgeYellowDesc. /// No description provided for @verificationBadgeYellowDesc.
@ -2786,6 +2738,18 @@ abstract class AppLocalizations {
/// **'A contact whose identity has *not* yet been verified.'** /// **'A contact whose identity has *not* yet been verified.'**
String get verificationBadgeRedDesc; String get verificationBadgeRedDesc;
/// No description provided for @scanNow.
///
/// In en, this message translates to:
/// **'Scan now'**
String get scanNow;
/// No description provided for @openQrCode.
///
/// In en, this message translates to:
/// **'Open QR code'**
String get openQrCode;
/// No description provided for @deleteVerificationTitle. /// No description provided for @deleteVerificationTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2810,12 +2774,6 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{1 mutual group} other{{count} mutual groups}}'** /// **'{count, plural, =1{1 mutual group} other{{count} mutual groups}}'**
String mutualGroupsTitle(num count); String mutualGroupsTitle(num count);
/// No description provided for @mutualGroupsSentMessages.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 message sent} other{{count} messages sent}}'**
String mutualGroupsSentMessages(num count);
/// No description provided for @chatEntryFlameRestored. /// No description provided for @chatEntryFlameRestored.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2888,12 +2846,6 @@ abstract class AppLocalizations {
/// **'When the typing indicator is turned off, you can\'t see when others are typing a message.'** /// **'When the typing indicator is turned off, you can\'t see when others are typing a message.'**
String get settingsTypingIndicationSubtitle; String get settingsTypingIndicationSubtitle;
/// No description provided for @scanQrOrShow.
///
/// In en, this message translates to:
/// **'Scan / Show QR'**
String get scanQrOrShow;
/// No description provided for @contactActionBlock. /// No description provided for @contactActionBlock.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2948,12 +2900,6 @@ abstract class AppLocalizations {
/// **'Mutual Friends'** /// **'Mutual Friends'**
String get userDiscoverySettingsTitle; String get userDiscoverySettingsTitle;
/// No description provided for @userDiscoveryWhyThisIsUsed.
///
/// In en, this message translates to:
/// **'Why this is used'**
String get userDiscoveryWhyThisIsUsed;
/// No description provided for @userDiscoveryFeatureOffers. /// No description provided for @userDiscoveryFeatureOffers.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3236,12 +3182,6 @@ abstract class AppLocalizations {
/// **'Back'** /// **'Back'**
String get back; String get back;
/// No description provided for @onboardingExampleLabel.
///
/// In en, this message translates to:
/// **'Example'**
String get onboardingExampleLabel;
/// No description provided for @makerChangedUsername. /// No description provided for @makerChangedUsername.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3410,12 +3350,6 @@ abstract class AppLocalizations {
/// **'Drag to Zoom'** /// **'Drag to Zoom'**
String get dragToZoom; String get dragToZoom;
/// No description provided for @showUsername.
///
/// In en, this message translates to:
/// **'Show username'**
String get showUsername;
/// No description provided for @onboardingProfileSelectionTitle. /// No description provided for @onboardingProfileSelectionTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3642,30 +3576,6 @@ abstract class AppLocalizations {
/// **'There are no images on your device.'** /// **'There are no images on your device.'**
String get importGalleryNoImagesFoundDesc; String get importGalleryNoImagesFoundDesc;
/// No description provided for @importGalleryShowAllImages.
///
/// In en, this message translates to:
/// **'Show all images'**
String get importGalleryShowAllImages;
/// No description provided for @importGalleryShowTwonlyAlbum.
///
/// In en, this message translates to:
/// **'Show twonly album'**
String get importGalleryShowTwonlyAlbum;
/// No description provided for @importGalleryToggleDescAll.
///
/// In en, this message translates to:
/// **'Viewing all images on your device.'**
String get importGalleryToggleDescAll;
/// No description provided for @importGalleryToggleDescTwonly.
///
/// In en, this message translates to:
/// **'Viewing the \"twonly\" album.'**
String get importGalleryToggleDescTwonly;
/// No description provided for @importGalleryFilterTwonly. /// No description provided for @importGalleryFilterTwonly.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -33,13 +33,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get onboardingFocusBody => String get onboardingFocusBody =>
'Verabschiede dich von süchtig machenden Funktionen! twonly wurde für das Teilen von Momenten ohne nutzlose Ablenkungen oder Werbung entwickelt.'; 'Verabschiede dich von süchtig machenden Funktionen! twonly wurde für das Teilen von Momenten ohne nutzlose Ablenkungen oder Werbung entwickelt.';
@override
String get onboardingSendTwonliesTitle => 'twonlies senden';
@override
String get onboardingSendTwonliesBody =>
'Teile Momente sicher mit deinem Partner. twonly stellt sicher, dass nur dein Partner sie öffnen kann, sodass deine Momente mit deinem Partner eine two(o)nly Sache bleiben!';
@override @override
String get onboardingNotProductTitle => 'Du bist nicht das Produkt!'; String get onboardingNotProductTitle => 'Du bist nicht das Produkt!';
@ -47,9 +40,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get onboardingNotProductBody => String get onboardingNotProductBody =>
'twonly wird durch Spenden und ein optionales Abonnement finanziert. Deine Daten werden niemals verkauft.'; 'twonly wird durch Spenden und ein optionales Abonnement finanziert. Deine Daten werden niemals verkauft.';
@override
String get onboardingGetStartedTitle => 'Auf geht\'s';
@override @override
String get registerUsernameSlogan => 'Konto erstellen'; String get registerUsernameSlogan => 'Konto erstellen';
@ -126,18 +116,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get searchUsernameNotFound => 'Benutzername nicht gefunden'; String get searchUsernameNotFound => 'Benutzername nicht gefunden';
@override
String searchUsernameNotFoundBody(Object username) {
return 'Es wurde kein Benutzer mit dem Benutzernamen \"$username\" gefunden.';
}
@override @override
String get searchUsernameNewFollowerTitle => 'Offene Anfragen'; String get searchUsernameNewFollowerTitle => 'Offene Anfragen';
@override
String get chatListViewSearchUserNameBtn =>
'Füge deinen ersten twonly-Kontakt hinzu!';
@override @override
String get chatListDetailInput => 'Nachricht eingeben'; String get chatListDetailInput => 'Nachricht eingeben';
@ -229,10 +210,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsStorageDataStoreInGTitle => 'In der Galerie speichern'; String get settingsStorageDataStoreInGTitle => 'In der Galerie speichern';
@override
String get settingsStorageDataStoreInGSubtitle =>
'Speichere Bilder zusätzlich in der Systemgalerie.';
@override @override
String get settingsStorageDataMediaAutoDownload => String get settingsStorageDataMediaAutoDownload =>
'Automatischer Mediendownload'; 'Automatischer Mediendownload';
@ -280,11 +257,11 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsPrivacy => 'Datenschutz & Sicherheit'; String get settingsPrivacy => 'Datenschutz & Sicherheit';
@override @override
String get settingsPrivacyBlockUsers => 'Benutzer blockieren'; String get settingsPrivacyBlockUsers => 'Kontakte blockieren';
@override @override
String get settingsPrivacyBlockUsersDesc => String get settingsPrivacyBlockUsersDesc =>
'Blockierte Benutzer können nicht mit dir kommunizieren. Du kannst einen blockierten Benutzer jederzeit wieder entsperren.'; 'Blockierte Kontakte können nicht mit dir kommunizieren. Du kannst einen blockierten Kontakt jederzeit wieder entsperren.';
@override @override
String settingsPrivacyBlockUsersCount(Object len) { String settingsPrivacyBlockUsersCount(Object len) {
@ -294,10 +271,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsPrivacyProfileSelectionTitle => 'Sicherheitsprofil'; String get settingsPrivacyProfileSelectionTitle => 'Sicherheitsprofil';
@override
String get settingsPrivacyProfileSelectionDesc =>
'Wähle deinen Setup-Pfad und deine Sicherheitskonfiguration';
@override @override
String get securityProfileTitle => 'Sicherheitsprofil'; String get securityProfileTitle => 'Sicherheitsprofil';
@ -460,10 +433,14 @@ class AppLocalizationsDe extends AppLocalizations {
'Dein Konto wird gelöscht. Es gibt keine Möglichkeit, es wiederherzustellen.'; 'Dein Konto wird gelöscht. Es gibt keine Möglichkeit, es wiederherzustellen.';
@override @override
String get contactVerifyNumberTitle => 'Benutzer verifizieren'; String get contactVerifyNumberTitle => 'Kontakte verifizieren';
@override @override
String get userVerifiedTitle => 'Benutzer verifiziert'; String get contactVerifyNumberSubtitle =>
'Überprüfe die Identität deiner Kontakte, um sicherzugehen, dass du mit der richtigen Person schreibst.';
@override
String get userVerifiedTitle => 'Kontakt verifiziert';
@override @override
String contactVerifiedBy(Object username) { String contactVerifiedBy(Object username) {
@ -508,10 +485,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contactBlockBody => String get contactBlockBody =>
'Ein blockierter Benutzer kann dir keine Nachrichten mehr senden, und deren Profil ist nicht mehr sichtbar. Um die Blockierung eines Benutzers aufzuheben, navigiere einfach zu Einstellungen > Datenschutz > Blockierte Benutzer.'; '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.';
@override @override
String get contactRemove => 'Benutzer löschen'; String get contactRemove => 'Kontakt löschen';
@override @override
String contactRemoveTitle(Object username) { String contactRemoveTitle(Object username) {
@ -520,7 +497,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contactRemoveBody => String get contactRemoveBody =>
'Den Benutzer dauerhaft entfernen. Wenn der Benutzer versucht, dir eine neue Nachricht zu senden, musst du den Benutzer erst wieder akzeptieren.'; 'Den Kontakt dauerhaft entfernen. Wenn der Kontakt versucht, dir eine neue Nachricht zu senden, musst du den Kontakt erst wieder akzeptieren.';
@override @override
String get undo => 'Rückgängig'; String get undo => 'Rückgängig';
@ -910,7 +887,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get reportUserReason => 'Meldegrund'; String get reportUserReason => 'Meldegrund';
@override @override
String get reportUser => 'Benutzer melden'; String get reportUser => 'Kontakt melden';
@override @override
String get newDeviceRegistered => String get newDeviceRegistered =>
@ -1307,12 +1284,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get draftMessage => 'Entwurf'; String get draftMessage => 'Entwurf';
@override
String get exportMemories => 'Memories exportieren (Beta)';
@override
String get importMemories => 'Memories importieren (Beta)';
@override @override
String get voiceMessageSlideToCancel => 'Zum Abbrechen ziehen'; String get voiceMessageSlideToCancel => 'Zum Abbrechen ziehen';
@ -1561,7 +1532,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get verificationBadgeGreenDesc => String get verificationBadgeGreenDesc =>
'Ein Kontakt, den du *persönlich verifiziert* hast.'; 'Ein Kontakt, den du über den QR-code *persönlich verifiziert* hast.';
@override @override
String get verificationBadgeYellowDesc => String get verificationBadgeYellowDesc =>
@ -1571,6 +1542,12 @@ class AppLocalizationsDe extends AppLocalizations {
String get verificationBadgeRedDesc => String get verificationBadgeRedDesc =>
'Ein Kontakt, dessen Identität noch *nicht überprüft* wurde.'; 'Ein Kontakt, dessen Identität noch *nicht überprüft* wurde.';
@override
String get scanNow => 'Jetzt scannen';
@override
String get openQrCode => 'QR-Code öffnen';
@override @override
String get deleteVerificationTitle => 'Verifizierung löschen?'; String get deleteVerificationTitle => 'Verifizierung löschen?';
@ -1594,17 +1571,6 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String mutualGroupsSentMessages(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Nachrichten gesendet',
one: '1 Nachricht gesendet',
);
return '$_temp0';
}
@override @override
String chatEntryFlameRestored(Object count) { String chatEntryFlameRestored(Object count) {
return '$count Flammen wiederhergestellt'; return '$count Flammen wiederhergestellt';
@ -1650,9 +1616,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsTypingIndicationSubtitle => String get settingsTypingIndicationSubtitle =>
'Bei deaktivierten Tipp-Indikatoren kannst du nicht sehen, wenn andere gerade eine Nachricht tippen.'; 'Bei deaktivierten Tipp-Indikatoren kannst du nicht sehen, wenn andere gerade eine Nachricht tippen.';
@override
String get scanQrOrShow => 'QR scannen / anzeigen';
@override @override
String get contactActionBlock => 'Blockieren'; String get contactActionBlock => 'Blockieren';
@ -1684,9 +1647,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get userDiscoverySettingsTitle => 'Gemeinsame Freunde'; String get userDiscoverySettingsTitle => 'Gemeinsame Freunde';
@override
String get userDiscoveryWhyThisIsUsed => 'Warum dies verwendet wird';
@override @override
String get userDiscoveryFeatureOffers => 'Dein Nutzen auf einen Blick'; String get userDiscoveryFeatureOffers => 'Dein Nutzen auf einen Blick';
@ -1854,9 +1814,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get back => 'Zurück'; String get back => 'Zurück';
@override
String get onboardingExampleLabel => 'Beispiel';
@override @override
String makerChangedUsername(Object maker, Object oldName, Object newName) { String makerChangedUsername(Object maker, Object oldName, Object newName) {
return '$maker hat den Benutzernamen von $oldName zu $newName geändert.'; return '$maker hat den Benutzernamen von $oldName zu $newName geändert.';
@ -1957,9 +1914,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get dragToZoom => 'Zum Zoomen ziehen'; String get dragToZoom => 'Zum Zoomen ziehen';
@override
String get showUsername => 'Benutzernamen anzeigen';
@override @override
String get onboardingProfileSelectionTitle => 'Wähle deinen Setup-Weg'; String get onboardingProfileSelectionTitle => 'Wähle deinen Setup-Weg';
@ -2094,20 +2048,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get importGalleryNoImagesFoundDesc => String get importGalleryNoImagesFoundDesc =>
'Es befinden sich keine Bilder auf deinem Gerät.'; 'Es befinden sich keine Bilder auf deinem Gerät.';
@override
String get importGalleryShowAllImages => 'Alle Bilder anzeigen';
@override
String get importGalleryShowTwonlyAlbum => 'twonly-Album anzeigen';
@override
String get importGalleryToggleDescAll =>
'Es werden alle Bilder auf deinem Gerät angezeigt.';
@override
String get importGalleryToggleDescTwonly =>
'Es wird das \"twonly\"-Album angezeigt.';
@override @override
String get importGalleryFilterTwonly => 'Nur das twonly-Album anzeigen'; String get importGalleryFilterTwonly => 'Nur das twonly-Album anzeigen';

View file

@ -32,13 +32,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get onboardingFocusBody => String get onboardingFocusBody =>
'Say goodbye to addictive features! twonly was created for sharing moments, free from useless distractions or ads.'; 'Say goodbye to addictive features! twonly was created for sharing moments, free from useless distractions or ads.';
@override
String get onboardingSendTwonliesTitle => 'Send twonlies';
@override
String get onboardingSendTwonliesBody =>
'Share moments securely with your partner. twonly ensures that only your partner can open it, keeping your moments with your partner a two(o)nly thing!';
@override @override
String get onboardingNotProductTitle => 'You are not the product!'; String get onboardingNotProductTitle => 'You are not the product!';
@ -46,9 +39,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get onboardingNotProductBody => String get onboardingNotProductBody =>
'twonly is financed by donations and an optional subscription. Your data will never be sold.'; 'twonly is financed by donations and an optional subscription. Your data will never be sold.';
@override
String get onboardingGetStartedTitle => 'Let\'s go!';
@override @override
String get registerUsernameSlogan => 'Create your account'; String get registerUsernameSlogan => 'Create your account';
@ -125,17 +115,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get searchUsernameNotFound => 'Username not found'; String get searchUsernameNotFound => 'Username not found';
@override
String searchUsernameNotFoundBody(Object username) {
return 'There is no user with the username \"$username\" registered';
}
@override @override
String get searchUsernameNewFollowerTitle => 'Open requests'; String get searchUsernameNewFollowerTitle => 'Open requests';
@override
String get chatListViewSearchUserNameBtn => 'Add your first twonly contact!';
@override @override
String get chatListDetailInput => 'Type a message'; String get chatListDetailInput => 'Type a message';
@ -226,10 +208,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsStorageDataStoreInGTitle => 'Store in Gallery'; String get settingsStorageDataStoreInGTitle => 'Store in Gallery';
@override
String get settingsStorageDataStoreInGSubtitle =>
'Store saved images additional in the systems gallery.';
@override @override
String get settingsStorageDataMediaAutoDownload => 'Media auto-download'; String get settingsStorageDataMediaAutoDownload => 'Media auto-download';
@ -276,11 +254,11 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsPrivacy => 'Privacy & Security'; String get settingsPrivacy => 'Privacy & Security';
@override @override
String get settingsPrivacyBlockUsers => 'Block users'; String get settingsPrivacyBlockUsers => 'Block contacts';
@override @override
String get settingsPrivacyBlockUsersDesc => String get settingsPrivacyBlockUsersDesc =>
'Blocked users will not be able to communicate with you. You can unblock a blocked user at any time.'; 'Blocked contacts will not be able to communicate with you. You can unblock a blocked contact at any time.';
@override @override
String settingsPrivacyBlockUsersCount(Object len) { String settingsPrivacyBlockUsersCount(Object len) {
@ -290,10 +268,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsPrivacyProfileSelectionTitle => 'Security Profile'; String get settingsPrivacyProfileSelectionTitle => 'Security Profile';
@override
String get settingsPrivacyProfileSelectionDesc =>
'Choose your setup path and security configuration';
@override @override
String get securityProfileTitle => 'Security Profile'; String get securityProfileTitle => 'Security Profile';
@ -455,10 +429,14 @@ class AppLocalizationsEn extends AppLocalizations {
'Your account will be deleted. There is no change to restore it.'; 'Your account will be deleted. There is no change to restore it.';
@override @override
String get contactVerifyNumberTitle => 'Verify contact'; String get contactVerifyNumberTitle => 'Verify contacts';
@override @override
String get userVerifiedTitle => 'User verified'; String get contactVerifyNumberSubtitle =>
'Verify the identity of your contacts to make sure you are texting the right person.';
@override
String get userVerifiedTitle => 'Contact verified';
@override @override
String contactVerifiedBy(Object username) { String contactVerifiedBy(Object username) {
@ -503,10 +481,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contactBlockBody => String get contactBlockBody =>
'A blocked user will no longer be able to send you messages and their profile will be hidden from view. To unblock a user, simply navigate to Settings > Privacy > Blocked Users.'; '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.';
@override @override
String get contactRemove => 'Remove user'; String get contactRemove => 'Remove contact';
@override @override
String contactRemoveTitle(Object username) { String contactRemoveTitle(Object username) {
@ -515,7 +493,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contactRemoveBody => String get contactRemoveBody =>
'Permanently remove the user. If the user tries to send you a new message, you will have to accept the user again first.'; 'Permanently remove the contact. If the contact tries to send you a new message, you will have to accept the contact again first.';
@override @override
String get undo => 'Undo'; String get undo => 'Undo';
@ -904,7 +882,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get reportUserReason => 'Reporting reason'; String get reportUserReason => 'Reporting reason';
@override @override
String get reportUser => 'Report user'; String get reportUser => 'Report contact';
@override @override
String get newDeviceRegistered => String get newDeviceRegistered =>
@ -1298,12 +1276,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get draftMessage => 'Draft'; String get draftMessage => 'Draft';
@override
String get exportMemories => 'Export memories (Beta)';
@override
String get importMemories => 'Import memories (Beta)';
@override @override
String get voiceMessageSlideToCancel => 'Slide to cancel'; String get voiceMessageSlideToCancel => 'Slide to cancel';
@ -1547,7 +1519,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get verificationBadgeGreenDesc => String get verificationBadgeGreenDesc =>
'A contact you have *personally* verified.'; 'A contact you have *personally verified* using the QR code.';
@override @override
String get verificationBadgeYellowDesc => String get verificationBadgeYellowDesc =>
@ -1557,6 +1529,12 @@ class AppLocalizationsEn extends AppLocalizations {
String get verificationBadgeRedDesc => String get verificationBadgeRedDesc =>
'A contact whose identity has *not* yet been verified.'; 'A contact whose identity has *not* yet been verified.';
@override
String get scanNow => 'Scan now';
@override
String get openQrCode => 'Open QR code';
@override @override
String get deleteVerificationTitle => 'Delete verification?'; String get deleteVerificationTitle => 'Delete verification?';
@ -1580,17 +1558,6 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String mutualGroupsSentMessages(num count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count messages sent',
one: '1 message sent',
);
return '$_temp0';
}
@override @override
String chatEntryFlameRestored(Object count) { String chatEntryFlameRestored(Object count) {
return '$count flames restored'; return '$count flames restored';
@ -1636,9 +1603,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsTypingIndicationSubtitle => String get settingsTypingIndicationSubtitle =>
'When the typing indicator is turned off, you can\'t see when others are typing a message.'; 'When the typing indicator is turned off, you can\'t see when others are typing a message.';
@override
String get scanQrOrShow => 'Scan / Show QR';
@override @override
String get contactActionBlock => 'Block'; String get contactActionBlock => 'Block';
@ -1670,9 +1634,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get userDiscoverySettingsTitle => 'Mutual Friends'; String get userDiscoverySettingsTitle => 'Mutual Friends';
@override
String get userDiscoveryWhyThisIsUsed => 'Why this is used';
@override @override
String get userDiscoveryFeatureOffers => 'Your benefits at a glance'; String get userDiscoveryFeatureOffers => 'Your benefits at a glance';
@ -1840,9 +1801,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get back => 'Back'; String get back => 'Back';
@override
String get onboardingExampleLabel => 'Example';
@override @override
String makerChangedUsername(Object maker, Object oldName, Object newName) { String makerChangedUsername(Object maker, Object oldName, Object newName) {
return '$maker changed their username from $oldName to $newName.'; return '$maker changed their username from $oldName to $newName.';
@ -1942,9 +1900,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get dragToZoom => 'Drag to Zoom'; String get dragToZoom => 'Drag to Zoom';
@override
String get showUsername => 'Show username';
@override @override
String get onboardingProfileSelectionTitle => 'Choose your setup path'; String get onboardingProfileSelectionTitle => 'Choose your setup path';
@ -2079,18 +2034,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get importGalleryNoImagesFoundDesc => String get importGalleryNoImagesFoundDesc =>
'There are no images on your device.'; 'There are no images on your device.';
@override
String get importGalleryShowAllImages => 'Show all images';
@override
String get importGalleryShowTwonlyAlbum => 'Show twonly album';
@override
String get importGalleryToggleDescAll => 'Viewing all images on your device.';
@override
String get importGalleryToggleDescTwonly => 'Viewing the \"twonly\" album.';
@override @override
String get importGalleryFilterTwonly => 'Only show the twonly-Album'; String get importGalleryFilterTwonly => 'Only show the twonly-Album';

@ -1 +1 @@
Subproject commit c95e98ca929d630ead028d84e13934b30dbeba3b Subproject commit 673f6d8c3036d64060b1114912bd5bf5515d5420

View file

@ -21,7 +21,8 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pbserver.dart'; import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pbserver.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart' as server; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
as server;
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart'; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart';
import 'package:twonly/src/services/api/client2client/user_discovery.c2c.dart'; import 'package:twonly/src/services/api/client2client/user_discovery.c2c.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart'; import 'package:twonly/src/services/api/mediafiles/download.api.dart';
@ -65,13 +66,15 @@ class ApiService {
Stream<SubscriptionPlan> get onPlanUpdated => _planUpdateController.stream; Stream<SubscriptionPlan> get onPlanUpdated => _planUpdateController.stream;
final _connectionStateController = StreamController<bool>.broadcast(); final _connectionStateController = StreamController<bool>.broadcast();
Stream<bool> get onConnectionStateUpdated => _connectionStateController.stream; Stream<bool> get onConnectionStateUpdated =>
_connectionStateController.stream;
final _appOutdatedController = StreamController<void>.broadcast(); final _appOutdatedController = StreamController<void>.broadcast();
Stream<void> get onAppOutdated => _appOutdatedController.stream; Stream<void> get onAppOutdated => _appOutdatedController.stream;
final _newDeviceRegisteredController = StreamController<void>.broadcast(); final _newDeviceRegisteredController = StreamController<void>.broadcast();
Stream<void> get onNewDeviceRegistered => _newDeviceRegisteredController.stream; Stream<void> get onNewDeviceRegistered =>
_newDeviceRegisteredController.stream;
bool appIsOutdated = false; bool appIsOutdated = false;
bool isAuthenticated = false; bool isAuthenticated = false;
@ -80,7 +83,8 @@ class ApiService {
Timer? reconnectionTimer; Timer? reconnectionTimer;
int _reconnectionDelay = 5; int _reconnectionDelay = 5;
final HashMap<Int64, Completer<server.ServerToClient?>> _pendingRequests = HashMap(); final HashMap<Int64, Completer<server.ServerToClient?>> _pendingRequests =
HashMap();
IOWebSocketChannel? _channel; IOWebSocketChannel? _channel;
// ignore: cancel_subscriptions // ignore: cancel_subscriptions
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription; StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
@ -112,7 +116,7 @@ class ApiService {
// Function is called after the user is authenticated at the server // Function is called after the user is authenticated at the server
Future<void> onAuthenticated() async { Future<void> onAuthenticated() async {
await initFCMAfterAuthenticated(); await FcmNotificationService.initFCMAfterAuthenticated();
_connectionStateController.add(true); _connectionStateController.add(true);
if (AppState.isInBackgroundTask) { if (AppState.isInBackgroundTask) {
@ -418,7 +422,9 @@ class ApiService {
} }
if (res.error == ErrorCode.UserIdNotFound && contactId != null) { if (res.error == ErrorCode.UserIdNotFound && contactId != null) {
Log.warn('Contact deleted their account $contactId.'); Log.warn('Contact deleted their account $contactId.');
final contact = await twonlyDB.contactsDao.getContactByUserId(contactId).getSingleOrNull(); final contact = await twonlyDB.contactsDao
.getContactByUserId(contactId)
.getSingleOrNull();
if (contact != null) { if (contact != null) {
await twonlyDB.contactsDao.updateContact( await twonlyDB.contactsDao.updateContact(
contactId, contactId,
@ -483,7 +489,8 @@ class ApiService {
return true; return true;
} }
if (result.isError) { if (result.isError) {
if (result.error != ErrorCode.AuthTokenNotValid && result.error != ErrorCode.ForegroundSessionConnected) { if (result.error != ErrorCode.AuthTokenNotValid &&
result.error != ErrorCode.ForegroundSessionConnected) {
Log.error( Log.error(
'got error while authenticating to the server: ${result.error}', 'got error while authenticating to the server: ${result.error}',
); );
@ -521,7 +528,8 @@ class ApiService {
return true; return true;
} }
if (result.isError) { if (result.isError) {
if (result.error != ErrorCode.AuthTokenNotValid && result.error != ErrorCode.ForegroundSessionConnected) { if (result.error != ErrorCode.AuthTokenNotValid &&
result.error != ErrorCode.ForegroundSessionConnected) {
Log.error( Log.error(
'got error while authenticating to the server: ${result.error}', 'got error while authenticating to the server: ${result.error}',
); );
@ -553,7 +561,8 @@ class ApiService {
return; return;
} }
final handshake = Handshake()..getAuthChallenge = Handshake_GetAuthChallenge(); final handshake = Handshake()
..getAuthChallenge = Handshake_GetAuthChallenge();
final req = createClientToServerFromHandshake(handshake); final req = createClientToServerFromHandshake(handshake);
final result = await sendRequestSync(req, authenticated: false); final result = await sendRequestSync(req, authenticated: false);
@ -618,7 +627,9 @@ class ApiService {
final register = Handshake_Register() final register = Handshake_Register()
..username = username ..username = username
..publicIdentityKey = (await signalStore.getIdentityKeyPair()).getPublicKey().serialize() ..publicIdentityKey = (await signalStore.getIdentityKeyPair())
.getPublicKey()
.serialize()
..registrationId = Int64(signalIdentity.registrationId) ..registrationId = Int64(signalIdentity.registrationId)
..signedPrekey = signedPreKey.getKeyPair().publicKey.serialize() ..signedPrekey = signedPreKey.getKeyPair().publicKey.serialize()
..signedPrekeySignature = signedPreKey.signature ..signedPrekeySignature = signedPreKey.signature

View file

@ -5,6 +5,7 @@ import 'package:clock/clock.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:hashlib/random.dart'; import 'package:hashlib/random.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
@ -281,7 +282,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw(
Log.info('[$receiptId] Finished handleEncryptedMessage'); Log.info('[$receiptId] Finished handleEncryptedMessage');
if (a == null && b == null) { if (a == null && b == null) {
unawaited(updateLastServerMessageTimestamp()); unawaited(FcmNotificationService.updateLastServerMessageTimestamp());
if (Platform.isAndroid) { if (Platform.isAndroid) {
// Message was handled without any error. Show push notification to the user for Android. // Message was handled without any error. Show push notification to the user for Android.
await showPushNotificationFromServerMessages( await showPushNotificationFromServerMessages(

View file

@ -0,0 +1,29 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/utils/log.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
SentryWidgetsFlutterBinding.ensureInitialized();
await AppEnvironment.init();
final isInitialized = await initBackgroundExecution();
await setupPushNotification();
Log.info('Handling a background message: ${message.messageId}');
await FcmNotificationService.handleRemoteMessage(message);
if (Platform.isAndroid) {
if (isInitialized) {
await handlePeriodicTask(lastExecutionInSecondsLimit: 10);
}
} else {
// make sure every thing run...
await Future.delayed(const Duration(milliseconds: 2000));
}
}

View file

@ -1,5 +1,3 @@
// ignore_for_file: unreachable_from_main
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform; import 'dart:io' show Platform;
@ -7,13 +5,11 @@ import 'package:firebase_app_installations/firebase_app_installations.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart'; import 'package:twonly/src/services/notifications/fcm.background.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
@ -21,9 +17,62 @@ import '../../../firebase_options.dart';
// see more here: https://firebase.google.com/docs/cloud-messaging/flutter/receive?hl=de // see more here: https://firebase.google.com/docs/cloud-messaging/flutter/receive?hl=de
Future<void> checkForTokenUpdates() async { class FcmNotificationService {
static Future<void> initStartup() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
FirebaseMessaging.onMessage.listen(handleRemoteMessage);
}
static Future<void> initAfterUserLoaded() async {
unawaited(_checkForTokenUpdates());
unawaited(_checkFcmHealthAndResetIfNeeded());
}
static Future<void> initFCMAfterAuthenticated({bool force = false}) async {
final fcmToken = userService.currentUser.fcmToken;
if (userService.currentUser.updateFCMToken || force) {
if (fcmToken == null) {
Log.error('FCM token could not be updated as it is empty');
await _checkForTokenUpdates();
return;
}
final res = await apiService.updateFCMToken(
fcmToken,
);
if (res.isSuccess) {
Log.info('Uploaded new FCM token!');
await UserService.update((u) {
u.updateFCMToken = false;
});
} else {
Log.error('Could not update FCM token!');
}
}
}
static Future<void> resetFCMTokens() async {
await FirebaseInstallations.instance.delete();
Log.info('Firebase Installation successfully deleted.');
await FirebaseMessaging.instance.deleteToken();
Log.info('Old FCM deleted.');
await UserService.update((u) => u.fcmToken = null);
await _checkForTokenUpdates();
await initFCMAfterAuthenticated(force: true);
}
static Future<void> _checkForTokenUpdates() async {
try { try {
if (!userService.isUserCreated) return; if (!userService.isUserCreated) {
Log.info(
'Checking for FCM token updates skipped: user is not yet created.',
);
return;
}
if (Platform.isIOS) { if (Platform.isIOS) {
var apnsToken = await FirebaseMessaging.instance.getAPNSToken(); var apnsToken = await FirebaseMessaging.instance.getAPNSToken();
for (var i = 0; i < 20; i++) { for (var i = 0; i < 20; i++) {
@ -53,35 +102,8 @@ Future<void> checkForTokenUpdates() async {
..updateFCMToken = true ..updateFCMToken = true
..fcmToken = fcmToken; ..fcmToken = fcmToken;
}); });
} if (apiService.isAuthenticated) {
final res = await apiService.updateFCMToken(fcmToken);
FirebaseMessaging.instance.onTokenRefresh
.listen((fcmToken) async {
await UserService.update((u) {
u
..updateFCMToken = true
..fcmToken = fcmToken;
});
})
.onError((err) {
Log.error('could not listen on token refresh');
});
} catch (e) {
Log.error('could not load fcm token: $e');
}
}
Future<void> initFCMAfterAuthenticated({bool force = false}) async {
final fcmToken = userService.currentUser.fcmToken;
if (userService.currentUser.updateFCMToken || force) {
if (fcmToken == null) {
Log.error('FCM token could not be updated as it is empty');
await checkForTokenUpdates();
return;
}
final res = await apiService.updateFCMToken(
fcmToken,
);
if (res.isSuccess) { if (res.isSuccess) {
Log.info('Uploaded new FCM token!'); Log.info('Uploaded new FCM token!');
await UserService.update((u) { await UserService.update((u) {
@ -93,50 +115,36 @@ Future<void> initFCMAfterAuthenticated({bool force = false}) async {
} }
} }
Future<void> resetFCMTokens() async { FirebaseMessaging.instance.onTokenRefresh
await FirebaseInstallations.instance.delete(); // ignore: avoid_types_on_closure_parameters
Log.info('Firebase Installation successfully deleted.'); .listen((String fcmToken) async {
await FirebaseMessaging.instance.deleteToken(); await UserService.update((u) {
Log.info('Old FCM deleted.'); u
await UserService.update((u) => u.fcmToken = null); ..updateFCMToken = true
await checkForTokenUpdates(); ..fcmToken = fcmToken;
await initFCMAfterAuthenticated(force: true); });
} if (apiService.isAuthenticated) {
final res = await apiService.updateFCMToken(fcmToken);
Future<void> initFCMService() async { if (res.isSuccess) {
await Firebase.initializeApp( Log.info('Uploaded new FCM token!');
options: DefaultFirebaseOptions.currentPlatform, await UserService.update((u) {
); u.updateFCMToken = false;
});
unawaited(checkForTokenUpdates());
unawaited(checkFcmHealthAndResetIfNeeded());
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
FirebaseMessaging.onMessage.listen(handleRemoteMessage);
}
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
SentryWidgetsFlutterBinding.ensureInitialized();
await AppEnvironment.init();
final isInitialized = await initBackgroundExecution();
await setupPushNotification();
Log.info('Handling a background message: ${message.messageId}');
await handleRemoteMessage(message);
if (Platform.isAndroid) {
if (isInitialized) {
await handlePeriodicTask(lastExecutionInSecondsLimit: 10);
}
} else { } else {
// make sure every thing run... Log.error('Could not update FCM token!');
await Future.delayed(const Duration(milliseconds: 2000)); }
}
})
.onError((err) {
Log.error('could not listen on token refresh');
});
} catch (e) {
Log.error('could not load fcm token: $e');
} }
} }
Future<void> handleRemoteMessage(RemoteMessage message) async { static Future<void> handleRemoteMessage(RemoteMessage message) async {
await updateLastFcmMessageTimestamp(); await _updateLastFcmMessageTimestamp();
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
Log.error('Got message in Dart while on iOS'); Log.error('Got message in Dart while on iOS');
} }
@ -154,15 +162,9 @@ Future<void> handleRemoteMessage(RemoteMessage message) async {
message.notification?.body ?? message.data['body'] as String? ?? ''; message.notification?.body ?? message.data['body'] as String? ?? '';
await customLocalPushNotification(title, body); await customLocalPushNotification(title, body);
} }
// On Android the push notification is now shown in the server_message.dart. This ensures
// that the messages was successfully decrypted before showing the push notification
// else if (message.data['push_data'] != null) {
// await handlePushData(message.data['push_data'] as String);
// }
} }
Future<void> updateLastFcmMessageTimestamp() async { static Future<void> _updateLastFcmMessageTimestamp() async {
const storage = FlutterSecureStorage(); const storage = FlutterSecureStorage();
final nowMs = DateTime.now().millisecondsSinceEpoch.toString(); final nowMs = DateTime.now().millisecondsSinceEpoch.toString();
try { try {
@ -180,7 +182,7 @@ Future<void> updateLastFcmMessageTimestamp() async {
} }
} }
Future<void> updateLastServerMessageTimestamp() async { static Future<void> updateLastServerMessageTimestamp() async {
const storage = FlutterSecureStorage(); const storage = FlutterSecureStorage();
final nowMs = DateTime.now().millisecondsSinceEpoch.toString(); final nowMs = DateTime.now().millisecondsSinceEpoch.toString();
try { try {
@ -198,8 +200,11 @@ Future<void> updateLastServerMessageTimestamp() async {
} }
} }
Future<void> checkFcmHealthAndResetIfNeeded() async { static Future<void> _checkFcmHealthAndResetIfNeeded() async {
if (!userService.isUserCreated) return; if (!userService.isUserCreated) {
Log.info('FCM health check skipped: user is not yet created.');
return;
}
const storage = FlutterSecureStorage(); const storage = FlutterSecureStorage();
try { try {
final lastFcmStr = await storage.read( final lastFcmStr = await storage.read(
@ -229,7 +234,9 @@ Future<void> checkFcmHealthAndResetIfNeeded() async {
} }
if (lastFcmTime != null) { if (lastFcmTime != null) {
Log.info('Last message received via FCM messaging system: $lastFcmTime'); Log.info(
'Last message received via FCM messaging system: $lastFcmTime',
);
} else { } else {
Log.info('No record of a message received via FCM messaging system.'); Log.info('No record of a message received via FCM messaging system.');
} }
@ -242,14 +249,15 @@ Future<void> checkFcmHealthAndResetIfNeeded() async {
} }
} }
// Check conditions: final fcmInactive =
// 1. No messages received via FCM in the last 3 days (either null or older than 3 days) lastFcmTime == null || lastFcmTime.isBefore(threeDaysAgo);
final fcmInactive = lastFcmTime == null || lastFcmTime.isBefore(threeDaysAgo); final serverActive =
// 2. Server message received within the last 3 days lastServerTime != null && lastServerTime.isAfter(threeDaysAgo);
final serverActive = lastServerTime != null && lastServerTime.isAfter(threeDaysAgo);
if (fcmInactive && serverActive) { if (fcmInactive && serverActive) {
Log.warn('FCM has been inactive for >3 days, but server messages have been active. Resetting FCM tokens...'); Log.warn(
'FCM has been inactive for >3 days, but server messages have been active. Resetting FCM tokens...',
);
await resetFCMTokens(); await resetFCMTokens();
} else { } else {
Log.info('FCM check passed. No reset needed.'); Log.info('FCM check passed. No reset needed.');
@ -258,3 +266,4 @@ Future<void> checkFcmHealthAndResetIfNeeded() async {
Log.error('Error during FCM health check: $e'); Log.error('Error during FCM health check: $e');
} }
} }
}

View file

@ -1,14 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'
show FaIcon, FontAwesomeIcons;
import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/profile.service.dart'; import 'package:twonly/src/services/profile.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/elements/svg_icon.element.dart'; import 'package:twonly/src/visual/elements/svg_icon.element.dart';
import 'package:twonly/src/visual/themes/light.dart'; import 'package:twonly/src/visual/themes/light.dart';
const colorVerificationBadgeYellow = Color.fromARGB(255, 0, 182, 238); const colorVerificationBadgeYellow = Color.fromARGB(255, 0, 182, 238);
class VerificationBadgeInfo extends StatelessWidget { class VerificationBadgeInfo extends StatelessWidget {
const VerificationBadgeInfo({super.key}); const VerificationBadgeInfo({
this.displayButtons = false,
super.key,
});
final bool displayButtons;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -24,6 +33,43 @@ class VerificationBadgeInfo extends StatelessWidget {
icon: const SvgIcon(assetPath: SvgIcons.verifiedGreen, size: 40), icon: const SvgIcon(assetPath: SvgIcons.verifiedGreen, size: 40),
description: context.lang.verificationBadgeGreenDesc, description: context.lang.verificationBadgeGreenDesc,
boldTextColor: primaryColor, boldTextColor: primaryColor,
onTap: () => context.push(Routes.cameraQRScanner),
),
if (displayButtons)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IntrinsicWidth(
child: MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: () => context.push(Routes.cameraQRScanner),
child: Row(
children: [
const FaIcon(FontAwesomeIcons.camera),
const SizedBox(width: 6),
Text(context.lang.scanNow),
],
),
),
),
const SizedBox(width: 8),
IntrinsicWidth(
child: MyButton(
variant: MyButtonVariant.primaryDense,
onPressed: () => context.push(Routes.settingsPublicProfile),
child: Row(
children: [
const FaIcon(FontAwesomeIcons.qrcode),
const SizedBox(width: 6),
Text(context.lang.openQrCode),
],
),
),
),
],
),
), ),
if (userService.currentUser.securityProfile != SecurityProfile.strict || if (userService.currentUser.securityProfile != SecurityProfile.strict ||
userService.currentUser.isUserDiscoveryEnabled) userService.currentUser.isUserDiscoveryEnabled)
@ -52,8 +98,9 @@ class VerificationBadgeInfo extends StatelessWidget {
required Widget icon, required Widget icon,
required String description, required String description,
required Color boldTextColor, required Color boldTextColor,
VoidCallback? onTap,
}) { }) {
return Padding( final item = Padding(
padding: const EdgeInsets.symmetric(vertical: 25), padding: const EdgeInsets.symmetric(vertical: 25),
child: Row( child: Row(
children: [ children: [
@ -74,5 +121,14 @@ class VerificationBadgeInfo extends StatelessWidget {
], ],
), ),
); );
if (onTap != null) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: item,
);
}
return item;
} }
} }

View file

@ -118,6 +118,7 @@ class _TextViewState extends State<TextLayer> {
child: TextField( child: TextField(
controller: textController, controller: textController,
autofocus: true, autofocus: true,
textCapitalization: TextCapitalization.sentences,
maxLines: null, maxLines: null,
minLines: 1, minLines: 1,
onEditingComplete: onEditionComplete, onEditingComplete: onEditionComplete,

View file

@ -248,6 +248,7 @@ Future<void> editTextMessage(BuildContext context, Message message) async {
child: TextField( child: TextField(
controller: controller, controller: controller,
autofocus: true, autofocus: true,
textCapitalization: TextCapitalization.sentences,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
maxLines: 4, maxLines: 4,
minLines: 1, minLines: 1,

View file

@ -293,6 +293,7 @@ class _MessageInputState extends State<MessageInput> {
TextField( TextField(
controller: _textFieldController, controller: _textFieldController,
focusNode: widget.textFieldFocus, focusNode: widget.textFieldFocus,
textCapitalization: TextCapitalization.sentences,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
showCursor: showCursor:
_recordingState != RecordingState.recording, _recordingState != RecordingState.recording,

View file

@ -69,11 +69,10 @@ class ResponseContainer extends StatelessWidget {
messageId: msg.quotesMessageId, messageId: msg.quotesMessageId,
showBorder: false, showBorder: false,
showLeftBorder: false, showLeftBorder: false,
colorUsername: false,
), ),
), ),
), ),
if (child != null) child!, ?child,
], ],
), ),
), ),
@ -263,7 +262,7 @@ class _ResponsePreviewState extends State<ResponsePreview> {
], ],
), ),
), ),
if (imageWidget != null) imageWidget, ?imageWidget,
], ],
), ),
); );

View file

@ -833,6 +833,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
child: TextField( child: TextField(
autofocus: true, autofocus: true,
controller: textMessageController, controller: textMessageController,
textCapitalization: TextCapitalization.sentences,
onChanged: (value) async { onChanged: (value) async {
await twonlyDB.groupsDao.updateGroup( await twonlyDB.groupsDao.updateGroup(
widget.group.groupId, widget.group.groupId,

View file

@ -278,6 +278,7 @@ Future<String?> showNicknameChangeDialog(
content: TextField( content: TextField(
controller: controller, controller: controller,
autofocus: true, autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration( decoration: InputDecoration(
hintText: context.lang.contactNicknameNew, hintText: context.lang.contactNicknameNew,
), ),
@ -319,6 +320,7 @@ Future<String?> showReportDialog(
content: TextField( content: TextField(
controller: controller, controller: controller,
autofocus: true, autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: context.lang.reportUserReason), decoration: InputDecoration(hintText: context.lang.reportUserReason),
), ),
actions: <Widget>[ actions: <Widget>[

View file

@ -322,6 +322,7 @@ Future<String?> showGroupNameChangeDialog(
content: TextField( content: TextField(
controller: controller, controller: controller,
autofocus: true, autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(hintText: context.lang.groupNameInput), decoration: InputDecoration(hintText: context.lang.groupNameInput),
), ),
actions: <Widget>[ actions: <Widget>[

View file

@ -32,7 +32,7 @@ class HomeView extends StatefulWidget {
State<HomeView> createState() => HomeViewState(); State<HomeView> createState() => HomeViewState();
} }
class HomeViewState extends State<HomeView> { class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
int _activePageIdx = 1; int _activePageIdx = 1;
double _offsetRatio = 0; double _offsetRatio = 0;
double _offsetFromOne = 0; double _offsetFromOne = 0;
@ -53,6 +53,7 @@ class HomeViewState extends State<HomeView> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
var initialPage = widget.initialPage; var initialPage = widget.initialPage;
if (initialPage == 1 && !userService.currentUser.startWithCameraOpen) { if (initialPage == 1 && !userService.currentUser.startWithCameraOpen) {
initialPage = 0; initialPage = 0;
@ -78,7 +79,9 @@ class HomeViewState extends State<HomeView> {
_selectNotificationSub = selectNotificationStream.stream.listen(( _selectNotificationSub = selectNotificationStream.stream.listen((
response, response,
) async { ) async {
if (response.payload != null && response.payload!.startsWith(Routes.chats) && response.payload! != Routes.chats) { if (response.payload != null &&
response.payload!.startsWith(Routes.chats) &&
response.payload! != Routes.chats) {
await routerProvider.push(response.payload!); await routerProvider.push(response.payload!);
} }
streamHomeViewPageIndex.add(0); streamHomeViewPageIndex.add(0);
@ -92,8 +95,12 @@ class HomeViewState extends State<HomeView> {
}); });
if (initialPage == 1) { if (initialPage == 1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isViewActive()) {
unawaited(_mainCameraController.selectCamera(0, true)); unawaited(_mainCameraController.selectCamera(0, true));
} }
});
}
unawaited(_initAsync()); unawaited(_initAsync());
@ -114,31 +121,40 @@ class HomeViewState extends State<HomeView> {
); );
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.initialPage == 1 && !userService.currentUser.startWithCameraOpen || widget.initialPage == 0) { if (widget.initialPage == 1 &&
!userService.currentUser.startWithCameraOpen ||
widget.initialPage == 0) {
streamHomeViewPageIndex.add(0); streamHomeViewPageIndex.add(0);
} }
}); });
} }
Future<void> _initAsync() async { Future<void> _initAsync() async {
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
RemoteMessage? initialRemoteMessage; RemoteMessage? initialRemoteMessage;
try { try {
initialRemoteMessage = await FirebaseMessaging.instance.getInitialMessage(); initialRemoteMessage = await FirebaseMessaging.instance
.getInitialMessage();
} catch (e) { } catch (e) {
Log.error('Could not get initial Firebase message: $e'); Log.error('Could not get initial Firebase message: $e');
} }
if (widget.initialPage == 0 || if (widget.initialPage == 0 ||
initialRemoteMessage != null || initialRemoteMessage != null ||
(notificationAppLaunchDetails != null && notificationAppLaunchDetails.didNotificationLaunchApp)) { (notificationAppLaunchDetails != null &&
notificationAppLaunchDetails.didNotificationLaunchApp)) {
if (initialRemoteMessage != null) { if (initialRemoteMessage != null) {
Log.info('App launched from iOS/Remote push notification tap.'); Log.info('App launched from iOS/Remote push notification tap.');
streamHomeViewPageIndex.add(0); streamHomeViewPageIndex.add(0);
} else if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) { } else if (notificationAppLaunchDetails?.didNotificationLaunchApp ??
final payload = notificationAppLaunchDetails?.notificationResponse?.payload; false) {
if (payload != null && payload.startsWith(Routes.chats) && payload != Routes.chats) { final payload =
notificationAppLaunchDetails?.notificationResponse?.payload;
if (payload != null &&
payload.startsWith(Routes.chats) &&
payload != Routes.chats) {
await routerProvider.push(payload); await routerProvider.push(payload);
streamHomeViewPageIndex.add(0); streamHomeViewPageIndex.add(0);
} }
@ -165,6 +181,7 @@ class HomeViewState extends State<HomeView> {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
_homeViewPageIndexSub?.cancel(); _homeViewPageIndexSub?.cancel();
_selectNotificationSub?.cancel(); _selectNotificationSub?.cancel();
@ -176,11 +193,38 @@ class HomeViewState extends State<HomeView> {
super.dispose(); super.dispose();
} }
bool _isViewActive() {
if (!mounted) return false;
return ModalRoute.of(context)?.isCurrent ?? false;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
if (_offsetRatio < 1 &&
!_mainCameraController.isSharePreviewIsShown &&
_isViewActive()) {
unawaited(
_mainCameraController.selectCamera(
_mainCameraController.selectedCameraDetails.cameraId,
false,
),
);
}
} else if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused) {
unawaited(_mainCameraController.closeCamera());
}
}
bool _onPageView(ScrollNotification notification) { bool _onPageView(ScrollNotification notification) {
_disableCameraTimer?.cancel(); _disableCameraTimer?.cancel();
if (notification.depth > 0 && notification.metrics.axis == Axis.vertical) { if (notification.depth > 0 && notification.metrics.axis == Axis.vertical) {
final canScroll = notification.metrics.maxScrollExtent > notification.metrics.minScrollExtent; final canScroll =
notification.metrics.maxScrollExtent >
notification.metrics.minScrollExtent;
if (!canScroll) { if (!canScroll) {
if (!_isBottomNavVisible) { if (!_isBottomNavVisible) {
setState(() { setState(() {
@ -188,13 +232,17 @@ class HomeViewState extends State<HomeView> {
}); });
} }
} else { } else {
if (_activePageIdx == 2 && notification.metrics.pixels < 100 && !_isBottomNavVisible) { if (_activePageIdx == 2 &&
notification.metrics.pixels < 100 &&
!_isBottomNavVisible) {
setState(() { setState(() {
_isBottomNavVisible = true; _isBottomNavVisible = true;
}); });
} else if (notification is ScrollUpdateNotification) { } else if (notification is ScrollUpdateNotification) {
final delta = notification.scrollDelta ?? 0; final delta = notification.scrollDelta ?? 0;
if (delta > 5 && _isBottomNavVisible && (_activePageIdx != 2 || notification.metrics.pixels >= 100)) { if (delta > 5 &&
_isBottomNavVisible &&
(_activePageIdx != 2 || notification.metrics.pixels >= 100)) {
setState(() { setState(() {
_isBottomNavVisible = false; _isBottomNavVisible = false;
}); });
@ -216,7 +264,8 @@ class HomeViewState extends State<HomeView> {
if (_mainCameraController.cameraController == null && if (_mainCameraController.cameraController == null &&
!_mainCameraController.initCameraStarted && !_mainCameraController.initCameraStarted &&
_offsetRatio < 1) { _offsetRatio < 1 &&
_isViewActive()) {
unawaited( unawaited(
_mainCameraController.selectCamera( _mainCameraController.selectCamera(
_mainCameraController.selectedCameraDetails.cameraId, _mainCameraController.selectedCameraDetails.cameraId,
@ -281,12 +330,15 @@ class HomeViewState extends State<HomeView> {
left: 0, left: 0,
top: 0, top: 0,
right: 0, right: 0,
bottom: (_offsetRatio > 0.25) ? MediaQuery.sizeOf(context).height * 2 : 0, bottom: (_offsetRatio > 0.25)
? MediaQuery.sizeOf(context).height * 2
: 0,
child: Opacity( child: Opacity(
opacity: 1 - (_offsetRatio * 4) % 1, opacity: 1 - (_offsetRatio * 4) % 1,
child: CameraPreviewControllerView( child: CameraPreviewControllerView(
mainController: _mainCameraController, mainController: _mainCameraController,
isVisible: ((1 - (_offsetRatio * 4) % 1) == 1) && _activePageIdx == 1, isVisible:
((1 - (_offsetRatio * 4) % 1) == 1) && _activePageIdx == 1,
), ),
), ),
), ),

View file

@ -10,6 +10,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/json/userdata.model.dart'; import 'package:twonly/src/model/json/userdata.model.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
@ -163,6 +164,8 @@ class _RegisterViewState extends State<RegisterView> {
await UserService.save(userData); await UserService.save(userData);
unawaited(FcmNotificationService.initAfterUserLoaded());
await apiService.authenticate(); await apiService.authenticate();
widget.callbackOnSuccess(); widget.callbackOnSuccess();
} catch (e, stack) { } catch (e, stack) {

View file

@ -18,9 +18,9 @@ import 'package:twonly/src/visual/views/settings/privacy/user_discovery/componen
enum SetupPages { enum SetupPages {
profile, profile,
backup, backup,
verificationBadge,
profileSelection, profileSelection,
securityProfile, securityProfile,
verificationBadge,
shareYourFriends, shareYourFriends,
letYourFriendsFindYou, letYourFriendsFindYou,
} }
@ -40,22 +40,23 @@ extension SetupPagesExtension on SetupPages {
return [ return [
SetupPages.profile, SetupPages.profile,
SetupPages.backup, SetupPages.backup,
SetupPages.verificationBadge,
SetupPages.profileSelection, SetupPages.profileSelection,
]; ];
case SetupProfile.maximum: case SetupProfile.maximum:
return [ return [
SetupPages.profile, SetupPages.profile,
SetupPages.backup, SetupPages.backup,
SetupPages.profileSelection,
SetupPages.verificationBadge, SetupPages.verificationBadge,
SetupPages.profileSelection,
]; ];
case SetupProfile.customized: case SetupProfile.customized:
return [ return [
SetupPages.profile, SetupPages.profile,
SetupPages.backup, SetupPages.backup,
SetupPages.verificationBadge,
SetupPages.profileSelection, SetupPages.profileSelection,
SetupPages.securityProfile, SetupPages.securityProfile,
SetupPages.verificationBadge,
SetupPages.shareYourFriends, SetupPages.shareYourFriends,
SetupPages.letYourFriendsFindYou, SetupPages.letYourFriendsFindYou,
]; ];

View file

@ -193,6 +193,7 @@ $debugLogToken
const SizedBox(height: 5), const SizedBox(height: 5),
TextField( TextField(
controller: _controller, controller: _controller,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration( decoration: InputDecoration(
hintText: context.lang.contactUsYourMessage, hintText: context.lang.contactUsYourMessage,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),

View file

@ -86,6 +86,7 @@ class _ContactUsState extends State<SubmitMessage> {
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( TextField(
controller: _controller, controller: _controller,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration( decoration: InputDecoration(
hintText: context.lang.contactUsYourMessage, hintText: context.lang.contactUsYourMessage,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),

View file

@ -1,10 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/verification_badge_info.comp.dart'; import 'package:twonly/src/visual/components/verification_badge_info.comp.dart';
import 'package:twonly/src/visual/elements/better_list_title.element.dart';
class VerificationBadeFaqView extends StatefulWidget { class VerificationBadeFaqView extends StatefulWidget {
const VerificationBadeFaqView({super.key}); const VerificationBadeFaqView({super.key});
@ -23,18 +19,9 @@ class _VerificationBadeFaqViewState extends State<VerificationBadeFaqView> {
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(40), padding: const EdgeInsets.all(40),
children: [ children: const [
const VerificationBadgeInfo(), VerificationBadgeInfo(
const SizedBox(height: 20), displayButtons: true,
BetterListTile(
leading: const FaIcon(FontAwesomeIcons.camera),
text: context.lang.scanOtherProfile,
onTap: () => context.push(Routes.cameraQRScanner),
),
BetterListTile(
leading: const FaIcon(FontAwesomeIcons.qrcode),
text: context.lang.openYourOwnQRcode,
onTap: () => context.push(Routes.settingsPublicProfile),
), ),
], ],
), ),

View file

@ -45,7 +45,7 @@ class _NotificationViewState extends State<NotificationView> {
_isLoadingTroubleshooting = true; _isLoadingTroubleshooting = true;
}); });
await initFCMAfterAuthenticated(force: true); await FcmNotificationService.initFCMAfterAuthenticated(force: true);
await setupNotificationWithUsers(force: true); await setupNotificationWithUsers(force: true);
@ -90,7 +90,7 @@ class _NotificationViewState extends State<NotificationView> {
setState(() { setState(() {
_isLoadingReset = true; _isLoadingReset = true;
}); });
await resetFCMTokens(); await FcmNotificationService.resetFCMTokens();
if (!mounted) return; if (!mounted) return;
await showAlertDialog( await showAlertDialog(
context, context,

View file

@ -67,8 +67,9 @@ class _PrivacyViewState extends State<PrivacyView> {
), ),
ListTile( ListTile(
title: Text(context.lang.contactVerifyNumberTitle), title: Text(context.lang.contactVerifyNumberTitle),
subtitle: Text(context.lang.contactVerifyNumberSubtitle),
onTap: () async { onTap: () async {
await context.push(Routes.settingsPublicProfile); await context.push(Routes.settingsHelpFaqVerifyBadge);
setState(() {}); setState(() {});
}, },
), ),

View file

@ -176,6 +176,7 @@ class _ProfileViewState extends State<ProfileView> {
context.lang.settingsProfileEditDisplayName, context.lang.settingsProfileEditDisplayName,
context.lang.settingsProfileEditDisplayNameNew, context.lang.settingsProfileEditDisplayNameNew,
maxLength: 30, maxLength: 30,
textCapitalization: TextCapitalization.words,
); );
if (context.mounted && if (context.mounted &&
displayName != null && displayName != null &&
@ -210,6 +211,7 @@ Future<String?> showDisplayNameChangeDialog(
String hintText, { String hintText, {
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
int? maxLength, int? maxLength,
TextCapitalization textCapitalization = TextCapitalization.none,
}) { }) {
final controller = TextEditingController(text: currentName); final controller = TextEditingController(text: currentName);
@ -223,6 +225,7 @@ Future<String?> showDisplayNameChangeDialog(
autofocus: true, autofocus: true,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
maxLength: maxLength, maxLength: maxLength,
textCapitalization: textCapitalization,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
), ),

View file

@ -256,6 +256,7 @@ class _UserStudyQuestionnaireViewState
Widget _buildTextField(String hint, void Function(String) onChanged) { Widget _buildTextField(String hint, void Function(String) onChanged) {
return TextField( return TextField(
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hint, hintText: hint,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.2.29+138 version: 0.3.0+139
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0

38
rust/Cargo.lock generated
View file

@ -391,12 +391,6 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@ -562,7 +556,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid 0.9.6", "const-oid",
"pem-rfc7468", "pem-rfc7468",
"zeroize", "zeroize",
] ]
@ -594,7 +588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"const-oid 0.9.6", "const-oid",
"crypto-common 0.1.7", "crypto-common 0.1.7",
"subtle", "subtle",
] ]
@ -606,7 +600,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [ dependencies = [
"block-buffer 0.12.0", "block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.1", "crypto-common 0.2.1",
"ctutils", "ctutils",
] ]
@ -1857,25 +1850,6 @@ dependencies = [
"prost", "prost",
] ]
[[package]]
name = "protocols"
version = "0.1.0"
dependencies = [
"base64",
"blahaj",
"hmac 0.13.0",
"prost",
"prost-build",
"rand 0.10.1",
"serde",
"serde_json",
"sha2 0.11.0",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@ -1991,7 +1965,7 @@ version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [ dependencies = [
"const-oid 0.9.6", "const-oid",
"digest 0.10.7", "digest 0.10.7",
"num-bigint-dig", "num-bigint-dig",
"num-integer", "num-integer",
@ -2012,20 +1986,24 @@ dependencies = [
"aes-gcm", "aes-gcm",
"android-native-keyring-store", "android-native-keyring-store",
"apple-native-keyring-store", "apple-native-keyring-store",
"base64",
"blahaj",
"chrono", "chrono",
"flutter_rust_bridge", "flutter_rust_bridge",
"hex", "hex",
"hkdf", "hkdf",
"hmac 0.13.0",
"keyring-core", "keyring-core",
"libsqlite3-sys", "libsqlite3-sys",
"paste", "paste",
"postcard", "postcard",
"pretty_env_logger", "pretty_env_logger",
"prost",
"prost-build", "prost-build",
"protocols",
"rand 0.10.1", "rand 0.10.1",
"scrypt", "scrypt",
"serde", "serde",
"serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"sqlx", "sqlx",
"tempfile", "tempfile",

View file

@ -36,7 +36,11 @@ libsqlite3-sys = { version = "0.35.0", features = [
tokio = { version = "1.44", features = ["full"] } tokio = { version = "1.44", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
rand = "0.10.1" rand = "0.10.1"
protocols = { path = "../rust_dependencies/protocols" } prost = "0.14.1"
blahaj = "0.6.0"
serde_json = "1.0"
base64 = "0.22.1"
hmac = "0.13.0"
hkdf = "0.12.4" hkdf = "0.12.4"
sha2 = "0.10.8" sha2 = "0.10.8"
aes-gcm = "0.10.3" aes-gcm = "0.10.3"

5
rust/build.rs Normal file
View file

@ -0,0 +1,5 @@
use std::io::Result;
fn main() -> Result<()> {
prost_build::compile_protos(&["src/user_discovery/types.proto"], &["src/"])?;
Ok(())
}

View file

@ -13,6 +13,7 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter};
pub(crate) struct BackupArchive {} pub(crate) struct BackupArchive {}
impl BackupArchive { impl BackupArchive {
#[allow(clippy::type_complexity)]
fn get_backup_files( fn get_backup_files(
ctx: &Context, ctx: &Context,
keys: &KeyManager, keys: &KeyManager,

View file

@ -52,7 +52,7 @@ impl BackupIdentity {
let key_manager: KeyManager = postcard::from_bytes(&decrypted_bytes)?; let key_manager: KeyManager = postcard::from_bytes(&decrypted_bytes)?;
key_manager.store_to_keychain(&secure_storage)?; key_manager.store_to_keychain(secure_storage)?;
Ok(()) Ok(())
} }

View file

@ -2,8 +2,8 @@ pub(crate) mod log;
mod macros; mod macros;
pub(crate) mod user_discovery; pub(crate) mod user_discovery;
use crate::user_discovery::traits::{AnnouncedUser, OtherPromotion};
use flutter_rust_bridge::DartFnFuture; use flutter_rust_bridge::DartFnFuture;
use protocols::user_discovery::traits::{AnnouncedUser, OtherPromotion};
use crate::error::{Result, TwonlyError}; use crate::error::{Result, TwonlyError};
use crate::{callback_generator, frb_generated::StreamSink}; use crate::{callback_generator, frb_generated::StreamSink};
@ -50,7 +50,9 @@ pub(crate) fn get_callbacks() -> Result<FlutterCallbacks> {
let caller_opt = CURRENT_CALLBACK_ID.try_with(|&c| c).ok(); let caller_opt = CURRENT_CALLBACK_ID.try_with(|&c| c).ok();
let lock = FLUTTER_CALLBACKS.read().unwrap(); let lock = FLUTTER_CALLBACKS.read().unwrap();
let map = lock.as_ref().ok_or(TwonlyError::MissingCallbackInitialization)?; let map = lock
.as_ref()
.ok_or(TwonlyError::MissingCallbackInitialization)?;
if let Some(id) = caller_opt { if let Some(id) = caller_opt {
if let Some(cb) = map.get(&id) { if let Some(cb) = map.get(&id) {

View file

@ -31,6 +31,7 @@ macro_rules! callback_generator {
// 3. Generate the Automated Init Function // 3. Generate the Automated Init Function
paste::paste! { paste::paste! {
#[allow(clippy::too_many_arguments)]
pub fn init_flutter_callbacks( pub fn init_flutter_callbacks(
callback_id: u32, callback_id: u32,
$( $(

View file

@ -1,9 +1,10 @@
use crate::bridge::callbacks::get_callbacks; use crate::bridge::callbacks::get_callbacks;
use crate::bridge::get_twonly_flutter; use crate::bridge::get_twonly_flutter;
use crate::error::TwonlyError; use crate::error::TwonlyError;
use protocols::user_discovery::error::{Result, UserDiscoveryError}; use crate::user_discovery::error::{Result, UserDiscoveryError};
use protocols::user_discovery::traits::UserDiscoveryUtils; use crate::user_discovery::traits::UserDiscoveryUtils;
use protocols::user_discovery::traits::{AnnouncedUser, OtherPromotion, UserDiscoveryStore}; use crate::user_discovery::traits::{AnnouncedUser, OtherPromotion, UserDiscoveryStore};
#[cfg(test)]
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -148,6 +149,7 @@ impl UserDiscoveryStore for UserDiscoveryStoreFlutter {
.ok_or(TwonlyError::DartError.into()) .ok_or(TwonlyError::DartError.into())
} }
#[cfg(test)]
async fn get_all_announced_users( async fn get_all_announced_users(
&self, &self,
) -> Result<HashMap<AnnouncedUser, Vec<(i64, Option<i64>)>>> { ) -> Result<HashMap<AnnouncedUser, Vec<(i64, Option<i64>)>>> {

View file

@ -13,12 +13,12 @@ use crate::error::Result;
use crate::error::TwonlyError; use crate::error::TwonlyError;
use crate::keys::KeyManager; use crate::keys::KeyManager;
use crate::secure_storage::SecureStorage; use crate::secure_storage::SecureStorage;
use crate::user_discovery::UserDiscovery;
use crate::utils::Shared; use crate::utils::Shared;
use flutter_rust_bridge::frb; use flutter_rust_bridge::frb;
use protocols::user_discovery::UserDiscovery;
pub use protocols::user_discovery::traits::AnnouncedUser; pub use crate::user_discovery::traits::AnnouncedUser;
pub use protocols::user_discovery::traits::OtherPromotion; pub use crate::user_discovery::traits::OtherPromotion;
use tokio::sync::Mutex; use tokio::sync::Mutex;
pub struct InitConfig { pub struct InitConfig {
@ -57,9 +57,9 @@ pub(crate) struct TwonlyFlutter {
pub(super) fn get_twonly_flutter() -> Result<&'static TwonlyFlutter> { pub(super) fn get_twonly_flutter() -> Result<&'static TwonlyFlutter> {
let ctx = Context::get_static()?; let ctx = Context::get_static()?;
if let Context::Flutter(twonly) = ctx { if let Context::Flutter(twonly) = ctx {
return Ok(twonly); Ok(twonly)
} else { } else {
return Err(TwonlyError::Initialization); Err(TwonlyError::Initialization)
} }
} }

View file

@ -53,7 +53,7 @@ impl RustBackupIdentity {
pub async fn get_identity_backup_bytes() -> Result<Vec<u8>> { pub async fn get_identity_backup_bytes() -> Result<Vec<u8>> {
let key_manager = get_twonly_flutter()?.key_manager.lock().await; let key_manager = get_twonly_flutter()?.key_manager.lock().await;
return BackupIdentity::encrypt_key_manager(&key_manager); BackupIdentity::encrypt_key_manager(&key_manager)
} }
pub async fn restore_identity_backup( pub async fn restore_identity_backup(
@ -70,7 +70,7 @@ impl RustBackupIdentity {
impl RustBackupArchive { impl RustBackupArchive {
pub async fn create_backup_archive() -> Result<(String, String)> { pub async fn create_backup_archive() -> Result<(String, String)> {
let ctx = Context::get_static()?; let ctx = Context::get_static()?;
let path = BackupArchive::create_backup(&ctx).await?; let path = BackupArchive::create_backup(ctx).await?;
let key_manager = get_twonly_flutter()?.key_manager.lock().await; let key_manager = get_twonly_flutter()?.key_manager.lock().await;
let token = hex::encode(key_manager.main_key.get_backup_download_token()); let token = hex::encode(key_manager.main_key.get_backup_download_token());
Ok((token, path.canonicalize()?.to_string_lossy().to_string())) Ok((token, path.canonicalize()?.to_string_lossy().to_string()))

View file

@ -1,3 +1,4 @@
use crate::user_discovery::UserDiscovery;
use crate::{ use crate::{
bridge::{ bridge::{
callbacks::user_discovery::{UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter}, callbacks::user_discovery::{UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter},
@ -9,7 +10,6 @@ use crate::{
log::init_tracing, log::init_tracing,
utils::Shared, utils::Shared,
}; };
use protocols::user_discovery::UserDiscovery;
use std::{path::PathBuf, sync::Arc}; use std::{path::PathBuf, sync::Arc};
use tokio::sync::{Mutex, OnceCell}; use tokio::sync::{Mutex, OnceCell};
use zeroize::Zeroize; use zeroize::Zeroize;

View file

@ -6,7 +6,7 @@ macro_rules! generate_insert {
pub async fn $fn_name( pub async fn $fn_name(
pool: &sqlx::SqlitePool, pool: &sqlx::SqlitePool,
$($field: $ty),+ $($field: $ty),+
) -> crate::error::Result<i64> { ) -> $crate::error::Result<i64> {
let sql = format!( let sql = format!(
"INSERT INTO {} ({}) VALUES ({}) RETURNING id", "INSERT INTO {} ({}) VALUES ({}) RETURNING id",
$table, $table,
@ -27,7 +27,7 @@ macro_rules! generate_insert {
#[macro_export] #[macro_export]
macro_rules! generate_select { macro_rules! generate_select {
($table:literal, $fn_name:ident) => { ($table:literal, $fn_name:ident) => {
pub async fn $fn_name(pool: &sqlx::SqlitePool) -> crate::error::Result<Vec<Self>> { pub async fn $fn_name(pool: &sqlx::SqlitePool) -> $crate::error::Result<Vec<Self>> {
let sql = format!("SELECT * FROM {}", $table); let sql = format!("SELECT * FROM {}", $table);
let results = sqlx::query_as::<_, Self>(sqlx::AssertSqlSafe(sql)) let results = sqlx::query_as::<_, Self>(sqlx::AssertSqlSafe(sql))
.fetch_all(pool) .fetch_all(pool)
@ -36,7 +36,7 @@ macro_rules! generate_select {
} }
}; };
($table:literal, $fn_name:ident, $($field:ident : $ty:ty),+) => { ($table:literal, $fn_name:ident, $($field:ident : $ty:ty),+) => {
pub async fn $fn_name(pool: &sqlx::SqlitePool, $($field: $ty),+) -> crate::error::Result<Vec<Self>> { pub async fn $fn_name(pool: &sqlx::SqlitePool, $($field: $ty),+) -> $crate::error::Result<Vec<Self>> {
let mut sql = format!("SELECT * FROM {} WHERE ", $table); let mut sql = format!("SELECT * FROM {} WHERE ", $table);
let mut filters = Vec::new(); let mut filters = Vec::new();
$( $(
@ -63,7 +63,7 @@ macro_rules! generate_table_tests {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::database::Database; use $crate::database::Database;
use tempfile::tempdir; use tempfile::tempdir;
#[tokio::test] #[tokio::test]
@ -88,11 +88,10 @@ macro_rules! generate_test_select {
#[cfg(test)] #[cfg(test)]
#[tokio::test] #[tokio::test]
async fn [<test_ $select_fn>]() { async fn [<test_ $select_fn>]() {
use crate::database::Database;
use tempfile::tempdir; use tempfile::tempdir;
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string(); let db_path = dir.path().join("test.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap(); let db = $crate::database::Database::new(&db_path, None, false).await.unwrap();
db.run_migrations().await.unwrap(); db.run_migrations().await.unwrap();
$struct::$insert_fn(&db.pool, $($arg),+).await.unwrap(); $struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();

View file

@ -1,5 +1,5 @@
use crate::user_discovery::error::UserDiscoveryError;
use hex::FromHexError; use hex::FromHexError;
use protocols::user_discovery::error::UserDiscoveryError;
use scrypt::errors::{InvalidOutputLen, InvalidParams}; use scrypt::errors::{InvalidOutputLen, InvalidParams};
use thiserror::Error; use thiserror::Error;
use zip::result::ZipError; use zip::result::ZipError;

View file

@ -61,12 +61,12 @@ impl MainKey {
self.decrypt_with_info(b"backup_key", encrypted_backup) self.decrypt_with_info(b"backup_key", encrypted_backup)
} }
/// Encrypts a newly generated media key using the derived Media Main Key. // Encrypts a newly generated media key using the derived Media Main Key.
// pub fn encrypt_media_key(&self, media_key: &[u8; 32]) -> Vec<u8> { // pub fn encrypt_media_key(&self, media_key: &[u8; 32]) -> Vec<u8> {
// self.encrypt_with_info(b"media_main_key", media_key) // self.encrypt_with_info(b"media_main_key", media_key)
// } // }
/// Decrypts a wrapped media key using the derived Media Main Key. // Decrypts a wrapped media key using the derived Media Main Key.
// pub fn decrypt_media_key(&self, wrapped_media_key: &[u8]) -> Result<[u8; 32]> { // pub fn decrypt_media_key(&self, wrapped_media_key: &[u8]) -> Result<[u8; 32]> {
// let decrypted = self.decrypt_with_info(b"media_main_key", wrapped_media_key)?; // let decrypted = self.decrypt_with_info(b"media_main_key", wrapped_media_key)?;

View file

@ -6,6 +6,8 @@ mod error;
mod frb_generated; mod frb_generated;
mod keys; mod keys;
mod log; mod log;
mod passwordless_recovery;
mod secure_storage; mod secure_storage;
mod standalone; mod standalone;
mod user_discovery;
mod utils; mod utils;

View file

@ -4,9 +4,10 @@ pub mod stores;
pub mod tests; pub mod tests;
pub mod traits; pub mod traits;
use std::collections::{HashMap, HashSet}; #[cfg(test)]
use std::collections::HashMap;
use std::collections::{HashSet};
use std::sync::Arc; use std::sync::Arc;
use std::u8;
use blahaj::{Share, Sharks}; use blahaj::{Share, Sharks};
use prost::Message; use prost::Message;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -184,6 +185,7 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
/// * `Ok(HashMap<AnnouncedUser, Vec<(UserID, Option<i64>)>>)` - All connections the user has discovered /// * `Ok(HashMap<AnnouncedUser, Vec<(UserID, Option<i64>)>>)` - All connections the user has discovered
/// * `Err(UserDiscoveryError)` - If there where erros in the store. /// * `Err(UserDiscoveryError)` - If there where erros in the store.
/// ///
#[cfg(test)]
pub async fn get_all_announced_users( pub async fn get_all_announced_users(
&self, &self,
) -> Result<HashMap<AnnouncedUser, Vec<(UserID, Option<i64>)>>> { ) -> Result<HashMap<AnnouncedUser, Vec<(UserID, Option<i64>)>>> {
@ -384,7 +386,8 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
config.promotion_version += 1; config.promotion_version += 1;
new_promotion_version = config.promotion_version; new_promotion_version = config.promotion_version;
announcement_version = config.announcement_version; announcement_version = config.announcement_version;
}).await?; })
.await?;
let message = UserDiscoveryMessage { let message = UserDiscoveryMessage {
version: Some(UserDiscoveryVersion { version: Some(UserDiscoveryVersion {
@ -430,7 +433,7 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
} }
.encode_to_vec(); .encode_to_vec();
let sharks = Sharks(config.threshold as u8); let sharks = Sharks(config.threshold);
let dealer = sharks.dealer(&encrypted_announcement); let dealer = sharks.dealer(&encrypted_announcement);
let mut shares: Vec<Vec<u8>> = dealer let mut shares: Vec<Vec<u8>> = dealer
@ -476,10 +479,8 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
where where
F: FnOnce(&mut UserDiscoveryConfig), F: FnOnce(&mut UserDiscoveryConfig),
{ {
let _lock = tokio::time::timeout( let _lock =
std::time::Duration::from_secs(10), tokio::time::timeout(std::time::Duration::from_secs(10), self.config_lock.lock())
self.config_lock.lock(),
)
.await .await
.ok(); .ok();
let mut config: UserDiscoveryConfig = let mut config: UserDiscoveryConfig =
@ -539,9 +540,9 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
.verify_stored_pubkey(contact_id, &signed_data.public_key) .verify_stored_pubkey(contact_id, &signed_data.public_key)
.await? .await?
{ {
return Err(UserDiscoveryError::MaliciousAnnouncementData(format!( return Err(UserDiscoveryError::MaliciousAnnouncementData(
"public key does not match with stored one", "public key does not match with stored one".to_string(),
))); ));
} }
if !self if !self
@ -553,9 +554,9 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
) )
.await? .await?
{ {
return Err(UserDiscoveryError::MaliciousAnnouncementData(format!( return Err(UserDiscoveryError::MaliciousAnnouncementData(
"signature invalid", "signature invalid".to_string(),
))); ));
} }
// Only add this user to the promotions if the users enabled this feature // Only add this user to the promotions if the users enabled this feature
@ -567,7 +568,8 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
config.promotion_version += 1; config.promotion_version += 1;
new_promotion_version = config.promotion_version; new_promotion_version = config.promotion_version;
announcement_version = config.announcement_version; announcement_version = config.announcement_version;
}).await?; })
.await?;
let message = UserDiscoveryMessage { let message = UserDiscoveryMessage {
version: Some(UserDiscoveryVersion { version: Some(UserDiscoveryVersion {
@ -624,11 +626,10 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
self.store self.store
.push_new_user_relation( .push_new_user_relation(
promotion.from_contact_id, promotion.from_contact_id,
announced_user, announced_user.clone(),
promotion.public_key_verified_timestamp, promotion.public_key_verified_timestamp,
) )
.await?; .await?;
return Ok(());
} }
Ok(()) Ok(())
@ -731,9 +732,9 @@ impl<Store: UserDiscoveryStore, Utils: UserDiscoveryUtils> UserDiscovery<Store,
) )
.await? .await?
{ {
return Err(UserDiscoveryError::MaliciousAnnouncementData(format!( return Err(UserDiscoveryError::MaliciousAnnouncementData(
"signature is invalid", "signature is invalid".to_string(),
))); ));
} }
tracing::debug!("Announcement valid."); tracing::debug!("Announcement valid.");
@ -790,4 +791,3 @@ impl Default for UserDiscoveryConfig {
} }
} }
} }

View file

@ -91,7 +91,7 @@ impl UserDiscoveryStore for InMemoryStore {
async fn get_own_promotions_after_version(&self, version: u32) -> Result<Vec<Vec<u8>>> { async fn get_own_promotions_after_version(&self, version: u32) -> Result<Vec<Vec<u8>>> {
let storage = self.storage(); let storage = self.storage();
let elements = storage.own_promotions[(version as usize)..] let elements = storage.own_promotions[(version as usize)..]
.into_iter() .iter()
.map(|(_, promotion)| promotion.to_owned()) .map(|(_, promotion)| promotion.to_owned())
.collect(); .collect();
Ok(elements) Ok(elements)
@ -107,7 +107,7 @@ impl UserDiscoveryStore for InMemoryStore {
if let Some(element) = element { if let Some(element) = element {
return Ok(Some(element.1.to_owned())); return Ok(Some(element.1.to_owned()));
} }
return Ok(None); Ok(None)
} }
async fn store_other_promotion(&self, promotion: OtherPromotion) -> Result<()> { async fn store_other_promotion(&self, promotion: OtherPromotion) -> Result<()> {
@ -158,7 +158,7 @@ impl UserDiscoveryStore for InMemoryStore {
let entry = storage let entry = storage
.announced_users .announced_users
.entry(announced_user.clone()) .entry(announced_user.clone())
.or_insert(vec![]); .or_default();
if announced_user.user_id != from_contact_id { if announced_user.user_id != from_contact_id {
if let Some(found) = entry.iter_mut().find(|x| x.0 == from_contact_id) { if let Some(found) = entry.iter_mut().find(|x| x.0 == from_contact_id) {
found.1 = public_key_verified_timestamp; found.1 = public_key_verified_timestamp;

View file

@ -0,0 +1,4 @@
#[cfg(test)]
mod in_memory_store;
#[cfg(test)]
pub(super) use in_memory_store::InMemoryStore;

View file

@ -1,9 +1,14 @@
#[cfg(test)]
use std::collections::HashMap; use std::collections::HashMap;
use crate::user_discovery::error::Result; use crate::user_discovery::error::Result;
use crate::user_discovery::UserID; use crate::user_discovery::UserID;
use std::future::Future; use std::future::Future;
/// Type alias used in `UserDiscoveryStore::get_all_announced_users`.
#[cfg(test)]
pub type AnnouncedUserMap = HashMap<AnnouncedUser, Vec<(UserID, Option<i64>)>>;
#[derive(Clone, sqlx::FromRow)] #[derive(Clone, sqlx::FromRow)]
pub struct OtherPromotion { pub struct OtherPromotion {
pub promotion_id: u32, pub promotion_id: u32,
@ -65,9 +70,8 @@ pub trait UserDiscoveryStore {
public_key_verified_timestamp: Option<i64>, public_key_verified_timestamp: Option<i64>,
) -> impl Future<Output = Result<()>> + Send; ) -> impl Future<Output = Result<()>> + Send;
fn get_all_announced_users( #[cfg(test)]
&self, fn get_all_announced_users(&self) -> impl Future<Output = Result<AnnouncedUserMap>> + Send;
) -> impl Future<Output = Result<HashMap<AnnouncedUser, Vec<(UserID, Option<i64>)>>>> + Send;
fn get_contact_promotion( fn get_contact_promotion(
&self, &self,

View file

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
[workspace]
members = ["protocols"]
resolver = "3"

View file

@ -1,29 +0,0 @@
[package]
name = "protocols"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["rlib", "cdylib", "staticlib"]
[dependencies]
thiserror = "2.0.18"
tracing = "0.1.44"
serde = "1.0.228"
prost = "0.14.1"
rand = "0.10.1"
blahaj = "0.6.0"
serde_json = "1.0"
base64 = "0.22.1"
hmac = "0.13.0"
sha2 = "0.11.0"
tokio = { version = "1.44", features = ["full"] }
sqlx = { version = "0.9.0-alpha.1", default-features = false, features = [
"derive",
] }
[dev-dependencies]
pretty_env_logger = "0.5.0"
[build-dependencies]
prost-build = "0.14.1"

View file

@ -1,11 +0,0 @@
use std::io::Result;
fn main() -> Result<()> {
prost_build::compile_protos(
&[
"src/user_discovery/types.proto",
"src/key_verification/types.proto",
],
&["src/"],
)?;
Ok(())
}

View file

@ -1,30 +0,0 @@
use prost::DecodeError;
use thiserror::Error;
pub type Result<T> = core::result::Result<T, KeyVerificationError>;
#[derive(Error, Debug)]
pub enum KeyVerificationError {
#[error("The prefix deeplink url must start with https:// and end with a #")]
InvalidDeeplinkPrefix,
#[error("Invalid qr text")]
InvalidQrText,
#[error(
"Contact user_id is known and the stored public_key does not match the received user id"
)]
InvalidPublicKeyAndUserIdCombination,
#[error("Store error: `{0}`")]
Store(String),
#[error("`{0}`")]
Base64(#[from] base64::DecodeError),
#[error("`{0}`")]
Prost(#[from] DecodeError),
#[error("`{0}`")]
Hmac(#[from] hmac::digest::InvalidLength),
}

View file

@ -1,194 +0,0 @@
use crate::key_verification::{error::KeyVerificationError, traits::KeyVerificationStore};
use crate::user_discovery::UserID;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use error::Result;
use hmac::{Hmac, KeyInit, Mac};
use prost::Message;
use sha2::Sha256;
pub(crate) mod error;
pub mod stores;
pub mod traits;
include!(concat!(env!("OUT_DIR"), "/key_verification.rs"));
pub struct KeyVerificationConfig {
/// The link prefix for the qr code which should be registered as a deeplink on Android and a universal link on iOS
/// The link MUST start with a https:// and end with a #
/// The link should contain the username of the user so the application can show the scanned user without internet
/// Example: https://me.twonly.eu/tobi#
deeplink_prefix: String,
/// The user ID used to calculate the verification proof
user_id: UserID,
/// The public_key of the user to calculate the verification proof
public_key: Vec<u8>,
}
pub struct ScannedUser {
pub user_id: UserID,
pub public_key: Vec<u8>,
pub verification_proof: Vec<u8>,
}
pub struct KeyVerification<Store: KeyVerificationStore> {
store: Store,
config: KeyVerificationConfig,
}
impl<Store: KeyVerificationStore> KeyVerification<Store> {
pub fn new(store: Store, config: KeyVerificationConfig) -> Result<KeyVerification<Store>> {
if !config.deeplink_prefix.starts_with("https://") || !config.deeplink_prefix.ends_with("#")
{
return Err(KeyVerificationError::InvalidDeeplinkPrefix);
}
Ok(Self { store, config })
}
/// Generates the a string which should be displayed in the UI so others can scan it.
pub fn generate_qr_text(&self) -> Result<String> {
// 10 Bytes should be enough. Tokens are only valid for one day and then deleted.
let secret_verification_token: Vec<u8> = rand::random_iter().take(16).collect();
self.store
.push_new_secret_verification_token(&secret_verification_token)?;
let verification_data = VerificationData {
user_id: self.config.user_id,
public_key: self.config.public_key.clone(),
secret_verification_token,
};
let verification_data_bytes = verification_data.encode_to_vec();
let encoded = URL_SAFE_NO_PAD.encode(verification_data_bytes);
Ok(format!("{}{}", self.config.deeplink_prefix, encoded))
}
/// Handles the scanned qr code text and creates a response message
/// which can be send to the other person
pub fn get_user_from_scanned_qr_text(&self, received_text: &str) -> Result<ScannedUser> {
let splitted: Vec<_> = received_text.split('#').collect();
if splitted.len() != 2 {
tracing::info!("Scanned qr text does not contain a #");
return Err(KeyVerificationError::InvalidQrText);
}
let verification_data_bytes = URL_SAFE_NO_PAD.decode(splitted[1])?;
let verification_data = VerificationData::decode(verification_data_bytes.as_slice())?;
let mut mac = Hmac::<Sha256>::new_from_slice(&verification_data.secret_verification_token)?;
mac.update(&self.config.user_id.to_le_bytes());
mac.update(&self.config.public_key);
mac.update(&verification_data.user_id.to_le_bytes());
mac.update(&verification_data.public_key);
let verification_proof = mac.finalize().into_bytes().to_vec();
Ok(ScannedUser {
user_id: verification_data.user_id,
public_key: verification_data.public_key,
verification_proof,
})
}
/// Checks whether the received verification proof is valid
pub fn is_received_verification_proof_valid(
&self,
from_user_id: UserID,
public_key: Vec<u8>,
verification_proof: Vec<u8>,
) -> Result<bool> {
let verification_tokens = self.store.get_all_valid_verification_tokens()?;
for verification_token in &verification_tokens {
let calculated_verification_proof = {
let mut mac = Hmac::<Sha256>::new_from_slice(verification_token)?;
mac.update(&from_user_id.to_le_bytes());
mac.update(&public_key);
mac.update(&self.config.user_id.to_le_bytes());
mac.update(&self.config.public_key);
mac.finalize().into_bytes().to_vec()
};
if calculated_verification_proof == verification_proof {
return Ok(true);
}
}
Ok(false)
}
}
#[cfg(test)]
mod tests {
use crate::key_verification::{stores::InMemoryStore, KeyVerification, KeyVerificationConfig};
#[test]
fn test_key_verification() {
let _ = pretty_env_logger::try_init();
const ALICE_ID: i64 = 10;
const BOB_ID: i64 = 11;
let alice_kv = KeyVerification::new(
InMemoryStore::default(),
KeyVerificationConfig {
user_id: ALICE_ID,
public_key: vec![ALICE_ID as u8; 32],
deeplink_prefix: "https://me.twonly.eu/alice#".into(),
},
)
.unwrap();
let bob_kv = KeyVerification::new(
InMemoryStore::default(),
KeyVerificationConfig {
user_id: BOB_ID,
public_key: vec![BOB_ID as u8; 32],
deeplink_prefix: "https://me.twonly.eu/bob#".into(),
},
)
.unwrap();
let qr_code_text = alice_kv.generate_qr_text().unwrap();
assert_eq!(qr_code_text.len(), 99);
tracing::debug!("Generated QR-Code-Link: {qr_code_text}");
let scanned_user = bob_kv.get_user_from_scanned_qr_text(&qr_code_text).unwrap();
// THIS must be done by the application
assert_eq!(scanned_user.user_id, ALICE_ID);
assert_eq!(scanned_user.public_key, vec![ALICE_ID as u8; 32]);
// SEND scanned_user.verification_proof over the establish e2ee protected session if public_key verification was valid.
let valid_verification_proof = alice_kv
.is_received_verification_proof_valid(
BOB_ID,
vec![BOB_ID as u8; 32],
scanned_user.verification_proof.clone(),
)
.unwrap();
assert_eq!(valid_verification_proof, true);
let valid_verification_proof = alice_kv
.is_received_verification_proof_valid(
BOB_ID,
vec![(BOB_ID + 1) as u8; 32],
scanned_user.verification_proof.clone(),
)
.unwrap();
assert_eq!(valid_verification_proof, false);
let mut modified_proof = scanned_user.verification_proof;
modified_proof[0] = modified_proof[0] + 1;
let valid_verification_proof = alice_kv
.is_received_verification_proof_valid(BOB_ID, vec![BOB_ID as u8; 32], modified_proof)
.unwrap();
assert_eq!(valid_verification_proof, false);
}
}

View file

@ -1,22 +0,0 @@
use std::sync::{Arc, Mutex};
use crate::key_verification::{error::Result, traits::KeyVerificationStore};
#[derive(Default)]
pub struct InMemoryStore {
verification_tokens: Arc<Mutex<Vec<Vec<u8>>>>,
}
impl KeyVerificationStore for InMemoryStore {
fn push_new_secret_verification_token(&self, token: &[u8]) -> Result<()> {
self.verification_tokens
.lock()
.unwrap()
.push(token.to_vec());
Ok(())
}
fn get_all_valid_verification_tokens(&self) -> Result<Vec<Vec<u8>>> {
Ok(self.verification_tokens.lock().unwrap().clone())
}
}

View file

@ -1,3 +0,0 @@
mod in_memory_store;
pub use in_memory_store::InMemoryStore;

View file

@ -1,7 +0,0 @@
use super::error::Result;
pub trait KeyVerificationStore {
fn push_new_secret_verification_token(&self, token: &[u8]) -> Result<()>;
/// This function should return all tokens from the last 24h
/// All other tokens can be removed from the database
fn get_all_valid_verification_tokens(&self) -> Result<Vec<Vec<u8>>>;
}

View file

@ -1,12 +0,0 @@
syntax = "proto3";
package key_verification;
message VerificationData {
int64 user_id = 1;
bytes public_key = 2;
bytes secret_verification_token = 3;
}
message VerificationMessage {
bytes calculated_mac = 1;
}

View file

@ -1,3 +0,0 @@
pub mod key_verification;
pub mod passwordless_recovery;
pub mod user_discovery;

View file

@ -1,2 +0,0 @@
mod in_memory_store;
pub use in_memory_store::InMemoryStore;

View file

@ -18,7 +18,7 @@ protoc --proto_path="$CLIENT_DIR" --dart_out="$GENERATED_DIR" "qr.proto"
protoc --proto_path="$CLIENT_DIR" --dart_out="$GENERATED_DIR" "data.proto" protoc --proto_path="$CLIENT_DIR" --dart_out="$GENERATED_DIR" "data.proto"
mkdir "$GENERATED_DIR/user_discovery/" &>/dev/null mkdir "$GENERATED_DIR/user_discovery/" &>/dev/null
protoc --proto_path="./rust_dependencies/protocols/src/user_discovery/" --dart_out="$GENERATED_DIR/user_discovery/" "types.proto" protoc --proto_path="./rust/src/user_discovery/" --dart_out="$GENERATED_DIR/user_discovery/" "types.proto"
protoc --proto_path="$CLIENT_DIR" --dart_out="$GENERATED_DIR" "push_notification.proto" protoc --proto_path="$CLIENT_DIR" --dart_out="$GENERATED_DIR" "push_notification.proto"
protoc --proto_path="$CLIENT_DIR" --swift_out="./ios/NotificationService/" "push_notification.proto" protoc --proto_path="$CLIENT_DIR" --swift_out="./ios/NotificationService/" "push_notification.proto"