StandAPP/app/src/main/java/com/stand/standapp/MainActivity.kt

501 lines
19 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.stand.standapp
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
import android.widget.FrameLayout
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSessionSettings
import org.mozilla.geckoview.GeckoView
import org.mozilla.geckoview.StorageController.ClearFlags
import org.mozilla.geckoview.WebRequestError
import timber.log.Timber
class MainActivity : AppCompatActivity() {
companion object {
private var instance: MainActivity? = null
// 防止对话框堆叠
@Volatile
private var isConflictDialogShowing = false
// 待显示的弹窗instance 为 null 时暂存)
@Volatile
private var pendingDialog: Pair<String, String>? = null
// 待执行的 JS 代码(由 bridge.js 轮询获取)
@Volatile
private var pendingJsCode: String? = null
@JvmStatic
fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script)
synchronized(pendingJsCode ?: Any()) {
// 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
}
}
@JvmStatic
fun onPttLoginSuccess() {
Timber.tag("MainActivity").d("onPttLoginSuccess called from Native")
executeJs("onPttLoginSuccess()")
}
// 硬件按键触发
@JvmStatic
fun onHardwarePttDown() {
instance?.runOnUiThread {
instance?.pttInterface?.pttSpeakPlay()
executeJs("onHardwarePttDown()")
}
}
@JvmStatic
fun onHardwarePttUp() {
instance?.runOnUiThread {
instance?.pttInterface?.pttSpeakRelease()
executeJs("onHardwarePttUp()")
}
}
@JvmStatic
fun showPttConflictDialog() {
if (isConflictDialogShowing) return
isConflictDialogShowing = true
instance?.runOnUiThread {
val ctx = instance ?: return@runOnUiThread
androidx.appcompat.app.AlertDialog.Builder(ctx)
.setTitle("账号冲突")
.setMessage("您的 PTT 账号已在其他设备登录,当前设备已被迫下线。\n请点击确认重启应用。")
.setCancelable(false)
.setPositiveButton("确认") { _, _ ->
com.stand.standapp.utils.AppKiller.killApp(ctx, restart = true)
}
.setOnDismissListener { isConflictDialogShowing = false }
.show()
}
}
@JvmStatic
fun showPttUpdateDialog(msg: String, title: String) {
if (isConflictDialogShowing) return
val ctx = instance
if (ctx == null) {
pendingDialog = title to msg
return
}
isConflictDialogShowing = true
ctx.runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(ctx)
.setTitle(title)
.setMessage(msg)
.setCancelable(false)
.setPositiveButton("确认") { _, _ ->
com.stand.standapp.utils.AppKiller.killApp(ctx, restart = true)
}
.setOnDismissListener { isConflictDialogShowing = false }
.show()
}
}
@JvmStatic
fun showPendingDialogIfNeeded() {
val pending = pendingDialog ?: return
pendingDialog = null
showPttUpdateDialog(pending.second, pending.first)
}
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic
fun pollPendingJs(): String {
synchronized(pendingJsCode ?: Any()) {
val code = pendingJsCode ?: ""
pendingJsCode = null
return code
}
}
}
private var geckoSession: GeckoSession? = null
private var geckoRuntime: GeckoRuntime? = null
private var pttInterface: AndroidInterface? = null
private var canGoBack = false
private lateinit var geckoView: GeckoView
private var officialMode = false
private var loginData = ""
private val mHandler = android.os.Handler(android.os.Looper.getMainLooper())
private val TIMEOUT_MS = 10000L
private val timeoutRunnable = Runnable {
showErrorPage()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
instance = this
showPendingDialogIfNeeded()
// 读取登录模式和数据
officialMode = intent.getBooleanExtra("official_mode", false)
loginData = intent.getStringExtra("login_data") ?: ""
try {
setContentView(R.layout.activity_main)
geckoView = findViewById<GeckoView>(R.id.geckoView)
?: return
findViewById<android.widget.Button>(R.id.btn_retry)?.setOnClickListener {
hideErrorPage()
geckoSession?.reload()
startTimeoutTimer()
}
// 创建 GeckoRuntime
geckoRuntime = createGeckoRuntime()
// 创建 AndroidInterface
pttInterface = AndroidInterface(this)
// 创建 GeckoSession 并配置(但不加载页面)
val session = createSession()
geckoSession = session
geckoView.setSession(session)
startTimeoutTimer()
// 安装桥接扩展,等安装完成后再加载页面
geckoRuntime?.webExtensionController?.installBuiltIn(
"resource://android/assets/extensions/bridge/"
)?.accept { extension ->
Timber.tag("MainActivity").d("WebExtension installed: ${extension?.metaData?.name}")
runOnUiThread {
val serverUrl = AppConfig.getServerUrl(this)
val finalUrl = if (officialMode) {
"$serverUrl/sysdispatch/home?t=${System.currentTimeMillis()}"
} else {
"$serverUrl/example/print_demo.html?t=${System.currentTimeMillis()}"
}
Timber.tag("MainActivity").d("Loading URL: $finalUrl (officialMode=$officialMode)")
session.loadUri(finalUrl)
}
}
// 启动时检查更新
UpdateManager(this).checkUpdate()
// 👈 动态注入 Compose 容器,让 FloatingChatWidget 呈现在最上层!
val composeView = androidx.compose.ui.platform.ComposeView(this).apply {
setContent {
com.stand.standapp.ui.chat.FloatingChatWidget()
}
}
findViewById<FrameLayout>(android.R.id.content)?.addView(
composeView,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
)
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Fatal Crash")
}
}
private fun createGeckoRuntime(): GeckoRuntime {
val runtime = GeckoRuntime.getDefault(this)
runtime.settings.aboutConfigEnabled = true
runtime.settings.consoleOutputEnabled = true
// 强制清缓存,确保 JS/CSS 等资源获取最新版本
try {
// runtime.storageController.clearData(ClearFlags.ALL)
Timber.tag("MainActivity").i("GeckoView cache cleared")
} catch (e: Exception) {
Timber.tag("MainActivity").w(e, "Failed to clear GeckoView cache")
}
return runtime
}
private fun createSession(): GeckoSession {
val session = GeckoSession()
val sessionSettings = session.settings
// 配置 GeckoSession 设置(对应原 WebView settings
sessionSettings.allowJavascript = true
sessionSettings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_MOBILE
val runtime = geckoRuntime ?: return session
// 设置各种代理
setupDelegates(session)
// 打开会话
session.open(runtime)
return session
}
private fun setupDelegates(session: GeckoSession) {
// NavigationDelegate - 处理页面加载错误
session.navigationDelegate = object : GeckoSession.NavigationDelegate {
override fun onLoadError(
session: GeckoSession,
uri: String?,
error: WebRequestError
): GeckoResult<String>? {
Timber.tag("MainActivity").e("Page load error: ${error.code} - ${error.category}")
runOnUiThread { showErrorPage() }
return null
}
override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
this@MainActivity.canGoBack = canGoBack
}
override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
}
}
// ProgressDelegate - 页面加载进度
session.progressDelegate = object : GeckoSession.ProgressDelegate {
override fun onPageStart(session: GeckoSession, url: String) {
Timber.tag("MainActivity").d("Page started: $url")
}
override fun onPageStop(session: GeckoSession, success: Boolean) {
Timber.tag("MainActivity").d("Page stopped, success: $success")
// 正式界面模式下,页面加载完成后传递登录数据给前端
// if (success && officialMode && loginData.isNotEmpty()) {
// // 延迟 500ms 确保 JS 上下文就绪
// mHandler.postDelayed({
// val escaped = loginData.replace("'", "\\'").replace("\n", "\\n")
// executeJs("if(window.appLogin){window.appLogin('$escaped');}")
// Timber.tag("MainActivity").d("Called appLogin with loginData")
// }, 500)
// }
}
}
// PromptDelegate - 处理 JS bridge 和 console 消息
session.promptDelegate = object : GeckoSession.PromptDelegate {
override fun onTextPrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.TextPrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
val message = prompt.message ?: ""
if (message.startsWith("bridge:")) {
// JS bridge 消息
val payload = message.substring(7)
if (payload == "_poll") {
// 轮询待执行的 JS 代码
val code = pollPendingJs()
return GeckoResult.fromValue(prompt.confirm(code))
}
val result = handleBridgeCall(payload, prompt.defaultValue ?: "")
return GeckoResult.fromValue(prompt.confirm(result))
}
// 默认处理(显示对话框)
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
val input = android.widget.EditText(this@MainActivity)
input.setText(prompt.defaultValue)
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
.setTitle(prompt.title ?: "")
.setMessage(prompt.message)
.setView(input)
.setPositiveButton("确定") { _, _ -> res.complete(prompt.confirm(input.text.toString())) }
.setNegativeButton("取消") { _, _ -> res.complete(prompt.dismiss()) }
.setOnCancelListener { res.complete(prompt.dismiss()) }
.show()
}
return res
}
override fun onAlertPrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.AlertPrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
.setMessage(prompt.message)
.setPositiveButton("确定") { _, _ -> res.complete(prompt.dismiss()) }
.setOnCancelListener { res.complete(prompt.dismiss()) }
.show()
}
return res
}
override fun onAuthPrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.AuthPrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
return null
}
override fun onChoicePrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.ChoicePrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
val choices = prompt.choices.map { it.label }.toTypedArray()
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
.setTitle(prompt.title)
.setSingleChoiceItems(choices, -1) { dialog, which ->
dialog.dismiss()
res.complete(prompt.confirm(prompt.choices[which]))
}
.setOnCancelListener { res.complete(prompt.dismiss()) }
.show()
}
return res
}
override fun onButtonPrompt(
session: GeckoSession,
prompt: GeckoSession.PromptDelegate.ButtonPrompt
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse>? {
val res = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
runOnUiThread {
androidx.appcompat.app.AlertDialog.Builder(this@MainActivity)
.setTitle(prompt.title ?: "提示")
.setMessage(prompt.message)
.setPositiveButton("确定") { _, _ -> res.complete(prompt.confirm(GeckoSession.PromptDelegate.ButtonPrompt.Type.POSITIVE)) }
.setNegativeButton("取消") { _, _ -> res.complete(prompt.dismiss()) }
.setOnCancelListener { res.complete(prompt.dismiss()) }
.show()
}
return res
}
}
}
private fun handleBridgeCall(method: String, argsJson: String): String {
return try {
pttInterface?.dispatch(method, argsJson) ?: ""
} catch (e: Exception) {
Timber.tag("Bridge").e(e, "Bridge call failed: $method")
"{\"error\":\"${e.message}\"}"
}
}
fun getGeckoSession(): GeckoSession? {
return geckoSession
}
fun getAndroidInterface(): AndroidInterface? {
return pttInterface
}
fun showExitDialog() {
executeJs("addExitText()")
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("提示")
.setMessage("确定要退出应用吗?")
.setPositiveButton("确定") { _, _ ->
com.stand.standapp.utils.AppKiller.killApp(this)
}
.setNegativeButton("取消", null)
.show()
}
private fun pttLogoutAndDestroy() {
try {
com.example.kingway.ptt.SendAtUtil.toCancelLogin()
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT cancel failed")
}
try {
com.example.kingway.ptt.SendAtUtil.toLogout()
Timber.tag("LoginActivity").i("PTT logout sent")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT logout failed")
}
try {
com.stand.standapp.utils.GpioManager.stopListening()
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "GPIO stop failed")
}
try {
com.stand.standapp.utils.GpioManager.stopListening()
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "GPIO stop failed")
}
try {
// com.example.kingway.ptt.MyApplication.destroy()
Timber.tag("LoginActivity").i("PTT native destroyed")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "PTT destroy failed")
}
try {
// PrinterManager.shutdown()
Timber.tag("LoginActivity").i("Printer shut down")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "Printer shutdown failed")
}
try {
com.stand.standapp.utils.LogManager.shutdown()
Timber.tag("LoginActivity").i("LogManager shut down")
} catch (e: Exception) {
Timber.tag("LoginActivity").e(e, "LogManager shutdown failed")
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) {
val session = geckoSession
if (canGoBack && session != null) {
session.goBack()
return true
}
showExitDialog()
return true
}
return super.onKeyDown(keyCode, event)
}
override fun onDestroy() {
cancelTimeoutTimer()
geckoView.releaseSession()
geckoSession?.close()
geckoSession = null
instance = null
super.onDestroy()
}
private fun startTimeoutTimer() {
cancelTimeoutTimer()
mHandler.postDelayed(timeoutRunnable, TIMEOUT_MS)
}
fun cancelTimeoutTimer() {
mHandler.removeCallbacks(timeoutRunnable)
}
fun isOfficialMode(): Boolean = officialMode
fun getLoginData(): String = loginData
private fun showErrorPage() {
findViewById<android.view.View>(R.id.loading_layout)?.visibility = android.view.View.GONE
findViewById<android.view.View>(R.id.error_layout)?.visibility = android.view.View.VISIBLE
}
private fun hideErrorPage() {
findViewById<android.view.View>(R.id.error_layout)?.visibility = android.view.View.GONE
findViewById<android.view.View>(R.id.loading_layout)?.visibility = android.view.View.VISIBLE
}
}