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 的固定版本
# 在应用构建时从 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 \
+4
View File
@@ -8,6 +8,7 @@ DSH Web Manager 的 Android 客户端:使用随 APK 打包的 Mozilla GeckoVie
- 登录状态持久化:使用非隐私 Gecko profile,持久 Cookie + DOM 存储跨重启保留(会话有效期仍由站点控制)
- 全面屏适配:edge-to-edge、刘海/挖孔 `shortEdges`、深色启动底色无白闪
- 文件上传:`<input type="file">` 走系统文件选择器(工作区上传、备份导入)
- 普通文件上传通过系统授权 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 返回键、横竖屏及前后台切换。
@@ -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())
}
}
+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)"
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" \
+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
# 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