feat(android): bundle GeckoView and bump version to 1.0.2

This commit is contained in:
2026-10-18 19:19:58 +08:00
parent 867989e09b
commit 7f493e0f89
10 changed files with 532 additions and 408 deletions
+6 -2
View File
@@ -13,7 +13,7 @@ if (gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = tr
android {
namespace = "ren.misaka.dshapp"
compileSdk = flutter.compileSdkVersion
compileSdk { version = release(37) { minorApiLevel = 0 } }
ndkVersion = flutter.ndkVersion
compileOptions {
@@ -26,7 +26,7 @@ android {
applicationId = "ren.misaka.dshapp"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 26
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
@@ -73,3 +73,7 @@ kotlin {
flutter {
source = "../.."
}
dependencies {
implementation("org.mozilla.geckoview:geckoview:158.0.20261007201254")
}
@@ -0,0 +1,303 @@
package ren.misaka.dshapp
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import android.view.View
import android.widget.EditText
import android.widget.Toast
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
import org.mozilla.geckoview.*
import org.mozilla.geckoview.GeckoSession.PromptDelegate.*
import java.io.InputStream
import java.util.concurrent.Executors
private object BrowserRuntime {
private var instance: GeckoRuntime? = null
fun get(context: Context): GeckoRuntime = instance ?: GeckoRuntime.create(context.applicationContext).also { instance = it }
}
internal class GeckoViewFactory(private val activity: MainActivity, private val messenger: BinaryMessenger) :
PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
val url = (args as? Map<*, *>)?.get("url") as? String
val uri = url?.let { Uri.parse(it) }
require(uri != null && uri.scheme == "https" && !uri.host.isNullOrBlank() && uri.userInfo == null) {
"GeckoView requires an HTTPS url without credentials"
}
return GeckoPlatformView(activity, context, messenger, viewId, url!!)
}
}
internal class GeckoPlatformView(
private val activity: MainActivity, context: Context, messenger: BinaryMessenger, id: Int,
private val initialUrl: String
) : PlatformView {
private val view = GeckoView(context).apply { setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW) }
private val session = GeckoSession(GeckoSessionSettings.Builder().usePrivateMode(false).build())
private val channel = MethodChannel(messenger, "dsh.workbench/gecko/" + id)
private val prompts = mutableMapOf<BasePrompt, () -> Unit>()
private val streams = java.util.Collections.synchronizedSet(mutableSetOf<InputStream>())
private val io = Executors.newSingleThreadExecutor()
@Volatile private var disposed = false
private var loaded = false
private var opened = false
private var canGoBack = false
private var loadFailed = false
init {
session.progressDelegate = object : GeckoSession.ProgressDelegate {
override fun onPageStart(session: GeckoSession, url: String) { loadFailed = false; emit("onPageStarted") }
override fun onPageStop(session: GeckoSession, success: Boolean) {
if (!loadFailed) emit("onPageFinished")
}
}
session.navigationDelegate = object : GeckoSession.NavigationDelegate {
override fun onCanGoBack(session: GeckoSession, value: Boolean) { canGoBack = value }
override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
loadFailed = true
emit("onLoadError", "页面加载失败 (" + error.code + ")")
return null
}
}
session.contentDelegate = object : GeckoSession.ContentDelegate {
override fun onExternalResponse(session: GeckoSession, response: WebResponse) { saveResponse(response) }
override fun onCrash(session: GeckoSession) { processStopped() }
override fun onKill(session: GeckoSession) { processStopped() }
}
session.promptDelegate = object : GeckoSession.PromptDelegate {
override fun onAlertPrompt(session: GeckoSession, prompt: AlertPrompt) =
dialog(prompt, prompt.message, positive = { prompt.dismiss() })
override fun onButtonPrompt(session: GeckoSession, prompt: ButtonPrompt) =
dialog(prompt, prompt.message, positive = { prompt.confirm(ButtonPrompt.Type.POSITIVE) }, negative = { prompt.confirm(ButtonPrompt.Type.NEGATIVE) })
override fun onTextPrompt(session: GeckoSession, prompt: TextPrompt): GeckoResult<PromptResponse> {
val input = EditText(activity).apply { setText(prompt.defaultValue) }
return dialog(prompt, prompt.message, input, { prompt.confirm(input.text.toString()) }, { prompt.dismiss() })
}
override fun onFilePrompt(session: GeckoSession, prompt: FilePrompt) = chooseFiles(prompt)
override fun onChoicePrompt(session: GeckoSession, prompt: ChoicePrompt) = chooseOptions(prompt)
}
activity.browserViews.add(this)
channel.setMethodCallHandler { call, result ->
try {
when (call.method) {
"load" -> { loadInitial(); result.success(null) }
"goBack" -> { val back = canGoBack; if (back) session.goBack(); result.success(back) }
"reload" -> { if (loaded) session.reload() else loadInitial(); result.success(null) }
else -> result.notImplemented()
}
} catch (error: Exception) { result.error("GECKO_ERROR", error.message, null) }
}
}
private fun emit(method: String, value: String? = null) {
activity.runOnUiThread { if (!disposed) channel.invokeMethod(method, value) }
}
private fun loadInitial() {
if (!opened) {
session.open(BrowserRuntime.get(activity))
opened = true
view.setSession(session)
setActive(activity.browserActive)
}
if (!loaded) { session.loadUri(initialUrl); loaded = true }
}
private fun processStopped() {
opened = false
loaded = false
canGoBack = false
loadFailed = true
view.releaseSession()
activity.cancelDocuments(this)
prompts.values.toList().forEach { it() }
emit("onLoadError", "网页进程已停止,请重试")
}
private fun chooseOptions(prompt: ChoicePrompt): GeckoResult<PromptResponse> {
val result = GeckoResult<PromptResponse>()
val choices = mutableListOf<ChoicePrompt.Choice>()
fun flatten(items: Array<ChoicePrompt.Choice>) {
items.filter { !it.disabled && !it.separator }.forEach { choice ->
val children = choice.items
if (children != null) flatten(children) else choices.add(choice)
}
}
flatten(prompt.choices)
var dialog: AlertDialog? = null
fun finish(ids: Array<String>?) {
if (!prompt.isComplete) result.complete(if (ids == null) prompt.dismiss() else prompt.confirm(ids))
prompt.setDelegate(null)
prompts.remove(prompt)
dialog?.dismiss()
}
prompts[prompt] = { finish(null) }
prompt.setDelegate(object : PromptInstanceDelegate {
override fun onPromptDismiss(prompt: BasePrompt) { finish(null) }
override fun onPromptUpdate(prompt: BasePrompt) { finish(null) }
})
val labels = choices.map { it.label }.toTypedArray()
val builder = AlertDialog.Builder(activity).setTitle(prompt.title)
.setNegativeButton(android.R.string.cancel) { _, _ -> finish(null) }
.setOnCancelListener { finish(null) }
if (prompt.type == ChoicePrompt.Type.MULTIPLE) {
val checked = choices.map { it.selected }.toBooleanArray()
builder.setMultiChoiceItems(labels, checked) { _, index, selected -> checked[index] = selected }
.setPositiveButton(android.R.string.ok) { _, _ ->
finish(choices.filterIndexed { index, _ -> checked[index] }.map { it.id }.toTypedArray())
}
} else {
builder.setSingleChoiceItems(labels, choices.indexOfFirst { it.selected }) { _, index ->
finish(arrayOf(choices[index].id))
}
}
dialog = builder.create()
dialog.show()
return result
}
private fun dialog(
prompt: BasePrompt, message: String?, input: EditText? = null,
positive: () -> PromptResponse, negative: (() -> PromptResponse)? = null
): GeckoResult<PromptResponse> {
val result = GeckoResult<PromptResponse>()
var dialog: AlertDialog? = null
fun finish(response: () -> PromptResponse) {
if (!prompt.isComplete) result.complete(response())
prompts.remove(prompt)
prompt.setDelegate(null)
dialog?.dismiss()
}
val cancel = { finish { prompt.dismiss() } }
prompts[prompt] = cancel
prompt.setDelegate(object : PromptInstanceDelegate {
override fun onPromptDismiss(prompt: BasePrompt) { cancel() }
})
val builder = AlertDialog.Builder(activity).setTitle(prompt.title).setMessage(message)
.setPositiveButton(android.R.string.ok) { _, _ -> finish(positive) }
.setOnCancelListener { cancel() }
if (input != null) builder.setView(input)
if (negative != null) builder.setNegativeButton(android.R.string.cancel) { _, _ -> finish(negative) }
dialog = builder.create()
dialog.show()
return result
}
private fun chooseFiles(prompt: FilePrompt): GeckoResult<PromptResponse> {
val result = GeckoResult<PromptResponse>()
fun finish(uris: Array<Uri>?) {
if (!prompt.isComplete) {
try {
result.complete(if (uris.isNullOrEmpty()) prompt.dismiss() else prompt.confirm(activity.applicationContext, uris))
} catch (error: Exception) {
if (!prompt.isComplete) result.complete(prompt.dismiss())
Toast.makeText(activity, "无法读取所选文件:" + error.message, Toast.LENGTH_LONG).show()
}
}
prompts.remove(prompt)
prompt.setDelegate(null)
}
prompts[prompt] = { finish(null) }
prompt.setDelegate(object : PromptInstanceDelegate {
override fun onPromptDismiss(prompt: BasePrompt) { finish(null) }
})
val folder = prompt.type == FilePrompt.Type.FOLDER
val intent = Intent(if (folder) Intent.ACTION_OPEN_DOCUMENT_TREE else Intent.ACTION_OPEN_DOCUMENT)
if (!folder) {
intent.addCategory(Intent.CATEGORY_OPENABLE)
val types = prompt.mimeTypes?.filter { it.contains("/") }?.toTypedArray()
intent.type = types?.singleOrNull() ?: "*/*"
if (!types.isNullOrEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types)
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, prompt.type == FilePrompt.Type.MULTIPLE)
}
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
activity.launchDocument(this, intent) { code, data ->
if (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())
}
}
} catch (error: Exception) { finish(null); Toast.makeText(activity, "无法打开文件选择器:" + error.message, Toast.LENGTH_LONG).show() }
return result
}
private fun saveResponse(response: WebResponse) {
if (!loadFailed) emit("onPageFinished")
val body = response.body
if (body == null) { emit("onDownloadError", "下载响应没有内容"); return }
if (disposed) { runCatching { body.close() }; return }
streams.add(body)
try {
val uri = Uri.parse(response.uri)
val disposition = response.headers.entries.firstOrNull { it.key.equals("content-disposition", true) }?.value
val encodedName = disposition?.let { Regex("(?i)filename\\*\\s*=\\s*UTF-8''([^;]+)").find(it)?.groupValues?.get(1) }
val headerName = encodedName?.let { Uri.decode(it) } ?: disposition?.let {
Regex("(?i)filename\\s*=\\s*(?:\"([^\"]*)\"|([^;]*))").find(it)?.let { match ->
match.groupValues[1].ifEmpty { match.groupValues[2].trim() }
}
}
val name = (headerName ?: (if (uri.isHierarchical) uri.getQueryParameter("path")?.substringAfterLast('/') else null) ?: uri.lastPathSegment ?: "download")
.map { if (it.isISOControl() || it in "\\/:*?\"<>|") '_' else it }.joinToString("")
.take(120).ifBlank { "download" }
val mime = response.headers.entries.firstOrNull { it.key.equals("content-type", true) }?.value?.substringBefore(';') ?: "application/octet-stream"
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE)
.setType(mime).putExtra(Intent.EXTRA_TITLE, name)
activity.launchDocument(this, intent) { code, data ->
val destination = data?.data
if (code != Activity.RESULT_OK || destination == null || disposed) {
closeBody(body)
} else {
io.execute {
try {
check(!disposed) { "下载已取消" }
activity.contentResolver.openOutputStream(destination, "w").use { output ->
checkNotNull(output) { "无法打开目标文件" }
body.use { it.copyTo(output, 64 * 1024) }
}
activity.runOnUiThread { if (!disposed) Toast.makeText(activity, "下载已保存", Toast.LENGTH_SHORT).show() }
} catch (error: Exception) {
runCatching { DocumentsContract.deleteDocument(activity.contentResolver, destination) }
emit("onDownloadError", "保存下载失败:" + error.message)
} finally { closeBody(body) }
}
}
}
} catch (error: Exception) { closeBody(body); emit("onDownloadError", "无法保存下载:" + error.message) }
}
private fun closeBody(body: InputStream) { streams.remove(body); runCatching { body.close() } }
internal fun setActive(active: Boolean) { if (!disposed && opened) { session.setActive(active); session.setFocused(active) } }
override fun getView(): View = view
override fun dispose() {
if (disposed) return
disposed = true
channel.setMethodCallHandler(null)
activity.cancelDocuments(this)
prompts.values.toList().forEach { it() }
synchronized(streams) { streams.toList() }.forEach { closeBody(it) }
io.shutdown()
if (opened) {
session.setActive(false)
view.releaseSession()
session.close()
}
session.progressDelegate = null
session.navigationDelegate = null
session.contentDelegate = null
session.promptDelegate = null
activity.browserViews.remove(this)
}
}
@@ -1,92 +1,62 @@
package ren.misaka.dshapp
import android.app.DownloadManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.webkit.CookieManager
import android.widget.Toast
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
private const val CHANNEL = "dsh.workbench/downloads"
}
internal val browserViews = mutableSetOf<GeckoPlatformView>()
private val requests = mutableMapOf<Int, Pair<GeckoPlatformView, (Int, Intent?) -> Unit>>()
private var nextRequest = 2000
internal var browserActive = false
private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"download" -> {
val url = call.argument<String>("url")
if (url == null) {
result.error("ARG_MISSING", "url is required", null)
return@setMethodCallHandler
}
try {
startDownload(url)
result.success(true)
} catch (e: Exception) {
result.error("DOWNLOAD_FAILED", e.message, null)
}
}
else -> result.notImplemented()
}
}
flutterEngine.platformViewsController.registry.registerViewFactory(
"dsh.workbench/gecko", GeckoViewFactory(this, flutterEngine.dartExecutor.binaryMessenger)
)
}
/// 把下载交给系统 DownloadManager,并携带面板登录 Cookie
/// (下载接口与站点同源、需要 JWT 会话,不能走无 Cookie 的外部浏览器)。
private fun startDownload(url: String) {
val request = DownloadManager.Request(Uri.parse(url))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
CookieManager.getInstance().getCookie(url)?.takeIf { it.isNotEmpty() }?.let { cookie ->
request.addRequestHeader("Cookie", cookie)
internal fun launchDocument(owner: GeckoPlatformView, intent: Intent, callback: (Int, Intent?) -> Unit) {
val code = nextRequest++
check(code <= 65535) { "Too many document requests" }
requests[code] = owner to callback
try {
startActivityForResult(intent, code)
} catch (error: Exception) {
requests.remove(code)
throw error
}
val fileName = deriveFileName(url)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// DownloadManager 不接受 MediaStore 的 content:// URI。
// targetSdk >= 29 时,系统下载器写公共 Downloads 不需要存储权限。
request.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS, fileName
)
} else {
// Android 9-:公共目录写入需运行时权限;退到应用外部目录(免权限)
@Suppress("DEPRECATION")
request.setDestinationInExternalFilesDir(
this, Environment.DIRECTORY_DOWNLOADS, fileName
)
}
val manager = getSystemService(DownloadManager::class.java)
manager.enqueue(request)
val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"系统下载目录"
} else {
"应用下载目录"
}
Toast.makeText(this, "已开始下载到$destination:$fileName", Toast.LENGTH_SHORT).show()
}
/// 从下载 URL 推断文件名,优先取 path 参数的 basename(即真实文件名)。
private fun deriveFileName(url: String): String {
val uri = Uri.parse(url)
uri.getQueryParameter("path")?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
?.let { return sanitize(it) }
uri.lastPathSegment?.takeIf { it.isNotBlank() && it != "download" }
?.let { return sanitize(it) }
return "dsh-download-${System.currentTimeMillis()}"
internal fun cancelDocuments(owner: GeckoPlatformView) {
requests.entries.filter { it.value.first === owner }.map { it.key }.forEach { code ->
requests.remove(code)?.second?.invoke(RESULT_CANCELED, null)
}
}
private fun sanitize(name: String): String =
name.replace(Regex("[\\\\/:*?\"<>|]"), "_").take(120).ifBlank { "file" }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val request = requests.remove(requestCode)
if (request != null) request.second(resultCode, data)
else super.onActivityResult(requestCode, resultCode, data)
}
override fun onResume() {
super.onResume()
browserActive = true
browserViews.forEach { it.setActive(true) }
}
override fun onPause() {
browserActive = false
browserViews.forEach { it.setActive(false) }
super.onPause()
}
override fun onDestroy() {
browserViews.toList().forEach { it.dispose() }
requests.clear()
super.onDestroy()
}
}