package com.cyjai.agent import android.annotation.SuppressLint import android.content.Intent import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Bundle import android.view.ViewGroup import android.view.View 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 androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowCompat import java.util.Locale class MainActivity : AppCompatActivity() { private lateinit var webView: WebView private var filePathCallback: ValueCallback>? = 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, systemBars.top, 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.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>?, fileChooserParams: FileChooserParams? ): Boolean { this@MainActivity.filePathCallback?.onReceiveValue(null) this@MainActivity.filePathCallback = filePathCallback val intent = fileChooserParams?.createIntent() ?: Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "*/*" } startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE) 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) } } @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode != FILE_CHOOSER_REQUEST_CODE) return val results = WebChromeClient.FileChooserParams.parseResult(resultCode, data) filePathCallback?.onReceiveValue(results) filePathCallback = null } override fun onSaveInstanceState(outState: Bundle) { webView.saveState(outState) super.onSaveInstanceState(outState) } override fun onDestroy() { 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(420px, 65vw) !important;', ' max-width: 65vw !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) } inner class ThemeBridge { @JavascriptInterface fun onThemeChanged(theme: String?) { runOnUiThread { applySystemBarTheme(theme) } } @JavascriptInterface fun getAppVersionCode(): String { return getInstalledVersionCode().toString() } @JavascriptInterface fun getAppVersionName(): String { return getInstalledVersionName() } } companion object { private const val HOME_URL = "https://agent.cyjai.com" private const val WEB_ASSET_VERSION = "20260406_4" private const val FILE_CHOOSER_REQUEST_CODE = 9001 } 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") } }