Feat(ToolView): Support upload and download file

This commit is contained in:
2024-09-30 15:06:28 +08:00
parent 2631c22e52
commit 585a261bb8
6 changed files with 205 additions and 2 deletions

View File

@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<application <application
android:name=".OxygenApplication" android:name=".OxygenApplication"

View File

@@ -1,8 +1,17 @@
package top.fatweb.oxygen.toolbox.ui.view package top.fatweb.oxygen.toolbox.ui.view
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log import android.util.Log
import android.webkit.ConsoleMessage import android.webkit.ConsoleMessage
import android.webkit.ValueCallback
import android.webkit.WebView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -21,7 +30,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -37,7 +48,10 @@ import top.fatweb.oxygen.toolbox.R
import top.fatweb.oxygen.toolbox.icon.OxygenIcons import top.fatweb.oxygen.toolbox.icon.OxygenIcons
import top.fatweb.oxygen.toolbox.ui.component.Indicator import top.fatweb.oxygen.toolbox.ui.component.Indicator
import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar import top.fatweb.oxygen.toolbox.ui.component.OxygenTopAppBar
import top.fatweb.oxygen.toolbox.ui.util.ResourcesUtils
import top.fatweb.oxygen.toolbox.util.NativeWebApi import top.fatweb.oxygen.toolbox.util.NativeWebApi
import top.fatweb.oxygen.toolbox.util.Permissions
import kotlin.coroutines.resume
@Composable @Composable
internal fun ToolViewRoute( internal fun ToolViewRoute(
@@ -67,6 +81,25 @@ internal fun ToolViewScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var fileChooserCallback by remember { mutableStateOf<ValueCallback<Array<Uri>>?>(null) }
val fileChooserLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri ->
fileChooserCallback?.onReceiveValue(arrayOf(uri))
} ?: {
fileChooserCallback?.onReceiveValue(emptyArray())
}
} else {
fileChooserCallback?.onReceiveValue(emptyArray())
}
}
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
Permissions.continuation?.resume(it)
}
Scaffold( Scaffold(
modifier = Modifier, modifier = Modifier,
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -146,15 +179,60 @@ internal fun ToolViewScreen(
} }
return false return false
} }
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
fileChooserCallback = filePathCallback
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
fileChooserLauncher.launch(
Intent.createChooser(
intent,
ResourcesUtils.getString(
context = context,
resId = R.string.core_file_select_one_text
)
)
)
return true
}
} }
}, },
onCreated = { onCreated = {
it.settings.javaScriptEnabled = true it.settings.javaScriptEnabled = true
it.settings.domStorageEnabled = true it.settings.domStorageEnabled = true
it.addJavascriptInterface( it.addJavascriptInterface(
NativeWebApi(context = context, webView = it), NativeWebApi(context = context, webView = it, permissionLauncher),
"NativeApi" "NativeApi"
) )
it.setDownloadListener { url, userAgent, _, mimetype, _ ->
if (!listOf("http://", "https://").any(url::startsWith)) {
it.evaluateJavascript(
"alert('${
ResourcesUtils.getString(
context = context,
resId = R.string.core_can_only_download_http_https,
url
)
}')"
) {}
return@setDownloadListener
}
val request = DownloadManager.Request(Uri.parse(url)).apply {
addRequestHeader("User-Agent", userAgent)
setMimeType(mimetype)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
}
val downloadManager: DownloadManager =
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
downloadManager.enqueue(request)
}
} }
) )
} }

View File

@@ -2,14 +2,26 @@ package top.fatweb.oxygen.toolbox.util
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Context.CLIPBOARD_SERVICE import android.content.Context.CLIPBOARD_SERVICE
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebView import android.webkit.WebView
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.annotation.RequiresApi
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
class NativeWebApi( class NativeWebApi(
private val context: Context, private val context: Context,
private val webView: WebView private val webView: WebView,
private val permissionLauncher: ManagedActivityResultLauncher<String, Boolean>
) { ) {
@JavascriptInterface @JavascriptInterface
fun copyToClipboard(text: String): Boolean { fun copyToClipboard(text: String): Boolean {
@@ -26,6 +38,65 @@ class NativeWebApi(
return clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString() ?: "" return clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString() ?: ""
} }
@JavascriptInterface
fun saveToDownloads(data: ByteArray, fileName: String): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveFileToDownloads(data = data, fileName = fileName)
} else {
saveFileToExternalDownloads(data = data, fileName = fileName)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveFileToDownloads(data: ByteArray, fileName: String): Boolean {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, fileName)
put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream")
put(MediaStore.Downloads.IS_PENDING, 1)
}
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val fileUri = resolver.insert(collection, contentValues)
return fileUri?.let { uri ->
resolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(data)
outputStream.flush()
}
contentValues.clear()
contentValues.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null)
true
} ?: let {
Timber.e("Could not save file $fileName to Downloads")
false
}
}
private fun saveFileToExternalDownloads(data: ByteArray, fileName: String): Boolean {
if (!runBlocking { Permissions.requestWriteExternalStoragePermission(context, permissionLauncher) }) {
return false
}
val downloadsDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, fileName)
return try {
FileOutputStream(file).apply {
write(data)
close()
}
true
} catch (e: IOException) {
Timber.e("Could not save file $fileName to ${file.absolutePath}", e)
false
}
}
private fun callback(callback: String, vararg args: Any) { private fun callback(callback: String, vararg args: Any) {
val jsCode = val jsCode =
"$callback(${args.map { if (it is String) "'$it'" else it }.joinToString(", ")})" "$callback(${args.map { if (it is String) "'$it'" else it }.joinToString(", ")})"

View File

@@ -0,0 +1,41 @@
package top.fatweb.oxygen.toolbox.util
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.core.content.ContextCompat
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
object Permissions {
var continuation: Continuation<Boolean>? = null
suspend fun requestWriteExternalStoragePermission(
context: Context,
permissionLauncher: ManagedActivityResultLauncher<String, Boolean>
): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return true
}
return suspendCancellableCoroutine { continuation ->
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
) {
continuation.resume(true)
} else {
this.continuation = continuation
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
continuation.invokeOnCancellation {
continuation.resume(false)
}
}
}
}

View File

@@ -26,6 +26,11 @@
<string name="core_undo">撤消</string> <string name="core_undo">撤消</string>
<string name="core_nothing">空空如也</string> <string name="core_nothing">空空如也</string>
<string name="core_nothing_found">未找到相关内容</string> <string name="core_nothing_found">未找到相关内容</string>
<string name="core_file_select_one">选择一个文件</string>
<string name="core_file_select_multi">选择文件</string>
<string name="core_file_select_one_text">选择一个文本文件</string>
<string name="core_file_select_multi_text">选择文本文件</string>
<string name="core_can_only_download_http_https">只能下载 HTTP/HTTPS URI%1$s</string>
<string name="feature_store_title">商店</string> <string name="feature_store_title">商店</string>
<string name="feature_store_retry">重试</string> <string name="feature_store_retry">重试</string>

View File

@@ -25,6 +25,11 @@
<string name="core_undo">Undo</string> <string name="core_undo">Undo</string>
<string name="core_nothing">Nothing</string> <string name="core_nothing">Nothing</string>
<string name="core_nothing_found">Nothing found</string> <string name="core_nothing_found">Nothing found</string>
<string name="core_file_select_one">Select a file</string>
<string name="core_file_select_multi">Select files</string>
<string name="core_file_select_one_text">Select a text file</string>
<string name="core_file_select_multi_text">Select text files</string>
<string name="core_can_only_download_http_https">Can only download HTTP/HTTPS URIs: %1$s</string>
<string name="feature_store_title">Store</string> <string name="feature_store_title">Store</string>
<string name="feature_store_retry">try again</string> <string name="feature_store_retry">try again</string>