support background execution
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-03-14 15:05:10 +01:00
parent 60ed2775bb
commit 5a2049fd4a
18 changed files with 333 additions and 81 deletions

View file

@ -3,6 +3,7 @@
## 0.0.98
- New: Groups can now collect flames as well
- New: Background execution to pre-load messages
- New: Adds a link if the image contains a QR code
- Improve: Video compression with progress updates
- Improve: Show message "Flames restored"

View file

@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<application
android:label="twonly"
android:name="${applicationName}"
android:name=".MyApplication"
android:allowBackup="false"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher">

View file

@ -0,0 +1,13 @@
package eu.twonly
import io.flutter.app.FlutterApplication
import dev.fluttercommunity.workmanager.WorkmanagerDebug
import dev.fluttercommunity.workmanager.LoggingDebugHandler
class MyApplication : FlutterApplication() {
override fun onCreate() {
super.onCreate()
// This enables the internal plugin logging to Logcat
WorkmanagerDebug.setCurrent(LoggingDebugHandler())
}
}

View file

@ -4,6 +4,7 @@ import Foundation
import UIKit
import UserNotifications
import flutter_sharing_intent
import workmanager_apple
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@ -16,6 +17,17 @@ import flutter_sharing_intent
if let registrar = self.registrar(forPlugin: "VideoCompressionChannel") {
VideoCompressionChannel.register(with: registrar.messenger())
}
WorkmanagerDebug.setCurrent(LoggingDebugHandler())
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "eu.twonly.periodic_task",
frequency: NSNumber(value: 20 * 60)
)
WorkmanagerPlugin.registerBGProcessingTask(
withIdentifier: "eu.twonly.processing_task"
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

View file

@ -2,27 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
@ -43,6 +24,17 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>SharingMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FIREBASE_ANALYTICS_COLLECTION_ENABLED</key>
@ -51,10 +43,10 @@
<false/>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Use your camera to make photos or videos and share them encrypted with your friends.</string>
<key>NSFaceIDUsageDescription</key>
@ -63,12 +55,39 @@
<string>Use your microphone to enable audio when making videos.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>twonly will save photos or videos to your library.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.twonly.periodic_task</string>
<string>eu.twonly.processing_task</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
@ -89,19 +108,5 @@
</array>
<key>firebase_performance_collection_deactivated</key>
<true/>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>SharingMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -31,7 +31,9 @@ void Function(SubscriptionPlan plan) globalCallbackUpdatePlan = (plan) {};
Map<String, VoidCallback> globalUserDataChangedCallBack = {};
bool globalIsAppInBackground = true;
bool globalIsInBackgroundTask = false;
bool globalAllowErrorTrackingViaSentry = false;
bool globalGotMessageFromServer = false;
late String globalApplicationCacheDirectory;
late String globalApplicationSupportDirectory;

View file

@ -17,9 +17,10 @@ import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/services/api/mediafiles/media_background.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/backup/create.backup.dart';
import 'package:twonly/src/services/fcm.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/log.dart';
@ -46,6 +47,7 @@ void main() async {
}
unawaited(performTwonlySafeBackup());
unawaited(initializeBackgroundTaskManager());
} else {
Log.info('User is not yet register. Ensure all local data is removed.');
await deleteLocalUserData();

View file

@ -0,0 +1,4 @@
class KeyValueKeys {
static const String lastPeriodicTaskExecution =
'last_periodic_task_execution';
}

View file

@ -121,6 +121,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
..where(
(t) => (t.uploadState.equals(UploadState.initialized.name) |
t.uploadState.equals(UploadState.uploadLimitReached.name) |
t.uploadState.equals(UploadState.uploading.name) |
t.uploadState.equals(UploadState.preprocessing.name)),
))
.get();

View file

@ -4,6 +4,7 @@ import 'package:hashlib/random.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/tables/receipts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/log.dart';
part 'receipts.dao.g.dart';
@ -33,6 +34,7 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
type: const Value(MessageActionType.ackByUserAt),
),
);
await handleMediaRelatedResponseFromReceiver(receipt.messageId!);
}
await (delete(receipts)

View file

@ -30,9 +30,9 @@ import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/api/server_messages.dart';
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/services/fcm.service.dart';
import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/group.services.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/signal/utils.signal.dart';
@ -90,7 +90,11 @@ class ApiService {
await initFCMAfterAuthenticated();
globalCallbackConnectionState(isConnected: true);
if (!globalIsAppInBackground) {
if (globalIsInBackgroundTask) {
await retransmitRawBytes();
await tryTransmitMessages();
await tryDownloadAllMediaFiles();
} else if (!globalIsAppInBackground) {
unawaited(retransmitRawBytes());
unawaited(tryTransmitMessages());
unawaited(tryDownloadAllMediaFiles());
@ -124,6 +128,7 @@ class ApiService {
}
Future<void> startReconnectionTimer() async {
if (globalIsInBackgroundTask) return;
if (reconnectionTimer?.isActive ?? false) {
return;
}
@ -330,7 +335,9 @@ class ApiService {
}
}
if (res.isError) {
Log.warn('Got error from server: ${res.error}');
if (res.error != ErrorCode.ForegroundSessionConnected) {
Log.warn('Got error from server: ${res.error}');
}
if (res.error == ErrorCode.AppVersionOutdated) {
globalCallbackAppIsOutdated();
Log.warn('App Version is OUTDATED.');
@ -395,6 +402,7 @@ class ApiService {
..userId = Int64(userId)
..appVersion = (await PackageInfo.fromPlatform()).version
..deviceId = Int64(user.deviceId)
..inBackground = globalIsInBackgroundTask
..authToken = base64Decode(apiAuthToken);
final handshake = Handshake()..authenticate = authenticate;
@ -404,12 +412,19 @@ class ApiService {
if (result.isSuccess) {
Log.info('websocket is authenticated');
unawaited(onAuthenticated());
if (globalIsInBackgroundTask) {
await onAuthenticated();
} else {
unawaited(onAuthenticated());
}
return true;
}
if (result.isError) {
if (result.error != ErrorCode.AuthTokenNotValid) {
Log.error('got error while authenticating to the server: $result');
if (result.error != ErrorCode.AuthTokenNotValid &&
result.error != ErrorCode.ForegroundSessionConnected) {
Log.error(
'got error while authenticating to the server: ${result.error}',
);
return false;
}
}

View file

@ -1,6 +1,7 @@
import 'package:clock/clock.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> handleReaction(
@ -17,6 +18,8 @@ Future<void> handleReaction(
reaction.remove,
);
await handleMediaRelatedResponseFromReceiver(reaction.targetMessageId);
if (!reaction.remove) {
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
}

View file

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:twonly/globals.dart';
@ -75,30 +74,7 @@ Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
if (update.status == TaskStatus.complete) {
if (update.responseStatusCode == 200) {
Log.info('Upload of ${media.mediaId} success!');
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,
const MediaFilesCompanion(
uploadState: Value(UploadState.uploaded),
),
);
/// As the messages where send in a bulk acknowledge all messages.
final messages =
await twonlyDB.messagesDao.getMessagesByMediaId(media.mediaId);
for (final message in messages) {
final contacts =
await twonlyDB.groupsDao.getGroupNonLeftMembers(message.groupId);
for (final contact in contacts) {
await twonlyDB.messagesDao.handleMessageAckByServer(
contact.contactId,
message.messageId,
clock.now(),
);
}
}
await markUploadAsSuccessful(media);
return;
}
Log.error(
@ -123,6 +99,20 @@ Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
'Background status $mediaId with status ${update.status} and ${update.responseStatusCode}. ',
);
if (update.status == TaskStatus.waitingToRetry) {
if (update.responseStatusCode == 401) {
// auth token is not valid, so either create a new task with a new token, or cancel task
final mediaService = MediaFileService(media);
await FileDownloader().cancelTaskWithId(update.task.taskId);
Log.info('Cancel task, already uploaded or will be reuploaded');
if (mediaService.mediaFile.uploadState != UploadState.uploaded) {
await mediaService.setUploadState(UploadState.uploading);
// In all other cases just try the upload again...
await startBackgroundMediaUpload(mediaService);
}
}
}
if (update.status == TaskStatus.failed ||
update.status == TaskStatus.canceled) {
Log.error(
@ -130,8 +120,11 @@ Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
);
final mediaService = MediaFileService(media);
await mediaService.setUploadState(UploadState.uploading);
// In all other cases just try the upload again...
await startBackgroundMediaUpload(mediaService);
// in case the media file is already uploaded to not reqtry
if (mediaService.mediaFile.uploadState != UploadState.uploaded) {
await mediaService.setUploadState(UploadState.uploading);
// In all other cases just try the upload again...
await startBackgroundMediaUpload(mediaService);
}
}
}

View file

@ -24,11 +24,14 @@ import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:workmanager/workmanager.dart' hide TaskStatus;
Future<void> finishStartedPreprocessing() async {
final mediaFiles =
await twonlyDB.mediaFilesDao.getAllMediaFilesPendingUpload();
Log.info('There are ${mediaFiles.length} media files pending');
for (final mediaFile in mediaFiles) {
if (mediaFile.isDraftMedia) {
continue;
@ -55,6 +58,47 @@ Future<void> finishStartedPreprocessing() async {
}
}
/// It can happen, that a media files is uploaded but not yet marked for been uploaded.
/// For example because the background_downloader plugin has not yet reported the finished upload.
/// In case the the message receipts or a reaction was received, mark the media file as been uploaded.
Future<void> handleMediaRelatedResponseFromReceiver(String messageId) async {
final message =
await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull();
if (message == null || message.mediaId == null) return;
final media = await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
if (media == null) return;
if (media.uploadState != UploadState.uploaded) {
Log.info('Media was not yet marked as uploaded. Doing it now.');
await markUploadAsSuccessful(media);
}
}
Future<void> markUploadAsSuccessful(MediaFile media) async {
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,
const MediaFilesCompanion(
uploadState: Value(UploadState.uploaded),
),
);
/// As the messages where send in a bulk acknowledge all messages.
final messages =
await twonlyDB.messagesDao.getMessagesByMediaId(media.mediaId);
for (final message in messages) {
final contacts =
await twonlyDB.groupsDao.getGroupNonLeftMembers(message.groupId);
for (final contact in contacts) {
await twonlyDB.messagesDao.handleMessageAckByServer(
contact.contactId,
message.messageId,
clock.now(),
);
}
}
}
Future<MediaFileService?> initializeMediaUpload(
MediaType type,
int? displayLimitInMilliseconds, {
@ -344,8 +388,9 @@ Future<void> _uploadUploadRequest(MediaFileService media) async {
final connectivityResult = await Connectivity().checkConnectivity();
if (!connectivityResult.contains(ConnectivityResult.mobile) &&
!connectivityResult.contains(ConnectivityResult.wifi)) {
if (globalIsInBackgroundTask ||
!connectivityResult.contains(ConnectivityResult.mobile) &&
!connectivityResult.contains(ConnectivityResult.wifi)) {
// no internet, directly put it into the background...
await FileDownloader().enqueue(task);
await media.setUploadState(UploadState.backgroundUploadTaskStarted);
@ -376,15 +421,30 @@ Future<void> uploadFileFastOrEnqueue(
);
try {
final workmanagerUniqueName =
'progressing_finish_uploads_${media.mediaFile.mediaId}';
await Workmanager().registerOneOffTask(
workmanagerUniqueName,
'eu.twonly.processing_task',
initialDelay: const Duration(minutes: 15),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
Log.info('Uploading fast: ${task.taskId}');
final response =
await requestMultipart.send().timeout(const Duration(seconds: 8));
final response = await requestMultipart.send();
var status = TaskStatus.failed;
if (response.statusCode == 200) {
status = TaskStatus.complete;
} else if (response.statusCode == 404) {
status = TaskStatus.notFound;
}
await Workmanager().cancelByUniqueName(workmanagerUniqueName);
await handleUploadStatusUpdate(
TaskStatusUpdate(
task,

View file

@ -63,6 +63,7 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
..response = response;
await apiService.sendResponse(ClientToServer()..v0 = v0);
globalGotMessageFromServer = true;
});
}

View file

@ -0,0 +1,130 @@
import 'dart:async';
import 'package:path_provider/path_provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:workmanager/workmanager.dart';
// ignore: unreachable_from_main
Future<void> initializeBackgroundTaskManager() async {
await Workmanager().initialize(callbackDispatcher);
await Workmanager().registerPeriodicTask(
'fetch_data_from_server',
'eu.twonly.periodic_task',
frequency: const Duration(minutes: 20),
initialDelay: const Duration(minutes: 5),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
}
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
switch (task) {
case 'eu.twonly.periodic_task':
if (await initBackgroundExecution()) {
await handlePeriodicTask();
}
case 'eu.twonly.processing_task':
if (await initBackgroundExecution()) {
await handleProcessingTask();
}
default:
Log.error('Unknown task was executed: $task');
}
return Future.value(true);
});
}
Future<bool> initBackgroundExecution() async {
SentryWidgetsFlutterBinding.ensureInitialized();
initLogger();
final user = await getUser();
if (user == null) return false;
gUser = user;
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path;
twonlyDB = TwonlyDB();
apiService = ApiService();
globalIsInBackgroundTask = true;
return true;
}
Future<bool> handlePeriodicTask() async {
final lastExecution =
await KeyValueStore.get(KeyValueKeys.lastPeriodicTaskExecution);
if (lastExecution == null || !lastExecution.containsKey('timestamp')) {
final lastExecutionTime = lastExecution?['timestamp'] as int?;
if (lastExecutionTime != null) {
final lastExecution =
DateTime.fromMillisecondsSinceEpoch(lastExecutionTime);
if (DateTime.now().difference(lastExecution).inMinutes < 2) {
Log.info(
'eu.twonly.periodic_task not executed as last execution was within the last two minutes.',
);
return true;
}
}
}
await KeyValueStore.put(KeyValueKeys.lastPeriodicTaskExecution, {
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
Log.info('eu.twonly.periodic_task was called.');
final stopwatch = Stopwatch()..start();
if (!await apiService.connect()) {
Log.info('Could not connect to the api. Returning early.');
return false;
}
if (!apiService.isAuthenticated) {
Log.info('Api is not authenticated. Returning early.');
return false;
}
while (!globalGotMessageFromServer) {
if (stopwatch.elapsed.inSeconds >= 15) {
Log.info('No new message from the server after 15 seconds.');
break;
}
await Future.delayed(const Duration(milliseconds: 500));
}
if (globalGotMessageFromServer) {
Log.info('Received a server message from the server.');
}
await finishStartedPreprocessing();
await Future.delayed(const Duration(milliseconds: 2000));
await apiService.close(() {});
stopwatch.stop();
Log.info('eu.twonly.periodic_task finished after ${stopwatch.elapsed}.');
return true;
}
Future<void> handleProcessingTask() async {
Log.info('eu.twonly.processing_task was called.');
final stopwatch = Stopwatch()..start();
await finishStartedPreprocessing();
Log.info('eu.twonly.processing_task finished after ${stopwatch.elapsed}.');
}

View file

@ -8,11 +8,12 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
import '../../firebase_options.dart';
import '../../../firebase_options.dart';
// see more here: https://firebase.google.com/docs/cloud-messaging/flutter/receive?hl=de
@ -111,8 +112,15 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
initLogger();
// Log.info('Handling a background message: ${message.messageId}');
await handleRemoteMessage(message);
// make sure every thing run...
await Future.delayed(const Duration(milliseconds: 2000));
if (Platform.isAndroid) {
if (await initBackgroundExecution()) {
await handlePeriodicTask();
}
} else {
// make sure every thing run...
await Future.delayed(const Duration(milliseconds: 2000));
}
}
Future<void> handleRemoteMessage(RemoteMessage message) async {

View file

@ -7,7 +7,7 @@ import 'package:hashlib/random.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/model/protobuf/client/generated/push_notification.pb.dart';
import 'package:twonly/src/services/fcm.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';