fix(android): support document uploads and bump version to 1.0.4
This commit is contained in:
@@ -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 \
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user