improve video, remove deps, fix notifications
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-08-31 23:00:09 +02:00
parent 0495f7326f
commit 6f597a08a1
39 changed files with 457 additions and 651 deletions

1
.gitignore vendored
View file

@ -59,3 +59,4 @@ fastlane/repo/status/running.json
.cache/
# Widget Preview related
.widget_preview/
ios/build/

View file

@ -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.

View file

@ -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"),

View file

@ -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,
),
)
}
}

View file

@ -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 {

@ -1 +1 @@
Subproject commit f46500ce45dbce12ee4431e5becb759ac7f26098
Subproject commit 60da6275f8c82c4238f0c3cf88a798e63b4a2ffd

View file

@ -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

View file

@ -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.

View file

@ -50,8 +50,8 @@ class RustKeyManager {
registrationId: registrationId,
);
static Future<void> removeKeyManager() => RustLib.instance.api
.crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager();
static Future<void> removeLocalCredentials() => RustLib.instance.api
.crateBridgeWrapperKeyManagerRustKeyManagerRemoveLocalCredentials();
/// Serialize the key_manager. Needed for the passwordless_recovery feature.
static Future<Uint8List> serialize() => RustLib.instance.api

View file

@ -86,7 +86,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
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<void> crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager();
Future<void>
crateBridgeWrapperKeyManagerRustKeyManagerRemoveLocalCredentials();
Future<Uint8List> crateBridgeWrapperKeyManagerRustKeyManagerSerialize();
@ -4961,7 +4962,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
@override
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerRemoveKeyManager() {
Future<void>
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<dynamic>;
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);

View file

@ -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;

View file

@ -133,7 +133,6 @@ void main() async {
final settingsController = SettingsChangeProvider()..loadSettings();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
unawaited(BackupService.initFileDownloader());
if (userExists) {
unawaited(FcmNotificationService.initAfterUserLoaded());

View file

@ -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';
}

View file

@ -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.

View file

@ -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 =>

View file

@ -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 =>

View file

@ -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<void>.broadcast();
static Stream<void> get onBackupUpdated => _backupUpdateController.stream;
static Future<void> 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<CurrentBackupStatus> getData() async {
@ -92,36 +66,87 @@ class BackupService {
unawaited(makeBackup(force: true));
}
static Future<void> handleBackupStatusUpdate(
String taskId,
TaskStatusUpdate update,
) async {
var status = LastBackupUploadState.success;
static Future<void> _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<bool> _uploadIdentity(
String backupId,
List<int> 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<bool> _uploadArchive(
String backupDownloadToken,
File archive,
Map<String, String> 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<void> 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<int>? 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);

View file

@ -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<void> 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<void> _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);

View file

@ -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<String?> _taps =
StreamController<String?>.broadcast();
static final StreamController<NativeNotificationTap> _taps =
StreamController<NativeNotificationTap>.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<List<String>>? _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<String?> get taps => _taps.stream;
/// Emits metadata for every native notification tapped while the app runs.
static Stream<NativeNotificationTap> 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<NativeNotificationTap?> consumeInitialTap() async {
if (!Platform.isAndroid) return null;
try {
final result = await _channel.invokeMapMethod<String, dynamic>(
'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});

View file

@ -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<UserConfig?> 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<void> _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<void> update(
void Function(UserConfig userData) updateUser,
) async {

View file

@ -1,5 +0,0 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureStorage {
static const FlutterSecureStorage instance = FlutterSecureStorage();
}

View file

@ -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<bool> 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<bool> 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<bool> deleteLocalUserData() async {
if (appDir.existsSync()) {
appDir.deleteSync(recursive: true);
}
await SecureStorage.instance.deleteAll();
locator
..unregister<TwonlyDB>()
..registerLazySingleton<TwonlyDB>(TwonlyDB.new);

View file

@ -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(),
),

View file

@ -50,7 +50,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<int>? _homeViewPageIndexSub;
StreamSubscription<NotificationResponse>? _selectNotificationSub;
StreamSubscription<String?>? _nativeNotificationSub;
StreamSubscription<NativeNotificationTap>? _nativeNotificationSub;
StreamSubscription<(String, MediaType)>? _sharedMediaSub;
static Uri? pendingSharedLink;
@ -111,7 +111,10 @@ class HomeViewState extends State<HomeView> 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<HomeView> 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<void> _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<HomeView> 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 =

View file

@ -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<RecoveryView> {
}
Future<void> _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',

View file

@ -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',

View file

@ -190,21 +190,23 @@ class _AppearanceViewState extends State<AppearanceView> {
);
}
Future<void> toggleShowNewsIcon() async {
Future<void> updateHideNewsIcon(bool hideNewsIcon) async {
await UserService.update((u) {
u.showNewsShortcut = !u.showNewsShortcut;
u.showNewsShortcut = !hideNewsIcon;
});
}
Future<void> toggleStartWithCameraOpen() async {
Future<void> updateStartWithCameraOpen(bool startWithCameraOpen) async {
await UserService.update((u) {
u.startWithCameraOpen = !u.startWithCameraOpen;
u.startWithCameraOpen = startWithCameraOpen;
});
}
Future<void> toggleShowImagePreviewWhenSending() async {
Future<void> updateShowImagePreviewWhenSending(
bool showImagePreviewWhenSending,
) async {
await UserService.update((u) {
u.showShowImagePreviewWhenSending = !u.showShowImagePreviewWhenSending;
u.showShowImagePreviewWhenSending = showImagePreviewWhenSending;
});
}
@ -231,6 +233,12 @@ class _AppearanceViewState extends State<AppearanceView> {
body: StreamBuilder<void>(
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<AppearanceView> {
),
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,
),
),
],

View file

@ -530,7 +530,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
'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',

View file

@ -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<DeveloperInformationsView> {
String? _lastServerTimestamp;
DateTime? _lastFcmTimestamp;
DateTime? _lastServerTimestamp;
@override
void initState() {
@ -23,18 +23,20 @@ class _DeveloperInformationsViewState extends State<DeveloperInformationsView> {
}
Future<void> _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<DeveloperInformationsView> {
} 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<DeveloperInformationsView> {
const Divider(),
ListTile(
title: const Text('Last FCM Message'),
subtitle: Text(_formatFcmWakeup()),
subtitle: Text(_formatTimestamp(_lastFcmTimestamp)),
),
ListTile(
title: const Text('Last Server Message'),

View file

@ -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<NotificationView> {
bool _isLoadingTroubleshooting = false;
bool _isLoadingReset = false;
bool _troubleshootingDidRun = false;
bool? _hasNotificationPermission;
@override
@ -35,39 +30,7 @@ class _NotificationViewState extends State<NotificationView> {
}
}
Future<void> _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<void> resetTokens() async {
Future<void> _resetTokens() async {
setState(() {
_isLoadingReset = true;
});
@ -97,21 +60,7 @@ class _NotificationViewState extends State<NotificationView> {
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<NotificationView> {
),
)
: null,
onTap: _isLoadingReset ? null : resetTokens,
onTap: _isLoadingReset ? null : _resetTokens,
),
],
),

View file

@ -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:

View file

@ -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

View file

@ -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 {

View file

@ -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(())
}

View file

@ -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 = <i64>::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::SseCodec,_,_,_>(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::SseCodec,_,_,_>(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 {
<Option<crate::user_config::PasswordlessRecoveryConfig>>::sse_decode(deserializer);
let mut var_fcmToken = <Option<String>>::sse_decode(deserializer);
let mut var_lastFcmWakeupAt = <Option<i64>>::sse_decode(deserializer);
let mut var_lastServerMessageAt = <Option<i64>>::sse_decode(deserializer);
let mut var_currentSetupPage = <Option<String>>::sse_decode(deserializer);
let mut var_skipSetupPages = <bool>::sse_decode(deserializer);
let mut var_hasZoomed = <bool>::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 {
);
<Option<String>>::sse_encode(self.fcm_token, serializer);
<Option<i64>>::sse_encode(self.last_fcm_wakeup_at, serializer);
<Option<i64>>::sse_encode(self.last_server_message_at, serializer);
<Option<String>>::sse_encode(self.current_setup_page, serializer);
<bool>::sse_encode(self.skip_setup_pages, serializer);
<bool>::sse_encode(self.has_zoomed, serializer);

View file

@ -327,6 +327,12 @@ pub struct UserConfig {
#[serde(default)]
#[frb(non_final)]
pub last_fcm_wakeup_at: Option<i64>,
/// 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<i64>,
#[frb(non_final)]
pub current_setup_page: Option<String>,
#[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]

View file

@ -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 = <String, String>{};
Future<dynamic> 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 = <String, String>{};
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;
},
);
}

View file

@ -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<String, dynamic> 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 {

View file

@ -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);
});
}