diff --git a/android/app/build.gradle b/android/app/build.gradle index 4f4416a..8a27916 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -87,4 +87,6 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'com.github.DylanCaiCoding:MMKV-KTX:1.2.16' implementation 'androidx.documentfile:documentfile:1.0.1' + implementation "org.greenrobot:eventbus:3.3.1" + implementation "com.anggrayudi:storage:1.5.5" } diff --git a/android/app/src/main/kotlin/com/lifegpc/ehf/MainActivity.kt b/android/app/src/main/kotlin/com/lifegpc/ehf/MainActivity.kt index cd90c0c..d98ff23 100644 --- a/android/app/src/main/kotlin/com/lifegpc/ehf/MainActivity.kt +++ b/android/app/src/main/kotlin/com/lifegpc/ehf/MainActivity.kt @@ -2,124 +2,41 @@ package com.lifegpc.ehf import android.app.Activity import android.content.Intent -import android.net.Uri -import androidx.documentfile.provider.DocumentFile -import com.lifegpc.ehf.annotation.ChannelMethod -import com.lifegpc.ehf.data.mmkv.SAFSettings -import com.lifegpc.ehf.util.ClipboardUtils -import com.lifegpc.ehf.util.MethodChannelUtils +import com.lifegpc.ehf.eventbus.SAFAuthEvent +import com.lifegpc.ehf.platform.ClipboardPlugin +import com.lifegpc.ehf.platform.MethodChannelUtils +import com.lifegpc.ehf.platform.SAFPlugin import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel +import org.greenrobot.eventbus.EventBus class MainActivity : FlutterActivity() { - private val safAuthorizationCode = 0x10086 - private var safAuthorizationResult: MethodChannel.Result? = null - private var afterAuthSuccess: (() -> Unit)? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannelUtils.registerMethodChannel( "lifegpc.eh_downloader_flutter/saf", flutterEngine, - this + SAFPlugin(this) ) MethodChannelUtils.registerMethodChannel( "lifegpc.eh_downloader_flutter/clipboard", flutterEngine, - ClipboardUtils + ClipboardPlugin ) } - @ChannelMethod(responseManually = true) - private fun saveFile( - channelResult: MethodChannel.Result, - filename: String, - dir: String, - mimeType: String, - content: ByteArray - ) { - this.safAuthorizationResult = channelResult - if (!checkSafPermission()) { - authSAF(channelResult) { - doWriteFile(filename, dir, mimeType, content) - channelResult.success(null) - } - } else { - doWriteFile(filename, dir, mimeType, content) - channelResult.success(null) - } - } - - private fun doWriteFile(filename: String, dir: String, mimeType: String, content: ByteArray) { - var documentDir = DocumentFile.fromTreeUri(this, Uri.parse(SAFSettings.authorizedUri))!! - val pathPart = dir.split('/', '\\') - pathPart.forEach { - if (it.isNotEmpty()) { - documentDir = documentDir.createDirectory(it)!! - } - } - - val filenameWithoutExtension = if (filename.indexOf('.') != -1) { - filename.substring(0, filename.lastIndexOf('.')) - } else { - filename - } - val file = documentDir.createFile(mimeType, filenameWithoutExtension)!! - val uri = file.uri - contentResolver.openOutputStream(uri)!!.use { - it.write(content) - } - } - - private fun authSAF(result: MethodChannel.Result, onSuccess: (() -> Unit)? = null) { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - startActivityForResult(intent, safAuthorizationCode) - safAuthorizationResult = result - this.afterAuthSuccess = onSuccess - } - - private fun onSafAuthSuccess(data: Intent) { - // 保存权限 - val resultData = data.data!! - val takeFlags = - (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - contentResolver.takePersistableUriPermission(resultData, takeFlags) - SAFSettings.authorizedUri = resultData.toString() - } - - private fun checkSafPermission(): Boolean { - val dir = SAFSettings.authorizedUri - if (dir.isBlank()) return false - - val uri = Uri.parse(dir) - - return try { - val flags = - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - contentResolver.takePersistableUriPermission(uri, flags) - DocumentFile.fromTreeUri(this, uri) - true - } catch (e: Exception) { - e.printStackTrace() - false - } - } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) when (requestCode) { - safAuthorizationCode -> { + SAFPlugin.safAuthorizationCode -> { if (resultCode == Activity.RESULT_OK) { // 授权成功 - onSafAuthSuccess(data!!) - afterAuthSuccess?.invoke() + EventBus.getDefault().post(SAFAuthEvent(true, data!!)) } else { // 授权失败 - safAuthorizationResult?.error("Permission denied", null, null) - safAuthorizationResult = null + EventBus.getDefault().post(SAFAuthEvent(false, null)) } - afterAuthSuccess = null } } } diff --git a/android/app/src/main/kotlin/com/lifegpc/ehf/eventbus/SAFAuthEvent.kt b/android/app/src/main/kotlin/com/lifegpc/ehf/eventbus/SAFAuthEvent.kt new file mode 100644 index 0000000..6343598 --- /dev/null +++ b/android/app/src/main/kotlin/com/lifegpc/ehf/eventbus/SAFAuthEvent.kt @@ -0,0 +1,8 @@ +package com.lifegpc.ehf.eventbus + +import android.content.Intent + +data class SAFAuthEvent( + val success: Boolean = true, + val data: Intent?, +) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/lifegpc/ehf/util/ClipboardUtils.kt b/android/app/src/main/kotlin/com/lifegpc/ehf/platform/ClipboardPlugin.kt similarity index 97% rename from android/app/src/main/kotlin/com/lifegpc/ehf/util/ClipboardUtils.kt rename to android/app/src/main/kotlin/com/lifegpc/ehf/platform/ClipboardPlugin.kt index 04b4860..b972e28 100644 --- a/android/app/src/main/kotlin/com/lifegpc/ehf/util/ClipboardUtils.kt +++ b/android/app/src/main/kotlin/com/lifegpc/ehf/platform/ClipboardPlugin.kt @@ -1,4 +1,4 @@ -package com.lifegpc.ehf.util +package com.lifegpc.ehf.platform import android.content.ClipData import android.content.ClipboardManager @@ -12,7 +12,7 @@ import com.lifegpc.ehf.annotation.ChannelMethod import java.io.File import java.util.UUID -object ClipboardUtils { +object ClipboardPlugin { private const val AUTHORITY = "com.lifegpc.ehf.ClipboardImageProvider" @ChannelMethod diff --git a/android/app/src/main/kotlin/com/lifegpc/ehf/util/MethodChannelUtils.kt b/android/app/src/main/kotlin/com/lifegpc/ehf/platform/MethodChannelUtils.kt similarity index 99% rename from android/app/src/main/kotlin/com/lifegpc/ehf/util/MethodChannelUtils.kt rename to android/app/src/main/kotlin/com/lifegpc/ehf/platform/MethodChannelUtils.kt index 6758ae8..af46479 100644 --- a/android/app/src/main/kotlin/com/lifegpc/ehf/util/MethodChannelUtils.kt +++ b/android/app/src/main/kotlin/com/lifegpc/ehf/platform/MethodChannelUtils.kt @@ -1,4 +1,4 @@ -package com.lifegpc.ehf.util +package com.lifegpc.ehf.platform import android.util.Log import com.lifegpc.ehf.annotation.ChannelMethod diff --git a/android/app/src/main/kotlin/com/lifegpc/ehf/platform/SAFPlugin.kt b/android/app/src/main/kotlin/com/lifegpc/ehf/platform/SAFPlugin.kt new file mode 100644 index 0000000..946b3a0 --- /dev/null +++ b/android/app/src/main/kotlin/com/lifegpc/ehf/platform/SAFPlugin.kt @@ -0,0 +1,190 @@ +package com.lifegpc.ehf.platform + +import android.content.Intent +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.file.makeFile +import com.anggrayudi.storage.file.openOutputStream +import com.lifegpc.ehf.MainActivity +import com.lifegpc.ehf.annotation.ChannelMethod +import com.lifegpc.ehf.data.mmkv.SAFSettings +import com.lifegpc.ehf.eventbus.SAFAuthEvent +import io.flutter.plugin.common.MethodChannel +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe + +class SAFPlugin(private val activity: MainActivity) { + private var safAuthorizationResult: MethodChannel.Result? = null + private val fdMap = mutableMapOf() + private var onSAFAuthSuccess: (() -> Unit)? = null + private var onSAFAuthFailed: (() -> Unit)? = null + + companion object { + const val safAuthorizationCode = 0x10086 + } + + init { + EventBus.getDefault().register(this) + } + + @Subscribe + @Suppress("unused") + fun onSAFAuthResult(event: SAFAuthEvent) { + if (!event.success) { + onSAFAuthFailed?.invoke() + onSAFAuthFailed = null + return + } + + val data = event.data!! + val authUri = data.data!! + saveSAFPermission(authUri) + onSAFAuthSuccess?.invoke() + onSAFAuthSuccess = null + } + + @ChannelMethod(responseManually = true) + @Suppress("unused") + private fun saveFile( + channelResult: MethodChannel.Result, + filename: String, + dir: String, + mimeType: String, + content: ByteArray + ) { + this.safAuthorizationResult = channelResult + if (!checkSafPermission()) { + onSAFAuthSuccess = { + doWriteFile(filename, dir, mimeType, content) + channelResult.success(null) + } + onSAFAuthFailed = { + channelResult.error("Permission denied", null, null) + } + authSAF() + } else { + doWriteFile(filename, dir, mimeType, content) + channelResult.success(null) + } + } + + @ChannelMethod(responseManually = true) + @Suppress("unused") + private fun openFile( + channelResult: MethodChannel.Result, + filenameWithoutExtension: String, + dir: String, + mimeType: String, + ) { + if (!checkSafPermission()) { + onSAFAuthSuccess = { + channelResult.success(doOpenFile(filenameWithoutExtension, mimeType, dir)) + } + onSAFAuthFailed = { + channelResult.error("Permission denied", null, null) + } + authSAF() + } else { + channelResult.success(doOpenFile(filenameWithoutExtension, mimeType, dir)) + } + } + + private fun doOpenFile(filenameWithoutExtension: String, mimeType: String, dir: String): Int { + var documentFile = + DocumentFile.fromTreeUri(activity, Uri.parse(SAFSettings.authorizedUri))!! + documentFile = createFileRecursively(documentFile, dir) + + val f = documentFile.makeFile(activity, filenameWithoutExtension, mimeType)!! + + val id = f.hashCode() + fdMap[id] = f + return id + } + + /** + * 写入文件 + * @param id Int + * @param bytes ByteArray + * @return Int 写入的字节数 + */ + @ChannelMethod + @Suppress("unused") + private fun writeFile(id: Int, bytes: ByteArray, append: Boolean): Int { + val f = fdMap[id]!! + f.openOutputStream(activity, append)!!.use { + it.write(bytes) + } + return bytes.size + } + + @ChannelMethod + @Suppress("unused") + private fun closeFile(id: Int) { + fdMap.remove(id) + } + + private fun createFileRecursively(base: DocumentFile, dir: String): DocumentFile { + var file = base + val pathPart = dir.split('/', '\\') + pathPart.forEach { + if (it.isNotEmpty()) { + file = file.createDirectory(it)!! + } + } + return file + } + + /** + * 写入文件 + * @param filename String + * @param dir String + * @param mimeType String + * @param content ByteArray + */ + private fun doWriteFile(filename: String, dir: String, mimeType: String, content: ByteArray) { + var documentDir = DocumentFile.fromTreeUri(activity, Uri.parse(SAFSettings.authorizedUri))!! + documentDir = createFileRecursively(documentDir, dir) + + val filenameWithoutExtension = if (filename.indexOf('.') != -1) { + filename.substring(0, filename.lastIndexOf('.')) + } else { + filename + } + val file = documentDir.createFile(mimeType, filenameWithoutExtension)!! + val uri = file.uri + activity.contentResolver.openOutputStream(uri)!!.use { + it.write(content) + } + } + + private fun authSAF() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + activity.startActivityForResult(intent, safAuthorizationCode) + } + + // 保存权限 + private fun saveSAFPermission(uri: Uri) { + val takeFlags = + (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + activity.contentResolver.takePersistableUriPermission(uri, takeFlags) + SAFSettings.authorizedUri = uri.toString() + } + + private fun checkSafPermission(): Boolean { + val dir = SAFSettings.authorizedUri + if (dir.isBlank()) return false + + val uri = Uri.parse(dir) + + return try { + val flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + activity.contentResolver.takePersistableUriPermission(uri, flags) + DocumentFile.fromTreeUri(activity, uri) + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} \ No newline at end of file diff --git a/lib/platform/path.dart b/lib/platform/path.dart index e8a8bcb..4baed45 100644 --- a/lib/platform/path.dart +++ b/lib/platform/path.dart @@ -1,7 +1,9 @@ import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; + import '../utils.dart'; import 'save_file.dart'; @@ -50,11 +52,19 @@ class Path { class SAFFile { SAFFile(this._fd); + final int _fd; bool _disposed = false; - Future write(Uint8List data) async { + + /// 写入文件,返回此次写入的字节数 + /// + /// [data] 要写入文件的数据 + /// + /// [append] 如果为 true 则追加写入,如果为 false 则清空原始文件内容,重新写入,默认为 true + Future write(Uint8List data, {bool append = true}) async { if (_disposed) throw Exception("File already closed"); - return await Path._safChannel.invokeMethod("writeFile", [_fd, data]); + return await Path._safChannel + .invokeMethod("writeFile", [_fd, data, append]); } Future dispose() async { diff --git a/pubspec.lock b/pubspec.lock index fc76759..718aae3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: archive - sha256: "49b1fad315e57ab0bbc15bcbb874e83116a1d78f77ebd500a4af6c9407d6b28e" + sha256: e0902a06f0e00414e4e3438a084580161279f137aeb862274710f29ec10cf01e url: "https://pub.dev" source: hosted - version: "3.3.8" + version: "3.3.9" args: dependency: transitive description: @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: build_resolvers - sha256: a7417cc44d9edb3f2c8760000270c99dba8c72ff66d0146772b8326565780745 + sha256: d912852cce27c9e80a93603db721c267716894462e7033165178b91138587972 url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" build_runner: dependency: "direct dev" description: @@ -190,26 +190,26 @@ packages: dependency: "direct main" description: name: cryptography - sha256: df156c5109286340817d21fa7b62f9140f17915077127dd70f8bd7a2a0997a35 + sha256: "11d083541666d80bba21190d35ff70b2497c81e064e82d1b8a07d801f7c7e282" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" cryptography_flutter: dependency: "direct main" description: name: cryptography_flutter - sha256: a66ce021e0b600688c2d51b0594cb156ee677ce9bfbc981b62219ad577dd302e + sha256: "423ac095bbc4650fe6c07c64632ddc5dda1d4851241367efa0f26ca24b0ce480" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.1" dart_style: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: abd7625e16f51f554ea244d090292945ec4d4be7bfbaf2ec8cccea568919d334 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.3" device_info_plus: dependency: transitive description: @@ -980,5 +980,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.1.0-185.0.dev <4.0.0" - flutter: ">=3.7.0" + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0"