diff --git a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt index 587c222..abffc78 100644 --- a/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt +++ b/sdk-core/src/main/java/com/xuqm/sdk/file/FileSDK.kt @@ -162,9 +162,13 @@ object FileSDK { ?: "download.bin" val baseDir = when (destination) { - FileDownloadDestination.PublicDownloads -> - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - .apply { mkdirs() } + FileDownloadDestination.PublicDownloads -> { + val appName = context.applicationInfo.loadLabel(context.packageManager).toString() + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + appName, + ).apply { mkdirs() } + } FileDownloadDestination.Sandbox -> { if (directoryName.isNullOrBlank()) { context.getExternalFilesDir(null) ?: context.filesDir @@ -243,8 +247,12 @@ object FileSDK { val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT) val baseDir = when (destination) { FileDownloadDestination.PublicDownloads -> { - android.os.Environment.getExternalStoragePublicDirectory( - android.os.Environment.DIRECTORY_DOWNLOADS + val appName = context.applicationInfo.loadLabel(context.packageManager).toString() + File( + android.os.Environment.getExternalStoragePublicDirectory( + android.os.Environment.DIRECTORY_DOWNLOADS + ), + appName, ).apply { mkdirs() } } FileDownloadDestination.Sandbox -> { diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewActivity.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewActivity.kt index 0082a60..8e04f40 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewActivity.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewActivity.kt @@ -54,9 +54,7 @@ class XWebViewActivity : ComponentActivity() { cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null) } - private val pickContentLauncher = registerForActivityResult( - ActivityResultContracts.GetContent() - ) { uri -> + private val pickContentLauncher = registerForActivityResult(GetContentWithMimeTypes()) { uri -> val cb = pendingFileCallback pendingFileCallback = null cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null) @@ -83,6 +81,23 @@ class XWebViewActivity : ComponentActivity() { } } + private fun launchCamera() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + runCatching { + val imageFile = File.createTempFile("cam_", ".jpg", cacheDir) + val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", imageFile) + pendingCameraUri = uri + takePictureLauncher.launch(uri) + }.onFailure { + pendingFileCallback?.onReceiveValue(null) + pendingFileCallback = null + } + } else { + shouldLaunchCamera = true + fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -136,26 +151,25 @@ class XWebViewActivity : ComponentActivity() { pendingFileCallback?.onReceiveValue(null) pendingFileCallback = filePathCallback + val acceptMimes = resolvePickerMimeTypes(fileChooserParams.acceptTypes) val isCameraCapture = fileChooserParams.isCaptureEnabled && fileChooserParams.acceptTypes.any { it.contains("image") } + val isImageOnly = acceptMimes.all { it.startsWith("image/") || it == "image/*" } - if (isCameraCapture) { - if (ContextCompat.checkSelfPermission(this@XWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - runCatching { - val imageFile = File.createTempFile("cam_", ".jpg", cacheDir) - val uri = FileProvider.getUriForFile(this@XWebViewActivity, "${packageName}.fileprovider", imageFile) - pendingCameraUri = uri - takePictureLauncher.launch(uri) - }.onFailure { + when { + isCameraCapture -> launchCamera() + isImageOnly -> android.app.AlertDialog.Builder(this@XWebViewActivity) + .setTitle("选择图片") + .setItems(arrayOf("从相册选择", "拍照")) { _, which -> + if (which == 0) pickContentLauncher.launch(arrayOf("image/*")) + else launchCamera() + } + .setOnCancelListener { pendingFileCallback?.onReceiveValue(null) pendingFileCallback = null } - } else { - shouldLaunchCamera = true - fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } else { - pickContentLauncher.launch(resolvePickerMimeType(fileChooserParams.acceptTypes)) + .show() + else -> pickContentLauncher.launch(acceptMimes) } return true } diff --git a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt index c965419..c0b61f7 100644 --- a/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt +++ b/sdk-webview/src/main/java/com/xuqm/sdk/webview/XWebViewView.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.os.Handler import android.os.Looper import android.util.Base64 @@ -20,6 +21,7 @@ import android.webkit.WebViewClient import android.webkit.WebView import android.widget.FrameLayout import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -31,6 +33,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.content.FileProvider @@ -42,12 +47,12 @@ import org.json.JSONObject import java.io.File import java.util.Locale -// Maps HTML accept types (MIME or dot-prefixed extensions) to a single MIME for ACTION_GET_CONTENT. -// Returns "*/*" when types are empty, mixed, or cannot be resolved. -internal fun resolvePickerMimeType(acceptTypes: Array): String { +// Maps HTML accept types (MIME or dot-prefixed extensions) to an array of MIME strings for ACTION_GET_CONTENT. +// Returns ["*/*"] when types are empty or cannot be resolved. +internal fun resolvePickerMimeTypes(acceptTypes: Array): Array { val nonBlank = acceptTypes.filter { it.isNotBlank() } - if (nonBlank.isEmpty()) return "*/*" - val resolved = nonBlank.map { type -> + if (nonBlank.isEmpty()) return arrayOf("*/*") + return nonBlank.map { type -> if (type.startsWith(".")) { MimeTypeMap.getSingleton() .getMimeTypeFromExtension(type.trimStart('.').lowercase(Locale.ROOT)) @@ -55,8 +60,24 @@ internal fun resolvePickerMimeType(acceptTypes: Array): String { } else { type } - }.distinct() - return if (resolved.size == 1) resolved[0] else "*/*" + }.distinct().toTypedArray() +} + +// ACTION_GET_CONTENT contract supporting multiple MIME types via EXTRA_MIME_TYPES. +internal class GetContentWithMimeTypes : ActivityResultContract, Uri?>() { + override fun createIntent(context: Context, input: Array): Intent = + Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + if (input.size == 1) { + type = input[0] + } else { + type = "*/*" + putExtra(Intent.EXTRA_MIME_TYPES, input) + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? = + intent?.takeIf { resultCode == android.app.Activity.RESULT_OK }?.data } // JS injected into every page to bridge dialog APIs and download interception. @@ -187,6 +208,11 @@ fun XWebViewView( var webView by remember { mutableStateOf(null) } var currentUrl by remember { mutableStateOf(config.url.ifBlank { null }) } val coroutineScope = rememberCoroutineScope() + var showImageSourceDialog by remember { mutableStateOf(false) } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { /* download continues regardless; notification skipped if denied */ } // Keep onMessage ref up-to-date across recompositions without recreating the bridge object. val onMessageRef = remember { mutableStateOf(config.onMessage) } @@ -206,6 +232,12 @@ fun XWebViewView( "download" -> { val url = payload.optString("url").takeIf { it.isNotBlank() } ?: return@handler val filename = payload.optString("filename").takeIf { it.isNotBlank() } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + config.downloadNotificationTitle != null && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } coroutineScope.launch(Dispatchers.IO) { runCatching { FileSDK.download( @@ -279,9 +311,7 @@ fun XWebViewView( cb?.onReceiveValue(if (success && uri != null) arrayOf(uri) else null) } - val pickContentLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.GetContent() - ) { uri -> + val pickContentLauncher = rememberLauncherForActivityResult(GetContentWithMimeTypes()) { uri -> val cb = pendingFileCallback.value pendingFileCallback.value = null cb?.onReceiveValue(if (uri != null) arrayOf(uri) else null) @@ -319,6 +349,23 @@ fun XWebViewView( } } + val launchCamera: () -> Unit = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + runCatching { + val imageFile = File.createTempFile("cam_", ".jpg", context.cacheDir) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", imageFile) + pendingCameraUri.value = uri + takePictureLauncher.launch(uri) + }.onFailure { + pendingFileCallback.value?.onReceiveValue(null) + pendingFileCallback.value = null + } + } else { + shouldLaunchCamera.value = true + fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + AndroidView( modifier = modifier, factory = { ctx -> @@ -376,26 +423,15 @@ fun XWebViewView( pendingFileCallback.value?.onReceiveValue(null) pendingFileCallback.value = filePathCallback + val acceptMimes = resolvePickerMimeTypes(fileChooserParams.acceptTypes) val isCameraCapture = fileChooserParams.isCaptureEnabled && fileChooserParams.acceptTypes.any { it.contains("image") } + val isImageOnly = acceptMimes.all { it.startsWith("image/") || it == "image/*" } - if (isCameraCapture) { - if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - runCatching { - val imageFile = File.createTempFile("cam_", ".jpg", ctx.cacheDir) - val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", imageFile) - pendingCameraUri.value = uri - takePictureLauncher.launch(uri) - }.onFailure { - pendingFileCallback.value?.onReceiveValue(null) - pendingFileCallback.value = null - } - } else { - shouldLaunchCamera.value = true - fileCameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } else { - pickContentLauncher.launch(resolvePickerMimeType(fileChooserParams.acceptTypes)) + when { + isCameraCapture -> launchCamera() + isImageOnly -> showImageSourceDialog = true + else -> pickContentLauncher.launch(acceptMimes) } return true } @@ -423,6 +459,30 @@ fun XWebViewView( }, ) + if (showImageSourceDialog) { + AlertDialog( + onDismissRequest = { + showImageSourceDialog = false + pendingFileCallback.value?.onReceiveValue(null) + pendingFileCallback.value = null + }, + title = { Text("选择图片") }, + text = null, + confirmButton = { + TextButton(onClick = { + showImageSourceDialog = false + pickContentLauncher.launch(arrayOf("image/*")) + }) { Text("从相册选择") } + }, + dismissButton = { + TextButton(onClick = { + showImageSourceDialog = false + launchCamera() + }) { Text("拍照") } + }, + ) + } + SideEffect { val view = webView ?: return@SideEffect setXWebViewController(object : XWebViewController {