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

- New: Video stabilization
- New: Crop or rotate images before sharing them.
- New: Clicking on “Text Notifications” will now open the chat directly (Android only)
- New: Developer settings to reduce flames
- Improve: Improved troubleshooting for issues with push notifications 
- Improve: A message appears if someone has deleted their account.
- Improve: Make the verification badge more visible.
- Fix: Flash not activated when starting a video recording
- Fix: Problem sending media when a recipient has deleted their account.
- Fix: Receive push notifications without receiving an in-app message (Android)
- Fix: Issue with sending GIFs from Memories
- Fix: Incorrect processing of messages that have already been fetched from the server causes the UI to freeze
This commit is contained in:
Tobi 2026-04-06 15:47:44 +02:00 committed by GitHub
commit 1b7ec19769
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1563 additions and 761 deletions

View file

@ -1,5 +1,20 @@
# Changelog
## 0.1.3
- New: Video stabilization
- New: Crop or rotate images before sharing them.
- New: Clicking on “Text Notifications” will now open the chat directly (Android only)
- New: Developer settings to reduce flames
- Improve: Improved troubleshooting for issues with push notifications
- Improve: A message appears if someone has deleted their account.
- Improve: Make the verification badge more visible.
- Fix: Flash not activated when starting a video recording
- Fix: Problem sending media when a recipient has deleted their account.
- Fix: Receive push notifications without receiving an in-app message (Android)
- Fix: Issue with sending GIFs from Memories
- Fix: Incorrect processing of messages that have already been fetched from the server causes the UI to freeze
## 0.1.1
- New: Groups can now collect flames as well

View file

@ -1,4 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#57CC99" class="bi bi-patch-check" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10.354 6.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708 0"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#57CC99" class="bi bi-patch-check-fill" viewBox="0 0 16 16">
<path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708"/>
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 604 B

View file

@ -1,4 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ff0000" class="bi bi-patch-check" viewBox="0 0 16 16">
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
<path d="m10.273 2.513-.921-.944.715-.698.622.637.89-.011a2.89 2.89 0 0 1 2.924 2.924l-.01.89.636.622a2.89 2.89 0 0 1 0 4.134l-.637.622.011.89a2.89 2.89 0 0 1-2.924 2.924l-.89-.01-.622.636a2.89 2.89 0 0 1-4.134 0l-.622-.637-.89.011a2.89 2.89 0 0 1-2.924-2.924l.01-.89-.636-.622a2.89 2.89 0 0 1 0-4.134l.637-.622-.011-.89a2.89 2.89 0 0 1 2.924-2.924l.89.01.622-.636a2.89 2.89 0 0 1 4.134 0l-.715.698a1.89 1.89 0 0 0-2.704 0l-.92.944-1.32-.016a1.89 1.89 0 0 0-1.911 1.912l.016 1.318-.944.921a1.89 1.89 0 0 0 0 2.704l.944.92-.016 1.32a1.89 1.89 0 0 0 1.912 1.911l1.318-.016.921.944a1.89 1.89 0 0 0 2.704 0l.92-.944 1.32.016a1.89 1.89 0 0 0 1.911-1.912l-.016-1.318.944-.921a1.89 1.89 0 0 0 0-2.704l-.944-.92.016-1.32a1.89 1.89 0 0 0-1.912-1.911z"/>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ff0000" class="bi bi-patch-exclamation-fill" viewBox="0 0 16 16">
<path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
</svg>

Before

Width:  |  Height:  |  Size: 995 B

After

Width:  |  Height:  |  Size: 629 B

View file

@ -56,13 +56,20 @@ PODS:
- FirebaseAnalytics (~> 12.9.0)
- Firebase/CoreOnly (12.9.0):
- FirebaseCore (~> 12.9.0)
- Firebase/Installations (12.9.0):
- Firebase/CoreOnly
- FirebaseInstallations (~> 12.9.0)
- Firebase/Messaging (12.9.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.9.0)
- firebase_core (4.5.0):
- firebase_app_installations (0.4.1):
- Firebase/Installations (= 12.9.0)
- firebase_core
- Flutter
- firebase_core (4.6.0):
- Firebase/CoreOnly (= 12.9.0)
- Flutter
- firebase_messaging (16.1.2):
- firebase_messaging (16.1.3):
- Firebase/Messaging (= 12.9.0)
- firebase_core
- Flutter
@ -278,17 +285,17 @@ PODS:
- PromisesObjC (2.4.0)
- restart_app (1.7.3):
- Flutter
- SDWebImage (5.21.6):
- SDWebImage/Core (= 5.21.6)
- SDWebImage/Core (5.21.6)
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- SDWebImageWebPCoder (0.15.0):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- Sentry/HybridSDK (8.56.2)
- sentry_flutter (9.14.0):
- Sentry/HybridSDK (8.58.0)
- sentry_flutter (9.16.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.56.2)
- Sentry/HybridSDK (= 8.58.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@ -297,32 +304,32 @@ PODS:
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.51.1):
- sqlite3/common (= 3.51.1)
- sqlite3/common (3.51.1)
- sqlite3/dbstatvtab (3.51.1):
- sqlite3 (3.52.0):
- sqlite3/common (= 3.52.0)
- sqlite3/common (3.52.0)
- sqlite3/dbstatvtab (3.52.0):
- sqlite3/common
- sqlite3/fts5 (3.51.1):
- sqlite3/fts5 (3.52.0):
- sqlite3/common
- sqlite3/math (3.51.1):
- sqlite3/math (3.52.0):
- sqlite3/common
- sqlite3/perf-threadsafe (3.51.1):
- sqlite3/perf-threadsafe (3.52.0):
- sqlite3/common
- sqlite3/rtree (3.51.1):
- sqlite3/rtree (3.52.0):
- sqlite3/common
- sqlite3/session (3.51.1):
- sqlite3/session (3.52.0):
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
- FlutterMacOS
- sqlite3 (~> 3.51.1)
- sqlite3 (~> 3.52.0)
- sqlite3/dbstatvtab
- sqlite3/fts5
- sqlite3/math
- sqlite3/perf-threadsafe
- sqlite3/rtree
- sqlite3/session
- SwiftProtobuf (1.34.1)
- SwiftProtobuf (1.36.1)
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
@ -343,6 +350,7 @@ DEPENDENCIES:
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Firebase
- firebase_app_installations (from `.symlinks/plugins/firebase_app_installations/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- FirebaseCore
@ -430,6 +438,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/emoji_picker_flutter/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_app_installations:
:path: ".symlinks/plugins/firebase_app_installations/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
@ -493,7 +503,7 @@ SPEC CHECKSUMS:
app_links: a754cbec3c255bd4bbb4d236ecc06f28cd9a7ce8
audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741
camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
cryptography_flutter_plus: 44f4e9e4079395fcbb3e7809c0ac2c6ae2d9576f
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
@ -502,8 +512,9 @@ SPEC CHECKSUMS:
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
firebase_core: afac1aac13c931e0401c7e74ed1276112030efab
firebase_messaging: 7cb2727feb789751fc6936bcc8e08408970e2820
firebase_app_installations: 1abd8d071ea2022d7888f7a9713710c37136ff91
firebase_core: 8e6f58412ca227827c366b92e7cee047a2148c60
firebase_messaging: c3aa897e0d40109cfb7927c40dc0dea799863f3b
FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352
FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8
FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72
@ -544,16 +555,16 @@ SPEC CHECKSUMS:
pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
restart_app: 0714144901e260eae68f7afc2fc4aacc1a323ad2
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7
sentry_flutter: 841fa2fe08dc72eb95e2320b76e3f751f3400cf5
Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be
sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41
SwiftProtobuf: c901f00a3e125dc33cac9b16824da85682ee47da
sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921
sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab
SwiftProtobuf: 9e106a71456f4d3f6a3b0c8fd87ef0be085efc38
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a

View file

@ -6,7 +6,8 @@ class Routes {
static const String chatsStartNewChat = '/chats/start_new_chat';
static const String chatsCameraSendTo = '/chats/camera_send_to';
static const String chatsMediaViewer = '/chats/media_viewer';
static const String chatsMessages = '/chats/messages';
static String chatsMessages(String groupId) => '/chats/messages/$groupId';
static String groupCreateSelectMember(String? groupId) =>
'/group/create/select_member${groupId == null ? '' : '/$groupId'}';
@ -53,5 +54,7 @@ class Routes {
'/settings/developer/retransmission_database';
static const String settingsDeveloperAutomatedTesting =
'/settings/developer/automated_testing';
static const String settingsDeveloperReduceFlames =
'/settings/developer/reduce_flames';
static const String settingsInvite = '/settings/invite';
}

View file

@ -26,14 +26,14 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
Log.error('Did not update reaction as it is not an emoji!');
return;
}
final msg =
await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull();
final msg = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (msg == null || msg.groupId != groupId) return;
try {
if (remove) {
await (delete(reactions)
..where(
await (delete(reactions)..where(
(t) =>
t.senderId.equals(contactId) &
t.messageId.equals(messageId) &
@ -63,13 +63,13 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
Log.error('Did not update reaction as it is not an emoji!');
return;
}
final msg =
await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull();
final msg = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (msg == null) return;
try {
await (delete(reactions)
..where(
await (delete(reactions)..where(
(t) =>
t.senderId.isNull() &
t.messageId.equals(messageId) &
@ -98,9 +98,8 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
}
Stream<Reaction?> watchLastReactions(String groupId) {
final query = (select(reactions)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
.join(
final query =
(select(reactions)).join(
[
innerJoin(
messages,
@ -110,7 +109,7 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
],
)
..where(messages.groupId.equals(groupId))
// ..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
..orderBy([OrderingTerm.desc(messages.createdAt)])
..limit(1);
return query.map((row) => row.readTable(reactions)).watchSingleOrNull();
}

View file

@ -733,9 +733,33 @@ abstract class AppLocalizations {
/// No description provided for @settingsNotifyTroubleshootingNoProblemDesc.
///
/// In en, this message translates to:
/// **'Press OK to receive a test notification. When you receive no message even after waiting for 10 minutes, please send us your debug log in Settings > Help > Debug log, so we can look at that issue.'**
/// **'Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.'**
String get settingsNotifyTroubleshootingNoProblemDesc;
/// No description provided for @settingsNotifyResetTitle.
///
/// In en, this message translates to:
/// **'Didn\'t receive a test notification?'**
String get settingsNotifyResetTitle;
/// No description provided for @settingsNotifyResetTitleSubtitle.
///
/// In en, this message translates to:
/// **'If you haven\'t received any test notifications, click here to reset your notification tokens.'**
String get settingsNotifyResetTitleSubtitle;
/// No description provided for @settingsNotifyResetTitleReset.
///
/// In en, this message translates to:
/// **'Your notification tokens have been reset.'**
String get settingsNotifyResetTitleReset;
/// No description provided for @settingsNotifyResetTitleResetDesc.
///
/// In en, this message translates to:
/// **'If the problem persists, please send us your debug log via Settings > Help so we can investigate the issue.'**
String get settingsNotifyResetTitleResetDesc;
/// No description provided for @settingsHelp.
///
/// In en, this message translates to:
@ -2818,6 +2842,12 @@ abstract class AppLocalizations {
/// **'Scan other profile'**
String get scanOtherProfile;
/// No description provided for @openYourOwnQRcode.
///
/// In en, this message translates to:
/// **'Open your own QR code'**
String get openYourOwnQRcode;
/// No description provided for @skipForNow.
///
/// In en, this message translates to:

View file

@ -356,7 +356,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsNotifyTroubleshootingNoProblemDesc =>
'Klicke auf OK, um eine Testbenachrichtigung zu erhalten. Wenn du auch nach 10 Minuten warten keine Nachricht erhältst, sende uns bitte dein Diagnoseprotokoll unter Einstellungen > Hilfe > Diagnoseprotokoll, damit wir uns das Problem ansehen können.';
'Um eine Testbenachrichtigung zu erhalten, klicke auf OK. Falls du die Testbenachrichtigung nicht erhältst, klicke bitte auf den neuen Menüpunkt, der nach dem Klicken auf „OK“ angezeigt wird.';
@override
String get settingsNotifyResetTitle => 'Keine Testbenachrichtigung erhalten?';
@override
String get settingsNotifyResetTitleSubtitle =>
'Falls du keine Testbenachrichtigungen erhalten hast, klicke hier, um deine Benachrichtigungstoken zurückzusetzen.';
@override
String get settingsNotifyResetTitleReset =>
'Deine Benachrichtigungstoken wurden zurückgesetzt.';
@override
String get settingsNotifyResetTitleResetDesc =>
'Sollte das Problem weiterhin bestehen, sende uns bitte dein Debug-Protokoll über „Einstellungen“ > „Hilfe“, damit wir das Problem untersuchen können.';
@override
String get settingsHelp => 'Hilfe';
@ -1553,6 +1568,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get scanOtherProfile => 'Scanne ein anderes Profil';
@override
String get openYourOwnQRcode => 'Eigenen QR-Code öffnen';
@override
String get skipForNow => 'Vorerst überspringen';

View file

@ -351,7 +351,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsNotifyTroubleshootingNoProblemDesc =>
'Press OK to receive a test notification. When you receive no message even after waiting for 10 minutes, please send us your debug log in Settings > Help > Debug log, so we can look at that issue.';
'Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.';
@override
String get settingsNotifyResetTitle => 'Didn\'t receive a test notification?';
@override
String get settingsNotifyResetTitleSubtitle =>
'If you haven\'t received any test notifications, click here to reset your notification tokens.';
@override
String get settingsNotifyResetTitleReset =>
'Your notification tokens have been reset.';
@override
String get settingsNotifyResetTitleResetDesc =>
'If the problem persists, please send us your debug log via Settings > Help so we can investigate the issue.';
@override
String get settingsHelp => 'Help';
@ -1543,6 +1558,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get scanOtherProfile => 'Scan other profile';
@override
String get openYourOwnQRcode => 'Open your own QR code';
@override
String get skipForNow => 'Skip for now';

View file

@ -351,7 +351,22 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get settingsNotifyTroubleshootingNoProblemDesc =>
'Press OK to receive a test notification. When you receive no message even after waiting for 10 minutes, please send us your debug log in Settings > Help > Debug log, so we can look at that issue.';
'Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.';
@override
String get settingsNotifyResetTitle => 'Didn\'t receive a test notification?';
@override
String get settingsNotifyResetTitleSubtitle =>
'If you haven\'t received any test notifications, click here to reset your notification tokens.';
@override
String get settingsNotifyResetTitleReset =>
'Your notification tokens have been reset.';
@override
String get settingsNotifyResetTitleResetDesc =>
'If the problem persists, please send us your debug log via Settings > Help so we can investigate the issue.';
@override
String get settingsHelp => 'Help';
@ -1543,6 +1558,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get scanOtherProfile => 'Scan other profile';
@override
String get openYourOwnQRcode => 'Open your own QR code';
@override
String get skipForNow => 'Skip for now';

@ -1 +1 @@
Subproject commit 284c602b507e77addc8f21c4fc8a321f237cac1b
Subproject commit 662b8ddafcbf1c789f54c93da51ebb0514ba1f81

View file

@ -53,6 +53,9 @@ class UserData {
@JsonKey(defaultValue: false)
bool requestedAudioPermission = false;
@JsonKey(defaultValue: true)
bool videoStabilizationEnabled = true;
@JsonKey(defaultValue: true)
bool showFeedbackShortcut = true;

View file

@ -30,6 +30,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
..defaultShowTime = (json['defaultShowTime'] as num?)?.toInt()
..requestedAudioPermission =
json['requestedAudioPermission'] as bool? ?? false
..videoStabilizationEnabled =
json['videoStabilizationEnabled'] as bool? ?? false
..showFeedbackShortcut = json['showFeedbackShortcut'] as bool? ?? true
..showShowImagePreviewWhenSending =
json['showShowImagePreviewWhenSending'] as bool? ?? false
@ -105,6 +107,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'defaultShowTime': instance.defaultShowTime,
'requestedAudioPermission': instance.requestedAudioPermission,
'videoStabilizationEnabled': instance.videoStabilizationEnabled,
'showFeedbackShortcut': instance.showFeedbackShortcut,
'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending,
'startWithCameraOpen': instance.startWithCameraOpen,

View file

@ -26,6 +26,7 @@ import 'package:twonly/src/views/settings/data_and_storage/export_media.view.dar
import 'package:twonly/src/views/settings/data_and_storage/import_media.view.dart';
import 'package:twonly/src/views/settings/developer/automated_testing.view.dart';
import 'package:twonly/src/views/settings/developer/developer.view.dart';
import 'package:twonly/src/views/settings/developer/reduce_flames.view.dart';
import 'package:twonly/src/views/settings/developer/retransmission_data.view.dart';
import 'package:twonly/src/views/settings/help/changelog.view.dart';
import 'package:twonly/src/views/settings/help/contact_us.view.dart';
@ -84,10 +85,10 @@ final routerProvider = GoRouter(
},
),
GoRoute(
path: 'messages',
path: 'messages/:groupId',
builder: (context, state) {
final group = state.extra! as Group;
return ChatMessagesView(group);
final groupId = state.pathParameters['groupId']!;
return ChatMessagesView(groupId);
},
),
],
@ -280,6 +281,10 @@ final routerProvider = GoRouter(
path: 'automated_testing',
builder: (context, state) => const AutomatedTestingView(),
),
GoRoute(
path: 'reduce_flames',
builder: (context, state) => const ReduceFlamesView(),
),
],
),
GoRoute(

View file

@ -133,11 +133,12 @@ class ApiService {
return;
}
reconnectionTimer?.cancel();
Log.info('Starting reconnection timer with $_reconnectionDelay s delay');
reconnectionTimer = Timer(Duration(seconds: _reconnectionDelay), () async {
Log.info('Reconnection timer triggered');
reconnectionTimer = null;
// only try to reconnect in case the app is in the foreground
if (!globalIsAppInBackground) {
await connect();
}
});
_reconnectionDelay = 3;
}

View file

@ -147,6 +147,16 @@ Future<void> insertMediaFileInMessagesTable(
),
);
for (final groupId in groupIds) {
final groupMembers = await twonlyDB.groupsDao.getGroupContact(groupId);
if (groupMembers.length == 1) {
if (groupMembers.first.accountDeleted) {
Log.warn(
'Did not send media file to $groupId because the only account has deleted his account.',
);
continue;
}
}
final message = await twonlyDB.messagesDao.insertMessage(
MessagesCompanion(
groupId: Value(groupId),
@ -280,6 +290,14 @@ Future<void> _createUploadRequest(MediaFileService media) async {
}
}
final contact = await twonlyDB.contactsDao.getContactById(
groupMember.contactId,
);
if (contact == null || contact.accountDeleted) {
continue;
}
final downloadToken = getRandomUint8List(32);
late EncryptedContent_Media_Type type;
@ -329,10 +347,11 @@ Future<void> _createUploadRequest(MediaFileService media) async {
Log.error(
'Could not generate ciphertext message for ${groupMember.contactId}',
);
continue;
}
final messageOnSuccess = TextMessage()
..body = cipherText!.$1
..body = cipherText.$1
..userId = Int64(groupMember.contactId);
if (cipherText.$2 != null) {

View file

@ -111,8 +111,8 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
);
Uint8List? pushData;
if (pushNotification != null && receipt.retryCount <= 3) {
/// In case the message has to be resend more than three times, do not show a notification again...
if (pushNotification != null && receipt.retryCount <= 1) {
// Only show the push notification the first two time.
pushData = await encryptPushNotification(
receipt.contactId,
pushNotification,

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
import 'package:hashlib/random.dart';
@ -25,6 +26,7 @@ import 'package:twonly/src/services/api/client2client/reaction.c2c.dart';
import 'package:twonly/src/services/api/client2client/text_message.c2c.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/group.services.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart';
@ -79,14 +81,19 @@ Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
final message = Message.fromBuffer(body);
final receiptId = message.receiptId;
await protectReceiptCheck.protect(() async {
final isDuplicated = await protectReceiptCheck.protect(() async {
if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) {
Log.warn('Got duplicated message from the server.');
return;
return true;
}
await twonlyDB.receiptsDao.gotReceipt(receiptId);
return false;
});
if (isDuplicated) {
return;
}
switch (message.type) {
case Message_Type.SENDER_DELIVERY_RECEIPT:
Log.info('Got delivery receipt for $receiptId!');
@ -131,8 +138,9 @@ Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
if (message.hasEncryptedContent()) {
Value<String>? receiptIdDB;
final encryptedContentRaw =
Uint8List.fromList(message.encryptedContent);
final encryptedContentRaw = Uint8List.fromList(
message.encryptedContent,
);
Message? response;
@ -155,8 +163,10 @@ Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
}
if (response == null) {
final (encryptedContent, plainTextContent) =
await handleEncryptedMessage(
final (
encryptedContent,
plainTextContent,
) = await handleEncryptedMessageRaw(
fromUserId,
encryptedContentRaw,
message.type,
@ -174,6 +184,9 @@ Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
encryptedContent: encryptedContent.writeToBuffer(),
);
receiptIdDB = const Value.absent();
} else {
// Message was successful processed
//
}
}
@ -198,27 +211,48 @@ Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
}
}
Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw(
int fromUserId,
Uint8List encryptedContentRaw,
Message_Type messageType,
String receiptId,
) async {
final (content, decryptionErrorType) = await signalDecryptMessage(
final (encryptedContent, decryptionErrorType) = await signalDecryptMessage(
fromUserId,
encryptedContentRaw,
messageType.value,
);
if (content == null) {
if (encryptedContent == null) {
return (
null,
PlaintextContent()
..decryptionErrorMessage = (PlaintextContent_DecryptionErrorMessage()
..type = decryptionErrorType!)
..type = decryptionErrorType!),
);
}
final (a, b) = await handleEncryptedMessage(
fromUserId,
encryptedContent,
messageType,
receiptId,
);
if (Platform.isAndroid && a == null && b == null) {
// Message was handled without any error -> Show push notification to the user.
await showPushNotificationFromServerMessages(fromUserId, encryptedContent);
}
return (a, b);
}
Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
int fromUserId,
EncryptedContent content,
Message_Type messageType,
String receiptId,
) async {
// We got a valid message fromUserId, so mark all messages which where
// send to the user but not yet ACK for retransmission. All marked messages
// will be either transmitted again after a new server connection (minimum 20 seconds).
@ -235,7 +269,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
return (
null,
PlaintextContent()
..retryControlError = PlaintextContent_RetryErrorMessage()
..retryControlError = PlaintextContent_RetryErrorMessage(),
);
}
return (null, null);
@ -312,7 +346,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
relatedReceiptId: receiptId,
),
),
null
null,
);
}
Log.info(
@ -333,7 +367,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
return (
null,
PlaintextContent()
..retryControlError = PlaintextContent_RetryErrorMessage()
..retryControlError = PlaintextContent_RetryErrorMessage(),
);
}
@ -365,7 +399,7 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
return (
null,
PlaintextContent()
..retryControlError = PlaintextContent_RetryErrorMessage()
..retryControlError = PlaintextContent_RetryErrorMessage(),
);
}
return (null, null);

View file

@ -49,7 +49,18 @@ void callbackDispatcher() {
});
}
bool _isInitialized = false;
Future<bool> initBackgroundExecution() async {
if (_isInitialized) {
// Reload the users, as on Android the background isolate can
// stay alive for multiple hours between task executions
final user = await getUser();
if (user == null) return false;
gUser = user;
return true;
}
SentryWidgetsFlutterBinding.ensureInitialized();
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
@ -65,12 +76,13 @@ Future<bool> initBackgroundExecution() async {
apiService = ApiService();
globalIsInBackgroundTask = true;
_isInitialized = true;
return true;
}
final Mutex _keyValueMutex = Mutex();
Future<void> handlePeriodicTask() async {
Future<void> handlePeriodicTask({int lastExecutionInSecondsLimit = 120}) async {
final shouldBeExecuted = await exclusiveAccess(
lockName: 'periodic_task',
mutex: _keyValueMutex,
@ -84,7 +96,8 @@ Future<void> handlePeriodicTask() async {
final lastExecutionDate = DateTime.fromMillisecondsSinceEpoch(
lastExecutionTime,
);
if (DateTime.now().difference(lastExecutionDate).inMinutes < 2) {
if (DateTime.now().difference(lastExecutionDate).inSeconds <
lastExecutionInSecondsLimit) {
return false;
}
}

View file

@ -13,8 +13,9 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
final groups = await twonlyDB.groupsDao.getAllGroups();
if (groups.isEmpty) return;
final maxMessageCounter = groups.map((x) => x.totalMediaCounter).max;
final bestFriend =
groups.firstWhere((x) => x.totalMediaCounter == maxMessageCounter);
final bestFriend = groups.firstWhere(
(x) => x.totalMediaCounter == maxMessageCounter,
);
if (gUser.myBestFriendGroupId != bestFriend.groupId) {
await updateUserdata((user) {
@ -42,8 +43,9 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
EncryptedContent(
flameSync: EncryptedContent_FlameSync(
flameCounter: Int64(flameCounter),
lastFlameCounterChange:
Int64(group.lastFlameCounterChange!.millisecondsSinceEpoch),
lastFlameCounterChange: Int64(
group.lastFlameCounterChange!.millisecondsSinceEpoch,
),
bestFriend: group.groupId == bestFriend.groupId,
forceUpdate: group.groupId == forceForGroup,
),
@ -134,8 +136,9 @@ Future<void> incFlameCounter(
// Overwrite max flame counter either the current is bigger or the the max flame counter is older then 4 days
if (flameCounter >= maxFlameCounter ||
maxFlameCounterFrom == null ||
maxFlameCounterFrom
.isBefore(clock.now().subtract(const Duration(days: 5)))) {
maxFlameCounterFrom.isBefore(
clock.now().subtract(const Duration(days: 5)),
)) {
maxFlameCounter = flameCounter;
maxFlameCounterFrom = clock.now();
}
@ -172,6 +175,7 @@ bool isItPossibleToRestoreFlames(Group group) {
final flameCounter = getFlameCounterFromGroup(group);
return group.maxFlameCounter > 2 &&
flameCounter < group.maxFlameCounter &&
group.maxFlameCounterFrom!
.isAfter(clock.now().subtract(const Duration(days: 5)));
group.maxFlameCounterFrom!.isAfter(
clock.now().subtract(const Duration(days: 7)),
);
}

View file

@ -72,13 +72,19 @@ Future<void> compressAndOverlayVideo(MediaFileService media) async {
try {
final task = VideoRenderData(
video: EditorVideo.file(media.originalPath),
imageBytes: media.overlayImagePath.readAsBytesSync(),
videoSegments: [
VideoSegment(video: EditorVideo.file(media.originalPath)),
],
imageLayers: [
ImageLayer(image: EditorLayerImage.file(media.overlayImagePath)),
],
enableAudio: !media.removeAudio,
);
await ProVideoEditor.instance
.renderVideoToFile(media.ffmpegOutputPath.path, task);
await ProVideoEditor.instance.renderVideoToFile(
media.ffmpegOutputPath.path,
task,
);
if (Platform.isIOS ||
media.ffmpegOutputPath.statSync().size >= 10_000_000 ||
@ -115,8 +121,8 @@ Future<void> compressAndOverlayVideo(MediaFileService media) async {
final sizeFrom = (media.ffmpegOutputPath.statSync().size / 1024 / 1024)
.toStringAsFixed(2);
final sizeTo =
(media.tempPath.statSync().size / 1024 / 1024).toStringAsFixed(2);
final sizeTo = (media.tempPath.statSync().size / 1024 / 1024)
.toStringAsFixed(2);
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to compress the video. Reduced from $sizeFrom to $sizeTo bytes.',

View file

@ -6,10 +6,12 @@ import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/localization/generated/app_localizations_de.dart';
import 'package:twonly/src/localization/generated/app_localizations_en.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/push_notification.pb.dart';
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/utils/log.dart';
@ -45,10 +47,34 @@ Future<void> customLocalPushNotification(String title, String msg) async {
);
}
Future<void> showPushNotificationFromServerMessages(
int fromUserId,
EncryptedContent encryptedContent,
) async {
final pushData = await getPushNotificationFromEncryptedContent(
null, // this is the toUserID which must be null as this means that the targetMessageId was send from this user.
null,
encryptedContent,
);
if (pushData != null) {
final pushUsers = await getPushKeys(SecureStorageKeys.receivingPushKeys);
for (final pushUser in pushUsers) {
if (pushUser.userId.toInt() == fromUserId) {
String? groupId;
if (encryptedContent.hasGroupId()) {
groupId = encryptedContent.groupId;
}
return showLocalPushNotification(pushUser, pushData, groupId: groupId);
}
}
}
}
Future<void> handlePushData(String pushDataB64) async {
try {
final pushData =
EncryptedPushNotification.fromBuffer(base64.decode(pushDataB64));
final pushData = EncryptedPushNotification.fromBuffer(
base64.decode(pushDataB64),
);
PushNotification? pushNotification;
PushUser? foundPushUser;
@ -121,8 +147,10 @@ Future<PushNotification?> tryDecryptMessage(
mac: Mac(push.mac),
);
final plaintext =
await chacha20.decrypt(secretBox, secretKey: secretKeyData);
final plaintext = await chacha20.decrypt(
secretBox,
secretKey: secretKeyData,
);
return PushNotification.fromBuffer(plaintext);
} catch (e) {
// this error is allowed to happen...
@ -132,8 +160,9 @@ Future<PushNotification?> tryDecryptMessage(
Future<void> showLocalPushNotification(
PushUser pushUser,
PushNotification pushNotification,
) async {
PushNotification pushNotification, {
String? groupId,
}) async {
String? title;
String? body;
@ -174,13 +203,26 @@ Future<void> showLocalPushNotification(
iOS: darwinNotificationDetails,
);
String? payload;
if (groupId != null &&
(pushNotification.kind == PushKind.text ||
pushNotification.kind == PushKind.response ||
pushNotification.kind == PushKind.reactionToAudio ||
pushNotification.kind == PushKind.storedMediaFile ||
pushNotification.kind == PushKind.reactionToImage ||
pushNotification.kind == PushKind.reactionToText ||
pushNotification.kind == PushKind.reactionToAudio)) {
payload = Routes.chatsMessages(groupId);
}
await flutterLocalNotificationsPlugin.show(
pushUser.userId.toInt() %
2147483647, // Invalid argument (id): must fit within the size of a 32-bit integer
// Invalid argument (id): must fit within the size of a 32-bit integer
pushUser.userId.toInt() % 2147483647,
title,
body,
notificationDetails,
// payload: pushNotification.kind.name,
payload: payload,
);
}
@ -259,17 +301,22 @@ String getPushNotificationText(PushNotification pushNotification) {
PushKind.storedMediaFile.name: lang.notificationStoredMediaFile,
PushKind.reaction.name: lang.notificationReaction,
PushKind.reopenedMedia.name: lang.notificationReopenedMedia,
PushKind.reactionToVideo.name:
lang.notificationReactionToVideo(pushNotification.additionalContent),
PushKind.reactionToAudio.name:
lang.notificationReactionToAudio(pushNotification.additionalContent),
PushKind.reactionToText.name:
lang.notificationReactionToText(pushNotification.additionalContent),
PushKind.reactionToImage.name:
lang.notificationReactionToImage(pushNotification.additionalContent),
PushKind.reactionToVideo.name: lang.notificationReactionToVideo(
pushNotification.additionalContent,
),
PushKind.reactionToAudio.name: lang.notificationReactionToAudio(
pushNotification.additionalContent,
),
PushKind.reactionToText.name: lang.notificationReactionToText(
pushNotification.additionalContent,
),
PushKind.reactionToImage.name: lang.notificationReactionToImage(
pushNotification.additionalContent,
),
PushKind.response.name: lang.notificationResponse(inGroup),
PushKind.addedToGroup.name:
lang.notificationAddedToGroup(pushNotification.additionalContent),
PushKind.addedToGroup.name: lang.notificationAddedToGroup(
pushNotification.additionalContent,
),
};
return pushNotificationText[pushNotification.kind.name] ?? '';

View file

@ -3,6 +3,7 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:firebase_app_installations/firebase_app_installations.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -41,22 +42,31 @@ Future<void> checkForTokenUpdates() async {
Log.error('Could not get fcm token');
return;
}
Log.info('Loaded fcm token');
Log.info('Loaded FCM token.');
if (storedToken == null || fcmToken != storedToken) {
Log.info('Got new FCM TOKEN.');
await storage.write(key: SecureStorageKeys.googleFcm, value: fcmToken);
await updateUserdata((u) {
u.updateFCMToken = true;
return u;
});
await storage.write(key: SecureStorageKeys.googleFcm, value: fcmToken);
}
FirebaseMessaging.instance.onTokenRefresh.listen((fcmToken) async {
FirebaseMessaging.instance.onTokenRefresh
.listen((fcmToken) async {
Log.info('Got new FCM TOKEN.');
await storage.write(
key: SecureStorageKeys.googleFcm,
value: fcmToken,
);
await updateUserdata((u) {
u.updateFCMToken = true;
return u;
});
await storage.write(key: SecureStorageKeys.googleFcm, value: fcmToken);
}).onError((err) {
})
.onError((err) {
Log.error('could not listen on token refresh');
});
} catch (e) {
@ -64,21 +74,35 @@ Future<void> checkForTokenUpdates() async {
}
}
Future<void> initFCMAfterAuthenticated() async {
if (gUser.updateFCMToken) {
Future<void> initFCMAfterAuthenticated({bool force = false}) async {
if (gUser.updateFCMToken || force) {
const storage = FlutterSecureStorage();
final storedToken = await storage.read(key: SecureStorageKeys.googleFcm);
if (storedToken != null) {
final res = await apiService.updateFCMToken(storedToken);
if (res.isSuccess) {
Log.info('Uploaded new fmt token!');
Log.info('Uploaded new FCM token!');
await updateUserdata((u) {
u.updateFCMToken = false;
return u;
});
} else {
Log.error('Could not update FCM token!');
}
} else {
Log.error('Could not send FCM update to server as token is empty.');
}
}
}
}
Future<void> resetFCMTokens() async {
await FirebaseInstallations.instance.delete();
Log.info('Firebase Installation successfully deleted.');
await FirebaseMessaging.instance.deleteToken();
Log.info('Old FCM deleted.');
await const FlutterSecureStorage().delete(key: SecureStorageKeys.googleFcm);
await checkForTokenUpdates();
await initFCMAfterAuthenticated(force: true);
}
Future<void> initFCMService() async {
@ -86,36 +110,24 @@ Future<void> initFCMService() async {
options: DefaultFirebaseOptions.currentPlatform,
);
unawaited(checkForTokenUpdates());
await checkForTokenUpdates();
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
// You may set the permission requests to "provisional" which allows the user to choose what type
// of notifications they would like to receive once the user receives a notification.
// final notificationSettings =
// await FirebaseMessaging.instance.requestPermission(provisional: true);
await FirebaseMessaging.instance.requestPermission();
// For apple platforms, ensure the APNS token is available before making any FCM plugin API calls
// if (Platform.isIOS) {
// final apnsToken = await FirebaseMessaging.instance.getAPNSToken();
// if (apnsToken == null) {
// return;
// }
// }
FirebaseMessaging.onMessage.listen(handleRemoteMessage);
}
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
initLogger();
// Log.info('Handling a background message: ${message.messageId}');
final isInitialized = await initBackgroundExecution();
Log.info('Handling a background message: ${message.messageId}');
await handleRemoteMessage(message);
if (Platform.isAndroid) {
if (await initBackgroundExecution()) {
await handlePeriodicTask();
if (isInitialized) {
await handlePeriodicTask(lastExecutionInSecondsLimit: 10);
}
} else {
// make sure every thing run...
@ -140,7 +152,11 @@ Future<void> handleRemoteMessage(RemoteMessage message) async {
final body =
message.notification?.body ?? message.data['body'] as String? ?? '';
await customLocalPushNotification(title, body);
} else if (message.data['push_data'] != null) {
await handlePushData(message.data['push_data'] as String);
}
// 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);
// }
}

View file

@ -49,13 +49,15 @@ Future<void> setupNotificationWithUsers({
final contacts = await twonlyDB.contactsDao.getAllContacts();
for (final contact in contacts) {
final pushUser =
pushUsers.firstWhereOrNull((x) => x.userId == contact.userId);
final pushUser = pushUsers.firstWhereOrNull(
(x) => x.userId == contact.userId,
);
if (pushUser != null && pushUser.pushKeys.isNotEmpty) {
// make it harder to predict the change of the key
final timeBefore =
clock.now().subtract(Duration(days: 10 + random.nextInt(5)));
final timeBefore = clock.now().subtract(
Duration(days: 10 + random.nextInt(5)),
);
final lastKey = pushUser.pushKeys.last;
final createdAt = DateTime.fromMillisecondsSinceEpoch(
lastKey.createdAtUnixTimestamp.toInt(),
@ -197,7 +199,7 @@ Future<void> updateLastMessageId(int fromUserId, String messageId) async {
}
Future<PushNotification?> getPushNotificationFromEncryptedContent(
int toUserId,
int? toUserId,
String? messageId,
EncryptedContent content,
) async {
@ -210,7 +212,7 @@ Future<PushNotification?> getPushNotificationFromEncryptedContent(
final msg = await twonlyDB.messagesDao
.getMessageById(content.reaction.targetMessageId)
.getSingleOrNull();
if (msg == null || msg.senderId == null || msg.senderId != toUserId) {
if (msg == null || msg.senderId != toUserId) {
return null;
}
if (msg.content != null) {
@ -285,7 +287,7 @@ Future<PushNotification?> getPushNotificationFromEncryptedContent(
.getMessageById(content.reaction.targetMessageId)
.getSingleOrNull();
// These notifications should only be send to the original sender.
if (msg == null || msg.senderId == null || msg.senderId != toUserId) {
if (msg == null || msg.senderId != toUserId) {
return null;
}
switch (content.mediaUpdate.type) {

View file

@ -8,8 +8,11 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/exclusive_access.dart';
bool _isInitialized = false;
void initLogger() {
// Logger.root.level = kReleaseMode ? Level.INFO : Level.ALL;
if (_isInitialized) return;
_isInitialized = true;
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) async {
unawaited(_writeLogToFile(record));
@ -126,17 +129,34 @@ Future<void> cleanLogFile() async {
return _protectFileAccess(() async {
final logFile = File('$globalApplicationSupportDirectory/app.log');
if (logFile.existsSync()) {
if (!logFile.existsSync()) {
return;
}
final lines = await logFile.readAsLines();
if (lines.length <= 10000) return;
final twoWeekAgo = clock.now().subtract(const Duration(days: 14));
var keepStartIndex = -1;
final removeCount = lines.length - 10000;
final remaining = lines.sublist(removeCount, lines.length);
for (var i = 0; i < lines.length; i += 100) {
if (lines[i].length >= 19) {
final date = DateTime.tryParse(lines[i].substring(0, 19));
if (date != null && date.isAfter(twoWeekAgo)) {
keepStartIndex = i;
break;
}
}
}
if (keepStartIndex == 0) return;
if (keepStartIndex == -1) {
await logFile.writeAsString('');
return;
}
final remaining = lines.sublist(keepStartIndex);
final sink = logFile.openWrite()..writeAll(remaining, '\n');
await sink.close();
}
});
}

View file

@ -181,8 +181,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
// Maybe this is the reason?
return;
} else {
androidVolumeDownSub =
FlutterAndroidVolumeKeydown.stream.listen((event) {
androidVolumeDownSub = FlutterAndroidVolumeKeydown.stream.listen((
event,
) {
if (widget.isVisible) {
takePicture();
} else {
@ -297,8 +298,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
return;
}
final image = await mc.screenshotController
.capture(pixelRatio: MediaQuery.of(context).devicePixelRatio);
final image = await mc.screenshotController.capture(
pixelRatio: MediaQuery.of(context).devicePixelRatio,
);
if (await pushMediaEditor(image, null)) {
return;
@ -314,7 +316,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
bool sharedFromGallery = false,
MediaType? mediaType,
}) async {
final type = mediaType ??
final type =
mediaType ??
((videoFilePath != null) ? MediaType.video : MediaType.image);
final mediaFileService = await initializeMediaUpload(
type,
@ -340,7 +343,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
await deInitVolumeControl();
if (!mounted) return true;
final shouldReturn = await Navigator.push(
final shouldReturn =
await Navigator.push(
context,
PageRouteBuilder(
opaque: false,
@ -352,13 +356,15 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
mainCameraController: mc,
previewLink: mc.sharedLinkForPreview,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return child;
},
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
),
) as bool?;
)
as bool?;
if (mounted) {
setState(() {
mc.isSharePreviewIsShown = false;
@ -396,13 +402,15 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
return;
}
mc.selectedCameraDetails.scaleFactor = (_baseScaleFactor +
mc.selectedCameraDetails.scaleFactor =
(_baseScaleFactor +
// ignore: avoid_dynamic_calls
(_basePanY - (details.localPosition.dy as double)) / 30)
.clamp(1, mc.selectedCameraDetails.maxAvailableZoom);
await mc.cameraController!
.setZoomLevel(mc.selectedCameraDetails.scaleFactor);
await mc.cameraController!.setZoomLevel(
mc.selectedCameraDetails.scaleFactor,
);
if (mounted) {
setState(() {});
}
@ -434,8 +442,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
ScreenshotImage? image;
MediaType? mediaType;
final isImage =
imageExtensions.any((ext) => pickedFile.name.contains(ext));
final isImage = imageExtensions.any(
(ext) => pickedFile.name.contains(ext),
);
if (isImage) {
if (pickedFile.name.contains('.gif')) {
mediaType = MediaType.gif;
@ -497,10 +506,15 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
mc.isVideoRecording = true;
});
if (mc.selectedCameraDetails.isFlashOn) {
await mc.cameraController?.setFlashMode(FlashMode.torch);
}
try {
await mc.cameraController?.startVideoRecording();
_videoRecordingTimer =
Timer.periodic(const Duration(milliseconds: 15), (timer) {
_videoRecordingTimer = Timer.periodic(const Duration(milliseconds: 15), (
timer,
) {
setState(() {
_currentTime = clock.now();
});
@ -521,6 +535,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
mc.isVideoRecording = false;
});
_showCameraException(e);
await mc.cameraController?.setFlashMode(FlashMode.off);
return;
}
}
@ -531,6 +546,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
_videoRecordingTimer = null;
}
await mc.cameraController?.setFlashMode(FlashMode.off);
setState(() {
_videoRecordingStarted = null;
mc.isVideoRecording = false;
@ -601,8 +618,12 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
keyTriggerButton.currentContext!.findRenderObject()! as RenderBox;
final localPosition = renderBox.globalToLocal(details.globalPosition);
final containerRect =
Rect.fromLTWH(0, 0, renderBox.size.width, renderBox.size.height);
final containerRect = Rect.fromLTWH(
0,
0,
renderBox.size.width,
renderBox.size.height,
);
if (containerRect.contains(localPosition)) {
startVideoRecording();
@ -676,12 +697,14 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
: Colors.white.withAlpha(160),
onPressed: () async {
if (mc.selectedCameraDetails.isFlashOn) {
await mc.cameraController
?.setFlashMode(FlashMode.off);
await mc.cameraController?.setFlashMode(
FlashMode.off,
);
mc.selectedCameraDetails.isFlashOn = false;
} else {
await mc.cameraController
?.setFlashMode(FlashMode.always);
await mc.cameraController?.setFlashMode(
FlashMode.always,
);
mc.selectedCameraDetails.isFlashOn = true;
}
setState(() {});
@ -786,7 +809,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
mc.isSelectingFaceFilters
? mc.currentFilterType.index ==
FaceFilterType
.values.length -
.values
.length -
1
? FontAwesomeIcons.xmark
: FontAwesomeIcons.arrowRight
@ -936,10 +960,13 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
: 'assets/animations/failed.lottie',
repeat: false,
onLoaded: (p0) {
Future.delayed(const Duration(seconds: 4),
Future.delayed(
const Duration(seconds: 4),
() {
widget.mainCameraController.setState();
});
widget.mainCameraController
.setState();
},
);
},
),
),

View file

@ -4,6 +4,7 @@ import 'package:camera/camera.dart';
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';
@ -133,6 +134,11 @@ class MainCameraController {
await cameraController?.initialize();
await cameraController?.startImageStream(_processCameraImage);
await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor);
if (gUser.videoStabilizationEnabled && !kDebugMode) {
await cameraController?.setVideoStabilizationMode(
VideoStabilizationMode.level1,
);
}
} else {
try {
if (!isVideoRecording) {

View file

@ -109,13 +109,21 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
sendingOrLoadingImage = false;
loadingImage = false;
});
videoController = VideoPlayerController.file(mediaService.originalPath);
videoController = VideoPlayerController.file(
mediaService.originalPath,
videoPlayerOptions: VideoPlayerOptions(
mixWithOthers: true,
),
);
videoController?.setLooping(true);
videoController?.initialize().then((_) async {
videoController
?.initialize()
.then((_) async {
await videoController!.play();
setState(() {});
// ignore: invalid_return_type_for_catch_error, argument_type_not_assignable_to_error_handler
}).catchError(Log.error);
})
// ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error
.catchError(Log.error);
}
}
@ -205,8 +213,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
List<Widget> get actionsAtTheRight {
if (layers.isNotEmpty &&
layers.last.isEditing &&
layers.last.hasCustomActionButtons) {
(layers.first.isEditing ||
(layers.last.isEditing && layers.last.hasCustomActionButtons))) {
return [];
}
return <Widget>[
@ -246,13 +254,15 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
Icons.add_reaction_outlined,
tooltipText: context.lang.addEmoji,
onPressed: () async {
final layer = await showModalBottomSheet(
final layer =
await showModalBottomSheet(
context: context,
backgroundColor: Colors.black,
builder: (context) {
return const EmojiPickerBottom();
},
) as Layer?;
)
as Layer?;
if (layer == null) return;
undoLayers.clear();
removedLayers.clear();
@ -277,7 +287,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
onPressed: _setImageDisplayTime,
),
),
if (media.type == MediaType.video)
if (media.type == MediaType.video) ...[
const SizedBox(height: 8),
ActionButton(
(mediaService.removeAudio)
? Icons.volume_off_rounded
@ -296,6 +307,29 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (mounted) setState(() {});
},
),
],
if (media.type == MediaType.image) ...[
const SizedBox(height: 8),
ActionButton(
Icons.crop_rotate_outlined,
tooltipText: 'Crop or rotate image',
color: Colors.white,
onPressed: () async {
final first = layers.first;
if (first is BackgroundLayerData) {
first.isEditing = !first.isEditing;
}
setState(() {});
// await mediaService.toggleRemoveAudio();
// if (mediaService.removeAudio) {
// await videoController?.setVolume(0);
// } else {
// await videoController?.setVolume(100);
// }
// if (mounted) setState(() {});
},
),
],
const SizedBox(height: 8),
ActionButton(
FontAwesomeIcons.shieldHeart,
@ -348,8 +382,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
List<Widget> get actionsAtTheTop {
if (layers.isNotEmpty &&
layers.last.isEditing &&
layers.last.hasCustomActionButtons) {
(layers.first.isEditing ||
(layers.last.isEditing && layers.last.hasCustomActionButtons))) {
return [];
}
return [
@ -411,7 +445,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await videoController?.pause();
if (isDisposed || !mounted) return;
final wasSend = await Navigator.push(
final wasSend =
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ShareImageView(
@ -422,7 +457,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
additionalData: getAdditionalData(),
),
),
) as bool?;
)
as bool?;
if (wasSend != null && wasSend && mounted) {
widget.mainCameraController?.onImageSend();
Navigator.pop(context, true);
@ -471,7 +507,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
mediaService.tempPath.deleteSync();
}
if (mediaService.originalPath.existsSync()) {
if (media.type != MediaType.video) {
if (media.type == MediaType.image) {
mediaService.originalPath.deleteSync();
}
}
@ -480,8 +516,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
if (media.type == MediaType.gif) {
if (bytes != null) {
mediaService.originalPath.writeAsBytesSync(bytes.toList());
} else {
Log.error('Could not load image bytes for gif!');
}
} else {
image = await getEditedImageBytes();
@ -552,8 +586,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
});
// It is important that the user can sending the image only when the image is fully loaded otherwise if the user
// will click on send before the image is painted the screenshot will be transparent..
_imageLoadingTimer =
Timer.periodic(const Duration(milliseconds: 10), (timer) {
_imageLoadingTimer = Timer.periodic(const Duration(milliseconds: 10), (
timer,
) {
final imageLayer = layers.first;
if (imageLayer is BackgroundLayerData) {
if (imageLayer.imageLoaded) {
@ -619,8 +654,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await askToCloseThenClose();
},
child: Scaffold(
backgroundColor:
widget.sharedFromGallery ? null : Colors.white.withAlpha(0),
backgroundColor: widget.sharedFromGallery
? null
: Colors.white.withAlpha(0),
resizeToAvoidBottomInset: false,
body: Stack(
fit: StackFit.expand,
@ -667,8 +703,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
OutlinedButton(
style: OutlinedButton.styleFrom(
iconColor: Theme.of(context).colorScheme.primary,
foregroundColor:
Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.primary,
),
onPressed: pushShareImageView,
child: const FaIcon(FontAwesomeIcons.userPlus),
@ -681,9 +718,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
width: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context)
.colorScheme
.inversePrimary,
color: Theme.of(
context,
).colorScheme.inversePrimary,
),
)
: const FaIcon(FontAwesomeIcons.solidPaperPlane),

View file

@ -1,6 +1,10 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:photo_view/photo_view.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
class BackgroundLayer extends StatefulWidget {
@ -29,7 +33,17 @@ class _BackgroundLayerState extends State<BackgroundLayer> {
Widget build(BuildContext context) {
final scImage = widget.layerData.image.image;
if (scImage == null || scImage.image == null) return Container();
return Container(
return Stack(
children: [
Positioned.fill(
child: PhotoView.customChild(
enableRotation: true,
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
child: Container(
width: widget.layerData.image.width.toDouble(),
height: widget.layerData.image.height.toDouble(),
padding: EdgeInsets.zero,
@ -37,6 +51,29 @@ class _BackgroundLayerState extends State<BackgroundLayer> {
child: CustomPaint(
painter: UiImagePainter(scImage.image!),
),
),
),
),
if (widget.layerData.isEditing)
Positioned(
top: 5,
left: 5,
right: 50,
child: Row(
children: [
ActionButton(
FontAwesomeIcons.check,
tooltipText: context.lang.imageEditorDrawOk,
onPressed: () async {
widget.layerData.isEditing = false;
widget.onUpdate!();
setState(() {});
},
),
],
),
),
],
);
}
}

View file

@ -23,11 +23,15 @@ class LayersViewer extends StatelessWidget {
alignment: Alignment.center,
children: [
...layers.whereType<BackgroundLayerData>().map((layerItem) {
if (!layerItem.isEditing) {
return BackgroundLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else {
return Container();
}
}),
...layers.whereType<FilterLayerData>().map((layerItem) {
return FilterLayer(
@ -71,6 +75,17 @@ class LayersViewer extends StatelessWidget {
}
return Container();
}),
...layers.whereType<BackgroundLayerData>().map((layerItem) {
if (layerItem.isEditing) {
return BackgroundLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else {
return Container();
}
}),
],
);
}

View file

@ -17,6 +17,7 @@ import 'package:twonly/src/views/chats/chat_messages_components/message_send_sta
import 'package:twonly/src/views/components/avatar_icon.component.dart';
import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/group_context_menu.component.dart';
import 'package:twonly/src/views/components/verified_shield.dart';
class GroupListItem extends StatefulWidget {
const GroupListItem({
@ -44,6 +45,7 @@ class _UserListItem extends State<GroupListItem> {
List<Message> _previewMessages = [];
final List<MediaFile> _previewMediaFiles = [];
bool _hasNonOpenedMediaFile = false;
bool _receiverDeletedAccount = false;
@override
void initState() {
@ -60,7 +62,7 @@ class _UserListItem extends State<GroupListItem> {
super.dispose();
}
void initStreams() {
Future<void> initStreams() async {
_lastMessageStream = twonlyDB.messagesDao
.watchLastMessage(widget.group.groupId)
.listen((update) {
@ -75,9 +77,6 @@ class _UserListItem extends State<GroupListItem> {
setState(() {
_lastReaction = update;
});
// protectUpdateState.protect(() async {
// await updateState(lastMessage, update, messagesNotOpened);
// });
});
_messagesNotOpenedStream = twonlyDB.messagesDao
@ -88,17 +87,26 @@ class _UserListItem extends State<GroupListItem> {
});
});
_lastMediaFilesStream =
twonlyDB.mediaFilesDao.watchNewestMediaFiles().listen((mediaFiles) {
_lastMediaFilesStream = twonlyDB.mediaFilesDao
.watchNewestMediaFiles()
.listen((mediaFiles) {
for (final mediaFile in mediaFiles) {
final index = _previewMediaFiles
.indexWhere((t) => t.mediaId == mediaFile.mediaId);
final index = _previewMediaFiles.indexWhere(
(t) => t.mediaId == mediaFile.mediaId,
);
if (index >= 0) {
_previewMediaFiles[index] = mediaFile;
}
}
setState(() {});
});
final groupContacts = await twonlyDB.groupsDao.getGroupContact(
widget.group.groupId,
);
if (groupContacts.length == 1) {
_receiverDeletedAccount = groupContacts.first.accountDeleted;
}
}
Mutex protectUpdateState = Mutex();
@ -113,8 +121,9 @@ class _UserListItem extends State<GroupListItem> {
_previewMessages = [];
} else if (newMessagesNotOpened.isNotEmpty) {
// Filter for the preview non opened messages. First messages which where send but not yet opened by the other side.
final receivedMessages =
newMessagesNotOpened.where((x) => x.senderId != null).toList();
final receivedMessages = newMessagesNotOpened
.where((x) => x.senderId != null)
.toList();
if (receivedMessages.isNotEmpty) {
_previewMessages = receivedMessages;
@ -125,8 +134,17 @@ class _UserListItem extends State<GroupListItem> {
}
} else {
// there are no not opened messages show just the last message in the table
// only shows the last message in case there was no newer messages which already got deleted
// This prevents, that it will show that a images got stored 10 days ago...
if (newLastMessage.createdAt.isAfter(
widget.group.lastMessageExchange.subtract(const Duration(days: 2)),
)) {
_currentMessage = newLastMessage;
_previewMessages = [newLastMessage];
} else {
_currentMessage = null;
_previewMessages = [];
}
}
final msgs = _previewMessages
@ -145,8 +163,9 @@ class _UserListItem extends State<GroupListItem> {
for (final message in _previewMessages) {
if (message.mediaId != null &&
!_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) {
final mediaFile =
await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
message.mediaId!,
);
if (mediaFile != null) {
_previewMediaFiles.add(mediaFile);
}
@ -171,8 +190,9 @@ class _UserListItem extends State<GroupListItem> {
final msgs = _previewMessages
.where((x) => x.type == MessageType.media.name)
.toList();
final mediaFile =
await twonlyDB.mediaFilesDao.getMediaFileById(msgs.first.mediaId!);
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
msgs.first.mediaId!,
);
if (mediaFile?.type != MediaType.audio) {
if (mediaFile?.downloadState == null) return;
if (mediaFile!.downloadState! == DownloadState.pending) {
@ -190,10 +210,7 @@ class _UserListItem extends State<GroupListItem> {
}
}
if (!mounted) return;
await context.push(
Routes.chatsMessages,
extra: widget.group,
);
await context.push(Routes.chatsMessages(widget.group.groupId));
}
@override
@ -201,10 +218,22 @@ class _UserListItem extends State<GroupListItem> {
return GroupContextMenu(
group: widget.group,
child: ListTile(
title: Text(
title: Row(
children: [
Text(
substringBy(widget.group.groupName, 30),
),
subtitle: (_currentMessage == null)
const SizedBox(width: 3),
VerifiedShield(
group: widget.group,
showOnlyIfVerified: true,
size: 12,
),
],
),
subtitle: _receiverDeletedAccount
? Text(context.lang.userDeletedAccount)
: (_currentMessage == null)
? (widget.group.totalMediaCounter == 0)
? Text(context.lang.chatsTapToSend)
: Row(
@ -239,8 +268,9 @@ class _UserListItem extends State<GroupListItem> {
leading: GestureDetector(
onTap: () async {
if (widget.group.isDirectChat) {
final contacts = await twonlyDB.groupsDao
.getGroupContact(widget.group.groupId);
final contacts = await twonlyDB.groupsDao.getGroupContact(
widget.group.groupId,
);
if (!context.mounted) return;
await context.push(Routes.profileContact(contacts.first.userId));
return;
@ -250,15 +280,19 @@ class _UserListItem extends State<GroupListItem> {
},
child: AvatarIcon(group: widget.group),
),
trailing: (widget.group.leftGroup)
trailing: (widget.group.leftGroup || _receiverDeletedAccount)
? null
: IconButton(
onPressed: () => context.push(
_hasNonOpenedMediaFile
? Routes.chatsMessages
: Routes.chatsCameraSendTo,
onPressed: () {
if (_hasNonOpenedMediaFile) {
context.push(Routes.chatsMessages(widget.group.groupId));
} else {
context.push(
Routes.chatsCameraSendTo,
extra: widget.group,
),
);
}
},
icon: FaIcon(
_hasNonOpenedMediaFile
? FontAwesomeIcons.solidComments

View file

@ -13,6 +13,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_group_action.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_date_chip.dart';
@ -23,40 +24,11 @@ import 'package:twonly/src/views/components/blink.component.dart';
import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/verified_shield.dart';
Color getMessageColor(Message message) {
return (message.senderId == null)
? const Color.fromARGB(255, 58, 136, 102)
: const Color.fromARGB(233, 68, 137, 255);
}
class ChatItem {
const ChatItem._({
this.message,
this.date,
this.groupAction,
});
factory ChatItem.date(DateTime date) {
return ChatItem._(date: date);
}
factory ChatItem.message(Message message) {
return ChatItem._(message: message);
}
factory ChatItem.groupAction(GroupHistory groupAction) {
return ChatItem._(groupAction: groupAction);
}
final GroupHistory? groupAction;
final Message? message;
final DateTime? date;
bool get isMessage => message != null;
bool get isDate => date != null;
bool get isGroupAction => groupAction != null;
}
/// Displays detailed information about a SampleItem.
class ChatMessagesView extends StatefulWidget {
const ChatMessagesView(this.group, {super.key});
const ChatMessagesView(this.groupId, {super.key});
final Group group;
final String groupId;
@override
State<ChatMessagesView> createState() => _ChatMessagesViewState();
@ -64,12 +36,13 @@ class ChatMessagesView extends StatefulWidget {
class _ChatMessagesViewState extends State<ChatMessagesView> {
HashSet<int> alreadyReportedOpened = HashSet<int>();
late Group group;
late StreamSubscription<Group?> userSub;
late StreamSubscription<List<Message>> messageSub;
StreamSubscription<List<GroupHistory>>? groupActionsSub;
StreamSubscription<List<Contact>>? contactSub;
Group? _group;
Map<int, Contact> userIdToContact = {};
List<ChatItem> messages = [];
@ -81,11 +54,11 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
late FocusNode textFieldFocus;
final ItemScrollController itemScrollController = ItemScrollController();
int? focusedScrollItem;
bool _receiverDeletedAccount = false;
@override
void initState() {
super.initState();
group = widget.group;
textFieldFocus = FocusNode();
initStreams();
}
@ -102,16 +75,18 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
Mutex protectMessageUpdating = Mutex();
Future<void> initStreams() async {
final groupStream = twonlyDB.groupsDao.watchGroup(group.groupId);
final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId);
userSub = groupStream.listen((newGroup) {
if (newGroup == null) return;
setState(() {
group = newGroup;
});
_group = newGroup;
});
if (!widget.group.isDirectChat) {
final actionsStream = twonlyDB.groupsDao.watchGroupActions(group.groupId);
protectMessageUpdating.protect(() async {
if (groupActionsSub == null && !newGroup.isDirectChat) {
final actionsStream = twonlyDB.groupsDao.watchGroupActions(
newGroup.groupId,
);
groupActionsSub = actionsStream.listen((update) async {
groupActions = update;
await setMessages(allMessages, update);
@ -124,14 +99,23 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
}
});
}
});
});
final msgStream = twonlyDB.messagesDao.watchByGroupId(group.groupId);
final msgStream = twonlyDB.messagesDao.watchByGroupId(widget.groupId);
messageSub = msgStream.listen((update) async {
allMessages = update;
await protectMessageUpdating.protect(() async {
await setMessages(update, groupActions);
});
});
final groupContacts = await twonlyDB.groupsDao.getGroupContact(
widget.groupId,
);
if (groupContacts.length == 1) {
_receiverDeletedAccount = groupContacts.first.accountDeleted;
}
}
Future<void> setMessages(
@ -153,8 +137,9 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
if (groupHistoryIndex < groupActions.length) {
for (; groupHistoryIndex < groupActions.length; groupHistoryIndex++) {
if (msg.createdAt.isAfter(groupActions[groupHistoryIndex].actionAt)) {
chatItems
.add(ChatItem.groupAction(groupActions[groupHistoryIndex]));
chatItems.add(
ChatItem.groupAction(groupActions[groupHistoryIndex]),
);
// groupHistoryIndex++;
} else {
break;
@ -230,6 +215,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
@override
Widget build(BuildContext context) {
if (_group == null) return Container();
final group = _group!;
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
@ -237,12 +224,14 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
title: GestureDetector(
onTap: () async {
if (group.isDirectChat) {
final member =
await twonlyDB.groupsDao.getAllGroupMembers(group.groupId);
final member = await twonlyDB.groupsDao.getAllGroupMembers(
group.groupId,
);
if (!context.mounted) return;
if (member.isEmpty) return;
await context
.push(Routes.profileContact(member.first.contactId));
await context.push(
Routes.profileContact(member.first.contactId),
);
} else {
await context.push(Routes.profileGroup(group.groupId));
}
@ -354,7 +343,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
],
),
),
if (!group.leftGroup)
if (!group.leftGroup && !_receiverDeletedAccount)
MessageInput(
group: group,
quotesMessage: quotesMessage,
@ -365,6 +354,8 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
});
},
),
if (_receiverDeletedAccount)
Text(context.lang.userDeletedAccount),
],
),
),
@ -372,3 +363,32 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
);
}
}
Color getMessageColor(Message message) {
return (message.senderId == null)
? const Color.fromARGB(255, 58, 136, 102)
: const Color.fromARGB(233, 68, 137, 255);
}
class ChatItem {
const ChatItem._({
this.message,
this.date,
this.groupAction,
});
factory ChatItem.date(DateTime date) {
return ChatItem._(date: date);
}
factory ChatItem.message(Message message) {
return ChatItem._(message: message);
}
factory ChatItem.groupAction(GroupHistory groupAction) {
return ChatItem._(groupAction: groupAction);
}
final GroupHistory? groupAction;
final Message? message;
final DateTime? date;
bool get isMessage => message != null;
bool get isDate => date != null;
bool get isGroupAction => groupAction != null;
}

View file

@ -102,8 +102,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
}
Future<void> asyncLoadNextMedia(bool firstRun) async {
final messages =
twonlyDB.messagesDao.watchMediaNotOpened(widget.group.groupId);
final messages = twonlyDB.messagesDao.watchMediaNotOpened(
widget.group.groupId,
);
_subscription = messages.listen((messages) async {
for (final msg in messages) {
@ -121,8 +122,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
/// If the messages was already there just replace it and go to the next...
final index =
allMediaFiles.indexWhere((m) => m.messageId == msg.messageId);
final index = allMediaFiles.indexWhere(
(m) => m.messageId == msg.messageId,
);
if (index >= 1) {
allMediaFiles[index] = msg;
@ -160,7 +162,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (group != null &&
group.draftMessage != null &&
group.draftMessage != '') {
context.replace(Routes.chatsMessages, extra: group);
context.replace(Routes.chatsMessages(group.groupId));
} else {
Navigator.pop(context);
}
@ -190,8 +192,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
unawaited(flutterLocalNotificationsPlugin.cancelAll());
final stream =
twonlyDB.mediaFilesDao.watchMedia(allMediaFiles.first.mediaId!);
final stream = twonlyDB.mediaFilesDao.watchMedia(
allMediaFiles.first.mediaId!,
);
var downloadTriggered = false;
@ -204,8 +207,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
});
if (!downloadTriggered) {
downloadTriggered = true;
final mediaFile = await twonlyDB.mediaFilesDao
.getMediaFileById(allMediaFiles.first.mediaId!);
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
allMediaFiles.first.mediaId!,
);
if (mediaFile == null) return;
await startDownloadMedia(mediaFile, true);
unawaited(tryDownloadAllMediaFiles(force: true));
@ -226,8 +230,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
setState(() {
_showDownloadingLoader = false;
});
final currentMediaLocal =
await MediaFileService.fromMediaId(allMediaFiles.first.mediaId!);
final currentMediaLocal = await MediaFileService.fromMediaId(
allMediaFiles.first.mediaId!,
);
if (currentMediaLocal == null || !mounted) return;
if (currentMediaLocal.mediaFile.requiresAuthentication) {
@ -259,8 +264,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
});
if (!widget.group.isDirectChat) {
final sender =
await twonlyDB.contactsDao.getContactById(currentMessage!.senderId!);
final sender = await twonlyDB.contactsDao.getContactById(
currentMessage!.senderId!,
);
if (sender != null) {
_currentMediaSender =
'${getContactDisplayName(sender)} (${widget.group.groupName})';
@ -281,28 +287,41 @@ class _MediaViewerViewState extends State<MediaViewerView> {
var timerRequired = false;
if (currentMediaLocal.mediaFile.type == MediaType.video) {
videoController = VideoPlayerController.file(currentMediaLocal.tempPath);
videoController = VideoPlayerController.file(
currentMediaLocal.tempPath,
videoPlayerOptions: VideoPlayerOptions(
// only mix in case the video can be played multiple times,
// otherwise stop the background music in case the video contains audio
mixWithOthers:
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
),
);
await videoController?.setLooping(
currentMediaLocal.mediaFile.displayLimitInMilliseconds == null,
);
await videoController?.initialize().then((_) {
await videoController
?.initialize()
.then((_) {
if (videoController == null) return;
videoController?.play();
videoController?.addListener(() {
setState(() {
progress = 1 -
progress =
1 -
videoController!.value.position.inSeconds /
videoController!.value.duration.inSeconds;
});
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) {
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds !=
null) {
if (videoController?.value.position ==
videoController?.value.duration) {
nextMediaOrExit();
}
}
});
// ignore: invalid_return_type_for_catch_error, argument_type_not_assignable_to_error_handler
}).catchError(Log.error);
})
// ignore: argument_type_not_assignable_to_error_handler, invalid_return_type_for_catch_error
.catchError(Log.error);
} else {
if (currentMediaLocal.mediaFile.displayLimitInMilliseconds != null) {
canBeSeenUntil = clock.now().add(
@ -434,8 +453,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
height: 8,
child: Center(
child: EmojiAnimation(
emoji:
EmojiAnimation.animatedIcons.keys.toList()[index],
emoji: EmojiAnimation.animatedIcons.keys
.toList()[index],
),
),
);

View file

@ -35,8 +35,9 @@ class _StartNewChatView extends State<StartNewChatView> {
void initState() {
super.initState();
contactSub =
twonlyDB.contactsDao.watchAllAcceptedContacts().listen((update) async {
contactSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((
update,
) async {
update.sort(
(a, b) => getContactDisplayName(a).compareTo(getContactDisplayName(b)),
);
@ -46,8 +47,9 @@ class _StartNewChatView extends State<StartNewChatView> {
await filterUsers();
});
allNonDirectGroupsSub =
twonlyDB.groupsDao.watchGroupsForStartNewChat().listen((update) async {
allNonDirectGroupsSub = twonlyDB.groupsDao
.watchGroupsForStartNewChat()
.listen((update) async {
setState(() {
allNonDirectGroups = update;
});
@ -72,16 +74,16 @@ class _StartNewChatView extends State<StartNewChatView> {
}
final usersFiltered = allContacts
.where(
(user) => getContactDisplayName(user)
.toLowerCase()
.contains(searchUserName.value.text.toLowerCase()),
(user) => getContactDisplayName(
user,
).toLowerCase().contains(searchUserName.value.text.toLowerCase()),
)
.toList();
final groupsFiltered = allNonDirectGroups
.where(
(g) => g.groupName
.toLowerCase()
.contains(searchUserName.value.text.toLowerCase()),
(g) => g.groupName.toLowerCase().contains(
searchUserName.value.text.toLowerCase(),
),
)
.toList();
setState(() {
@ -108,7 +110,7 @@ class _StartNewChatView extends State<StartNewChatView> {
context,
MaterialPageRoute(
builder: (context) {
return ChatMessagesView(directChat!);
return ChatMessagesView(directChat!.groupId);
},
),
);
@ -119,7 +121,7 @@ class _StartNewChatView extends State<StartNewChatView> {
context,
MaterialPageRoute(
builder: (context) {
return ChatMessagesView(group);
return ChatMessagesView(group.groupId);
},
),
);
@ -133,8 +135,12 @@ class _StartNewChatView extends State<StartNewChatView> {
),
body: SafeArea(
child: Padding(
padding:
const EdgeInsets.only(bottom: 40, left: 10, top: 20, right: 10),
padding: const EdgeInsets.only(
bottom: 40,
left: 10,
top: 20,
right: 10,
),
child: Column(
children: [
Padding(
@ -167,8 +173,9 @@ class _StartNewChatView extends State<StartNewChatView> {
size: 13,
),
),
onTap: () => context
.push(Routes.groupCreateSelectMember(null)),
onTap: () => context.push(
Routes.groupCreateSelectMember(null),
),
);
}
if (i == 1) {

View file

@ -46,10 +46,7 @@ class GroupContextMenu extends StatelessWidget {
),
ContextMenuItem(
title: context.lang.contextMenuOpenChat,
onTap: () => context.push(
Routes.chatsMessages,
extra: group,
),
onTap: () => context.push(Routes.chatsMessages(group.groupId)),
icon: FontAwesomeIcons.comments,
),
if (!group.archived)

View file

@ -12,11 +12,14 @@ class VerifiedShield extends StatefulWidget {
this.group,
super.key,
this.size = 15,
this.showOnlyIfVerified = false,
});
final Group? group;
final Contact? contact;
final double size;
final bool showOnlyIfVerified;
@override
State<VerifiedShield> createState() => _VerifiedShieldState();
}
@ -56,19 +59,24 @@ class _VerifiedShieldState extends State<VerifiedShield> {
@override
Widget build(BuildContext context) {
if (!isVerified && widget.showOnlyIfVerified) return Container();
return GestureDetector(
onTap: (contact == null)
? null
: () => context.push(Routes.settingsPublicProfile),
child: Tooltip(
message: isVerified
? 'You verified this contact'
: 'You have not verifies this contact.',
: () => context.push(Routes.settingsHelpFaqVerifyBadge),
child: ColoredBox(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsetsGeometry.only(top: 2),
padding: const EdgeInsetsGeometry.only(
top: 4,
left: 3,
right: 3,
bottom: 3,
),
child: SvgIcon(
assetPath:
isVerified ? SvgIcons.verifiedGreen : SvgIcons.verifiedRed,
assetPath: isVerified
? SvgIcons.verifiedGreen
: SvgIcons.verifiedRed,
size: widget.size,
),
),

View file

@ -21,7 +21,12 @@ class _VideoPlayerWrapperState extends State<VideoPlayerWrapper> {
@override
void initState() {
super.initState();
_controller = VideoPlayerController.file(widget.videoPath);
_controller = VideoPlayerController.file(
widget.videoPath,
videoPlayerOptions: VideoPlayerOptions(
mixWithOthers: true,
),
);
unawaited(
_controller.initialize().then((_) async {

View file

@ -14,7 +14,6 @@ import 'package:twonly/src/views/components/better_list_title.dart';
import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/max_flame_list_title.dart';
import 'package:twonly/src/views/components/select_chat_deletion_time.comp.dart';
import 'package:twonly/src/views/components/svg_icon.dart';
import 'package:twonly/src/views/components/verified_shield.dart';
import 'package:twonly/src/views/groups/group.view.dart';
@ -36,8 +35,9 @@ class _ContactViewState extends State<ContactView> {
@override
void initState() {
_contactSub =
twonlyDB.contactsDao.watchContact(widget.userId).listen((update) {
_contactSub = twonlyDB.contactsDao.watchContact(widget.userId).listen((
update,
) {
setState(() {
_contact = update;
});
@ -81,8 +81,9 @@ class _ContactViewState extends State<ContactView> {
final remove = await showAlertDialog(
context,
context.lang
.contactRemoveTitle(getContactDisplayName(contact, maxLength: 20)),
context.lang.contactRemoveTitle(
getContactDisplayName(contact, maxLength: 20),
),
context.lang.contactRemoveBody,
);
if (remove) {
@ -177,13 +178,11 @@ class _ContactViewState extends State<ContactView> {
icon: FontAwesomeIcons.solidComments,
text: context.lang.contactViewMessage,
onTap: () async {
final group =
await twonlyDB.groupsDao.getDirectChat(contact.userId);
if (group != null && context.mounted) {
await context.push(
Routes.chatsMessages,
extra: group,
final group = await twonlyDB.groupsDao.getDirectChat(
contact.userId,
);
if (group != null && context.mounted) {
await context.push(Routes.chatsMessages(group.groupId));
}
},
),
@ -196,8 +195,10 @@ class _ContactViewState extends State<ContactView> {
if (context.mounted && nickName != null && nickName != '') {
final update = ContactsCompanion(nickName: Value(nickName));
await twonlyDB.contactsDao
.updateContact(contact.userId, update);
await twonlyDB.contactsDao.updateContact(
contact.userId,
update,
);
}
},
),
@ -208,15 +209,15 @@ class _ContactViewState extends State<ContactView> {
MaxFlameListTitle(
contactId: widget.userId,
),
if (!contact.verified)
BetterListTile(
leading: SvgIcon(
assetPath: SvgIcons.verifiedGreen,
leading: VerifiedShield(
contact: contact,
size: 20,
color: IconTheme.of(context).color,
),
text: context.lang.contactVerifyNumberTitle,
onTap: () async {
await context.push(Routes.settingsPublicProfile);
await context.push(Routes.settingsHelpFaqVerifyBadge);
setState(() {});
},
),
@ -247,8 +248,9 @@ Future<String?> showNicknameChangeDialog(
BuildContext context,
Contact contact,
) {
final controller =
TextEditingController(text: getContactDisplayName(contact));
final controller = TextEditingController(
text: getContactDisplayName(contact),
);
return showDialog<String>(
context: context,
@ -258,8 +260,9 @@ Future<String?> showNicknameChangeDialog(
content: TextField(
controller: controller,
autofocus: true,
decoration:
InputDecoration(hintText: context.lang.contactNicknameNew),
decoration: InputDecoration(
hintText: context.lang.contactNicknameNew,
),
),
actions: <Widget>[
TextButton(
@ -271,8 +274,9 @@ Future<String?> showNicknameChangeDialog(
TextButton(
child: Text(context.lang.ok),
onPressed: () {
Navigator.of(context)
.pop(controller.text); // Return the input text
Navigator.of(
context,
).pop(controller.text); // Return the input text
},
),
],
@ -291,8 +295,9 @@ Future<String?> showReportDialog(
context: context,
builder: (context) {
return AlertDialog(
title:
Text(context.lang.reportUserTitle(getContactDisplayName(contact))),
title: Text(
context.lang.reportUserTitle(getContactDisplayName(contact)),
),
content: TextField(
controller: controller,
autofocus: true,

View file

@ -124,14 +124,15 @@ class GroupMemberContextMenu extends StatelessWidget {
ContextMenuItem(
title: context.lang.contextMenuOpenChat,
onTap: () async {
final directChat =
await twonlyDB.groupsDao.getDirectChat(contact.userId);
final directChat = await twonlyDB.groupsDao.getDirectChat(
contact.userId,
);
if (directChat == null) {
// create
return;
}
if (!context.mounted) return;
await context.push(Routes.chatsMessages, extra: directChat);
await context.push(Routes.chatsMessages(directChat.groupId));
},
icon: FontAwesomeIcons.message,
),

View file

@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/providers/routing.provider.dart';
import 'package:twonly/src/services/intent/links.intent.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
@ -106,7 +108,12 @@ class HomeViewState extends State<HomeView> {
});
};
selectNotificationStream.stream.listen((response) async {
if (response.payload != null &&
response.payload!.startsWith(Routes.chats)) {
await routerProvider.push(response.payload!);
} else {
globalUpdateOfHomeViewPageIndex(0);
}
});
unawaited(_mainCameraController.selectCamera(0, true));
unawaited(initAsync());
@ -140,14 +147,27 @@ class HomeViewState extends State<HomeView> {
}
Future<void> initAsync() async {
final notificationAppLaunchDetails =
await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails();
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (widget.initialPage == 0 ||
(notificationAppLaunchDetails != null &&
notificationAppLaunchDetails.didNotificationLaunchApp)) {
var pushed = false;
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload =
notificationAppLaunchDetails?.notificationResponse?.payload;
if (payload != null && payload.startsWith(Routes.chats)) {
await routerProvider.push(payload);
pushed = true;
}
}
if (!pushed) {
globalUpdateOfHomeViewPageIndex(0);
}
}
final draftMedia = await twonlyDB.mediaFilesDao.getDraftMediaFile();
if (draftMedia != null) {
@ -168,8 +188,9 @@ class HomeViewState extends State<HomeView> {
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
onDoubleTap:
offsetRatio == 0 ? _mainCameraController.onDoubleTap : null,
onDoubleTap: offsetRatio == 0
? _mainCameraController.onDoubleTap
: null,
onTapDown: offsetRatio == 0 ? _mainCameraController.onTapDown : null,
child: Stack(
children: <Widget>[

View file

@ -3,6 +3,7 @@ import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:restart_app/restart_app.dart';
import 'package:twonly/globals.dart';
@ -32,6 +33,14 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
setState(() {});
}
Future<void> toggleVideoStabilization() async {
await updateUserdata((u) {
u.videoStabilizationEnabled = !u.videoStabilizationEnabled;
return u;
});
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -53,6 +62,14 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
onTap: () =>
context.push(Routes.settingsDeveloperRetransmissionDatabase),
),
ListTile(
title: const Text('Toggle Video Stabilization'),
onTap: toggleVideoStabilization,
trailing: Switch(
value: gUser.videoStabilizationEnabled,
onChanged: (a) => toggleVideoStabilization(),
),
),
ListTile(
title: const Text('Delete all (!) app data'),
onTap: () async {
@ -71,6 +88,10 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
}
},
),
ListTile(
title: const Text('Reduce flames'),
onTap: () => context.push(Routes.settingsDeveloperReduceFlames),
),
if (!kReleaseMode)
ListTile(
title: const Text('Make it possible to reset flames'),
@ -84,9 +105,13 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
flameCounter: const Value(0),
maxFlameCounter: const Value(365),
lastFlameCounterChange: Value(clock.now()),
maxFlameCounterFrom: Value(
clock.now().subtract(const Duration(days: 1)),
),
),
);
}
await HapticFeedback.heavyImpact();
},
),
if (!kReleaseMode)

View file

@ -0,0 +1,130 @@
import 'dart:async';
import 'dart:collection';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart';
import 'package:twonly/src/views/components/flame.dart';
class ReduceFlamesView extends StatefulWidget {
const ReduceFlamesView({super.key});
@override
State<ReduceFlamesView> createState() => _ReduceFlamesViewState();
}
class _ReduceFlamesViewState extends State<ReduceFlamesView> {
List<Group> allGroups = [];
List<Group> backupFlames = [];
HashSet<String> changedGroupIds = HashSet();
late StreamSubscription<List<Group>> groupSub;
@override
void initState() {
super.initState();
final stream = twonlyDB.groupsDao.watchGroupsForChatList();
groupSub = stream.listen((update) async {
if (backupFlames.isEmpty) {
backupFlames = update;
}
update.sort(
(a, b) => a.flameCounter.compareTo(b.flameCounter),
);
setState(() {
allGroups = update.where((g) => g.flameCounter > 1).toList();
});
});
}
@override
void dispose() {
unawaited(groupSub.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: const Text('Reduce Flames'),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.only(
bottom: 40,
left: 10,
top: 20,
right: 10,
),
child: Column(
children: [
const Text(
'There was a bug that caused the flames in direct messages to be replaced by group flames. Here, you can reduce the flames again. If you reduce the flames, the other person MUST do the same; otherwise, they will be resynchronized to the higher value.',
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: () async {
for (final backupGroup in backupFlames) {
if (changedGroupIds.contains(backupGroup.groupId)) {
await twonlyDB.groupsDao.updateGroup(
backupGroup.groupId,
GroupsCompanion(
flameCounter: Value(backupGroup.flameCounter),
),
);
}
}
},
child: const Text('Undo changes'),
),
const SizedBox(height: 10),
Expanded(
child: ListView.builder(
restorationId: 'new_message_users_list',
itemCount: allGroups.length,
itemBuilder: (context, i) {
final group = allGroups[i];
return ListTile(
title: Row(
children: [
Text(group.groupName),
FlameCounterWidget(
groupId: group.groupId,
prefix: true,
),
],
),
leading: AvatarIcon(
group: group,
fontSize: 13,
),
trailing: OutlinedButton(
onPressed: () {
changedGroupIds.add(group.groupId);
twonlyDB.groupsDao.updateGroup(
group.groupId,
GroupsCompanion(
flameCounter: Value(group.flameCounter - 1),
),
);
},
child: const Text('-1'),
),
);
},
),
),
],
),
),
),
),
);
}
}

View file

@ -1,7 +1,13 @@
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/views/components/better_list_title.dart';
import 'package:twonly/src/views/components/svg_icon.dart';
const colorVerificationBadgeYellow = Color(0xffffa500);
class VerificationBadeFaqView extends StatefulWidget {
const VerificationBadeFaqView({super.key});
@ -33,7 +39,7 @@ class _VerificationBadeFaqViewState extends State<VerificationBadeFaqView> {
icon: const SvgIcon(
assetPath: SvgIcons.verifiedGreen,
size: 40,
color: Color.fromARGB(255, 227, 227, 3),
color: colorVerificationBadgeYellow,
),
description: context.lang.verificationBadgeYellowDesc,
),
@ -41,6 +47,18 @@ class _VerificationBadeFaqViewState extends State<VerificationBadeFaqView> {
icon: const SvgIcon(assetPath: SvgIcons.verifiedRed, size: 40),
description: context.lang.verificationBadgeRedDesc,
),
const SizedBox(height: 20),
const SizedBox(height: 20),
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

@ -13,26 +13,32 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
class NotificationView extends StatelessWidget {
class NotificationView extends StatefulWidget {
const NotificationView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.settingsNotification),
),
body: ListView(
children: [
ListTile(
title: Text(context.lang.settingsNotifyTroubleshooting),
subtitle: Text(context.lang.settingsNotifyTroubleshootingDesc),
onTap: () async {
await initFCMAfterAuthenticated();
final storedToken = await (const FlutterSecureStorage()
.read(key: SecureStorageKeys.googleFcm));
State<NotificationView> createState() => _NotificationViewState();
}
class _NotificationViewState extends State<NotificationView> {
bool _isLoadingTroubleshooting = false;
bool _isLoadingReset = false;
bool _troubleshootingDidRun = false;
Future<void> _troubleshooting() async {
setState(() {
_isLoadingTroubleshooting = true;
});
await initFCMAfterAuthenticated(force: true);
final storedToken = await (const FlutterSecureStorage().read(
key: SecureStorageKeys.googleFcm,
));
await setupNotificationWithUsers(force: true);
if (!context.mounted) return;
if (!mounted) return;
if (storedToken == null) {
final platform = Platform.isAndroid ? "Google's" : "Apple's";
@ -64,9 +70,66 @@ class NotificationView extends StatelessWidget {
pushData,
);
}
_troubleshootingDidRun = true;
}
}
},
setState(() {
_isLoadingTroubleshooting = false;
});
}
Future<void> resetTokens() async {
setState(() {
_isLoadingReset = true;
});
await resetFCMTokens();
if (!mounted) return;
await showAlertDialog(
context,
context.lang.settingsNotifyResetTitleReset,
context.lang.settingsNotifyResetTitleResetDesc,
);
setState(() {
_isLoadingReset = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.lang.settingsNotification),
),
body: ListView(
children: [
ListTile(
title: Text(context.lang.settingsNotifyTroubleshooting),
subtitle: Text(context.lang.settingsNotifyTroubleshootingDesc),
trailing: _isLoadingTroubleshooting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: null,
onTap: _isLoadingTroubleshooting ? null : _troubleshooting,
),
if (_troubleshootingDidRun)
ListTile(
title: Text(context.lang.settingsNotifyResetTitle),
subtitle: Text(context.lang.settingsNotifyResetTitleSubtitle),
trailing: _isLoadingReset
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: null,
onTap: _isLoadingReset ? null : resetTokens,
),
],
),

View file

@ -121,7 +121,12 @@ class _SettingsMainViewState extends State<SettingsMainView> {
BetterListTile(
icon: FontAwesomeIcons.circleQuestion,
text: context.lang.settingsHelp,
onTap: () => context.push(Routes.settingsHelp),
onTap: () async {
await context.push(Routes.settingsHelp);
setState(() {
// gUser could have been changed
});
},
),
if (gUser.isDeveloper)
BetterListTile(

View file

@ -13,10 +13,10 @@ packages:
dependency: transitive
description:
name: _flutterfire_internals
sha256: afe15ce18a287d2f89da95566e62892df339b1936bbe9b83587df45b944ee72a
sha256: f698de6eb8a0dd7a9a931bbfe13568e8b77e702eb2deb13dd83480c5373e7746
url: "https://pub.dev"
source: hosted
version: "1.3.67"
version: "1.3.68"
adaptive_number:
dependency: "direct overridden"
description:
@ -84,10 +84,10 @@ packages:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.0"
version: "2.13.1"
audio_waveforms:
dependency: "direct main"
description:
@ -124,18 +124,18 @@ packages:
dependency: transitive
description:
name: build
sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3"
sha256: aadd943f4f8cc946882c954c187e6115a84c98c81ad1d9c6cbf0895a8c85da9c
url: "https://pub.dev"
source: hosted
version: "4.0.4"
version: "4.0.5"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.3.0"
build_daemon:
dependency: transitive
description:
@ -148,10 +148,10 @@ packages:
dependency: "direct dev"
description:
name: build_runner
sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734"
sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
version: "2.13.1"
built_collection:
dependency: transitive
description:
@ -164,10 +164,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9"
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
source: hosted
version: "8.12.4"
version: "8.12.5"
cached_network_image:
dependency: "direct main"
description:
@ -196,27 +196,27 @@ packages:
dependency: "direct main"
description:
name: camera
sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437"
sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777"
url: "https://pub.dev"
source: hosted
version: "0.11.4"
version: "0.12.0+1"
camera_android_camerax:
dependency: "direct overridden"
description:
path: "packages/camera/camera_android_camerax"
ref: "43b87faec960306f98d767253b9bf2cee61be630"
resolved-ref: "43b87faec960306f98d767253b9bf2cee61be630"
ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69
resolved-ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69
url: "https://github.com/otsmr/flutter-packages.git"
source: git
version: "0.6.25+1"
version: "0.7.1+2"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b"
sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d"
url: "https://pub.dev"
source: hosted
version: "0.9.23+2"
version: "0.10.1"
camera_platform_interface:
dependency: transitive
description:
@ -301,18 +301,18 @@ packages:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c"
sha256: b8fe52979ff12432ecf8f0abf6ff70410b1bb734be1c9e4f2f86807ad7166c79
url: "https://pub.dev"
source: hosted
version: "7.0.0"
version: "7.1.0"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "2.1.0"
convert:
dependency: "direct main"
description:
@ -365,10 +365,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "6f6b30cba0301e7b38f32bdc9a6bdae6f5921a55f0a1eb9450e1e6515645dbb2"
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
version: "3.1.7"
dbus:
dependency: transitive
description:
@ -381,10 +381,10 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c"
sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
url: "https://pub.dev"
source: hosted
version: "12.3.0"
version: "12.4.0"
device_info_plus_platform_interface:
dependency: transitive
description:
@ -504,54 +504,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
firebase_app_installations:
dependency: "direct main"
description:
name: firebase_app_installations
sha256: "9103cac19ec40561b49a023e8e583f007f77a499c2058f5a1d82ba480c2367d7"
url: "https://pub.dev"
source: hosted
version: "0.4.1"
firebase_app_installations_platform_interface:
dependency: transitive
description:
name: firebase_app_installations_platform_interface
sha256: "0811d37b91c992cc0c98200cca79652d0375a16c58b364a4db5601571c198ee5"
url: "https://pub.dev"
source: hosted
version: "0.1.4+67"
firebase_app_installations_web:
dependency: transitive
description:
name: firebase_app_installations_web
sha256: "02f2a96e85581bd1f78319b503dc92c80afa1527cbc299c7c921995b75595bbd"
url: "https://pub.dev"
source: hosted
version: "0.1.7+4"
firebase_core:
dependency: "direct main"
description:
name: firebase_core
sha256: f0997fee80fbb6d2c658c5b88ae87ba1f9506b5b37126db64fc2e75d8e977fbb
sha256: "2f988dab915efde3b3105268dbd69efce0e8570d767a218ccd914afd0c10c8cc"
url: "https://pub.dev"
source: hosted
version: "4.5.0"
version: "4.6.0"
firebase_core_platform_interface:
dependency: transitive
description:
name: firebase_core_platform_interface
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
url: "https://pub.dev"
source: hosted
version: "6.0.2"
version: "6.0.3"
firebase_core_web:
dependency: transitive
description:
name: firebase_core_web
sha256: "856ca92bf2d75a63761286ab8e791bda3a85184c2b641764433b619647acfca6"
sha256: "1399ab1f0ac3b503d8a9be64a4c997fc066bbf33f701f42866e5569f26205ebe"
url: "https://pub.dev"
source: hosted
version: "3.5.0"
version: "3.5.1"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: bd17823b70e629877904d384841cda72ed2cc197517404c0c90da5c0ba786a8c
sha256: "8dc372085b1647f05e3ec1b8bc1dada87c0062f93b2a6976f620eb85edc44f97"
url: "https://pub.dev"
source: hosted
version: "16.1.2"
version: "16.1.3"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
sha256: "550435235cc7d53683f32bf0762c28ef8cfc20a8d36318a033676ae09526d7fb"
sha256: "6ea10f7df747542b17679d5939213c09163aab9c301b2f9b858cb55f38efdb54"
url: "https://pub.dev"
source: hosted
version: "4.7.7"
version: "4.7.8"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: "6b1b93ed90309fbce91c219e3cd32aa831e8eccaf4a61f3afaea1625479275d2"
sha256: "1f9798c8021ccf22b7e43e7fba81becd42252cb168228379fcabb7c2ef7dd638"
url: "https://pub.dev"
source: hosted
version: "4.1.3"
version: "4.1.4"
fixnum:
dependency: "direct main"
description:
@ -727,10 +751,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.33"
version: "2.0.34"
flutter_secure_storage:
dependency: "direct main"
description:
@ -790,10 +814,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
version: "2.2.4"
flutter_test:
dependency: "direct dev"
description: flutter
@ -848,10 +872,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
sha256: "48fb2f42ad057476fa4b733cb95e9f9ea7b0b010bb349ea491dca7dbdb18ffc4"
url: "https://pub.dev"
source: hosted
version: "17.1.0"
version: "17.2.0"
google_mlkit_barcode_scanning:
dependency: "direct main"
description:
@ -917,10 +941,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "1.0.2"
html:
dependency: "direct main"
description:
@ -973,10 +997,10 @@ packages:
dependency: transitive
description:
name: image_picker_android
sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156
sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622"
url: "https://pub.dev"
source: hosted
version: "0.8.13+14"
version: "0.8.13+16"
image_picker_for_web:
dependency: transitive
description:
@ -1037,10 +1061,10 @@ packages:
dependency: transitive
description:
name: in_app_purchase_android
sha256: abb254ae159a5a9d4f867795ecb076864faeba59ce015ab81d4cca380f23df45
sha256: "634bee4734b17fe55f370f0ac07a22431a9666e0f3a870c6d20350856e8bbf71"
url: "https://pub.dev"
source: hosted
version: "0.4.0+8"
version: "0.4.0+10"
in_app_purchase_platform_interface:
dependency: "direct dev"
description:
@ -1053,10 +1077,10 @@ packages:
dependency: transitive
description:
name: in_app_purchase_storekit
sha256: "2f1a1db44798158076ced07d401b349880dd24a29c7c50a1b1a0de230b7f2f62"
sha256: "1d512809edd9f12ff88fce4596a13a18134e2499013f4d6a8894b04699363c93"
url: "https://pub.dev"
source: hosted
version: "0.4.8"
version: "0.4.8+1"
intl:
dependency: "direct main"
description:
@ -1100,10 +1124,10 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0"
sha256: fbcf404b03520e6e795f6b9b39badb2b788407dfc0a50cf39158a6ae1ca78925
url: "https://pub.dev"
source: hosted
version: "6.13.0"
version: "6.13.1"
leak_tracker:
dependency: transitive
description:
@ -1155,10 +1179,10 @@ packages:
dependency: transitive
description:
name: local_auth_android
sha256: dc9663a7bc8ac33d7d988e63901974f63d527ebef260eabd19c479447cc9c911
sha256: b41970749c2d43791790724b76917eeee1e90de76e6b0eec3edca03a329bf44c
url: "https://pub.dev"
source: hosted
version: "2.0.5"
version: "2.0.7"
local_auth_darwin:
dependency: transitive
description:
@ -1241,10 +1265,10 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
version: "0.17.6"
nested:
dependency: transitive
description:
@ -1303,10 +1327,10 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.0"
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
@ -1343,10 +1367,10 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
url: "https://pub.dev"
source: hosted
version: "2.2.22"
version: "2.2.23"
path_provider_foundation:
dependency: transitive
description:
@ -1485,10 +1509,10 @@ packages:
dependency: "direct main"
description:
name: pro_video_editor
sha256: "5aa37aed1399333a3ac4b78ce00c7dcba77c5e407b6420960bba43751895fa22"
sha256: cfed1424b3ca3d5981cc81efdd20b844c995c0ad2818e185eb5bc06a8674f728
url: "https://pub.dev"
source: hosted
version: "1.6.2"
version: "1.14.2"
protobuf:
dependency: "direct main"
description:
@ -1570,26 +1594,26 @@ packages:
dependency: transitive
description:
name: sentry
sha256: "605ad1f6f1ae5b72018cbe8fc20f490fa3bd53e58882e5579566776030d8c8c1"
sha256: "288aee3d35f252ac0dc3a4b0accbbe7212fa2867604027f2cc5bc65334afd743"
url: "https://pub.dev"
source: hosted
version: "9.14.0"
version: "9.16.0"
sentry_flutter:
dependency: "direct main"
description:
name: sentry_flutter
sha256: "7fd0fb80050c1f6a77ae185bda997a76d384326d6777cf5137a6c38952c4ac7d"
sha256: f9e87d5895cc437902aa2b081727ee7e46524fe7cc2e1910f535480a3eeb8bed
url: "https://pub.dev"
source: hosted
version: "9.14.0"
version: "9.16.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840"
sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa"
url: "https://pub.dev"
source: hosted
version: "12.0.1"
version: "12.0.2"
share_plus_platform_interface:
dependency: transitive
description:
@ -1602,18 +1626,18 @@ packages:
dependency: transitive
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.4"
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41"
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.21"
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
@ -1634,10 +1658,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
@ -1679,18 +1703,18 @@ packages:
dependency: transitive
description:
name: source_gen
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
sha256: "732792cfd197d2161a65bb029606a46e0a18ff30ef9e141a7a82172b05ea8ecd"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
version: "4.2.2"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae"
sha256: "1d3b229b2934034fb2e691fbb3d53e0f75a4af7b1407f88425ed8f209bcb1b8f"
url: "https://pub.dev"
source: hosted
version: "1.3.10"
version: "1.3.11"
source_span:
dependency: transitive
description:
@ -1711,10 +1735,10 @@ packages:
dependency: transitive
description:
name: sqflite_android
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+2"
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
@ -1751,10 +1775,10 @@ packages:
dependency: transitive
description:
name: sqlite3_flutter_libs
sha256: "1e800ebe7f85a80a66adacaa6febe4d5f4d8b75f244e9838a27cb2ffc7aec08d"
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.5.41"
version: "0.5.42"
sqlparser:
dependency: transitive
description:
@ -1847,10 +1871,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
@ -1911,10 +1935,10 @@ packages:
dependency: "direct main"
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
url: "https://pub.dev"
source: hosted
version: "1.1.19"
version: "1.1.21"
vector_graphics_codec:
dependency: transitive
description:
@ -1951,26 +1975,26 @@ packages:
dependency: "direct main"
description:
name: video_player
sha256: "08bfba72e311d48219acad4e191b1f9c27ff8cf928f2c7234874592d9c9d7341"
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
version: "2.11.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3"
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
version: "2.9.5"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
url: "https://pub.dev"
source: hosted
version: "2.9.3"
version: "2.9.4"
video_player_platform_interface:
dependency: transitive
description:

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none'
version: 0.1.1+101
version: 0.1.3+103
environment:
sdk: ^3.11.0
@ -30,7 +30,7 @@ dependencies:
# Trusted publisher flutter.dev
camera: ^0.11.2
camera: ^0.12.0+1
flutter_svg: ^2.0.17
image_picker: ^1.1.2
local_auth: ^3.0.0
@ -54,6 +54,7 @@ dependencies:
# Trustworthy publishers
firebase_core: ^4.3.0 # firebase.google.com
firebase_messaging: ^16.1.0 # firebase.google.com
firebase_app_installations: ^0.4.1 # firebase.google.com
json_annotation: ^4.9.0 # google.dev
protobuf: ^4.0.0 # google.dev
scrollable_positioned_list: ^0.3.8 # google.dev
@ -152,7 +153,7 @@ dependency_overrides:
git:
url: https://github.com/otsmr/flutter-packages.git
path: packages/camera/camera_android_camerax
ref: 43b87faec960306f98d767253b9bf2cee61be630
ref: e83fb3a27d4da2c37a3c8acbf2486283965b4f69
emoji_picker_flutter:
# Fixes the issue with recent emojis (solved by https://github.com/Fintasys/emoji_picker_flutter/pull/238)
# Using override until this gets merged.

View file

@ -126,6 +126,20 @@ void main() {
);
await withClock(
Clock.fixed(DateTime(2026, 3, 25, 19)),
() async {
final group2 = (await twonlyDB.groupsDao.getGroup(group.groupId))!;
expect(isItPossibleToRestoreFlames(group2), true);
},
);
await withClock(
Clock.fixed(DateTime(2026, 3, 26, 19)),
() async {
final group2 = (await twonlyDB.groupsDao.getGroup(group.groupId))!;
expect(isItPossibleToRestoreFlames(group2), true);
},
);
await withClock(
Clock.fixed(DateTime(2026, 3, 27, 19)),
() async {
final group2 = (await twonlyDB.groupsDao.getGroup(group.groupId))!;
expect(isItPossibleToRestoreFlames(group2), false);