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.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<application
android:name=".OxygenApplication"

View File

@@ -1,8 +1,17 @@
package top.fatweb.oxygen.toolbox.ui.view
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.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.Column
import androidx.compose.foundation.layout.WindowInsets
@@ -21,7 +30,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.ui.component.Indicator
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.Permissions
import kotlin.coroutines.resume
@Composable
internal fun ToolViewRoute(
@@ -67,6 +81,25 @@ internal fun ToolViewScreen(
) {
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(
modifier = Modifier,
containerColor = Color.Transparent,
@@ -146,15 +179,60 @@ internal fun ToolViewScreen(
}
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 = {
it.settings.javaScriptEnabled = true
it.settings.domStorageEnabled = true
it.addJavascriptInterface(
NativeWebApi(context = context, webView = it),
NativeWebApi(context = context, webView = it, permissionLauncher),
"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.ClipboardManager
import android.content.ContentValues
import android.content.Context
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.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(
private val context: Context,
private val webView: WebView
private val webView: WebView,
private val permissionLauncher: ManagedActivityResultLauncher<String, Boolean>
) {
@JavascriptInterface
fun copyToClipboard(text: String): Boolean {
@@ -26,6 +38,65 @@ class NativeWebApi(
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) {
val jsCode =
"$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_nothing">空空如也</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_retry">重试</string>

View File

@@ -25,6 +25,11 @@
<string name="core_undo">Undo</string>
<string name="core_nothing">Nothing</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_retry">try again</string>