diff --git a/Dockerfile b/Dockerfile
index 7c2e82f..74006e3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -50,6 +50,9 @@ RUN flutter config --android-sdk "${ANDROID_HOME}" \
# 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本
# 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。
+
+# Java/Gradle 不自动读取 http_proxy;仅构建时注入 JVM 代理参数,不固化到镜像 ENV。
+ARG JAVA_TOOL_OPTIONS
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
&& cd /tmp/warmup \
&& flutter build apk --release \
diff --git a/README.md b/README.md
index 2e286d1..db86e4a 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoVie
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`` 走系统文件选择器(工作区上传、备份导入)
+- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
- 返回键:优先回退页面历史,无历史时退出应用
@@ -17,6 +18,8 @@ DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoVie
前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。
+Java/Gradle 不自动使用上述代理变量。需要时另外设置 `JAVA_TOOL_OPTIONS`,例如 `-Dhttp.proxyHost=代理主机 -Dhttp.proxyPort=端口 -Dhttps.proxyHost=代理主机 -Dhttps.proxyPort=端口`;`build.sh` 会将其传入镜像构建和 APK 构建容器。代理必须能从容器访问,不要把宿主机的 `127.0.0.1` 直接当成容器代理地址。Java 会输出此变量,切勿在其中放入密码或其他秘密。
+
```sh
./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release
```
@@ -64,6 +67,7 @@ CI 可通过 secret 管理器注入 `DSH_STORE_PASSWORD` 和 `DSH_KEY_PASSWORD`
- 登录后重启应用,确认站点的持久登录状态及本地存储保留。
- 工作区单文件/多文件上传、选择器取消、配置备份导入。
+- 从下载目录、内部存储及云盘提供程序选择文件;验证中文名、同名多选、空文件、大文件、复制失败及复制中退出。目录上传仍走 Gecko 原有目录 URI 处理路径,需单独验收。
- 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
- 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。
- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt
index 952520b..bd3abb8 100644
--- a/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt
+++ b/android/app/src/main/kotlin/ren/misaka/dshapp/GeckoPlatformView.kt
@@ -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 Unit>()
private val streams = java.util.Collections.synchronizedSet(mutableSetOf())
private val io = Executors.newSingleThreadExecutor()
+ private var uploadSession = UUID.randomUUID().toString()
private val downloads = java.util.Collections.synchronizedSet(mutableSetOf())
@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 {
@@ -195,15 +214,29 @@ internal class GeckoPlatformView(
private fun chooseFiles(prompt: FilePrompt): GeckoResult {
val result = GeckoResult()
- fun finish(uris: Array?) {
+ 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?, 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()
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
diff --git a/android/app/src/main/kotlin/ren/misaka/dshapp/UploadCache.kt b/android/app/src/main/kotlin/ren/misaka/dshapp/UploadCache.kt
new file mode 100644
index 0000000..bbf20ad
--- /dev/null
+++ b/android/app/src/main/kotlin/ren/misaka/dshapp/UploadCache.kt
@@ -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()
+ private val closedSessions = mutableSetOf()
+
+ 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)
+
+ fun copy(session: String, sources: List, 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
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/ren/misaka/dshapp/UploadCacheTest.kt b/android/app/src/test/kotlin/ren/misaka/dshapp/UploadCacheTest.kt
new file mode 100644
index 0000000..4a5deed
--- /dev/null
+++ b/android/app/src/test/kotlin/ren/misaka/dshapp/UploadCacheTest.kt
@@ -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 {
+ 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())
+ }
+}
diff --git a/build.sh b/build.sh
index 8cffb06..8db72af 100755
--- a/build.sh
+++ b/build.sh
@@ -69,7 +69,7 @@ if [ "$REBUILD" = true ] || ! docker image inspect "$IMAGE" > /dev/null 2>&1; th
echo "==> 构建镜像 $IMAGE(首次较慢,需下载 Flutter/Android SDK)"
BUILD_ARGS=()
if [ "$REBUILD" = true ]; then BUILD_ARGS+=(--no-cache); fi
- docker build --build-arg http_proxy --build-arg https_proxy --build-arg no_proxy "${BUILD_ARGS[@]}" -t "$IMAGE" .
+ docker build --build-arg http_proxy --build-arg https_proxy --build-arg no_proxy --build-arg JAVA_TOOL_OPTIONS "${BUILD_ARGS[@]}" -t "$IMAGE" .
fi
echo "==> 在容器内构建 release APK"
@@ -80,6 +80,7 @@ docker run --rm \
-e DSH_KEYSTORE=/run/dsh-signing/release.keystore \
-e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \
-e http_proxy -e https_proxy -e no_proxy \
+ -e JAVA_TOOL_OPTIONS \
-e PUB_CACHE=/opt/pub-cache \
-e DSH_SITE_URL="$DSH_SITE_URL" \
"$IMAGE" \
diff --git a/pubspec.yaml b/pubspec.yaml
index 20403e5..f87376e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 1.0.3+4
+version: 1.0.4+5
environment:
sdk: ^3.13.5