fix(android): support document uploads and bump version to 1.0.4

This commit is contained in:
2026-10-22 01:17:12 +08:00
parent 836d876783
commit 3bea0ed9b5
7 changed files with 479 additions and 6 deletions
@@ -5,7 +5,9 @@ import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.CancellationSignal
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.view.View
import android.widget.EditText
import android.widget.Toast
@@ -18,6 +20,13 @@ import org.mozilla.geckoview.*
import org.mozilla.geckoview.GeckoSession.PromptDelegate.*
import java.io.InputStream
import java.util.concurrent.Executors
import java.util.UUID
private object BrowserUploads {
val executor = Executors.newCachedThreadPool()
private var cache: UploadCache? = null
@Synchronized fun get(context: Context): UploadCache = cache ?: UploadCache(context.cacheDir).also { cache = it }
}
private object BrowserRuntime {
private var instance: GeckoRuntime? = null
@@ -46,6 +55,7 @@ internal class GeckoPlatformView(
private val prompts = mutableMapOf<BasePrompt, () -> Unit>()
private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>())
private val io = Executors.newSingleThreadExecutor()
private var uploadSession = UUID.randomUUID().toString()
private val downloads = java.util.Collections.synchronizedSet(mutableSetOf<DownloadNotifications>())
@Volatile private var disposed = false
private var loaded = false
@@ -54,6 +64,8 @@ internal class GeckoPlatformView(
private var loadFailed = false
init {
val appContext = activity.applicationContext
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(appContext) } }
session.progressDelegate = object : GeckoSession.ProgressDelegate {
override fun onPageStart(session: GeckoSession, url: String) { loadFailed = false; emit("onPageStarted") }
override fun onPageStop(session: GeckoSession, success: Boolean) {
@@ -122,6 +134,13 @@ internal class GeckoPlatformView(
cancelDownloads()
prompts.values.toList().forEach { it() }
emit("onLoadError", "网页进程已停止,请重试")
closeUploads()
}
private fun closeUploads() {
val previous = uploadSession
uploadSession = UUID.randomUUID().toString()
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(activity.applicationContext).closeSession(previous) } }
}
private fun chooseOptions(prompt: ChoicePrompt): GeckoResult<PromptResponse> {
@@ -195,15 +214,29 @@ internal class GeckoPlatformView(
private fun chooseFiles(prompt: FilePrompt): GeckoResult<PromptResponse> {
val result = GeckoResult<PromptResponse>()
fun finish(uris: Array<Uri>?) {
val cancellation = UploadCancellation()
val signal = CancellationSignal()
val unregisterSignal = cancellation.onCancel { signal.cancel() }
val selectionSession = uploadSession
var finished = false
fun discard(batch: UploadCache.Batch) {
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(activity.applicationContext).discard(batch) } }
}
fun finish(uris: Array<Uri>?, batch: UploadCache.Batch? = null) {
if (finished) { batch?.let { discard(it) }; return }
finished = true
cancellation.cancel()
var confirmed = false
if (!prompt.isComplete) {
try {
result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris))
confirmed = !uris.isNullOrEmpty()
} catch (error: Exception) {
if (!prompt.isComplete) result.complete(prompt.dismiss())
Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
if (!disposed) Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
}
}
if (!confirmed) batch?.let { discard(it) }
prompts.remove(prompt)
prompt.setDelegate(null)
}
@@ -223,12 +256,59 @@ internal class GeckoPlatformView(
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
activity.launchDocument(this, intent) { code, data ->
if (code != Activity.RESULT_OK || data == null) finish(null)
if (finished || disposed || code != Activity.RESULT_OK || data == null) finish(null)
else {
val uris = mutableListOf<Uri>()
data.clipData?.let { clip -> for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri) }
if (uris.isEmpty()) data.data?.let { uris.add(it) }
finish((if (prompt.type == FilePrompt.Type.MULTIPLE) uris else uris.take(1)).toTypedArray())
val selected = if (prompt.type == FilePrompt.Type.MULTIPLE) uris else uris.take(1)
// Gecko's FOLDER traversal is separate; copying files cannot preserve directory semantics.
if (folder || selected.isEmpty()) finish(selected.toTypedArray())
else {
Toast.makeText(activity, "正在准备上传文件…", Toast.LENGTH_SHORT).show()
val appContext = activity.applicationContext
BrowserUploads.executor.execute {
try {
cancellation.check()
val resolver = appContext.contentResolver
val sources = selected.map { uri ->
cancellation.check()
val name = runCatching {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null, signal)?.use { cursor ->
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null
}
}.getOrNull() ?: uri.lastPathSegment
cancellation.check()
UploadCache.Source(name) {
cancellation.check()
val descriptor = resolver.openAssetFileDescriptor(uri, "r", signal)
?: throw java.io.IOException("无法打开所选文件")
try { descriptor.createInputStream() } catch (error: Exception) {
descriptor.close()
throw error
}
}
}
val batch = BrowserUploads.get(appContext).copy(selectionSession, sources, cancellation)
activity.runOnUiThread {
if (disposed || finished || prompt.isComplete) {
discard(batch)
finish(null)
} else finish(batch.files.map { Uri.fromFile(it) }.toTypedArray(), batch)
}
} catch (error: Exception) {
activity.runOnUiThread {
if (!finished && !disposed) {
Toast.makeText(activity, "无法准备上传文件,请检查可用空间或重新选择:" + error.message, Toast.LENGTH_LONG).show()
}
finish(null)
}
} finally {
unregisterSignal()
}
}
}
}
}
} catch (error: Exception) { finish(null); Toast.makeText(activity, "无法打开文件选择器:" + error.message, Toast.LENGTH_LONG).show() }
@@ -335,6 +415,7 @@ internal class GeckoPlatformView(
view.releaseSession()
session.close()
}
closeUploads()
session.progressDelegate = null
session.navigationDelegate = null
session.contentDelegate = null
@@ -0,0 +1,144 @@
package ren.misaka.dshapp
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.Executors
internal class UploadCancellation {
private val cancelled = AtomicBoolean(false)
private val actions = mutableSetOf<() -> Unit>()
fun cancel() {
val pending = synchronized(actions) {
if (!cancelled.compareAndSet(false, true)) return
actions.toList().also { actions.clear() }
}
pending.forEach { action -> closer.execute { runCatching(action) } }
}
fun onCancel(action: () -> Unit): () -> Unit {
synchronized(actions) {
if (cancelled.get()) closer.execute { runCatching(action) } else actions.add(action)
}
return { synchronized(actions) { actions.remove(action) }; Unit }
}
fun check() { if (cancelled.get()) throw CancellationException("Upload cancelled") }
companion object {
// Provider cancellation and close can themselves block; neither may run on the UI thread.
private val closer = Executors.newCachedThreadPool { task ->
Thread(task, "upload-cancel").apply { isDaemon = true }
}
}
}
// Never hold the cache monitor while calling a provider.
internal class UploadCache(
cacheDir: File,
private val openOutput: (File) -> java.io.OutputStream = { it.outputStream() }
) {
private val root = File(cacheDir.canonicalFile, "gecko-upload-v1")
private val active = mutableMapOf<File, UploadCancellation>()
private val closedSessions = mutableSetOf<String>()
init {
require(root.canonicalFile == root) { "Upload cache must not be a symlink" }
remove(root)
if (!root.mkdirs()) throw IOException("Cannot create upload cache")
}
data class Source(val name: String?, val open: () -> InputStream)
data class Batch(val directory: File, val files: List<File>)
fun copy(session: String, sources: List<Source>, cancellation: UploadCancellation): Batch {
require(UUID.fromString(session).toString() == session)
val directory = File(File(root, session), UUID.randomUUID().toString())
synchronized(this) {
cancellation.check()
if (session in closedSessions) throw CancellationException("Upload session closed")
if (!directory.mkdirs()) throw IOException("Cannot create upload directory")
active[directory] = cancellation
}
var completed = false
try {
val files = sources.mapIndexed { index, source ->
cancellation.check()
val parent = File(directory, index.toString())
if (!parent.mkdir()) throw IOException("Cannot create upload directory")
val target = File(parent, safeName(source.name))
source.open().use { input ->
val unregister = cancellation.onCancel { input.close() }
try {
cancellation.check()
openOutput(target).use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
cancellation.check()
val count = input.read(buffer)
if (count < 0) break
cancellation.check()
output.write(buffer, 0, count)
}
}
} finally { unregister() }
}
target
}
return synchronized(this) {
cancellation.check()
completed = true
Batch(directory, files)
}
} finally {
synchronized(this) {
active.remove(directory)
if (!completed || session in closedSessions) remove(directory)
if (session in closedSessions && active.keys.none { it.parentFile == directory.parentFile }) {
deleteOwned(directory.parentFile)
}
}
}
}
@Synchronized fun discard(batch: Batch) {
require(batch.directory !in active)
deleteOwned(batch.directory)
}
@Synchronized fun closeSession(session: String) {
require(UUID.fromString(session).toString() == session)
closedSessions.add(session)
val directory = File(root, session)
val live = active.filterKeys { it.parentFile == directory }
live.values.forEach { it.cancel() }
if (live.isEmpty()) deleteOwned(directory)
else directory.listFiles()?.filter { it !in live }?.forEach { deleteOwned(it) }
}
private fun deleteOwned(file: File) {
require(file.toPath().normalize().startsWith(root.toPath()) && file != root)
require(root.canonicalFile == root && file.canonicalFile == file)
remove(file)
}
private fun remove(file: File) {
if (!Files.exists(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
Files.walk(file.toPath()).use { paths ->
paths.sorted(Comparator.reverseOrder()).forEach { Files.delete(it) }
}
}
companion object {
fun safeName(name: String?): String {
val cleaned = name.orEmpty().map { if (it == '/' || it.code == 92 || it.isISOControl()) '_' else it }.joinToString("")
if (cleaned.isBlank() || cleaned == "." || cleaned == "..") return "upload"
if (cleaned.toByteArray(Charsets.UTF_8).size <= 240) return cleaned
val extension = cleaned.substringAfterLast('.', "").takeIf { it.length in 1..20 }?.let { ".$it" }.orEmpty()
var stem = cleaned.removeSuffix(extension)
while ((stem + extension).toByteArray(Charsets.UTF_8).size > 240) stem = stem.dropLast(1)
return stem + extension
}
}
}
@@ -0,0 +1,240 @@
package ren.misaka.dshapp
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class UploadCacheTest {
@get:Rule val temporary = TemporaryFolder()
private fun session() = UUID.randomUUID().toString()
private fun source(name: String = "报告.txt", value: String = "content") =
UploadCache.Source(name) { value.byteInputStream() }
@Test fun preservesNamesAndSeparatesDuplicatesAndSelections() {
val cache = UploadCache(temporary.root)
val id = session()
val first = cache.copy(id, listOf(source(), source(value = "second")), UploadCancellation())
val second = cache.copy(id, listOf(source()), UploadCancellation())
assertEquals(listOf("报告.txt", "报告.txt"), first.files.map { it.name })
assertEquals(listOf("content", "second"), first.files.map { it.readText() })
assertNotEquals(first.files[0], first.files[1])
cache.discard(second)
assertTrue(first.files.all { it.exists() })
cache.closeSession(id)
assertTrue(first.files.none { it.exists() })
}
@Test fun sanitizesTraversalAndKeepsExtensions() {
assertEquals(".._.._report.pdf", UploadCache.safeName("../../report.pdf"))
assertEquals("a_b.txt", UploadCache.safeName("a" + 92.toChar() + "b.txt"))
assertEquals("a_b.txt", UploadCache.safeName("a" + 0.toChar() + "b.txt"))
listOf(null, "", " ", ".", "..").forEach { assertEquals("upload", UploadCache.safeName(it)) }
val longName = UploadCache.safeName("文".repeat(300) + ".pdf")
assertTrue(longName.endsWith(".pdf"))
assertTrue(longName.toByteArray().size <= 240)
}
@Test fun streamsLargeInputWithBoundedReadsAndClosesIt() {
val cache = UploadCache(temporary.root)
var remaining = 10 * 1024 * 1024
var closed = false
val input = object : InputStream() {
override fun read(): Int = error("Must use buffered reads")
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
assertTrue(length <= 64 * 1024)
if (remaining == 0) return -1
val count = minOf(length, remaining)
bytes.fill(42, offset, offset + count)
remaining -= count
return count
}
override fun close() { closed = true }
}
val batch = cache.copy(session(), listOf(UploadCache.Source("large.bin") { input }), UploadCancellation())
assertEquals(10L * 1024 * 1024, batch.files.single().length())
assertTrue(closed)
}
@Test fun cancellationBeforeCopyDoesNotOpenInput() {
val cache = UploadCache(temporary.root)
val cancel = UploadCancellation().apply { cancel() }
assertThrows(CancellationException::class.java) {
cache.copy(session(), listOf(UploadCache.Source("file") { error("Opened after cancellation") }), cancel)
}
assertFalse(temporary.root.walkTopDown().any { it.isFile })
}
@Test fun cancellationDuringCopyClosesInputAndRemovesWholeBatch() {
val cache = UploadCache(temporary.root)
val cancel = UploadCancellation()
var closed = false
val input = object : InputStream() {
override fun read(): Int { cancel.cancel(); return 1 }
override fun close() { closed = true }
}
assertThrows(CancellationException::class.java) {
cache.copy(session(), listOf(source(), UploadCache.Source("cancel.bin") { input }), cancel)
}
assertTrue(closed)
assertFalse(temporary.root.walkTopDown().any { it.isFile })
}
@Test fun providerFailureRemovesEarlierFilesAndPartialFile() {
val cache = UploadCache(temporary.root)
val input = object : InputStream() {
var reads = 0
override fun read(): Int { if (++reads > 70000) throw IOException("Provider failed"); return 1 }
}
assertThrows(IOException::class.java) {
cache.copy(session(), listOf(source(), UploadCache.Source("failed") { input }), UploadCancellation())
}
assertFalse(temporary.root.walkTopDown().any { it.isFile })
}
@Test fun externalCancellationClosesBlockedReadOffCallerThread() {
val cache = UploadCache(temporary.root)
val cancellation = UploadCancellation()
val reading = CountDownLatch(1)
val closed = CountDownLatch(1)
val caller = Thread.currentThread()
val worker = Executors.newSingleThreadExecutor()
val input = object : InputStream() {
override fun read(): Int {
reading.countDown()
check(closed.await(5, TimeUnit.SECONDS))
throw IOException("Closed")
}
override fun close() {
assertNotEquals(caller, Thread.currentThread())
closed.countDown()
}
}
try {
val copy = worker.submit {
assertThrows(IOException::class.java) {
cache.copy(session(), listOf(UploadCache.Source("blocked") { input }), cancellation)
}
}
assertTrue(reading.await(5, TimeUnit.SECONDS))
cancellation.cancel()
assertTrue(closed.await(5, TimeUnit.SECONDS))
copy.get(5, TimeUnit.SECONDS)
assertFalse(temporary.root.walkTopDown().any { it.isFile })
} finally { closed.countDown(); worker.shutdownNow() }
}
@Test fun uncooperativeOpenDoesNotBlockOtherCopiesOrDeleteLiveBatch() {
val cache = UploadCache(temporary.root)
val id = session()
val opening = CountDownLatch(1)
val release = CountDownLatch(1)
val workers = Executors.newCachedThreadPool()
try {
val blocked = workers.submit {
assertThrows(CancellationException::class.java) {
cache.copy(id, listOf(UploadCache.Source("blocked") {
opening.countDown()
check(release.await(5, TimeUnit.SECONDS))
"late".byteInputStream()
}), UploadCancellation())
}
}
assertTrue(opening.await(5, TimeUnit.SECONDS))
val directory = File(temporary.root, "gecko-upload-v1/$id")
val live = directory.listFiles()!!.single()
workers.submit { cache.closeSession(id) }.get(5, TimeUnit.SECONDS)
assertTrue(live.exists())
val other = workers.submit<UploadCache.Batch> {
cache.copy(session(), listOf(source()), UploadCancellation())
}.get(5, TimeUnit.SECONDS)
assertEquals("content", other.files.single().readText())
assertThrows(CancellationException::class.java) {
cache.copy(id, listOf(source()), UploadCancellation())
}
release.countDown()
blocked.get(5, TimeUnit.SECONDS)
assertFalse(directory.exists())
assertTrue(other.files.single().exists())
cache.discard(other)
cache.discard(other)
} finally { release.countDown(); workers.shutdownNow() }
}
@Test fun blockedCancellationCallbackDoesNotBlockCallerOrOtherCallbacks() {
val cancellation = UploadCancellation()
val release = CountDownLatch(1)
val started = CountDownLatch(1)
val other = CountDownLatch(1)
cancellation.onCancel { started.countDown(); release.await(5, TimeUnit.SECONDS) }
cancellation.onCancel { other.countDown() }
try {
cancellation.cancel()
assertTrue(started.await(5, TimeUnit.SECONDS))
assertTrue(other.await(5, TimeUnit.SECONDS))
val late = CountDownLatch(1)
cancellation.onCancel { late.countDown() }
assertTrue(late.await(5, TimeUnit.SECONDS))
} finally { release.countDown() }
}
@Test fun diskFullClosesStreamsAndDeletesPartialBatch() {
var inputClosed = false
var outputClosed = false
val cache = UploadCache(temporary.root) { file ->
val output = file.outputStream()
object : java.io.OutputStream() {
override fun write(value: Int) { output.write(value); throw IOException("No space left on device") }
override fun close() { outputClosed = true; output.close() }
}
}
val input = object : java.io.ByteArrayInputStream(byteArrayOf(1, 2, 3)) {
override fun close() { inputClosed = true; super.close() }
}
assertThrows(IOException::class.java) {
cache.copy(session(), listOf(UploadCache.Source("full.bin") { input }), UploadCancellation())
}
assertTrue(inputClosed)
assertTrue(outputClosed)
assertFalse(temporary.root.walkTopDown().any { it.isFile })
}
@Test fun restartCleansOnlyOwnedCacheAndSessionCleanupIsIsolated() {
val other = temporary.newFile("unrelated")
val cache = UploadCache(temporary.root)
val firstId = session()
val first = cache.copy(firstId, listOf(source()), UploadCancellation())
val second = cache.copy(session(), listOf(source()), UploadCancellation())
cache.closeSession(firstId)
assertFalse(first.files.single().exists())
assertTrue(second.files.single().exists())
UploadCache(temporary.root)
assertFalse(second.files.single().exists())
assertTrue(other.exists())
assertThrows(IllegalArgumentException::class.java) { cache.closeSession("../unrelated") }
assertThrows(IllegalArgumentException::class.java) { cache.discard(UploadCache.Batch(other, emptyList())) }
}
@Test fun rejectsSymlinkRootAndDoesNotFollowNestedSymlinks() {
val outside = temporary.newFolder("outside")
val retained = File(outside, "retained").apply { writeText("keep") }
val parent = temporary.newFolder("cache")
val root = File(parent, "gecko-upload-v1")
Files.createSymbolicLink(root.toPath(), outside.toPath())
assertThrows(IllegalArgumentException::class.java) { UploadCache(parent) }
Files.delete(root.toPath())
root.mkdir()
Files.createSymbolicLink(File(root, "link").toPath(), outside.toPath())
UploadCache(parent)
assertTrue(retained.exists())
}
}