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
+3
View File
@@ -50,6 +50,9 @@ RUN flutter config --android-sdk "${ANDROID_HOME}" \
# 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本 # 预热默认模板的 Gradle/AGP/AndroidX 和 SDK;GeckoView 的固定版本
# 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。 # 在应用构建时从 Mozilla Maven 下载,不在这里预热旧 WebView 插件。
# Java/Gradle 不自动读取 http_proxy;仅构建时注入 JVM 代理参数,不固化到镜像 ENV。
ARG JAVA_TOOL_OPTIONS
RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \ RUN flutter create --org dev.dsh --project-name warmup /tmp/warmup \
&& cd /tmp/warmup \ && cd /tmp/warmup \
&& flutter build apk --release \ && flutter build apk --release \
+4
View File
@@ -8,6 +8,7 @@ DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoVie
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制) - 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪 - 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入) - 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
- 普通文件上传通过系统授权 URI 流式复制到应用私有临时目录后交给 Gecko,兼容下载目录和文档提供程序;需要足够的本地临时空间。已交给页面的临时文件保留至浏览会话关闭,避免提前删除中断上传。
- 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie - 文件下载:直接保存 Gecko 已认证请求的响应流,由系统“另存为”选择位置,不通过系统 WebView 获取 Cookie
- 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。 - 下载状态通知:保存时显示进度和已下载大小,完成、失败或取消后更新状态;通知不提供打开文件或目录的操作。未知总大小时显示不定进度。Android 13+ 首次下载请求通知权限,拒绝不会阻止下载。
- 返回键:优先回退页面历史,无历史时退出应用 - 返回键:优先回退页面历史,无历史时退出应用
@@ -17,6 +18,8 @@ DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoVie
前置:本机可用 Docker(构建通过代理访问 Google/Gradle 源时需在环境变量中提供 `http_proxy`/`https_proxy`)。 前置:本机可用 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 ```sh
./build.sh https://panel.example.com --keystore /安全路径/dsh-release.jks --alias dsh-release ./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、配置备份下载,检查保存内容及取消流程。 - 需要登录的文件、目录 ZIP、配置备份下载,检查保存内容及取消流程。
- 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。 - 下载通知:已知/未知总大小、多个下载、断网/保存失败、退出取消;Android 13+ 通知权限允许和拒绝,以及系统关闭通知的情况。
- JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。 - JS alert/confirm/prompt、输入法、页面滚动、SPA 返回键、横竖屏及前后台切换。
@@ -5,7 +5,9 @@ import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.CancellationSignal
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.view.View import android.view.View
import android.widget.EditText import android.widget.EditText
import android.widget.Toast import android.widget.Toast
@@ -18,6 +20,13 @@ import org.mozilla.geckoview.*
import org.mozilla.geckoview.GeckoSession.PromptDelegate.* import org.mozilla.geckoview.GeckoSession.PromptDelegate.*
import java.io.InputStream import java.io.InputStream
import java.util.concurrent.Executors 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 object BrowserRuntime {
private var instance: GeckoRuntime? = null private var instance: GeckoRuntime? = null
@@ -46,6 +55,7 @@ internal class GeckoPlatformView(
private val prompts = mutableMapOf<BasePrompt, () -> Unit>() private val prompts = mutableMapOf<BasePrompt, () -> Unit>()
private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>()) private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>())
private val io = Executors.newSingleThreadExecutor() private val io = Executors.newSingleThreadExecutor()
private var uploadSession = UUID.randomUUID().toString()
private val downloads = java.util.Collections.synchronizedSet(mutableSetOf<DownloadNotifications>()) private val downloads = java.util.Collections.synchronizedSet(mutableSetOf<DownloadNotifications>())
@Volatile private var disposed = false @Volatile private var disposed = false
private var loaded = false private var loaded = false
@@ -54,6 +64,8 @@ internal class GeckoPlatformView(
private var loadFailed = false private var loadFailed = false
init { init {
val appContext = activity.applicationContext
BrowserUploads.executor.execute { runCatching { BrowserUploads.get(appContext) } }
session.progressDelegate = object : GeckoSession.ProgressDelegate { session.progressDelegate = object : GeckoSession.ProgressDelegate {
override fun onPageStart(session: GeckoSession, url: String) { loadFailed = false; emit("onPageStarted") } override fun onPageStart(session: GeckoSession, url: String) { loadFailed = false; emit("onPageStarted") }
override fun onPageStop(session: GeckoSession, success: Boolean) { override fun onPageStop(session: GeckoSession, success: Boolean) {
@@ -122,6 +134,13 @@ internal class GeckoPlatformView(
cancelDownloads() cancelDownloads()
prompts.values.toList().forEach { it() } prompts.values.toList().forEach { it() }
emit("onLoadError", "网页进程已停止,请重试") 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> { private fun chooseOptions(prompt: ChoicePrompt): GeckoResult<PromptResponse> {
@@ -195,15 +214,29 @@ internal class GeckoPlatformView(
private fun chooseFiles(prompt: FilePrompt): GeckoResult<PromptResponse> { private fun chooseFiles(prompt: FilePrompt): GeckoResult<PromptResponse> {
val result = 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) { if (!prompt.isComplete) {
try { try {
result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris)) result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris))
confirmed = !uris.isNullOrEmpty()
} catch (error: Exception) { } catch (error: Exception) {
if (!prompt.isComplete) result.complete(prompt.dismiss()) 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) prompts.remove(prompt)
prompt.setDelegate(null) prompt.setDelegate(null)
} }
@@ -223,12 +256,59 @@ internal class GeckoPlatformView(
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try { try {
activity.launchDocument(this, intent) { code, data -> 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 { else {
val uris = mutableListOf<Uri>() val uris = mutableListOf<Uri>()
data.clipData?.let { clip -> for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).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) } 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() } } catch (error: Exception) { finish(null); Toast.makeText(activity, "无法打开文件选择器:" + error.message, Toast.LENGTH_LONG).show() }
@@ -335,6 +415,7 @@ internal class GeckoPlatformView(
view.releaseSession() view.releaseSession()
session.close() session.close()
} }
closeUploads()
session.progressDelegate = null session.progressDelegate = null
session.navigationDelegate = null session.navigationDelegate = null
session.contentDelegate = 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())
}
}
+2 -1
View File
@@ -69,7 +69,7 @@ if [ "$REBUILD" = true ] || ! docker image inspect "$IMAGE" > /dev/null 2>&1; th
echo "==> 构建镜像 $IMAGE(首次较慢,需下载 Flutter/Android SDK)" echo "==> 构建镜像 $IMAGE(首次较慢,需下载 Flutter/Android SDK)"
BUILD_ARGS=() BUILD_ARGS=()
if [ "$REBUILD" = true ]; then BUILD_ARGS+=(--no-cache); fi 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 fi
echo "==> 在容器内构建 release APK" echo "==> 在容器内构建 release APK"
@@ -80,6 +80,7 @@ docker run --rm \
-e DSH_KEYSTORE=/run/dsh-signing/release.keystore \ -e DSH_KEYSTORE=/run/dsh-signing/release.keystore \
-e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \ -e DSH_KEY_ALIAS -e DSH_STORE_PASSWORD -e DSH_KEY_PASSWORD \
-e http_proxy -e https_proxy -e no_proxy \ -e http_proxy -e https_proxy -e no_proxy \
-e JAVA_TOOL_OPTIONS \
-e PUB_CACHE=/opt/pub-cache \ -e PUB_CACHE=/opt/pub-cache \
-e DSH_SITE_URL="$DSH_SITE_URL" \ -e DSH_SITE_URL="$DSH_SITE_URL" \
"$IMAGE" \ "$IMAGE" \
+1 -1
View File
@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.13.5 sdk: ^3.13.5