501 lines
19 KiB
Kotlin
501 lines
19 KiB
Kotlin
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
|
||
}
|
||
}
|