agent-Specialization/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt

501 lines
20 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.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")
}
}