From 6f597a08a1345e8d5a95ffba186bd42805286bd9 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 31 Aug 2026 23:00:09 +0200 Subject: [PATCH] improve video, remove deps, fix notifications --- .gitignore | 1 + .../eu/twonly/media/NativeVideoCodec.kt | 51 +++- .../notifications/NativeNotificationModels.kt | 2 + .../notifications/NotificationTapChannel.kt | 22 +- .../notifications/TwonlyNotificationWorker.kt | 17 +- dependencies | 2 +- .../NotificationService.swift | 1 + ios/Runner/NativeVideoCodec.swift | 49 +++- lib/core/bridge/wrapper/key_manager.dart | 4 +- lib/core/frb_generated.dart | 28 +- lib/core/user_config.dart | 8 + lib/main.dart | 1 - lib/src/constants/secure_storage.keys.dart | 15 - .../generated/app_localizations.dart | 28 +- .../generated/app_localizations_de.dart | 19 +- .../generated/app_localizations_en.dart | 18 +- lib/src/services/backup.service.dart | 267 ++++++++++-------- .../notifications/fcm.notifications.dart | 47 +-- .../notifications/native.notifications.dart | 40 ++- lib/src/services/user.service.dart | 34 --- lib/src/utils/secure_storage.dart | 5 - lib/src/utils/storage.dart | 12 +- lib/src/visual/themes/light.dart | 2 + lib/src/visual/views/home.view.dart | 34 ++- .../recovery_from_secure_storage.view.dart | 9 +- .../visual/views/settings/account.view.dart | 2 +- .../views/settings/appearance.view.dart | 47 +-- .../settings/developer/developer.view.dart | 2 +- .../settings/developer/informations.view.dart | 44 ++- .../views/settings/notification.view.dart | 57 +--- pubspec.lock | 56 ---- pubspec.yaml | 2 - rust/src/api/messages/incoming.rs | 11 +- rust/src/bridge/wrapper/key_manager.rs | 3 +- rust/src/frb_generated.rs | 14 +- rust/src/user_config.rs | 7 + test/mocks/platform_channels.dart | 60 ---- test/services/backup_service_test.dart | 63 ----- test/services/notification_routing_test.dart | 24 ++ 39 files changed, 457 insertions(+), 651 deletions(-) delete mode 100644 lib/src/constants/secure_storage.keys.dart delete mode 100644 lib/src/utils/secure_storage.dart create mode 100644 test/services/notification_routing_test.dart diff --git a/.gitignore b/.gitignore index ba46011e..a555743a 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ fastlane/repo/status/running.json .cache/ # Widget Preview related .widget_preview/ +ios/build/ diff --git a/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt b/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt index 67a70b08..911e99d0 100644 --- a/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt +++ b/android/app/src/main/kotlin/eu/twonly/media/NativeVideoCodec.kt @@ -2,12 +2,13 @@ package eu.twonly.media import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.media.MediaCodecInfo import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMetadataRetriever -import android.util.Log import android.os.Handler import android.os.Looper +import android.util.Log import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.OverlaySettings @@ -19,6 +20,7 @@ import androidx.media3.effect.OverlayEffect import androidx.media3.effect.Presentation import androidx.media3.effect.StaticOverlaySettings import androidx.media3.effect.TextureOverlay +import androidx.media3.transformer.AudioEncoderSettings import androidx.media3.transformer.Composition import androidx.media3.transformer.DefaultEncoderFactory import androidx.media3.transformer.EditedMediaItem @@ -57,20 +59,23 @@ object NativeVideoCodec { private const val MAX_FRAME_RATE = 30.0 /** - * Bits per pixel per frame asked of the encoder. HEVC stays close to the - * source at roughly this rate; below it motion smears into blocks, and above - * it the extra bits go to detail a phone camera never recorded. The bitrate - * is derived from the output size and frame rate rather than fixed, so a - * clip that is downscaled hard is not given the same budget as one that is - * already 720p. Kept in sync with the same constants in the iOS renderer so - * the same clip looks the same whichever platform sent it. + * Bits per pixel per frame asked of the encoder. The previous 0.12 budget + * reproduced the deliberately generous bitrate of a real-time camera + * encode. Twonly is encoding an already captured clip and can use VBR plus + * frame reordering, so 0.08 retains the useful detail without spending bits + * on camera noise. The bitrate is derived from the output size and frame + * rate rather than fixed, so smaller clips do not inherit a 720p budget. + * Kept in sync with iOS so a clip has comparable size on either platform. */ - private const val BITS_PER_PIXEL_PER_FRAME = 0.12 - private const val MIN_BITRATE = 1_500_000 - private const val MAX_BITRATE = 4_000_000 + private const val BITS_PER_PIXEL_PER_FRAME = 0.08 + private const val MIN_BITRATE = 600_000 + private const val MAX_BITRATE = 2_500_000 /** 720p30 at the rate above, used when the source cannot be probed. */ - private const val DEFAULT_BITRATE = 3_300_000 + private const val DEFAULT_BITRATE = 2_200_000 private const val DEFAULT_FRAME_RATE = 30.0 + private const val AUDIO_BITRATE = 96_000 + private const val I_FRAME_INTERVAL_SECONDS = 2.0f + private const val MAX_B_FRAMES = 2 private const val PROGRESS_INTERVAL_MS = 500L /** No send should hold a background worker hostage indefinitely. */ private const val RENDER_TIMEOUT_MINUTES = 30L @@ -121,7 +126,27 @@ object NativeVideoCodec { .setEncoderFactory( DefaultEncoderFactory.Builder(context) .setRequestedVideoEncoderSettings( - VideoEncoderSettings.Builder().setBitrate(bitrate).build(), + VideoEncoderSettings.Builder() + .setBitrate(bitrate) + // Let quiet sections use fewer bits than the + // average instead of padding every second to + // the requested rate. + .setBitrateMode( + MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR, + ) + // Two seconds keeps seeking responsive without + // paying the size penalty of an I-frame every + // second. B-frames improve HEVC efficiency when + // the device encoder supports them; fallback + // below drops unsupported settings safely. + .setiFrameIntervalSeconds(I_FRAME_INTERVAL_SECONDS) + .setMaxBFrames(MAX_B_FRAMES) + .build(), + ) + .setRequestedAudioEncoderSettings( + AudioEncoderSettings.Builder() + .setBitrate(AUDIO_BITRATE) + .build(), ) // Falling back lets a device without an HEVC encoder // still produce a file rather than failing the send. diff --git a/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationModels.kt b/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationModels.kt index 56e5249f..3c1f0f0d 100644 --- a/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationModels.kt +++ b/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationModels.kt @@ -11,6 +11,7 @@ internal data class NativeNotificationAddition( val eventId: String, val notificationId: String, val conversationId: String?, + val kind: String, val senderId: Long, val senderName: String, val title: String, @@ -42,6 +43,7 @@ internal data class NativeNotificationResponse( eventId = item.getString("event_id"), notificationId = item.getString("notification_id"), conversationId = item.nullableString("conversation_id"), + kind = item.getString("kind"), senderId = item.getLong("sender_id"), senderName = item.getString("sender_name"), title = item.getString("title"), diff --git a/android/app/src/main/kotlin/eu/twonly/notifications/NotificationTapChannel.kt b/android/app/src/main/kotlin/eu/twonly/notifications/NotificationTapChannel.kt index ba11b8db..4d99ad1f 100644 --- a/android/app/src/main/kotlin/eu/twonly/notifications/NotificationTapChannel.kt +++ b/android/app/src/main/kotlin/eu/twonly/notifications/NotificationTapChannel.kt @@ -11,15 +11,17 @@ internal fun nativeNotificationId(value: String): Int = value.hashCode() and Int /** * Forwards taps on natively rendered notifications into Flutter. * - * Only the opaque conversation identifier travels across this channel; the - * route itself is built in Dart so Kotlin never duplicates Flutter routing. + * Only opaque notification metadata travels across this channel; the route + * itself is built in Dart so Kotlin never duplicates Flutter routing. */ object NotificationTapChannel { private const val CHANNEL = "eu.twonly/notificationTap" const val EXTRA_CONVERSATION_ID = "conversation_id" + const val EXTRA_NOTIFICATION_KIND = "notification_kind" private var channel: MethodChannel? = null private var pendingConversationId: String? = null + private var pendingNotificationKind: String? = null private var pendingLaunch = false fun configure(flutterEngine: FlutterEngine, context: Context) { @@ -29,11 +31,16 @@ object NotificationTapChannel { "consumeInitialNotification" -> { val launched = pendingLaunch val conversationId = pendingConversationId + val notificationKind = pendingNotificationKind pendingLaunch = false pendingConversationId = null + pendingNotificationKind = null result.success( if (launched) { - mapOf(EXTRA_CONVERSATION_ID to conversationId) + mapOf( + EXTRA_CONVERSATION_ID to conversationId, + EXTRA_NOTIFICATION_KIND to notificationKind, + ) } else { null }, @@ -65,19 +72,26 @@ object NotificationTapChannel { if (intent?.hasExtra(EXTRA_CONVERSATION_ID) != true) return val conversationId = intent.getStringExtra(EXTRA_CONVERSATION_ID)?.takeIf(String::isNotEmpty) + val notificationKind = + intent.getStringExtra(EXTRA_NOTIFICATION_KIND)?.takeIf(String::isNotEmpty) // A tap must only route once, even if the activity is recreated with // the same intent after a configuration change. intent.removeExtra(EXTRA_CONVERSATION_ID) + intent.removeExtra(EXTRA_NOTIFICATION_KIND) val channel = this.channel if (channel == null) { pendingLaunch = true pendingConversationId = conversationId + pendingNotificationKind = notificationKind return } channel.invokeMethod( "onNotificationTapped", - mapOf(EXTRA_CONVERSATION_ID to conversationId), + mapOf( + EXTRA_CONVERSATION_ID to conversationId, + EXTRA_NOTIFICATION_KIND to notificationKind, + ), ) } } diff --git a/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt b/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt index d476b981..0ee61a12 100644 --- a/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt +++ b/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt @@ -10,8 +10,6 @@ import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.app.Person -import androidx.core.graphics.drawable.IconCompat import androidx.work.Worker import androidx.work.WorkerParameters import eu.twonly.MainActivity @@ -73,15 +71,6 @@ class TwonlyNotificationWorker( addition: NativeNotificationAddition, ): Boolean { val avatarBitmap = addition.avatarPath?.let(BitmapFactory::decodeFile) - val avatar = avatarBitmap?.let(IconCompat::createWithBitmap) - val sender = Person.Builder() - .setName(addition.senderName) - .setKey(addition.senderId.toString()) - .setIcon(avatar) - .build() - val user = Person.Builder().setName(applicationLabel()).setKey("twonly-user").build() - val style = NotificationCompat.MessagingStyle(user) - .addMessage(addition.body, addition.createdAt * 1_000, sender) val intent = Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP // Only opaque identifiers cross into the activity; Dart owns routing. @@ -89,6 +78,7 @@ class TwonlyNotificationWorker( NotificationTapChannel.EXTRA_CONVERSATION_ID, addition.conversationId.orEmpty(), ) + putExtra(NotificationTapChannel.EXTRA_NOTIFICATION_KIND, addition.kind) } val pendingIntent = PendingIntent.getActivity( applicationContext, @@ -100,16 +90,11 @@ class TwonlyNotificationWorker( .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(addition.title) .setContentText(addition.body) - .setStyle(style) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setAutoCancel(true) .setContentIntent(pendingIntent) .setPriority(NotificationCompat.PRIORITY_HIGH) .setGroup(addition.conversationId ?: addition.senderId.toString()) - // Android only draws the MessagingStyle person icon for conversation - // notifications, which require a long-lived shortcut. Without one the - // standard template is used, where the large icon is the only place - // the sender's avatar can appear. .apply { avatarBitmap?.let(::setLargeIcon) } .build() return try { diff --git a/dependencies b/dependencies index f46500ce..60da6275 160000 --- a/dependencies +++ b/dependencies @@ -1 +1 @@ -Subproject commit f46500ce45dbce12ee4431e5becb759ac7f26098 +Subproject commit 60da6275f8c82c4238f0c3cf88a798e63b4a2ffd diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index 64f60d21..ee6c148a 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -168,6 +168,7 @@ final class NotificationService: UNNotificationServiceExtension { if let conversationId = addition.conversationId { userInfo["conversation_id"] = conversationId } + userInfo["notification_kind"] = addition.kind userInfo["notification_id"] = addition.notificationId mutable.userInfo = userInfo diff --git a/ios/Runner/NativeVideoCodec.swift b/ios/Runner/NativeVideoCodec.swift index 3adbb0b4..b295e8c3 100644 --- a/ios/Runner/NativeVideoCodec.swift +++ b/ios/Runner/NativeVideoCodec.swift @@ -2,6 +2,7 @@ import AVFoundation import CoreImage import Foundation import ImageIO +import VideoToolbox /// Burns the editor's overlay into the video and transcodes it in one pass: /// Core Image composites each frame on the GPU and VideoToolbox encodes it. @@ -18,21 +19,22 @@ enum NativeVideoCodec { private static let maxShortSide: CGFloat = 720 private static let maxFrameRate: Double = 30 - /// Bits per pixel per frame asked of VideoToolbox. HEVC stays close to the - /// source at roughly this rate; below it motion smears into blocks, and above - /// it the extra bits go to detail a phone camera never recorded. The bitrate - /// is derived from the output size and frame rate rather than fixed, so a clip - /// that is downscaled hard is not given the same budget as one that is already - /// 720p. Kept in sync with the same constants in the Android renderer so the - /// same clip looks the same whichever platform sent it. + /// Bits per pixel per frame asked of VideoToolbox. The previous 0.12 budget + /// reproduced the deliberately generous bitrate of a real-time camera encode. + /// Twonly is encoding an already captured clip and can use VBR plus frame + /// reordering, so 0.08 retains the useful detail without spending bits on + /// camera noise. The bitrate is derived from the output size and frame rate + /// rather than fixed, so smaller clips do not inherit a 720p budget. Kept in + /// sync with Android so a clip has comparable size on either platform. /// /// `AVAssetExportSession` presets cannot express any of this, which is why /// the reader/writer pair is driven by hand. - private static let bitsPerPixelPerFrame: Double = 0.12 - private static let minBitrate = 1_500_000 - private static let maxBitrate = 4_000_000 + private static let bitsPerPixelPerFrame: Double = 0.08 + private static let minBitrate = 600_000 + private static let maxBitrate = 2_500_000 private static let defaultFrameRate: Double = 30 - private static let audioBitrate = 128_000 + private static let audioBitrate = 96_000 + private static let keyFrameInterval: Double = 2 static func render( inputPath: String, @@ -101,6 +103,12 @@ enum NativeVideoCodec { AVVideoCompressionPropertiesKey: [ AVVideoAverageBitRateKey: videoBitrate, AVVideoExpectedSourceFrameRateKey: Int(frameRate.rounded()), + AVVideoProfileLevelKey: kVTProfileLevel_HEVC_Main_AutoLevel, + // A two-second GOP still seeks accurately while spending less on + // intra frames than the previous one-second default. VideoToolbox + // can reorder frames here because this is an offline export. + AVVideoMaxKeyFrameIntervalDurationKey: keyFrameInterval, + AVVideoAllowFrameReorderingKey: true, ], ] ) @@ -115,6 +123,7 @@ enum NativeVideoCodec { var audioOutput: AVAssetReaderTrackOutput? var audioInput: AVAssetWriterInput? if !removeAudio, let audioTrack = asset.tracks(withMediaType: .audio).first { + let audioChannels = channelCount(for: audioTrack) let output = AVAssetReaderTrackOutput( track: audioTrack, outputSettings: [AVFormatIDKey: kAudioFormatLinearPCM] @@ -123,7 +132,10 @@ enum NativeVideoCodec { mediaType: .audio, outputSettings: [ AVFormatIDKey: kAudioFormatMPEG4AAC, - AVNumberOfChannelsKey: 2, + // Do not turn the common mono camera track into stereo. It carries + // no additional information and makes the AAC encoder less + // efficient at the same total bitrate. + AVNumberOfChannelsKey: audioChannels, AVSampleRateKey: 44100, AVEncoderBitRateKey: audioBitrate, ] @@ -225,6 +237,19 @@ enum NativeVideoCodec { return min(maxBitrate, max(minBitrate, Int(bits.rounded()))) } + /// Keeps mono sources mono and limits unusual multichannel camera input to + /// stereo, which is the most widely supported AAC layout on mobile players. + private static func channelCount(for track: AVAssetTrack) -> Int { + guard let rawDescription = track.formatDescriptions.first else { return 2 } + // The track is an audio track, so its descriptions use the corresponding + // Core Media alias. AVFoundation exposes the collection as `[Any]`. + let description = rawDescription as! CMAudioFormatDescription + guard let format = CMAudioFormatDescriptionGetStreamBasicDescription(description) else { + return 2 + } + return min(2, max(1, Int(format.pointee.mChannelsPerFrame))) + } + /// Caps the long and short side the way the previous exporter did, and never /// scales a smaller source up. Hardware encoders produce edge artifacts on /// odd dimensions. diff --git a/lib/core/bridge/wrapper/key_manager.dart b/lib/core/bridge/wrapper/key_manager.dart index 70422ccb..ec8eb590 100644 --- a/lib/core/bridge/wrapper/key_manager.dart +++ b/lib/core/bridge/wrapper/key_manager.dart @@ -50,8 +50,8 @@ class RustKeyManager { registrationId: registrationId, ); - static Future removeKeyManager() => RustLib.instance.api - .crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager(); + static Future removeLocalCredentials() => RustLib.instance.api + .crateBridgeWrapperKeyManagerRustKeyManagerRemoveLocalCredentials(); /// Serialize the key_manager. Needed for the passwordless_recovery feature. static Future serialize() => RustLib.instance.api diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index 88eb47ce..0a34940a 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -86,7 +86,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 174490644; + int get rustContentHash => -1370816897; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -579,7 +579,8 @@ abstract class RustLibApi extends BaseApi { required PlatformInt64 registrationId, }); - Future crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager(); + Future + crateBridgeWrapperKeyManagerRustKeyManagerRemoveLocalCredentials(); Future crateBridgeWrapperKeyManagerRustKeyManagerSerialize(); @@ -4961,7 +4962,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager() { + Future + crateBridgeWrapperKeyManagerRustKeyManagerRemoveLocalCredentials() { return handler.executeNormal( NormalTask( callFfi: (port_) { @@ -4978,7 +4980,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_AnyhowException, ), constMeta: - kCrateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManagerConstMeta, + kCrateBridgeWrapperKeyManagerRustKeyManagerRemoveLocalCredentialsConstMeta, argValues: [], apiImpl: this, ), @@ -4986,9 +4988,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } TaskConstMeta - get kCrateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManagerConstMeta => + get kCrateBridgeWrapperKeyManagerRustKeyManagerRemoveLocalCredentialsConstMeta => const TaskConstMeta( - debugName: "rust_key_manager_remove_key_manager", + debugName: "rust_key_manager_remove_local_credentials", argNames: [], ); @@ -6642,8 +6644,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { UserConfig dco_decode_user_config(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 59) - throw Exception('unexpected arr length: expect 59 but see ${arr.length}'); + if (arr.length != 60) + throw Exception('unexpected arr length: expect 60 but see ${arr.length}'); return UserConfig( userId: dco_decode_i_64(arr[0]), username: dco_decode_String(arr[1]), @@ -6706,9 +6708,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[53]), fcmToken: dco_decode_opt_String(arr[54]), lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[55]), - currentSetupPage: dco_decode_opt_String(arr[56]), - skipSetupPages: dco_decode_bool(arr[57]), - hasZoomed: dco_decode_bool(arr[58]), + lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[56]), + currentSetupPage: dco_decode_opt_String(arr[57]), + skipSetupPages: dco_decode_bool(arr[58]), + hasZoomed: dco_decode_bool(arr[59]), ); } @@ -7827,6 +7830,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_decode_opt_box_autoadd_passwordless_recovery_config(deserializer); var var_fcmToken = sse_decode_opt_String(deserializer); var var_lastFcmWakeupAt = sse_decode_opt_box_autoadd_i_64(deserializer); + var var_lastServerMessageAt = sse_decode_opt_box_autoadd_i_64(deserializer); var var_currentSetupPage = sse_decode_opt_String(deserializer); var var_skipSetupPages = sse_decode_bool(deserializer); var var_hasZoomed = sse_decode_bool(deserializer); @@ -7890,6 +7894,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { passwordLessRecovery: var_passwordLessRecovery, fcmToken: var_fcmToken, lastFcmWakeupAt: var_lastFcmWakeupAt, + lastServerMessageAt: var_lastServerMessageAt, currentSetupPage: var_currentSetupPage, skipSetupPages: var_skipSetupPages, hasZoomed: var_hasZoomed, @@ -8977,6 +8982,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); sse_encode_opt_String(self.fcmToken, serializer); sse_encode_opt_box_autoadd_i_64(self.lastFcmWakeupAt, serializer); + sse_encode_opt_box_autoadd_i_64(self.lastServerMessageAt, serializer); sse_encode_opt_String(self.currentSetupPage, serializer); sse_encode_bool(self.skipSetupPages, serializer); sse_encode_bool(self.hasZoomed, serializer); diff --git a/lib/core/user_config.dart b/lib/core/user_config.dart index d3fd8192..af73ea31 100644 --- a/lib/core/user_config.dart +++ b/lib/core/user_config.dart @@ -163,6 +163,11 @@ class UserConfig { /// notification worker. Recorded in Rust because Flutter is no longer /// started for background delivery on either platform. PlatformInt64? lastFcmWakeupAt; + + /// Unix seconds of the last server message that was committed locally. + /// Kept next to the FCM wake-up timestamp so notification health can be + /// evaluated without a second secure-storage implementation in Dart. + PlatformInt64? lastServerMessageAt; String? currentSetupPage; bool skipSetupPages; bool hasZoomed; @@ -224,6 +229,7 @@ class UserConfig { this.passwordLessRecovery, this.fcmToken, this.lastFcmWakeupAt, + this.lastServerMessageAt, this.currentSetupPage, required this.skipSetupPages, required this.hasZoomed, @@ -287,6 +293,7 @@ class UserConfig { passwordLessRecovery.hashCode ^ fcmToken.hashCode ^ lastFcmWakeupAt.hashCode ^ + lastServerMessageAt.hashCode ^ currentSetupPage.hashCode ^ skipSetupPages.hashCode ^ hasZoomed.hashCode; @@ -359,6 +366,7 @@ class UserConfig { passwordLessRecovery == other.passwordLessRecovery && fcmToken == other.fcmToken && lastFcmWakeupAt == other.lastFcmWakeupAt && + lastServerMessageAt == other.lastServerMessageAt && currentSetupPage == other.currentSetupPage && skipSetupPages == other.skipSetupPages && hasZoomed == other.hasZoomed; diff --git a/lib/main.dart b/lib/main.dart index 39bd2259..d0ea2628 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -133,7 +133,6 @@ void main() async { final settingsController = SettingsChangeProvider()..loadSettings(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - unawaited(BackupService.initFileDownloader()); if (userExists) { unawaited(FcmNotificationService.initAfterUserLoaded()); diff --git a/lib/src/constants/secure_storage.keys.dart b/lib/src/constants/secure_storage.keys.dart deleted file mode 100644 index a6da571c..00000000 --- a/lib/src/constants/secure_storage.keys.dart +++ /dev/null @@ -1,15 +0,0 @@ -class SecureStorageKeys { - @Deprecated('Use the secure storage in rust') - static const String signalIdentity = 'signal_identity'; - @Deprecated('Use the secure storage in rust') - static const String signalSignedPreKey = 'signed_pre_key_store'; - @Deprecated('Use the login token') - static const String apiAuthToken = 'api_auth_token'; - - @Deprecated('Use user.json file') - static const String userData = 'userData'; - - // Not required for backup... - static const String lastServerMessageTimestamp = - 'last_server_message_timestamp'; -} diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 7abe3f11..9c231205 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -608,40 +608,16 @@ abstract class AppLocalizations { /// **'Open system settings to allow push notifications.'** String get settingsNotifyPermissionDesc; - /// No description provided for @settingsNotifyTroubleshooting. - /// - /// In en, this message translates to: - /// **'Troubleshooting'** - String get settingsNotifyTroubleshooting; - - /// No description provided for @settingsNotifyTroubleshootingDesc. - /// - /// In en, this message translates to: - /// **'Click here if you have problems receiving push notifications.'** - String get settingsNotifyTroubleshootingDesc; - - /// No description provided for @settingsNotifyTroubleshootingNoProblem. - /// - /// In en, this message translates to: - /// **'No problem detected'** - String get settingsNotifyTroubleshootingNoProblem; - - /// No description provided for @settingsNotifyTroubleshootingNoProblemDesc. - /// - /// In en, this message translates to: - /// **'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?'** + /// **'Reset notification tokens'** String get settingsNotifyResetTitle; /// No description provided for @settingsNotifyResetTitleSubtitle. /// /// In en, this message translates to: - /// **'If you haven\'t received any test notifications, click here to reset your notification tokens.'** + /// **'Reset your notification tokens if you have problems receiving push notifications.'** String get settingsNotifyResetTitleSubtitle; /// No description provided for @settingsNotifyResetTitleReset. diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 095ab75d..8ff73f8f 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -279,26 +279,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Systemeinstellungen öffnen, um Push-Benachrichtigungen zu erlauben.'; @override - String get settingsNotifyTroubleshooting => 'Fehlersuche'; - - @override - String get settingsNotifyTroubleshootingDesc => - 'Hier klicken, wenn Probleme beim Empfang von Push-Benachrichtigungen auftreten.'; - - @override - String get settingsNotifyTroubleshootingNoProblem => - 'Kein Problem festgestellt'; - - @override - String get settingsNotifyTroubleshootingNoProblemDesc => - 'Um eine Testbenachrichtigung zu erhalten, klicke auf OK. Falls du die Testbenachrichtigung nicht erhältst, klicke bitte auf den neuen Menüpunkt, der nach dem Klicken auf „OK“ angezeigt wird.'; - - @override - String get settingsNotifyResetTitle => 'Keine Testbenachrichtigung erhalten?'; + String get settingsNotifyResetTitle => 'Benachrichtigungstoken zurücksetzen'; @override String get settingsNotifyResetTitleSubtitle => - 'Falls du keine Testbenachrichtigungen erhalten hast, klicke hier, um deine Benachrichtigungstoken zurückzusetzen.'; + 'Setze deine Benachrichtigungstoken zurück, wenn du Probleme beim Empfang von Push-Benachrichtigungen hast.'; @override String get settingsNotifyResetTitleReset => diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index d7936b11..5da5fb6d 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -276,25 +276,11 @@ class AppLocalizationsEn extends AppLocalizations { 'Open system settings to allow push notifications.'; @override - String get settingsNotifyTroubleshooting => 'Troubleshooting'; - - @override - String get settingsNotifyTroubleshootingDesc => - 'Click here if you have problems receiving push notifications.'; - - @override - String get settingsNotifyTroubleshootingNoProblem => 'No problem detected'; - - @override - String get settingsNotifyTroubleshootingNoProblemDesc => - 'Press OK to receive a test notification. If you do not receive the test notification, please click on the new menu item that appears after you click “OK”.'; - - @override - String get settingsNotifyResetTitle => 'Didn\'t receive a test notification?'; + String get settingsNotifyResetTitle => 'Reset notification tokens'; @override String get settingsNotifyResetTitleSubtitle => - 'If you haven\'t received any test notifications, click here to reset your notification tokens.'; + 'Reset your notification tokens if you have problems receiving push notifications.'; @override String get settingsNotifyResetTitleReset => diff --git a/lib/src/services/backup.service.dart b/lib/src/services/backup.service.dart index 9e87bea7..1cc64ee2 100644 --- a/lib/src/services/backup.service.dart +++ b/lib/src/services/backup.service.dart @@ -1,9 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; -import 'package:background_downloader/background_downloader.dart'; import 'package:clock/clock.dart' as clock; -import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:mutex/mutex.dart'; import 'package:twonly/core/bridge/wrapper/backup.dart'; @@ -20,6 +19,9 @@ import 'package:twonly/src/utils/storage.dart'; class BackupService { static final Mutex _protected = Mutex(); + static const _retryDelay = Duration(minutes: 15); + static const _uploadTimeout = Duration(minutes: 10); + static Timer? _retryTimer; static String _getIdentityBackupUrl(String backupId) => '${RustApi.apiBaseUrl(protocol: 'https')}backup/identity/$backupId'; @@ -30,40 +32,12 @@ class BackupService { static final _backupUpdateController = StreamController.broadcast(); static Stream get onBackupUpdated => _backupUpdateController.stream; - static Future initFileDownloader() async { - FileDownloader().updates.listen((update) async { - switch (update) { - case TaskStatusUpdate(): - if (update.task.taskId.contains('backup_')) { - await handleBackupStatusUpdate(update.task.taskId, update); - } - case TaskProgressUpdate(): - Log.info( - 'Progress update for ${update.task} with progress ${update.progress}', - ); - } + static void _scheduleRetry() { + if (_retryTimer?.isActive ?? false) return; + _retryTimer = Timer(_retryDelay, () { + _retryTimer = null; + unawaited(makeBackup()); }); - - await FileDownloader().start(); - try { - var androidConfig = []; - if (!kReleaseMode) { - androidConfig = [(Config.bypassTLSCertificateValidation, true)]; - } - await FileDownloader().configure(androidConfig: androidConfig); - } catch (error) { - Log.error(error); - } - - if (!kReleaseMode) { - FileDownloader().configureNotification( - running: const TaskNotification( - 'Uploading/Downloading', - '{filename} ({progress}).', - ), - progressBar: true, - ); - } } static Future getData() async { @@ -92,36 +66,87 @@ class BackupService { unawaited(makeBackup(force: true)); } - static Future handleBackupStatusUpdate( - String taskId, - TaskStatusUpdate update, - ) async { - var status = LastBackupUploadState.success; + static Future _saveStatus(CurrentBackupStatus backup) async { + await KeyValueStore.put( + KeyValueKeys.currentBackupState, + backup.toJson(), + ); + _backupUpdateController.add(null); + } - if (update.status == TaskStatus.failed || - update.status == TaskStatus.canceled) { - status = LastBackupUploadState.failed; - } else if (update.status != TaskStatus.complete) { - Log.info('Backup is in state: ${update.status}'); - return; - } - await _protected.protect(() async { - final backup = await getData(); - if (taskId == 'backup_identity') { - backup - ..identityLastSuccessFull = clock.clock.now() - ..identityState = status; - } else { - backup - ..archiveLastSuccessFull = clock.clock.now() - ..archiveState = status; - } - await KeyValueStore.put( - KeyValueKeys.currentBackupState, - backup.toJson(), + static bool _isSuccessful(http.BaseResponse response) => + response.statusCode >= 200 && response.statusCode < 300; + + static Future _uploadIdentity( + String backupId, + List encryptedBackup, + ) async { + final client = http.Client(); + try { + final response = await client + .put( + Uri.parse(_getIdentityBackupUrl(backupId)), + headers: const {'Content-Type': 'application/octet-stream'}, + body: encryptedBackup, + ) + .timeout(_uploadTimeout); + if (_isSuccessful(response)) return true; + Log.error( + 'Identity backup upload failed with status ${response.statusCode}.', ); - _backupUpdateController.add(null); - }); + } catch (error, stackTrace) { + Log.error( + 'Identity backup upload failed.', + error: error, + stackTrace: stackTrace, + ); + } finally { + client.close(); + } + return false; + } + + static Future _uploadArchive( + String backupDownloadToken, + File archive, + Map headers, + ) async { + final client = http.Client(); + try { + final request = http.MultipartRequest( + 'POST', + Uri.parse(_getArchiveBackupUrl(backupDownloadToken, null)), + )..headers.addAll(headers); + request.files.add( + await http.MultipartFile.fromPath( + 'file', + archive.path, + filename: archive.uri.pathSegments.last, + ), + ); + + final streamedResponse = await client + .send(request) + .timeout( + _uploadTimeout, + ); + final response = await http.Response.fromStream( + streamedResponse, + ).timeout(_uploadTimeout); + if (_isSuccessful(response)) return true; + Log.error( + 'Archive backup upload failed with status ${response.statusCode}.', + ); + } catch (error, stackTrace) { + Log.error( + 'Archive backup upload failed.', + error: error, + stackTrace: stackTrace, + ); + } finally { + client.close(); + } + return false; } static Future makeBackup({bool force = false}) async { @@ -132,65 +157,59 @@ class BackupService { final lastWeek = clock.clock.now().subtract(const Duration(days: 7)); if (force || + backup.identityState != LastBackupUploadState.success || backup.identityLastSuccessFull == null || - (backup.identityState != LastBackupUploadState.pending && - backup.identityLastSuccessFull!.isBefore(lastWeek) || - backup.identityLastSuccessFull!.isBefore( - lastWeek.subtract(const Duration(days: 1)), - ))) { + backup.identityLastSuccessFull!.isBefore(lastWeek)) { final backupId = await RustBackupIdentity.getBackupId(); if (backupId == null) { Log.warn('No backup password was set by the user.'); backup.identityState = LastBackupUploadState.failed; + await _saveStatus(backup); await UserService.update((u) => u.isBackupEnabled = false); } else { Log.info('Performing a identity backup.'); - final encryptedBackup = - await RustBackupIdentity.getIdentityBackupBytes(); + List? encryptedBackup; + try { + encryptedBackup = await RustBackupIdentity.getIdentityBackupBytes(); + } catch (error, stackTrace) { + Log.error( + 'Creating identity backup failed.', + error: error, + stackTrace: stackTrace, + ); + backup.identityState = LastBackupUploadState.failed; + await _saveStatus(backup); + _scheduleRetry(); + } - final backupTempFile = File( - '${AppEnvironment.cacheDir}/identity_backup.bin', - )..writeAsBytesSync(encryptedBackup); + if (encryptedBackup != null) { + Log.info( + 'Identity backup has a size of ${encryptedBackup.length}.', + ); - Log.info( - 'Identity backup has a size of ${backupTempFile.statSync().size}.', - ); - - final task = UploadTask.fromFile( - taskId: 'backup_identity', - httpRequestMethod: 'PUT', - file: backupTempFile, - url: _getIdentityBackupUrl(backupId), - post: 'binary', - retries: 2, - headers: { - 'Content-Type': 'application/octet-stream', - }, - ); - if (await FileDownloader().enqueue(task)) { - Log.info('Starting upload from backup identity.'); backup ..identityState = LastBackupUploadState.pending - ..identityLastSuccessFull = clock.clock.now() ..identitySize = encryptedBackup.length; - await KeyValueStore.put( - KeyValueKeys.currentBackupState, - backup.toJson(), - ); - _backupUpdateController.add(null); - } else { - Log.error('Error starting upload task for backup identity.'); + await _saveStatus(backup); + + if (await _uploadIdentity(backupId, encryptedBackup)) { + Log.info('Identity backup uploaded.'); + backup + ..identityState = LastBackupUploadState.success + ..identityLastSuccessFull = clock.clock.now(); + } else { + backup.identityState = LastBackupUploadState.failed; + _scheduleRetry(); + } + await _saveStatus(backup); } } } if (force || + backup.archiveState != LastBackupUploadState.success || backup.archiveLastSuccessFull == null || - (backup.archiveState != LastBackupUploadState.pending && - backup.archiveLastSuccessFull!.isBefore(lastDay) || - backup.archiveLastSuccessFull!.isBefore( - lastDay.subtract(const Duration(days: 1)), - ))) { + backup.archiveLastSuccessFull!.isBefore(lastDay)) { Log.info('Creating a archive backup.'); late final String backupArchive; late final String backupDownloadToken; @@ -199,6 +218,9 @@ class BackupService { await RustBackupArchive.createBackupArchive(); } catch (e) { Log.warn('Creating archive backup failed: $e'); + backup.archiveState = LastBackupUploadState.failed; + await _saveStatus(backup); + _scheduleRetry(); return; } Log.info( @@ -210,31 +232,28 @@ class BackupService { headers = await RustApi.authenticationHeaders(); } catch (error) { Log.error('Could not load authentication headers', error: error); + backup.archiveState = LastBackupUploadState.failed; + await _saveStatus(backup); + _scheduleRetry(); return; } - final task = UploadTask.fromFile( - taskId: 'backup_archive', - file: File(backupArchive), - url: _getArchiveBackupUrl(backupDownloadToken, null), - priority: 0, - retries: 10, - headers: headers, - ); - if (await FileDownloader().enqueue(task)) { - Log.info('Uploading backup archive.'); + final archive = File(backupArchive); + backup + ..archiveState = LastBackupUploadState.pending + ..archiveSize = archive.statSync().size; + await _saveStatus(backup); + + if (await _uploadArchive(backupDownloadToken, archive, headers)) { + Log.info('Backup archive uploaded.'); backup - ..archiveState = LastBackupUploadState.pending - ..archiveLastSuccessFull = clock.clock.now() - ..archiveSize = File(backupArchive).statSync().size; - await KeyValueStore.put( - KeyValueKeys.currentBackupState, - backup.toJson(), - ); - _backupUpdateController.add(null); + ..archiveState = LastBackupUploadState.success + ..archiveLastSuccessFull = clock.clock.now(); } else { - Log.error('Error starting upload task for backup archive.'); + backup.archiveState = LastBackupUploadState.failed; + _scheduleRetry(); } + await _saveStatus(backup); } }); } @@ -360,7 +379,7 @@ class BackupService { password: password, ); - await deleteLocalUserData(); + await deleteLocalUserData(removeCredentials: true); await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); return _nextBackupStage(); } @@ -376,7 +395,7 @@ class BackupService { userId: userId, )..state = BackupRecoveryState.archiveBackupStarted; - await deleteLocalUserData(); + await deleteLocalUserData(removeCredentials: true); // Import KeyManager keys into secure storage & in-memory key manager await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes); diff --git a/lib/src/services/notifications/fcm.notifications.dart b/lib/src/services/notifications/fcm.notifications.dart index 7e6179ce..8231bf05 100644 --- a/lib/src/services/notifications/fcm.notifications.dart +++ b/lib/src/services/notifications/fcm.notifications.dart @@ -4,9 +4,8 @@ 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'; +import 'package:twonly/core/bridge/user_config.dart'; import 'package:twonly/locator.dart'; -import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/log.dart'; @@ -139,45 +138,24 @@ class FcmNotificationService { } } - static Future updateLastServerMessageTimestamp() async { - const storage = FlutterSecureStorage(); - final nowMs = DateTime.now().millisecondsSinceEpoch.toString(); - try { - await storage.write( - key: SecureStorageKeys.lastServerMessageTimestamp, - value: nowMs, - iOptions: const IOSOptions( - groupId: 'CN332ZUGRP.eu.twonly.shared', - accessibility: KeychainAccessibility.first_unlock, - ), - ); - Log.info('Updated last server message timestamp to $nowMs'); - } catch (e) { - Log.error('Could not write last server message timestamp: $e'); - } - } - static Future _checkFcmHealthAndResetIfNeeded() async { if (!userService.isUserCreated) { Log.info('FCM health check skipped: user is not yet created.'); return; } - const storage = FlutterSecureStorage(); try { - final lastServerStr = await storage.read( - key: SecureStorageKeys.lastServerMessageTimestamp, - iOptions: const IOSOptions( - groupId: 'CN332ZUGRP.eu.twonly.shared', - accessibility: KeychainAccessibility.first_unlock, - ), - ); + final config = await UserConfigApi.load(); + if (config == null) { + Log.warn('FCM health check skipped: user configuration is missing.'); + return; + } final now = DateTime.now(); final threeDaysAgo = now.subtract(const Duration(days: 3)); // Recorded by the Rust notification worker, because neither platform // starts Flutter for a background wake-up any more. - final lastFcmWakeup = userService.currentUser.lastFcmWakeupAt; + final lastFcmWakeup = config.lastFcmWakeupAt; final lastFcmTime = lastFcmWakeup == null ? null : DateTime.fromMillisecondsSinceEpoch(lastFcmWakeup * 1000); @@ -190,13 +168,10 @@ class FcmNotificationService { Log.info('No record of a message received via FCM messaging system.'); } - DateTime? lastServerTime; - if (lastServerStr != null) { - final ms = int.tryParse(lastServerStr); - if (ms != null) { - lastServerTime = DateTime.fromMillisecondsSinceEpoch(ms); - } - } + final lastServerMessage = config.lastServerMessageAt; + final lastServerTime = lastServerMessage == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastServerMessage * 1000); final fcmInactive = lastFcmTime == null || lastFcmTime.isBefore(threeDaysAgo); diff --git a/lib/src/services/notifications/native.notifications.dart b/lib/src/services/notifications/native.notifications.dart index 83b2b08b..f9d4368e 100644 --- a/lib/src/services/notifications/native.notifications.dart +++ b/lib/src/services/notifications/native.notifications.dart @@ -10,14 +10,15 @@ import 'package:twonly/src/utils/log.dart'; /// through a platform channel instead of the Firebase Messaging plugin, which /// no longer owns background delivery. /// -/// Only the opaque conversation identifier crosses the channel; the Flutter -/// route is built here so the native layer stays free of routing knowledge. +/// Only opaque notification metadata crosses the channel; Flutter owns the +/// routing decision. class NativeNotificationService { static const MethodChannel _channel = MethodChannel( 'eu.twonly/notificationTap', ); static const String _conversationIdKey = 'conversation_id'; + static const String _notificationKindKey = 'notification_kind'; static const String _notificationIdsKey = 'notification_ids'; static const String _badgeCountKey = 'badge_count'; @@ -25,25 +26,28 @@ class NativeNotificationService { /// delivered or cleared. static const String _outboxTable = 'notification_outbox'; - static final StreamController _taps = - StreamController.broadcast(); + static final StreamController _taps = + StreamController.broadcast(); /// Lives for the whole process: the badge has to follow the outbox for as /// long as the app runs. // ignore: cancel_subscriptions static StreamSubscription>? _outboxChanges; - /// Emits the conversation id of every notification tapped while the app is - /// running. A `null` value means the notification had no specific - /// conversation and should only open the chats tab. - static Stream get taps => _taps.stream; + /// Emits metadata for every native notification tapped while the app runs. + static Stream get taps => _taps.stream; + + /// Text messages (including quoted replies) open their conversation. Media + /// and every other notification kind stay on the chat overview. + static bool opensConversation(String? kind) => + kind == 'text' || kind == 'response'; static void init() { _startBadgeSync(); if (!Platform.isAndroid) return; _channel.setMethodCallHandler((call) async { if (call.method != 'onNotificationTapped') return; - _taps.add(_conversationIdOf(call.arguments)); + _taps.add(_tapOf(call.arguments)); }); } @@ -112,14 +116,14 @@ class NativeNotificationService { /// Returns the tap that launched the app, or `null` when the app was not /// started from a native notification. The result is consumed once. - static Future<({String? conversationId})?> consumeInitialTap() async { + static Future consumeInitialTap() async { if (!Platform.isAndroid) return null; try { final result = await _channel.invokeMapMethod( 'consumeInitialNotification', ); if (result == null) return null; - return (conversationId: _conversationIdOf(result)); + return _tapOf(result); } catch (e) { Log.error('Could not read the initial native notification: $e'); return null; @@ -150,4 +154,18 @@ class NativeNotificationService { if (conversationId is! String || conversationId.isEmpty) return null; return conversationId; } + + static String? _notificationKindOf(Object? arguments) { + if (arguments is! Map) return null; + final notificationKind = arguments[_notificationKindKey]; + if (notificationKind is! String || notificationKind.isEmpty) return null; + return notificationKind; + } + + static NativeNotificationTap _tapOf(Object? arguments) => ( + conversationId: _conversationIdOf(arguments), + kind: _notificationKindOf(arguments), + ); } + +typedef NativeNotificationTap = ({String? conversationId, String? kind}); diff --git a/lib/src/services/user.service.dart b/lib/src/services/user.service.dart index 39a5cf66..f8ebeeb0 100644 --- a/lib/src/services/user.service.dart +++ b/lib/src/services/user.service.dart @@ -5,7 +5,6 @@ import 'package:twonly/core/bridge/user_config.dart'; import 'package:twonly/core/user_config.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/utils/log.dart'; -import 'package:twonly/src/utils/secure_storage.dart'; class UserService { late UserConfig currentUser; @@ -22,39 +21,6 @@ class UserService { return isUserCreated; } - static Future getUser() async { - try { - final config = await UserConfigApi.load(); - if (config != null) return config; - - // One-time migration from the pre-user.json secure-storage format. - final userDataJson = await SecureStorage.instance.read( - key: 'userData', - ); - - if (userDataJson != null) { - final migrated = await UserConfigApi.importJson(json: userDataJson); - await _removeLegacySecureStorageUser(); - return migrated; - } - - return null; - } catch (e) { - Log.error('could not load user: $e'); - rethrow; - } - } - - static Future _removeLegacySecureStorageUser() async { - try { - await SecureStorage.instance.delete(key: 'userData'); - } catch (e) { - Log.error('Could not delete user data from SecureStorage: $e'); - } - - Log.info('Migrated user data from SecureStorage to KeyValueStore'); - } - static Future update( void Function(UserConfig userData) updateUser, ) async { diff --git a/lib/src/utils/secure_storage.dart b/lib/src/utils/secure_storage.dart deleted file mode 100644 index b2d62241..00000000 --- a/lib/src/utils/secure_storage.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -class SecureStorage { - static const FlutterSecureStorage instance = FlutterSecureStorage(); -} diff --git a/lib/src/utils/storage.dart b/lib/src/utils/storage.dart index ae28a451..8c293823 100644 --- a/lib/src/utils/storage.dart +++ b/lib/src/utils/storage.dart @@ -2,11 +2,18 @@ import 'dart:async'; import 'dart:ui'; import 'package:path_provider/path_provider.dart'; +import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/utils/secure_storage.dart'; -Future deleteLocalUserData() async { +/// Deletes local databases and files. +/// +/// Set [removeCredentials] only when the current account is intentionally +/// abandoned or its key manager is about to be replaced during recovery. +Future deleteLocalUserData({bool removeCredentials = false}) async { + if (removeCredentials) { + await RustKeyManager.removeLocalCredentials(); + } await twonlyDB.close(); // Wait for the background drift isolate to potentially shut down await Future.delayed(const Duration(milliseconds: 200)); @@ -19,7 +26,6 @@ Future deleteLocalUserData() async { if (appDir.existsSync()) { appDir.deleteSync(recursive: true); } - await SecureStorage.instance.deleteAll(); locator ..unregister() ..registerLazySingleton(TwonlyDB.new); diff --git a/lib/src/visual/themes/light.dart b/lib/src/visual/themes/light.dart index 8625e8f0..ecf7ff80 100644 --- a/lib/src/visual/themes/light.dart +++ b/lib/src/visual/themes/light.dart @@ -9,7 +9,9 @@ ThemeData getLightTheme([Color primary = defaultPrimaryColor]) { colorScheme: ColorScheme.fromSeed( seedColor: primary, primary: primary, + surface: Colors.white, ), + scaffoldBackgroundColor: Colors.white, inputDecorationTheme: const InputDecorationTheme( border: OutlineInputBorder(), ), diff --git a/lib/src/visual/views/home.view.dart b/lib/src/visual/views/home.view.dart index 5ad95aef..8ba09987 100644 --- a/lib/src/visual/views/home.view.dart +++ b/lib/src/visual/views/home.view.dart @@ -50,7 +50,7 @@ class HomeViewState extends State with WidgetsBindingObserver { StreamSubscription? _onMessageOpenedAppSub; StreamSubscription? _homeViewPageIndexSub; StreamSubscription? _selectNotificationSub; - StreamSubscription? _nativeNotificationSub; + StreamSubscription? _nativeNotificationSub; StreamSubscription<(String, MediaType)>? _sharedMediaSub; static Uri? pendingSharedLink; @@ -111,7 +111,10 @@ class HomeViewState extends State with WidgetsBindingObserver { message, ) { Log.info('Opened app from iOS/Remote push notification tap.'); - streamHomeViewPageIndex.add(0); + _openNotification( + conversationId: _notificationDataValue(message, 'conversation_id'), + kind: _notificationDataValue(message, 'notification_kind'), + ); }); _nativeNotificationSub = NativeNotificationService.taps.listen( @@ -199,19 +202,29 @@ class HomeViewState extends State with WidgetsBindingObserver { }); } - void _openNativeNotification(String? conversationId) { + void _openNativeNotification(NativeNotificationTap tap) { Log.info('Opened app from a native push notification tap.'); - if (conversationId != null) { + _openNotification(conversationId: tap.conversationId, kind: tap.kind); + } + + void _openNotification({String? conversationId, String? kind}) { + if (conversationId != null && + NativeNotificationService.opensConversation(kind)) { routerProvider.go(Routes.chatsMessages(conversationId)); } streamHomeViewPageIndex.add(0); } + String? _notificationDataValue(RemoteMessage message, String key) { + final value = message.data[key]; + return value is String && value.isNotEmpty ? value : null; + } + Future _initAsync() async { final initialNativeTap = await NativeNotificationService.consumeInitialTap(); if (initialNativeTap != null) { - _openNativeNotification(initialNativeTap.conversationId); + _openNativeNotification(initialNativeTap); } final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin @@ -231,7 +244,16 @@ class HomeViewState extends State with WidgetsBindingObserver { notificationAppLaunchDetails.didNotificationLaunchApp)) { if (initialRemoteMessage != null) { Log.info('App launched from iOS/Remote push notification tap.'); - streamHomeViewPageIndex.add(0); + _openNotification( + conversationId: _notificationDataValue( + initialRemoteMessage, + 'conversation_id', + ), + kind: _notificationDataValue( + initialRemoteMessage, + 'notification_kind', + ), + ); } else if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) { final payload = diff --git a/lib/src/visual/views/recovery_from_secure_storage.view.dart b/lib/src/visual/views/recovery_from_secure_storage.view.dart index 85ad4a94..fa0d4de2 100644 --- a/lib/src/visual/views/recovery_from_secure_storage.view.dart +++ b/lib/src/visual/views/recovery_from_secure_storage.view.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:restart_app/restart_app.dart'; -import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/services/backup.service.dart'; -import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; @@ -55,12 +53,7 @@ class _RecoveryViewState extends State { } Future _registerNewAccount() async { - try { - await RustKeyManager.removeKeyManager(); - } catch (e) { - Log.error('Could not remove KeyManager during account reset: $e'); - } - await deleteLocalUserData(); + await deleteLocalUserData(removeCredentials: true); if (!mounted) return; await Restart.restartApp( notificationTitle: 'twonly', diff --git a/lib/src/visual/views/settings/account.view.dart b/lib/src/visual/views/settings/account.view.dart index 24e301ff..36e859e3 100644 --- a/lib/src/visual/views/settings/account.view.dart +++ b/lib/src/visual/views/settings/account.view.dart @@ -65,7 +65,7 @@ class AccountView extends StatelessWidget { ); return; } - await deleteLocalUserData(); + await deleteLocalUserData(removeCredentials: true); await Restart.restartApp( notificationTitle: 'Account successfully deleted', notificationBody: 'Click here to open the app again', diff --git a/lib/src/visual/views/settings/appearance.view.dart b/lib/src/visual/views/settings/appearance.view.dart index 59bcfdd3..4b043e75 100644 --- a/lib/src/visual/views/settings/appearance.view.dart +++ b/lib/src/visual/views/settings/appearance.view.dart @@ -190,21 +190,23 @@ class _AppearanceViewState extends State { ); } - Future toggleShowNewsIcon() async { + Future updateHideNewsIcon(bool hideNewsIcon) async { await UserService.update((u) { - u.showNewsShortcut = !u.showNewsShortcut; + u.showNewsShortcut = !hideNewsIcon; }); } - Future toggleStartWithCameraOpen() async { + Future updateStartWithCameraOpen(bool startWithCameraOpen) async { await UserService.update((u) { - u.startWithCameraOpen = !u.startWithCameraOpen; + u.startWithCameraOpen = startWithCameraOpen; }); } - Future toggleShowImagePreviewWhenSending() async { + Future updateShowImagePreviewWhenSending( + bool showImagePreviewWhenSending, + ) async { await UserService.update((u) { - u.showShowImagePreviewWhenSending = !u.showShowImagePreviewWhenSending; + u.showShowImagePreviewWhenSending = showImagePreviewWhenSending; }); } @@ -231,6 +233,12 @@ class _AppearanceViewState extends State { body: StreamBuilder( stream: userService.onUserUpdated, builder: (context, snapshot) { + final hideNewsIcon = !userService.currentUser.showNewsShortcut; + final startWithCameraOpen = + userService.currentUser.startWithCameraOpen; + final showImagePreviewWhenSending = + userService.currentUser.showShowImagePreviewWhenSending; + return ListView( children: [ ListTile( @@ -260,27 +268,34 @@ class _AppearanceViewState extends State { ), ListTile( title: Text(context.lang.hideNewsIcon), - onTap: toggleShowNewsIcon, + onTap: () async { + await updateHideNewsIcon(!hideNewsIcon); + }, trailing: Switch.adaptive( - value: !userService.currentUser.showNewsShortcut, - onChanged: (a) => toggleShowNewsIcon(), + value: hideNewsIcon, + onChanged: updateHideNewsIcon, ), ), ListTile( title: Text(context.lang.startWithCameraOpen), - onTap: toggleStartWithCameraOpen, + onTap: () async { + await updateStartWithCameraOpen(!startWithCameraOpen); + }, trailing: Switch.adaptive( - value: userService.currentUser.startWithCameraOpen, - onChanged: (a) => toggleStartWithCameraOpen(), + value: startWithCameraOpen, + onChanged: updateStartWithCameraOpen, ), ), ListTile( title: Text(context.lang.showImagePreviewWhenSending), - onTap: toggleShowImagePreviewWhenSending, + onTap: () async { + await updateShowImagePreviewWhenSending( + !showImagePreviewWhenSending, + ); + }, trailing: Switch.adaptive( - value: - userService.currentUser.showShowImagePreviewWhenSending, - onChanged: (a) => toggleShowImagePreviewWhenSending(), + value: showImagePreviewWhenSending, + onChanged: updateShowImagePreviewWhenSending, ), ), ], diff --git a/lib/src/visual/views/settings/developer/developer.view.dart b/lib/src/visual/views/settings/developer/developer.view.dart index a13865bd..43cc071c 100644 --- a/lib/src/visual/views/settings/developer/developer.view.dart +++ b/lib/src/visual/views/settings/developer/developer.view.dart @@ -530,7 +530,7 @@ class _DeveloperSettingsViewState extends State { 'If you do not have a backup, you have to register with a new account.', ); if (ok) { - await deleteLocalUserData(); + await deleteLocalUserData(removeCredentials: true); await Restart.restartApp( notificationTitle: 'Account successfully deleted', notificationBody: 'Click here to open the app again', diff --git a/lib/src/visual/views/settings/developer/informations.view.dart b/lib/src/visual/views/settings/developer/informations.view.dart index 7383df43..0f3019f8 100644 --- a/lib/src/visual/views/settings/developer/informations.view.dart +++ b/lib/src/visual/views/settings/developer/informations.view.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:twonly/core/bridge/user_config.dart'; import 'package:twonly/locator.dart'; -import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; class DeveloperInformationsView extends StatefulWidget { @@ -14,7 +13,8 @@ class DeveloperInformationsView extends StatefulWidget { } class _DeveloperInformationsViewState extends State { - String? _lastServerTimestamp; + DateTime? _lastFcmTimestamp; + DateTime? _lastServerTimestamp; @override void initState() { @@ -23,18 +23,20 @@ class _DeveloperInformationsViewState extends State { } Future _loadInformations({bool showFeedback = false}) async { - const storage = FlutterSecureStorage(); try { - final lastServer = await storage.read( - key: SecureStorageKeys.lastServerMessageTimestamp, - iOptions: const IOSOptions( - groupId: 'CN332ZUGRP.eu.twonly.shared', - accessibility: KeychainAccessibility.first_unlock, - ), - ); + final config = await UserConfigApi.load(); if (mounted) { setState(() { - _lastServerTimestamp = lastServer; + final lastFcmWakeup = config?.lastFcmWakeupAt; + final lastServerMessage = config?.lastServerMessageAt; + _lastFcmTimestamp = lastFcmWakeup == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastFcmWakeup * 1000); + _lastServerTimestamp = lastServerMessage == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + lastServerMessage * 1000, + ); }); if (showFeedback) { showSnackbar( @@ -47,20 +49,8 @@ class _DeveloperInformationsViewState extends State { } catch (_) {} } - String _formatFcmWakeup() { - final seconds = userService.currentUser.lastFcmWakeupAt; - if (seconds == null) return 'Never'; - return DateTime.fromMillisecondsSinceEpoch( - seconds * 1000, - ).toLocal().toString(); - } - - String _formatTimestamp(String? timestampStr) { - if (timestampStr == null) return 'Never'; - final ms = int.tryParse(timestampStr); - if (ms == null) return 'Invalid: $timestampStr'; - final dt = DateTime.fromMillisecondsSinceEpoch(ms); - return dt.toLocal().toString(); + String _formatTimestamp(DateTime? timestamp) { + return timestamp?.toLocal().toString() ?? 'Never'; } @override @@ -93,7 +83,7 @@ class _DeveloperInformationsViewState extends State { const Divider(), ListTile( title: const Text('Last FCM Message'), - subtitle: Text(_formatFcmWakeup()), + subtitle: Text(_formatTimestamp(_lastFcmTimestamp)), ), ListTile( title: const Text('Last Server Message'), diff --git a/lib/src/visual/views/settings/notification.view.dart b/lib/src/visual/views/settings/notification.view.dart index 0738969b..bfa60c9b 100644 --- a/lib/src/visual/views/settings/notification.view.dart +++ b/lib/src/visual/views/settings/notification.view.dart @@ -1,8 +1,5 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:twonly/locator.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; @@ -15,9 +12,7 @@ class NotificationView extends StatefulWidget { } class _NotificationViewState extends State { - bool _isLoadingTroubleshooting = false; bool _isLoadingReset = false; - bool _troubleshootingDidRun = false; bool? _hasNotificationPermission; @override @@ -35,39 +30,7 @@ class _NotificationViewState extends State { } } - Future _troubleshooting() async { - setState(() { - _isLoadingTroubleshooting = true; - }); - - await FcmNotificationService.initFCMAfterAuthenticated(force: true); - - if (!mounted) return; - - if (userService.currentUser.fcmToken == null) { - final platform = Platform.isAndroid ? "Google's" : "Apple's"; - await showAlertDialog( - context, - 'Problem detected', - 'twonly is not able to register your app to $platform push server infrastructure. For Android that can happen when you do not have the Google Play Services installed. If you theses installed and want to help us to fix the issue please send us your debug log in Settings > Help > Debug log.', - ); - } else { - final run = await showAlertDialog( - context, - context.lang.settingsNotifyTroubleshootingNoProblem, - context.lang.settingsNotifyTroubleshootingNoProblemDesc, - ); - - if (run) { - _troubleshootingDidRun = true; - } - } - setState(() { - _isLoadingTroubleshooting = false; - }); - } - - Future resetTokens() async { + Future _resetTokens() async { setState(() { _isLoadingReset = true; }); @@ -97,21 +60,7 @@ class _NotificationViewState extends State { subtitle: Text(context.lang.settingsNotifyPermissionDesc), onTap: openAppSettings, ), - ListTile( - title: Text(context.lang.settingsNotifyTroubleshooting), - subtitle: Text(context.lang.settingsNotifyTroubleshootingDesc), - trailing: _isLoadingTroubleshooting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ) - : null, - onTap: _isLoadingTroubleshooting ? null : _troubleshooting, - ), - if (_troubleshootingDidRun) + if (_hasNotificationPermission == true) ListTile( title: Text(context.lang.settingsNotifyResetTitle), subtitle: Text(context.lang.settingsNotifyResetTitleSubtitle), @@ -124,7 +73,7 @@ class _NotificationViewState extends State { ), ) : null, - onTap: _isLoadingReset ? null : resetTokens, + onTap: _isLoadingReset ? null : _resetTokens, ), ], ), diff --git a/pubspec.lock b/pubspec.lock index c5ff523d..7fabdd8b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -103,14 +103,6 @@ packages: relative: true source: path version: "1.5.0" - background_downloader: - dependency: "direct main" - description: - name: background_downloader - sha256: "4cb23d9ad4f5060944f38164e7b90d4bf99b57b2472a3bd4676e59b2db4afd06" - url: "https://pub.dev" - source: hosted - version: "9.5.4" blurhash_dart: dependency: "direct main" description: @@ -704,54 +696,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" - url: "https://pub.dev" - source: hosted - version: "10.3.1" - flutter_secure_storage_darwin: - dependency: transitive - description: - name: flutter_secure_storage_darwin - sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" - url: "https://pub.dev" - source: hosted - version: "0.3.2" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 - url: "https://pub.dev" - source: hosted - version: "3.0.1" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" - url: "https://pub.dev" - source: hosted - version: "4.2.2" flutter_sharing_intent: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8448787d..fa679d0a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,11 +68,9 @@ dependencies: # With high download. (But should be checked nonetheless.) app_links: ^7.0.0 # 1.6 mio - flutter_secure_storage: ^10.3.1 # 1.85 mio permission_handler: ^12.0.0+1 # 2 mio # Not yet checked - background_downloader: ^9.4.0 cryptography_flutter_plus: ^3.0.0 cryptography_plus: ^3.0.0 flutter_android_volume_keydown: ^1.0.1 diff --git a/rust/src/api/messages/incoming.rs b/rust/src/api/messages/incoming.rs index c971aec8..56ddfbc6 100644 --- a/rust/src/api/messages/incoming.rs +++ b/rust/src/api/messages/incoming.rs @@ -249,9 +249,6 @@ pub(crate) async fn handle_decoded_server_message( message: proto::Message, ) -> Result<()> { tracing::Span::current().record("receipt_id", &message.receipt_id); - if let Ok(user) = ctx.user_id().await { - tracing::Span::current().record("user", user); - } if message.receipt_id.is_empty() { return Err(TwonlyError::Generic( @@ -430,8 +427,14 @@ pub(crate) async fn handle_decoded_server_message( Receipt::clear_pending_plaintext(&mut t, &message.receipt_id).await?; t.commit().await?; - ctx.mark_incoming_committed(); + if let Err(error) = crate::user_config::UserConfig::update(ctx, |user| { + user.last_server_message_at = Some(crate::utils::current_time().timestamp()); + }) { + tracing::warn!(%error, "could not record the last server-message timestamp"); + } + + ctx.mark_incoming_committed(); let ctx = ctx.clone(); tokio::spawn(async move { diff --git a/rust/src/bridge/wrapper/key_manager.rs b/rust/src/bridge/wrapper/key_manager.rs index 2ad94021..f8413eb3 100644 --- a/rust/src/bridge/wrapper/key_manager.rs +++ b/rust/src/bridge/wrapper/key_manager.rs @@ -84,9 +84,10 @@ impl RustKeyManager { } } - pub async fn remove_key_manager() -> Result<()> { + pub async fn remove_local_credentials() -> Result<()> { let ctx = get_twonly_flutter()?; crate::keys::KeyManager::remove_from_keychain(&ctx.secure_storage)?; + ctx.secure_storage.delete("api_auth_token")?; Ok(()) } diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 446646c1..4ad89108 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 174490644; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1370816897; // Section: executor @@ -4463,18 +4463,18 @@ let api_registration_id = ::sse_decode(&mut deserializer);deserializer.end( })().await) } }) } -fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl( +fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, data_len_: i32, ) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "rust_key_manager_remove_key_manager", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::(flutter_rust_bridge::for_generated::TaskInfo{ debug_name: "rust_key_manager_remove_local_credentials", port: Some(port_), mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal }, move || { let message = unsafe { flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(ptr_, rust_vec_len_, data_len_) }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>((move || async move { - let output_ok = crate::bridge::wrapper::key_manager::RustKeyManager::remove_key_manager().await?; Ok(output_ok) + let output_ok = crate::bridge::wrapper::key_manager::RustKeyManager::remove_local_credentials().await?; Ok(output_ok) })().await) } }) } @@ -6399,6 +6399,7 @@ impl SseDecode for crate::user_config::UserConfig { >::sse_decode(deserializer); let mut var_fcmToken = >::sse_decode(deserializer); let mut var_lastFcmWakeupAt = >::sse_decode(deserializer); + let mut var_lastServerMessageAt = >::sse_decode(deserializer); let mut var_currentSetupPage = >::sse_decode(deserializer); let mut var_skipSetupPages = ::sse_decode(deserializer); let mut var_hasZoomed = ::sse_decode(deserializer); @@ -6460,6 +6461,7 @@ impl SseDecode for crate::user_config::UserConfig { password_less_recovery: var_passwordLessRecovery, fcm_token: var_fcmToken, last_fcm_wakeup_at: var_lastFcmWakeupAt, + last_server_message_at: var_lastServerMessageAt, current_setup_page: var_currentSetupPage, skip_setup_pages: var_skipSetupPages, has_zoomed: var_hasZoomed, @@ -6611,7 +6613,7 @@ fn pde_ffi_dispatcher_primary_impl( 122 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), 123 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), 124 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), -125 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len), +125 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), 126 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), 127 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), 128 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), @@ -7534,6 +7536,7 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig { self.password_less_recovery.into_into_dart().into_dart(), self.fcm_token.into_into_dart().into_dart(), self.last_fcm_wakeup_at.into_into_dart().into_dart(), + self.last_server_message_at.into_into_dart().into_dart(), self.current_setup_page.into_into_dart().into_dart(), self.skip_setup_pages.into_into_dart().into_dart(), self.has_zoomed.into_into_dart().into_dart(), @@ -8464,6 +8467,7 @@ impl SseEncode for crate::user_config::UserConfig { ); >::sse_encode(self.fcm_token, serializer); >::sse_encode(self.last_fcm_wakeup_at, serializer); + >::sse_encode(self.last_server_message_at, serializer); >::sse_encode(self.current_setup_page, serializer); ::sse_encode(self.skip_setup_pages, serializer); ::sse_encode(self.has_zoomed, serializer); diff --git a/rust/src/user_config.rs b/rust/src/user_config.rs index c6904fbd..098f2642 100644 --- a/rust/src/user_config.rs +++ b/rust/src/user_config.rs @@ -327,6 +327,12 @@ pub struct UserConfig { #[serde(default)] #[frb(non_final)] pub last_fcm_wakeup_at: Option, + /// Unix seconds of the last server message that was committed locally. + /// Kept next to the FCM wake-up timestamp so notification health can be + /// evaluated without a second secure-storage implementation in Dart. + #[serde(default)] + #[frb(non_final)] + pub last_server_message_at: Option, #[frb(non_final)] pub current_setup_page: Option, #[serde(default)] @@ -460,6 +466,7 @@ mod tests { assert!(config.can_use_login_token_for_auth); assert!(!config.is_user_discovery_enabled); + assert_eq!(config.last_server_message_at, None); } #[test] diff --git a/test/mocks/platform_channels.dart b/test/mocks/platform_channels.dart index 9128ea87..8cb97cc7 100644 --- a/test/mocks/platform_channels.dart +++ b/test/mocks/platform_channels.dart @@ -1,57 +1,7 @@ -// ignore_for_file: avoid_dynamic_calls - -import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void setupPlatformChannelMocks() { - final secureStorageMock = {}; - Future mockHandler(MethodCall methodCall) async { - final userId = Zone.current[#userId] as int?; - final keyPrefix = userId != null ? '${userId}_' : ''; - if (methodCall.method == 'read') { - final key = methodCall.arguments['key'] as String; - return secureStorageMock[keyPrefix + key]; - } else if (methodCall.method == 'write') { - final key = methodCall.arguments['key'] as String; - final value = methodCall.arguments['value'] as String; - secureStorageMock[keyPrefix + key] = value; - return true; - } else if (methodCall.method == 'delete') { - final key = methodCall.arguments['key'] as String; - secureStorageMock.remove(keyPrefix + key); - return true; - } else if (methodCall.method == 'readAll') { - final result = {}; - secureStorageMock.forEach((k, v) { - if (k.startsWith(keyPrefix)) { - result[k.substring(keyPrefix.length)] = v; - } - }); - return result; - } else if (methodCall.method == 'deleteAll') { - if (userId != null) { - secureStorageMock.removeWhere((k, v) => k.startsWith(keyPrefix)); - } else { - secureStorageMock.clear(); - } - return true; - } - return null; - } - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel( - 'plugins.it_crowd.double_tapp/flutter_secure_storage', - ), - mockHandler, - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), - mockHandler, - ); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( const MethodChannel('dev.fluttercommunity.plus/connectivity'), @@ -62,14 +12,4 @@ void setupPlatformChannelMocks() { return null; }, ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('com.bbflight.background_downloader'), - (call) async { - if (call.method == 'enqueue') { - return true; - } - return null; - }, - ); } diff --git a/test/services/backup_service_test.dart b/test/services/backup_service_test.dart index 8a09a547..d4d7febe 100644 --- a/test/services/backup_service_test.dart +++ b/test/services/backup_service_test.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:background_downloader/background_downloader.dart'; import 'package:drift/native.dart'; import 'package:flutter/services.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; @@ -33,15 +32,6 @@ void main() { late Map initialUserData; setUpAll(() async { - const channel = MethodChannel('com.bbflight.background_downloader'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (methodCall) async { - if (methodCall.method == 'enqueue') { - return true; - } - return null; - }); - const pathProviderChannel = MethodChannel( 'plugins.flutter.io/path_provider', ); @@ -130,59 +120,6 @@ void main() { expect(data.archiveLastSuccessFull, isNull); }); - test( - 'onBackupUpdated stream emits events when backup status changes', - () async { - var eventEmitted = false; - final subscription = BackupService.onBackupUpdated.listen((_) { - eventEmitted = true; - }); - - final dummyTask = UploadTask(url: 'http://localhost', filename: 'test'); - await BackupService.handleBackupStatusUpdate( - 'backup_identity', - TaskStatusUpdate(dummyTask, TaskStatus.complete), - ); - - await Future.delayed(Duration.zero); - expect(eventEmitted, isTrue); - await subscription.cancel(); - }, - ); - - test( - 'handleBackupStatusUpdate updates identity and archive status correctly', - () async { - // Test success update for identity status - final dummyTask1 = UploadTask( - url: 'http://localhost', - filename: 'test', - ); - await BackupService.handleBackupStatusUpdate( - 'backup_identity', - TaskStatusUpdate(dummyTask1, TaskStatus.complete), - ); - - var data = await BackupService.getData(); - expect(data.identityState, LastBackupUploadState.success); - expect(data.identityLastSuccessFull, isNotNull); - - // Test failure update for archive status - final dummyTask2 = UploadTask( - url: 'http://localhost', - filename: 'test', - ); - await BackupService.handleBackupStatusUpdate( - 'backup_archive', - TaskStatusUpdate(dummyTask2, TaskStatus.failed), - ); - - data = await BackupService.getData(); - expect(data.archiveState, LastBackupUploadState.failed); - expect(data.archiveLastSuccessFull, isNotNull); - }, - ); - test( 'startFullBackupRecovery returns usernameNotValid for offline/unknown user', () async { diff --git a/test/services/notification_routing_test.dart b/test/services/notification_routing_test.dart new file mode 100644 index 00000000..c1475191 --- /dev/null +++ b/test/services/notification_routing_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:twonly/src/services/notifications/native.notifications.dart'; + +void main() { + test('text notifications open their conversation', () { + expect(NativeNotificationService.opensConversation('text'), isTrue); + expect(NativeNotificationService.opensConversation('response'), isTrue); + }); + + test('media notifications stay on the chat overview', () { + for (final kind in ['twonly', 'image', 'video', 'audio']) { + expect( + NativeNotificationService.opensConversation(kind), + isFalse, + reason: kind, + ); + } + }); + + test('missing and non-message kinds stay on the chat overview', () { + expect(NativeNotificationService.opensConversation(null), isFalse); + expect(NativeNotificationService.opensConversation('reaction'), isFalse); + }); +}