mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 11:18:41 +00:00
commit
f50add6530
15 changed files with 261 additions and 111 deletions
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Flutter",
|
||||
"type": "dart",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"flutterMode": "profile"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ android {
|
|||
// compileSdk = flutter.compileSdkVersion
|
||||
compileSdk 36
|
||||
//ndkVersion = flutter.ndkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
ndkVersion = "28.2.13676358"
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package eu.twonly
|
||||
|
||||
class MyMediaStorageProxy {
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
//...
|
||||
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])
|
||||
}
|
||||
|
||||
override func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
NSLog("userNotificationCenter:willPresent")
|
||||
let data = getPushNotificationData(pushDataJson: push_data)
|
||||
print(data)
|
||||
*/
|
||||
|
||||
/*
|
||||
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])
|
||||
}
|
||||
completionHandler([.alert, .sound])
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ part 'twonly_database_old.g.dart';
|
|||
SignalContactSignedPreKeys,
|
||||
MessageRetransmissions,
|
||||
],
|
||||
daos: [],
|
||||
)
|
||||
class TwonlyDatabaseOld extends _$TwonlyDatabaseOld {
|
||||
TwonlyDatabaseOld([QueryExecutor? e])
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -328,6 +328,12 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
imageSaving = true;
|
||||
});
|
||||
await currentMedia!.storeMediaFile();
|
||||
await twonlyDB.messagesDao.updateMessageId(
|
||||
currentMessage!.messageId,
|
||||
const MessagesCompanion(
|
||||
mediaStored: Value(true),
|
||||
),
|
||||
);
|
||||
await sendCipherTextToGroup(
|
||||
widget.group.groupId,
|
||||
pb.EncryptedContent(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue