This commit is contained in:
otsmr 2025-11-15 00:01:47 +01:00
parent eb8b7d31de
commit 72495b4c2a
12 changed files with 254 additions and 109 deletions

11
.vscode/launch.json vendored
View file

@ -1,11 +0,0 @@
{
"configurations": [
{
"name": "Flutter",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"flutterMode": "profile"
}
]
}

View file

@ -19,7 +19,7 @@ android {
// compileSdk = flutter.compileSdkVersion
compileSdk 36
//ndkVersion = flutter.ndkVersion
ndkVersion = "27.0.12077973"
ndkVersion = "28.2.13676358"
compileOptions {
coreLibraryDesugaringEnabled true

View file

@ -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<Map<String, String>>() as Map<String, String>
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
}
}

View file

@ -0,0 +1,6 @@
package eu.twonly
class MyMediaStorageProxy {
}

View file

@ -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<UNUserNotificationCenterDelegate>) 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])
}
}

View file

@ -2,6 +2,27 @@
<!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>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>

View file

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

View file

@ -69,13 +69,19 @@ Future<void> 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);

View file

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

View file

@ -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<bool> 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<ExportMediaView> {
File? _zipFile;
bool _isZipping = false;
bool _zipSaved = false;
bool _isStoring = false;
Future<Directory> _mediaFolder() async {
final dir = MediaFileService.buildDirectoryPath(
@ -77,7 +100,6 @@ class _ExportMediaViewState extends State<ExportMediaView> {
_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<ExportMediaView> {
Future<void> _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<ExportMediaView> {
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,
),
],
),

View file

@ -83,9 +83,9 @@ class _ImportMediaViewState extends State<ImportMediaView> {
});
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();

View file

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