diff --git a/android/app/src/main/kotlin/io/openim/flutter/openim/MainActivity.kt b/android/app/src/main/kotlin/io/openim/flutter/openim/MainActivity.kt index 6fe1592..0ca3894 100644 --- a/android/app/src/main/kotlin/io/openim/flutter/openim/MainActivity.kt +++ b/android/app/src/main/kotlin/io/openim/flutter/openim/MainActivity.kt @@ -1,6 +1,7 @@ package io.openim.flutter.openim import android.content.Intent +import android.app.Activity import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.Bitmap @@ -23,8 +24,11 @@ import java.io.File class MainActivity : FlutterActivity() { private val CHANNEL = "io.openim.flutter.openim/apk_info" private val SHELL_BRANDING_CHANNEL = "io.openim.flutter.im_webview_app/shell_branding" + private val FILE_PICKER_CHANNEL = "io.openim.flutter.openim/file_picker" private val TAG = "MainActivity" private val MAX_BRANDING_ICON_SIZE = 192 + private val FILE_PICKER_REQUEST_CODE = 4201 + private var pendingFilePickerResult: MethodChannel.Result? = null override fun onCreate(savedInstanceState: android.os.Bundle?) { // 华为/荣耀/OPPO 等国产设备:在任意网络请求之前同步安装 Conscrypt,修复 SSL 握手失败(无 GMS 时系统 SSL 实现不完整) @@ -107,6 +111,137 @@ class MainActivity : FlutterActivity() { } } } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, FILE_PICKER_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "pickFiles" -> { + val acceptTypes = call.argument>("acceptTypes") ?: emptyList() + val allowMultiple = call.argument("allowMultiple") ?: false + openNativeFilePicker(acceptTypes, allowMultiple, result) + } + else -> { + result.notImplemented() + } + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == FILE_PICKER_REQUEST_CODE) { + val result = pendingFilePickerResult + pendingFilePickerResult = null + if (result == null) { + super.onActivityResult(requestCode, resultCode, data) + return + } + + if (resultCode != Activity.RESULT_OK || data == null) { + result.success(emptyList()) + return + } + + val uris = mutableListOf() + val clipData = data.clipData + if (clipData != null) { + for (index in 0 until clipData.itemCount) { + clipData.getItemAt(index).uri?.let { uri -> + grantPickedFileReadPermission(uri) + uris.add(uri.toString()) + } + } + } else { + data.data?.let { uri -> + grantPickedFileReadPermission(uri) + uris.add(uri.toString()) + } + } + result.success(uris) + return + } + + super.onActivityResult(requestCode, resultCode, data) + } + + private fun openNativeFilePicker( + acceptTypes: List, + allowMultiple: Boolean, + result: MethodChannel.Result + ) { + if (pendingFilePickerResult != null) { + result.error("FILE_PICKER_BUSY", "A file picker request is already active", null) + return + } + + val mimeTypes = normalizeMimeTypes(acceptTypes) + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = when (mimeTypes.size) { + 0 -> "*/*" + 1 -> mimeTypes.first() + else -> "*/*" + } + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) + if (mimeTypes.size > 1) { + putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray()) + } + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + } + + pendingFilePickerResult = result + try { + startActivityForResult( + Intent.createChooser(intent, "选择文件"), + FILE_PICKER_REQUEST_CODE + ) + } catch (e: Exception) { + pendingFilePickerResult = null + result.error("FILE_PICKER_ERROR", e.message ?: "Unable to open file picker", null) + } + } + + private fun normalizeMimeTypes(acceptTypes: List): List { + val mimeTypes = linkedSetOf() + acceptTypes + .map { it.trim().lowercase() } + .filter { it.isNotEmpty() && it != "*/*" } + .forEach { type -> + when { + type.startsWith(".") -> mimeTypes.addAll(mimeTypesForExtension(type)) + type.contains("/") -> mimeTypes.add(type) + } + } + return mimeTypes.toList() + } + + private fun mimeTypesForExtension(extension: String): List { + return when (extension) { + ".jpg", ".jpeg" -> listOf("image/jpeg") + ".png" -> listOf("image/png") + ".gif" -> listOf("image/gif") + ".webp" -> listOf("image/webp") + ".heic" -> listOf("image/heic") + ".heif" -> listOf("image/heif") + ".mp4", ".m4v" -> listOf("video/mp4") + ".mov" -> listOf("video/quicktime") + ".webm" -> listOf("video/webm") + ".mp3" -> listOf("audio/mpeg") + ".m4a" -> listOf("audio/mp4") + ".aac" -> listOf("audio/aac") + ".wav" -> listOf("audio/wav") + else -> emptyList() + } + } + + private fun grantPickedFileReadPermission(uri: Uri) { + try { + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: Exception) { + // Not all providers grant persistable permissions. The transient + // read grant is enough for WebView's file upload handoff. + } } private fun getCurrentAppName(): String { diff --git a/lib/main.dart b/lib/main.dart index f5df625..6c6da7d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_android/webview_flutter_android.dart'; import 'config/app_config.dart'; @@ -14,6 +15,8 @@ const _shellAccent = Color(0xFF0089FF); const _shellSubText = Color(0xFF8E9AB0); const _shellBrandingChannel = MethodChannel('io.openim.flutter.im_webview_app/shell_branding'); +const _androidFilePickerChannel = + MethodChannel('io.openim.flutter.openim/file_picker'); Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -120,7 +123,7 @@ class _H5ShellPageState extends State { } WebViewController _buildController(int lineIndex) { - return WebViewController( + final controller = WebViewController( onPermissionRequest: (request) { unawaited(_handleWebViewPermissionRequest(request)); }, @@ -176,6 +179,39 @@ class _H5ShellPageState extends State { onNavigationRequest: _handleNavigationRequest, ), ); + + _configurePlatformController(controller); + return controller; + } + + void _configurePlatformController(WebViewController controller) { + final platformController = controller.platform; + if (platformController is AndroidWebViewController) { + unawaited( + platformController.setOnShowFileSelector(_handleAndroidFileSelection), + ); + } + } + + Future> _handleAndroidFileSelection( + FileSelectorParams params, + ) async { + if (params.mode == FileSelectorMode.save) { + return []; + } + + try { + final result = await _androidFilePickerChannel.invokeListMethod( + 'pickFiles', + { + 'acceptTypes': params.acceptTypes, + 'allowMultiple': params.mode == FileSelectorMode.openMultiple, + }, + ); + return result ?? []; + } catch (_) { + return []; + } } @override