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

639 lines
24 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 com.example.kingway.ptt.CallBackResolution
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
private val jsLock = Any()
@JvmStatic
fun executeJs(script: String) {
Timber.tag("ExecuteJs").d(script)
synchronized(jsLock) {
// 合并多次调用(用换行分隔)
pendingJsCode = if (pendingJsCode != null) "$pendingJsCode;$script" else script
}
}
@Volatile
private var hasFirstGroupEntered = false
@Volatile
private var hasPageFinished = false
@Volatile
private var hasAutoJumped = false
@JvmStatic
fun onFirstGroupEntered() {
Timber.tag("PTT").d("Condition met: First group entered")
hasFirstGroupEntered = true
checkAndAutoJumpToLastGroup()
}
@JvmStatic
fun onPageLoadFinished() {
Timber.tag("PTT").d("Condition met: Web page loaded and initialized")
hasPageFinished = true
checkAndAutoJumpToLastGroup()
}
@JvmStatic
fun resetAutoJumpStates() {
hasFirstGroupEntered = false
hasPageFinished = false
hasAutoJumped = false
Timber.tag("PTT").d("Auto jump states reset")
}
@JvmStatic
fun checkAndAutoJumpToLastGroup() {
// synchronized(this) {
// if (hasFirstGroupEntered && hasPageFinished && !hasAutoJumped) {
// hasAutoJumped = true
// instance?.let { ctx ->
// val lastGroupId = AppConfig.getLastGroupId(ctx)
// if (lastGroupId.isNotEmpty()) {
// Timber.tag("PTT").d("Both conditions met. Auto-jumping to last joined group: %s after 2s delay", lastGroupId)
// ctx.runOnUiThread {
// android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
// instance?.pttInterface?.pttJumpGroup(lastGroupId)
// }, 1000)
// }
// } else {
// Timber.tag("PTT").d("Both conditions met but lastGroupId is empty, skip auto-jump")
// }
// }
// } else {
// Timber.tag("PTT").d("Check auto jump: hasFirstGroupEntered=$hasFirstGroupEntered, hasPageFinished=$hasPageFinished, hasAutoJumped=$hasAutoJumped")
// }
// }
}
@JvmStatic
fun onPttLoginSuccess() {
Timber.tag("MainActivity").d("onPttLoginSuccess called from Native")
executeJs("onPttLoginSuccess()")
}
// 硬件按键触发
@JvmStatic
fun onHardwarePttDown() {
Timber.tag("MainActivity").d("onHardwarePttDown triggered")
instance?.runOnUiThread {
instance?.pttInterface?.pttSpeakPlay()
executeJs("onHardwarePttDown()")
}
}
@JvmStatic
fun onHardwarePttUp() {
Timber.tag("MainActivity").d("onHardwarePttUp triggered")
instance?.runOnUiThread {
instance?.pttInterface?.pttSpeakRelease()
executeJs("onHardwarePttUp()")
}
}
@JvmStatic
fun showPttConflictDialog() {
Timber.tag("PTT").w("showPttConflictDialog: account conflict detected")
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) {
Timber.tag("PTT").w("showPttUpdateDialog: title=%s msg=%s", title, msg)
if (isConflictDialogShowing) return
val ctx = instance
if (ctx == null) {
pendingDialog = title to msg
Timber.tag("PTT").d("Pending dialog stored (instance null): title=%s", title)
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
Timber.tag("PTT").d("Flushing pending dialog: %s", pending.second)
pendingDialog = null
showPttUpdateDialog(pending.second, pending.first)
}
// 获取并清除待执行的 JS 代码(由 bridge.js 轮询)
@JvmStatic
fun pollPendingJs(): String {
synchronized(jsLock) {
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 {
// 1. 将配置写入应用的本地沙盒文件 (YAML 格式)
val configFile = java.io.File(filesDir, "geckoview-config.yaml")
try {
val configContent = """
prefs:
layout.testing.overlay-scrollbars.always-visible: true
""".trimIndent()
configFile.writeText(configContent)
Timber.tag("MainActivity").d("Wrote GeckoView config to: ${configFile.absolutePath}")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to write geckoview-config.yaml")
}
// 2. 通过 GeckoRuntimeSettings.Builder 加载该 YAML 配置文件并创建 GeckoRuntime 实例
val settingsBuilder = org.mozilla.geckoview.GeckoRuntimeSettings.Builder()
.aboutConfigEnabled(true)
.consoleOutput(true)
if (configFile.exists()) {
settingsBuilder.configFilePath(configFile.absolutePath)
}
val runtime = GeckoRuntime.create(this, settingsBuilder.build())
// 3. 强制清缓存,确保 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()
}
fun logout() {
runOnUiThread {
Timber.tag("MainActivity").i("用户注销,清理程序")
// 1. PTT Logout and cancel login
// try {
// com.example.kingway.ptt.SendAtUtil.toCancelLogin()
// } catch (e: Exception) {
// Timber.tag("MainActivity").e(e, "PTT cancel failed")
// }
// try {
// com.example.kingway.ptt.SendAtUtil.toLogout()
// Timber.tag("MainActivity").i("PTT logout sent")
// } catch (e: Exception) {
// Timber.tag("MainActivity").e(e, "PTT logout failed")
// }
// 2. Stop GPIO listening
try {
com.stand.standapp.utils.GpioManager.stopListening()
Timber.tag("MainActivity").i("GPIO stop listening")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "GPIO stop failed")
}
try {
resetAutoJumpStates()
CallBackResolution.resetPttLoginStatus();
}catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to clear public value")
}
// 4. Stop and destroy AudioRecord
try {
com.example.kingway.ptt.AudioRecordManager.destroy()
Timber.tag("MainActivity").i("AudioRecordManager destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "AudioRecordManager destroy failed")
}
// 3. Destroy/release AudioTrack in PTT Native
try {
com.stand.standapp.MyApplication.pNative?.destory()
com.stand.standapp.MyApplication.pNative = null
Timber.tag("MainActivity").i("PTT native AudioTrack destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "PTT native destroy failed")
}
// 5. Close printer connection and reset
try {
com.stand.standapp.printer.PrinterManager.destroy()
Timber.tag("MainActivity").i("Printer destroyed")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Printer destroy failed")
}
// 6. Clear Access Token
try {
AppConfig.setAccessToken(this, "")
Timber.tag("MainActivity").i("Cleared access token")
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to clear access token")
}
// 7. Redirect back to LoginActivity and clear task stack
try {
val intent = Intent(this, LoginActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
} catch (e: Exception) {
Timber.tag("MainActivity").e(e, "Failed to redirect to LoginActivity")
}
}
}
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 onPause() {
super.onPause()
geckoSession?.setActive(false)
}
override fun onResume() {
super.onResume()
geckoSession?.setActive(true)
}
override fun onDestroy() {
cancelTimeoutTimer()
geckoView.releaseSession()
geckoSession?.close()
geckoSession = null
clearCompanionState()
instance = null
super.onDestroy()
}
private fun clearCompanionState() {
synchronized(jsLock) {
pendingJsCode = null
}
pendingDialog = null
isConflictDialogShowing = false
}
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
}
}