From eb8b7d31de3530593ecb2b809d757f1b05fce93a Mon Sep 17 00:00:00 2001 From: otsmr Date: Fri, 14 Nov 2025 00:02:00 +0100 Subject: [PATCH 1/4] fix analyzer --- lib/src/database/twonly_database_old.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/database/twonly_database_old.dart b/lib/src/database/twonly_database_old.dart index 1125a21..75ec3c2 100644 --- a/lib/src/database/twonly_database_old.dart +++ b/lib/src/database/twonly_database_old.dart @@ -31,7 +31,6 @@ part 'twonly_database_old.g.dart'; SignalContactSignedPreKeys, MessageRetransmissions, ], - daos: [], ) class TwonlyDatabaseOld extends _$TwonlyDatabaseOld { TwonlyDatabaseOld([QueryExecutor? e]) From 72495b4c2a265c61e90718004ad928eee7376086 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 15 Nov 2025 00:01:47 +0100 Subject: [PATCH 2/4] fix #310 --- .vscode/launch.json | 11 --- android/app/build.gradle | 2 +- .../src/main/kotlin/eu/twonly/MainActivity.kt | 95 +++++++++++++++++++ .../kotlin/eu/twonly/MyMediaStorageProxy.kt | 6 ++ ios/Runner/AppDelegate.swift | 82 ++++++++-------- ios/Runner/Info.plist | 21 ++++ lib/main.dart | 12 +-- .../mediafiles/compression.service.dart | 10 +- .../views/settings/data_and_storage.view.dart | 59 ++++++------ .../data_and_storage/export_media.view.dart | 51 ++++++++-- .../data_and_storage/import_media.view.dart | 6 +- pubspec.lock | 8 +- 12 files changed, 254 insertions(+), 109 deletions(-) delete mode 100644 .vscode/launch.json create mode 100644 android/app/src/main/kotlin/eu/twonly/MyMediaStorageProxy.kt diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 763eb9e..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "configurations": [ - { - "name": "Flutter", - "type": "dart", - "request": "launch", - "program": "lib/main.dart", - "flutterMode": "profile" - } - ] -} \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 0d01190..361ae10 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -19,7 +19,7 @@ android { // compileSdk = flutter.compileSdkVersion compileSdk 36 //ndkVersion = flutter.ndkVersion - ndkVersion = "27.0.12077973" + ndkVersion = "28.2.13676358" compileOptions { coreLibraryDesugaringEnabled true diff --git a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt index bb90f69..bbe7f45 100644 --- a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt +++ b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt @@ -1,12 +1,27 @@ package eu.twonly +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import io.flutter.embedding.android.FlutterFragmentActivity import android.view.KeyEvent import dev.darttools.flutter_android_volume_keydown.FlutterAndroidVolumeKeydownPlugin.eventSink import android.view.KeyEvent.KEYCODE_VOLUME_DOWN import android.view.KeyEvent.KEYCODE_VOLUME_UP +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.io.OutputStream class MainActivity : FlutterFragmentActivity() { + + private val MEDIA_STORE_CHANNEL = "eu.twonly/mediaStore" + private lateinit var channel: MethodChannel + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { if (keyCode == KEYCODE_VOLUME_DOWN && eventSink != null) { eventSink!!.success(true) @@ -18,4 +33,84 @@ class MainActivity : FlutterFragmentActivity() { } return super.onKeyDown(keyCode, event) } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MEDIA_STORE_CHANNEL) + + channel.setMethodCallHandler {call, result -> + try { + if (call.method == "safeFileToDownload") { + val arguments = call.arguments>() as Map + val sourceFile = arguments["sourceFile"] + if (sourceFile == null) { + result.success(false) + } else { + + val context = applicationContext + val inputStream = FileInputStream(File(sourceFile)) + + val outputName = File(sourceFile).name.takeIf { it.isNotEmpty() } ?: "memories.zip" + + val savedUri = saveZipToDownloads(context, outputName, inputStream) + if (savedUri != null) { + result.success(savedUri.toString()) + } else { + result.error("SAVE_FAILED", "Could not save ZIP", null) + } + } + } else { + result.notImplemented() + } + } catch (e: Exception) { + result.error("EXCEPTION", e.message, null) + } + } + } +} + +fun saveZipToDownloads( + context: Context, + fileName: String = "archive.zip", + sourceStream: InputStream +): android.net.Uri? { + val resolver = context.contentResolver + + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/zip") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + + val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + MediaStore.Files.getContentUri("external") + } + + val uri = resolver.insert(collection, contentValues) ?: return null + + try { + resolver.openOutputStream(uri).use { out: OutputStream? -> + requireNotNull(out) { "Unable to open output stream" } + sourceStream.use { input -> + input.copyTo(out) + } + out.flush() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } + resolver.update(uri, done, null, null) + } + + return uri + } catch (e: Exception) { + try { resolver.delete(uri, null, null) } catch (_: Exception) {} + return null + } } diff --git a/android/app/src/main/kotlin/eu/twonly/MyMediaStorageProxy.kt b/android/app/src/main/kotlin/eu/twonly/MyMediaStorageProxy.kt new file mode 100644 index 0000000..7caceb2 --- /dev/null +++ b/android/app/src/main/kotlin/eu/twonly/MyMediaStorageProxy.kt @@ -0,0 +1,6 @@ +package eu.twonly + +class MyMediaStorageProxy { + + +} \ No newline at end of file diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 1b12b3f..ae7cbe1 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,56 +1,56 @@ +import CryptoKit import Flutter +import Foundation import UIKit import UserNotifications -import CryptoKit -import Foundation - @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - - if #available(iOS 10.0, *) { - //UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate - } - - UNUserNotificationCenter.current().delegate = self - - // if (@available(iOS 10.0, *)) { - // [UNUserNotificationCenter currentNotificationCenter].delegate = (id) self; - // } - - GeneratedPluginRegistrant.register(with: self) + UNUserNotificationCenter.current().delegate = self return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - - override func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - NSLog("Application delegate method userNotificationCenter:didReceive:withCompletionHandler: is called with user info: %@", response.notification.request.content.userInfo) - //... - } - override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - NSLog("userNotificationCenter:willPresent") - - /* - debugging NotificationService - let pushKeys = getPushKey(); - print(pushKeys) - - let bestAttemptContent = notification.request.content - - guard let _userInfo = bestAttemptContent.userInfo as? [String: Any], - let push_data = bestAttemptContent.userInfo["push_data"] as? String else { - return completionHandler([.alert, .sound]) - } - - let data = getPushNotificationData(pushDataJson: push_data) - print(data) - */ - - completionHandler([.alert, .sound]) + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + NSLog( + "Application delegate method userNotificationCenter:didReceive:withCompletionHandler: is called with user info: %@", + response.notification.request.content.userInfo) + //... + } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + NSLog("userNotificationCenter:willPresent") + + /* + debugging NotificationService + let pushKeys = getPushKey(); + print(pushKeys) + + let bestAttemptContent = notification.request.content + + guard let _userInfo = bestAttemptContent.userInfo as? [String: Any], + let push_data = bestAttemptContent.userInfo["push_data"] as? String else { + return completionHandler([.alert, .sound]) } + + let data = getPushNotificationData(pushDataJson: push_data) + print(data) + */ + + completionHandler([.alert, .sound]) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3ec4e9c..62e89b3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,27 @@ + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + FlutterSceneDelegate + UISceneConfigurationName + flutter + UISceneStoryboardFile + Main + + + + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/lib/main.dart b/lib/main.dart index 92bd6d0..1fbe97e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -80,17 +80,7 @@ void main() async { unawaited(MediaFileService.purgeTempFolder()); await initFileDownloader(); - if (Platform.isAndroid) { - if ((await DeviceInfoPlugin().androidInfo).version.release == '9') { - Future.delayed(const Duration(seconds: 20), () { - unawaited(finishStartedPreprocessing()); - }); - } else { - unawaited(finishStartedPreprocessing()); - } - } else { - unawaited(finishStartedPreprocessing()); - } + unawaited(finishStartedPreprocessing()); unawaited(createPushAvatars()); diff --git a/lib/src/services/mediafiles/compression.service.dart b/lib/src/services/mediafiles/compression.service.dart index 45a5699..1d9b4a8 100644 --- a/lib/src/services/mediafiles/compression.service.dart +++ b/lib/src/services/mediafiles/compression.service.dart @@ -69,13 +69,19 @@ Future compressAndOverlayVideo(MediaFileService media) async { media.ffmpegOutputPath.deleteSync(); } + var overLayCommand = ''; + if (media.overlayImagePath.existsSync()) { + overLayCommand = + '-i "${media.overlayImagePath.path}" -filter_complex "[1:v][0:v]scale2ref=w=ref_w:h=ref_h[ovr][base];[base][ovr]overlay=0:0"'; + } + final stopwatch = Stopwatch()..start(); var command = - '-i "${media.originalPath.path}" -i "${media.overlayImagePath.path}" -filter_complex "[1:v][0:v]scale2ref=w=ref_w:h=ref_h[ovr][base];[base][ovr]overlay=0:0" -map "0:a?" -preset veryfast -crf 28 -c:a aac -b:a 64k "${media.ffmpegOutputPath.path}"'; + '-i "${media.originalPath.path}" $overLayCommand -map "0:a?" -preset veryfast -crf 28 -c:a aac -b:a 64k "${media.ffmpegOutputPath.path}"'; if (media.removeAudio) { command = - '-i "${media.originalPath.path}" -i "${media.overlayImagePath.path}" -filter_complex "[1:v][0:v]scale2ref=w=ref_w:h=ref_h[ovr][base];[base][ovr]overlay=0:0" -preset veryfast -crf 28 -an "${media.ffmpegOutputPath.path}"'; + '-i "${media.originalPath.path}" $overLayCommand -preset veryfast -crf 28 -an "${media.ffmpegOutputPath.path}"'; } final session = await FFmpegKit.execute(command); diff --git a/lib/src/views/settings/data_and_storage.view.dart b/lib/src/views/settings/data_and_storage.view.dart index 3a39e19..21b0848 100644 --- a/lib/src/views/settings/data_and_storage.view.dart +++ b/lib/src/views/settings/data_and_storage.view.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; @@ -64,36 +65,38 @@ class _DataAndStorageViewState extends State { onChanged: (a) => toggleStoreInGallery(), ), ), - ListTile( - title: Text( - context.lang.exportMemories, + if (Platform.isAndroid) + ListTile( + title: Text( + context.lang.exportMemories, + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) { + return const ExportMediaView(); + }, + ), + ); + }, ), - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) { - return const ExportMediaView(); - }, - ), - ); - }, - ), - ListTile( - title: Text( - context.lang.importMemories, + if (Platform.isAndroid) + ListTile( + title: Text( + context.lang.importMemories, + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) { + return const ImportMediaView(); + }, + ), + ); + }, ), - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) { - return const ImportMediaView(); - }, - ), - ); - }, - ), const Divider(), ListTile( title: Text( diff --git a/lib/src/views/settings/data_and_storage/export_media.view.dart b/lib/src/views/settings/data_and_storage/export_media.view.dart index 2b89b05..9fd087c 100644 --- a/lib/src/views/settings/data_and_storage/export_media.view.dart +++ b/lib/src/views/settings/data_and_storage/export_media.view.dart @@ -3,9 +3,31 @@ import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/utils/log.dart'; + +class AndroidMediaStore { + static const androidMediaStoreChannel = MethodChannel('eu.twonly/mediaStore'); + + static Future safeFileToDownload(File sourceFile) async { + try { + Log.info('Storing $sourceFile'); + final storedPath = ( + await androidMediaStoreChannel.invokeMethod('safeFileToDownload', { + 'sourceFile': sourceFile.path, + }), + ); + Log.info(storedPath); + return true; + } catch (e) { + Log.error(e); + return false; + } + } +} class ExportMediaView extends StatefulWidget { const ExportMediaView({super.key}); @@ -20,6 +42,7 @@ class _ExportMediaViewState extends State { File? _zipFile; bool _isZipping = false; bool _zipSaved = false; + bool _isStoring = false; Future _mediaFolder() async { final dir = MediaFileService.buildDirectoryPath( @@ -77,7 +100,6 @@ class _ExportMediaViewState extends State { _status = 'Adding $relative'; }); - // ZipFileEncoder doesn't give per-file progress; update after adding. await encoder.addFile(f, relative); processedBytes += await f.length(); @@ -108,17 +130,28 @@ class _ExportMediaViewState extends State { Future _saveZip() async { if (_zipFile == null) return; + setState(() { + _isStoring = true; + }); try { - final outputFile = await FilePicker.platform.saveFile( - dialogTitle: 'Save your memories to desired location', - fileName: p.basename(_zipFile!.path), - bytes: _zipFile!.readAsBytesSync(), - ); - if (outputFile == null) return; + if (Platform.isAndroid) { + if (!await AndroidMediaStore.safeFileToDownload(_zipFile!)) { + return; + } + } else { + final outputFile = await FilePicker.platform.saveFile( + dialogTitle: 'Save your memories to desired location', + fileName: p.basename(_zipFile!.path), + bytes: _zipFile!.readAsBytesSync(), + ); + if (outputFile == null) return; + } _zipSaved = true; + _isStoring = false; _status = 'ZIP stored: ${p.basename(_zipFile!.path)}'; setState(() {}); } catch (e) { + _isStoring = false; setState(() => _status = 'Save failed: $e'); } } @@ -165,7 +198,9 @@ class _ExportMediaViewState extends State { ElevatedButton.icon( icon: const Icon(Icons.save_alt), label: const Text('Save ZIP'), - onPressed: (_zipFile != null && !_isZipping) ? _saveZip : null, + onPressed: (_zipFile != null && !_isZipping && !_isStoring) + ? _saveZip + : null, ), ], ), diff --git a/lib/src/views/settings/data_and_storage/import_media.view.dart b/lib/src/views/settings/data_and_storage/import_media.view.dart index 69fd0e1..d21999e 100644 --- a/lib/src/views/settings/data_and_storage/import_media.view.dart +++ b/lib/src/views/settings/data_and_storage/import_media.view.dart @@ -83,9 +83,9 @@ class _ImportMediaViewState extends State { }); try { - // Read zip bytes and decode - final bytes = await zipFile.readAsBytes(); - final archive = ZipDecoder().decodeBytes(bytes); + final stream = InputFileStream(zipFile.path); + + final archive = ZipDecoder().decodeStream(stream); // Optionally: compute total entries to show progress final entries = archive.where((e) => e.isFile).toList(); diff --git a/pubspec.lock b/pubspec.lock index 95c6a9a..48f6fde 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1132,10 +1132,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1713,10 +1713,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timezone: dependency: transitive description: From 4623487556d23927e3f740aaafa1d5428effcae3 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 15 Nov 2025 00:13:53 +0100 Subject: [PATCH 3/4] fix #311 --- lib/src/views/chats/media_viewer.view.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/src/views/chats/media_viewer.view.dart b/lib/src/views/chats/media_viewer.view.dart index ad937d0..e3da12f 100644 --- a/lib/src/views/chats/media_viewer.view.dart +++ b/lib/src/views/chats/media_viewer.view.dart @@ -328,6 +328,12 @@ class _MediaViewerViewState extends State { imageSaving = true; }); await currentMedia!.storeMediaFile(); + await twonlyDB.messagesDao.updateMessageId( + currentMessage!.messageId, + const MessagesCompanion( + mediaStored: Value(true), + ), + ); await sendCipherTextToGroup( widget.group.groupId, pb.EncryptedContent( From e00700d4e34c119637d00de5f412e0d885205e9d Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 15 Nov 2025 00:14:23 +0100 Subject: [PATCH 4/4] bump version --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index f377f5d..0cf26b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.0.69+69 +version: 0.0.70+70 environment: sdk: ^3.6.0