From 4beb93c5525b8bebb6d6ac6cf9ef142111ace484 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 3 May 2022 18:44:48 +0800 Subject: [PATCH 01/23] Overhaul and migrate MediaStore --- .../com/nkming/nc_photos/MainActivity.kt | 6 - .../nc_photos/MediaStoreChannelHandler.kt | 83 ------------ .../com/nkming/nc_photos/PermissionHandler.kt | 29 ----- app/lib/download_handler.dart | 1 + app/lib/exception.dart | 16 --- app/lib/mobile/android/media_store.dart | 44 ------- app/lib/mobile/download.dart | 14 +-- app/lib/mobile/file_saver.dart | 4 +- app/lib/share_handler.dart | 2 +- .../kotlin/com/nkming/nc_photos/plugin/K.kt | 1 + .../plugin/MediaStoreChannelHandler.kt | 118 ++++++++++++++++++ .../nkming/nc_photos/plugin/MediaStoreUtil.kt | 83 +++++++----- .../nkming/nc_photos/plugin/NcPhotosPlugin.kt | 33 ++++- .../nkming/nc_photos/plugin/PermissionUtil.kt | 36 ++++++ .../com/nkming/nc_photos/plugin/UriUtil.kt | 34 +++++ plugin/lib/nc_photos_plugin.dart | 2 + plugin/lib/src/exception.dart | 15 +++ plugin/lib/src/media_store.dart | 57 +++++++++ 18 files changed, 354 insertions(+), 224 deletions(-) delete mode 100644 app/android/app/src/main/kotlin/com/nkming/nc_photos/MediaStoreChannelHandler.kt delete mode 100644 app/android/app/src/main/kotlin/com/nkming/nc_photos/PermissionHandler.kt delete mode 100644 app/lib/mobile/android/media_store.dart create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreChannelHandler.kt create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/PermissionUtil.kt create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/UriUtil.kt create mode 100644 plugin/lib/src/exception.dart create mode 100644 plugin/lib/src/media_store.dart diff --git a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt index 4f3628fc..c022773c 100644 --- a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt @@ -9,12 +9,6 @@ import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel( - flutterEngine.dartExecutor.binaryMessenger, - MediaStoreChannelHandler.CHANNEL - ).setMethodCallHandler( - MediaStoreChannelHandler(this) - ) MethodChannel( flutterEngine.dartExecutor.binaryMessenger, SelfSignedCertChannelHandler.CHANNEL diff --git a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MediaStoreChannelHandler.kt b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MediaStoreChannelHandler.kt deleted file mode 100644 index eb059bd7..00000000 --- a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MediaStoreChannelHandler.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.nkming.nc_photos - -import android.app.Activity -import com.nkming.nc_photos.plugin.MediaStoreUtil -import com.nkming.nc_photos.plugin.PermissionException -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel - -/* - * Save downloaded item on device - * - * Methods: - * Write binary content to a file in the Download directory. Return the Uri to - * the file - * fun saveFileToDownload(fileName: String, content: ByteArray): String - */ -class MediaStoreChannelHandler(activity: Activity) : - MethodChannel.MethodCallHandler { - companion object { - @JvmStatic - val CHANNEL = "com.nkming.nc_photos/media_store" - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - "saveFileToDownload" -> { - try { - saveFileToDownload( - call.argument("fileName")!!, - call.argument("content")!!, - result - ) - } catch (e: Throwable) { - result.error("systemException", e.message, null) - } - } - - "copyFileToDownload" -> { - try { - copyFileToDownload( - call.argument("toFileName")!!, - call.argument("fromFilePath")!!, - result - ) - } catch (e: Throwable) { - result.error("systemException", e.message, null) - } - } - - else -> result.notImplemented() - } - } - - private fun saveFileToDownload( - fileName: String, content: ByteArray, result: MethodChannel.Result - ) { - try { - val uri = - MediaStoreUtil.saveFileToDownload(_context, fileName, content) - result.success(uri.toString()) - } catch (e: PermissionException) { - PermissionHandler.ensureWriteExternalStorage(_activity) - result.error("permissionError", "Permission not granted", null) - } - } - - private fun copyFileToDownload( - toFileName: String, fromFilePath: String, result: MethodChannel.Result - ) { - try { - val uri = MediaStoreUtil.copyFileToDownload( - _context, toFileName, fromFilePath - ) - result.success(uri.toString()) - } catch (e: PermissionException) { - PermissionHandler.ensureWriteExternalStorage(_activity) - result.error("permissionError", "Permission not granted", null) - } - } - - private val _activity = activity - private val _context get() = _activity -} diff --git a/app/android/app/src/main/kotlin/com/nkming/nc_photos/PermissionHandler.kt b/app/android/app/src/main/kotlin/com/nkming/nc_photos/PermissionHandler.kt deleted file mode 100644 index ed7fbff5..00000000 --- a/app/android/app/src/main/kotlin/com/nkming/nc_photos/PermissionHandler.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.nkming.nc_photos - -import android.Manifest -import android.app.Activity -import android.content.pm.PackageManager -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat - -private const val PERMISSION_REQUEST_CODE = 11011 - -class PermissionHandler { - companion object { - fun ensureWriteExternalStorage(activity: Activity): Boolean { - return if (ContextCompat.checkSelfPermission( - activity, Manifest.permission.WRITE_EXTERNAL_STORAGE - ) != PackageManager.PERMISSION_GRANTED - ) { - ActivityCompat.requestPermissions( - activity, - arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), - PERMISSION_REQUEST_CODE - ) - false - } else { - true - } - } - } -} diff --git a/app/lib/download_handler.dart b/app/lib/download_handler.dart index e430a5f9..28c658ff 100644 --- a/app/lib/download_handler.dart +++ b/app/lib/download_handler.dart @@ -16,6 +16,7 @@ import 'package:nc_photos/mobile/platform.dart' import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/use_case/download_file.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; import 'package:tuple/tuple.dart'; class DownloadHandler { diff --git a/app/lib/exception.dart b/app/lib/exception.dart index 2ba9e60e..dac3c672 100644 --- a/app/lib/exception.dart +++ b/app/lib/exception.dart @@ -34,22 +34,6 @@ class ApiException implements Exception { final dynamic message; } -/// Platform permission is not granted by user -class PermissionException implements Exception { - PermissionException([this.message]); - - @override - toString() { - if (message == null) { - return "PermissionException"; - } else { - return "PermissionException: $message"; - } - } - - final dynamic message; -} - /// The Nextcloud base URL address is invalid class InvalidBaseUrlException implements Exception { InvalidBaseUrlException([this.message]); diff --git a/app/lib/mobile/android/media_store.dart b/app/lib/mobile/android/media_store.dart deleted file mode 100644 index c47fad94..00000000 --- a/app/lib/mobile/android/media_store.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter/services.dart'; -import 'package:nc_photos/exception.dart'; - -class MediaStore { - static Future saveFileToDownload( - String fileName, Uint8List fileContent) async { - try { - return (await _channel - .invokeMethod("saveFileToDownload", { - "fileName": fileName, - "content": fileContent, - }))!; - } on PlatformException catch (e) { - if (e.code == _exceptionCodePermissionError) { - throw PermissionException(); - } else { - rethrow; - } - } - } - - static Future copyFileToDownload( - String toFileName, String fromFilePath) async { - try { - return (await _channel - .invokeMethod("copyFileToDownload", { - "toFileName": toFileName, - "fromFilePath": fromFilePath, - }))!; - } on PlatformException catch (e) { - if (e.code == _exceptionCodePermissionError) { - throw PermissionException(); - } else { - rethrow; - } - } - } - - static const _exceptionCodePermissionError = "permissionError"; - - static const _channel = MethodChannel("com.nkming.nc_photos/media_store"); -} diff --git a/app/lib/mobile/download.dart b/app/lib/mobile/download.dart index 9ac2f108..21d30d50 100644 --- a/app/lib/mobile/download.dart +++ b/app/lib/mobile/download.dart @@ -4,9 +4,9 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:nc_photos/exception.dart'; -import 'package:nc_photos/mobile/android/media_store.dart'; import 'package:nc_photos/platform/download.dart' as itf; import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; @@ -92,13 +92,11 @@ class _AndroidDownload extends itf.Download { } // copy the file to the actual dir - final String path; - if (parentDir?.isNotEmpty == true) { - path = "$parentDir/$filename"; - } else { - path = filename; - } - return await MediaStore.copyFileToDownload(path, file.path); + return await MediaStore.copyFileToDownload( + file.path, + filename: filename, + subDir: parentDir, + ); } finally { file.delete(); } diff --git a/app/lib/mobile/file_saver.dart b/app/lib/mobile/file_saver.dart index 9abc3aeb..b3ea409e 100644 --- a/app/lib/mobile/file_saver.dart +++ b/app/lib/mobile/file_saver.dart @@ -1,8 +1,8 @@ import 'dart:typed_data'; -import 'package:nc_photos/mobile/android/media_store.dart'; import 'package:nc_photos/platform/file_saver.dart' as itf; import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; class FileSaver extends itf.FileSaver { @override @@ -15,5 +15,5 @@ class FileSaver extends itf.FileSaver { } Future _saveFileAndroid(String filename, Uint8List content) => - MediaStore.saveFileToDownload(filename, content); + MediaStore.saveFileToDownload(content, filename); } diff --git a/app/lib/share_handler.dart b/app/lib/share_handler.dart index a5e7d4e0..5b6a8dda 100644 --- a/app/lib/share_handler.dart +++ b/app/lib/share_handler.dart @@ -11,7 +11,6 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/share/data_source.dart'; -import 'package:nc_photos/exception.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; @@ -27,6 +26,7 @@ import 'package:nc_photos/widget/processing_dialog.dart'; import 'package:nc_photos/widget/share_link_multiple_files_dialog.dart'; import 'package:nc_photos/widget/share_method_dialog.dart'; import 'package:nc_photos/widget/simple_input_dialog.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; import 'package:tuple/tuple.dart'; /// Handle sharing to other apps diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/K.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/K.kt index a28d69a2..97656f61 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/K.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/K.kt @@ -5,6 +5,7 @@ interface K { const val DOWNLOAD_NOTIFICATION_ID_MIN = 1000 const val DOWNLOAD_NOTIFICATION_ID_MAX = 2000 + const val PERMISSION_REQUEST_CODE = 11011 const val LIB_ID = "com.nkming.nc_photos.plugin" const val ACTION_DOWNLOAD_CANCEL = "${LIB_ID}.ACTION_DOWNLOAD_CANCEL" diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreChannelHandler.kt new file mode 100644 index 00000000..ebb68233 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreChannelHandler.kt @@ -0,0 +1,118 @@ +package com.nkming.nc_photos.plugin + +import android.app.Activity +import android.content.Context +import android.net.Uri +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File + +/* + * Save downloaded item on device + * + * Methods: + * Write binary content to a file in the Download directory. Return the Uri to + * the file + * fun saveFileToDownload(content: ByteArray, filename: String, subDir: String?): String + */ +class MediaStoreChannelHandler(context: Context) : + MethodChannel.MethodCallHandler, ActivityAware { + companion object { + const val METHOD_CHANNEL = "${K.LIB_ID}/media_store_method" + + private const val TAG = "MediaStoreChannelHandler" + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onReattachedToActivityForConfigChanges( + binding: ActivityPluginBinding + ) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + activity = null + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "saveFileToDownload" -> { + try { + saveFileToDownload( + call.argument("content")!!, call.argument("filename")!!, + call.argument("subDir"), result + ) + } catch (e: Throwable) { + result.error("systemException", e.message, null) + } + } + + "copyFileToDownload" -> { + try { + copyFileToDownload( + call.argument("fromFile")!!, call.argument("filename"), + call.argument("subDir"), result + ) + } catch (e: Throwable) { + result.error("systemException", e.message, null) + } + } + + else -> result.notImplemented() + } + } + + private fun saveFileToDownload( + content: ByteArray, filename: String, subDir: String?, + result: MethodChannel.Result + ) { + try { + val uri = MediaStoreUtil.saveFileToDownload( + context, content, filename, subDir + ) + result.success(uri.toString()) + } catch (e: PermissionException) { + activity?.let { PermissionUtil.requestWriteExternalStorage(it) } + result.error("permissionError", "Permission not granted", null) + } + } + + private fun copyFileToDownload( + fromFile: String, filename: String?, subDir: String?, + result: MethodChannel.Result + ) { + try { + val fromUri = inputToUri(fromFile) + val uri = MediaStoreUtil.copyFileToDownload( + context, fromUri, filename, subDir + ) + result.success(uri.toString()) + } catch (e: PermissionException) { + activity?.let { PermissionUtil.requestWriteExternalStorage(it) } + result.error("permissionError", "Permission not granted", null) + } + } + + private fun inputToUri(fromFile: String): Uri { + val testUri = Uri.parse(fromFile) + return if (testUri.scheme == null) { + // is a file path + Uri.fromFile(File(fromFile)) + } else { + // is a uri + Uri.parse(fromFile) + } + } + + private val context = context + private var activity: Activity? = null +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt index ecd2bf44..e749842a 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt @@ -1,65 +1,84 @@ package com.nkming.nc_photos.plugin -import android.Manifest import android.content.ContentValues import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import java.io.* +class MediaStoreCopyWriter(data: InputStream) { + operator fun invoke(ostream: OutputStream) { + data.copyTo(ostream) + } + + private val data = data +} + interface MediaStoreUtil { companion object { /** * Save the @c content as a file under the user Download dir * * @param context - * @param filename Filename of the new file * @param content + * @param filename Filename of the new file + * @param subDir * @return Uri of the created file */ fun saveFileToDownload( - context: Context, filename: String, content: ByteArray + context: Context, content: ByteArray, filename: String, + subDir: String? = null ): Uri { - val stream = ByteArrayInputStream(content) - return writeFileToDownload(context, filename, stream) + return ByteArrayInputStream(content).use { + writeFileToDownload( + context, MediaStoreCopyWriter(it)::invoke, filename, subDir + ) + } } /** * Copy a file from @c fromFilePath to the user Download dir * * @param context - * @param toFilename Filename of the new file - * @param fromFilePath Path of the file to be copied + * @param fromFile Path of the file to be copied + * @param filename Filename of the new file. If null, the same filename + * @param subDir + * will be used * @return Uri of the created file */ fun copyFileToDownload( - context: Context, toFilename: String, fromFilePath: String + context: Context, fromFile: Uri, filename: String? = null, + subDir: String? = null ): Uri { - val file = File(fromFilePath) - val stream = file.inputStream() - return writeFileToDownload(context, toFilename, stream) + return context.contentResolver.openInputStream(fromFile)!!.use { + writeFileToDownload( + context, MediaStoreCopyWriter(it)::invoke, + filename ?: UriUtil.resolveFilename(context, fromFile)!!, + subDir + ) + } } - private fun writeFileToDownload( - context: Context, filename: String, data: InputStream + fun writeFileToDownload( + context: Context, writer: (OutputStream) -> Unit, filename: String, + subDir: String? = null ): Uri { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - writeFileToDownload29(context, filename, data) + writeFileToDownload29(context, writer, filename, subDir) } else { - writeFileToDownload0(context, filename, data) + writeFileToDownload0(context, writer, filename, subDir) } } @RequiresApi(Build.VERSION_CODES.Q) private fun writeFileToDownload29( - context: Context, filename: String, data: InputStream + context: Context, writer: (OutputStream) -> Unit, filename: String, + subDir: String? ): Uri { // Add a media item that other apps shouldn't see until the item is // fully written to the media store. @@ -69,13 +88,12 @@ interface MediaStoreUtil { val collection = MediaStore.Downloads.getContentUri( MediaStore.VOLUME_EXTERNAL_PRIMARY ) - val file = File(filename) val details = ContentValues().apply { - put(MediaStore.Downloads.DISPLAY_NAME, file.name) - if (file.parent != null) { + put(MediaStore.Downloads.DISPLAY_NAME, filename) + if (subDir != null) { put( MediaStore.Downloads.RELATIVE_PATH, - "${Environment.DIRECTORY_DOWNLOADS}/${file.parent}" + "${Environment.DIRECTORY_DOWNLOADS}/$subDir" ) } } @@ -87,19 +105,17 @@ interface MediaStoreUtil { BufferedOutputStream( FileOutputStream(pfd!!.fileDescriptor) ).use { stream -> - data.copyTo(stream) + writer(stream) } } return contentUri } private fun writeFileToDownload0( - context: Context, filename: String, data: InputStream + context: Context, writer: (OutputStream) -> Unit, filename: String, + subDir: String? ): Uri { - if (ContextCompat.checkSelfPermission( - context, Manifest.permission.WRITE_EXTERNAL_STORAGE - ) != PackageManager.PERMISSION_GRANTED - ) { + if (!PermissionUtil.hasWriteExternalStorage(context)) { throw PermissionException("Permission not granted") } @@ -107,19 +123,19 @@ interface MediaStoreUtil { val path = Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOWNLOADS ) - var file = File(path, filename) + val prefix = if (subDir != null) "$subDir/" else "" + var file = File(path, prefix + filename) + val baseFilename = file.nameWithoutExtension var count = 1 while (file.exists()) { - val f = File(filename) file = File( - path, - "${f.nameWithoutExtension} ($count).${f.extension}" + path, prefix + "$baseFilename ($count).${file.extension}" ) ++count } file.parentFile?.mkdirs() BufferedOutputStream(FileOutputStream(file)).use { stream -> - data.copyTo(stream) + writer(stream) } val fileUri = Uri.fromFile(file) @@ -138,6 +154,5 @@ interface MediaStoreUtil { } context.sendBroadcast(scanIntent) } - } } diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt index a93882de..63734a8f 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt @@ -2,10 +2,12 @@ package com.nkming.nc_photos.plugin import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel -class NcPhotosPlugin : FlutterPlugin { +class NcPhotosPlugin : FlutterPlugin, ActivityAware { override fun onAttachedToEngine( @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding ) { @@ -36,6 +38,14 @@ class NcPhotosPlugin : FlutterPlugin { NativeEventChannelHandler.METHOD_CHANNEL ) nativeEventMethodChannel.setMethodCallHandler(nativeEventHandler) + + mediaStoreChannelHandler = + MediaStoreChannelHandler(flutterPluginBinding.applicationContext) + mediaStoreMethodChannel = MethodChannel( + flutterPluginBinding.binaryMessenger, + MediaStoreChannelHandler.METHOD_CHANNEL + ) + mediaStoreMethodChannel.setMethodCallHandler(mediaStoreChannelHandler) } override fun onDetachedFromEngine( @@ -46,12 +56,33 @@ class NcPhotosPlugin : FlutterPlugin { notificationChannel.setMethodCallHandler(null) nativeEventChannel.setStreamHandler(null) nativeEventMethodChannel.setMethodCallHandler(null) + mediaStoreMethodChannel.setMethodCallHandler(null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + mediaStoreChannelHandler.onAttachedToActivity(binding) + } + + override fun onReattachedToActivityForConfigChanges( + binding: ActivityPluginBinding + ) { + mediaStoreChannelHandler.onReattachedToActivityForConfigChanges(binding) + } + + override fun onDetachedFromActivity() { + mediaStoreChannelHandler.onDetachedFromActivity() + } + + override fun onDetachedFromActivityForConfigChanges() { + mediaStoreChannelHandler.onDetachedFromActivityForConfigChanges() } private lateinit var lockChannel: MethodChannel private lateinit var notificationChannel: MethodChannel private lateinit var nativeEventChannel: EventChannel private lateinit var nativeEventMethodChannel: MethodChannel + private lateinit var mediaStoreMethodChannel: MethodChannel private lateinit var lockChannelHandler: LockChannelHandler + private lateinit var mediaStoreChannelHandler: MediaStoreChannelHandler } diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/PermissionUtil.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/PermissionUtil.kt new file mode 100644 index 00000000..de9caed2 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/PermissionUtil.kt @@ -0,0 +1,36 @@ +package com.nkming.nc_photos.plugin + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +interface PermissionUtil { + companion object { + fun request(activity: Activity, vararg permissions: String) { + ActivityCompat.requestPermissions( + activity, permissions, K.PERMISSION_REQUEST_CODE + ) + } + + fun hasReadExternalStorage(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, Manifest.permission.READ_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } + + fun requestReadExternalStorage(activity: Activity) = + request(activity, Manifest.permission.READ_EXTERNAL_STORAGE) + + fun hasWriteExternalStorage(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } + + fun requestWriteExternalStorage(activity: Activity) = + request(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) + } +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/UriUtil.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/UriUtil.kt new file mode 100644 index 00000000..e57ecdcd --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/UriUtil.kt @@ -0,0 +1,34 @@ +package com.nkming.nc_photos.plugin + +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import android.util.Log + +interface UriUtil { + companion object { + fun resolveFilename(context: Context, uri: Uri): String? { + return if (uri.scheme == "file") { + uri.lastPathSegment!! + } else { + context.contentResolver.query( + uri, arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, + null, null + ).use { + if (it == null || !it.moveToFirst()) { + Log.i(TAG, "Uri not found: $uri") + null + } else { + it.getString( + it.getColumnIndexOrThrow( + MediaStore.MediaColumns.DISPLAY_NAME + ) + ) + } + } + } + } + + private const val TAG = "UriUtil" + } +} diff --git a/plugin/lib/nc_photos_plugin.dart b/plugin/lib/nc_photos_plugin.dart index 29afa79d..8ecc08c5 100644 --- a/plugin/lib/nc_photos_plugin.dart +++ b/plugin/lib/nc_photos_plugin.dart @@ -1,5 +1,7 @@ library nc_photos_plugin; +export 'src/exception.dart'; export 'src/lock.dart'; +export 'src/media_store.dart'; export 'src/native_event.dart'; export 'src/notification.dart'; diff --git a/plugin/lib/src/exception.dart b/plugin/lib/src/exception.dart new file mode 100644 index 00000000..3db5a5a6 --- /dev/null +++ b/plugin/lib/src/exception.dart @@ -0,0 +1,15 @@ +/// Platform permission is not granted by user +class PermissionException implements Exception { + const PermissionException([this.message]); + + @override + toString() { + if (message == null) { + return "PermissionException"; + } else { + return "PermissionException: $message"; + } + } + + final dynamic message; +} diff --git a/plugin/lib/src/media_store.dart b/plugin/lib/src/media_store.dart new file mode 100644 index 00000000..a091594d --- /dev/null +++ b/plugin/lib/src/media_store.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:nc_photos_plugin/src/exception.dart'; +import 'package:nc_photos_plugin/src/k.dart' as k; + +class MediaStore { + static Future saveFileToDownload( + Uint8List content, + String filename, { + String? subDir, + }) async { + try { + return (await _methodChannel + .invokeMethod("saveFileToDownload", { + "content": content, + "filename": filename, + "subDir": subDir, + }))!; + } on PlatformException catch (e) { + if (e.code == _exceptionCodePermissionError) { + throw const PermissionException(); + } else { + rethrow; + } + } + } + + /// Copy a file to the user Download dir + /// + /// [fromFile] must be either a path or a content uri. If [filename] is not + /// null, it will be used instead of the source filename + static Future copyFileToDownload( + String fromFile, { + String? filename, + String? subDir, + }) async { + try { + return (await _methodChannel + .invokeMethod("copyFileToDownload", { + "fromFile": fromFile, + "filename": filename, + "subDir": subDir, + }))!; + } on PlatformException catch (e) { + if (e.code == _exceptionCodePermissionError) { + throw const PermissionException(); + } else { + rethrow; + } + } + } + + static const _methodChannel = MethodChannel("${k.libId}/media_store_method"); + + static const _exceptionCodePermissionError = "permissionError"; +} From e343e5974168b09c691ea142ef17230af2bb98ac Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 4 May 2022 01:19:47 +0800 Subject: [PATCH 02/23] Add image enhancement algorithm: ZeroDCE --- plugin/android/build.gradle | 1 + plugin/android/src/main/AndroidManifest.xml | 12 +- .../zero_dce_lite_200x300_iter8_60.tflite | Bin 0 -> 53992 bytes .../com/nkming/nc_photos/plugin/BitmapUtil.kt | 162 ++++++++++++ .../com/nkming/nc_photos/plugin/Event.kt | 15 ++ .../plugin/ImageProcessorChannelHandler.kt | 61 +++++ .../nc_photos/plugin/ImageProcessorService.kt | 250 ++++++++++++++++++ .../kotlin/com/nkming/nc_photos/plugin/K.kt | 6 + .../nkming/nc_photos/plugin/NcPhotosPlugin.kt | 18 ++ .../com/nkming/nc_photos/plugin/Util.kt | 14 + .../plugin/image_processor/TfLiteHelper.kt | 92 +++++++ .../plugin/image_processor/ZeroDce.kt | 77 ++++++ .../outline_auto_fix_high_white_24.png | Bin 0 -> 400 bytes .../drawable-hdpi/outline_image_white_24.png | Bin 0 -> 253 bytes .../outline_auto_fix_high_white_24.png | Bin 0 -> 254 bytes .../drawable-mdpi/outline_image_white_24.png | Bin 0 -> 177 bytes .../outline_auto_fix_high_white_24.png | Bin 0 -> 410 bytes .../drawable-xhdpi/outline_image_white_24.png | Bin 0 -> 295 bytes .../outline_auto_fix_high_white_24.png | Bin 0 -> 654 bytes .../outline_image_white_24.png | Bin 0 -> 407 bytes .../outline_auto_fix_high_white_24.png | Bin 0 -> 749 bytes .../outline_image_white_24.png | Bin 0 -> 532 bytes plugin/lib/nc_photos_plugin.dart | 1 + plugin/lib/src/image_processor.dart | 15 ++ 24 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 plugin/android/src/main/assets/zero_dce_lite_200x300_iter8_60.tflite create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/BitmapUtil.kt create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Event.kt create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/TfLiteHelper.kt create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt create mode 100644 plugin/android/src/main/res/drawable-hdpi/outline_auto_fix_high_white_24.png create mode 100644 plugin/android/src/main/res/drawable-hdpi/outline_image_white_24.png create mode 100644 plugin/android/src/main/res/drawable-mdpi/outline_auto_fix_high_white_24.png create mode 100644 plugin/android/src/main/res/drawable-mdpi/outline_image_white_24.png create mode 100644 plugin/android/src/main/res/drawable-xhdpi/outline_auto_fix_high_white_24.png create mode 100644 plugin/android/src/main/res/drawable-xhdpi/outline_image_white_24.png create mode 100644 plugin/android/src/main/res/drawable-xxhdpi/outline_auto_fix_high_white_24.png create mode 100644 plugin/android/src/main/res/drawable-xxhdpi/outline_image_white_24.png create mode 100644 plugin/android/src/main/res/drawable-xxxhdpi/outline_auto_fix_high_white_24.png create mode 100644 plugin/android/src/main/res/drawable-xxxhdpi/outline_image_white_24.png create mode 100644 plugin/lib/src/image_processor.dart diff --git a/plugin/android/build.gradle b/plugin/android/build.gradle index 16510a6b..f68b539c 100644 --- a/plugin/android/build.gradle +++ b/plugin/android/build.gradle @@ -55,4 +55,5 @@ dependencies { implementation "androidx.annotation:annotation:1.3.0" implementation "androidx.core:core-ktx:1.7.0" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'org.tensorflow:tensorflow-lite:2.8.0' } diff --git a/plugin/android/src/main/AndroidManifest.xml b/plugin/android/src/main/AndroidManifest.xml index 7b1d1a3b..d2bb947b 100644 --- a/plugin/android/src/main/AndroidManifest.xml +++ b/plugin/android/src/main/AndroidManifest.xml @@ -1,3 +1,11 @@ - + + + + + + + diff --git a/plugin/android/src/main/assets/zero_dce_lite_200x300_iter8_60.tflite b/plugin/android/src/main/assets/zero_dce_lite_200x300_iter8_60.tflite new file mode 100644 index 0000000000000000000000000000000000000000..972490cc0daedef9c038715d4592ebc7559a1b9b GIT binary patch literal 53992 zcmZVFc{o*H^f>-&9-9myB2rXnQgP2(H=2v)K}w~8l4w+#jUgE`rZOi&qQpIGlOj}7 zLTMgI^IRH~?|FYepWpNSsPIFb6@SVX@^KwSZ6BuZ~LsCo}Ll^yR0ny-|Vwvi_eZ#V}wP~C82EA|G#YXe`WvQ z@%GH`>G^LzN&*k>|K2+K|CasFd8_~5bN_F@j)MQT(-e4lc(2;#;CKSh%PE zyH<5UzG@mXaX8HmRur)x|GWOb3K0F}6nLAz#Tkctm}>WHcA)zzi`*H8*8SRW_SIVC zj6aags}9a|Z~)x$y2Na%u3_NMUXp#AoLEuF1$KFnGG4C=VtYCY#VeG{vGhg?&MUWI zPXhBXyleq(t+>e&YqF@fazFNVVI*2R5$4)u;)OeN@yWOxHhQK3*^bj=)9wDD;+kt% zGKiRYNdfAGtl*0#)zWhFMK(vLaL!u6V6Y+L^r zc6H@wiAGdCOEPrFv9r8r_Wn-FS>B74WF3Uo9e;3gbsb(goksaKb@<0N3G@puv)}U+ zDsua%GwJfj7#4hp4fjlEPfsml;dP3VsYdrPe!3LP9?4YfNQ+1P8f{34YGt|ulX2;R z26Vkq$L`l&hV94VNF(xdv6qjQMuu-%LM;vYlAk!TjH-k0s{eG1k(ZGry0K5OY8 z0IgrLxwsiJY^B8^7`^^1DUEprs@^<+%X3g4Z$w)@oFgxX&2;Boed|7jj?veO3YAv1i!T>W5AL~a7or=H|2?x-zY#y zTsfQTIua*dFGa<)?d*>CI##O?!M@aGu)?2d$o?du`Z-rT)_52d<6f}(i5Bd~DL=gR z)f%QNaew8WvLa^V3W#@GJ}2UQJF) zCs_NSKCIg|6BFL%Qr+4%Mvcw9N+>fER;zpUd%OBtdG(9Ja*o1gH8+j(rb$2#+sM zg0dJ*=00&P=8fJ1WvvnX@vuu={nvL~WZ3o6J0I@QC=FlsajOMciq^4(P8MkYE1nuv zGpTQ<3p?!9MmvqKNaL*T*pBhu37`6$rNu>>(xHLs*y%AJy$7G>JD-_i&DmU-*06xh znNv--;xu?_@MqH#ccbM*z$=E^*nrd=HtI(nQ$MhVWnE0d@g@N{H+CL3cHtXz=FYOs z{eamS)!<^CQVbt=n&xN+W1&^DbjaIa7Fuk|{u)kW7Yf2z;f;mpAX=+2`4S zXb-l?E|sN(?qpY964z_jB9u#yL4S7Ub%IjMW(FBIhSft^L7VKdfkFP8x&dWvNsoE8<04ju3#xC@FYA>RiAIAY2hGMy1|5!P z+^yP#rfwFS+q zo+#12VaNUZb_cgc52~2gN4uiTrI*BEsab`#b1g}CWm3pbj&Fv%6(ErFz1@5G#AC|1_^iA$jaUPl88&zQ! zu3gc^4P!?{+7*5N%EG@FeI)Jiy(Ol}p%kxqh&`FL21gEz#HY3XtSi`)DN{TP)yiZK zRyJeV#{H&>aT;?_)JUe7)NR+r3!|RJjY_xx8sWL z;~1Hf%8o005c{gllHOd97R{VRSL}TGR?R4W_LUN@Ag{D!$+IouYJKUOVmfUiTeS@&Qou*oc!-fxq`3pH{$ z;iD~%@R!B6!?v(E*=%m4*DC(f$VK?MR0lQBGCui<5@ysHK`*xnRQa_pp3vIHIqT|T z(f&Q0RbK;kVf|m*y+iu5m?s4=af=s4htFnrY696u%?!5STqv%bl*k?Vm&LhXQf1-s zi6lz{f?iK~=l}XU`~RRp(TgtHwq&_9?ORap z&AR1)t(}v}@Ua_?KkACRodW2=)(}>ueU066O=bJiuP{ydrF5|%8XF_>DQUX~9tqE9 z67D8HSoa8Se07(leJz9AzctvQctwhLxG!G(RD-1@j-sK3jp(xZ5udX&4g*T(u#)5$ zRIy;#vHSoHUc=*VyJk8vY7Ny+HKko+H$v&=4tn_hCkxL=VHT$kNlpAq*_p>P=v=QX zOg1}(t|h<3`=X-o?k6dmJl2TqTl13}Grtoa?5uzpD;*0VRzO0fL?Q8aOox)?V zu-^!HJ!KOun)KCnXlfvHdZkD&?wGJVg>-y(eiXZ04`#vjrwQX^!FAX-TSwtO z%vhU2cRPPmL{J}mcGREWJVt|G)_exaW<)__ggR4GO=P`Y|FoIV+pEwqt`KA&21!jj+9BlJaQKKE ze!8N{MQ{Ah9}c+69~<6}IokxmwB*6;@zd{|!JHAqCF?V-U%gnb8PTx!HxFf}N25-l zulVUhSuiZy&W(>e3#aamgy2`D(g4HbqJd6|)5n7ab{4^_Z&mmLuBwT5~{ zRzs4~9GaVG1Bn-tpuRSk59>Fq{L9%9sB`rxZAr+ZpqI)_r7KSyS@ns?BaPbX7&wd* zN#;I24iEZ`!+z)1W8pg!G@l$PeU*Iy?z!Bi(J^b;ry;wc)v%Smy?c!Yl4$&?`VIzt z3XsL>GXnMd2$G*hnTQjR?H2#cZMd-ddsi9RZio6 zw!rom6ZqEW*Wg%449jkm<<7sh;2&$81ece6uxB3fZCj#f=Hq&Diyup0s-H^r_C}NM z#`D~Lvm{nHzl^yD$3b;f242{Ap2h5&!H#|^L?0OiiB*4ZEPED@3R#ihd-EVVyS}E- zbHV7GTEvPsE@#u$kH@_oXIWOdtYjSvMx|rfSd=N{=8e0^hIl99nsvE&L1rj_e6T%G zqz(I}pTP1Om3OK415*vNI)aJWJx)a=gZliLiq_bEzvph1QuFF#14^EzzY zS_8IkwG&1UZWCvn%V9H;t57Suh#N9#pm^2JT$upT2yX zof-QNDi@DNt)X*a&6GAUcp$^G^x zhxA zB31tF!pGIBlfADzaTjCRv`N?4$4&X5H8}=dS2nRCuUO{OkdG>-Wh8o^f^o?3CX~-y zj+3flFh|-Sat9V6cmEr!oDjkGOqqyxqK-4SSw@m>c{hCiuL;W^-jJ^T8^+!k6=2Dp z4)pjjfZZJ2#h1KGWme?}SYYcXbc*)3z36KRJ2aQGKfRCP)WLvl!4h!!&>!}$Y=my} z8nBWzh3F~?EBGbLaCZp!6d!_5+V|KW6Mqch*GRQH?m)zt;VemR56HKVhtL=0xMraY zTl%$uFWkL_&HDKm`uv)N$KR-8WB=3qttw02-f9cXa8#9aSGrJu+!gA5#*ryWvlts{ z$`)DXVOPXq2D2R4FM5I_I!z>?>7UA>?cbL`t9rjaTY~sQ=*5GKy z#%K94Wj0K*`rRZVPL!}-?-e9dyH#=aQwzG=E@HRy^>L25EuHQ5pj|0XxV008L!{$jykQ7)M37ZDr@5JO1}-0!-Er!aJ)%6|MKZFTqFaOeew-h{aX$fO^vZr zYYc0+B?Cn@pXrNJvNWjSGG&;Kg#u#`w`YYzeEKJ8S%EuoeWbMhvK3@~mhvs(t#E&E zr1bI>S!R4_qtxU=2E-R#hUO#RDQN0k`Wfm8DfYSKd;2{f`R@zQHvuelRzwQfL6hUZ zLT-vY(z)+8b$y~a3!4Fuep|#ID|>>)qBmUaEfK`aGVX3&2tTK138l=@BbVcS@M^Fc zT94OdvX;Z>?_*Cea2x~gr0QVibC?(1M;ftf12g{^%Uvu^CCs!U-H-KRwS6(vKJ5&| ztoq7bZ5)k(XXlaLiaP#Cz%vR~dqO6kuYhT%i*P=2@aV-eu==`PI@5n3n)wgF#FV}K z^RvUj{Z=^NYtmV8nHa&g&%Gqxxm|`$a&h4%e7?&)p8NTp=2GHITNxPa=yUCrD2@i<8^(iC$Dy(bjj@ zrNzIcWUtyv-@Cl&PeC0l_nHW$GC}0}Rg39AP$%Dc=JaM=wAc(!(ZxmHpma8XoMc02 zg`ETs+$hCOb&DH zZ4P|id^6e`)fd{HPv#$#JCe!v@$e$`IH&2fTI_693X4A)^7Fa^X@U0uR{TH~{R|gE zudEbmH)?_9+jmmm{iU#f>J+duRK>oty?FWkiLhagD$}yvMm1qi_?2Fx_~-X#QlfYh zxqZte@AAuB@n z`&0vdy_Y4tZLp@|gd6;p$8FH1=tjFn-2hHKkb1}Ik;<0E6r1px(C#S&N&+DFb|EZo z|43)TL@dfho-8W6Y0v>#oL*SMD=v$myLZoV*UW>tulBR4Nq!79HI6E$HW^GDWK0oX zVrlC(WjtwvOzZU#D))K{H9_WOyNpNC)9YsmlO!y+GM_s&x{F&J=fRB#nQA*_QU%C6 z1i{qi4G^&Pu6RrOS4uq-1*^*uqU=+o_O^>5r$PxPzqxa9LC+&T)G?j(Kfd65tC*DS zdPKZdzbm}R>IBX7I3m63q6&Tu9b81B1tzY#1rBn~pcW8KXQlDdTg8riu(LOvT-8#( zupx(B^L#1Ha~G#N)e7XhhY9VEF=|%J64~zttC< zRGT@AtG}Vi=#hB1c^5=vjiakO6JV*`LDDa7k>2!fmbzWnrTr?0!6hb!kE)nX39IDj zt}YMS{<5&ntWesj(I7UwuTC$nEKfsNI0p150)e`r|&uJxqT3s4&R3{ zwYS&?h0$!GUkna5?=Oj$K89@5F6={juuuCsZi_!ci`~>E*3-|i^i@jiT1kp+)!SH> z(KA)Dsq7d&Ei1q)wQ9J#;w^jb^atJcYf9wi%YwqQ8*ul`6xO^(ouNxGs{S#jjnDJA zBfjTZX!s2rUDE?*7oI@ur%#|JXND*3Z}C|-r*Vr-j+0fk8aul!4qfff!-}*MtasxD zj1RgEp$BcL)6I`f4LM77&yLVdN{eIz!{CgzB+-XChBFRTCr$222C}Zbpcy;atnBP4?o8=b61$}o&pP2%(ksh=x zTMoaD_{dL7KhCdO_lvhw+DF^>r9pdO1cdwiA;p?By4jv8_4%PeWdmyX67N7R>WDo! z-nvMd$GfT2!{51$Dd*{D%>)`b^Df6u55qf0^Qe5bk?r1GIY=9MoPNj$ zQjMt!*ZwjM)<1tEw$9CiIqBEP?57Aju1&zHdtPzg%3l2AB7d>%?h4X8RS0)8CUc%m z3x(LxV4OK75%&CWYx>Jc(m&pS^NL0Ia$YjO>j~k^{VfzZ zFPnLQIx8@{!Zr^4f&QaoG2P}EuJV7!OBbcHs(TEz*4CiX!B|j#7YwcALfM(i_u1^w zSUljA3M(_zrQW;FFz@^v{N7=KTUZ@+7WKnzD-&|vSL7NN1^ zD@wH&;h>w#@b8BmFa{$qbH{Q1+SLI3pXP|#?3Sbi`78v=XNW915n zzFo#1-s@m3msU})6^C)hpbc!|bTPZCmyIjmXQAG*NW2(!2b*e5@Mru+e4m#jUVcj* z=T|f^uR*bF*y_I&_3#3NTz$#*cULgUq!Po*KT3bvo?~VzCve)0A-Klpwe9mc32?$E zo}K(r$qp(T2bb)>Y$)?@iCZ`IzqVy;;$0ha?C_Hv_CUNynVv8_cTUB%Qn!&Ak5_ z;1^#ZUi^G2UpRdgZda)k`>*eX#RhfQc}5Xljgp0HZ6(asuauq8@FS}aUr=MvGv;$~ zHw$+=jMK_`NuHL^z)yS2vE67ac1_xkrT;d;{=N;kElajSMfNz`k!HZAE52hs4hoVN zy%{>|WnkS~Rm|*njydQmOV)aP#k$MysA&BmO15lbcU_CwF4Z9H?_Uk~r%CB@NEs`* z-irMS_d{aINiIKPGd#R@5pLX0f{*`HFsb4S=-pBwbI0ZQ;9dX}DmAl>TRU;qf#Lj^ zlLkz3e>E-Z@S_if4?#osI<}9^W=sEE;q>0NveovdLFM>Bd^43)CBm8nhJvy+#huIC#qxp+X_ydg-LFLbJnEN`JL@ztQ!A2EZcRNZR z?x_>!_L66zoA=_Wa4oVb8wayTt6?)X)33Ts+~%J{@!!Th(3lm;e}6NBf$4F!tacNW ze7%fb^;_8XFH(c-W+5f zXFO$J4p~aB*Cyb~mGw9yA`sMqE7_FVEZlr74>cQ|=uGZnx>jGsJe=CuxgE-qc^gLI zRy|%C7}3e>>)UbF>%-XSHW9W*_LJV|t-=NPZ55};{^ss1IL5g?Iza8aHsjY7&NN-N ziVn~#wfH_)#8&p|!)*77v3Y0@-q%RM zg;u>V`}kn|Q(K1(=I3a*pC~rnrtbul=Iiq{yDeB$@guk!90aDj61l_Y_Va61lDJ3Hi{NXqBedNQg~;R~ z7_@Z&vmJSpzB$X`_gEP+9Df0vQvbqR6;mAZypw;MJ_RmW^uVy}v7G*75vc6cME9_b zU{_TQgB@OTS<*U)*XTu~HXFhhY>C^IMolO%d0)=5Hl5>Qx(jZd5OC3Vls5 z_OkSu_E~D3A;;DqTfl;90_fl(4VKrroqP*qXp*GOcB$uZ=$@Pma&^ftIdvdpTfGx2 z>|4V72A0DIkEh&;Emxp>xfMgvO8!FHDu|5x%5SO<1ASEO+jS2)#+Rs5VRLuqy8eZKjWJ=fCPfS0LX2-fG7aQ0aRc3?s@Y|(9| zsOAQ`URFwGAy%|&gFPi~s^x0d@8idI9fhdfxm0BP4@?$^a$A;#vCZW(A^Sl;-gxOy zcJ@}hbb-P}7#ewt-}^-#Q~b<9Ula+FxN`o_$t<;^Q8j;T|c>1K)t>^sxIq=ls1=T=w?~rPU9jsQ0;~WK{|J#c6bR#l`ZE zmz%}!-p_^I*;#NoRR>Hej?s$x8=RJnA!ls+g_gh5gt^X|AWB63<)RF#^NQpq?KlOY z&$ZBq)8WGo*pX956?t4A%aqq%q=kPRxrt{N!-U~WXu_<&<%NS8jOtfF7p8sX^4jLp z;q^yJBX1X6dpZ!xM_d8tlQA$&^AYWf`6rcER$*qItLeAnJn9pwPM=5Vg5K~)q_#Ge zmW=h2emG&vaW`Mn7ZyqW_0`;&J5$h3>4VsB?>35k?@YTKi@?=;GqnA2qEQnx`3se! zslfjVb!N%Jrm^N27qpC)zL?C?A1{FYt24oCo;*9UrkqPw$hAGTO3FPu^@+=or7ik&qbl+das>@?Wdi`iPcMwSyc9uIDsjGrG{lFW~42z`@C-xHhY*trLIwsAjP_b@^^pDI?o zPNl+4YZmB9Td~IXG>b}T6*->`xAhDshJw4mYS9oSH!0*{8MRJT4j&&J&n={JFuchW_AF9|j5>dQUH1j)n9xz&Ck-9(#2HGsbVnw~ZS;kk z-zSl)s{tFXFcq!@q|%BV9o%f|8!%iUCl3 z&lI4*mRmaKBrluV&LwYiCHMYe)OyDZhUxUh>DziURbh^4(&{L>zblODx8;+<98Jhs zc$(~%R>0W_CakYc7n^wJ91RU~#Egg{CR-4~+zn6R>>rwvmlT2zORI4pHw#%@DoT%) zl56W#+!h$mnxni}PWLjt<<((UWi?V#?0W%&vOnW>qmkgec`n;#KNK&FzKmPcu8J3| zjG>)Iam>c4gN@KThL)qIbEEw)&?~*_r)Yvyk%exb{p z3s18Fw?c4I<07=MCz$Io0S(W;wF#K22{Q8G*tz??I0Mt@#Grkg^@z#Tw(& zgbkl*V9O@yv=ML1HFgb0k@gRWa5%|N@AZpLPmUr{MsH!B)s5CAoaMV08iPr(KOMct zi$kILD9(ssc-ZnVsJv^1PJgD|3$#~^N8CMV(%M$(; z;77evc+so@R71mIp_4y*S8K@DP&$?-j3?Q_9Eti?uwv7T*w_3mjM+buCap}NwQ{#1 zwY~wad=_!dK{M$-e~Z4p)rO;&74f!i4)c>M!v144`5WEAbR=R0e=q5XkmE3q|KTD$ zS91ok#WHV&q@i49`XdeV5>s$+(gw;cE{3Pq$D&bwG{1dS6RwukWj6-b@Rlc*usFR) z<{w~;5i5^j)@hZB$-%a4g8{>qgYuH#_w68=`2c?f48`-(Tr`g`VH0;;#I}%XR^4kc z>lLnstEBhYw|?f5$!YuX@^~4EO2}cFIpj8zJM$E88Ykj`Z3CI3%Xtbdu4M7^OIbr$ z1YX&Z0D)g!=}<=yd!|=~7WYdTb)AbV>&xce2fioIobc z9GhuT!^ZeW;=zjnFl$X;IOt@;EREIZ*d`yoqo@+otZSL&zUvgObekzHGsJ0Y#-Lo8 zGOiI{r>UAVAt6d05`Pq+$(Cz0!0-R%Meh1oE^ZEtLAA{p*chxwb`NGT#Rg-pI%x~2 z4(g#%Ez@x0U2RH;GhucLBbgpt;-Zp=eXo9?8HN++aIz zc`)vOBlPD2`+&H!litUkqOm*LV9O#yT)p%;jqh2^{L>4-GeCwZw)H}OuPSIcaO7?6 zK+jwDfLDDC6&+4tdJWk$^hq20U44`N9=8CCmL%ZI0a_JmhOc0V+Fh(op27W6Fvj@} z-$8GKNYZ33Bp-D8F)`lQjl!bxO0JxY#3j>O6= zV_duN0?FP~qn<|=DEfGlWlh^cEBtOj@H8#7Iw)bT!t_`1u(x2T_ySkCPTzLSzjhw^nt*Jm=cW>@yQ4(|&Zo_^3*I~!T{jkp?jSYCZ z7mNP7;oL}HddHREh>g*dk|x8(#Anjiw)+%(`U&;QIz*>dr{H*9{=W9A-v0K23mIpDJnomK9v`v!Bq|Rfn>Q zX0&JEVLmNd3c+d?lweQ;&o3piYvP-9W{Mo`xUYnV-l;*PY$UDR*9P)471-0L47mH^ z5w|2_A&WA~fY@7_3M}}zYHr-5%(*e#TI8{ z9Dn3IdK5-d>YAPCwU=k=lZ1ST2U%R&{nPA_aV7t4cQgi87GlnX8dyBLiM_X|#19{T zU~Tz1y4%=5W6r2l{B>KxM)}^vf*-+@vF9Jx=pn-zZIUo!+YrpQK0xXUpWuapEG~Mf z%_~0N%SBBdjnPX~*rt}jNDk-tf4?5GQ0oZB-b6xf%K+3cblhaN9fP%Q--ehR{rE`ZX6>7ahP6@4#tL5t@|<{8(@7VlT8xE2w{ zuj&Z__rAZFqn8%5{{0;O>i7GxC6j0xpf3WTMZ;V_vhCXe$BGhqp$$-x?k|otT((n>lK1^0ran1ZS^RfAY zc_!BUxl0*z|LSX|tle887iNU#r;R2H<3}9p?!=j6)tU2(L@=C{%7yj%Ncr=k(V$p~ zyw%=l1*b;c>lf&-m=1|smqR2t=l3vS&9KG{IAHU01tn!UPdLD;SXGCN@ zry10`#`Ek|7Q{d7Ey>aO%mu!YgZs-qv915=nTL8eFHTcu%JWaNO(yf%!r!U**G(23 zg_v-kp#hkgb_^ff`GaOB>|mm687R8RRfOyou3s;d6#rDRy=e>Z(~wHE2~NS-?``~e z-&gF{ZJ{l?k&E6J5+ROv;^*(W#taQ|*sA5Z*m^t%1}?wH-MjvcwKvz`x?dBpy|jW8 zZOagcJMD-49V#$Z=mXa*Iz(d`*jVMnZ!Q=ILkDUSxZk9h z3riv4ZzHBN9~vC{fY+2hVS&@X19!mzTi*VKAL%w=<~kU6yL^U8`4zasUmpL>yj`B4 z8;rYXJZ2v8gRlGd((E6FP&-qO^Yyi0VV8fQoWmnt^{OJ1(;UrpoLfMto!jUr8_ynW zn1#O{_5rhz7a`{DFkD$?E3xQn#tu4cqU{@dlUeXO99p%T`OG{|Cev)lNKMEI>r%jH zw`}nFeM_czw3vRMbD;@x%9!5$oW#Zk?8(Cz{=NNZSTwYdgMO}{5G>5&mNak|%ID#V z9VIl-Za;aQ;85%AHB`E)!IsN^0-d=5v{`o`o%opze@6{wpL~xpuN)IP?7kUCe~$yt zfi^76Z7QrD{FLGXqqre8%V?+ldCvEN4teB9(^D^JCOXs1qCQzetL=8`efK$QSu>AX z=hx#J$VY8AZ=5t#9kq)`;=}h0Cx!H9Ujp9Yhdn3Q3}s?zy{?gqVkOfX)P_gHBXQuD zuUO)#OXzclS$SW=_iu0GU3F7d_E;YV*fRFxdp!$VdI41;{?Z*EEy_C-#Bw)Fag5w1 zm^!h9fBDr~`d8&A40p5ROy7nWF*f3`vj5Qtx+d2!l=^N*; z1L8SC?jgrI!VK^wO@|`cG4!r;Dz51L8STT5@NomY=-lB1xK=7gnFn38#G!z`$@Srj zWg^OF>V!bp;!MzND(2nxITHC8(<`T6{Ix-InEgW|c5(N7x~K0)hjuD4(?0ps^yM~x zFZB_)Z+`*u!g#W0_&wpd ze*w*31Hj1UC&ea?Wghk?;9I*T_sB1uE=MmF4^ICnt}H%8V?P)(8QXFW3pn`ZJP17W zgW*S%6__XllG5H;@Lgsiq{X3FXICPmzn5cIb!#ZB_ek+){bc^r_6Gj%iwQ7lQxw0? zVFRo=pT)Nnnc)^Oj&Q_-QXF0Fi-MSY@&ld8#GSl1fI(Y$Q> z6xGcsjI{ungBox$&xq_bzSF=rwlHD;ej!)$2siFnAF|w1K_SDkg+BLRX>xuc_0IL7 zKPN6qNB)q3HH1-c;4Fb20o06 z!J8|EN&VHC>}khrlK2_26Mw^T&R926b2vxe&I)7HWI#S`H+ZQZ$L6=Ol)3LREmu|* z7d71nt~rnyYuZr7k5=mSb`?HddL0S5?qbi?#s!PrTW@Lo5aKiOFV zLlus~H(y&YNsMRNKbm-#n{z26r!UK|l&RPxTfhcPoQ>zQ>bT&sf9b8MDH!ishwTIM zz(?I2XU~60!^E-dOYLTs)UOnJg&vW%4GTwgv88y2O&ac&MzSt$C41TJ!E~Zp@#uC> zE;(g9ZD|tvDlrDw_Tm61lazpNZ^ppg8auAX-I6|xAB)ZXQyJ(8Ia><@9vcJvCR{*`@^EdnRDpsE1H7LCPL&zRNaGPQzbn6S)R?1N>xD zKoian2TI;b)9T-gsYsbseVL0%k>l{;$zc>%&`R$VACb7Njk64RMLll}&@y!~H*Yn- zD38$<3pR{r8u@b|Wl(RhJQ6`CO6Ri|d^N7WJq$#C<+QD+hx^f?Cd5G|z|yRzaNSOw zKW%CbO)9Dg30L7{&=B@ozk!NRey04Ei*U5LP+Vd$pSp}vNGo+c{0&az>Tf=TMb6e# z*key7Pp6P`LbC;USsrQ3A($7l<(-{_MGEa-+POT(8w^DMCG^M?(Bf#Tf zDR&`H2OW+-7ljBf2r*7`hTK>Q-cB zFV4ff6BnWLkSSa{x|hEC_mf_34}(7nMW8sWmK)hJnl+AT1D}%L{ECbwE@84FDhaXr z*nrnuj!(Aq`M86;!OdRc<6RB#bMr#bxRgbA^DFtXNh82n_;+*kbJd=$i;!bUg~}8ikOqNs2C!Lu9l4=T%~|1E;oj$ESi+s7p#Ay{ zuQ}@-J)H2AN>lQ<|Jo_;OFij`{&DlpY=yifS=8>J2A;LioV}SB{ms*5#w)B~O7u`X zklW3dKl;Iae&xsU-Ipogz$#Xj+Z$E?-KPo_P1ai**ofB8T;G0C{2W&$xG9+o2L7cG zI(!AM{jiPO750q3vqBqF-vz<(?GgO@Z=P@_+lBV^yG}#$Q%J>PH0Ycy=dCAZgUzs| zU_8+rYF@nNRC*nwDGS>qK_SFekA^ipGkUOk4-Q~gSpWSkxk=loVvhs=^p-XA+cJ%2m#Fca^3l@YU{`kDuKR`&qp4I0jcRh*|p z=L+!@;ki5}=?7$7S0jh2P@+FUC~*!6p zKDc%kh&$~}n9SK4zMy&%$+|exqrZNz+OC~TAO4-@4Nu^HIj-OiFWW`xI~HS=z601+ z@j@M};r3?Er`QdFC>c^CO;pi=Cl~rbmG@cNUuOo^Yh1wa`+WHRVmys7PUSWqv4b0W ze$bqG4$?|;;JL$dZcE>76nV3mY_1ug$D2Ti+{U49@GZJLz>ogy+`xZ$bqR*94dgbt zKBvNu$7pHgbU0;ck4h%JSW4_yNPJugw!LHM$|yZ@^9uxg`GrQ79R)l6g}mqGKB!@^ z26oRKN6H7(sM&IhG}|MaidFLYtfp4T*?Ux6Kfns~n}#t(_e&67=tz;-3AQ&+4n$8! ze@Yy#&Xii_VE_L^-g&t7{DuEtLs}^9p^}smC86HuK3Q2QrIIaVd?F#6(9+OQDGjBe zP+An#``jm^K~~vOs8GlzB|HDMsPd~!wxmkGVO)!obN})FGHo1Q6F}UCu z`Xcu>+rRQ9yK_tu-7Ynvftm%VZBK=}H4m}4xSyDmA^X1THxq}NvXXTl0OlRR(}QgI zj!$>-#koCjO;|517G;vq>~(DILS;N7eD9~HB5~jLGSQyTrR*?W4yxgvL@hi8qaHmK z|Ga$*RHTlQOSiIFUaJZHjy6m|R)cpO;8+;h0&;t8$N+wh)cKx+$4M3xinfA8h$Fds z`8*~mU&qB=Ggz2PHcZSdwM{Sr^oC}L@1H^)3=?7FK#KM=gYf5|R5oGyCy_TzVHG9Q z_`lv{QhHpMrl?LP+H-WOA~OxRRcbxtY*!^?rG`P!xm!4W^c>o4G>7C{ZzCQu>Re;5 zJ@@dB#oW#JCE4+JWH~E3`U{W87sA!>ElgO$>FCBo z@G4lIjqu-!nN~N!{nT){enOAx{*>k>%U#(~l_6}lbuet(A5i&aSvZrAtpv@BXGqAa zc~rwm9e1u;OWcnPr~A7z*~<28G}>?$7Yt~D$RKk%=kqZ7DK{OS8>m8M!I&!DHRE`i z$YAm)yML7!H8x24g_i@}YOb?g!zhDtgPuYZ;-OTFF5h!*OgTml@I6)>3LuOdPVIhm{F}V=* z+JD1Fi}|oA!wyfiDe;^4<IZ(&`o1C`!l$gjD)V}~b>$AH+)R8}g8 zu2)uN?b{7$%IQ?PyV8@p#_97ct!1{Y;WzQ4?;2d%QRZx9r8RCL`{$**9nCF0*;wGv}#YB7Q4R&C%8dj(r=APO|=oUvwI&s%_ zvH9iu(7tjKoq8@sB=T=!Q!;C5g>N>fSu28BT$@P!<2gJ4j7})4z~V+1dUVuuUOq^g zzic=RTkM3K#>pLA@#-r2p)CgnysBdzJN0q$Sb6IEzKHp6h~?)~3;1o5Y_*!r3xSc&p)G3m zT=IG@o#|CUDw4G^#dA0>v$zan0t-bOTocHxFH(4Pm;>qQ8x5g%HsN|{9d;~1o7ucF zhM`O4;oZ*^DEYdOw$Ijr;g74)xfGc3svvgq(J7F~{Q-Nv2C`3{8?nR20(3Nwf=B;2 zJo@b}mQ?>>k|RclY<|sz-j#1*Z$l+mG^n8aMoSu`?juTmFa^8+O+>FZ#{d)Th`PZ^ zaby1>(dn7Z;vt_efJwkS(cRpMZ29~UT=XRw1B+JCJ(4}-YIQT(OKE}i=Ol=4cNHmI zx`RD?gdB6wKw*~mMfAKP24BtI!Eaeh(+45$D7~@?C+;2zSAU4adkVe4K7A0#N=T87 z-jd{lgbZdL$Yu)bZi0vI3sN(C2#&s@NNw#bdEmBcBu;v;H&L6;bzF>U`6I~k$H7n+ zaD}M8K0vnQJ|f%a00|<&a7}hJ-7arWlP{fzN1IQu#;y0s()ADi|)i$&v^1& z2oxlCXkgynL$D~Q4#ppPK(H?sLK7yCFEh1BRE-a;cMZd=#n0KY?QUe)@RKBAX}P#d zrxZ@ySW84Ze6dT)18$BT!`^6|B8xBmA!fV(5Ly2%cx8bf49mBITyTN+!P%(W^&f;e zZ-*~w>tLGJSF!qv<(Spk&ffPM^B+!eMB8W=7gvPy5$VHdMXNsT+qf5Ghk7x`pCf2; zPBK3>^)PPQFU8gR-;oFO7Qwf&RDN>=?gm4uwY!(7#=U}fV=c&p_6Mx!ZX#>hY9e@H zzF=%}Jl@V*3fdBbQSOpHHAstxrR)4~t-d$Ck}wnNfbAird6{c`}e}grTcjB zfE)mfgCm!U{4YGWP3x$|uCRTu z^-nOoZu^4o(ihUT6p2zyIIK?JRAs+fhp$;Mm@BR^fc7)<_~CnR*ygoE>BAW#SnsAu ze1!2acp4r};)Qz&m&-0>|J6sL0OAUJLeEq-=M9844^-Jf*H%2UWdwXF*#P=i28u%> z%<-;@IlL~pgL0Zraf50wSXS_IT0~6q!o&wi9mJd^Zj$rlQT*(@@^LS^W6#Fu~ zg;|FFAqm>10_Unr71%8(4ww$#a~;@=O?j2odOq-FRRwt+V8CtT?~}>hmVCC#YxZXT zCft-WlXCqkQRL2>_*~5#FS>pof*$X~Q1ZV@Y?^Z&wYCgkuD9PZoU@r6xJYP-&l$j1XQ8KW1e*HWqW9DY z7+=htR^c>!P+d$9J@iMNbBm~C(QH1c--)+1?BO@& zJp`M)6ujjY4K8t%58dHOUyT39uqT)PVkY#(iZXm$IvB6S$kSOnj&Z#i(b)dW5bw{F zr)NJ8rCrKtI6U|p>^ZuN?p&A89U|NC;M*He*kBIt&uKG%ixR#?YY$&kQcMR2T@i2K zGw58oM6g}`9!5n~;*iPzK;_*UKE@^u#@syu3C6`3@W%_y+vQ;Mp~0w!r7&fa3XT8V zEbKoz*s>p%Re|$|^6gLj_=G11X_=D_zd5)HTwcaf<6HVLH}o`0_7*XP#gEW?g;dr0 z19P#-dpwKw-T~)&`*=le2>q;hji<<~R;dOLuDV)L0v*0$fdk^A4PuL`J>M?V!^Rp^ zWYt37lU6(?a}#^52IFZfZM!X5-}zy+Hn{A)8XCVpp}D4`>A|kEF#q>ETGHG{B@U`r zJ(nNK>kn0lo9Fx>18Ye`28@Oe!!B+BZ+pz={wXXMgB}#jhpH1kzUx%^aQ#5+1y4pF-W%R*^ROpDz zV5!?{gx$6>`jRpd863=p-wR;3u4~a#71DIipEG#Wa6MVtlZ*|Y+Qr8|h%rcEAM8zy zw$+%hb7Wp$ZoKO1i9z6M_U3tDrC<=3Td*jjFiyn6+;6-$7mT{s_`dltdh7(?VM_|&i&H0@C+Uh5nxusc(#rY|w$ z4N6P!N1QHq?8?Q(%a(!lxskMU>LCcMc!5jGFN@w!evhwma&YJ7D$H+HhlYI`R84Of zz58D!QLUZM=Xrm@rf;S^;+q!a?394d)x}_M5r;EB&Y-utUJ%Dud5|-(n7sQK1pPtQ z)F-e2Og1rm^YJ()>4%br>9gT)SORptKY$Zro55JF6m*Baz|WV~)6#>t$ruY$lyfwu ztvf>bufV~)tT|p(s5^weZu6x+mD_Q)(kOvnO~cte3Uu9|)mWlu0*N24h#jIQkq6`Y z@u8{?T@Cv%@{t)=e_jnk-ls8WapXs@UFOUB$MM>(v&_wM3SPXi1~P67b32=7;G?>b z-v4q6rG8IjQ$y4F+P)LmrCZBZeh?%J=9Qt&ul`b z(Yc}h==^dO9c82f?qB7xs&)!AE5<|kzF=Hb-$Xv;S%(-bdr1F6Q!Y9nL8CwK!fKB^ zTW@{msz>3K7?5@w3)gA#$_qu{y6v+p6?G>+@nmDG!@~|4oRwG zU`<1<75VAn(`1@V0Qz1X3A>yV$=<*!wj#w5)L#vtE@mb;?do_qUS5NG&!6DqoHSS^ zY-g+oR%snUnMGsyn)_FYpSPop&e#_0ZafHVSP`xoG#dj7PLqu?Yw+r# zd?va&2Q}pP;QS3Dc0kA?|ENETXAb(2K6wM$-O@_%o9O{nQ|3T!pbXvrt(~+xyFs+?Er?fig2c-N7tXVW zX`5f$2LIPZ+}D@jbn9ex?uRnFrx1t!J~Pq6WiPCO1hHP=ZSa}-9k-;m;IMnjI5gk^ zH0jie%co3aHwTuZ5Luw^BPB^%$UL0T`x_i%m*U@s)2w~=LYDAv02nOog1y3qxIGj| zwnGguWBM>(&W_$Zk%f!j%kk5HGr{y@Jh^ROjAO37B9?*Mh~(sX@Mh}}p3@KsAEqS0 zr5_O>KehladRu|Bgc5X5--G8b>e7J95OUP>ns~XK8a;45mt0CX2QjAw4r2UUl49Qu z8Dj#mz)cFZoU9;0s~Yy|1qg>l8>B6Ae9laD(0LyYj=}5bGKqlP=-nG=-L!b3-0Dhw$Mg}aGt$@}l#T5l z4Fs-dIX4*)3iIa!l+852y+Xb%@v{#~YsM2Hs(?C&DQ*3hFFsWGi?}-vt(w~fd||?J z%==o2Q|??w9eG!DUw?*{3_Qtxq!*&)aX)_O#Xu@U(%|^cXcWR8(rpII9mK!)zf{{SF6puFw@8&r@c&+&|27OFKyS+_B&%lB1f7mHH7?I6J zpS=e^T)sid>h-|b5nQ=s8h9!X6OZ%Dha0V5abevU+N81q?ur^PedRJGuC5I_PwU#nkuNgpx`X5Dy z^N;b6*L!$xyqxFx+(H~O8r&u*W61tTxO|f~zSj$7StA~>MJJ@e#;*^o^8POuIqp?E zuGQKF4wF}raV7)lx`U(WPYn;8yL1}Z`W@zXrX8TBzoKz|t|lFOfl{+6-J;{+r?G59 z6N%c<%`Q#q2hrjmIIKFqa&PetE_>35@3=MqXN1U><~F>3VN93Q@JixX^2X~gaA z3qdrb9VWRi#P4IHaF3b=ar8__7n1>yCY8jjr)y9dt6k7>yc{OY)8UTBHZY{oj@sqU z!b0<1)FZtS_kFBD`=mN(`lv%2w+zJKE!SbVvoh?pOGG%^LMm5A^FJmz{G8Kh!M(Wy z?7@#-iT?y1N$J#estk`YAIg_^ZQ-B(655-o&nIvhD8C-Uf1S6cE`?@nRBHm&*}oY} z6GQPpR}NEhYM|;6&XcalKx9Uc$fD#pJtv(+OQdJuweDKD)O3_?yjH_Q6uRMP?K#~2 zbsP*&yacx$*Yn*Wk=$ML9<8)h<&E3*=ridoG_P-ifkB<3Z^z|eYqKlZ+&6=NTLw_w z4|0(9`y|&E9Em%_Z5UgM1eJoKQoTu;fE)%%RdXr*#JBKe3Jn< z9vn|6JspkjOonpP_ypp>Y{|IQs)F-t7L72i!Bqofc*hTadL^_+SZCG9FrNsjG2D?( zT`I%vhF-w7;Yy6{c}4eI97b!0r+E3IA(s@n(Z(si#0xJyWrNMtdBKuQQd;fDtb0S* z#PJ}o4G#1lew)34p&^PotqJ8awh`>3 zO&P1`b454nnHYJd7MFkMhQ8)H){`j3BRvx#^?5L?wqHlPeK?BGz5w5h7kn4BqT{~@ zvxE{OnC||FI)&NrD-)iwCp$X?Z%Gif-qcM-$Yx`TLnK&7=8OF|92fWp2Y&Wn0e`zy zihe7Ts02cTW{;GU zzUFHcgI^&rRyw` z8T$~!*S(`x&xQc$I)yJDw(>@;P~II=33(46gYlwd{`h%1RPQdK(LMyqg*{aCqY9K9 zbBRZ*ujf{$fi=`!fTOQVX}UO^j?E<0$i$SFpBv7zRXln1IKjt5ouD`=lC4y%#_&(8 zXlF$QW~J<+^FKynn7$blKRyrVF5Cv!H=O%EOhS9VWnk*sj{62h(Tnw8VEfQ#5RfrT zU_OwmR?9%f$ygvqR^SD{$rK}|@z38j^WEE1==;5+z(^~Cer}7!c?W+G^St-s$BH_z z?7%&2Xo-b;Uj2~Tq6(df4*ZI8Br|-PhVj30d4RDDn58_&6Q^oG@z)Ca_)iQ^Y!itd zY?uIhN5#Xk^M-W#>@qqx;~uEe0k~;W7;N3BNsWFTr*C6~JDt0ep?~Tx^38lJ?do*m zF?EHI_I(Z2)svwOYa;}Ql`D}hn2lH4`ry{*x7gbg58iwK<2w_j=)sFh(DC{+j9uoy zYla5Ux3+fF*o8t!+go@fqs)gWe#2OMTe#J-fj7JlqIsdS;B>A8(X9R;+%5Rhx;2XQ zj_e+~?~54E)R)5CYyLc9i9J5G7*7|DT@IuDqRE=!Iq<{P1h(eCVU5$TlRY;(soEeP zxUQLl_0!8qZ_zoBSD3=({k+j4D~--tn~IlzMB$wiXYk$uN7_2LSh&wvN`EK+7C(M; z8qO_tg4^oF{Bg(;F1EhG?r9p3`IEzF>VoxDPJ1f-Z$=frZy3XGwPo<~z+Bp5z76iH zhvBXXu0l2}njTh*g4f$}C~LZoTfeID&g@|{uxP2UPdvn;j`+1AlDxOr)I^1tyQq@heI?f}61kDn3sl za9)vjcdzBUJ`92R+uQ_a+aPXO7LWUN(`epG;d5XdirpjBxm>Lmce3!HwJeV)OdA7> zl9$09w;fbTc?MLB>;sdLXQ@KLVVbHD4>6g#?HH|LDxx*IGaX#jJ__6UkXNcXxu^M7R~taM+oeF{0I{!p2Db(hoV<) zvNZBqKNwG|#&vOX1)g~c%62(G*AI%%Z!4m_?{@V0c^pgo)yY8#JN(k~8Y5;Y)68qK zG&pk`x!COrE%j3T@`W7C+t-Xo#A)o>CPTV=X0<5t_Xj3<+yYXA)}qS#<9J>|5qF+g zjWtS(@xty)(Cw?sUR_Hk&5sY!lJ-lC`3zF=QR`VI@^5q|Ra- zKaiuqw~orfTTO=FnA?2} z=S&_!9rr}DbYTuCqY{QalisqfntABDY7w8Yc0X25oDS~S8$j;mN*aH~8C16d)$3ot zU4{ASqCYb+*7FSZ>IwOU)E~?^#(>ZGE=Q_U1vk|6beN|+2UqMdMH|7lTB9SdH|~{i zXuT|1|DQj7DkaVPT5jUgiu?FRxQl2x|CW95SAoVl!9Bk3Duz@qW$QP)vQWs-sraAFH&|Wo&S!rSGJ%o#V0V8Gf4*%ny%KmAmyKTsRfYhc zq;>iIQ>(!yV-?O-Tn>+a$%Kk#E!$#50TpKEgw9brwI&Tzp`PB(F*=5=C{#lJdI4Y|h(NG)Y#0PD=F#|BsqEJ^cn3z(LI&A% z7xo@>qz0byKumy#6r2*7YVKg??w8@g$x|>rXCuwM_cXOQxBy00T2+ z=&)pD?&CE93K}7O)B|8+Z;^w|A910dJbo!%ftLi{_5imB(?aQ#YA^T$T>6HQ?)&W6>tputJ4?!1qfU#aEu{@FyKFi2Pi4SQ)AVAxkwd zHg70S?|hCP(%$%?xE{V)^%94BhA?9FQMkI~0#r(PA{z2;A#r_WFKRb7!Ywa0p!?Nv)LJft6vY1|AB0@$2bnHX zZ@C23p_px!S_}gdO!?eJ*|2ti5uH}|8k(}RK>5K7oEPH?hWcqR^|uahJkmn8&FLbo zv1hQRB?&a|B;%sRlJtc5Gkg>{e}~;hke+Xamcbqn`kyo$Ox_HW$6SZC6K3I{@KEf0 zU<)YH!p~ja;+kEm?D*U)c=^~I2YuN<*8NTdrIRaQ^~c{NdVWG>YLYpOQQw6VrTob^ zD}A28`{QibcCyz`|lQca<0`k+T=kHE=$_k*-YHPo&2#v@smaLB1RQPGMLI90h(G-hKkbSg|| zP4Ded?)o71$i7d9`a>2$4Trbu5x3U*KH#7(QJ$mz^^ut<9aOpIEEhPKbxfabMO;QJkK z#oCc2eIsF3Tf4x<9Ogwr1j{ z7{Iy&56R%SfuQMj7}MY9p+u|^oL!trBo15`$^2_U-{<)ds%^uHE59+LwtGML}t@F5esCi7oA&I^02akOXF8k#xx9{h>C zhdcj#sXTG=J_)H5_A*V$DCsDy86FQ&u|ozH3RyY-=7BUWcp8e{jKWXC|8MN9QP|w{ z9CR~th*r{6o>}xC+zwg}7E3h6Z3$vDiQ5Q4N_yZ{{f6vTevS(jp29Z0Ippu3N6B2dg4x z!h>5^Vat0bXdEpGQr@-L{X&t|S9? zD-P~}num#SZgwFqxmXWHLJE4{yD0IfmftM&`d4wG?{bh(d_){B<+G5iBm9AC3|@Ku zUL5xQGEV8Y0-t<)>^?t(@4YEFZvGY#oH7!E_sm0|?+YPe`%<*d-HGb5&uwqc{VDEE zP6W@TyID^6WIFMlKL0BY#x;w&+3Pi9v0<7lcQp!v;tfYh+vFNZxxE2~zY^AqN+VJ1 z7_sO~VF}Z&_D98GyFe~hmrpmMu>WiUK8d^~ZYl0$|IHtS6UM&>=`9f$+^a?4lT8FM!g%(~!Ph4g43rxBdC98XjIc zg+1b#Y+m08KJ4y!aQ~cu51(yi?~Wm=;xOEACJCDk_<{Yz6)CqKo6K$+S#?s*sTj$MpsN6Yd3GbMb(FlLF{A00MUdh&XXSmR4y8I6`lPwY5S5a zJgp=_*Yqafe6K%{7uct~+PiEp0PVAcX5)0Tnct8gE`$!QvnS3V3A z{>t*wcctQu^Yoylb~rYc+KTU-6ntb@19ClnyfxbZwC0V5m&JoXyN|Q?PZq$F}_*G)1P^y@b^#b3p+=4zm!7LBRin( zhZN}DyG-=P72$AgW0ZQf1g}@^1K7Em1Sfuk+143&X8K@qxmpE+?N3coSp14SeyYej zB_z4-me=^^cOXK71e(6<$4uv=7+G7158x>N=Q5he*#?ur;h*5w)m0e0N|yb&moNG^ zM~8n+_d|)J+rVYOZW4X`H&*pzLh*t`vReByCfxkZtfeYRys0@jE|Pua%<>P$ROlrhilN(t< z#eU)%Ru5#d7LMJv0?X<~@Cic6*OgiwI&8NQZ8VI9z!7%bJR=FIYytD>xr{eAe8l@s zwb(Z%3Q-{i#{3ZG7nugO*%J!TUqXdA2yBac_HfMeH^vzQyhXiTt(7rqeyrYN5p-OC z%EH|OSmf|1s8^t*>_fTe`jakZwdM**339`PorP$S7%18+oRK<5UWYP`mw1kjpsLrm zfJ28Veu~>jLw^mUMHXjZ(-BRcPi%RxmjkAL_W(9~5*s&dEmbE23IIg5F#{jzya)Q|Uleo>y`X z?%x^-w|B)n@f1d>%Q`4Zx$CP_i|G!y4fKa}7|Klm4I+$W@`^5DKa9Jg~WH#0Z|k;8@v zy($0S8ro04-x>zNlK8XBf=n{>%o(tdzk& z%Q8`-{T03rGv+yK{po_jVv&h|2fjQ16*8|T!`gmt9{pRJ?%9`vlTC-v?$0l=^^p|> zxh}-}=A&r8n+^UnXg9k znUyd!vKG46d$1n8UodaoMtZ$D8{AKqqfL+({M5DNqw03kjiXjm z874{Z$Nh$rS3~)@i>+j0WC)hNmgnNnCF1*{??ghlX51J)h|g9U4>DCVVRS?>UX%NV zQHT9u@y-A!H|PSHSHe1*C6M5(2)`GeffX`GLE(8CbT$hvrD-udf5k}nwY@oB;2VHru8*fyV+AMSvNVvEiULKIIaK|pA9t3W3V{j?VrK@DgI*5&zX^5l*6<@_ z+?>Y0Er`SR!byB&X(BON=EL^h%dwz5RwxP zi@hzuJ$wyMf1<;`C=H=f#uNFaSC;Hln*{!zXh5Bxw2>hoC_~i^zVP3iSkd7zcOWlnCw}bT#|rJ0 zcw6ZOeD-`Wm|ZF*4To~^;?4-17FvrYWeaet?pWR^^c}5L7TC~`3!=kAN^n`xR6b#@ z5`B73o0gTFAW`MUG<)+U=qr1O>67bG95;ifSw0qiCLt$d5DtFEuTiyd7nqv?uF$W* zQJXp-Oik#6Sws=(aPS&wLbl)7h)cIA)BifQLviO>#%v;ZubP=~pYB0hmTK{ku{vn# z76*YVSKysR+BC>1k%nwJ0@IIX-(0eAtqlq3O2UPWkFZI%9gRP}feA8W_^&PI!d>nT2)*8o4eI+L zbio>?lYf^SUg(BveH_?C$9lM%F$+g{d@nAi37rb`drLlwmW}U{HzlU)B&Q_9OB8zjZ$3ey70iqO_LBznx z2I2}w(YXt*B9$A9n=S9K8~sH%Csho7B9VB)xwoiflK>|aU&7`J@06oO3ISwQ$OJC$E@;`}zLR*>w>)9NV(Q$Zz#{@V3mO~F`;rz?AsPo2p>LqM zTyS%bJBfO)mFSnrN7$cDgQ#)PQkayrkABIQwOgj<40l%V;vqVtK{RJ1cRIU}qE9ql z*6Alke+yw*-$;H@;xB$zwdE;&S&+L%;2bfC|2xxrN=EyA9IaCE~uLwziQn=ScQx8M^(9H?f=@2e*IdfmxzH)rk?D)Hw%Wtbr7N zJ>;0DE?2j+m z(h3eIdbZ+9g+B4*!0+sCKsG7U%ND#lXGx#C47+u;0`#8gfwXSAh@D6UI>ZtjcIjiG z&3=*Rs1VTfD}ufutHAiL6|b265Oo~dq0T>;C-JNBwQd^f`RJhQ_HuszfhBdk8vyN7 zKazym+SKn^7#^7S4@%Exz!&@Z)X)91DCyx4;SNFGPWg*4_i10x4>cXb9hNis=A~8i z{I*0`<>`i_tybCSNX_Lg>6B}Je1*F-GC;A`2BM!C@qu&4lkZ3Wiq41vVb^gZFnJw8 z+y@z=&V2>ke50IfR%--FuQoDnyav@dp8&F7zLA@H- zpA@Zx8k-iwfq+H4-c#t(6mp*mv*$swp)x4fU&1nzWETB7n#_ur112U>q*ozROND{(F9c+LU9|(6R%@Nz~%>vC$%j>lwICLWcek__R~^wQ0*90`sa~kr3HgqOB6k z`Mdyo9R4&Cz0BOW>5>{zd`mI;F)N5)Uc8a2L0~DA6YIK>A!Rj5b$$vgdR=_2~Ic*IaOB>t>GO2Yz|5zOHmG=Me?h3@@W! zmOY#MpsG0Ji6R5VuWA4BVVTE2S1<*nJghF!4Sd%>D*0yR_+T zvnlXp*E)Jj&W(50HsUGmaQHJ=o|i>%XtPPcaix1;Y_tl#UhoKOSupo7SI1042k;0n zqjoD?_-cjTlC0%<}XCspw&{%)40 z`_c^Qn>cN1sPvPWcg6@RwcGSnh8~BRSH*dBJh-H2a2-7*p|jGIpXohT#K%^4JB}ra z-j7L*mM|}D5i+dtBVpUAlR_t}4gYTV5)v}TlV%&CPT4L7hiwWFGA2Rdx#Mm^iM1aY z;m{{^oKzFrgF9jMI5|Axts<&9cb~1iTmqee?_q0dFLC}a7VDfxG5<0HJ_!_=j zRI*{v)KE;DJ{_!%X`#|d3(+$51Y!MIMolWl;h5E{+3v8*ti(Z;JEwlZgEj`V;=B!n ztdxdz@3LV1tdsD2mL^Ugp^9nid)QRxdH8sT47RyH7CMc$lJ1o*uw_p%JkLDDbF7_2 zuZPBv+Zl^_$ZspU`^YIM*D@0Y?MS*_Xw-46Kbi(tB(^R%q z=oo6hO{OeXqa$xg@M6y`@O|kX9Q>u2K#dEpcozd7JioK@6GG-WGM3+q70&Qiba1}j zbo}~JRXl9QG=XpU#i~9|=4TEpf>&0yTzvvii{@xHLqx^y8>9H|(8+Yl-AhoT^bP#( zMMHz`LtAe{FZy#;6skT^ruN}=Ol8wTKR%o4Wfl5=?iZxrVGXGu+G#!;Ico7f5c)=g~Wc)iQQnkhu!)$V+Fbn@&`3$u) zt?Bp$Z7|Hc21ZT1DOzr<0%c+)l$!GbLv{{>iz73|n=Z-2;65oH{opY%QMxUX-(Cw6 zd?`AH??$mvDZJRG&5UnWvg&JcT-5sketDpe+Li$7w`>mezN$fG_hnAN(S8xf$!HL%Hc5VO z*-4^UBdkN_UO4${Ad=RjY|BAg*x9*>xt}@?d#2c+{h%u>O2~xS_&KA&)N<6))ufW$ z9c0c>p?kvp9_aQ=hDo2K#KzZDz{z7IRe7|J1`HX(qde>IS^hxu?iBiag;~AkuuE7h z8$sIt)k5u{k&vy|1?8rcEO|BpRI4>$&>~xY;dMS)6znX*pPBG)P6N*Q*F+AhjNmfz z6QFU>8GK4Z;poA!=>KW~=}@r7`hHiq@Gpur341v6`+5+gDEO8nRgg>`PD2MTfW%-M z+CQKj47aOu61^0?jXn|m$q7*8X9=^Pc%#QM2|8S` zFz3%RjM0+7sR4oT=d;kq0Q*=#(rHXQUk7nc3+M{pdT~veJ{H|ABg$51p?8i1{+e9` z1*NjI)8B}=6dgh7S8|k{a)XqJN06^J1CJVfL@!fWwEQA;_&6*kx!dH(kE?_EW#b_H zcPj{nw-@u8De|F44+tdQ&%cq8L+6vNKg)$~reo-0RfGjs zyTC=lfd1O5P1ju64nL-BBVGa1_`a3Bs4)2uT69$7lRNqN)pb5k%@R^N|6UXOS83o{ zoP@y+YNX<80-3jbIsMU=%^Vh}V6Uty)mZ#ZylB51H?mD7CyE7MZ*T_WDCOca?dPzu zUzb+*7Kj^N!bznlgZ(*f1FsL~!utiY;jrf*nDjdVCa2tilc{O&?m!6XQP8KIpB>n) zf3n#5_7HvJJd%&T>>=*^cb1K=e*usB%(%*qS{7a=MFal{e1Dn*l@q!&9vqT|C!-Bm z*B=9(A}JOPi&ABl@qXgKVr%;2X#fuY&LDDqEf(p&C$(Sxr@1$e$7*}ufFGn1Nzx$H zLo|zu!d^Fu<}?o)(MhE=QZ!4FWa=nWl9FaaNIZM3>QtKRNaK-)(`@@jQFS5)F(K?umn%d;KT|MBY=`3r@ysC60j|&6MU=Ldie5+bh2|Q; zvX4hp;M;W%hQw8R?O3NsV@16>_&((RDZm|Iej7(Lehl=~T~}yl@{)-i(b9jxXOSzVI3+ zd;b0&UG*V|cIxj(&Yz7CBV?P%+XFzJ<~h-q8e56W6!+LD|iOd?`3YRhKm;j<-*U z&d;@2PEZ3eW6)h;#Mkb^KTe0}gb~k(qW>tm!+mIWx}zBw9!run@30sK^%)C7sUmc^ z){We=TS$gBdr8_y|4p;JbY+>QTZChNQPjSk1;n2ECWLp2BKzAcq~D)vvp4k{u}*GA zM9p=Dm?gPQXDIs6oPuN;kn&pGv1J1@9~4KLrf(M8ci&00)Aq71C*z2R=47J$$`aJf zmcw6JORS29>=7L#qY3Fa6MEha7bm>ahPmx6VO&#uH)I=4HsSBNYP9w(eRZ>PT+-4WpCeH)htRiiP;%-#og4m~n#^cM zu5XJVy%e3<*r9{j`g`wbBg1K|u#b}LXdfTC+WfjW{7F}q-P=y~PyRO=`{yIl=&S`> zXq5qrPIYCE;?4=`CyMDQCm*t+pC!Al=fsSkHiqXVYAk=(d;r-Svezn-%-N76dvJUL zQ6E;18BO$lpQ7gHb3 zOp?;3$EMRbv-`r~l$KP%a;5OmCQ%GK)`KnSq|1gZX)44ISG8^uHXWwrOd|I+a;d@_ zOR{QpE`1O^lG#38PqeNliHS<)q)l-mj8)n!2-t_*uk)IE-yO|HnXhGvYdtLWPI%Jn zMQ_Nz6CcxA3#P-kOC>~Mg#zsKTZ;FuXOox1w?atAhr)R?d$@SY8+4|hAle>Y;-BMf z;IpYIWbROB2?IT7cV9KqWl0#*w~@fN!^ULz#c$-}rCVhAiyRsq7R+WI3S;JWk7Y`nS^9+ zUj>Xd)dRmre(+@Aa5B+UA5^vfB3o?6!b9(y%)?ugUYoR*eeX01uJzG}H!~c`=?HCz z^V-CgMSHXL;eO2TKz-InZ;+O3nT-z z8$i1O1K|B%GfCn1R5ECGrqyKc*^o5~&kalaL%SOW;%v1Mu!wB|r}3MCxo%}8H|mjI z{Yt6p>vYIUvw{sL45;|>K1o^R!;~-P&`BCiSB;&wGsjCAXP-=_D0!}TMCU^gSU zy6r2CUUEwqTu+THKDwSQN^ZmIh4y00Z>5VLP8&kI@r#Lc_AcSEy#chY7bGq>@R4bq zyhleHD_ei_HD*sv_<;0KZ({%7bwQZ>f_@2}%J!#yBjHiaVMJR29)IgXjb7V;>4Ipn z{g55P$bEOI`CcDk+{I{N@mw9qbZ~%Cx;JUQ^HBW0K{&hk>OM*B128H>AB>E(iQPK9 zJ8al0%-) zD6x#2Hl2M+F~x8BD6rw|EP2#0p1931q)}gz$)(^Hg6E5l)_EQasaAR_VG|dz(Q%RN z@P#i{^I9vhM(Pb6g#vOmK9WvOX?c#pgW^3kqOZU z>7ggX1X~?N`gF-PGIQ8PAv*jL8T8;1$<_NzN`@?>1NW%N-UwZUF@3v|*>;<=lMBtL z@9S2~{p)Emw)X4UR&H@fv*gfQDt zOL$^4Pk=xGAr(Ui)ti26~9kUqVw|CK)+i9SPnjSy6i$b*0y;O)jY9^ z+IAXCChodQpTriCb>Zq zC9qHUeD@I+#P+w+XVd2#rlXE~LH^#wVBI&L>@-LML%$f*G9eq6{>ePT-RXpSp)fqd zgc*Dv!wM}1u$NB);Yx=gkkwlS9^{ULZyML&zP=OdvuHhhPufinD(ArSK0d@U4d|-# zpM`wIA!Pfh?yUE0YpQ1yKxck-B@HH}(xm(lvirCi)te@j^3|Y?b%q0tuK(U@TU2|pJ>*}DUK*|F#$teH>%xT*%I#pzm<=Q#TbZ@B^`ds~ z;%M<-_TrtDpGnt4I zmpYHUZ1rFMQKq+jBgC#P7FsmahUWjhB6kwU!E~MRbj=F^wx+a#?;hQtkMxMyo(mzaMM9LeqL=6u$4+>lOinXDqM%bnx1K z*Q!m=7}{-0KdKQkj7&Bt7MxDSg3)$>Wpk&2qQWjn9I}}>YyQEe&0Wbzli{qS+Z}Pv zrDnp|z9|r*v>WCaTZ-$x_(8OO5^E5v1d~p!Xa5-DIo{?&aJqSa*2YwiHTDf;9s0RL zgZlpP@l<`%;!_Mf{}>2|ymXn@#p5J0D24nna67c@eldG==03QW{E#Xo$Y7n~EpX7a zB@t=Pkh|D!nI;;n6(NcV~d8krD!bZ?plEL;c9V z@d2QH6Z^TkFJ!aB@6pX}eOVXl?_}r07WC0P9kyc(UQ>5z4dSTHFw84~rs5cu$3qS1 zzb$|{T8xDOHxH8f8-_sqR1I?QKE4yAUU$kom`jFu0t_f^zCn zlYzO^S`rU2*A{>hK2u%qt_}FzHDd`&ok)DcDeQ9N$)s8TwXpng2HUDNpV~JLX1x?f zk|~+}!RX~N+N9eGC~o~1Y)`jjCmU-)i}k8xWMm5bX?=hUjrx~Zgn9{2oZKO|U=HM+ zEG8eP9H+VVqsYuGJr<%9#IlF&rHhjF;N;Fi^885{+{{~6obeRv2I@z;~3D)?%iUeX(yr0HIIYP=_71(Olmwous7Q({Lh|V{)+3?Y> z^znk#a9n9TJXGEf6I@HkgGJ+Dd*NVKu&6yVHA-eHLT0e+HV!mD%K=7Db%3CHJ79)G zD9BEXVqslIh%VdXn7hUut8X*6fzi{|?9VOwbjzeLX3gn5TjTvM zk@)R_HRIuWs*csUY4#9#r#Fc|c8XkR(T_IG?oKxp8;K4}!mTW{7ZHUC-N}K8*YTSJ zN2&7hBKr6EuAqD-i(ay6#qM@Duo^eQfcm@-Bp)Xf-fROeAZ96J}j!>LO z2C=8~hWbK!+w73&Vf;qC@V+HX3r(U4nRz%)_pjLGY%Gl`jufm)?Z{+PA83pBfk|@% zh|tLz5}Q)i@<=ATT@b_c=bO^RP*ZT|58ypZkDWK}1fz2-Vcn@tOr>NVQBGXSK9@L> z?*URVYI!DGIe0AV@-Bc04L6Xz>m%Uhm1ycP&jBWMoD6}+=WU{o?G;rTCX4NZ-oLC$RAD_%-N{4RKV?8gk49h=BuSe4%a+oSzd4CfG`=^3$ zU>-f@y%LVAsaW3a9uJ*8!pJMJFGz3uz+bJR!2AS0M}B-1?Ab7j&22T78EiFUwr9FA z%}6~Q3)=;2EaTx(&Qj>ON(Wl0^kE4R#X|p6{h0@&V)LwBuuwUqwV44x+$#Rm|zHgp)BM&OX0vJZ|b@C5Y;nq7Pjsb@x5PH=);%z zo)O0Y+RHDFhKy-S)GVLT2G%m_K6f|ud=ZV$PdyRet9Pc2L(;`Yy@SZTqQRnM!x1X` z=O(_BV-|b%hKf?(uHwL_quESZia53BSHafl7+D}3v+T`usNq=~l14J=1XT@jX_^wu zeyazz+U?=S@J?X)y$|sp+78}~+QGKz?PLj;doY!b!|A@q^N5D-LEOKOLd*S^@IB&V zp8t)57EcVhNMjIh(>L7TW ze~6|j9tW?d*Xe@xIpq4vMs#zI2r4#;!u4^zz%ICpwf2j*R%7?4(a_XkpBqlk>PVr*r@O?Vw8hcgZ+xk% zww?9cJ@r9xwzAb-1!BE?OD}v6X%fDhuo>KKp-S$zUJZAqwgd3m1X?{6z{+tQT)o%| zZ=5?1n}+pat}eFhboL6itz;1Ux}ZqBp`Hnkj*Ww)3=^0gxduMNeb#rkCcF0SF)bNp zLA<`DLhp7hsE4qX>MqV-7dF6%7{p%dWo4nLTBNy?ng4us@}f!UeO1n^3| zL0=Y*fWV25N#CTY@Sn~|@y+?}(E3aOYk9UQ(}~SsBfPiL4$TWi{a9P*esH9+mpQ27EI-GfY|c(b@=YMmqqwoqc{J%%Zgc7_)yppW)JEtzH*O)(7wUo zu$r)A2mL_B#9#d9d>)yt-vknv0^9FCjJXUCBiauXS<)1IZ(I)zGIFG=RrASf{NgMP`7@l{N`63G@L6TwzwcT4 zs^wc5s_wU(Lsk&snKFyMbKT1Dfd&a!(3ZF<8Ig{*o5jNSn}}1hlcY=0P-x^I0k@~M zh3A_(!e0S***UkF?3rlE5O$Y{8Qu5WX}AQet(9{YrBPXk-T?y9?}{QXq_L>gAx-f=a%J=#_FzgfYf_ZKZeJP3e6`0><&03cEBZql-SOc6 zXg$nP8A+4FHN?0R<5}~^_?)2mDj4aX$O03t2>fxp3^$TcOqdhR`j>gGrynv--(jS=Q%+ zba!b4-KX9cmd}fXu%c$r+3YGv8~C&D`U=cx>jY-& zA=N4z8iXz*IU&xVuO9*1=m0iLg5P=*gT$SOdx5=8H#%p)hwSkWHVUP93~%tsL})U8 zG+h4Hh9nkR3oC6~(9b?D;?9sUg2zEyI$%;eHvj!TarYq?ns8^jaPA=$B&kj$a!#bU zY*tfJWW8Tp7#kvn&KN-hZH?LEX@SIfh#@{7y<7%6*O9-j{+pfTb&g*CA`?cO*C#Vl zTC#5kM+>npGl{9e%--~Rs<8R|2XWiK#WdPdA1*3;lZF;Ag$2p;smgg1*cGxc7Xp2@^cr$qovmY~u^>GVj zd0%yz|2=nnrpFd^UG+h4pbxx!ngR>ohceq`M(k0e{>*#gZE(JfAkov$juUuhk|V(<|b9UcQ~lQz=5JEP#iG{V|Qwu4s_P5kc2T4=lS z40-WSF1|}{4cn>V%fd=xSlCs3PoBy?(tS!A%ySt9>n2_%^V;qNtEhYQ`^g?+KxQb5 zebbu7866;ZzUnY()@ZiO{Q^6R{VbC|eZe<>grG9n9W-{BgML_F*4QT!q)Qx0;q73M zer+lqO5FyM;Be6B9RMnQQ^e_xo8i>xNH*!=Zf2A;h8cZQqfx1h1U4{-y=)1DUebe0 zW(Pod+(vfckL$84j)}C1${4!aVlk|69LrR{1+vGh!|^ABAAqlUko}syFcgQ-+{Z?aaT0_uhJvMKu z7OayT6df-lQvL7m#ldP>wC^21qRg&R&!_GJT=`0T|4?H7FDl0+*T~$3H+$^Z*fmaU zLWgn(#y9AyOA3zeAh4`%5PSK@2&snOr!c#w|ksbfbvlM)|xk4G&zAJU-4!y z*1FQ>p^?m2Gnwc#n@LR#JV0uK?-kUJW}4G>L&m@RSQy#HG6yHK{-hU}A8;1p{s@GY zeyhp;1NA}2a0Ixku3;;iH>M8R>)8}XW8$LhEZi?S%J%da2!2=WnBwC!daB3?dhR$) zyRKZtW-aLno%32Tv*1Z^H~p3H+&l#o@R{sxCuh@#vv8CK@3ofBjAfe#U#5$rJ%o}& zFNI~gR`BM^elj+`KE3z7n0k%GK2o=JY|qQ4Ea~G2_F5-T7WKvey5l`EDJLe;2Ms1b z%f&Oqc?Y!M&clY}bCN5$9yx%G&1=T4#w)Rrc#rs1BNcKtL?G*Ob=Z?HI&i_l3BT*- z4@q_uN-lOKja>wg?zdp4B9u_0dXk|FI~1UQnp@Ok8B5dHwKR%{-g>d@)RBH<;JsYdFv^~-z8wahbMR#Qjy~&)eTSWc9NE10p)f$N zHBqs%gE95OX;Cb`ajqr4gMcMNq2dsx+b;qd7UrQ2LLi-H3W*J2K zF1U}+aoz`Qzd6j>5podJHHCcK8p%dnh$i(@|AZr**D$pZ2Hy)BTM*a5CAeIs~@14yQKBet;VeEO&HJ^ESk3f*V(PCVzSL1T7I z61Vq=fjzhz+rMhg%%awlw9R2;+8@`+Yacsys_ozKDLJdX-B62O%0;Qs+F)&h8IMdF$ByQXjY|4P)~+9bs!nr^C1X z`NSqKo4Ev0P*Us3Hmw{4k;&M1ITQ?kzwx6QW|>64`9}8KP#F&OH)35oC%{5uf1<8J z$?AGq&{WYEj&407E}3af&+m9f{+`o;jVr}xef}{O^WG|u4S)EO2Z^KTh{1+Xq&Xi< zS3Ap=)jvxdMlE1g``Y8R_S1rS0`?Cxn}XWHJi$0mm-wIGC3|{Qk!ZdtZ;SAmcCD+6 zWm+Fq#EBz!3kgX&WWcoytF~eg*|#g5wwn_syJe-(QpbvwIhtS%B|Z z+dPjc7u_bUWjReVP%HW@t^qB2Tq0+4Wyp{d^VL8oIOA?IMsj??K#*>f#;dh)~dXkr4`>|F#H$tf00k&dp zw2&1MEz7(J{|{a+(D~huSlls|!Tpulk<-^m(8)-;oanKI>Cec)!MHIS9-syj_mICe zAJMkW8nR~fUz0O_>GYoH#HPYT=#i<%Lh<_t+0UL6?^TOvQL}dJ-m4rU^bDt4j3w+& ziV9dg3ZN7A7_lS4@x=T4Xu7jJUo|-oWaaC9aD6u`16fM&6_}oY-{%a!4EYW3Xa74+ zglF*yFu?vOjB!eZjErekuR22J*gePLTf>^!3A1^f^9amjnbj3LZ`vg#>c?M?E@MA@Gy91 zNyP@u&Oq$))3EkQ1Pl&dESnI+;CNh9I_}GH@L&5Egsd;f-l=gKW*j&RXA@3CK~t$1 zyCX)HW^))!G7dv`*-0=QoSL0iB^Dxmm$~YIh1eR5#Sxt;q%l{7Cp0ZCJ`&Y)YUVM;82D5?8qQc=lbt!c1zOL_lqH&e zl{GU97B|1$0Gll0p!G&4QN86vv7Mcf09J{x{!{1ded`9oCZ8CX`o7YayD>qa-Zo4Y zBrTSuJW7(adJ+%eGxveLdN36H7Y?Co(vPg@Hxq^i>Sj;t6Oz3zx2t$cZ9w+URV`to z#`mm?DJeuV#36lo;9^*~=(%j~zuIE1nW(MEdgveale9cPR>O3gWpivlf5anO}38VSK@Q$rR-|Z ztE|o9v26F@=HxbO2ABQU%f>u72kn;(r4J0QWOw=gOxDKF3wAGimp%1)Q`sEb3-GRB z8|?bqjvPCd2mSAx!WVUX_BU;m>{FOJd1HJrdycv&)A7!R2R^>CB_Aq%8EB^`OK9v1 zj%_J;-^hnU9~gA=I19I$HkCP)>dHp$xg&F#*e$zZkJ4-tGDN%~%K}B;mSSmPI2g^2 zgy&`{FtsQNRO1y{;;tiL>~I{y4<5_5=%FXe8>AjSQ7&>T_)NcN~C9aD+U8i@G@YkAXAtVxYNh@5^kf=!- zO6uXSAM15-K0;L@aYRf;;Nw1$xh`(=a3<-2P{VZx8%ZPs5%myMMI!M=m?B;@lt^|V zrX!??TMZv-B*zg! z2uFk=;*+98ataZKn2az*lwje`A~qvtAQ;+;Rymh*xUMy#58`wszgpZ; z<$TWJeD1d`;zK3BTHI&L`JBV~HVB@_`5NLbF6VL%kDK#`efsHNwYa4>fB3;UT-OQ# zh*g#RYH^P!=W|X)+ys}CEBV#p_9^Fc4(AI9o`)~;<^4Wb&gC2)H|N#=TyxyF%DJ4w zb$cN!`Mi=}E$)W5ew34QD&pq*(2PocwYV+H`JBV~y%9VQE9A?^J-wXEIXrI8J6fq* z&F`>sF6VGv-WHi7URCm|#eKY-&p8!wo8fYsFF$>$7WeCNKId?LPXy27Z4Ghj{`12V z&f#%$-jYh)YH<%N=W-6$?S|-%*j>r57PogfpK~hWHpS%|mHcXP?=9zZ4(FR7cpm$a zFYouYaxUlaxH+%e*Pnh=i(8Waql}!xb@^nBm|DrN7Po0RpK~hW?uyGHmHcXPJD2l0 zhx4@&Kc+@Mk4boV!K2fUwve~kJZ8SX^Lb}P5o$|uj*~)1{I5bwBHj`zX}D&4M036p@v2gzTHN<>ebt!x zGEc|T8zFd%JnnAaewMph-2H!qU8ceLJT5&1k8xUM8U<gr z5S+WaQlnbjQMg_{Zk~>(H$Z42c-&8r$Lk5FMiuqNIoR@*)q8zh;zi(EJRQ%oEkd9F z#ya8U$4Nno|5fkZlMgF!J^uL@A9z}> z!Tsj@r7~h(rADH|;5)AQ{gyOCuvBqL9X%Qz|U{kNhWQ^PH^Sy|t-L0Mk> z?-g1V%Up-+wMOv$oY#5Peyu9H#J!U8)wl^%v=LlKt*j=0^nH0*Dzq!+T$5|_sUf04 z*^+wMe#86B725J`ti`<__0QWguEi(5?F~j$v|W7N5QLT#`?XlH3lOG=*H}rZhy@5; zLs|l-v8@{7G6(mMAo$;K6DsB0u9$|`8NFXx%Kabh>Eh`+W3l@Pmqng#o^JE! zxlXrpad)@k+Za!W^s+oE(uK+8!RxGoE1!oHxqLsex9ev-%+=Fm?qYW*-jAtCw@^-Z zP#wD7a=IRir`4)!`=Gif15wwkJO=rAb5R$o`DeVN7cO>La%MI7>N1(nmau0wYya;tj5J+4hx z>q&Jlpbp(#a=P5(+H~FJba~rZ!If_-N>H~3k2$?YkI%|!3w6Zoh}^1PaF6+UR88Bl z^l5c3tm@EBmDA-O*QV<&r)ybgk)UCl|E?uJ+VRG8MGHQ!iirlJRaF4s!qFX4Z%LAxQ*IQ1Pdt95YZBcbEIIlL{ zTsd9tac#O$a=KizHjg_ZR|EfE(GM!`S8(YXJua5h=3^0E%enIHkRNif+xb%)J+4u6 z;PdJ}bgM)2mYgQ~Tcc*IoThOdnq84wHBR)lM$Ol9n!Mb#^!U!-PP$2ykO7+J8>p`-CR8SOrI{)L6J`- z$ge<|X(-dZMo;R!tR8P?jK{Htcx`drW-J?TpZ;G2V=~=!f#)nQHxF0b>Ezo#uIVS2 ze#VC^gQ=`5+_-D1E7Sj~=ieUl zudS|*WBxVNRgLYEE$RfxX&d3XYlxcaN*B3l__tc?%Ir6FW%mE6uIk9{_w{O4N4*BT zuI{TLmaVpWZH3%A>(%U+jqJDe+7RzgsoG|>!~FR%N8Wo|To;A;2u{tw>wsKfhPcppnu?{R3aR_|vaw@&YW z4d%DrJL3H!RrAN8#9F;C#Qf{@zWZ;y@BY8_zWe{d`|fpkKjwY)vh(57+P3>g=u@5E z{~FA1y}u@xKMvWJ-G3n8mPv8_bmZ>Gd2J1Q-Om3{egD1db$EXELv_#jxO`2X^RZb+ z=)Derkn?L(MT$7J!ao8emRME%F1`>Nj zJzQ7k{?Y4~&8Avy@ok>#^Sn505!Rn8%h3XV^K!UjK6RGk*S_M!Uo1bshn$NF&qcUwNrm0$nX;rkF zaXmyBYS&qYU$uW*hVxbO(JasB)9>=BtIo^IVT<$jcx}F7-)V}!xj$ntzdHT-wV&~e zKT_23t)k8I;QpkdcAftGs$Hi)JRg34K}8w(bz5GBR=BRtGW>eHuCWaL@%ns)c3FFb z+McLgXBmFg{%skOtK`GSt9Tg-epiNHk9#$ip%Gq#uPB2W>hgN9!2Ieg!>`)EEyJQJ z`KXqcAs+ML=jyfXYrnKi$mRR76j8CS@iysl6>Yq%S>_MF|Dw)1{ogVfs59nY_uHSkc~$iA(rww; zkp$N@$7T5gNO?ElXL;3eP^qH4^~=)|Ov{fU@@bvQJ8rx@QpDgYY4Or8>+e7Q1^o zxy@U!*wd+dt=GAEUMmpJh!F@|gegLb;AP=|uUGa@BFVx$_^|;e%~QC{_c@$8<>IpL sk7eBgT;^rRNfC$KOSz{wDPi}zl-CVT_0-DlnJ8PtCA?>sFXHU~0l~`8X#fBK literal 0 HcmV?d00001 diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/BitmapUtil.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/BitmapUtil.kt new file mode 100644 index 00000000..0637d46f --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/BitmapUtil.kt @@ -0,0 +1,162 @@ +package com.nkming.nc_photos.plugin + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log + +fun Bitmap.aspectRatio() = width / height.toFloat() + +enum class BitmapResizeMethod { + FIT, + FILL, +} + +interface BitmapUtil { + companion object { + fun loadImageFixed( + context: Context, uri: Uri, targetW: Int, targetH: Int + ): Bitmap { + val opt = loadImageBounds(context, uri) + val subsample = calcBitmapSubsample( + opt.outWidth, opt.outHeight, targetW, targetH, + BitmapResizeMethod.FILL + ) + if (subsample > 1) { + Log.d( + TAG, + "Subsample image to fixed: $subsample ${opt.outWidth}x${opt.outHeight} -> ${targetW}x$targetH" + ) + } + val outOpt = BitmapFactory.Options().apply { + inSampleSize = subsample + } + val bitmap = loadImage(context, uri, outOpt) + if (subsample > 1) { + Log.d( + TAG, "Bitmap subsampled: ${bitmap.width}x${bitmap.height}" + ) + } + return Bitmap.createScaledBitmap(bitmap, targetW, targetH, true) + } + + /** + * Load a bitmap + * + * If @c resizeMethod == FIT, make sure the size of the bitmap can fit + * inside the bound defined by @c targetW and @c targetH, i.e., + * bitmap.w <= @c targetW and bitmap.h <= @c targetH + * + * If @c resizeMethod == FILL, make sure the size of the bitmap can + * completely fill the bound defined by @c targetW and @c targetH, i.e., + * bitmap.w >= @c targetW and bitmap.h >= @c targetH + * + * If bitmap is smaller than the bound and @c shouldUpscale == true, it + * will be upscaled + * + * @param context + * @param uri + * @param targetW + * @param targetH + * @param resizeMethod + * @param isAllowSwapSide + * @param shouldUpscale + * @return + */ + fun loadImage( + context: Context, + uri: Uri, + targetW: Int, + targetH: Int, + resizeMethod: BitmapResizeMethod, + isAllowSwapSide: Boolean = false, + shouldUpscale: Boolean = true, + ): Bitmap { + val opt = loadImageBounds(context, uri) + val shouldSwapSide = isAllowSwapSide && + opt.outWidth != opt.outHeight && + (opt.outWidth >= opt.outHeight) != (targetW >= targetH) + val dstW = if (shouldSwapSide) targetH else targetW + val dstH = if (shouldSwapSide) targetW else targetH + val subsample = calcBitmapSubsample( + opt.outWidth, opt.outHeight, dstW, dstH, resizeMethod + ) + if (subsample > 1) { + Log.d( + TAG, + "Subsample image to ${resizeMethod.name}: $subsample ${opt.outWidth}x${opt.outHeight} -> ${dstW}x$dstH" + + (if (shouldSwapSide) " (swapped)" else "") + ) + } + val outOpt = BitmapFactory.Options().apply { + inSampleSize = subsample + } + val bitmap = loadImage(context, uri, outOpt) + if (subsample > 1) { + Log.d( + TAG, "Bitmap subsampled: ${bitmap.width}x${bitmap.height}" + ) + } + if (bitmap.width < dstW && bitmap.height < dstH && !shouldUpscale) { + return bitmap + } + return when (resizeMethod) { + BitmapResizeMethod.FIT -> Bitmap.createScaledBitmap( + bitmap, + minOf(dstW, (dstH * bitmap.aspectRatio()).toInt()), + minOf(dstH, (dstW / bitmap.aspectRatio()).toInt()), + true + ) + + BitmapResizeMethod.FILL -> Bitmap.createScaledBitmap( + bitmap, + maxOf(dstW, (dstH * bitmap.aspectRatio()).toInt()), + maxOf(dstH, (dstW / bitmap.aspectRatio()).toInt()), + true + ) + } + } + + private fun loadImageBounds( + context: Context, uri: Uri + ): BitmapFactory.Options { + context.contentResolver.openInputStream(uri)!!.use { + val opt = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeStream(it, null, opt) + return opt + } + } + + private fun loadImage( + context: Context, uri: Uri, opt: BitmapFactory.Options + ): Bitmap { + context.contentResolver.openInputStream(uri)!!.use { + return BitmapFactory.decodeStream(it, null, opt)!! + } + } + + private fun calcBitmapSubsample( + originalW: Int, + originalH: Int, + targetW: Int, + targetH: Int, + resizeMethod: BitmapResizeMethod + ): Int { + return when (resizeMethod) { + BitmapResizeMethod.FIT -> maxOf( + originalW / targetW, + originalH / targetH + ) + BitmapResizeMethod.FILL -> minOf( + originalW / targetW, + originalH / targetH + ) + } + } + + private const val TAG = "BitmapUtil" + } +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Event.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Event.kt new file mode 100644 index 00000000..651aca66 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Event.kt @@ -0,0 +1,15 @@ +package com.nkming.nc_photos.plugin + +import android.net.Uri + +interface MessageEvent + +data class ImageProcessorCompletedEvent( + val image: Uri, + val result: Uri, +) : MessageEvent + +data class ImageProcessorFailedEvent( + val image: Uri, + val exception: Throwable, +) : MessageEvent diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt new file mode 100644 index 00000000..a299d5d8 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt @@ -0,0 +1,61 @@ +package com.nkming.nc_photos.plugin + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class ImageProcessorChannelHandler(context: Context) : + MethodChannel.MethodCallHandler, EventChannel.StreamHandler { + companion object { + const val METHOD_CHANNEL = "${K.LIB_ID}/image_processor_method" + + private const val TAG = "ImageProcessorChannelHandler" + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "zeroDce" -> { + try { + zeroDce( + call.argument("image")!!, + call.argument("filename")!!, + result + ) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + + else -> result.notImplemented() + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + private fun zeroDce( + image: String, filename: String, result: MethodChannel.Result + ) { + val intent = Intent(context, ImageProcessorService::class.java).apply { + putExtra( + ImageProcessorService.EXTRA_METHOD, + ImageProcessorService.METHOD_ZERO_DCE + ) + putExtra(ImageProcessorService.EXTRA_IMAGE, image) + putExtra(ImageProcessorService.EXTRA_FILENAME, filename) + } + ContextCompat.startForegroundService(context, intent) + result.success(null) + } + + private val context = context + private var eventSink: EventChannel.EventSink? = null +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt new file mode 100644 index 00000000..dfca302d --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt @@ -0,0 +1,250 @@ +package com.nkming.nc_photos.plugin + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.os.AsyncTask +import android.os.Bundle +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.nkming.nc_photos.plugin.image_processor.ZeroDce + +class ImageProcessorService : Service() { + companion object { + const val EXTRA_METHOD = "method" + const val METHOD_ZERO_DCE = "zero-dce" + const val EXTRA_IMAGE = "image" + const val EXTRA_FILENAME = "filename" + + private const val NOTIFICATION_ID = + K.IMAGE_PROCESSOR_SERVICE_NOTIFICATION_ID + private const val RESULT_NOTIFICATION_ID = + K.IMAGE_PROCESSOR_SERVICE_RESULT_NOTIFICATION_ID + private const val RESULT_FAILED_NOTIFICATION_ID = + K.IMAGE_PROCESSOR_SERVICE_RESULT_FAILED_NOTIFICATION_ID + private const val CHANNEL_ID = "ImageProcessorService" + + const val TAG = "ImageProcessorService" + } + + override fun onBind(intent: Intent?): IBinder? = null + + @SuppressLint("WakelockTimeout") + override fun onCreate() { + Log.i(TAG, "[onCreate] Service created") + super.onCreate() + wakeLock.acquire() + createNotificationChannel() + } + + override fun onDestroy() { + Log.i(TAG, "[onDestroy] Service destroyed") + wakeLock.release() + super.onDestroy() + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + assert(intent.hasExtra(EXTRA_METHOD)) + assert(intent.hasExtra(EXTRA_IMAGE)) + if (!isForeground) { + try { + startForeground(NOTIFICATION_ID, buildNotification()) + isForeground = true + } catch (e: Throwable) { + // ??? + Log.e(TAG, "[onStartCommand] Failed while startForeground", e) + } + } + + val method = intent.getStringExtra(EXTRA_METHOD) + when (method) { + METHOD_ZERO_DCE -> onZeroDce(startId, intent.extras!!) + else -> { + Log.e(TAG, "Unknown method: $method") + // we can't call stopSelf here as it'll stop the service even if + // there are commands running in the bg + addCommand( + ImageProcessorCommand(startId, "null", Uri.EMPTY, "") + ) + } + } + return START_REDELIVER_INTENT + } + + private fun onZeroDce(startId: Int, extras: Bundle) { + val imageUri = Uri.parse(extras.getString(EXTRA_IMAGE)!!) + val filename = extras.getString(EXTRA_FILENAME)!! + addCommand( + ImageProcessorCommand(startId, METHOD_ZERO_DCE, imageUri, filename) + ) + } + + private fun createNotificationChannel() { + val channel = NotificationChannelCompat.Builder( + CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW + ).run { + setName("Image processing") + setDescription("Enhance images in the background") + build() + } + notificationManager.createNotificationChannel(channel) + } + + private fun buildNotification(content: String? = null): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID).run { + setSmallIcon(R.drawable.outline_auto_fix_high_white_24) + setContentTitle("Processing image") + if (content != null) setContentText(content) + build() + } + } + + private fun buildResultNotification(result: Uri): Notification { + val intent = Intent().apply { + `package` = packageName + component = ComponentName( + "com.nkming.nc_photos", "com.nkming.nc_photos.MainActivity" + ) + action = K.ACTION_SHOW_IMAGE_PROCESSOR_RESULT + putExtra(K.EXTRA_IMAGE_RESULT_URI, result) + } + val pi = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or getPendingIntentFlagImmutable() + ) + return NotificationCompat.Builder(this, CHANNEL_ID).run { + setSmallIcon(R.drawable.outline_image_white_24) + setContentTitle("Successfully enhanced image") + setContentText("Tap to view the result") + setContentIntent(pi) + setAutoCancel(true) + build() + } + } + + private fun buildResultFailedNotification( + exception: Throwable + ): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID).run { + setSmallIcon(R.drawable.outline_image_white_24) + setContentTitle("Failed enhancing image") + setContentText(exception.message) + build() + } + } + + private fun addCommand(cmd: ImageProcessorCommand) { + cmds.add(cmd) + if (cmdTask == null) { + runCommand() + } + } + + @SuppressLint("StaticFieldLeak") + private fun runCommand() { + val cmd = cmds.first() + notificationManager.notify( + NOTIFICATION_ID, buildNotification(cmd.filename) + ) + cmdTask = object : ImageProcessorCommandTask(applicationContext) { + override fun onPostExecute(result: MessageEvent) { + notifyResult(result) + cmds.removeFirst() + stopSelf(cmd.startId) + if (cmds.isNotEmpty()) { + runCommand() + } else { + cmdTask = null + } + } + }.apply { + @Suppress("Deprecation") + executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, cmd) + } + } + + private fun notifyResult(event: MessageEvent) { + if (event is ImageProcessorCompletedEvent) { + notificationManager.notify( + RESULT_NOTIFICATION_ID, buildResultNotification(event.result) + ) + } else if (event is ImageProcessorFailedEvent) { + notificationManager.notify( + RESULT_FAILED_NOTIFICATION_ID, + buildResultFailedNotification(event.exception) + ) + } + } + + private var isForeground = false + private val cmds = mutableListOf() + private var cmdTask: ImageProcessorCommandTask? = null + + private val notificationManager by lazy { + NotificationManagerCompat.from(this) + } + private val wakeLock: PowerManager.WakeLock by lazy { + (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "nc-photos:ImageProcessorService" + ).apply { + setReferenceCounted(false) + } + } +} + +private data class ImageProcessorCommand( + val startId: Int, + val method: String, + val uri: Uri, + val filename: String, + val args: Map = mapOf(), +) + +@Suppress("Deprecation") +private open class ImageProcessorCommandTask(context: Context) : + AsyncTask() { + companion object { + private const val TAG = "ImageProcessorCommandTask" + } + + override fun doInBackground( + vararg params: ImageProcessorCommand? + ): MessageEvent { + val cmd = params[0]!! + return try { + val output = when (cmd.method) { + ImageProcessorService.METHOD_ZERO_DCE -> ZeroDce(context).infer( + cmd.uri + ) + else -> throw IllegalArgumentException( + "Unknown method: ${cmd.method}" + ) + } + val uri = saveBitmap(output, cmd.filename) + ImageProcessorCompletedEvent(cmd.uri, uri) + } catch (e: Throwable) { + ImageProcessorFailedEvent(cmd.uri, e) + } + } + + private fun saveBitmap(bitmap: Bitmap, filename: String): Uri { + return MediaStoreUtil.writeFileToDownload( + context, { + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) + }, filename, "Photos (for Nextcloud)/Enhanced Photos" + ) + } + + @SuppressLint("StaticFieldLeak") + private val context = context +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/K.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/K.kt index 97656f61..f2a5d9d1 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/K.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/K.kt @@ -4,12 +4,18 @@ interface K { companion object { const val DOWNLOAD_NOTIFICATION_ID_MIN = 1000 const val DOWNLOAD_NOTIFICATION_ID_MAX = 2000 + const val IMAGE_PROCESSOR_SERVICE_NOTIFICATION_ID = 5000 + const val IMAGE_PROCESSOR_SERVICE_RESULT_NOTIFICATION_ID = 5001 + const val IMAGE_PROCESSOR_SERVICE_RESULT_FAILED_NOTIFICATION_ID = 5002 const val PERMISSION_REQUEST_CODE = 11011 const val LIB_ID = "com.nkming.nc_photos.plugin" const val ACTION_DOWNLOAD_CANCEL = "${LIB_ID}.ACTION_DOWNLOAD_CANCEL" + const val ACTION_SHOW_IMAGE_PROCESSOR_RESULT = + "${LIB_ID}.ACTION_SHOW_IMAGE_PROCESSOR_RESULT" const val EXTRA_NOTIFICATION_ID = "${LIB_ID}.EXTRA_NOTIFICATION_ID" + const val EXTRA_IMAGE_RESULT_URI = "${LIB_ID}.EXTRA_IMAGE_RESULT_URI" } } diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt index 63734a8f..6ced3ad2 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt @@ -8,6 +8,12 @@ import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel class NcPhotosPlugin : FlutterPlugin, ActivityAware { + companion object { + const val ACTION_SHOW_IMAGE_PROCESSOR_RESULT = + K.ACTION_SHOW_IMAGE_PROCESSOR_RESULT + const val EXTRA_IMAGE_RESULT_URI = K.EXTRA_IMAGE_RESULT_URI + } + override fun onAttachedToEngine( @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding ) { @@ -46,6 +52,16 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware { MediaStoreChannelHandler.METHOD_CHANNEL ) mediaStoreMethodChannel.setMethodCallHandler(mediaStoreChannelHandler) + + imageProcessorMethodChannel = MethodChannel( + flutterPluginBinding.binaryMessenger, + ImageProcessorChannelHandler.METHOD_CHANNEL + ) + imageProcessorMethodChannel.setMethodCallHandler( + ImageProcessorChannelHandler( + flutterPluginBinding.applicationContext + ) + ) } override fun onDetachedFromEngine( @@ -57,6 +73,7 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware { nativeEventChannel.setStreamHandler(null) nativeEventMethodChannel.setMethodCallHandler(null) mediaStoreMethodChannel.setMethodCallHandler(null) + imageProcessorMethodChannel.setMethodCallHandler(null) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { @@ -82,6 +99,7 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware { private lateinit var nativeEventChannel: EventChannel private lateinit var nativeEventMethodChannel: MethodChannel private lateinit var mediaStoreMethodChannel: MethodChannel + private lateinit var imageProcessorMethodChannel: MethodChannel private lateinit var lockChannelHandler: LockChannelHandler private lateinit var mediaStoreChannelHandler: MediaStoreChannelHandler diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt new file mode 100644 index 00000000..79100b89 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt @@ -0,0 +1,14 @@ +package com.nkming.nc_photos.plugin + +import android.app.PendingIntent +import android.os.Build + +fun getPendingIntentFlagImmutable(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_IMMUTABLE else 0 +} + +fun getPendingIntentFlagMutable(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_MUTABLE else 0 +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/TfLiteHelper.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/TfLiteHelper.kt new file mode 100644 index 00000000..15337768 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/TfLiteHelper.kt @@ -0,0 +1,92 @@ +package com.nkming.nc_photos.plugin.image_processor + +import android.content.Context +import android.graphics.Bitmap +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.FloatBuffer +import java.nio.IntBuffer +import java.nio.channels.FileChannel +import kotlin.math.abs + +interface TfLiteHelper { + companion object { + /** + * Load a TFLite model from the assets dir + * + * @param context + * @param name Name of the model file + * @return + */ + fun loadModelFromAsset(context: Context, name: String): ByteBuffer { + val fd = context.assets.openFd(name) + val istream = FileInputStream(fd.fileDescriptor) + val channel = istream.channel + return channel.map( + FileChannel.MapMode.READ_ONLY, fd.startOffset, fd.declaredLength + ) + } + + /** + * Convert an ARGB_8888 Android bitmap to a float RGB buffer + * + * @param bitmap + * @return + */ + fun bitmapToRgbFloatArray(bitmap: Bitmap): FloatBuffer { + val buffer = IntBuffer.allocate(bitmap.width * bitmap.height) + bitmap.copyPixelsToBuffer(buffer) + val input = FloatBuffer.allocate(bitmap.width * bitmap.height * 3) + buffer.array().forEach { + input.put((it and 0xFF) / 255.0f) + input.put((it shr 8 and 0xFF) / 255.0f) + input.put((it shr 16 and 0xFF) / 255.0f) + } + input.rewind() + return input + } + + /** + * Convert a float RGB buffer to an ARGB_8888 Android bitmap + * + * @param output + * @param width + * @param height + * @return + */ + fun rgbFloatArrayToBitmap( + output: FloatBuffer, width: Int, height: Int + ): Bitmap { + val buffer = IntBuffer.allocate(width * height) + var i = 0 + var pixel = 0 + output.array().forEach { + val value = (abs(it * 255f)).toInt().coerceIn(0, 255) + when (i++) { + 0 -> { + // A + pixel = 0xFF shl 24 + // R + pixel = pixel or value + } + 1 -> { + // G + pixel = pixel or (value shl 8) + } + 2 -> { + // B + pixel = pixel or (value shl 16) + + buffer.put(pixel) + i = 0 + } + } + } + buffer.rewind() + val outputBitmap = + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + outputBitmap.copyPixelsFromBuffer(buffer) + return outputBitmap + } + } +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt new file mode 100644 index 00000000..21f9af55 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt @@ -0,0 +1,77 @@ +package com.nkming.nc_photos.plugin.image_processor + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import com.nkming.nc_photos.plugin.BitmapResizeMethod +import com.nkming.nc_photos.plugin.BitmapUtil +import org.tensorflow.lite.Interpreter +import java.nio.FloatBuffer +import kotlin.math.pow + +class ZeroDce(context: Context) { + companion object { + private const val TAG = "ZeroDce" + private const val MODEL = "zero_dce_lite_200x300_iter8_60.tflite" + private const val WIDTH = 300 + private const val HEIGHT = 200 + private const val ITERATION = 8 + } + + fun infer(imageUri: Uri): Bitmap { + val alphaMaps = inferAlphaMaps(imageUri) + return enhance(imageUri, alphaMaps, ITERATION) + } + + private fun inferAlphaMaps(imageUri: Uri): Bitmap { + val interpreter = + Interpreter(TfLiteHelper.loadModelFromAsset(context, MODEL)) + interpreter.allocateTensors() + + Log.i(TAG, "Converting bitmap to input") + val inputBitmap = + BitmapUtil.loadImageFixed(context, imageUri, WIDTH, HEIGHT) + val inputs = arrayOf(TfLiteHelper.bitmapToRgbFloatArray(inputBitmap)) + val outputs = mapOf( + 0 to FloatBuffer.allocate(inputs[0].capacity()), + 1 to FloatBuffer.allocate(inputs[0].capacity()) + ) + Log.i(TAG, "Inferring") + interpreter.runForMultipleInputsOutputs(inputs, outputs) + + return TfLiteHelper.rgbFloatArrayToBitmap( + outputs[1]!!, inputBitmap.width, inputBitmap.height + ) + } + + private fun enhance( + imageUri: Uri, alphaMaps: Bitmap, iteration: Int + ): Bitmap { + Log.i(TAG, "Enhancing image, iteration: $iteration") + // downscale original to prevent OOM + val resized = BitmapUtil.loadImage( + context, imageUri, 1920, 1080, BitmapResizeMethod.FIT, + isAllowSwapSide = true, shouldUpscale = false + ) + // resize aMaps + val resizedFilter = Bitmap.createScaledBitmap( + alphaMaps, resized.width, resized.height, true + ) + + val imgBuf = TfLiteHelper.bitmapToRgbFloatArray(resized) + val filterBuf = TfLiteHelper.bitmapToRgbFloatArray(resizedFilter) + for (i in 0 until iteration) { + val src = imgBuf.array() + val filter = filterBuf.array() + for (j in src.indices) { + src[j] = src[j] + -filter[j] * (src[j].pow(2f) - src[j]) + } + } + return TfLiteHelper.rgbFloatArrayToBitmap( + imgBuf, resized.width, resized.height + ) + } + + private val context = context +} diff --git a/plugin/android/src/main/res/drawable-hdpi/outline_auto_fix_high_white_24.png b/plugin/android/src/main/res/drawable-hdpi/outline_auto_fix_high_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..28e5c12f68c622ccf8d9b36c057829ffd1d6a902 GIT binary patch literal 400 zcmV;B0dM|^P)29op}P8B)Ab0Sw)_}=mN7{x=H(Yur#f= zpSN}KPR;45QIWqMbaH33Xcd8;=;h3qFv(}S_&j9JXtHNdGn+Aa1it8JFjD}g zsT3AAE~oLz84aa3V-Der1LMSA)dtm8TCL4immiZA4#U#RAK#Fe>;Ui>mEMpEj6&%# z9v>B`Xi<^pp)La(3=zyAD@VUKFvDPk2f$jds7;vF53|G6>y5!Il9?_eB7(uQ%Fy_I z3e%a}Aaavb6axk|Fd}T(*1_}vQtpQca~DLe6N~CG=Lo=&xpKD`W;!g1j%DnR6Ec1_ z6W&(~02XwUv+=nPB3Iel4-*$xdB?gOBO-#0tp+~{epMxp}{SW`Y`#%#|2K^UAVr%~Y43w=S!yus67yk{RY@Ywuz_Ne-JCbM+ zRO_Sv;$YrNc(L&eB0{`DtDqW>{b&DQfKmeOfrJ!M2K_HY(zyHoFSNp}o=AiK+hS-% z1%LiK5ob_3!O}5(Xk`(q75>z!j;K~aQnAV&1vmfzC?zg%DzM%Z00000NkvXXu0mjf D<7;XZ literal 0 HcmV?d00001 diff --git a/plugin/android/src/main/res/drawable-mdpi/outline_auto_fix_high_white_24.png b/plugin/android/src/main/res/drawable-mdpi/outline_auto_fix_high_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..958060fa4325be5dce9c092631bb85d5911ded33 GIT binary patch literal 254 zcmV4!ss!U7kcVH8EHWymf3MJNPnh!YGiGsVwB5MqbbNoId+gr6pOL`5|^=qj%1LVMuO z4qVo7#}3?qoWl}6dnJxMfqV&fUEr(u;;mAWdgFhL57_WHx_|5~y#N3J07*qoM6N<$ Ef_VXFVgLXD literal 0 HcmV?d00001 diff --git a/plugin/android/src/main/res/drawable-mdpi/outline_image_white_24.png b/plugin/android/src/main/res/drawable-mdpi/outline_image_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..e465a85463e362acf0d25f92a0a499104cc4e7f1 GIT binary patch literal 177 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+i3Qrfu5R22v2@-1_>_7hh`2UZM z$N$>~nA-gE=df|Q%D_?)+n{H^ic#^D)1Hq)$ literal 0 HcmV?d00001 diff --git a/plugin/android/src/main/res/drawable-xhdpi/outline_auto_fix_high_white_24.png b/plugin/android/src/main/res/drawable-xhdpi/outline_auto_fix_high_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..39c1301589305fac6c4564fe5438328c6b0fc6a7 GIT binary patch literal 410 zcmV;L0cHM)P) zOKQU~5QYWnHHu9GIYjP}g>RKbAh|#v>BCnDwAU#34)LOGjQP5VNiEy4C3$2@sdgGh z-!JARDfQw_cQ{*Lo`W}Cn?kJ)*)g~&KfAz6XAI>le+yKRevs?oIZ=JNzm+fe9fG!Eyt_Qn_AJ1SV6Veee~;rq2Uw>62l zwv)iOBtFFPEeUyihlDh~ArS&!Nvz1(kN7G4uV1yk0a3L_0cTI6BT+!uhg$0OBz zf_X4%qm)t%czq@CDu;j}UTD;eCYP9ypJ)nbbB_%2IK{SzU=x`Hl-d7XRQye36D%@y tp|u=#MG;spzt-!!?F)$YE0iE;@don@_h1X_l~e!#002ovPDHLkV1l(1b^ZVV literal 0 HcmV?d00001 diff --git a/plugin/android/src/main/res/drawable-xxhdpi/outline_auto_fix_high_white_24.png b/plugin/android/src/main/res/drawable-xxhdpi/outline_auto_fix_high_white_24.png new file mode 100644 index 0000000000000000000000000000000000000000..828ce9570056556bd1e3273a4cc4a6dcd9a9c08c GIT binary patch literal 654 zcmV;90&)F`P)eLN2o7XPQaM|F(xQ9I}s0y z91lc=W{Da-Zb-%rJ!%?D`HX2I$CNXzy4O{GhX_qZy%-~kw#3OHSv;Ugpq)gQkheOXdhc4}e}pI6`m@z_l&TS3^#)cr&O1XKU%oV$7Cp z&L^$4(`2x8_wjoFF;M<2PvLw^aHh(Y)#8#kwU@_SDqO`|=Jd&nz9}bA=NQjWf)n%I zjAOKh%HkBPG6hbjI7hn=mCw=Y)a<+9XzXSDbivWs?Ri@Hxu_hD#(9Xz$`AhLaB1ww z9!L4aNc-&uIYHuRZKTYz@(Sr=8p#_cgLLYVlS;xXCx_&NBO&?XWRQGvQb@izYosc0 ogb;!~e(=Z@ZLx_>Y~r273o)(x$7D6yi?CW)SOx@*jj63Xfe0Q!Q`seF5PPqXKbKIvLDHCQ)Y{TU1*2dZd2K?N zB(pjpGs{4JZ#m0m9zJH9Op++2lyZp*E&7ZI(TF}RD$8)H{5<1{%15!W?B=BR7!I5?_LIOaHz=0tqCLKmrLQkU#>h9f(>~AWr$Ysvsq7) zXOJ<4>zVa!o>{CD7&JG9#&kX97JKIbK?_qDb2NE!Nl{6o}^@ z+}wX4>+8?X%D0m9*pG7~R1!i60aKVt?jo>)mE$mL##@@2q2RiMg{VkD-6FfkDgM*_P5Fzd;EdxkHEijuM&^ z`Sm44etij%UtdDx*Ow6aTU8}Q{)tcwi%N+62G@or0{f4#j$1L`LjJ|adKvi)^<%o> zm_H)FKf8F=G424rbh8)vXjuXV*f1Jvk>9X{p7M_{6Zwrw=qVo-BEMY;(>>)6BftAq za~iuE6ZzeIz@wAvo?$og3*;YjT0-O($Zu6b4>+{FJboe=ih*+jxad>|qtpVbUp_aW+?Y_=Gh#Hi{>FFyUBy^^Ek2_Ja>T%0_gaOfcAR!7XW{SIa{srM;iFn)pWV zoj$Glo#om0yYEfD{a^j%$=plD!kkN|1bJzSN;EGy@gUKvpGTAJzgf*B-I_;d(z{c> zKAxZCygXFs#+Ia-hO@8s{oZHFu)k}s!>L&x8H1)WUkYQqG?m$AHACFfhDkm4_5XhT zWa3b0V8A3CbQvWIr4CrwT|US3;*sEo7sWyj%Hlg`$6kJ;l(73j+3snK8OtBHi*8mm zpRtD%J0{N=uH4E>{9gt$MZoD47^KrF#qV++A22Z=$HKJ0#$#BkwU?FkR=PL|+5+rrs+m{&ZvC|SZk zP0MP{gk?u{5~lutcR-<`^Uvbf_Os1d7R**(v?4>@g>6Bn_o7t^>Mrazluz;X{uk@g zVq_6;U|>vQv#9@Y_2=T_=j8vD{r$1`v%bTX1KJwr_Ond6&;4#+8#w4gAJ;N!{a^5s U&3v0aFt!;yUHx3vIVCg!06`$^I{*Lx literal 0 HcmV?d00001 diff --git a/plugin/lib/nc_photos_plugin.dart b/plugin/lib/nc_photos_plugin.dart index 8ecc08c5..f3ad3482 100644 --- a/plugin/lib/nc_photos_plugin.dart +++ b/plugin/lib/nc_photos_plugin.dart @@ -1,6 +1,7 @@ library nc_photos_plugin; export 'src/exception.dart'; +export 'src/image_processor.dart'; export 'src/lock.dart'; export 'src/media_store.dart'; export 'src/native_event.dart'; diff --git a/plugin/lib/src/image_processor.dart b/plugin/lib/src/image_processor.dart new file mode 100644 index 00000000..f47568b0 --- /dev/null +++ b/plugin/lib/src/image_processor.dart @@ -0,0 +1,15 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:nc_photos_plugin/src/k.dart' as k; + +class ImageProcessor { + static Future zeroDce(String image, String filename) => + _methodChannel.invokeMethod("zeroDce", { + "image": image, + "filename": filename, + }); + + static const _methodChannel = + MethodChannel("${k.libId}/image_processor_method"); +} From 731370e994076b614403707e33b40a952259508e Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 4 May 2022 01:30:46 +0800 Subject: [PATCH 03/23] Support loading metadata from local file --- app/lib/file_extension.dart | 15 +++++++++++++++ app/lib/use_case/load_metadata.dart | 27 ++++++++++++++++++++++----- app/pubspec.lock | 2 +- app/pubspec.yaml | 1 + 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 app/lib/file_extension.dart diff --git a/app/lib/file_extension.dart b/app/lib/file_extension.dart new file mode 100644 index 00000000..ae0a2890 --- /dev/null +++ b/app/lib/file_extension.dart @@ -0,0 +1,15 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path_lib; +import 'package:mime/mime.dart'; + +extension FileExtension on File { + Future readMime() async { + final header = await openRead(0, defaultMagicNumbersMaxLength) + .expand((element) => element) + .toList(); + return lookupMimeType(path, headerBytes: header); + } + + String get filename => path_lib.basename(path); +} diff --git a/app/lib/use_case/load_metadata.dart b/app/lib/use_case/load_metadata.dart index c08f7a9d..f9a2cefb 100644 --- a/app/lib/use_case/load_metadata.dart +++ b/app/lib/use_case/load_metadata.dart @@ -1,19 +1,23 @@ +import 'dart:io' as io; import 'dart:typed_data'; import 'package:exifdart/exifdart.dart' as exifdart; +import 'package:exifdart/exifdart_io.dart'; import 'package:exifdart/exifdart_memory.dart'; import 'package:image_size_getter/image_size_getter.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/entity/exif.dart'; -import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file.dart' as app; import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/file_extension.dart'; import 'package:nc_photos/image_size_getter_util.dart'; class LoadMetadata { /// Load metadata of [binary], which is the content of [file] - Future loadRemote(Account account, File file, Uint8List binary) { + Future loadRemote( + Account account, app.File file, Uint8List binary) { return _loadMetadata( mime: file.contentType ?? "", exifdartReaderBuilder: () => MemoryBlobReader(binary), @@ -22,7 +26,20 @@ class LoadMetadata { ); } - Future _loadMetadata({ + Future loadLocal( + io.File file, { + String? mime, + }) async { + mime = mime ?? await file.readMime(); + return _loadMetadata( + mime: mime ?? "", + exifdartReaderBuilder: () => FileReader(file), + imageSizeGetterInputBuilder: () => AsyncFileInput(file), + filename: file.path, + ); + } + + Future _loadMetadata({ required String mime, required exifdart.AbstractBlobReader Function() exifdartReaderBuilder, required AsyncImageInput Function() imageSizeGetterInputBuilder, @@ -86,7 +103,7 @@ class LoadMetadata { return _buildMetadata(map); } - Metadata _buildMetadata(Map map) { + app.Metadata _buildMetadata(Map map) { int? imageWidth, imageHeight; Exif? exif; if (map.containsKey("resolution")) { @@ -96,7 +113,7 @@ class LoadMetadata { if (map.containsKey("exif")) { exif = Exif(map["exif"]); } - return Metadata( + return app.Metadata( imageWidth: imageWidth, imageHeight: imageHeight, exif: exif, diff --git a/app/pubspec.lock b/app/pubspec.lock index e8f98acf..ccf3fe81 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -620,7 +620,7 @@ packages: source: hosted version: "2.0.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime url: "https://pub.dartlang.org" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 32f2a27f..9da6e24c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -71,6 +71,7 @@ dependencies: intl: ^0.17.0 kiwi: ^4.0.1 logging: ^1.0.1 + mime: ^1.0.1 mutex: ^3.0.0 native_device_orientation: ^1.0.0 nc_photos_plugin: From a65c8d824e37121d1668c708ace36eb941339ec3 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 4 May 2022 16:42:46 +0800 Subject: [PATCH 04/23] Add enhance button in viewer --- app/lib/help_utils.dart | 2 + app/lib/l10n/app_en.arb | 8 ++ app/lib/l10n/untranslated-messages.txt | 33 +++++- app/lib/platform/features.dart | 1 + app/lib/widget/handler/enhance_handler.dart | 115 ++++++++++++++++++++ app/lib/widget/viewer.dart | 29 +++++ 6 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 app/lib/widget/handler/enhance_handler.dart diff --git a/app/lib/help_utils.dart b/app/lib/help_utils.dart index e8db3b5c..7325b91a 100644 --- a/app/lib/help_utils.dart +++ b/app/lib/help_utils.dart @@ -6,3 +6,5 @@ const twoFactorAuthUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/help/two-factor-authentication"; const homeFolderNotFoundUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/help/home-folder-not-found"; +const enhanceZeroDceUrl = + "https://gitlab.com/nkming2/nc-photos/-/wikis/help/enhance/zero-dce"; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 522f6b45..dea73037 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1171,6 +1171,14 @@ "@metadataTaskPauseLowBatteryNotification": { "description": "Shown when the app has paused reading image metadata due to low battery" }, + "enhanceTooltip": "Enhance", + "@enhanceTooltip": { + "description": "Enhance a photo" + }, + "enhanceLowLightTitle": "Low-light enhancement", + "@enhanceLowLightTitle": { + "description": "Enhance a photo taken in low-light environment" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 3d9180f8..4f52f171 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -84,6 +84,8 @@ "tagPickerNoTagSelectedNotification", "backgroundServiceStopping", "metadataTaskPauseLowBatteryNotification", + "enhanceTooltip", + "enhanceLowLightTitle", "errorAlbumDowngrade" ], @@ -186,6 +188,8 @@ "tagPickerNoTagSelectedNotification", "backgroundServiceStopping", "metadataTaskPauseLowBatteryNotification", + "enhanceTooltip", + "enhanceLowLightTitle", "errorAlbumDowngrade" ], @@ -343,6 +347,8 @@ "tagPickerNoTagSelectedNotification", "backgroundServiceStopping", "metadataTaskPauseLowBatteryNotification", + "enhanceTooltip", + "enhanceLowLightTitle", "errorAlbumDowngrade" ], @@ -350,14 +356,23 @@ "rootPickerSkipConfirmationDialogContent2", "helpButtonLabel", "backgroundServiceStopping", - "metadataTaskPauseLowBatteryNotification" + "metadataTaskPauseLowBatteryNotification", + "enhanceTooltip", + "enhanceLowLightTitle" + ], + + "fi": [ + "enhanceTooltip", + "enhanceLowLightTitle" ], "fr": [ "collectionsTooltip", "helpTooltip", "helpButtonLabel", - "removeFromAlbumTooltip" + "removeFromAlbumTooltip", + "enhanceTooltip", + "enhanceLowLightTitle" ], "pl": [ @@ -381,6 +396,18 @@ "addTagInputHint", "tagPickerNoTagSelectedNotification", "backgroundServiceStopping", - "metadataTaskPauseLowBatteryNotification" + "metadataTaskPauseLowBatteryNotification", + "enhanceTooltip", + "enhanceLowLightTitle" + ], + + "pt": [ + "enhanceTooltip", + "enhanceLowLightTitle" + ], + + "ru": [ + "enhanceTooltip", + "enhanceLowLightTitle" ] } diff --git a/app/lib/platform/features.dart b/app/lib/platform/features.dart index ee9d10da..598c7c78 100644 --- a/app/lib/platform/features.dart +++ b/app/lib/platform/features.dart @@ -2,3 +2,4 @@ import 'package:nc_photos/platform/k.dart' as platform_k; final isSupportMapView = platform_k.isWeb || platform_k.isAndroid; final isSupportSelfSignedCert = platform_k.isAndroid; +final isSupportEnhancement = platform_k.isAndroid; diff --git a/app/lib/widget/handler/enhance_handler.dart b/app/lib/widget/handler/enhance_handler.dart new file mode 100644 index 00000000..6d4dc155 --- /dev/null +++ b/app/lib/widget/handler/enhance_handler.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/help_utils.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class EnhanceHandler { + const EnhanceHandler({ + required this.account, + required this.file, + }); + + static bool isSupportedFormat(File file) => + file_util.isSupportedImageFormat(file) && file.contentType != "image/gif"; + + Future call(BuildContext context) async { + final selected = await showDialog<_Algorithm>( + context: context, + builder: (context) => SimpleDialog( + children: _getOptions() + .map((o) => SimpleDialogOption( + child: ListTile( + title: Text(o.title), + subtitle: o.subtitle?.run((t) => Text(t)), + trailing: o.link != null + ? SizedBox( + height: double.maxFinite, + child: TextButton( + child: Text(L10n.global().detailsTooltip), + onPressed: () { + launch(o.link!); + }, + ), + ) + : null, + onTap: () { + Navigator.of(context).pop(o.algorithm); + }, + ), + )) + .toList(), + ), + ); + if (selected == null) { + // user canceled + return; + } + _log.info("[call] Selected: ${selected.name}"); + final imageUri = await _getFileUri(); + switch (selected) { + case _Algorithm.zeroDce: + await ImageProcessor.zeroDce(imageUri.toString(), file.filename); + break; + } + } + + List<_Option> _getOptions() => [ + if (platform_k.isAndroid) + _Option( + title: L10n.global().enhanceLowLightTitle, + subtitle: "Zero-DCE", + link: enhanceZeroDceUrl, + algorithm: _Algorithm.zeroDce, + ), + ]; + + Future _getFileUri() async { + final f = await LargeImageCacheManager.inst.getSingleFile( + api_util.getFilePreviewUrl( + account, + file, + width: k.photoLargeSize, + height: k.photoLargeSize, + a: true, + ), + headers: { + "Authorization": Api.getAuthorizationHeaderValue(account), + }, + ); + return f.absolute.uri; + } + + final Account account; + final File file; + + static final _log = Logger("widget.handler.enhance_handler.EnhanceHandler"); +} + +enum _Algorithm { + zeroDce, +} + +class _Option { + const _Option({ + required this.title, + this.subtitle, + this.link, + required this.algorithm, + }); + + final String title; + final String? subtitle; + final String? link; + final _Algorithm algorithm; +} diff --git a/app/lib/widget/viewer.dart b/app/lib/widget/viewer.dart index d4902707..072bd954 100644 --- a/app/lib/widget/viewer.dart +++ b/app/lib/widget/viewer.dart @@ -16,12 +16,14 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/notified_action.dart'; +import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/use_case/update_property.dart'; import 'package:nc_photos/widget/animated_visibility.dart'; import 'package:nc_photos/widget/disposable.dart'; +import 'package:nc_photos/widget/handler/enhance_handler.dart'; import 'package:nc_photos/widget/handler/remove_selection_handler.dart'; import 'package:nc_photos/widget/horizontal_page_viewer.dart'; import 'package:nc_photos/widget/image_viewer.dart'; @@ -187,6 +189,9 @@ class _ViewerState extends State } Widget _buildBottomAppBar(BuildContext context) { + final index = + _isViewerLoaded ? _viewerController.currentPage : widget.startIndex; + final file = widget.streamFiles[index]; return Align( alignment: Alignment.bottomCenter, child: Material( @@ -206,6 +211,16 @@ class _ViewerState extends State tooltip: L10n.global().shareTooltip, onPressed: () => _onSharePressed(context), ), + if (features.isSupportEnhancement && + EnhanceHandler.isSupportedFormat(file)) + IconButton( + icon: Icon( + Icons.auto_fix_high_outlined, + color: Colors.white.withOpacity(.87), + ), + tooltip: L10n.global().enhanceTooltip, + onPressed: () => _onEnhancePressed(context), + ), IconButton( icon: Icon( Icons.download_outlined, @@ -555,6 +570,20 @@ class _ViewerState extends State ).shareFiles(widget.account, [file]); } + void _onEnhancePressed(BuildContext context) { + final file = widget.streamFiles[_viewerController.currentPage]; + if (!file_util.isSupportedImageFormat(file)) { + _log.shout("[_onEnhancePressed] Video file not supported"); + return; + } + + _log.info("[_onEnhancePressed] Enhance file: ${file.path}"); + EnhanceHandler( + account: widget.account, + file: file, + )(context); + } + void _onDownloadPressed() { final file = widget.streamFiles[_viewerController.currentPage]; _log.info("[_onDownloadPressed] Downloading file: ${file.path}"); From c6e6b99128a399b0d165b72b1ea52fee30767faa Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 4 May 2022 18:29:58 +0800 Subject: [PATCH 05/23] Add image provider backed by content uri --- .../android/content_uri_image_provider.dart | 64 +++++++++++++++++++ .../plugin/ContentUriChannelHandler.kt | 45 +++++++++++++ .../nkming/nc_photos/plugin/NcPhotosPlugin.kt | 10 +++ plugin/lib/nc_photos_plugin.dart | 1 + plugin/lib/src/content_uri.dart | 26 ++++++++ plugin/lib/src/exception.dart | 15 +++++ 6 files changed, 161 insertions(+) create mode 100644 app/lib/mobile/android/content_uri_image_provider.dart create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ContentUriChannelHandler.kt create mode 100644 plugin/lib/src/content_uri.dart diff --git a/app/lib/mobile/android/content_uri_image_provider.dart b/app/lib/mobile/android/content_uri_image_provider.dart new file mode 100644 index 00000000..4d4fb904 --- /dev/null +++ b/app/lib/mobile/android/content_uri_image_provider.dart @@ -0,0 +1,64 @@ +import 'dart:ui' as ui show Codec; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +class ContentUriImage extends ImageProvider + with EquatableMixin { + /// Creates an object that decodes a content Uri as an image. + const ContentUriImage( + this.uri, { + this.scale = 1.0, + }); + + @override + obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + load(ContentUriImage key, DecoderCallback decode) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: key.scale, + debugLabel: key.uri, + informationCollector: () => [ + ErrorDescription("Content uri: $uri"), + ], + ); + } + + Future _loadAsync( + ContentUriImage key, DecoderCallback decode) async { + assert(key == this); + + final bytes = await ContentUri.readUri(uri); + + if (bytes.lengthInBytes == 0) { + // The file may become available later. + PaintingBinding.instance!.imageCache!.evict(key); + throw StateError("$uri is empty and cannot be loaded as an image."); + } + + return decode(bytes); + } + + @override + get props => [ + uri, + scale, + ]; + + @override + toString() => "${objectRuntimeType(this, "ContentUriImage")} {" + "uri: $uri, " + "scale: $scale, " + "}"; + + final String uri; + + /// The scale to place in the [ImageInfo] object of the image. + final double scale; +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ContentUriChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ContentUriChannelHandler.kt new file mode 100644 index 00000000..4fd0e123 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ContentUriChannelHandler.kt @@ -0,0 +1,45 @@ +package com.nkming.nc_photos.plugin + +import android.content.Context +import android.net.Uri +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.FileNotFoundException + +class ContentUriChannelHandler(context: Context) : + MethodChannel.MethodCallHandler { + companion object { + const val METHOD_CHANNEL = "${K.LIB_ID}/content_uri_method" + + private const val TAG = "ContentUriChannelHandler" + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "readUri" -> { + try { + readUri(call.argument("uri")!!, result) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + + else -> result.notImplemented() + } + } + + private fun readUri(uri: String, result: MethodChannel.Result) { + val uriTyped = Uri.parse(uri) + try { + val bytes = + context.contentResolver.openInputStream(uriTyped)!!.use { + it.readBytes() + } + result.success(bytes) + } catch (e: FileNotFoundException) { + result.error("fileNotFoundException", e.toString(), null) + } + } + + private val context = context +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt index 6ced3ad2..cbbcebac 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt @@ -62,6 +62,14 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware { flutterPluginBinding.applicationContext ) ) + + contentUriMethodChannel = MethodChannel( + flutterPluginBinding.binaryMessenger, + ContentUriChannelHandler.METHOD_CHANNEL + ) + contentUriMethodChannel.setMethodCallHandler( + ContentUriChannelHandler(flutterPluginBinding.applicationContext) + ) } override fun onDetachedFromEngine( @@ -74,6 +82,7 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware { nativeEventMethodChannel.setMethodCallHandler(null) mediaStoreMethodChannel.setMethodCallHandler(null) imageProcessorMethodChannel.setMethodCallHandler(null) + contentUriMethodChannel.setMethodCallHandler(null) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { @@ -100,6 +109,7 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware { private lateinit var nativeEventMethodChannel: MethodChannel private lateinit var mediaStoreMethodChannel: MethodChannel private lateinit var imageProcessorMethodChannel: MethodChannel + private lateinit var contentUriMethodChannel: MethodChannel private lateinit var lockChannelHandler: LockChannelHandler private lateinit var mediaStoreChannelHandler: MediaStoreChannelHandler diff --git a/plugin/lib/nc_photos_plugin.dart b/plugin/lib/nc_photos_plugin.dart index f3ad3482..0b893113 100644 --- a/plugin/lib/nc_photos_plugin.dart +++ b/plugin/lib/nc_photos_plugin.dart @@ -1,5 +1,6 @@ library nc_photos_plugin; +export 'src/content_uri.dart'; export 'src/exception.dart'; export 'src/image_processor.dart'; export 'src/lock.dart'; diff --git a/plugin/lib/src/content_uri.dart b/plugin/lib/src/content_uri.dart new file mode 100644 index 00000000..74c953ea --- /dev/null +++ b/plugin/lib/src/content_uri.dart @@ -0,0 +1,26 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:nc_photos_plugin/src/exception.dart'; +import 'package:nc_photos_plugin/src/k.dart' as k; + +class ContentUri { + static Future readUri(String uri) async { + try { + return await _methodChannel.invokeMethod("readUri", { + "uri": uri, + }); + } on PlatformException catch (e) { + if (e.code == _exceptionFileNotFound) { + throw const FileNotFoundException(); + } else { + rethrow; + } + } + } + + static const _methodChannel = MethodChannel("${k.libId}/content_uri_method"); + + static const _exceptionFileNotFound = "fileNotFoundException"; +} diff --git a/plugin/lib/src/exception.dart b/plugin/lib/src/exception.dart index 3db5a5a6..b3aa54a9 100644 --- a/plugin/lib/src/exception.dart +++ b/plugin/lib/src/exception.dart @@ -1,3 +1,18 @@ +class FileNotFoundException implements Exception { + const FileNotFoundException([this.message]); + + @override + toString() { + if (message == null) { + return "FileNotFoundException"; + } else { + return "FileNotFoundException: $message"; + } + } + + final dynamic message; +} + /// Platform permission is not granted by user class PermissionException implements Exception { const PermissionException([this.message]); From b71620ee285bc7237b2ed8a3e8252328b0e7e27c Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 4 May 2022 22:40:44 +0800 Subject: [PATCH 06/23] Query files from media store --- app/android/app/src/main/AndroidManifest.xml | 1 + .../plugin/MediaStoreChannelHandler.kt | 94 +++++++++++++++++++ plugin/lib/src/media_store.dart | 34 +++++++ 3 files changed, 129 insertions(+) diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index c53da564..3f05186b 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ package="com.nkming.nc_photos"> + diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreChannelHandler.kt index ebb68233..a3428c11 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreChannelHandler.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreChannelHandler.kt @@ -1,8 +1,11 @@ package com.nkming.nc_photos.plugin import android.app.Activity +import android.content.ContentUris import android.content.Context import android.net.Uri +import android.os.Build +import android.provider.MediaStore import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall @@ -16,6 +19,9 @@ import java.io.File * Write binary content to a file in the Download directory. Return the Uri to * the file * fun saveFileToDownload(content: ByteArray, filename: String, subDir: String?): String + * + * Return files under @c relativePath and its sub dirs + * fun queryFiles(relativePath: String): List */ class MediaStoreChannelHandler(context: Context) : MethodChannel.MethodCallHandler, ActivityAware { @@ -67,6 +73,14 @@ class MediaStoreChannelHandler(context: Context) : } } + "queryFiles" -> { + try { + queryFiles(call.argument("relativePath")!!, result) + } catch (e: Throwable) { + result.error("systemException", e.message, null) + } + } + else -> result.notImplemented() } } @@ -102,6 +116,86 @@ class MediaStoreChannelHandler(context: Context) : } } + private fun queryFiles(relativePath: String, result: MethodChannel.Result) { + if (!PermissionUtil.hasReadExternalStorage(context)) { + activity?.let { PermissionUtil.requestReadExternalStorage(it) } + result.error("permissionError", "Permission not granted", null) + return + } + + val pathColumnName: String + val pathArg: String + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + pathColumnName = MediaStore.Images.Media.RELATIVE_PATH + pathArg = "${relativePath}/%" + } else { + @Suppress("Deprecation") + pathColumnName = MediaStore.Images.Media.DATA + pathArg = "%/${relativePath}/%" + } + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DATE_MODIFIED, + MediaStore.Images.Media.MIME_TYPE, + MediaStore.Images.Media.DATE_TAKEN, + MediaStore.Images.Media.DISPLAY_NAME, + pathColumnName + ) + val selection = StringBuilder().apply { + append("${MediaStore.Images.Media.MIME_TYPE} LIKE ?") + append("AND $pathColumnName LIKE ?") + }.toString() + val selectionArgs = arrayOf("image/%", pathArg) + val files = context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, selection, selectionArgs, null + )!!.use { + val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + val dateModifiedColumn = + it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED) + val mimeTypeColumn = + it.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE) + val dateTakenColumn = + it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN) + val displayNameColumn = + it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) + val pathColumn = it.getColumnIndexOrThrow(pathColumnName) + val products = mutableListOf>() + while (it.moveToNext()) { + val id = it.getLong(idColumn) + val dateModified = it.getLong(dateModifiedColumn) + val mimeType = it.getString(mimeTypeColumn) + val dateTaken = it.getLong(dateTakenColumn) + val displayName = it.getString(displayNameColumn) + val path = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // RELATIVE_PATH + "${it.getString(pathColumn).trimEnd('/')}/$displayName" + } else { + // DATA + it.getString(pathColumn) + } + val contentUri = ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id + ) + products.add(buildMap { + put("uri", contentUri.toString()) + put("displayName", displayName) + put("path", path) + put("dateModified", dateModified * 1000) + put("mimeType", mimeType) + if (dateTaken != 0L) put("dateTaken", dateTaken) + }) + Log.d( + TAG, + "[queryEnhancedPhotos] Found $displayName, path=$path, uri=$contentUri" + ) + } + products + } + Log.i(TAG, "[queryEnhancedPhotos] Found ${files.size} files") + result.success(files) + } + private fun inputToUri(fromFile: String): Uri { val testUri = Uri.parse(fromFile) return if (testUri.scheme == null) { diff --git a/plugin/lib/src/media_store.dart b/plugin/lib/src/media_store.dart index a091594d..72f9b89e 100644 --- a/plugin/lib/src/media_store.dart +++ b/plugin/lib/src/media_store.dart @@ -4,6 +4,18 @@ import 'package:flutter/services.dart'; import 'package:nc_photos_plugin/src/exception.dart'; import 'package:nc_photos_plugin/src/k.dart' as k; +class MediaStoreQueryResult { + const MediaStoreQueryResult(this.uri, this.displayName, this.path, + this.dateModified, this.mimeType, this.dateTaken); + + final String uri; + final String displayName; + final String path; + final int dateModified; + final String? mimeType; + final int? dateTaken; +} + class MediaStore { static Future saveFileToDownload( Uint8List content, @@ -51,6 +63,28 @@ class MediaStore { } } + /// Return files under [relativePath] and its sub dirs + static Future> queryFiles( + String relativePath) async { + try { + final List results = + await _methodChannel.invokeMethod("queryFiles", { + "relativePath": relativePath, + }); + return results + .cast() + .map((e) => MediaStoreQueryResult(e["uri"], e["displayName"], + e["path"], e["dateModified"], e["mimeType"], e["dateTaken"])) + .toList(); + } on PlatformException catch (e) { + if (e.code == _exceptionCodePermissionError) { + throw const PermissionException(); + } else { + rethrow; + } + } + } + static const _methodChannel = MethodChannel("${k.libId}/media_store_method"); static const _exceptionCodePermissionError = "permissionError"; From 4da8b95c6167bb1569ecc404edb6be59bf27b992 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 5 May 2022 22:06:47 +0800 Subject: [PATCH 07/23] List local files --- app/lib/app_init.dart | 8 ++ app/lib/di_container.dart | 10 ++ app/lib/entity/local_file.dart | 104 +++++++++++++++++++++ app/lib/entity/local_file/data_source.dart | 31 ++++++ app/lib/use_case/scan_local_dir.dart | 20 ++++ 5 files changed, 173 insertions(+) create mode 100644 app/lib/entity/local_file.dart create mode 100644 app/lib/entity/local_file/data_source.dart create mode 100644 app/lib/use_case/scan_local_dir.dart diff --git a/app/lib/app_init.dart b/app/lib/app_init.dart index 654767f2..793f3168 100644 --- a/app/lib/app_init.dart +++ b/app/lib/app_init.dart @@ -15,6 +15,8 @@ import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/favorite/data_source.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/entity/local_file/data_source.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/person/data_source.dart'; import 'package:nc_photos/entity/share.dart'; @@ -142,6 +144,11 @@ void _initSelfSignedCertManager() { } void _initDiContainer() { + LocalFileRepo? localFileRepo; + if (platform_k.isAndroid) { + // local file currently only supported on Android + localFileRepo = const LocalFileRepo(LocalFileMediaStoreDataSource()); + } KiwiContainer().registerInstance(DiContainer( albumRepo: AlbumRepo(AlbumCachedDataSource(AppDb())), faceRepo: const FaceRepo(FaceRemoteDataSource()), @@ -152,6 +159,7 @@ void _initDiContainer() { favoriteRepo: const FavoriteRepo(FavoriteRemoteDataSource()), tagRepo: const TagRepo(TagRemoteDataSource()), taggedFileRepo: const TaggedFileRepo(TaggedFileRemoteDataSource()), + localFileRepo: localFileRepo, appDb: AppDb(), pref: Pref(), )); diff --git a/app/lib/di_container.dart b/app/lib/di_container.dart index 7d23e7ba..36e58978 100644 --- a/app/lib/di_container.dart +++ b/app/lib/di_container.dart @@ -3,6 +3,7 @@ import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/face.dart'; import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; @@ -21,6 +22,7 @@ enum DiType { favoriteRepo, tagRepo, taggedFileRepo, + localFileRepo, appDb, pref, } @@ -36,6 +38,7 @@ class DiContainer { FavoriteRepo? favoriteRepo, TagRepo? tagRepo, TaggedFileRepo? taggedFileRepo, + LocalFileRepo? localFileRepo, AppDb? appDb, Pref? pref, }) : _albumRepo = albumRepo, @@ -47,6 +50,7 @@ class DiContainer { _favoriteRepo = favoriteRepo, _tagRepo = tagRepo, _taggedFileRepo = taggedFileRepo, + _localFileRepo = localFileRepo, _appDb = appDb, _pref = pref; @@ -70,6 +74,8 @@ class DiContainer { return contianer._tagRepo != null; case DiType.taggedFileRepo: return contianer._taggedFileRepo != null; + case DiType.localFileRepo: + return contianer._localFileRepo != null; case DiType.appDb: return contianer._appDb != null; case DiType.pref: @@ -87,6 +93,7 @@ class DiContainer { OrNull? favoriteRepo, OrNull? tagRepo, OrNull? taggedFileRepo, + OrNull? localFileRepo, OrNull? appDb, OrNull? pref, }) { @@ -101,6 +108,7 @@ class DiContainer { tagRepo: tagRepo == null ? _tagRepo : tagRepo.obj, taggedFileRepo: taggedFileRepo == null ? _taggedFileRepo : taggedFileRepo.obj, + localFileRepo: localFileRepo == null ? _localFileRepo : localFileRepo.obj, appDb: appDb == null ? _appDb : appDb.obj, pref: pref == null ? _pref : pref.obj, ); @@ -115,6 +123,7 @@ class DiContainer { FavoriteRepo get favoriteRepo => _favoriteRepo!; TagRepo get tagRepo => _tagRepo!; TaggedFileRepo get taggedFileRepo => _taggedFileRepo!; + LocalFileRepo get localFileRepo => _localFileRepo!; AppDb get appDb => _appDb!; Pref get pref => _pref!; @@ -128,6 +137,7 @@ class DiContainer { final FavoriteRepo? _favoriteRepo; final TagRepo? _tagRepo; final TaggedFileRepo? _taggedFileRepo; + final LocalFileRepo? _localFileRepo; final AppDb? _appDb; final Pref? _pref; diff --git a/app/lib/entity/local_file.dart b/app/lib/entity/local_file.dart new file mode 100644 index 00000000..73aff651 --- /dev/null +++ b/app/lib/entity/local_file.dart @@ -0,0 +1,104 @@ +import 'package:equatable/equatable.dart'; + +abstract class LocalFile with EquatableMixin { + const LocalFile(); + + /// Compare the identity of two local files + /// + /// Return true if two Files point to the same local file on the device. Be + /// careful that this does NOT mean that the two objects are identical + bool compareIdentity(LocalFile other); + + String get logTag; + + String get filename; + DateTime get lastModified; + String? get mime; + DateTime? get dateTaken; +} + +extension LocalFileExtension on LocalFile { + DateTime get bestDateTime => dateTaken ?? lastModified; +} + +/// A local file represented by its content uri on Android +class LocalUriFile with EquatableMixin implements LocalFile { + const LocalUriFile({ + required this.uri, + required this.displayName, + required this.path, + required this.lastModified, + this.mime, + this.dateTaken, + }); + + @override + compareIdentity(LocalFile other) { + if (other is! LocalUriFile) { + return false; + } else { + return uri == other.uri; + } + } + + @override + toString() { + var product = "$runtimeType {" + "uri: $uri, " + "displayName: $displayName, " + "path: '$path', " + "lastModified: $lastModified, "; + if (mime != null) { + product += "mime: $mime, "; + } + if (dateTaken != null) { + product += "dateTaken: $dateTaken, "; + } + return product + "}"; + } + + @override + get logTag => path; + + @override + get filename => displayName; + + @override + get props => [ + uri, + displayName, + path, + lastModified, + mime, + dateTaken, + ]; + + final String uri; + final String displayName; + + /// [path] could be a relative path or an absolute path + final String path; + @override + final DateTime lastModified; + @override + final String? mime; + @override + final DateTime? dateTaken; +} + +typedef LocalFileOnDeleteFailureListener = void Function( + LocalFile file, Object? error, StackTrace? stackTrace); + +class LocalFileRepo { + const LocalFileRepo(this.dataSrc); + + /// See [LocalFileDataSource.listDir] + Future> listDir(String path) => dataSrc.listDir(path); + + final LocalFileDataSource dataSrc; +} + +abstract class LocalFileDataSource { + /// List all files under [path] + Future> listDir(String path); +} diff --git a/app/lib/entity/local_file/data_source.dart b/app/lib/entity/local_file/data_source.dart new file mode 100644 index 00000000..befc389e --- /dev/null +++ b/app/lib/entity/local_file/data_source.dart @@ -0,0 +1,31 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +class LocalFileMediaStoreDataSource implements LocalFileDataSource { + const LocalFileMediaStoreDataSource(); + + @override + listDir(String path) async { + _log.info("[listDir] $path"); + final results = await MediaStore.queryFiles(path); + return results + .where((r) => file_util.isSupportedMime(r.mimeType ?? "")) + .map(_toLocalFile) + .toList(); + } + + static LocalFile _toLocalFile(MediaStoreQueryResult r) => LocalUriFile( + uri: r.uri, + displayName: r.displayName, + path: r.path, + lastModified: DateTime.fromMillisecondsSinceEpoch(r.dateModified), + mime: r.mimeType, + dateTaken: r.dateTaken?.run(DateTime.fromMillisecondsSinceEpoch), + ); + + static final _log = + Logger("entity.local_file.data_source.LocalFileMediaStoreDataSource"); +} diff --git a/app/lib/use_case/scan_local_dir.dart b/app/lib/use_case/scan_local_dir.dart new file mode 100644 index 00000000..488a11e6 --- /dev/null +++ b/app/lib/use_case/scan_local_dir.dart @@ -0,0 +1,20 @@ +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/local_file.dart'; + +class ScanLocalDir { + ScanLocalDir(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => + DiContainer.has(c, DiType.localFileRepo); + + /// List all files under a local dir recursively + Future> call(String relativePath) async { + final files = await _c.localFileRepo.listDir(relativePath); + return files + .where((f) => file_util.isSupportedImageMime(f.mime ?? "")) + .toList(); + } + + final DiContainer _c; +} From d33e3af806101dade2c4cb061b26a114aa59e1c5 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Thu, 5 May 2022 00:54:20 +0800 Subject: [PATCH 08/23] Delete files via MediaStore api --- app/android/app/src/main/AndroidManifest.xml | 5 +- .../kotlin/com/nkming/nc_photos/plugin/K.kt | 2 + .../plugin/MediaStoreChannelHandler.kt | 69 ++++++++++++++++++- .../nkming/nc_photos/plugin/NcPhotosPlugin.kt | 43 +++++++++++- plugin/lib/src/media_store.dart | 36 ++++++++++ plugin/pubspec.lock | 7 ++ plugin/pubspec.yaml | 2 + 7 files changed, 160 insertions(+), 4 deletions(-) diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 3f05186b..23237366 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ + android:largeHeap="true" + android:requestLegacyExternalStorage="true"> */ class MediaStoreChannelHandler(context: Context) : - MethodChannel.MethodCallHandler, ActivityAware { + MethodChannel.MethodCallHandler, EventChannel.StreamHandler, + ActivityAware, PluginRegistry.ActivityResultListener { companion object { + const val EVENT_CHANNEL = "${K.LIB_ID}/media_store" const val METHOD_CHANNEL = "${K.LIB_ID}/media_store_method" private const val TAG = "MediaStoreChannelHandler" @@ -49,6 +55,27 @@ class MediaStoreChannelHandler(context: Context) : activity = null } + override fun onActivityResult( + requestCode: Int, resultCode: Int, data: Intent? + ): Boolean { + if (requestCode == K.MEDIA_STORE_DELETE_REQUEST_CODE) { + eventSink?.success(buildMap { + put("event", "DeleteRequestResult") + put("resultCode", resultCode) + }) + return true + } + return false + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "saveFileToDownload" -> { @@ -81,6 +108,14 @@ class MediaStoreChannelHandler(context: Context) : } } + "deleteFiles" -> { + try { + deleteFiles(call.argument("uris")!!, result) + } catch (e: Throwable) { + result.error("systemException", e.message, null) + } + } + else -> result.notImplemented() } } @@ -196,6 +231,37 @@ class MediaStoreChannelHandler(context: Context) : result.success(files) } + private fun deleteFiles(uris: List, result: MethodChannel.Result) { + val urisTyped = uris.map(Uri::parse) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val pi = MediaStore.createDeleteRequest( + context.contentResolver, urisTyped + ) + activity!!.startIntentSenderForResult( + pi.intentSender, K.MEDIA_STORE_DELETE_REQUEST_CODE, null, 0, 0, + 0 + ) + result.success(null) + } else { + if (!PermissionUtil.hasWriteExternalStorage(context)) { + activity?.let { PermissionUtil.requestWriteExternalStorage(it) } + result.error("permissionError", "Permission not granted", null) + return + } + + val failed = mutableListOf() + for (uri in urisTyped) { + try { + context.contentResolver.delete(uri, null, null) + } catch (e: Throwable) { + Log.e(TAG, "[deleteFiles] Failed while delete", e) + failed += uri.toString() + } + } + result.success(failed) + } + } + private fun inputToUri(fromFile: String): Uri { val testUri = Uri.parse(fromFile) return if (testUri.scheme == null) { @@ -209,4 +275,5 @@ class MediaStoreChannelHandler(context: Context) : private val context = context private var activity: Activity? = null + private var eventSink: EventChannel.EventSink? = null } diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt index cbbcebac..95203f0a 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt @@ -1,17 +1,23 @@ package com.nkming.nc_photos.plugin +import android.content.Intent +import android.util.Log import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.PluginRegistry -class NcPhotosPlugin : FlutterPlugin, ActivityAware { +class NcPhotosPlugin : FlutterPlugin, ActivityAware, + PluginRegistry.ActivityResultListener { companion object { const val ACTION_SHOW_IMAGE_PROCESSOR_RESULT = K.ACTION_SHOW_IMAGE_PROCESSOR_RESULT const val EXTRA_IMAGE_RESULT_URI = K.EXTRA_IMAGE_RESULT_URI + + private const val TAG = "NcPhotosPlugin" } override fun onAttachedToEngine( @@ -47,6 +53,11 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware { mediaStoreChannelHandler = MediaStoreChannelHandler(flutterPluginBinding.applicationContext) + mediaStoreChannel = EventChannel( + flutterPluginBinding.binaryMessenger, + MediaStoreChannelHandler.EVENT_CHANNEL + ) + mediaStoreChannel.setStreamHandler(mediaStoreChannelHandler) mediaStoreMethodChannel = MethodChannel( flutterPluginBinding.binaryMessenger, MediaStoreChannelHandler.METHOD_CHANNEL @@ -87,26 +98,56 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware { override fun onAttachedToActivity(binding: ActivityPluginBinding) { mediaStoreChannelHandler.onAttachedToActivity(binding) + pluginBinding = binding + binding.addActivityResultListener(this) } override fun onReattachedToActivityForConfigChanges( binding: ActivityPluginBinding ) { mediaStoreChannelHandler.onReattachedToActivityForConfigChanges(binding) + pluginBinding = binding + binding.addActivityResultListener(this) } override fun onDetachedFromActivity() { mediaStoreChannelHandler.onDetachedFromActivity() + pluginBinding?.removeActivityResultListener(this) } override fun onDetachedFromActivityForConfigChanges() { mediaStoreChannelHandler.onDetachedFromActivityForConfigChanges() + pluginBinding?.removeActivityResultListener(this) } + override fun onActivityResult( + requestCode: Int, resultCode: Int, data: Intent? + ): Boolean { + return try { + when (requestCode) { + K.MEDIA_STORE_DELETE_REQUEST_CODE -> { + mediaStoreChannelHandler.onActivityResult( + requestCode, resultCode, data + ) + } + + else -> false + } + } catch (e: Throwable) { + Log.e( + TAG, "Failed while onActivityResult, requestCode=$requestCode" + ) + false + } + } + + private var pluginBinding: ActivityPluginBinding? = null + private lateinit var lockChannel: MethodChannel private lateinit var notificationChannel: MethodChannel private lateinit var nativeEventChannel: EventChannel private lateinit var nativeEventMethodChannel: MethodChannel + private lateinit var mediaStoreChannel: EventChannel private lateinit var mediaStoreMethodChannel: MethodChannel private lateinit var imageProcessorMethodChannel: MethodChannel private lateinit var contentUriMethodChannel: MethodChannel diff --git a/plugin/lib/src/media_store.dart b/plugin/lib/src/media_store.dart index 72f9b89e..ce272841 100644 --- a/plugin/lib/src/media_store.dart +++ b/plugin/lib/src/media_store.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; import 'package:nc_photos_plugin/src/exception.dart'; import 'package:nc_photos_plugin/src/k.dart' as k; @@ -16,6 +17,12 @@ class MediaStoreQueryResult { final int? dateTaken; } +class MediaStoreDeleteRequestResultEvent { + const MediaStoreDeleteRequestResultEvent(this.resultCode); + + final int resultCode; +} + class MediaStore { static Future saveFileToDownload( Uint8List content, @@ -85,7 +92,36 @@ class MediaStore { } } + static Future?> deleteFiles(List uris) async { + return (await _methodChannel + .invokeMethod("deleteFiles", { + "uris": uris, + })) + ?.cast(); + } + + static Stream get stream => _eventStream; + + static late final _eventStream = + _eventChannel.receiveBroadcastStream().map((event) { + if (event is Map) { + switch (event["event"]) { + case _eventDeleteRequestResult: + return MediaStoreDeleteRequestResultEvent(event["resultCode"]); + + default: + _log.shout("[_eventStream] Unknown event: ${event["event"]}"); + } + } else { + return event; + } + }); + + static const _eventChannel = EventChannel("${k.libId}/media_store"); static const _methodChannel = MethodChannel("${k.libId}/media_store_method"); static const _exceptionCodePermissionError = "permissionError"; + static const _eventDeleteRequestResult = "DeleteRequestResult"; + + static final _log = Logger("media_store.MediaStore"); } diff --git a/plugin/pubspec.lock b/plugin/pubspec.lock index 602a6ac4..8111b762 100644 --- a/plugin/pubspec.lock +++ b/plugin/pubspec.lock @@ -34,6 +34,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + logging: + dependency: "direct main" + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" material_color_utilities: dependency: transitive description: diff --git a/plugin/pubspec.yaml b/plugin/pubspec.yaml index 48d8becf..d9f61838 100644 --- a/plugin/pubspec.yaml +++ b/plugin/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: flutter: sdk: flutter + logging: ^1.0.2 + dev_dependencies: flutter_lints: ^1.0.0 From c38ace893d007a4752f77cbd5d075ec08cc7387d Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Fri, 6 May 2022 02:34:30 +0800 Subject: [PATCH 09/23] Delete local files --- app/lib/entity/local_file.dart | 15 +++++- app/lib/entity/local_file/data_source.dart | 62 ++++++++++++++++++++++ app/lib/event/event.dart | 7 +++ app/lib/mobile/android/k.dart | 8 +++ app/lib/use_case/delete_local.dart | 32 +++++++++++ 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 app/lib/mobile/android/k.dart create mode 100644 app/lib/use_case/delete_local.dart diff --git a/app/lib/entity/local_file.dart b/app/lib/entity/local_file.dart index 73aff651..55d30535 100644 --- a/app/lib/entity/local_file.dart +++ b/app/lib/entity/local_file.dart @@ -86,7 +86,7 @@ class LocalUriFile with EquatableMixin implements LocalFile { final DateTime? dateTaken; } -typedef LocalFileOnDeleteFailureListener = void Function( +typedef LocalFileOnFailureListener = void Function( LocalFile file, Object? error, StackTrace? stackTrace); class LocalFileRepo { @@ -95,10 +95,23 @@ class LocalFileRepo { /// See [LocalFileDataSource.listDir] Future> listDir(String path) => dataSrc.listDir(path); + /// See [LocalFileDataSource.deleteFiles] + Future deleteFiles( + List files, { + LocalFileOnFailureListener? onFailure, + }) => + dataSrc.deleteFiles(files, onFailure: onFailure); + final LocalFileDataSource dataSrc; } abstract class LocalFileDataSource { /// List all files under [path] Future> listDir(String path); + + /// Delete files + Future deleteFiles( + List files, { + LocalFileOnFailureListener? onFailure, + }); } diff --git a/app/lib/entity/local_file/data_source.dart b/app/lib/entity/local_file/data_source.dart index befc389e..e9cbaad9 100644 --- a/app/lib/entity/local_file/data_source.dart +++ b/app/lib/entity/local_file/data_source.dart @@ -1,7 +1,12 @@ +import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/mobile/android/android_info.dart'; +import 'package:nc_photos/mobile/android/k.dart' as android; import 'package:nc_photos/object_extension.dart'; +import 'package:nc_photos/stream_extension.dart'; import 'package:nc_photos_plugin/nc_photos_plugin.dart'; class LocalFileMediaStoreDataSource implements LocalFileDataSource { @@ -17,6 +22,63 @@ class LocalFileMediaStoreDataSource implements LocalFileDataSource { .toList(); } + @override + deleteFiles( + List files, { + LocalFileOnFailureListener? onFailure, + }) async { + _log.info("[deleteFiles] ${files.map((f) => f.logTag).toReadableString()}"); + final uriFiles = files + .where((f) { + if (f is! LocalUriFile) { + _log.warning( + "[deleteFiles] Can't remove file not returned by this data source: $f"); + onFailure?.call(f, ArgumentError("File not supported"), null); + return false; + } else { + return true; + } + }) + .cast() + .toList(); + if (AndroidInfo().sdkInt >= AndroidVersion.R) { + await _deleteFiles30(uriFiles, onFailure); + } else { + await _deleteFiles0(uriFiles, onFailure); + } + } + + Future _deleteFiles30( + List files, LocalFileOnFailureListener? onFailure) async { + assert(AndroidInfo().sdkInt >= AndroidVersion.R); + int? resultCode; + final resultFuture = MediaStore.stream + .whereType() + .first + .then((ev) => resultCode = ev.resultCode); + await MediaStore.deleteFiles(files.map((f) => f.uri).toList()); + await resultFuture; + if (resultCode != android.resultOk) { + _log.warning("[_deleteFiles30] result != OK: $resultCode"); + for (final f in files) { + onFailure?.call(f, null, null); + } + } + } + + Future _deleteFiles0( + List files, LocalFileOnFailureListener? onFailure) async { + assert(AndroidInfo().sdkInt < AndroidVersion.R); + final failedUris = + await MediaStore.deleteFiles(files.map((f) => f.uri).toList()); + final failedFilesIt = failedUris! + .map((uri) => files.firstWhereOrNull((f) => f.uri == uri)) + .whereNotNull(); + for (final f in failedFilesIt) { + onFailure?.call(f, null, null); + } + } + static LocalFile _toLocalFile(MediaStoreQueryResult r) => LocalUriFile( uri: r.uri, displayName: r.displayName, diff --git a/app/lib/event/event.dart b/app/lib/event/event.dart index f8f6e8ba..6a602264 100644 --- a/app/lib/event/event.dart +++ b/app/lib/event/event.dart @@ -6,6 +6,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/album.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/pref.dart'; @@ -148,6 +149,12 @@ class PrefUpdatedEvent { final dynamic value; } +class LocalFileDeletedEvent { + const LocalFileDeletedEvent(this.files); + + final List files; +} + extension FilePropertyUpdatedEventExtension on FilePropertyUpdatedEvent { bool hasAnyProperties(List properties) => properties.any((p) => this.properties & p != 0); diff --git a/app/lib/mobile/android/k.dart b/app/lib/mobile/android/k.dart new file mode 100644 index 00000000..bc96c556 --- /dev/null +++ b/app/lib/mobile/android/k.dart @@ -0,0 +1,8 @@ +/// Standard activity result: operation canceled. +const resultCanceled = 0; + +/// Standard activity result: operation succeeded. +const resultOk = -1; + +/// Start of user-defined activity results. +const resultFirstUser = 1; diff --git a/app/lib/use_case/delete_local.dart b/app/lib/use_case/delete_local.dart new file mode 100644 index 00000000..82e10267 --- /dev/null +++ b/app/lib/use_case/delete_local.dart @@ -0,0 +1,32 @@ +import 'package:event_bus/event_bus.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/event/event.dart'; + +class DeleteLocal { + DeleteLocal(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => + DiContainer.has(c, DiType.localFileRepo); + + Future call( + List files, { + LocalFileOnFailureListener? onFailure, + }) async { + final deleted = List.of(files); + await _c.localFileRepo.deleteFiles(files, onFailure: (f, e, stackTrace) { + deleted.removeWhere((d) => d.compareIdentity(f)); + onFailure?.call(f, e, stackTrace); + }); + if (deleted.isNotEmpty) { + _log.info("[call] Deleted ${deleted.length} files successfully"); + KiwiContainer().resolve().fire(LocalFileDeletedEvent(deleted)); + } + } + + final DiContainer _c; + + static final _log = Logger("use_case.delete_local.DeleteLocal"); +} From 39840c8e455f456c0ca8d23dd2c119b4f30ed14e Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Fri, 6 May 2022 13:23:04 +0800 Subject: [PATCH 10/23] Local image file viewer --- app/lib/widget/image_viewer.dart | 73 ++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/app/lib/widget/image_viewer.dart b/app/lib/widget/image_viewer.dart index 6983ec72..0c52472a 100644 --- a/app/lib/widget/image_viewer.dart +++ b/app/lib/widget/image_viewer.dart @@ -6,10 +6,75 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/cache_manager_util.dart'; -import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file.dart' as app; +import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/android/content_uri_image_provider.dart'; import 'package:nc_photos/widget/cached_network_image_mod.dart' as mod; +class LocalImageViewer extends StatefulWidget { + const LocalImageViewer({ + Key? key, + required this.file, + required this.canZoom, + this.onLoaded, + this.onHeightChanged, + this.onZoomStarted, + this.onZoomEnded, + }) : super(key: key); + + @override + createState() => _LocalImageViewerState(); + + final LocalFile file; + final bool canZoom; + final VoidCallback? onLoaded; + final ValueChanged? onHeightChanged; + final VoidCallback? onZoomStarted; + final VoidCallback? onZoomEnded; +} + +class _LocalImageViewerState extends State { + @override + build(BuildContext context) { + final ImageProvider provider; + if (widget.file is LocalUriFile) { + provider = ContentUriImage((widget.file as LocalUriFile).uri); + } else { + throw ArgumentError("Invalid file"); + } + + return _ImageViewer( + canZoom: widget.canZoom, + onHeightChanged: widget.onHeightChanged, + onZoomStarted: widget.onZoomStarted, + onZoomEnded: widget.onZoomEnded, + child: Image( + image: provider, + fit: BoxFit.contain, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + WidgetsBinding.instance!.addPostFrameCallback((_) { + _onItemLoaded(); + }); + return child; + }, + ), + ); + } + + void _onItemLoaded() { + if (!_isLoaded) { + _log.info("[_onItemLoaded] ${widget.file.logTag}"); + _isLoaded = true; + widget.onLoaded?.call(); + } + } + + var _isLoaded = false; + + static final _log = Logger("widget.image_viewer._LocalImageViewerState"); +} + class RemoteImageViewer extends StatefulWidget { const RemoteImageViewer({ Key? key, @@ -25,7 +90,7 @@ class RemoteImageViewer extends StatefulWidget { @override createState() => _RemoteImageViewerState(); - static void preloadImage(Account account, File file) { + static void preloadImage(Account account, app.File file) { LargeImageCacheManager.inst.getFileStream( _getImageUrl(account, file), headers: { @@ -35,7 +100,7 @@ class RemoteImageViewer extends StatefulWidget { } final Account account; - final File file; + final app.File file; final bool canZoom; final VoidCallback? onLoaded; final ValueChanged? onHeightChanged; @@ -264,7 +329,7 @@ class _ImageViewerState extends State<_ImageViewer> static final _log = Logger("widget.image_viewer._ImageViewerState"); } -String _getImageUrl(Account account, File file) { +String _getImageUrl(Account account, app.File file) { if (file.contentType == "image/gif") { return api_util.getFileUrl(account, file); } else { From ef4daf552b0747a186232de51d552a5cff6233f1 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Fri, 6 May 2022 17:16:56 +0800 Subject: [PATCH 11/23] Browse enhanced photos --- app/lib/bloc/scan_local_dir.dart | 131 +++++++ app/lib/entity/file_util.dart | 5 +- app/lib/l10n/app_en.arb | 8 + app/lib/l10n/untranslated-messages.txt | 30 +- app/lib/widget/enhanced_photo_browser.dart | 369 ++++++++++++++++++ .../delete_local_selection_handler.dart | 51 +++ app/lib/widget/home_albums.dart | 16 + app/lib/widget/local_file_viewer.dart | 218 +++++++++++ app/lib/widget/my_app.dart | 32 ++ 9 files changed, 853 insertions(+), 7 deletions(-) create mode 100644 app/lib/bloc/scan_local_dir.dart create mode 100644 app/lib/widget/enhanced_photo_browser.dart create mode 100644 app/lib/widget/handler/delete_local_selection_handler.dart create mode 100644 app/lib/widget/local_file_viewer.dart diff --git a/app/lib/bloc/scan_local_dir.dart b/app/lib/bloc/scan_local_dir.dart new file mode 100644 index 00000000..8d092b22 --- /dev/null +++ b/app/lib/bloc/scan_local_dir.dart @@ -0,0 +1,131 @@ +import 'package:bloc/bloc.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/use_case/scan_local_dir.dart'; + +abstract class ScanLocalDirBlocEvent { + const ScanLocalDirBlocEvent(); +} + +class ScanLocalDirBlocQuery extends ScanLocalDirBlocEvent { + const ScanLocalDirBlocQuery(this.relativePaths); + + @override + toString() => "$runtimeType {" + "relativePaths: ${relativePaths.toReadableString()}, " + "}"; + + final List relativePaths; +} + +class _ScanLocalDirBlocFileDeleted extends ScanLocalDirBlocEvent { + const _ScanLocalDirBlocFileDeleted(this.files); + + @override + toString() => "$runtimeType {" + "files: ${files.map((f) => f.logTag).toReadableString()}, " + "}"; + + final List files; +} + +abstract class ScanLocalDirBlocState { + const ScanLocalDirBlocState(this.files); + + @override + toString() => "$runtimeType {" + "files: List {length: ${files.length}}, " + "}"; + + final List files; +} + +class ScanLocalDirBlocInit extends ScanLocalDirBlocState { + const ScanLocalDirBlocInit() : super(const []); +} + +class ScanLocalDirBlocLoading extends ScanLocalDirBlocState { + const ScanLocalDirBlocLoading(List files) : super(files); +} + +class ScanLocalDirBlocSuccess extends ScanLocalDirBlocState { + const ScanLocalDirBlocSuccess(List files) : super(files); +} + +class ScanLocalDirBlocFailure extends ScanLocalDirBlocState { + const ScanLocalDirBlocFailure(List files, this.exception) + : super(files); + + @override + toString() => "$runtimeType {" + "super: ${super.toString()}, " + "exception: $exception, " + "}"; + + final dynamic exception; +} + +class ScanLocalDirBloc + extends Bloc { + ScanLocalDirBloc() : super(const ScanLocalDirBlocInit()) { + on(_onScanLocalDirBlocQuery); + on<_ScanLocalDirBlocFileDeleted>(_onScanLocalDirBlocFileDeleted); + + _fileDeletedEventListener.begin(); + } + + @override + close() { + _fileDeletedEventListener.end(); + return super.close(); + } + + Future _onScanLocalDirBlocQuery( + ScanLocalDirBlocQuery event, Emitter emit) async { + final shouldEmitIntermediate = state.files.isEmpty; + try { + emit(ScanLocalDirBlocLoading(state.files)); + final c = KiwiContainer().resolve(); + final products = []; + for (final p in event.relativePaths) { + if (shouldEmitIntermediate) { + emit(ScanLocalDirBlocLoading(products)); + } + final files = await ScanLocalDir(c)(p); + products.addAll(files); + } + emit(ScanLocalDirBlocSuccess(products)); + } catch (e, stackTrace) { + _log.severe( + "[_onScanLocalDirBlocQuery] Exception while request", e, stackTrace); + emit(ScanLocalDirBlocFailure(state.files, e)); + } + } + + Future _onScanLocalDirBlocFileDeleted( + _ScanLocalDirBlocFileDeleted event, + Emitter emit) async { + final newFiles = state.files + .where((f) => !event.files.any((d) => d.compareIdentity(f))) + .toList(); + if (newFiles.length != state.files.length) { + emit(ScanLocalDirBlocSuccess(newFiles)); + } + } + + void _onFileDeletedEvent(LocalFileDeletedEvent ev) { + if (state is ScanLocalDirBlocInit) { + return; + } + add(_ScanLocalDirBlocFileDeleted(ev.files)); + } + + late final _fileDeletedEventListener = + AppEventListener(_onFileDeletedEvent); + + static final _log = Logger("bloc.scan_local_dir.ScanLocalDirBloc"); +} diff --git a/app/lib/entity/file_util.dart b/app/lib/entity/file_util.dart index 4b73aa39..5fe11c95 100644 --- a/app/lib/entity/file_util.dart +++ b/app/lib/entity/file_util.dart @@ -11,8 +11,11 @@ bool isSupportedMime(String mime) => _supportedFormatMimes.contains(mime); bool isSupportedFormat(File file) => isSupportedMime(file.contentType ?? ""); +bool isSupportedImageMime(String mime) => + isSupportedMime(mime) && mime.startsWith("image/") == true; + bool isSupportedImageFormat(File file) => - isSupportedFormat(file) && file.contentType?.startsWith("image/") == true; + isSupportedImageMime(file.contentType ?? ""); bool isSupportedVideoFormat(File file) => isSupportedFormat(file) && file.contentType?.startsWith("video/") == true; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index dea73037..283fc0ec 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1179,6 +1179,14 @@ "@enhanceLowLightTitle": { "description": "Enhance a photo taken in low-light environment" }, + "collectionEnhancedPhotosLabel": "Enhanced photos", + "@collectionEnhancedPhotosLabel": { + "description": "List photos enhanced by the app" + }, + "deletePermanentlyLocalConfirmationDialogContent": "Selected items will be deleted permanently from this device.\n\nThis action is nonreversible", + "@deletePermanentlyLocalConfirmationDialogContent": { + "description": "Make sure the user wants to delete the items from the current device" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 4f52f171..f28e5354 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -86,6 +86,8 @@ "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent", "errorAlbumDowngrade" ], @@ -190,6 +192,8 @@ "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent", "errorAlbumDowngrade" ], @@ -349,6 +353,8 @@ "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent", "errorAlbumDowngrade" ], @@ -358,12 +364,16 @@ "backgroundServiceStopping", "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "fi": [ "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "fr": [ @@ -372,7 +382,9 @@ "helpButtonLabel", "removeFromAlbumTooltip", "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "pl": [ @@ -398,16 +410,22 @@ "backgroundServiceStopping", "metadataTaskPauseLowBatteryNotification", "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "pt": [ "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ], "ru": [ "enhanceTooltip", - "enhanceLowLightTitle" + "enhanceLowLightTitle", + "collectionEnhancedPhotosLabel", + "deletePermanentlyLocalConfirmationDialogContent" ] } diff --git a/app/lib/widget/enhanced_photo_browser.dart b/app/lib/widget/enhanced_photo_browser.dart new file mode 100644 index 00000000..8db7eb16 --- /dev/null +++ b/app/lib/widget/enhanced_photo_browser.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc/scan_local_dir.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/android/content_uri_image_provider.dart'; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/empty_list_indicator.dart'; +import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart'; +import 'package:nc_photos/widget/local_file_viewer.dart'; +import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util; +import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; +import 'package:nc_photos/widget/selection_app_bar.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +class EnhancedPhotoBrowserArguments { + const EnhancedPhotoBrowserArguments(this.filename); + + final String? filename; +} + +class EnhancedPhotoBrowser extends StatefulWidget { + static const routeName = "/enhanced-photo-browser"; + + static Route buildRoute(EnhancedPhotoBrowserArguments args) => + MaterialPageRoute( + builder: (context) => EnhancedPhotoBrowser.fromArgs(args), + ); + + const EnhancedPhotoBrowser({ + Key? key, + required this.filename, + }) : super(key: key); + + EnhancedPhotoBrowser.fromArgs(EnhancedPhotoBrowserArguments args, {Key? key}) + : this( + key: key, + filename: args.filename, + ); + + @override + createState() => _EnhancedPhotoBrowserState(); + + final String? filename; +} + +class _EnhancedPhotoBrowserState extends State + with SelectableItemStreamListMixin { + @override + initState() { + super.initState(); + _initBloc(); + _thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + bloc: _bloc, + builder: (context, state) => _buildContent(context, state), + ), + ), + ), + ); + } + + void _initBloc() { + if (_bloc.state is ScanLocalDirBlocInit) { + _log.info("[_initBloc] Initialize bloc"); + _reqQuery(); + } else { + // process the current state + WidgetsBinding.instance!.addPostFrameCallback((_) { + setState(() { + _onStateChange(context, _bloc.state); + }); + _reqQuery(); + }); + } + } + + Widget _buildContent(BuildContext context, ScanLocalDirBlocState state) { + if (state is ScanLocalDirBlocSuccess && itemStreamListItems.isEmpty) { + return Column( + children: [ + AppBar( + title: Text(L10n.global().collectionEnhancedPhotosLabel), + elevation: 0, + ), + Expanded( + child: EmptyListIndicator( + icon: Icons.folder_outlined, + text: L10n.global().listEmptyText, + ), + ), + ], + ); + } else { + return Stack( + children: [ + buildItemStreamListOuter( + context, + child: Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of(context).colorScheme.copyWith( + secondary: AppTheme.getOverscrollIndicatorColor(context), + ), + ), + child: CustomScrollView( + slivers: [ + _buildAppBar(context), + buildItemStreamList( + maxCrossAxisExtent: _thumbSize.toDouble(), + ), + ], + ), + ), + ), + if (state is ScanLocalDirBlocLoading) + const Align( + alignment: Alignment.bottomCenter, + child: LinearProgressIndicator(), + ), + ], + ); + } + } + + Widget _buildAppBar(BuildContext context) { + if (isSelectionMode) { + return _buildSelectionAppBar(context); + } else { + return _buildNormalAppBar(context); + } + } + + Widget _buildNormalAppBar(BuildContext context) => SliverAppBar( + title: Text(L10n.global().collectionEnhancedPhotosLabel), + ); + + Widget _buildSelectionAppBar(BuildContext context) { + return SelectionAppBar( + count: selectedListItems.length, + onClosePressed: () { + setState(() { + clearSelectedItems(); + }); + }, + actions: [ + PopupMenuButton<_SelectionMenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) => [ + PopupMenuItem( + value: _SelectionMenuOption.delete, + child: Text(L10n.global().deletePermanentlyTooltip), + ), + ], + onSelected: (option) => _onSelectionMenuSelected(context, option), + ), + ], + ); + } + + void _onStateChange(BuildContext context, ScanLocalDirBlocState state) { + if (state is ScanLocalDirBlocInit) { + itemStreamListItems = []; + } else if (state is ScanLocalDirBlocLoading) { + _transformItems(state.files); + } else if (state is ScanLocalDirBlocSuccess) { + _transformItems(state.files); + if (_isFirstRun) { + _isFirstRun = false; + if (widget.filename != null) { + _openInitialImage(widget.filename!); + } + } + } else if (state is ScanLocalDirBlocFailure) { + _transformItems(state.files); + SnackBarManager().showSnackBar(SnackBar( + content: Text(state.exception is PermissionException + ? L10n.global().errorNoStoragePermission + : exception_util.toUserString(state.exception)), + duration: k.snackBarDurationNormal, + )); + } + } + + void _onSelectionMenuSelected( + BuildContext context, _SelectionMenuOption option) { + switch (option) { + case _SelectionMenuOption.delete: + _onSelectionDeletePressed(context); + break; + default: + _log.shout("[_onSelectionMenuSelected] Unknown option: $option"); + break; + } + } + + Future _onSelectionDeletePressed(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(L10n.global().deletePermanentlyConfirmationDialogTitle), + content: Text( + L10n.global().deletePermanentlyLocalConfirmationDialogContent, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text(L10n.global().confirmButtonLabel), + ), + ], + ), + ); + if (result != true) { + return; + } + + final selectedFiles = selectedListItems + .whereType<_FileListItem>() + .map((e) => e.file) + .toList(); + setState(() { + clearSelectedItems(); + }); + await const DeleteLocalSelectionHandler()(selectedFiles: selectedFiles); + } + + void _onItemTap(int index) { + Navigator.pushNamed(context, LocalFileViewer.routeName, + arguments: LocalFileViewerArguments(_backingFiles, index)); + } + + void _transformItems(List files) { + // we use last modified here to keep newly enhanced photo at the top + _backingFiles = + files.stableSorted((a, b) => b.lastModified.compareTo(a.lastModified)); + + itemStreamListItems = () sync* { + for (int i = 0; i < _backingFiles.length; ++i) { + final f = _backingFiles[i]; + if (file_util.isSupportedImageMime(f.mime ?? "")) { + yield _ImageListItem( + file: f, + onTap: () => _onItemTap(i), + ); + } + } + }() + .toList(); + _log.info("[_transformItems] Length: ${itemStreamListItems.length}"); + } + + void _openInitialImage(String filename) { + final index = _backingFiles.indexWhere((f) => f.filename == filename); + if (index == -1) { + _log.severe("[openInitialImage] Filename not found: $filename"); + return; + } + Navigator.pushNamed(context, LocalFileViewer.routeName, + arguments: LocalFileViewerArguments(_backingFiles, index)); + } + + void _reqQuery() { + _bloc.add(const ScanLocalDirBlocQuery( + ["Download/Photos (for Nextcloud)/Enhanced Photos"])); + } + + final _bloc = ScanLocalDirBloc(); + + var _backingFiles = []; + + var _isFirstRun = true; + var _thumbZoomLevel = 0; + int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); + + static final _log = + Logger("widget.enhanced_photo_browser._EnhancedPhotoBrowserState"); +} + +abstract class _ListItem implements SelectableItem { + _ListItem({ + VoidCallback? onTap, + }) : _onTap = onTap; + + @override + get onTap => _onTap; + + @override + get isSelectable => true; + + @override + get staggeredTile => const StaggeredTile.count(1, 1); + + final VoidCallback? _onTap; +} + +abstract class _FileListItem extends _ListItem { + _FileListItem({ + required this.file, + VoidCallback? onTap, + }) : super(onTap: onTap); + + final LocalFile file; +} + +class _ImageListItem extends _FileListItem { + _ImageListItem({ + required LocalFile file, + VoidCallback? onTap, + }) : super(file: file, onTap: onTap); + + @override + buildWidget(BuildContext context) { + final ImageProvider provider; + if (file is LocalUriFile) { + provider = ContentUriImage((file as LocalUriFile).uri); + } else { + throw ArgumentError("Invalid file"); + } + + return Padding( + padding: const EdgeInsets.all(2), + child: FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: Container( + // arbitrary size here + constraints: BoxConstraints.tight(const Size(128, 128)), + color: AppTheme.getListItemBackgroundColor(context), + child: Image( + image: ResizeImage.resizeIfNeeded(k.photoThumbSize, null, provider), + filterQuality: FilterQuality.high, + fit: BoxFit.cover, + errorBuilder: (context, e, stackTrace) { + return Center( + child: Icon( + Icons.image_not_supported, + size: 64, + color: Colors.white.withOpacity(.8), + ), + ); + }, + ), + ), + ), + ); + } +} + +enum _SelectionMenuOption { + delete, +} diff --git a/app/lib/widget/handler/delete_local_selection_handler.dart b/app/lib/widget/handler/delete_local_selection_handler.dart new file mode 100644 index 00000000..8b253e64 --- /dev/null +++ b/app/lib/widget/handler/delete_local_selection_handler.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/use_case/delete_local.dart'; + +class DeleteLocalSelectionHandler { + const DeleteLocalSelectionHandler(); + + /// Delete [selectedFiles] permanently from device + Future call({ + required List selectedFiles, + bool isRemoveOpened = false, + }) async { + final c = KiwiContainer().resolve(); + var failureCount = 0; + await DeleteLocal(c)( + selectedFiles, + onFailure: (file, e, stackTrace) { + if (e != null) { + _log.shout( + "[call] Failed while deleting file: ${logFilename(file.logTag)}", + e, + stackTrace); + } + ++failureCount; + }, + ); + if (failureCount == 0) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().deleteSelectedSuccessNotification), + duration: k.snackBarDurationNormal, + )); + } else { + SnackBarManager().showSnackBar(SnackBar( + content: + Text(L10n.global().deleteSelectedFailureNotification(failureCount)), + duration: k.snackBarDurationNormal, + )); + } + return selectedFiles.length - failureCount; + } + + static final _log = Logger( + "widget.handler.delete_local_selection_handler.DeleteLocalSelectionHandler"); +} diff --git a/app/lib/widget/home_albums.dart b/app/lib/widget/home_albums.dart index bb03da8a..804130be 100644 --- a/app/lib/widget/home_albums.dart +++ b/app/lib/widget/home_albums.dart @@ -27,6 +27,7 @@ import 'package:nc_photos/widget/album_search_delegate.dart'; import 'package:nc_photos/widget/archive_browser.dart'; import 'package:nc_photos/widget/builder/album_grid_item_builder.dart'; import 'package:nc_photos/widget/dynamic_album_browser.dart'; +import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/fancy_option_picker.dart'; import 'package:nc_photos/widget/favorite_browser.dart'; import 'package:nc_photos/widget/home_app_bar.dart'; @@ -37,6 +38,7 @@ import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; import 'package:nc_photos/widget/selection_app_bar.dart'; import 'package:nc_photos/widget/sharing_browser.dart'; import 'package:nc_photos/widget/trashbin_browser.dart'; +import 'package:nc_photos/platform/features.dart' as features; class HomeAlbums extends StatefulWidget { const HomeAlbums({ @@ -285,6 +287,19 @@ class _HomeAlbumsState extends State ); } + SelectableItem _buildEnhancedPhotosItem(BuildContext context) { + return _ButtonListItem( + icon: Icons.auto_fix_high_outlined, + label: L10n.global().collectionEnhancedPhotosLabel, + onTap: () { + if (!isSelectionMode) { + Navigator.of(context).pushNamed(EnhancedPhotoBrowser.routeName, + arguments: const EnhancedPhotoBrowserArguments(null)); + } + }, + ); + } + SelectableItem _buildNewAlbumItem(BuildContext context) { return _ButtonListItem( icon: Icons.add, @@ -483,6 +498,7 @@ class _HomeAlbumsState extends State if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr()) _buildPersonItem(context), _buildSharingItem(context), + if (features.isSupportEnhancement) _buildEnhancedPhotosItem(context), _buildArchiveItem(context), _buildTrashbinItem(context), _buildNewAlbumItem(context), diff --git a/app/lib/widget/local_file_viewer.dart b/app/lib/widget/local_file_viewer.dart new file mode 100644 index 00000000..8102525c --- /dev/null +++ b/app/lib/widget/local_file_viewer.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart'; +import 'package:nc_photos/widget/horizontal_page_viewer.dart'; +import 'package:nc_photos/widget/image_viewer.dart'; + +class LocalFileViewerArguments { + LocalFileViewerArguments(this.streamFiles, this.startIndex); + + final List streamFiles; + final int startIndex; +} + +class LocalFileViewer extends StatefulWidget { + static const routeName = "/local-file-viewer"; + + static Route buildRoute(LocalFileViewerArguments args) => MaterialPageRoute( + builder: (context) => LocalFileViewer.fromArgs(args), + ); + + const LocalFileViewer({ + Key? key, + required this.streamFiles, + required this.startIndex, + }) : super(key: key); + + LocalFileViewer.fromArgs(LocalFileViewerArguments args, {Key? key}) + : this( + key: key, + streamFiles: args.streamFiles, + startIndex: args.startIndex, + ); + + @override + createState() => _LocalFileViewerState(); + + final List streamFiles; + final int startIndex; +} + +class _LocalFileViewerState extends State { + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: Builder( + builder: _buildContent, + ), + ), + ); + } + + Widget _buildContent(BuildContext context) { + return GestureDetector( + onTap: () { + setState(() { + _isShowVideoControl = !_isShowVideoControl; + }); + }, + child: Stack( + children: [ + Container(color: Colors.black), + if (!_isViewerLoaded || + !_pageStates[_viewerController.currentPage]!.hasLoaded) + const Align( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ), + HorizontalPageViewer( + pageCount: widget.streamFiles.length, + pageBuilder: _buildPage, + initialPage: widget.startIndex, + controller: _viewerController, + viewportFraction: _viewportFraction, + canSwitchPage: _canSwitchPage, + ), + _buildAppBar(context), + ], + ), + ); + } + + Widget _buildAppBar(BuildContext context) { + return Wrap( + children: [ + Stack( + children: [ + Container( + // + status bar height + height: kToolbarHeight + MediaQuery.of(context).padding.top, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment(0, -1), + end: Alignment(0, 1), + colors: [ + Color.fromARGB(192, 0, 0, 0), + Color.fromARGB(0, 0, 0, 0), + ], + ), + ), + ), + AppBar( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + foregroundColor: Colors.white.withOpacity(.87), + actions: [ + PopupMenuButton<_AppBarMenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) => [ + PopupMenuItem( + value: _AppBarMenuOption.delete, + child: Text(L10n.global().deletePermanentlyTooltip), + ), + ], + onSelected: (option) => _onMenuSelected(context, option), + ), + ], + ), + ], + ), + ], + ); + } + + void _onMenuSelected(BuildContext context, _AppBarMenuOption option) { + switch (option) { + case _AppBarMenuOption.delete: + _onDeletePressed(context); + break; + default: + _log.shout("[_onMenuSelected] Unknown option: $option"); + break; + } + } + + Future _onDeletePressed(BuildContext context) async { + final file = widget.streamFiles[_viewerController.currentPage]; + _log.info("[_onDeletePressed] Deleting file: ${file.logTag}"); + final count = await const DeleteLocalSelectionHandler()( + selectedFiles: [file], + isRemoveOpened: true, + ); + if (count > 0) { + Navigator.of(context).pop(); + } + } + + Widget _buildPage(BuildContext context, int index) { + if (_pageStates[index] == null) { + _pageStates[index] = _PageState(); + } + return FractionallySizedBox( + widthFactor: 1 / _viewportFraction, + child: _buildItemView(context, index), + ); + } + + Widget _buildItemView(BuildContext context, int index) { + final file = widget.streamFiles[index]; + if (file_util.isSupportedImageMime(file.mime ?? "")) { + return _buildImageView(context, index); + } else { + _log.shout("[_buildItemView] Unknown file format: ${file.mime}"); + return Container(); + } + } + + Widget _buildImageView(BuildContext context, int index) => LocalImageViewer( + file: widget.streamFiles[index], + canZoom: true, + onLoaded: () => _onImageLoaded(index), + onZoomStarted: () { + setState(() { + _isZoomed = true; + }); + }, + onZoomEnded: () { + setState(() { + _isZoomed = false; + }); + }, + ); + + void _onImageLoaded(int index) { + if (_viewerController.currentPage == index && + !_pageStates[index]!.hasLoaded) { + setState(() { + _pageStates[index]!.hasLoaded = true; + _isViewerLoaded = true; + }); + } + } + + bool get _canSwitchPage => !_isZoomed; + + var _isShowVideoControl = true; + var _isZoomed = false; + + final _viewerController = HorizontalPageViewerController(); + bool _isViewerLoaded = false; + final _pageStates = {}; + + static final _log = Logger("widget.local_file_viewer._LocalFileViewerState"); + + static const _viewportFraction = 1.05; +} + +class _PageState { + bool hasLoaded = false; +} + +enum _AppBarMenuOption { + delete, +} diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index db3269f6..42176e6e 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -16,8 +16,10 @@ import 'package:nc_photos/widget/album_share_outlier_browser.dart'; import 'package:nc_photos/widget/archive_browser.dart'; import 'package:nc_photos/widget/connect.dart'; import 'package:nc_photos/widget/dynamic_album_browser.dart'; +import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/favorite_browser.dart'; import 'package:nc_photos/widget/home.dart'; +import 'package:nc_photos/widget/local_file_viewer.dart'; import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/person_browser.dart'; import 'package:nc_photos/widget/root_picker.dart'; @@ -168,6 +170,8 @@ class _MyAppState extends State route ??= _handleAlbumPickerRoute(settings); route ??= _handleSmartAlbumBrowserRoute(settings); route ??= _handleFavoriteBrowserRoute(settings); + route ??= _handleEnhancedPhotoBrowserRoute(settings); + route ??= _handleLocalFileViewerRoute(settings); return route; } @@ -498,6 +502,34 @@ class _MyAppState extends State return null; } + Route? _handleEnhancedPhotoBrowserRoute(RouteSettings settings) { + try { + if (settings.name == EnhancedPhotoBrowser.routeName && + settings.arguments != null) { + final args = settings.arguments as EnhancedPhotoBrowserArguments; + return EnhancedPhotoBrowser.buildRoute(args); + } + } catch (e) { + _log.severe( + "[_handleEnhancedPhotoBrowserRoute] Failed while handling route", e); + } + return null; + } + + Route? _handleLocalFileViewerRoute(RouteSettings settings) { + try { + if (settings.name == LocalFileViewer.routeName && + settings.arguments != null) { + final args = settings.arguments as LocalFileViewerArguments; + return LocalFileViewer.buildRoute(args); + } + } catch (e) { + _log.severe( + "[_handleLocalFileViewerRoute] Failed while handling route", e); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); final _navigatorKey = GlobalKey(); From 80689fa4317c8e7db78cd13b6aebf3eac2b79936 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Fri, 6 May 2022 17:44:54 +0800 Subject: [PATCH 12/23] Open enhanced photo browser from notification --- .../com/nkming/nc_photos/MainActivity.kt | 69 ++++++++++++++++++- app/lib/mobile/android/activity.dart | 8 +++ app/lib/widget/my_app.dart | 6 ++ app/lib/widget/splash.dart | 29 +++++--- 4 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 app/lib/mobile/android/activity.dart diff --git a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt index c022773c..5e03d549 100644 --- a/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/nkming/nc_photos/MainActivity.kt @@ -1,12 +1,35 @@ package com.nkming.nc_photos +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log import androidx.annotation.NonNull +import com.nkming.nc_photos.plugin.NcPhotosPlugin +import com.nkming.nc_photos.plugin.UriUtil import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.net.URLEncoder + +class MainActivity : FlutterActivity(), MethodChannel.MethodCallHandler { + companion object { + private const val METHOD_CHANNEL = "com.nkming.nc_photos/activity" + + private const val TAG = "MainActivity" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.action == NcPhotosPlugin.ACTION_SHOW_IMAGE_PROCESSOR_RESULT) { + val route = getRouteFromImageProcessorResult(intent) ?: return + Log.i(TAG, "Initial route: $route") + _initialRoute = route + } + } -class MainActivity : FlutterActivity() { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel( @@ -21,6 +44,9 @@ class MainActivity : FlutterActivity() { ).setMethodCallHandler( ShareChannelHandler(this) ) + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL + ).setMethodCallHandler(this) EventChannel( flutterEngine.dartExecutor.binaryMessenger, @@ -29,4 +55,45 @@ class MainActivity : FlutterActivity() { DownloadEventCancelChannelHandler(this) ) } + + override fun onNewIntent(intent: Intent) { + if (intent.action == NcPhotosPlugin.ACTION_SHOW_IMAGE_PROCESSOR_RESULT) { + val route = getRouteFromImageProcessorResult(intent) ?: return + Log.i(TAG, "Navigate to route: $route") + flutterEngine?.navigationChannel?.pushRoute(route) + } else { + super.onNewIntent(intent) + } + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "consumeInitialRoute" -> { + result.success(_initialRoute) + _initialRoute = null + } + + else -> result.notImplemented() + } + } + + private fun getRouteFromImageProcessorResult(intent: Intent): String? { + val resultUri = + intent.getParcelableExtra( + NcPhotosPlugin.EXTRA_IMAGE_RESULT_URI + ) + if (resultUri == null) { + Log.e(TAG, "Image result uri == null") + return null + } + val filename = UriUtil.resolveFilename(this, resultUri)?.let { + URLEncoder.encode(it, Charsets.UTF_8.toString()) + } + return StringBuilder().apply { + append("/enhanced-photo-browser?") + if (filename != null) append("filename=$filename") + }.toString() + } + + private var _initialRoute: String? = null } diff --git a/app/lib/mobile/android/activity.dart b/app/lib/mobile/android/activity.dart new file mode 100644 index 00000000..16388ab3 --- /dev/null +++ b/app/lib/mobile/android/activity.dart @@ -0,0 +1,8 @@ +import 'package:flutter/services.dart'; + +class Activity { + static Future consumeInitialRoute() => + _methodChannel.invokeMethod("consumeInitialRoute"); + + static const _methodChannel = MethodChannel("com.nkming.nc_photos/activity"); +} diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index 42176e6e..d273089b 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -508,6 +508,12 @@ class _MyAppState extends State settings.arguments != null) { final args = settings.arguments as EnhancedPhotoBrowserArguments; return EnhancedPhotoBrowser.buildRoute(args); + } else if (settings.name + ?.startsWith("${EnhancedPhotoBrowser.routeName}?") == + true) { + final queries = Uri.parse(settings.name!).queryParameters; + final args = EnhancedPhotoBrowserArguments(queries["filename"]); + return EnhancedPhotoBrowser.buildRoute(args); } } catch (e) { _log.severe( diff --git a/app/lib/widget/splash.dart b/app/lib/widget/splash.dart index 076bb208..c130d8d9 100644 --- a/app/lib/widget/splash.dart +++ b/app/lib/widget/splash.dart @@ -7,6 +7,8 @@ import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/changelog.dart' as changelog; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/android/activity.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; @@ -80,18 +82,23 @@ class _SplashState extends State { ); } - void _initTimedExit() { - Future.delayed(const Duration(seconds: 1)).then((_) { - final account = Pref().getCurrentAccount(); - if (isNeedSetup()) { - Navigator.pushReplacementNamed(context, Setup.routeName); - } else if (account == null) { - Navigator.pushReplacementNamed(context, SignIn.routeName); - } else { - Navigator.pushReplacementNamed(context, Home.routeName, - arguments: HomeArguments(account)); + Future _initTimedExit() async { + await Future.delayed(const Duration(seconds: 1)); + final account = Pref().getCurrentAccount(); + if (isNeedSetup()) { + Navigator.pushReplacementNamed(context, Setup.routeName); + } else if (account == null) { + Navigator.pushReplacementNamed(context, SignIn.routeName); + } else { + Navigator.pushReplacementNamed(context, Home.routeName, + arguments: HomeArguments(account)); + if (platform_k.isAndroid) { + final initialRoute = await Activity.consumeInitialRoute(); + if (initialRoute != null) { + Navigator.pushNamed(context, initialRoute); + } } - }); + } } bool _shouldUpgrade() { From 1cf2e565ba6290f8f908ea5192f89bd725c317da Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 7 May 2022 18:20:47 +0800 Subject: [PATCH 13/23] Handle android permission request on dart side --- app/lib/mobile/android/permission_util.dart | 18 +++ .../nkming/nc_photos/plugin/NcPhotosPlugin.kt | 49 +++++++- .../plugin/PermissionChannelHandler.kt | 113 ++++++++++++++++++ plugin/lib/nc_photos_plugin.dart | 1 + plugin/lib/src/permission.dart | 62 ++++++++++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 app/lib/mobile/android/permission_util.dart create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/PermissionChannelHandler.kt create mode 100644 plugin/lib/src/permission.dart diff --git a/app/lib/mobile/android/permission_util.dart b/app/lib/mobile/android/permission_util.dart new file mode 100644 index 00000000..690d8ff0 --- /dev/null +++ b/app/lib/mobile/android/permission_util.dart @@ -0,0 +1,18 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/stream_extension.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +Future> requestPermissionsForResult( + List permissions) async { + Map? result; + final resultFuture = Permission.stream + .whereType() + .first + .then((ev) => result = ev.grantResults); + await Permission.request(permissions); + await resultFuture; + _log.info("[requestPermissionsForResult] Result: $result"); + return result!; +} + +final _log = Logger("mobile.android.permission_util"); diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt index 95203f0a..2909ab2a 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt @@ -11,7 +11,8 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.PluginRegistry class NcPhotosPlugin : FlutterPlugin, ActivityAware, - PluginRegistry.ActivityResultListener { + PluginRegistry.ActivityResultListener, + PluginRegistry.RequestPermissionsResultListener { companion object { const val ACTION_SHOW_IMAGE_PROCESSOR_RESULT = K.ACTION_SHOW_IMAGE_PROCESSOR_RESULT @@ -81,6 +82,19 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware, contentUriMethodChannel.setMethodCallHandler( ContentUriChannelHandler(flutterPluginBinding.applicationContext) ) + + permissionChannelHandler = + PermissionChannelHandler(flutterPluginBinding.applicationContext) + permissionChannel = EventChannel( + flutterPluginBinding.binaryMessenger, + PermissionChannelHandler.EVENT_CHANNEL + ) + permissionChannel.setStreamHandler(permissionChannelHandler) + permissionMethodChannel = MethodChannel( + flutterPluginBinding.binaryMessenger, + PermissionChannelHandler.METHOD_CHANNEL + ) + permissionMethodChannel.setMethodCallHandler(permissionChannelHandler) } override fun onDetachedFromEngine( @@ -94,30 +108,39 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware, mediaStoreMethodChannel.setMethodCallHandler(null) imageProcessorMethodChannel.setMethodCallHandler(null) contentUriMethodChannel.setMethodCallHandler(null) + permissionMethodChannel.setMethodCallHandler(null) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { mediaStoreChannelHandler.onAttachedToActivity(binding) + permissionChannelHandler.onAttachedToActivity(binding) pluginBinding = binding binding.addActivityResultListener(this) + binding.addRequestPermissionsResultListener(this) } override fun onReattachedToActivityForConfigChanges( binding: ActivityPluginBinding ) { mediaStoreChannelHandler.onReattachedToActivityForConfigChanges(binding) + permissionChannelHandler.onReattachedToActivityForConfigChanges(binding) pluginBinding = binding binding.addActivityResultListener(this) + binding.addRequestPermissionsResultListener(this) } override fun onDetachedFromActivity() { mediaStoreChannelHandler.onDetachedFromActivity() + permissionChannelHandler.onDetachedFromActivity() pluginBinding?.removeActivityResultListener(this) + pluginBinding?.removeRequestPermissionsResultListener(this) } override fun onDetachedFromActivityForConfigChanges() { mediaStoreChannelHandler.onDetachedFromActivityForConfigChanges() + permissionChannelHandler.onDetachedFromActivityForConfigChanges() pluginBinding?.removeActivityResultListener(this) + pluginBinding?.removeRequestPermissionsResultListener(this) } override fun onActivityResult( @@ -141,6 +164,27 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware, } } + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ): Boolean { + return try { + when (requestCode) { + K.PERMISSION_REQUEST_CODE -> { + permissionChannelHandler.onRequestPermissionsResult( + requestCode, permissions, grantResults + ) + } + + else -> false + } + } catch (e: Throwable) { + Log.e( + TAG, "Failed while onActivityResult, requestCode=$requestCode" + ) + false + } + } + private var pluginBinding: ActivityPluginBinding? = null private lateinit var lockChannel: MethodChannel @@ -151,7 +195,10 @@ class NcPhotosPlugin : FlutterPlugin, ActivityAware, private lateinit var mediaStoreMethodChannel: MethodChannel private lateinit var imageProcessorMethodChannel: MethodChannel private lateinit var contentUriMethodChannel: MethodChannel + private lateinit var permissionChannel: EventChannel + private lateinit var permissionMethodChannel: MethodChannel private lateinit var lockChannelHandler: LockChannelHandler private lateinit var mediaStoreChannelHandler: MediaStoreChannelHandler + private lateinit var permissionChannelHandler: PermissionChannelHandler } diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/PermissionChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/PermissionChannelHandler.kt new file mode 100644 index 00000000..f3dc820e --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/PermissionChannelHandler.kt @@ -0,0 +1,113 @@ +package com.nkming.nc_photos.plugin + +import android.app.Activity +import android.content.Context +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.PluginRegistry + +class PermissionChannelHandler(context: Context) : + MethodChannel.MethodCallHandler, EventChannel.StreamHandler, ActivityAware, + PluginRegistry.RequestPermissionsResultListener { + companion object { + const val EVENT_CHANNEL = "${K.LIB_ID}/permission" + const val METHOD_CHANNEL = "${K.LIB_ID}/permission_method" + + private const val TAG = "PermissionChannelHandler" + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onReattachedToActivityForConfigChanges( + binding: ActivityPluginBinding + ) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + activity = null + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ): Boolean { + return if (requestCode == K.PERMISSION_REQUEST_CODE) { + eventSink?.success(buildMap { + put("event", "RequestPermissionsResult") + put( + "grantResults", + permissions.zip(grantResults.toTypedArray()).toMap() + ) + }) + true + } else { + false + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "request" -> { + try { + request(call.argument("permissions")!!, result) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + + "hasWriteExternalStorage" -> { + try { + result.success( + PermissionUtil.hasWriteExternalStorage(context) + ) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + + "hasReadExternalStorage" -> { + try { + result.success( + PermissionUtil.hasReadExternalStorage(context) + ) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + + else -> result.notImplemented() + } + } + + private fun request( + permissions: List, result: MethodChannel.Result + ) { + if (activity == null) { + result.error("systemException", "Activity is not ready", null) + return + } + PermissionUtil.request(activity!!, *permissions.toTypedArray()) + result.success(null) + } + + private val context = context + private var activity: Activity? = null + private var eventSink: EventChannel.EventSink? = null +} diff --git a/plugin/lib/nc_photos_plugin.dart b/plugin/lib/nc_photos_plugin.dart index 0b893113..acb92591 100644 --- a/plugin/lib/nc_photos_plugin.dart +++ b/plugin/lib/nc_photos_plugin.dart @@ -7,3 +7,4 @@ export 'src/lock.dart'; export 'src/media_store.dart'; export 'src/native_event.dart'; export 'src/notification.dart'; +export 'src/permission.dart'; diff --git a/plugin/lib/src/permission.dart b/plugin/lib/src/permission.dart new file mode 100644 index 00000000..b8787e43 --- /dev/null +++ b/plugin/lib/src/permission.dart @@ -0,0 +1,62 @@ +// ignore_for_file: constant_identifier_names + +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos_plugin/src/k.dart' as k; + +class Permission { + static const READ_EXTERNAL_STORAGE = + "android.permission.READ_EXTERNAL_STORAGE"; + static const WRITE_EXTERNAL_STORAGE = + "android.permission.WRITE_EXTERNAL_STORAGE"; + + static Future request(List permissions) => + _methodChannel.invokeMethod("request", { + "permissions": permissions, + }); + + static Future hasWriteExternalStorage() async { + return (await _methodChannel + .invokeMethod("hasWriteExternalStorage"))!; + } + + static Future hasReadExternalStorage() async { + return (await _methodChannel.invokeMethod("hasReadExternalStorage"))!; + } + + static Stream get stream => _eventStream; + + static late final _eventStream = + _eventChannel.receiveBroadcastStream().map((event) { + if (event is Map) { + switch (event["event"]) { + case _eventRequestPermissionsResult: + return PermissionRequestResult( + (event["grantResults"] as Map).cast()); + + default: + _log.shout("[_eventStream] Unknown event: ${event["event"]}"); + } + } else { + return event; + } + }); + + static const _eventChannel = EventChannel("${k.libId}/permission"); + static const _methodChannel = MethodChannel("${k.libId}/permission_method"); + + static const _eventRequestPermissionsResult = "RequestPermissionsResult"; + + static final _log = Logger("plugin.permission.Permission"); +} + +class PermissionRequestResult { + static const granted = 0; + static const denied = -1; + + const PermissionRequestResult(this.grantResults); + + final Map grantResults; +} From 65d8825b6b8418823f405c9c99416486d8d07478 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 7 May 2022 18:21:10 +0800 Subject: [PATCH 14/23] Fix permission not requested before enhancing photo --- app/lib/widget/handler/enhance_handler.dart | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/lib/widget/handler/enhance_handler.dart b/app/lib/widget/handler/enhance_handler.dart index 6d4dc155..1ec07ae9 100644 --- a/app/lib/widget/handler/enhance_handler.dart +++ b/app/lib/widget/handler/enhance_handler.dart @@ -9,8 +9,11 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/help_utils.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/android/android_info.dart'; +import 'package:nc_photos/mobile/android/permission_util.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos_plugin/nc_photos_plugin.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -24,6 +27,10 @@ class EnhanceHandler { file_util.isSupportedImageFormat(file) && file.contentType != "image/gif"; Future call(BuildContext context) async { + if (!await _ensurePermission()) { + return; + } + final selected = await showDialog<_Algorithm>( context: context, builder: (context) => SimpleDialog( @@ -64,6 +71,28 @@ class EnhanceHandler { } } + Future _ensurePermission() async { + if (platform_k.isAndroid) { + if (AndroidInfo().sdkInt < AndroidVersion.R && + !await Permission.hasWriteExternalStorage()) { + final results = await requestPermissionsForResult([ + Permission.WRITE_EXTERNAL_STORAGE, + ]); + if (results[Permission.WRITE_EXTERNAL_STORAGE] != + PermissionRequestResult.granted) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(L10n.global().errorNoStoragePermission), + duration: k.snackBarDurationNormal, + )); + return false; + } else { + return true; + } + } + } + return true; + } + List<_Option> _getOptions() => [ if (platform_k.isAndroid) _Option( From e088b1dbaa7ca1ba6c19e0dad2e86bbd77927307 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 7 May 2022 18:27:26 +0800 Subject: [PATCH 15/23] Share enhanced photos --- app/lib/entity/local_file.dart | 13 +++++ app/lib/entity/local_file/data_source.dart | 57 +++++++++++++++++----- app/lib/share_handler.dart | 31 ++++++++++++ app/lib/use_case/share_local.dart | 26 ++++++++++ app/lib/widget/enhanced_photo_browser.dart | 23 +++++++++ app/lib/widget/local_file_viewer.dart | 14 ++++++ 6 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 app/lib/use_case/share_local.dart diff --git a/app/lib/entity/local_file.dart b/app/lib/entity/local_file.dart index 55d30535..7e2b30da 100644 --- a/app/lib/entity/local_file.dart +++ b/app/lib/entity/local_file.dart @@ -102,6 +102,13 @@ class LocalFileRepo { }) => dataSrc.deleteFiles(files, onFailure: onFailure); + /// See [LocalFileDataSource.shareFiles] + Future shareFiles( + List files, { + LocalFileOnFailureListener? onFailure, + }) => + dataSrc.shareFiles(files, onFailure: onFailure); + final LocalFileDataSource dataSrc; } @@ -114,4 +121,10 @@ abstract class LocalFileDataSource { List files, { LocalFileOnFailureListener? onFailure, }); + + /// Share files + Future shareFiles( + List files, { + LocalFileOnFailureListener? onFailure, + }); } diff --git a/app/lib/entity/local_file/data_source.dart b/app/lib/entity/local_file/data_source.dart index e9cbaad9..eb97e4cc 100644 --- a/app/lib/entity/local_file/data_source.dart +++ b/app/lib/entity/local_file/data_source.dart @@ -5,6 +5,7 @@ import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/mobile/android/android_info.dart'; import 'package:nc_photos/mobile/android/k.dart' as android; +import 'package:nc_photos/mobile/share.dart'; import 'package:nc_photos/object_extension.dart'; import 'package:nc_photos/stream_extension.dart'; import 'package:nc_photos_plugin/nc_photos_plugin.dart'; @@ -28,19 +29,9 @@ class LocalFileMediaStoreDataSource implements LocalFileDataSource { LocalFileOnFailureListener? onFailure, }) async { _log.info("[deleteFiles] ${files.map((f) => f.logTag).toReadableString()}"); - final uriFiles = files - .where((f) { - if (f is! LocalUriFile) { - _log.warning( - "[deleteFiles] Can't remove file not returned by this data source: $f"); - onFailure?.call(f, ArgumentError("File not supported"), null); - return false; - } else { - return true; - } - }) - .cast() - .toList(); + final uriFiles = _filterUriFiles(files, (f) { + onFailure?.call(f, ArgumentError("File not supported"), null); + }); if (AndroidInfo().sdkInt >= AndroidVersion.R) { await _deleteFiles30(uriFiles, onFailure); } else { @@ -48,6 +39,27 @@ class LocalFileMediaStoreDataSource implements LocalFileDataSource { } } + @override + shareFiles( + List files, { + LocalFileOnFailureListener? onFailure, + }) async { + _log.info("[shareFiles] ${files.map((f) => f.logTag).toReadableString()}"); + final uriFiles = _filterUriFiles(files, (f) { + onFailure?.call(f, ArgumentError("File not supported"), null); + }); + + final share = AndroidFileShare(uriFiles.map((e) => e.uri).toList(), + uriFiles.map((e) => e.mime).toList()); + try { + await share.share(); + } catch (e, stackTrace) { + for (final f in uriFiles) { + onFailure?.call(f, e, stackTrace); + } + } + } + Future _deleteFiles30( List files, LocalFileOnFailureListener? onFailure) async { assert(AndroidInfo().sdkInt >= AndroidVersion.R); @@ -79,6 +91,25 @@ class LocalFileMediaStoreDataSource implements LocalFileDataSource { } } + List _filterUriFiles( + List files, [ + void Function(LocalFile)? nonUriFileCallback, + ]) { + return files + .where((f) { + if (f is! LocalUriFile) { + _log.warning( + "[deleteFiles] Can't remove file not returned by this data source: $f"); + nonUriFileCallback?.call(f); + return false; + } else { + return true; + } + }) + .cast() + .toList(); + } + static LocalFile _toLocalFile(MediaStoreQueryResult r) => LocalUriFile( uri: r.uri, displayName: r.displayName, diff --git a/app/lib/share_handler.dart b/app/lib/share_handler.dart index 5b6a8dda..a3e98395 100644 --- a/app/lib/share_handler.dart +++ b/app/lib/share_handler.dart @@ -3,12 +3,16 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:kiwi/kiwi.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/app_db.dart'; import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/debug_util.dart'; +import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/share/data_source.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; @@ -22,6 +26,7 @@ import 'package:nc_photos/use_case/copy.dart'; import 'package:nc_photos/use_case/create_dir.dart'; import 'package:nc_photos/use_case/create_share.dart'; import 'package:nc_photos/use_case/download_file.dart'; +import 'package:nc_photos/use_case/share_local.dart'; import 'package:nc_photos/widget/processing_dialog.dart'; import 'package:nc_photos/widget/share_link_multiple_files_dialog.dart'; import 'package:nc_photos/widget/share_method_dialog.dart'; @@ -36,6 +41,32 @@ class ShareHandler { this.clearSelection, }); + Future shareLocalFiles(List files) async { + if (!isSelectionCleared) { + clearSelection?.call(); + } + final c = KiwiContainer().resolve(); + var hasShownError = false; + await ShareLocal(c)( + files, + onFailure: (f, e, stackTrace) { + if (e != null) { + _log.shout( + "[shareLocalFiles] Failed while sharing file: ${logFilename(f.logTag)}", + e, + stackTrace); + if (!hasShownError) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(e)), + duration: k.snackBarDurationNormal, + )); + hasShownError = true; + } + } + }, + ); + } + Future shareFiles(Account account, List files) async { try { final method = await _askShareMethod(); diff --git a/app/lib/use_case/share_local.dart b/app/lib/use_case/share_local.dart new file mode 100644 index 00000000..f24b3bcc --- /dev/null +++ b/app/lib/use_case/share_local.dart @@ -0,0 +1,26 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/local_file.dart'; + +class ShareLocal { + ShareLocal(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => + DiContainer.has(c, DiType.localFileRepo); + + Future call( + List files, { + LocalFileOnFailureListener? onFailure, + }) async { + var count = files.length; + await _c.localFileRepo.shareFiles(files, onFailure: (f, e, stackTrace) { + --count; + onFailure?.call(f, e, stackTrace); + }); + _log.info("[call] Shared $count files successfully"); + } + + final DiContainer _c; + + static final _log = Logger("use_case.share_local.ShareLocal"); +} diff --git a/app/lib/widget/enhanced_photo_browser.dart b/app/lib/widget/enhanced_photo_browser.dart index 8db7eb16..a9dc618d 100644 --- a/app/lib/widget/enhanced_photo_browser.dart +++ b/app/lib/widget/enhanced_photo_browser.dart @@ -11,6 +11,7 @@ import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/android/content_uri_image_provider.dart'; import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/widget/empty_list_indicator.dart'; @@ -160,6 +161,13 @@ class _EnhancedPhotoBrowserState extends State }); }, actions: [ + IconButton( + icon: const Icon(Icons.share), + tooltip: L10n.global().shareTooltip, + onPressed: () { + _onSelectionSharePressed(context); + }, + ), PopupMenuButton<_SelectionMenuOption>( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, itemBuilder: (context) => [ @@ -198,6 +206,21 @@ class _EnhancedPhotoBrowserState extends State } } + Future _onSelectionSharePressed(BuildContext context) async { + final selected = selectedListItems + .whereType<_FileListItem>() + .map((e) => e.file) + .toList(); + await ShareHandler( + context: context, + clearSelection: () { + setState(() { + clearSelectedItems(); + }); + }, + ).shareLocalFiles(selected); + } + void _onSelectionMenuSelected( BuildContext context, _SelectionMenuOption option) { switch (option) { diff --git a/app/lib/widget/local_file_viewer.dart b/app/lib/widget/local_file_viewer.dart index 8102525c..fdb0678c 100644 --- a/app/lib/widget/local_file_viewer.dart +++ b/app/lib/widget/local_file_viewer.dart @@ -3,6 +3,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/local_file.dart'; +import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/widget/handler/delete_local_selection_handler.dart'; import 'package:nc_photos/widget/horizontal_page_viewer.dart'; @@ -108,6 +109,13 @@ class _LocalFileViewerState extends State { shadowColor: Colors.transparent, foregroundColor: Colors.white.withOpacity(.87), actions: [ + IconButton( + icon: const Icon(Icons.share), + tooltip: L10n.global().shareTooltip, + onPressed: () { + _onSharePressed(context); + }, + ), PopupMenuButton<_AppBarMenuOption>( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, itemBuilder: (context) => [ @@ -126,6 +134,12 @@ class _LocalFileViewerState extends State { ); } + Future _onSharePressed(BuildContext context) async { + final file = widget.streamFiles[_viewerController.currentPage]; + _log.info("[_onSharePressed] Sharing file: ${file.logTag}"); + await ShareHandler(context: context).shareLocalFiles([file]); + } + void _onMenuSelected(BuildContext context, _AppBarMenuOption option) { switch (option) { case _AppBarMenuOption.delete: From 192fe923a2323438408fc7bc08ad597c80fb5914 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sun, 8 May 2022 22:18:47 +0800 Subject: [PATCH 16/23] Tweak style --- app/lib/widget/handler/enhance_handler.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/app/lib/widget/handler/enhance_handler.dart b/app/lib/widget/handler/enhance_handler.dart index 1ec07ae9..3b712b58 100644 --- a/app/lib/widget/handler/enhance_handler.dart +++ b/app/lib/widget/handler/enhance_handler.dart @@ -36,6 +36,7 @@ class EnhanceHandler { builder: (context) => SimpleDialog( children: _getOptions() .map((o) => SimpleDialogOption( + padding: const EdgeInsets.all(0), child: ListTile( title: Text(o.title), subtitle: o.subtitle?.run((t) => Text(t)), From 6d0f612c7b15e48d7e878490c7ffdd6e2c475e31 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 9 May 2022 22:51:37 +0800 Subject: [PATCH 17/23] Enhance original file instead of cached preview --- app/lib/widget/handler/enhance_handler.dart | 27 ++--- .../com/nkming/nc_photos/plugin/Event.kt | 2 - .../com/nkming/nc_photos/plugin/Exception.kt | 2 + .../plugin/ImageProcessorChannelHandler.kt | 11 +- .../nc_photos/plugin/ImageProcessorService.kt | 102 +++++++++++++++--- .../com/nkming/nc_photos/plugin/Util.kt | 10 ++ plugin/lib/src/image_processor.dart | 9 +- 7 files changed, 123 insertions(+), 40 deletions(-) diff --git a/app/lib/widget/handler/enhance_handler.dart b/app/lib/widget/handler/enhance_handler.dart index 3b712b58..420ae6f5 100644 --- a/app/lib/widget/handler/enhance_handler.dart +++ b/app/lib/widget/handler/enhance_handler.dart @@ -2,9 +2,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; -import 'package:nc_photos/api/api_util.dart' as api_util; import 'package:nc_photos/app_localizations.dart'; -import 'package:nc_photos/cache_manager_util.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/help_utils.dart'; @@ -64,10 +62,15 @@ class EnhanceHandler { return; } _log.info("[call] Selected: ${selected.name}"); - final imageUri = await _getFileUri(); switch (selected) { case _Algorithm.zeroDce: - await ImageProcessor.zeroDce(imageUri.toString(), file.filename); + await ImageProcessor.zeroDce( + "${account.url}/${file.path}", + file.filename, + headers: { + "Authorization": Api.getAuthorizationHeaderValue(account), + }, + ); break; } } @@ -104,22 +107,6 @@ class EnhanceHandler { ), ]; - Future _getFileUri() async { - final f = await LargeImageCacheManager.inst.getSingleFile( - api_util.getFilePreviewUrl( - account, - file, - width: k.photoLargeSize, - height: k.photoLargeSize, - a: true, - ), - headers: { - "Authorization": Api.getAuthorizationHeaderValue(account), - }, - ); - return f.absolute.uri; - } - final Account account; final File file; diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Event.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Event.kt index 651aca66..53826f9b 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Event.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Event.kt @@ -5,11 +5,9 @@ import android.net.Uri interface MessageEvent data class ImageProcessorCompletedEvent( - val image: Uri, val result: Uri, ) : MessageEvent data class ImageProcessorFailedEvent( - val image: Uri, val exception: Throwable, ) : MessageEvent diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Exception.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Exception.kt index e132c15a..a43eda4a 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Exception.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Exception.kt @@ -1,3 +1,5 @@ package com.nkming.nc_photos.plugin class PermissionException(message: String) : Exception(message) + +class HttpException(statusCode: Int, message: String): Exception(message) diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt index a299d5d8..22348686 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt @@ -20,7 +20,8 @@ class ImageProcessorChannelHandler(context: Context) : "zeroDce" -> { try { zeroDce( - call.argument("image")!!, + call.argument("fileUrl")!!, + call.argument("headers"), call.argument("filename")!!, result ) @@ -42,14 +43,18 @@ class ImageProcessorChannelHandler(context: Context) : } private fun zeroDce( - image: String, filename: String, result: MethodChannel.Result + fileUrl: String, headers: Map?, filename: String, + result: MethodChannel.Result ) { val intent = Intent(context, ImageProcessorService::class.java).apply { putExtra( ImageProcessorService.EXTRA_METHOD, ImageProcessorService.METHOD_ZERO_DCE ) - putExtra(ImageProcessorService.EXTRA_IMAGE, image) + putExtra(ImageProcessorService.EXTRA_FILE_URL, fileUrl) + putExtra( + ImageProcessorService.EXTRA_HEADERS, + headers?.let { HashMap(it) }) putExtra(ImageProcessorService.EXTRA_FILENAME, filename) } ContextCompat.startForegroundService(context, intent) diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt index dfca302d..fc2c9177 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt @@ -18,12 +18,16 @@ import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.nkming.nc_photos.plugin.image_processor.ZeroDce +import java.io.File +import java.net.HttpURLConnection +import java.net.URL class ImageProcessorService : Service() { companion object { const val EXTRA_METHOD = "method" const val METHOD_ZERO_DCE = "zero-dce" - const val EXTRA_IMAGE = "image" + const val EXTRA_FILE_URL = "fileUrl" + const val EXTRA_HEADERS = "headers" const val EXTRA_FILENAME = "filename" private const val NOTIFICATION_ID = @@ -45,6 +49,7 @@ class ImageProcessorService : Service() { super.onCreate() wakeLock.acquire() createNotificationChannel() + cleanUp() } override fun onDestroy() { @@ -55,7 +60,7 @@ class ImageProcessorService : Service() { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { assert(intent.hasExtra(EXTRA_METHOD)) - assert(intent.hasExtra(EXTRA_IMAGE)) + assert(intent.hasExtra(EXTRA_FILE_URL)) if (!isForeground) { try { startForeground(NOTIFICATION_ID, buildNotification()) @@ -73,19 +78,23 @@ class ImageProcessorService : Service() { Log.e(TAG, "Unknown method: $method") // we can't call stopSelf here as it'll stop the service even if // there are commands running in the bg - addCommand( - ImageProcessorCommand(startId, "null", Uri.EMPTY, "") - ) + addCommand(ImageProcessorCommand(startId, "null", "", null, "")) } } return START_REDELIVER_INTENT } private fun onZeroDce(startId: Int, extras: Bundle) { - val imageUri = Uri.parse(extras.getString(EXTRA_IMAGE)!!) + val fileUrl = extras.getString(EXTRA_FILE_URL)!! + + @Suppress("Unchecked_cast") + val headers = + extras.getSerializable(EXTRA_HEADERS) as HashMap? val filename = extras.getString(EXTRA_FILENAME)!! addCommand( - ImageProcessorCommand(startId, METHOD_ZERO_DCE, imageUri, filename) + ImageProcessorCommand( + startId, METHOD_ZERO_DCE, fileUrl, headers, filename + ) ) } @@ -186,6 +195,17 @@ class ImageProcessorService : Service() { } } + /** + * Clean up temp files in case the service ended prematurely last time + */ + private fun cleanUp() { + try { + getTempDir(this).deleteRecursively() + } catch (e: Throwable) { + Log.e(TAG, "[cleanUp] Failed while cleanUp", e) + } + } + private var isForeground = false private val cmds = mutableListOf() private var cmdTask: ImageProcessorCommandTask? = null @@ -205,7 +225,8 @@ class ImageProcessorService : Service() { private data class ImageProcessorCommand( val startId: Int, val method: String, - val uri: Uri, + val fileUrl: String, + val headers: Map?, val filename: String, val args: Map = mapOf(), ) @@ -222,18 +243,62 @@ private open class ImageProcessorCommandTask(context: Context) : ): MessageEvent { val cmd = params[0]!! return try { + val outUri = handleCommand(cmd) + ImageProcessorCompletedEvent(outUri) + } catch (e: Throwable) { + Log.e(TAG, "[doInBackground] Failed while handleCommand", e) + ImageProcessorFailedEvent(e) + } + } + + private fun handleCommand(cmd: ImageProcessorCommand): Uri { + val file = downloadFile(cmd.fileUrl, cmd.headers) + return try { + val fileUri = Uri.fromFile(file) val output = when (cmd.method) { ImageProcessorService.METHOD_ZERO_DCE -> ZeroDce(context).infer( - cmd.uri + fileUri ) else -> throw IllegalArgumentException( "Unknown method: ${cmd.method}" ) } - val uri = saveBitmap(output, cmd.filename) - ImageProcessorCompletedEvent(cmd.uri, uri) - } catch (e: Throwable) { - ImageProcessorFailedEvent(cmd.uri, e) + saveBitmap(output, cmd.filename) + } finally { + file.delete() + } + } + + private fun downloadFile( + fileUrl: String, headers: Map? + ): File { + Log.i(TAG, "[downloadFile] $fileUrl") + return (URL(fileUrl).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + instanceFollowRedirects = true + connectTimeout = 8000 + readTimeout = 15000 + for (entry in (headers ?: mapOf()).entries) { + setRequestProperty(entry.key, entry.value) + } + }.use { + val responseCode = it.responseCode + if (responseCode / 100 == 2) { + val file = + File.createTempFile("img", null, getTempDir(context)) + file.outputStream().use { oStream -> + it.inputStream.copyTo(oStream) + } + file + } else { + Log.e( + TAG, + "[downloadFile] Failed downloading file: HTTP$responseCode" + ) + throw HttpException( + responseCode, "Failed downloading file (HTTP$responseCode)" + ) + } } } @@ -248,3 +313,14 @@ private open class ImageProcessorCommandTask(context: Context) : @SuppressLint("StaticFieldLeak") private val context = context } + +private fun getTempDir(context: Context): File { + val f = File(context.cacheDir, "imageProcessor") + if (!f.exists()) { + f.mkdirs() + } else if (!f.isDirectory) { + f.delete() + f.mkdirs() + } + return f +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt index 79100b89..fa72fd3e 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/Util.kt @@ -2,6 +2,7 @@ package com.nkming.nc_photos.plugin import android.app.PendingIntent import android.os.Build +import java.net.HttpURLConnection fun getPendingIntentFlagImmutable(): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) @@ -12,3 +13,12 @@ fun getPendingIntentFlagMutable(): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 } + +inline fun HttpURLConnection.use(block: (HttpURLConnection) -> T): T { + try { + connect() + return block(this) + } finally { + disconnect() + } +} diff --git a/plugin/lib/src/image_processor.dart b/plugin/lib/src/image_processor.dart index f47568b0..d2d69c6f 100644 --- a/plugin/lib/src/image_processor.dart +++ b/plugin/lib/src/image_processor.dart @@ -4,9 +4,14 @@ import 'package:flutter/services.dart'; import 'package:nc_photos_plugin/src/k.dart' as k; class ImageProcessor { - static Future zeroDce(String image, String filename) => + static Future zeroDce( + String fileUrl, + String filename, { + Map? headers, + }) => _methodChannel.invokeMethod("zeroDce", { - "image": image, + "fileUrl": fileUrl, + "headers": headers, "filename": filename, }); From 9469ee1d6f4061b7e7cb82473624b4739dc2ea18 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 9 May 2022 22:54:00 +0800 Subject: [PATCH 18/23] Retain EXIF in enhanced files --- plugin/android/build.gradle | 1 + .../nc_photos/plugin/ImageProcessorService.kt | 152 +++++++++++++++++- 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/plugin/android/build.gradle b/plugin/android/build.gradle index f68b539c..ffc3918a 100644 --- a/plugin/android/build.gradle +++ b/plugin/android/build.gradle @@ -54,6 +54,7 @@ dependencies { coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5" implementation "androidx.annotation:annotation:1.3.0" implementation "androidx.core:core-ktx:1.7.0" + implementation "androidx.exifinterface:exifinterface:1.3.3" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'org.tensorflow:tensorflow-lite:2.8.0' } diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt index fc2c9177..e3c75def 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt @@ -17,6 +17,7 @@ import android.util.Log import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.exifinterface.media.ExifInterface import com.nkming.nc_photos.plugin.image_processor.ZeroDce import java.io.File import java.net.HttpURLConnection @@ -235,6 +236,115 @@ private data class ImageProcessorCommand( private open class ImageProcessorCommandTask(context: Context) : AsyncTask() { companion object { + private val exifTagOfInterests = listOf( + ExifInterface.TAG_IMAGE_DESCRIPTION, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_ORIENTATION, + ExifInterface.TAG_X_RESOLUTION, + ExifInterface.TAG_Y_RESOLUTION, + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_ARTIST, + ExifInterface.TAG_COPYRIGHT, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_F_NUMBER, + ExifInterface.TAG_EXPOSURE_PROGRAM, + ExifInterface.TAG_SPECTRAL_SENSITIVITY, + ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY, + ExifInterface.TAG_OECF, + ExifInterface.TAG_SENSITIVITY_TYPE, + ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY, + ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX, + ExifInterface.TAG_ISO_SPEED, + ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY, + ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ, + ExifInterface.TAG_EXIF_VERSION, + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_OFFSET_TIME, + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, + ExifInterface.TAG_SHUTTER_SPEED_VALUE, + ExifInterface.TAG_APERTURE_VALUE, + ExifInterface.TAG_BRIGHTNESS_VALUE, + ExifInterface.TAG_EXPOSURE_BIAS_VALUE, + ExifInterface.TAG_MAX_APERTURE_VALUE, + ExifInterface.TAG_SUBJECT_DISTANCE, + ExifInterface.TAG_METERING_MODE, + ExifInterface.TAG_LIGHT_SOURCE, + ExifInterface.TAG_FLASH, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_SUBJECT_AREA, + ExifInterface.TAG_MAKER_NOTE, + ExifInterface.TAG_USER_COMMENT, + ExifInterface.TAG_SUBSEC_TIME, + ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, + ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, + ExifInterface.TAG_FLASHPIX_VERSION, + ExifInterface.TAG_FLASH_ENERGY, + ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE, + ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT, + ExifInterface.TAG_SUBJECT_LOCATION, + ExifInterface.TAG_EXPOSURE_INDEX, + ExifInterface.TAG_SENSING_METHOD, + ExifInterface.TAG_FILE_SOURCE, + ExifInterface.TAG_SCENE_TYPE, + ExifInterface.TAG_CFA_PATTERN, + ExifInterface.TAG_CUSTOM_RENDERED, + ExifInterface.TAG_EXPOSURE_MODE, + ExifInterface.TAG_WHITE_BALANCE, + ExifInterface.TAG_DIGITAL_ZOOM_RATIO, + ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM, + ExifInterface.TAG_SCENE_CAPTURE_TYPE, + ExifInterface.TAG_GAIN_CONTROL, + ExifInterface.TAG_CONTRAST, + ExifInterface.TAG_SATURATION, + ExifInterface.TAG_SHARPNESS, + ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION, + ExifInterface.TAG_SUBJECT_DISTANCE_RANGE, + ExifInterface.TAG_IMAGE_UNIQUE_ID, + ExifInterface.TAG_CAMERA_OWNER_NAME, + ExifInterface.TAG_BODY_SERIAL_NUMBER, + ExifInterface.TAG_LENS_SPECIFICATION, + ExifInterface.TAG_LENS_MAKE, + ExifInterface.TAG_LENS_MODEL, + ExifInterface.TAG_GAMMA, + ExifInterface.TAG_GPS_VERSION_ID, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_GPS_SATELLITES, + ExifInterface.TAG_GPS_STATUS, + ExifInterface.TAG_GPS_MEASURE_MODE, + ExifInterface.TAG_GPS_DOP, + ExifInterface.TAG_GPS_SPEED_REF, + ExifInterface.TAG_GPS_SPEED, + ExifInterface.TAG_GPS_TRACK_REF, + ExifInterface.TAG_GPS_TRACK, + ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + ExifInterface.TAG_GPS_IMG_DIRECTION, + ExifInterface.TAG_GPS_MAP_DATUM, + ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + ExifInterface.TAG_GPS_DEST_LATITUDE, + ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, + ExifInterface.TAG_GPS_DEST_LONGITUDE, + ExifInterface.TAG_GPS_DEST_BEARING_REF, + ExifInterface.TAG_GPS_DEST_BEARING, + ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + ExifInterface.TAG_GPS_DEST_DISTANCE, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_AREA_INFORMATION, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_DIFFERENTIAL, + ExifInterface.TAG_GPS_H_POSITIONING_ERROR, + ) + private const val TAG = "ImageProcessorCommandTask" } @@ -263,7 +373,7 @@ private open class ImageProcessorCommandTask(context: Context) : "Unknown method: ${cmd.method}" ) } - saveBitmap(output, cmd.filename) + saveBitmap(output, cmd.filename, file) } finally { file.delete() } @@ -302,12 +412,42 @@ private open class ImageProcessorCommandTask(context: Context) : } } - private fun saveBitmap(bitmap: Bitmap, filename: String): Uri { - return MediaStoreUtil.writeFileToDownload( - context, { - bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) - }, filename, "Photos (for Nextcloud)/Enhanced Photos" + private fun saveBitmap( + bitmap: Bitmap, filename: String, srcFile: File + ): Uri { + val outFile = File.createTempFile("out", null, getTempDir(context)) + outFile.outputStream().use { + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, it) + } + + // then copy the EXIF tags + try { + val iExif = ExifInterface(srcFile) + val oExif = ExifInterface(outFile) + copyExif(iExif, oExif) + oExif.saveAttributes() + } catch (e: Throwable) { + Log.e(TAG, "[copyExif] Failed while saving EXIF", e) + } + + // move file to user accessible storage + val uri = MediaStoreUtil.copyFileToDownload( + context, Uri.fromFile(outFile), filename, + "Photos (for Nextcloud)/Enhanced Photos" ) + outFile.delete() + return uri + } + + private fun copyExif(from: ExifInterface, to: ExifInterface) { + // only a subset will be copied over + for (t in exifTagOfInterests) { + try { + from.getAttribute(t)?.let { to.setAttribute(t, it) } + } catch (e: Throwable) { + Log.e(TAG, "[copyExif] Failed while copying tag: $t", e) + } + } } @SuppressLint("StaticFieldLeak") From c378bffaedb05f53bc86494044b88c06488b7ecf Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 10 May 2022 19:08:06 +0800 Subject: [PATCH 19/23] Simplify code --- .../com/nkming/nc_photos/plugin/MediaStoreUtil.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt index e749842a..ac178c0a 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt @@ -99,14 +99,8 @@ interface MediaStoreUtil { } val contentUri = resolver.insert(collection, details) - - resolver.openFileDescriptor(contentUri!!, "w", null).use { pfd -> - // Write data into the pending audio file. - BufferedOutputStream( - FileOutputStream(pfd!!.fileDescriptor) - ).use { stream -> - writer(stream) - } + resolver.openOutputStream(contentUri!!).use { + writer(it!!) } return contentUri } From 2275da0170a8c0230f833912790afaf3876eaddc Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 10 May 2022 19:09:01 +0800 Subject: [PATCH 20/23] Fix write file not working on Q after disabling scoped storage --- .../kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt index ac178c0a..a5268e0a 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/MediaStoreUtil.kt @@ -68,15 +68,15 @@ interface MediaStoreUtil { context: Context, writer: (OutputStream) -> Unit, filename: String, subDir: String? = null ): Uri { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - writeFileToDownload29(context, writer, filename, subDir) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + writeFileToDownload30(context, writer, filename, subDir) } else { writeFileToDownload0(context, writer, filename, subDir) } } @RequiresApi(Build.VERSION_CODES.Q) - private fun writeFileToDownload29( + private fun writeFileToDownload30( context: Context, writer: (OutputStream) -> Unit, filename: String, subDir: String? ): Uri { From c84083886bb74be920fb9e20e5c8761974c129ce Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Tue, 10 May 2022 19:11:56 +0800 Subject: [PATCH 21/23] Check permission before querying local files --- app/lib/widget/enhanced_photo_browser.dart | 57 +++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/app/lib/widget/enhanced_photo_browser.dart b/app/lib/widget/enhanced_photo_browser.dart index a9dc618d..2a344249 100644 --- a/app/lib/widget/enhanced_photo_browser.dart +++ b/app/lib/widget/enhanced_photo_browser.dart @@ -9,7 +9,10 @@ import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/mobile/android/android_info.dart'; import 'package:nc_photos/mobile/android/content_uri_image_provider.dart'; +import 'package:nc_photos/mobile/android/permission_util.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/snack_bar_manager.dart'; @@ -58,8 +61,18 @@ class _EnhancedPhotoBrowserState extends State @override initState() { super.initState(); - _initBloc(); _thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0); + _ensurePermission().then((value) { + if (value) { + _initBloc(); + } else { + if (mounted) { + setState(() { + _isNoPermission = true; + }); + } + } + }); } @override @@ -94,7 +107,23 @@ class _EnhancedPhotoBrowserState extends State } Widget _buildContent(BuildContext context, ScanLocalDirBlocState state) { - if (state is ScanLocalDirBlocSuccess && itemStreamListItems.isEmpty) { + if (_isNoPermission) { + return Column( + children: [ + AppBar( + title: Text(L10n.global().collectionEnhancedPhotosLabel), + elevation: 0, + ), + Expanded( + child: EmptyListIndicator( + icon: Icons.folder_off_outlined, + text: L10n.global().errorNoStoragePermission, + ), + ), + ], + ); + } else if (state is ScanLocalDirBlocSuccess && + itemStreamListItems.isEmpty) { return Column( children: [ AppBar( @@ -300,6 +329,29 @@ class _EnhancedPhotoBrowserState extends State arguments: LocalFileViewerArguments(_backingFiles, index)); } + Future _ensurePermission() async { + if (platform_k.isAndroid) { + if (AndroidInfo().sdkInt >= AndroidVersion.R) { + if (!await Permission.hasReadExternalStorage()) { + final results = await requestPermissionsForResult([ + Permission.READ_EXTERNAL_STORAGE, + ]); + return results[Permission.READ_EXTERNAL_STORAGE] == + PermissionRequestResult.granted; + } + } else { + if (!await Permission.hasWriteExternalStorage()) { + final results = await requestPermissionsForResult([ + Permission.WRITE_EXTERNAL_STORAGE, + ]); + return results[Permission.WRITE_EXTERNAL_STORAGE] == + PermissionRequestResult.granted; + } + } + } + return true; + } + void _reqQuery() { _bloc.add(const ScanLocalDirBlocQuery( ["Download/Photos (for Nextcloud)/Enhanced Photos"])); @@ -312,6 +364,7 @@ class _EnhancedPhotoBrowserState extends State var _isFirstRun = true; var _thumbZoomLevel = 0; int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel); + var _isNoPermission = false; static final _log = Logger("widget.enhanced_photo_browser._EnhancedPhotoBrowserState"); From 91d4d3be81518accd299fc6b447490bdf9c35f93 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 11 May 2022 00:20:26 +0800 Subject: [PATCH 22/23] Tweak resolution of enhanced photos --- .../com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt index 21f9af55..62549084 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/ZeroDce.kt @@ -17,6 +17,9 @@ class ZeroDce(context: Context) { private const val WIDTH = 300 private const val HEIGHT = 200 private const val ITERATION = 8 + + private const val MAX_WIDTH = 2048 + private const val MAX_HEIGHT = 1536 } fun infer(imageUri: Uri): Bitmap { @@ -51,7 +54,7 @@ class ZeroDce(context: Context) { Log.i(TAG, "Enhancing image, iteration: $iteration") // downscale original to prevent OOM val resized = BitmapUtil.loadImage( - context, imageUri, 1920, 1080, BitmapResizeMethod.FIT, + context, imageUri, MAX_WIDTH, MAX_HEIGHT, BitmapResizeMethod.FIT, isAllowSwapSide = true, shouldUpscale = false ) // resize aMaps From d1aa3ffe7f11a86f55dcc746384d6a253e93c3b8 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 11 May 2022 02:08:17 +0800 Subject: [PATCH 23/23] Cancel queued photo enhancement task --- .../nc_photos/plugin/ImageProcessorService.kt | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt index e3c75def..713e72da 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorService.kt @@ -31,6 +31,8 @@ class ImageProcessorService : Service() { const val EXTRA_HEADERS = "headers" const val EXTRA_FILENAME = "filename" + private const val ACTION_CANCEL = "cancel" + private const val NOTIFICATION_ID = K.IMAGE_PROCESSOR_SERVICE_NOTIFICATION_ID private const val RESULT_NOTIFICATION_ID = @@ -60,6 +62,20 @@ class ImageProcessorService : Service() { } override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + when (intent.action) { + ACTION_CANCEL -> onCancel(startId) + else -> onNewImage(intent, startId) + } + return START_REDELIVER_INTENT + } + + private fun onCancel(startId: Int) { + Log.i(TAG, "[onCancel] Cancel requested") + cmdTask?.cancel(false) + stopSelf(startId) + } + + private fun onNewImage(intent: Intent, startId: Int) { assert(intent.hasExtra(EXTRA_METHOD)) assert(intent.hasExtra(EXTRA_FILE_URL)) if (!isForeground) { @@ -82,7 +98,6 @@ class ImageProcessorService : Service() { addCommand(ImageProcessorCommand(startId, "null", "", null, "")) } } - return START_REDELIVER_INTENT } private fun onZeroDce(startId: Int, extras: Bundle) { @@ -111,10 +126,20 @@ class ImageProcessorService : Service() { } private fun buildNotification(content: String? = null): Notification { + val cancelIntent = + Intent(this, ImageProcessorService::class.java).apply { + action = ACTION_CANCEL + } + val cancelPendingIntent = PendingIntent.getService( + this, 0, cancelIntent, getPendingIntentFlagImmutable() + ) return NotificationCompat.Builder(this, CHANNEL_ID).run { setSmallIcon(R.drawable.outline_auto_fix_high_white_24) setContentTitle("Processing image") if (content != null) setContentText(content) + addAction( + 0, getString(android.R.string.cancel), cancelPendingIntent + ) build() } } @@ -171,7 +196,8 @@ class ImageProcessorService : Service() { notifyResult(result) cmds.removeFirst() stopSelf(cmd.startId) - if (cmds.isNotEmpty()) { + @Suppress("Deprecation") + if (cmds.isNotEmpty() && !isCancelled) { runCommand() } else { cmdTask = null @@ -363,6 +389,7 @@ private open class ImageProcessorCommandTask(context: Context) : private fun handleCommand(cmd: ImageProcessorCommand): Uri { val file = downloadFile(cmd.fileUrl, cmd.headers) + handleCancel() return try { val fileUri = Uri.fromFile(file) val output = when (cmd.method) { @@ -373,6 +400,7 @@ private open class ImageProcessorCommandTask(context: Context) : "Unknown method: ${cmd.method}" ) } + handleCancel() saveBitmap(output, cmd.filename, file) } finally { file.delete() @@ -450,6 +478,13 @@ private open class ImageProcessorCommandTask(context: Context) : } } + private fun handleCancel() { + if (isCancelled) { + Log.i(TAG, "[handleCancel] Canceled") + throw InterruptedException() + } + } + @SuppressLint("StaticFieldLeak") private val context = context }