Overhaul and migrate MediaStore

This commit is contained in:
Ming Ming 2022-05-03 18:44:48 +08:00
parent 8095a15972
commit 4beb93c552
18 changed files with 354 additions and 224 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,44 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:nc_photos/exception.dart';
class MediaStore {
static Future<String> saveFileToDownload(
String fileName, Uint8List fileContent) async {
try {
return (await _channel
.invokeMethod<String>("saveFileToDownload", <String, dynamic>{
"fileName": fileName,
"content": fileContent,
}))!;
} on PlatformException catch (e) {
if (e.code == _exceptionCodePermissionError) {
throw PermissionException();
} else {
rethrow;
}
}
}
static Future<String> copyFileToDownload(
String toFileName, String fromFilePath) async {
try {
return (await _channel
.invokeMethod<String>("copyFileToDownload", <String, dynamic>{
"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");
}

View file

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

View file

@ -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<String> _saveFileAndroid(String filename, Uint8List content) =>
MediaStore.saveFileToDownload(filename, content);
MediaStore.saveFileToDownload(content, filename);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
}
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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<String> saveFileToDownload(
Uint8List content,
String filename, {
String? subDir,
}) async {
try {
return (await _methodChannel
.invokeMethod<String>("saveFileToDownload", <String, dynamic>{
"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<String> copyFileToDownload(
String fromFile, {
String? filename,
String? subDir,
}) async {
try {
return (await _methodChannel
.invokeMethod<String>("copyFileToDownload", <String, dynamic>{
"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";
}