501 lines
20 KiB
Kotlin
501 lines
20 KiB
Kotlin
package com.cyjai.agent
|
||
|
||
import android.Manifest
|
||
import android.annotation.SuppressLint
|
||
import android.app.DownloadManager
|
||
import android.content.Intent
|
||
import android.content.pm.PackageManager
|
||
import android.graphics.Color
|
||
import android.net.Uri
|
||
import android.os.Build
|
||
import android.os.Bundle
|
||
import android.os.Environment
|
||
import android.provider.DocumentsContract
|
||
import android.util.Log
|
||
import android.view.ViewGroup
|
||
import android.view.View
|
||
import android.content.Context
|
||
import android.webkit.CookieManager
|
||
import android.webkit.JavascriptInterface
|
||
import android.webkit.PermissionRequest
|
||
import android.webkit.ValueCallback
|
||
import android.webkit.WebChromeClient
|
||
import android.webkit.WebResourceRequest
|
||
import android.webkit.WebSettings
|
||
import android.webkit.WebView
|
||
import android.webkit.WebViewClient
|
||
import android.widget.Toast
|
||
import androidx.activity.OnBackPressedCallback
|
||
import androidx.activity.result.ActivityResultLauncher
|
||
import androidx.activity.result.contract.ActivityResultContracts
|
||
import androidx.appcompat.app.AppCompatActivity
|
||
import androidx.core.app.ActivityCompat
|
||
import androidx.core.content.ContextCompat
|
||
import androidx.core.view.ViewCompat
|
||
import androidx.core.view.WindowInsetsCompat
|
||
import androidx.core.view.WindowCompat
|
||
import androidx.lifecycle.lifecycleScope
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.withContext
|
||
import java.util.Locale
|
||
|
||
class MainActivity : AppCompatActivity() {
|
||
|
||
private lateinit var webView: WebView
|
||
private var filePathCallback: ValueCallback<Array<Uri>>? = null
|
||
private var voiceBridge: VoiceBridge? = null
|
||
|
||
companion object {
|
||
private const val TAG = "MainActivity"
|
||
private const val REQUEST_RECORD_AUDIO = 300
|
||
private const val REQUEST_WRITE_STORAGE = 301
|
||
private const val HOME_URL = "https://agent.cyjai.com"
|
||
private const val WEB_ASSET_VERSION = "20260624_1"
|
||
}
|
||
|
||
private val fileChooserLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
||
ActivityResultContracts.StartActivityForResult()
|
||
) { result ->
|
||
val results = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
|
||
filePathCallback?.onReceiveValue(results)
|
||
filePathCallback = null
|
||
}
|
||
|
||
@SuppressLint("SetJavaScriptEnabled")
|
||
override fun onCreate(savedInstanceState: Bundle?) {
|
||
super.onCreate(savedInstanceState)
|
||
|
||
webView = WebView(this)
|
||
webView.layoutParams = ViewGroup.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
ViewGroup.LayoutParams.MATCH_PARENT
|
||
)
|
||
webView.isVerticalScrollBarEnabled = false
|
||
webView.isHorizontalScrollBarEnabled = false
|
||
webView.overScrollMode = View.OVER_SCROLL_NEVER
|
||
setContentView(webView)
|
||
|
||
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
|
||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||
view.setPadding(0, 0, 0, systemBars.bottom)
|
||
insets
|
||
}
|
||
ViewCompat.requestApplyInsets(webView)
|
||
|
||
val cookieManager = CookieManager.getInstance()
|
||
cookieManager.setAcceptCookie(true)
|
||
cookieManager.setAcceptThirdPartyCookies(webView, true)
|
||
|
||
webView.settings.apply {
|
||
javaScriptEnabled = true
|
||
domStorageEnabled = true
|
||
databaseEnabled = true
|
||
mediaPlaybackRequiresUserGesture = false
|
||
// 你的 Nginx 对静态资源设置了 immutable 长缓存,这里对 App 侧禁用缓存,确保样式更新及时生效
|
||
cacheMode = WebSettings.LOAD_NO_CACHE
|
||
useWideViewPort = true
|
||
loadWithOverviewMode = true
|
||
setSupportZoom(false)
|
||
builtInZoomControls = false
|
||
displayZoomControls = false
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||
safeBrowsingEnabled = true
|
||
}
|
||
}
|
||
webView.clearCache(true)
|
||
|
||
webView.addJavascriptInterface(ThemeBridge(), "AndroidThemeBridge")
|
||
webView.addJavascriptInterface(PdfPreviewBridge(), "AndroidPdfBridge")
|
||
webView.addJavascriptInterface(DownloadBridge(), "AndroidDownloadBridge")
|
||
|
||
// 语音识别桥接(立即注册,前端始终可检测到;引擎在首次使用时懒初始化)
|
||
voiceBridge = VoiceBridge(this@MainActivity, webView)
|
||
webView.addJavascriptInterface(voiceBridge!!, "AndroidVoiceBridge")
|
||
|
||
// 后台检查并下载模型
|
||
lifecycleScope.launch(Dispatchers.IO) {
|
||
ModelManager.init(this@MainActivity)
|
||
if (!ModelManager.isModelReady(this@MainActivity)) {
|
||
Log.i(TAG, "首次启动,开始下载语音模型...")
|
||
withContext(Dispatchers.Main) {
|
||
injectVoiceStatus("downloading")
|
||
}
|
||
val success = ModelManager.downloadModels(this@MainActivity) { pct, msg ->
|
||
Log.i(TAG, "模型下载: ${pct}% - $msg")
|
||
runOnUiThread {
|
||
injectVoiceDownloadProgress(pct, msg)
|
||
}
|
||
}
|
||
if (!success) {
|
||
Log.e(TAG, "模型下载失败")
|
||
withContext(Dispatchers.Main) {
|
||
injectVoiceStatus("error")
|
||
}
|
||
return@launch
|
||
}
|
||
}
|
||
Log.i(TAG, "模型就绪")
|
||
withContext(Dispatchers.Main) {
|
||
injectVoiceStatus("ready")
|
||
// 模型就绪后立即预初始化识别引擎,用户点击时直接可用
|
||
voiceBridge?.ensureEngine()
|
||
}
|
||
}
|
||
|
||
// 请求录音权限
|
||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||
!= PackageManager.PERMISSION_GRANTED
|
||
) {
|
||
ActivityCompat.requestPermissions(
|
||
this,
|
||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
||
REQUEST_RECORD_AUDIO
|
||
)
|
||
}
|
||
|
||
// Android 9 及以下需要写外部存储权限才能使用 DownloadManager
|
||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P &&
|
||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||
!= PackageManager.PERMISSION_GRANTED
|
||
) {
|
||
ActivityCompat.requestPermissions(
|
||
this,
|
||
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
|
||
REQUEST_WRITE_STORAGE
|
||
)
|
||
}
|
||
|
||
webView.webViewClient = object : WebViewClient() {
|
||
override fun onPageFinished(view: WebView?, url: String?) {
|
||
super.onPageFinished(view, url)
|
||
injectThemeObserver()
|
||
injectMobileOverlayWidthPatch()
|
||
injectNoPageScrollPatch()
|
||
}
|
||
|
||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||
val url = request?.url?.toString() ?: return false
|
||
if (isApkUrl(url)) {
|
||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||
startActivity(intent)
|
||
return true
|
||
}
|
||
return !url.startsWith(HOME_URL)
|
||
}
|
||
}
|
||
|
||
webView.webChromeClient = object : WebChromeClient() {
|
||
override fun onPermissionRequest(request: PermissionRequest) {
|
||
// 按需放行媒体权限(例如文件上传触发的媒体访问)
|
||
request.grant(request.resources)
|
||
}
|
||
|
||
override fun onShowFileChooser(
|
||
webView: WebView?,
|
||
filePathCallback: ValueCallback<Array<Uri>>?,
|
||
fileChooserParams: FileChooserParams?
|
||
): Boolean {
|
||
this@MainActivity.filePathCallback?.onReceiveValue(null)
|
||
this@MainActivity.filePathCallback = filePathCallback
|
||
|
||
// 使用 ACTION_OPEN_DOCUMENT 走系统文件管理器(DocumentsUI),
|
||
// 避免 ACTION_GET_CONTENT 在某些 ROM 上唤起相册/Photo Picker 导致返回 URI 失效。
|
||
val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() }
|
||
?: arrayListOf("*/*")
|
||
val allowMultiple = fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE
|
||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||
addCategory(Intent.CATEGORY_OPENABLE)
|
||
type = acceptTypes.first()
|
||
if (allowMultiple) {
|
||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||
}
|
||
if (acceptTypes.size > 1) {
|
||
putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes.toTypedArray())
|
||
}
|
||
}
|
||
fileChooserLauncher.launch(intent)
|
||
return true
|
||
}
|
||
}
|
||
|
||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||
override fun handleOnBackPressed() {
|
||
if (webView.canGoBack()) {
|
||
webView.goBack()
|
||
} else {
|
||
finish()
|
||
}
|
||
}
|
||
})
|
||
|
||
if (savedInstanceState == null) {
|
||
webView.loadUrl(buildHomeUrl(), mapOf(
|
||
"Cache-Control" to "no-cache, no-store, must-revalidate",
|
||
"Pragma" to "no-cache"
|
||
))
|
||
} else {
|
||
webView.restoreState(savedInstanceState)
|
||
}
|
||
}
|
||
|
||
override fun onSaveInstanceState(outState: Bundle) {
|
||
webView.saveState(outState)
|
||
super.onSaveInstanceState(outState)
|
||
}
|
||
|
||
override fun onDestroy() {
|
||
voiceBridge?.destroy()
|
||
webView.destroy()
|
||
super.onDestroy()
|
||
}
|
||
|
||
private fun applySystemBarTheme(rawTheme: String?) {
|
||
val theme = (rawTheme ?: "").lowercase(Locale.getDefault())
|
||
val isDark = theme == "dark"
|
||
val bgColor = if (isDark) Color.BLACK else Color.WHITE
|
||
|
||
window.statusBarColor = bgColor
|
||
window.navigationBarColor = bgColor
|
||
WindowCompat.getInsetsController(window, window.decorView)?.let { controller ->
|
||
controller.isAppearanceLightStatusBars = !isDark
|
||
controller.isAppearanceLightNavigationBars = !isDark
|
||
}
|
||
}
|
||
|
||
private fun injectThemeObserver() {
|
||
val script = """
|
||
(function() {
|
||
function readTheme() {
|
||
var t = document.documentElement.getAttribute('data-theme')
|
||
|| document.body.getAttribute('data-theme')
|
||
|| localStorage.getItem('agents_ui_theme')
|
||
|| 'claude';
|
||
return String(t).toLowerCase();
|
||
}
|
||
function notify() {
|
||
if (window.AndroidThemeBridge && window.AndroidThemeBridge.onThemeChanged) {
|
||
window.AndroidThemeBridge.onThemeChanged(readTheme());
|
||
}
|
||
}
|
||
notify();
|
||
if (!window.__androidThemeObserverInstalled) {
|
||
window.__androidThemeObserverInstalled = true;
|
||
var ob = new MutationObserver(function() { notify(); });
|
||
ob.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||
if (document.body) {
|
||
ob.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] });
|
||
}
|
||
window.addEventListener('storage', function(e){
|
||
if (e && e.key === 'agents_ui_theme') notify();
|
||
});
|
||
}
|
||
})();
|
||
""".trimIndent()
|
||
webView.evaluateJavascript(script, null)
|
||
}
|
||
|
||
private fun injectMobileOverlayWidthPatch() {
|
||
val script = """
|
||
(function() {
|
||
var styleId = '__android_mobile_overlay_width_patch';
|
||
var css = [
|
||
'.mobile-panel-sheet.mobile-panel-sheet--conversation {',
|
||
' width: min(var(--conversation-expanded-width, 306px), 100vw) !important;',
|
||
' max-width: 100vw !important;',
|
||
' padding-top: 0 !important;',
|
||
'}',
|
||
'.mobile-panel-sheet--conversation .mobile-overlay-content {',
|
||
' height: 100% !important;',
|
||
'}',
|
||
'.mobile-panel-sheet.mobile-panel-sheet--workspace {',
|
||
' width: fit-content !important;',
|
||
' min-width: min(420px, 60vw) !important;',
|
||
' max-width: 100vw !important;',
|
||
'}'
|
||
].join('\n');
|
||
var existing = document.getElementById(styleId);
|
||
if (existing) {
|
||
existing.textContent = css;
|
||
return;
|
||
}
|
||
var style = document.createElement('style');
|
||
style.id = styleId;
|
||
style.type = 'text/css';
|
||
style.textContent = css;
|
||
(document.head || document.documentElement).appendChild(style);
|
||
})();
|
||
""".trimIndent()
|
||
webView.evaluateJavascript(script, null)
|
||
}
|
||
|
||
private fun injectNoPageScrollPatch() {
|
||
val script = """
|
||
(function() {
|
||
var styleId = '__android_no_page_scroll_patch';
|
||
var css = [
|
||
'html, body {',
|
||
' overflow: hidden !important;',
|
||
' height: 100% !important;',
|
||
' max-height: 100% !important;',
|
||
'}',
|
||
'body {',
|
||
' position: fixed !important;',
|
||
' width: 100% !important;',
|
||
' inset: 0 !important;',
|
||
'}',
|
||
'* {',
|
||
' scrollbar-width: none !important;',
|
||
'}',
|
||
'*::-webkit-scrollbar {',
|
||
' width: 0 !important;',
|
||
' height: 0 !important;',
|
||
' display: none !important;',
|
||
'}',
|
||
'#app, .app-root, .chat-container {',
|
||
' max-height: 100% !important;',
|
||
' overflow: hidden !important;',
|
||
'}'
|
||
].join('\n');
|
||
var existing = document.getElementById(styleId);
|
||
if (existing) {
|
||
existing.textContent = css;
|
||
return;
|
||
}
|
||
var style = document.createElement('style');
|
||
style.id = styleId;
|
||
style.type = 'text/css';
|
||
style.textContent = css;
|
||
(document.head || document.documentElement).appendChild(style);
|
||
})();
|
||
""".trimIndent()
|
||
webView.evaluateJavascript(script, null)
|
||
}
|
||
|
||
// ═══════════════════ 语音状态通知(JS 注入) ═══════════════════
|
||
|
||
private fun injectVoiceStatus(status: String) {
|
||
val script = """
|
||
(function() {
|
||
if (window.__onVoiceStatus) window.__onVoiceStatus('$status');
|
||
if (window.dispatchEvent) {
|
||
window.dispatchEvent(new CustomEvent('voicebridge:status', { detail: '$status' }));
|
||
}
|
||
})();
|
||
""".trimIndent()
|
||
webView.evaluateJavascript(script, null)
|
||
}
|
||
|
||
private fun injectVoiceDownloadProgress(pct: Int, msg: String) {
|
||
val safeMsg = msg.replace("'", "\\'")
|
||
val script = """
|
||
(function() {
|
||
if (window.__onVoiceDownloadProgress) {
|
||
window.__onVoiceDownloadProgress($pct, '$safeMsg');
|
||
}
|
||
})();
|
||
""".trimIndent()
|
||
webView.evaluateJavascript(script, null)
|
||
}
|
||
|
||
inner class PdfPreviewBridge {
|
||
@JavascriptInterface
|
||
fun previewPdf(pdfUrl: String?) {
|
||
val url = pdfUrl ?: return
|
||
runOnUiThread {
|
||
val intent = Intent(this@MainActivity, PdfPreviewActivity::class.java)
|
||
intent.putExtra(PdfPreviewActivity.EXTRA_PDF_URL, url)
|
||
startActivity(intent)
|
||
}
|
||
}
|
||
|
||
@JavascriptInterface
|
||
fun isPdfPreviewSupported(): Boolean = true
|
||
}
|
||
|
||
inner class DownloadBridge {
|
||
@JavascriptInterface
|
||
fun downloadFile(fileUrl: String?, fileName: String?) {
|
||
val url = fileUrl ?: return
|
||
val name = fileName ?: suggestFileName(null, url)
|
||
runOnUiThread {
|
||
startSystemDownload(url, name)
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun startSystemDownload(url: String, fileName: String) {
|
||
try {
|
||
val request = DownloadManager.Request(Uri.parse(url))
|
||
.setTitle(fileName)
|
||
.setDescription("正在下载...")
|
||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
|
||
.setAllowedOverRoaming(true)
|
||
.setAllowedOverMetered(true)
|
||
|
||
val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||
downloadManager.enqueue(request)
|
||
Toast.makeText(this, "已开始下载:$fileName", Toast.LENGTH_SHORT).show()
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "下载失败: ${e.message}", e)
|
||
Toast.makeText(this, "下载启动失败,请重试", Toast.LENGTH_LONG).show()
|
||
// 兜底:尝试用浏览器打开
|
||
try {
|
||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||
startActivity(intent)
|
||
} catch (_: Exception) {}
|
||
}
|
||
}
|
||
|
||
private fun suggestFileName(contentDisposition: String?, url: String): String {
|
||
contentDisposition?.let {
|
||
val regex = Regex("filename\\*?=\\s*\"?([^\";]+)\"?", RegexOption.IGNORE_CASE)
|
||
regex.find(it)?.groupValues?.get(1)?.trim()?.takeIf { name -> name.isNotBlank() }?.let { return it }
|
||
}
|
||
val uri = Uri.parse(url)
|
||
uri.lastPathSegment?.takeIf { it.isNotBlank() && it.contains(".") }?.let { return it }
|
||
uri.getQueryParameter("path")?.split("/")?.lastOrNull()?.takeIf { it.isNotBlank() }?.let { return it }
|
||
return "download_${System.currentTimeMillis()}"
|
||
}
|
||
|
||
inner class ThemeBridge {
|
||
@JavascriptInterface
|
||
fun onThemeChanged(theme: String?) {
|
||
runOnUiThread {
|
||
applySystemBarTheme(theme)
|
||
}
|
||
}
|
||
|
||
@JavascriptInterface
|
||
fun getAppVersionCode(): String {
|
||
return getInstalledVersionCode().toString()
|
||
}
|
||
|
||
@JavascriptInterface
|
||
fun getAppVersionName(): String {
|
||
return getInstalledVersionName()
|
||
}
|
||
}
|
||
|
||
private fun buildHomeUrl(): String {
|
||
val vc = getInstalledVersionCode()
|
||
val vn = Uri.encode(getInstalledVersionName())
|
||
return "$HOME_URL/?app_shell=$WEB_ASSET_VERSION&app_vc=$vc&app_vn=$vn"
|
||
}
|
||
|
||
private fun getInstalledVersionCode(): Long {
|
||
val pkgInfo = packageManager.getPackageInfo(packageName, 0)
|
||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pkgInfo.longVersionCode else @Suppress("DEPRECATION") pkgInfo.versionCode.toLong()
|
||
}
|
||
|
||
private fun getInstalledVersionName(): String {
|
||
val pkgInfo = packageManager.getPackageInfo(packageName, 0)
|
||
return pkgInfo.versionName ?: "unknown"
|
||
}
|
||
|
||
private fun isApkUrl(url: String): Boolean {
|
||
return url.lowercase(Locale.getDefault()).endsWith(".apk") || url.contains("/api/app/apk/latest")
|
||
}
|
||
}
|