diff --git a/.gitignore b/.gitignore index 08b6b387..196407e9 100644 --- a/.gitignore +++ b/.gitignore @@ -80,7 +80,6 @@ research/ # 含私人信息 / 个人工作流约定(脱敏前不进仓库) CLAUDE.md api_doc/ -android-webview-app/ config/host_workspaces.json.example upload_android_apk.sh scripts/zip_agents.sh diff --git a/AGENTS.md b/AGENTS.md index 63cbd27d..88c03409 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,8 +305,10 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - `android-webview-app/APP_CHANGELOG.md`:在顶部新增当前版本说明 3. 完成修改后提醒用户运行上传脚本 - 在发布流程中运行:`bash ./upload_android_apk.sh`(本地私有脚本,未随仓库发布) + - 脚本除上传 APK 外,还会同步 `app/build.gradle.kts` 与 `APP_CHANGELOG.md` 到服务器(2026-09-02 修复) > 禁止只改前端代码而不更新版本号/更新说明,否则会导致客户端更新提示与分发信息不一致。 +> 服务端 `/api/app/version` 的版本号是从服务器文件系统上的 `build.gradle.kts` 解析的(实现见 `server/status/docker.py`),**不是从 APK 包解析**——只传 APK 不同步元数据文件会导致 App 检测不到新版本。 ## 9) 给 Agent 的硬性要求 diff --git a/android-webview-app/.gitignore b/android-webview-app/.gitignore new file mode 100644 index 00000000..1741fc1d --- /dev/null +++ b/android-webview-app/.gitignore @@ -0,0 +1,25 @@ +# macOS +.DS_Store + +# Local Android/IDE state +local.properties +.idea/ +.gradle/ + +# Build outputs +build/ +**/build/ + +# Signing keys and secrets +*.jks +*.keystore + +# Logs +*.log + +# Release artifacts +*.apk +*.aab +*.apks +*.dm +app/release/ diff --git a/android-webview-app/APP_CHANGELOG.md b/android-webview-app/APP_CHANGELOG.md new file mode 100644 index 00000000..9115b8ca --- /dev/null +++ b/android-webview-app/APP_CHANGELOG.md @@ -0,0 +1,206 @@ +# App 更新说明 + +# 1.0.43 +- 应用更名为 Astrion(原 CYJ AI Agent) + +# 1.0.42 +- 修复 App 内点击外部链接无反应的问题:对话中的网页链接、引用来源链接现在会跳转系统浏览器打开,不再被静默吞掉 +- 站内页面仍在 App 内打开,且站内外判断改为域名精确匹配,防止伪造域名绕过 + +# 1.0.41 +- 修复 Android App 内文件下载报错 404 的问题:下载地址不再错误地把当前对话路径拼进去 +- 修复下载时 `CookieManager` 在非主线程调用导致的崩溃提示 + +# 1.0.40 +- 修复 Android App 内文件下载后系统提示「下载失败」的问题: + - 放弃系统 DownloadManager,改为应用内下载到私有目录 + - 下载完成后弹出系统「分享文件」面板,让用户选择保存到下载管理、QQ浏览器、微信等目标 + - 仍保留 WebView Cookie,下载接口可正常鉴权 + +# 1.0.39 +- 修复 Android App 内图片/视频「从本地发送」无响应的问题:在唤起系统文件选择器前主动请求 `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` 权限,拒绝后支持再次请求并引导到系统设置 +- 修复 Android App 内所有下载(文件卡片、markdown 下载链接)提示「下载启动失败」的问题: + - 前端在 App 内优先调用 `AndroidDownloadBridge` + - Android 端将相对下载地址补全为绝对地址,并携带 WebView 登录 Cookie,避免 401 / 无效链接 + - Android 10+ 下载目录异常时自动回退到 DownloadManager 默认位置 + - Android 9 及以下未授予存储权限时先请求权限再开始下载 + +# 1.0.38 +- PDF 文件卡片改为前端 `vue-pdf-embed` 渲染:在卡片下方直接滚动预览多页,不再调用浏览器原生 PDF 预览或 Android 原生 PDF 预览 Activity +- 与桌面端保持一致的文件卡片交互:PDF 和其他类型文件统一头部 + 下方预览布局 + +# 1.0.37 +- 修复 Android App 内 PDF 文件卡片无法 inline 显示的问题:WebView 不支持 iframe PDF,改为卡片下方显示占位区,点击后调原生 PDF 预览 +- 修复进入含 PDF 卡片的对话自动弹出「已开始下载」的误报:移除 WebView `setDownloadListener`,避免把 PDF 加载请求误判为下载 +- 修复文件卡片点击下载无反应的问题:保留 `AndroidDownloadBridge` 走系统 DownloadManager +- 修复图片/视频「从本地发送」仍然唤起相册/Photo Picker 的问题:Android 端文件选择器改用 `ACTION_OPEN_DOCUMENT` 走系统文件管理器 +- 前端常规设置新增「上传诊断日志」按钮,便于排查 App 端问题 + +# 1.0.36 +- 修复 Android App 内文件卡片点击「下载」无反应的问题:前端优先调用 Android DownloadBridge,WebView 同时注册 `setDownloadListener` 兜底,使用系统 DownloadManager 下载到 Download 目录 +- PDF 文件卡片移除单独的「预览」按钮,改为与桌面端一致的卡片下方 inline 预览(iframe),视觉和交互统一 +- 修复 Android App 内图片/视频「从本地发送」容易失败的问题: + - 延长文件选择器关闭后的 backdrop 点击屏蔽时长,避免 touch 穿透误关选择弹窗导致上传中断 + - Android 端统一使用 `ACTION_GET_CONTENT` 选择器(按 `image/*` / `video/*` 过滤),避免不同 ROM 的相册/Photo Picker 返回临时 URI 导致上传失效 + - 上传链路增强对 Android WebView 返回文件无文件名/无扩展名的兼容:前端根据 MIME 类型补全文件名,后端在 filename 缺失时自动推断兜底文件名 + +# 1.0.35 +- 新增 PDF 文件原生预览:集成 AndroidPdfViewer,点击卡片「预览」按钮可在 App 内直接查看 PDF,不再依赖浏览器 + +# 1.0.32 +- 识别引擎改为预初始化:模型下载完成后立即在后台加载,用户点击时直接可用(不再每次点击等3-5秒初始化) +- startRecordingInternal 增加详细日志,便于排查录音问题 + +# 1.0.31 +- 修复语音识别无结果:去掉多余的 decode() 调用,对齐 sherpa-onnx Android 官方 API(getResult 内部已含 decode) +- recognizeSegment 增加识别结果详细日志 + +# 1.0.30 +- 修复语音识别无声问题:前端处理 initializing 状态,防止初始化期间重复触发 startListening 导致录音永不停止 +- Android 端增加启动取消机制,初始化期间按停止不会意外开始录音 +- 完善录音链路日志,便于后续调试 + +# 1.0.29 +- 暂时禁用 VAD(Android 端 Silero VAD 存在 Native 兼容性问题),改为整段识别模式 +- 录音结束后一次性识别,效果无差异,彻底消除闪退 + +# 1.0.28 +- 修复 VAD 初始化导致闪退:maxSpeechDuration 30f→5.0f(默认值),VAD 失败时降级为整段识别不崩溃 + +# 1.0.27 +- 语音调试日志改为直接上传服务器(/api/voice_debug),无需手动找文件 + +# 1.0.26 +- 修复点击麦克风后闪退问题:initEngine 增加文件完整性前置校验,numThreads 降为 1 降低内存压力 +- 新增「保存日志」按钮,调试日志写入手机 Download/voice_debug.log +- 下载/删除前自动释放引擎资源,避免文件锁定导致异常 + +# 1.0.25 +- 修复下载进度一直显示 0% 的问题(整数除法 bug) +- 新增模型文件完整性校验(文件大小 ±5% 容差),避免下载中断后显示“已下载” +- 新增「删除模型」按钮,可清理不完整的模型文件 +- 模型文件移至 agents 代码目录外(/opt/agent/voice_models/),避免部署时被覆盖 + +# 1.0.24 +- 修复语音按钮在首次启动(模型未下载)时不显示的问题:VoiceBridge 改为随 App 启动即注册,不再等待模型下载完成 +- 修复个人空间中「下载语音模型」按钮误提示「仅在 App 内支持」的问题 + +# 1.0.23 +- 新增端侧语音输入功能:集成 sherpa-onnx SenseVoice int8 离线语音识别 +- 支持中英混说 + 自动标点,完全离线运行,无需网络 +- 语音模型约 228MB,首次使用自动下载(可在「个人空间 → 语音模型」手动下载) +- 输入框语音按钮仅在 App 端显示,桌面端隐藏 +- 新增录音权限请求(RECORD_AUDIO) + +# 1.0.22 +- 修复 Android App 内从本地发送图片/视频无效的问题:补充 Android 13+ 所需的媒体读取权限(`READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO`) +- 修复 Android App 文件选择器在 Android 13+ 上回调丢失的问题:将已废弃的 `startActivityForResult` 升级为 `registerForActivityResult`(Activity Result API) + +# 1.0.21 +- 修复 Android App 内手机端展开“对话记录”时仍沿用旧宽度的问题,面板宽度与桌面端对话侧栏保持一致 +- 修复 Android App 内对话记录展开面板顶部残留系统栏空白的问题,内容从面板顶端开始显示 + +# 1.0.20 +- 新增“新用户欢迎弹窗 + 新手教程”持久化机制:普通账号首次进入会提示是否开始教程,选择“开始吧”或“不再提示”后将状态写入 `data/users.json` +- 新增后端教程状态接口(查询/更新),并排除宿主机模式用户,避免 host 模式触发教程弹窗 +- 修复宿主机模式切回普通账号登录时可能遗留 `host_mode` 状态,导致教程提示判定异常的问题 +- 优化新手教程欢迎弹窗视觉风格:按钮与系统现有主题风格统一,支持多主题配色 + +# 1.0.19 +- 修复 Android APK 内「个人空间-新手教程」期间无法上下滚动的问题:教程遮罩层现在会正确转发触摸滑动到可滚动容器 +- 优化教程滚动目标识别:优先命中当前可滚动页面区域,减少 WebView 场景下滑动失效 +- 调整软件更新页版本展示:移除括号内的构建代数,仅显示语义版本号(如 1.0.19) + +# 1.0.18 +- 新增完整「新手教程」系统(高光 + 悬浮说明窗):覆盖桌面端与手机端核心功能入口 +- 教程交互统一优化:仅保留“下一步”,支持自动点击与点击特效,避免误触真实页面 +- 手机端教程新增专属流程:菜单展开、对话记录、工作文件、新建对话、模型/思考模式、个人空间全链路引导 +- 教程定位与层级修复:高光区域更贴合小目标;教程弹窗始终保持最高图层,不再被移动端菜单遮挡 +- 个人空间教程体验优化:教程期间支持滚动查看内容;移动端标签自动横向滚动到可见后再点击;桌面端标签自动纵向滚动后再点击 +- 新增“连接状态指示灯”教程步骤:绿色表示连接正常,红色表示与后端断开 +- 教程入口页精简:移除“已完成/重新开始”状态文案,统一为“开始新手教程” +- 主题命名文案统一:将“Claude 经典”调整为“经典”,并同步更新相关引导文案 +- 模型偏好页优化:移除“默认思考模型”中“思考模式”的“推荐”标记 +- 修复“对话回顾”弹窗多主题适配问题:经典/明亮/夜间主题下的背景、边框、文本、按钮与状态样式统一跟随主题变量 + +# 1.0.17 +- 登录/注册页改为 Vue 实现,统一前端技术栈 +- 登录/注册页接入主题变量体系,自动跟随已保存主题(Claude / 浅色 / 深色) +- 移除登录/注册页独立主题切换控件,主题入口统一由主界面个人空间管理 + +# 1.0.16 +- 工作区顶部品牌图标改为内联 SVG 渲染(不再依赖静态路径加载) +- 修复深色/浅色主题下 logo 颜色适配问题,跟随主题文本色显示 + +# 1.0.15 +- 修复图片查看链路:`view_image` 工具结果可正确附带图片内容,降低“识别成其他图片/内容”的误判 +- 新增「强约束系统」开关(个人空间):可分别约束 terminal 系列与子智能体系列工具,要求先阅读对应 `skills/*/SKILL.md`(且仅对已启用 skill 生效) +- 优化输入与消息气泡换行策略:达到最大宽度即换行 +- 修复深色模式下 `run_python` 结果区显示异常,代码与输出统一为白色文字 + +# 1.0.14 +- 移除前端「实验功能」中的 Liquid Glass 实验页与悬浮组件,减少无关试验功能干扰 +- 删除 `scripts/liquid-glass-demo` 目录(含 node_modules/.next),显著降低部署压缩包体积 + +# 1.0.13 +- 修复工作区连接状态指示灯在轮询模式下常亮问题:新增连接探活心跳,断连时可正确切换为离线状态 +- 调整状态灯视觉:离线红灯与在线绿灯均支持扩散呼吸特效 + +# 1.0.12 +- 进一步修复手机端菜单点击反馈:增强去除系统默认蓝色点击高亮框(含快捷菜单按钮按压态) + +# 1.0.11 +- 优化手机端点击反馈样式:移除默认蓝色点击高亮矩形,减少触控干扰感 + +# 1.0.10 +- 手机 App 左上角菜单新增“刷新页面”入口(仅 App 端显示) +- 加载界面(正在连接服务器)新增“刷新页面”按钮 +- 继续优化 App 内更新下载链路稳定性 + +# 1.0.9-test1 +- 测试版本:验证 App 内下载更新链路(DownloadManager)是否在不同浏览器环境下稳定工作 + +# 1.0.9 +- 修复 App 内点击“更新”后依赖外部浏览器下载导致部分机型失败的问题 +- 新增 Android JS Bridge 下载通道:优先使用系统 DownloadManager 直接下载 APK +- 前端下载逻辑增加桥接优先与浏览器回退,提升下载成功率 + +# 1.0.8 +- 同步最新对话压缩体验:压缩中状态提示更稳定,压缩完成后反馈更清晰 +- 优化压缩后会话切换与列表刷新表现,减少需要手动刷新的情况 +- 修复电脑端摘要行与正式输出内容的对齐问题 + +# 1.0.7 +- 应用图标替换为新的机器人插画 PNG 版本 + +# 1.0.6 +- 应用图标替换为指定 JPG 图片资源 +- 更新 AndroidManifest 图标引用(icon / roundIcon) + +# 1.0.5 +- 手机端「工作文件」侧栏最大宽度由 86% 提升到 100% +- 保持默认 60% 起始宽度,并在内容需要时继续自适应扩展 + +# 1.0.4 +- 调整手机端「工作文件」侧栏宽度策略:默认按 60% 屏宽展开 +- 当内容较宽时可自适应扩展侧栏宽度(上限 86%),减少内容被截断 +- 保持「对话记录」侧栏宽度逻辑不变 + +# 1.0.3 +- 修复 App 端版本读取时机,避免显示“当前版本未知(?)” +- 进一步隐藏 App 端页面滚动条(保留页面内部可滚动区域) + +# 1.0.2 +- 修复 App 端“当前版本显示未知”问题(版本号改为优先从 Android JS Bridge 读取) +- 优化“软件更新”页更新日志展示:长内容在卡片内滚动,不再无限拉长 +- 强化 App 页面滚动隐藏补丁,减少出现系统滚动条的情况 +- 更新上传脚本:支持自动打包 + 上传,并同步版本号与更新说明 + +# 1.0.1 +- 移除 App 页面上下滚动条(仅 App 壳生效) +- 优化 App 内页面样式补丁与资源版本参数 + +## 1.0.0 +- 首次发布 Android WebView 壳应用 +- 适配移动端抽屉宽度与状态栏主题联动 +- 新增个人空间「软件更新」页(仅 App 端显示) diff --git a/android-webview-app/README.md b/android-webview-app/README.md new file mode 100644 index 00000000..c3bfe906 --- /dev/null +++ b/android-webview-app/README.md @@ -0,0 +1,45 @@ +# Astrion Android WebView 壳应用 + +这个目录是一个最小可用的 Android APK 壳工程: +- 后端仍运行在你的服务器 +- APK 只承载 WebView 前端 + +## 1) 打开工程 + +Android Studio -> Open -> 选择 `android-webview-app`。 + +首次打开会自动下载 Gradle 依赖(需要联网)。 + +## 2) 配置后端地址(必须) + +仓库代码中不包含真实后端域名,构建前需要通过以下任一方式注入(优先级从高到低): + +1. 命令行参数:`./gradlew assembleRelease -PHOME_URL=https://your-server.example.com` +2. `local.properties`(本机文件,不纳入版本控制):添加一行 `HOME_URL=https://your-server.example.com` +3. 环境变量:`export HOME_URL=https://your-server.example.com` + +不配置则使用占位地址 `https://agent.example.com`,App 无法连接真实服务。 + +## 3) 可改项 + +- 应用名:`app/src/main/res/values/strings.xml` +- 后端地址:见上文「配置后端地址」,最终编译进 `BuildConfig.HOME_URL` + +## 4) 调试打包 + +- 连接手机/模拟器后,点击 Run +- 生成 release APK: + - 配置签名环境变量 `ANDROID_KEYSTORE_PATH` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` + - `./gradlew assembleRelease` + +## 5) 已处理能力 + +- JS / DOM Storage 已开启 +- Cookie 已开启 +- 文件上传 (``) 已支持 +- 物理返回键支持网页回退 +- 外部链接自动跳转系统浏览器打开 + +## 6) Nginx 建议 + +如后续出现 WebSocket 不稳定,可把 `Connection 'upgrade'` 改为官方 map 写法(按 upgrade 条件设置)。 diff --git a/android-webview-app/app/build.gradle.kts b/android-webview-app/app/build.gradle.kts new file mode 100644 index 00000000..519d81c6 --- /dev/null +++ b/android-webview-app/app/build.gradle.kts @@ -0,0 +1,100 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +fun resolveConfig(name: String): String? { + return (project.findProperty(name) as String?)?.takeIf { it.isNotBlank() } + ?: System.getenv(name)?.takeIf { it.isNotBlank() } +} + +// 后端服务地址(构建期注入,仓库中只保留占位符): +// 优先级:-PHOME_URL 命令行参数 > local.properties 的 HOME_URL > 环境变量 HOME_URL > 占位默认值 +// 自部署构建示例:./gradlew assembleRelease -PHOME_URL=https://your-server.example.com +fun resolveHomeUrl(): String { + (project.findProperty("HOME_URL") as String?)?.takeIf { it.isNotBlank() }?.let { return it } + val localPropsFile = rootProject.file("local.properties") + if (localPropsFile.exists()) { + val props = Properties() + localPropsFile.inputStream().use { props.load(it) } + props.getProperty("HOME_URL")?.takeIf { it.isNotBlank() }?.let { return it } + } + System.getenv("HOME_URL")?.takeIf { it.isNotBlank() }?.let { return it } + return "https://agent.example.com" +} + +val homeUrl: String = resolveHomeUrl() + +android { + namespace = "com.cyjai.agent" + compileSdk = 35 + + defaultConfig { + applicationId = "com.cyjai.agent" + minSdk = 24 + targetSdk = 35 + versionCode = 45 + versionName = "1.0.43" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "HOME_URL", "\"$homeUrl\"") + } + + buildFeatures { + buildConfig = true + } + + signingConfigs { + create("release") { + val storeFilePath = resolveConfig("ANDROID_KEYSTORE_PATH") + val storePass = resolveConfig("ANDROID_KEYSTORE_PASSWORD") + val keyAliasValue = resolveConfig("ANDROID_KEY_ALIAS") + val keyPass = resolveConfig("ANDROID_KEY_PASSWORD") + + if (!storeFilePath.isNullOrBlank()) { + storeFile = file(storeFilePath) + } + storePassword = storePass + keyAlias = keyAliasValue + keyPassword = keyPass + } + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.activity:activity-ktx:1.9.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + // PDF 预览(不依赖浏览器) + implementation("com.github.mhiew:android-pdf-viewer:3.2.0-beta.3") + + // sherpa-onnx 语音识别库(需手动下载 AAR 放到 app/libs/) + // 下载地址:https://huggingface.co/csukuangfj/sherpa-onnx-libs/tree/main/android/aar + // 下载最新 sherpa-onnx-*.aar 放到 app/libs/ 目录即可 + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) +} diff --git a/android-webview-app/app/libs/sherpa-onnx-1.10.45.aar b/android-webview-app/app/libs/sherpa-onnx-1.10.45.aar new file mode 100644 index 00000000..f50d1b2c Binary files /dev/null and b/android-webview-app/app/libs/sherpa-onnx-1.10.45.aar differ diff --git a/android-webview-app/app/proguard-rules.pro b/android-webview-app/app/proguard-rules.pro new file mode 100644 index 00000000..2cb8d493 --- /dev/null +++ b/android-webview-app/app/proguard-rules.pro @@ -0,0 +1 @@ +# Intentionally minimal for WebView shell app diff --git a/android-webview-app/app/src/main/AndroidManifest.xml b/android-webview-app/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4835d3af --- /dev/null +++ b/android-webview-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt b/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt new file mode 100644 index 00000000..479576ed --- /dev/null +++ b/android-webview-app/app/src/main/java/com/cyjai/agent/MainActivity.kt @@ -0,0 +1,686 @@ +package com.cyjai.agent + +import android.Manifest +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +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.provider.DocumentsContract +import android.provider.Settings +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.content.FileProvider +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.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLConnection +import java.util.Locale + +class MainActivity : AppCompatActivity() { + + private lateinit var webView: WebView + private var filePathCallback: ValueCallback>? = null + private var voiceBridge: VoiceBridge? = null + private var pendingFileChooserParams: WebChromeClient.FileChooserParams? = 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 REQUEST_READ_MEDIA = 302 + private val HOME_URL: String = BuildConfig.HOME_URL + private val HOME_HOST: String? = Uri.parse(HOME_URL).host + private const val WEB_ASSET_VERSION = "20260624_2" + } + + private val fileChooserLauncher: ActivityResultLauncher = 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 + val uri = Uri.parse(url) + // APK 下载优先:虽然是站内 host,也要交给系统下载器/浏览器 + if (isApkUrl(url)) { + startActivity(Intent(Intent.ACTION_VIEW, uri)) + return true + } + // 站内页面:WebView 内加载。用 host 精确比较,防止 example.com.evil.com 之类伪造前缀 + if ((uri.scheme == "http" || uri.scheme == "https") && uri.host == HOME_HOST) { + return false + } + // 外部链接:跳系统浏览器(或对应 app,如 mailto/tel)打开; + // 无 handler 的自定义 scheme(如 download://)直接吞掉,维持原行为 + return try { + startActivity(Intent(Intent.ACTION_VIEW, uri)) + true + } catch (e: ActivityNotFoundException) { + true + } + } + } + + 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 + + // 对图片/视频选择,先检查并请求媒体权限;部分国产 ROM 的文件管理器会要求 + // 应用持有 READ_MEDIA_IMAGES/READ_MEDIA_VIDEO 才能展示本地媒体。 + val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() } + ?: listOf("*/*") + if (needsMediaPermission(acceptTypes) && !hasMediaPermission(acceptTypes)) { + pendingFileChooserParams = fileChooserParams + requestMediaPermissions(acceptTypes) + return true + } + + launchFileChooser(fileChooserParams) + 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 needsMediaPermission(acceptTypes: List): Boolean { + // 仅当选择器明确针对图片或视频时才需要媒体权限;通配类型仍走 DocumentsUI 的临时授权 + if (acceptTypes.isEmpty() || acceptTypes.any { it == "*/*" }) return false + return acceptTypes.any { it.startsWith("image/") || it.startsWith("video/") } + } + + private fun getRequiredMediaPermissions(acceptTypes: List): Array { + val needsImage = acceptTypes.any { it.startsWith("image/") } + val needsVideo = acceptTypes.any { it.startsWith("video/") } + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + val perms = mutableListOf() + if (needsImage) perms.add(Manifest.permission.READ_MEDIA_IMAGES) + if (needsVideo) perms.add(Manifest.permission.READ_MEDIA_VIDEO) + perms.toTypedArray() + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + else -> emptyArray() + } + } + + private fun hasMediaPermission(acceptTypes: List): Boolean { + val perms = getRequiredMediaPermissions(acceptTypes) + if (perms.isEmpty()) return true + return perms.all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + } + + private fun requestMediaPermissions(acceptTypes: List) { + val perms = getRequiredMediaPermissions(acceptTypes) + if (perms.isEmpty()) return + ActivityCompat.requestPermissions(this, perms, REQUEST_READ_MEDIA) + } + + private fun launchFileChooser(fileChooserParams: WebChromeClient.FileChooserParams?) { + pendingFileChooserParams = null + val acceptTypes = fileChooserParams?.acceptTypes?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() } + ?: listOf("*/*") + val allowMultiple = fileChooserParams?.mode == WebChromeClient.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) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + REQUEST_READ_MEDIA -> { + val params = pendingFileChooserParams + if (params != null) { + if (grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { + launchFileChooser(params) + } else { + filePathCallback?.onReceiveValue(null) + filePathCallback = null + pendingFileChooserParams = null + val permanentlyDenied = grantResults.isNotEmpty() && + permissions.isNotEmpty() && + !ActivityCompat.shouldShowRequestPermissionRationale(this, permissions[0]) + if (permanentlyDenied) { + Toast.makeText(this, "请在系统设置中开启媒体访问权限", Toast.LENGTH_LONG).show() + openAppSettings() + } else { + Toast.makeText(this, "需要媒体访问权限才能选择本地文件", Toast.LENGTH_LONG).show() + } + } + } + } + REQUEST_WRITE_STORAGE, REQUEST_RECORD_AUDIO -> { + // 启动时请求的权限,无需额外处理 + } + } + } + + private fun openAppSettings() { + try { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + startActivity(intent) + } catch (_: Exception) { + // ignore + } + } + + 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?.takeIf { it.isNotBlank() } + ?: deriveFileName("", null, url) + runOnUiThread { + startSystemDownload(url, name) + } + } + } + + private fun startSystemDownload(rawUrl: String, fileName: String) { + // 不再使用系统 DownloadManager(国产 ROM 上 enqueue 后经常静默失败)。 + // 改为应用内下载到私有目录,然后通过系统分享 sheet 让用户选择保存位置。 + lifecycleScope.launch(Dispatchers.IO) { + try { + val absoluteUrl = resolveAbsoluteUrl(rawUrl) + if (absoluteUrl.isBlank()) { + withContext(Dispatchers.Main) { + Toast.makeText(this@MainActivity, "下载链接无效", Toast.LENGTH_LONG).show() + } + return@launch + } + + withContext(Dispatchers.Main) { + Toast.makeText(this@MainActivity, "正在下载:$fileName", Toast.LENGTH_SHORT).show() + } + + // CookieManager 必须在主线程访问 + val cookie = withContext(Dispatchers.Main) { + CookieManager.getInstance().getCookie(absoluteUrl) + } + + val downloadedFile = downloadFileToAppDir(absoluteUrl, fileName, cookie) + + withContext(Dispatchers.Main) { + shareDownloadedFile(downloadedFile) + } + } catch (e: Exception) { + Log.e(TAG, "下载失败: ${e.message}", e) + withContext(Dispatchers.Main) { + Toast.makeText(this@MainActivity, "下载失败:${e.message}", Toast.LENGTH_LONG).show() + // 兜底:尝试用浏览器打开 + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolveAbsoluteUrl(rawUrl))) + startActivity(intent) + } catch (_: Exception) {} + } + } + } + } + + private fun downloadFileToAppDir(urlString: String, preferredName: String, cookie: String?): File { + val dir = File(filesDir, "downloads").apply { mkdirs() } + val connection = URL(urlString).openConnection() as HttpURLConnection + connection.connectTimeout = 30000 + connection.readTimeout = 30000 + connection.instanceFollowRedirects = true + connection.setRequestProperty("Cookie", cookie ?: "") + connection.connect() + + val finalUrl = connection.url.toString() + val contentDisposition = connection.getHeaderField("Content-Disposition") + val fileName = deriveFileName(preferredName, contentDisposition, finalUrl) + + val file = File(dir, fileName) + connection.inputStream.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + connection.disconnect() + return file + } + + private fun shareDownloadedFile(file: File) { + try { + val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file) + val mimeType = URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream" + val intent = Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_TITLE, file.name) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(intent, "分享文件").apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(chooser) + } catch (e: ActivityNotFoundException) { + Toast.makeText(this, "没有可用的文件分享应用", Toast.LENGTH_LONG).show() + } catch (e: Exception) { + Log.e(TAG, "分享文件失败: ${e.message}", e) + Toast.makeText(this, "分享文件失败:${e.message}", Toast.LENGTH_LONG).show() + } + } + + private fun deriveFileName(preferredName: String, 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 } + } + if (preferredName.isNotBlank() && preferredName.contains(".")) return preferredName + Uri.parse(url).lastPathSegment?.takeIf { it.isNotBlank() && it.contains(".") }?.let { return it } + Uri.parse(url).getQueryParameter("path")?.split("/")?.lastOrNull()?.takeIf { it.isNotBlank() }?.let { return it } + return "download_${System.currentTimeMillis()}" + } + + private fun resolveAbsoluteUrl(rawUrl: String): String { + if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl + // API 始终位于域名根路径,不能拿当前 WebView URL(可能包含对话路径 /conv_id)作为 base, + // 否则会把 /api/download/file 拼到对话路径后面,导致 404。 + val base = HOME_URL.trimEnd('/') + return if (rawUrl.startsWith("/")) "$base$rawUrl" else "$base/$rawUrl" + } + + 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") + } +} diff --git a/android-webview-app/app/src/main/java/com/cyjai/agent/ModelManager.kt b/android-webview-app/app/src/main/java/com/cyjai/agent/ModelManager.kt new file mode 100644 index 00000000..06ca5f2d --- /dev/null +++ b/android-webview-app/app/src/main/java/com/cyjai/agent/ModelManager.kt @@ -0,0 +1,194 @@ +package com.cyjai.agent + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.* +import java.io.* +import java.net.HttpURLConnection +import java.net.URL + +/** + * 语音模型下载管理器 + * 首次启动时自动下载 SenseVoice + Silero VAD 模型到内部存储 + * + * 模型来源:HuggingFace (csukuangfj) + * 总大小:约 230MB(SenseVoice int8 228MB + tokens 308KB + Silero VAD 1.5MB) + */ +object ModelManager { + private const val TAG = "ModelManager" + + // 模型下载地址(从自有服务器下载,3 个文件约 230MB) + // 需提前上传到服务器静态目录:/static/voice_models/ + private val BASE_URL = BuildConfig.HOME_URL.trimEnd('/') + "/static/voice_models" + private val SENSEVOICE_MODEL_URL = "$BASE_URL/model.int8.onnx" + private val SENSEVOICE_TOKENS_URL = "$BASE_URL/tokens.txt" + private val SILERO_VAD_URL = "$BASE_URL/silero_vad.onnx" + + private var _modelDir: File? = null + + fun init(context: Context) { + _modelDir = File(context.filesDir, "voice_models") + } + + private fun getModelDir(context: Context): File { + return _modelDir ?: File(context.filesDir, "voice_models").also { _modelDir = it } + } + + // 模型文件预期大小(字节),用于校验下载完整性(±5% 容差) + private const val EXPECTED_MODEL_SIZE = 239_233_841L // model.int8.onnx ~228MB + private const val EXPECTED_TOKENS_SIZE = 316_000L // tokens.txt ~308KB + private const val EXPECTED_VAD_SIZE = 644_000L // silero_vad.onnx ~629KB + private const val SIZE_TOLERANCE = 0.05 // 5% 容差 + + private fun isSizeValid(file: File, expected: Long): Boolean { + if (!file.exists()) return false + val size = file.length() + val lower = (expected * (1.0 - SIZE_TOLERANCE)).toLong() + val upper = (expected * (1.0 + SIZE_TOLERANCE)).toLong() + return size in lower..upper + } + + /** + * 检查模型是否已下载完毕且文件完整 + */ + fun isModelReady(context: Context): Boolean { + val dir = getModelDir(context) + val senseVoiceModel = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/model.int8.onnx") + val senseVoiceTokens = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/tokens.txt") + val sileroVad = File(dir, "silero_vad.onnx") + return isSizeValid(senseVoiceModel, EXPECTED_MODEL_SIZE) + && isSizeValid(senseVoiceTokens, EXPECTED_TOKENS_SIZE) + && isSizeValid(sileroVad, EXPECTED_VAD_SIZE) + } + + /** + * 删除所有已下载的模型文件 + */ + fun deleteModels(context: Context): Boolean { + val dir = getModelDir(context) + return try { + dir.deleteRecursively() + } catch (e: Exception) { + Log.e(TAG, "删除模型文件失败", e) + false + } + } + + /** + * 下载所有模型(应在后台线程调用) + * @param onProgress 进度回调 (总百分比: Int, 阶段描述: String) + * @return 是否全部成功 + */ + suspend fun downloadModels( + context: Context, + onProgress: (Int, String) -> Unit = { _, _ -> } + ): Boolean = withContext(Dispatchers.IO) { + val dir = getModelDir(context) + val senseVoiceDir = File(dir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17") + senseVoiceDir.mkdirs() + + try { + // 1. Silero VAD (~1.5MB) + val sileroFile = File(dir, "silero_vad.onnx") + if (!sileroFile.exists()) { + onProgress(0, "下载 VAD 模型...") + downloadFile(SILERO_VAD_URL, sileroFile) { pct -> + onProgress(pct * 5 / 100, "下载 VAD 模型... ${pct}%") + } + } + + // 2. tokens.txt (~308KB) + val tokensFile = File(senseVoiceDir, "tokens.txt") + if (!tokensFile.exists()) { + onProgress(5, "下载 tokens...") + downloadFile(SENSEVOICE_TOKENS_URL, tokensFile) { pct -> + onProgress(5 + pct * 2 / 100, "下载 tokens... ${pct}%") + } + } + + // 3. SenseVoice int8 (~228MB) + val modelFile = File(senseVoiceDir, "model.int8.onnx") + if (!modelFile.exists()) { + onProgress(7, "下载 SenseVoice 模型(约 228MB)...") + downloadFile(SENSEVOICE_MODEL_URL, modelFile) { pct -> + onProgress(7 + pct * 93 / 100, "下载 SenseVoice 模型... ${pct}%") + } + } + + onProgress(100, "模型就绪") + Log.i(TAG, "模型下载完成") + true + } catch (e: Exception) { + Log.e(TAG, "模型下载失败", e) + false + } + } + + private fun downloadFile(urlStr: String, dest: File, onProgress: (Int) -> Unit) { + var attempt = 0 + val maxAttempts = 3 + + while (attempt < maxAttempts) { + try { + attempt++ + doDownload(urlStr, dest, onProgress) + return + } catch (e: Exception) { + Log.w(TAG, "下载失败 (第${attempt}次): ${dest.name}", e) + if (attempt >= maxAttempts) throw e + Thread.sleep(2000) // 重试前等待 + } + } + } + + private fun doDownload(urlStr: String, dest: File, onProgress: (Int) -> Unit) { + val url = URL(urlStr) + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 15000 + conn.readTimeout = 120000 + + // 断点续传 + var downloaded = if (dest.exists()) dest.length() else 0L + if (downloaded > 0) { + conn.setRequestProperty("Range", "bytes=$downloaded-") + } + + conn.connect() + + val totalSize = if (downloaded > 0) { + conn.getHeaderField("Content-Range")?.substringAfter("/")?.toLongOrNull() + ?: (conn.contentLengthLong + downloaded) + } else { + conn.contentLengthLong + } + + val inputStream = conn.inputStream + val outputStream = FileOutputStream(dest, downloaded > 0) + val buffer = ByteArray(65536) + var bytesRead: Int + var totalRead = downloaded + var lastProgressReport = System.currentTimeMillis() + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalRead += bytesRead + + // 限流进度回调(每 200ms 最多报一次) + val now = System.currentTimeMillis() + if (totalSize > 0 && now - lastProgressReport > 200) { + val pct = (totalRead * 100 / totalSize).toInt() + onProgress(pct.coerceIn(0, 100)) + lastProgressReport = now + } + } + + if (totalSize > 0 && totalRead < totalSize) { + throw IOException("下载不完整: 期望 ${totalSize} 字节,实际 ${totalRead} 字节") + } + + inputStream.close() + outputStream.close() + conn.disconnect() + } +} diff --git a/android-webview-app/app/src/main/java/com/cyjai/agent/PdfPreviewActivity.kt b/android-webview-app/app/src/main/java/com/cyjai/agent/PdfPreviewActivity.kt new file mode 100644 index 00000000..e1f31ba4 --- /dev/null +++ b/android-webview-app/app/src/main/java/com/cyjai/agent/PdfPreviewActivity.kt @@ -0,0 +1,150 @@ +package com.cyjai.agent + +import android.net.Uri +import android.os.Bundle +import android.view.MenuItem +import android.view.View +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.github.barteksc.pdfviewer.PDFView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +class PdfPreviewActivity : AppCompatActivity() { + + companion object { + const val EXTRA_PDF_URL = "pdf_url" + private val HOME_URL: String = BuildConfig.HOME_URL + } + + private lateinit var pdfView: PDFView + private lateinit var loadingContainer: LinearLayout + private lateinit var progressBar: ProgressBar + private lateinit var statusText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_pdf_preview) + + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) + title = "PDF 预览" + } + + pdfView = findViewById(R.id.pdfView) + loadingContainer = findViewById(R.id.loadingContainer) + progressBar = findViewById(R.id.progressBar) + statusText = findViewById(R.id.statusText) + + val rawUrl = intent.getStringExtra(EXTRA_PDF_URL) ?: "" + if (rawUrl.isBlank()) { + showError("PDF 地址为空") + return + } + + val url = if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) { + rawUrl + } else { + HOME_URL.trimEnd('/') + (if (rawUrl.startsWith("/")) rawUrl else "/$rawUrl") + } + + loadPdf(url) + } + + private fun showLoading(msg: String) { + loadingContainer.visibility = View.VISIBLE + progressBar.visibility = View.VISIBLE + statusText.visibility = View.VISIBLE + statusText.text = msg + pdfView.visibility = View.INVISIBLE + } + + private fun hideLoading() { + loadingContainer.visibility = View.GONE + progressBar.visibility = View.GONE + statusText.visibility = View.GONE + pdfView.visibility = View.VISIBLE + } + + private fun showError(msg: String) { + loadingContainer.visibility = View.VISIBLE + progressBar.visibility = View.GONE + statusText.visibility = View.VISIBLE + statusText.text = msg + pdfView.visibility = View.INVISIBLE + Toast.makeText(this, msg, Toast.LENGTH_LONG).show() + } + + private fun loadPdf(url: String) { + showLoading("加载中...") + lifecycleScope.launch(Dispatchers.IO) { + try { + val file = downloadToCache(url) + withContext(Dispatchers.Main) { + showPdf(file) + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + showError("PDF 加载失败: ${e.message}") + } + } + } + } + + private fun downloadToCache(url: String): File { + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 60_000 + conn.setRequestProperty("Accept", "application/pdf,*/*") + conn.connect() + + if (conn.responseCode !in 200..299) { + throw RuntimeException("HTTP ${conn.responseCode}") + } + + val pathParam = Uri.parse(url).getQueryParameter("path") + val fileName = if (!pathParam.isNullOrBlank()) { + "preview_" + pathParam.replace("/", "_") + } else { + "preview_${System.currentTimeMillis()}.pdf" + } + val file = File(cacheDir, fileName) + + FileOutputStream(file).use { out -> + conn.inputStream.use { input -> + input.copyTo(out) + } + } + conn.disconnect() + return file + } + + private fun showPdf(file: File) { + pdfView.fromFile(file) + .enableSwipe(true) + .swipeHorizontal(false) + .enableDoubletap(true) + .defaultPage(0) + .onError { showError("PDF 渲染失败") } + .onLoad { hideLoading() } + .load() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return if (item.itemId == android.R.id.home) { + finish() + true + } else { + super.onOptionsItemSelected(item) + } + } +} diff --git a/android-webview-app/app/src/main/java/com/cyjai/agent/VoiceBridge.kt b/android-webview-app/app/src/main/java/com/cyjai/agent/VoiceBridge.kt new file mode 100644 index 00000000..95be6d85 --- /dev/null +++ b/android-webview-app/app/src/main/java/com/cyjai/agent/VoiceBridge.kt @@ -0,0 +1,501 @@ +package com.cyjai.agent + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import androidx.core.app.ActivityCompat +import com.k2fsa.sherpa.onnx.OfflineRecognizer +import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig +import com.k2fsa.sherpa.onnx.OfflineSenseVoiceModelConfig +import com.k2fsa.sherpa.onnx.OfflineModelConfig +import com.k2fsa.sherpa.onnx.FeatureConfig +import com.k2fsa.sherpa.onnx.Vad +import com.k2fsa.sherpa.onnx.VadModelConfig +import com.k2fsa.sherpa.onnx.SileroVadModelConfig +import com.k2fsa.sherpa.onnx.SpeechSegment +import android.os.Environment +import kotlinx.coroutines.* +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 语音识别桥接 — 通过 JS Bridge 暴露给 WebView + * + * 前端调用: + * window.AndroidVoiceBridge.startListening() // 开始录音 + * window.AndroidVoiceBridge.stopListening() // 停止录音 + * window.AndroidVoiceBridge.isSupported() // 是否支持 → true + * + * 结果通过全局回调传回前端: + * window.__onVoiceResult(text) // 识别结果 + * window.__onVoiceStatus(status) // 状态变化: "idle"|"listening"|"processing" + * window.__onVoiceError(error) // 错误 + */ +class VoiceBridge( + private val context: Context, + private val webView: WebView +) { + companion object { + private const val TAG = "VoiceBridge" + private const val SAMPLE_RATE = 16000 + private const val CHUNK_SIZE = 512 // 每次读取的采样数 + } + + // ── 状态 ── + private val isRecording = AtomicBoolean(false) + private var audioRecord: AudioRecord? = null + private var recordingJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var recordingStartTime = 0L // 录音开始时间戳(用于防抖) + private val MIN_RECORDING_MS = 400L // 最小录音时长(防抖) + + // ── sherpa-onnx 引擎 ── + private var recognizer: OfflineRecognizer? = null + private var vad: Vad? = null + private var initialized = false + private var engineInitJob: Job? = null // 预初始化任务 + + // ── 调试日志 ── + private val logFile: File + get() = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "voice_debug.log") + private val dateFmt = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.getDefault()) + + private fun logToFile(msg: String) { + try { + val ts = dateFmt.format(Date()) + logFile.parentFile?.mkdirs() + FileWriter(logFile, true).use { it.write("[$ts] $msg\n") } + } catch (_: Exception) {} + } + + // ── 模型路径 ── + private val modelDir: File + get() = File(context.filesDir, "voice_models") + private val senseVoiceModel: File + get() = File(modelDir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/model.int8.onnx") + private val senseVoiceTokens: File + get() = File(modelDir, "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17/tokens.txt") + private val sileroVadModel: File + get() = File(modelDir, "silero_vad.onnx") + + // ═══════════════════ JS Bridge 接口 ═══════════════════ + + @JavascriptInterface + fun isSupported(): Boolean = true + + /** 模型是否已就绪(含文件大小校验) */ + @JavascriptInterface + fun isModelReady(): Boolean { + return ModelManager.isModelReady(context) + } + + /** 是否有模型文件残留(存在但不完整,需清理) */ + @JavascriptInterface + fun isModelPartial(): Boolean { + val filesExist = senseVoiceModel.exists() || senseVoiceTokens.exists() || sileroVadModel.exists() + return filesExist && !ModelManager.isModelReady(context) + } + + /** 删除已下载的模型文件 */ + @JavascriptInterface + fun deleteModel(): Boolean { + releaseEngine() + return ModelManager.deleteModels(context) + } + + /** 触发模型下载(供个人空间手动下载),下载前先释放引擎并清理旧文件 */ + @JavascriptInterface + fun downloadModel() { + scope.launch { + try { + // 先释放引擎(避免文件被 onnxruntime 锁定导致删除失败) + releaseEngine() + ModelManager.deleteModels(context) + postToJs("window.__onVoiceDownloadProgress(0, '开始下载...')") + val success = ModelManager.downloadModels(context) { pct, msg -> + postToJs("window.__onVoiceDownloadProgress($pct, '${escapeJs(msg)}')") + } + if (success) { + postToJs("window.__onVoiceDownloadProgress(100, '下载完成')") + // 下载完成后立即预初始化引擎,这样用户点击时直接可用 + ensureEngine() + } else { + postToJs("window.__onVoiceError('模型下载失败,请检查网络后重试')") + } + } catch (e: Exception) { + Log.e(TAG, "下载流程异常", e) + postToJs("window.__onVoiceError('${escapeJs(e.message ?: "未知错误")}')") + } + } + } + + /** 释放识别引擎资源 */ + private fun releaseEngine() { + initialized = false + engineInitJob?.cancel() + engineInitJob = null + try { recognizer?.release() } catch (_: Exception) {} + recognizer = null + try { vad?.release() } catch (_: Exception) {} + vad = null + } + + /** 预初始化引擎(后台,不阻塞调用者)。下载完成后或 App 启动时调用 */ + fun ensureEngine() { + if (initialized) return + if (!isModelReady()) { + logToFile("ensureEngine: 模型未就绪,跳过") + return + } + if (engineInitJob?.isActive == true) { + logToFile("ensureEngine: 已有初始化任务运行中") + return + } + logToFile("ensureEngine: 开始后台预初始化引擎") + engineInitJob = scope.launch(Dispatchers.IO) { + val ok = initEngine() + logToFile("ensureEngine: initEngine 返回 $ok") + if (ok) { + withContext(Dispatchers.Main) { + postToJs("window.__onVoiceModelReady()") + } + } + } + } + + /** 获取模型大小(用于 UI 展示) */ + @JavascriptInterface + fun getModelSizeMB(): Double { + return 228.0 + } + + /** 收集调试日志并上传到服务器 */ + @JavascriptInterface + fun debugLog(msg: String) { + logToFile("[JS] $msg") + } + + @JavascriptInterface + fun collectDebugLog(): String { + val sb = StringBuilder() + sb.appendLine("=== 语音调试日志 ===") + sb.appendLine("时间: ${dateFmt.format(Date())}") + sb.appendLine("模型目录: ${modelDir.absolutePath}") + sb.appendLine("senseVoice model: 存在=${senseVoiceModel.exists()}, 大小=${if (senseVoiceModel.exists()) senseVoiceModel.length() else -1}") + sb.appendLine("senseVoice tokens: 存在=${senseVoiceTokens.exists()}, 大小=${if (senseVoiceTokens.exists()) senseVoiceTokens.length() else -1}") + sb.appendLine("sileroVad: 存在=${sileroVadModel.exists()}, 大小=${if (sileroVadModel.exists()) sileroVadModel.length() else -1}") + sb.appendLine("isModelReady: ${ModelManager.isModelReady(context)}") + sb.appendLine("引擎已初始化: $initialized") + sb.appendLine("recognizer: ${recognizer != null}, vad: ${vad != null}") + sb.appendLine("录音权限: ${hasRecordPermission()}") + sb.appendLine("isRecording: ${isRecording.get()}") + sb.appendLine("") + sb.appendLine("=== 文件日志 ===") + if (logFile.exists()) { + try { sb.append(logFile.readText()) } catch (e: Exception) { sb.appendLine("(读取日志失败: ${e.message})") } + } else { + sb.appendLine("(无文件日志)") + } + return sb.toString() + } + + @JavascriptInterface + fun startListening() { + logToFile("startListening 被调用, initialized=$initialized") + if (!hasRecordPermission()) { + logToFile("startListening: 缺少录音权限") + postToJs("window.__onVoiceError('缺少录音权限')") + Log.e(TAG, "缺少录音权限") + return + } + + // 检查模型是否就绪 + if (!isModelReady()) { + logToFile("startListening: 模型未就绪") + postToJs("window.__onVoiceStatus('model_not_ready')") + Log.w(TAG, "模型未就绪") + return + } + + if (isRecording.get()) { + logToFile("startListening: 已在录音中,忽略") + Log.w(TAG, "已经在录音中") + return + } + + if (!initialized) { + // 触发后台初始化并等待,完成后自动开始录音 + logToFile("startListening: 引擎未初始化,触发后台初始化并等待") + postToJs("window.__onVoiceStatus('initializing')") + pendingStartJob?.cancel() + pendingStartJob = scope.launch(Dispatchers.IO) { + val ok = initEngine() + logToFile("startListening: initEngine 返回 $ok") + withContext(Dispatchers.Main) { + if (pendingStartJob == null || !pendingStartJob!!.isActive) { + logToFile("startListening: 启动请求在初始化期间被取消") + return@withContext + } + pendingStartJob = null + if (!ok) { + postToJs("window.__onVoiceError('模型初始化失败')") + return@withContext + } + startRecordingInternal() + } + } + } else { + startRecordingInternal() + } + } + + @JavascriptInterface + fun stopListening() { + logToFile("stopListening 被调用, isRecording=${isRecording.get()}") + + // 取消待处理的启动(初始化期间的取消) + pendingStartJob?.cancel() + pendingStartJob = null + + // 如果还没开始录音(初始化中),直接通知前端停止 + if (!isRecording.get()) { + logToFile("stopListening: 录音尚未开始,取消启动请求") + postToJs("window.__onVoiceStatus('idle')") + return + } + + // 防抖:录音开始后 MIN_RECORDING_MS 内不允许停止 + val elapsed = System.currentTimeMillis() - recordingStartTime + if (elapsed < MIN_RECORDING_MS) { + logToFile("stopListening: 录音时长不足 ${MIN_RECORDING_MS}ms,忽略 (已录 ${elapsed}ms)") + Log.w(TAG, "录音时长不足 ${MIN_RECORDING_MS}ms,忽略停止请求 (已录 ${elapsed}ms)") + return + } + stopRecordingInternal() + } + + // ═══════════════════ 引擎初始化 ═══════════════════ + + private suspend fun initEngine(): Boolean = withContext(Dispatchers.IO) { + try { + // 先做文件大小校验,避免用残缺文件初始化导致 Native crash + if (!ModelManager.isModelReady(context)) { + logToFile("initEngine: 模型文件不完整,拒绝初始化") + Log.e(TAG, "模型文件不完整,拒绝初始化") + return@withContext false + } + + logToFile("initEngine: 模型文件完整,开始加载 SenseVoice...") + Log.i(TAG, "初始化 SenseVoice 识别器... model=${senseVoiceModel.length()} tokens=${senseVoiceTokens.length()}") + + // SenseVoice 模型配置 + val senseVoiceConfig = OfflineSenseVoiceModelConfig( + model = senseVoiceModel.absolutePath, + useInverseTextNormalization = true + ) + + val featConfig = FeatureConfig( + sampleRate = SAMPLE_RATE, + featureDim = 80 + ) + + val modelConfig = OfflineModelConfig() + modelConfig.senseVoice = senseVoiceConfig + modelConfig.tokens = senseVoiceTokens.absolutePath + modelConfig.numThreads = 1 // 单线程降低内存压力 + modelConfig.provider = "cpu" + + val config = OfflineRecognizerConfig( + featConfig = featConfig, + modelConfig = modelConfig + ) + + Log.i(TAG, "开始创建 OfflineRecognizer...") + logToFile("initEngine: 开始创建 OfflineRecognizer (numThreads=1)...") + recognizer = OfflineRecognizer( + assetManager = null, + config = config + ) + logToFile("initEngine: OfflineRecognizer 创建完成") + Log.i(TAG, "SenseVoice 识别器初始化完成") + + // VAD 暂时禁用(Android 端 Silero VAD 存在兼容性问题,改用整段识别) + logToFile("initEngine: 跳过 VAD,使用整段识别模式") + vad = null + + initialized = true + logToFile("initEngine: 全部初始化完成") + true + } catch (e: Exception) { + logToFile("initEngine: 异常 ${e.javaClass.simpleName}: ${e.message}") + false + } + } + + // ═══════════════════ 录音 ═══════════════════ + + // ── 待处理的启动任务(用于在初始化期间取消)── + private var pendingStartJob: Job? = null + + // ── 录音数据缓冲(无 VAD 模式,整段识别)── + private val audioBuffer = mutableListOf() + + private fun startRecordingInternal() { + logToFile("startRecordingInternal: 开始, isRecording=${isRecording.get()}") + if (isRecording.getAndSet(true)) { + logToFile("startRecordingInternal: 已在录音中,跳过") + return + } + + val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + logToFile("startRecordingInternal: bufferSize=$bufferSize") + + audioRecord = AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize * 2 + ) + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + logToFile("startRecordingInternal: AudioRecord 初始化失败 state=${audioRecord?.state}") + Log.e(TAG, "AudioRecord 初始化失败") + isRecording.set(false) + postToJs("window.__onVoiceError('麦克风初始化失败')") + return + } + + audioRecord?.startRecording() + recordingStartTime = System.currentTimeMillis() + audioBuffer.clear() + postToJs("window.__onVoiceStatus('listening')") + logToFile("startRecordingInternal: 录音已开始") + Log.i(TAG, "开始录音") + + recordingJob = scope.launch { + processAudioLoop() + } + } + + private suspend fun processAudioLoop() { + val buffer = ShortArray(CHUNK_SIZE) + while (isRecording.get()) { + val readCount = audioRecord?.read(buffer, 0, buffer.size) ?: -1 + if (readCount <= 0) continue + for (i in 0 until readCount) { + audioBuffer.add(buffer[i]) + } + } + } + + private fun recognizeFullAudio() { + logToFile("recognizeFullAudio: buffer size=${audioBuffer.size}") + if (audioBuffer.isEmpty()) { + logToFile("recognizeFullAudio: buffer 为空") + postToJs("window.__onVoiceError('未检测到语音')") + return + } + scope.launch { + postToJs("window.__onVoiceStatus('processing')") + val samples = FloatArray(audioBuffer.size) { audioBuffer[it] / 32768.0f } + logToFile("recognizeFullAudio: 开始识别 ${samples.size} 采样 (${samples.size / SAMPLE_RATE}s)") + val text = withContext(Dispatchers.IO) { recognizeSegment(samples) } + logToFile("recognizeFullAudio: 识别结果 text='$text' length=${text.length}") + if (text.isNotBlank()) { + Log.i(TAG, "识别结果: $text") + logToFile("recognizeFullAudio: 准备调用 postToJs __onVoiceResult") + postToJs("window.__onVoiceResult('${escapeJs(text)}')") + logToFile("recognizeFullAudio: postToJs __onVoiceResult 已提交") + } else { + logToFile("recognizeFullAudio: 识别结果为空") + postToJs("window.__onVoiceError('未识别到语音内容')") + } + postToJs("window.__onVoiceStatus('idle')") + } + } + + private fun recognizeSegment(samples: FloatArray): String { + val rec = recognizer ?: return "" + return try { + val stream = rec.createStream() + stream.acceptWaveform(samples, SAMPLE_RATE) + rec.decode(stream) + val result = rec.getResult(stream) + stream.release() + val text = result.text?.trim() ?: "" + logToFile("recognizeSegment: text='$text' lang='${result.lang}' emotion='${result.emotion}' event='${result.event}'") + text + } catch (e: Exception) { + logToFile("recognizeSegment: 异常 ${e.javaClass.simpleName}: ${e.message}") + Log.e(TAG, "识别错误", e) + "" + } + } + + private fun stopRecordingInternal() { + logToFile("stopRecordingInternal 被调用, isRecording=${isRecording.get()}") + if (!isRecording.getAndSet(false)) { + logToFile("stopRecordingInternal: isRecording 已为 false,跳过") + return + } + + recordingJob?.cancel() + recordingJob = null + + try { + audioRecord?.stop() + audioRecord?.release() + } catch (e: Exception) { + Log.w(TAG, "AudioRecord 释放异常", e) + logToFile("stopRecordingInternal: AudioRecord 释放异常 ${e.message}") + } + audioRecord = null + + logToFile("stopRecordingInternal: 录音已停止,共 ${audioBuffer.size} 采样") + Log.i(TAG, "录音已停止,共 ${audioBuffer.size} 采样") + recognizeFullAudio() + } + + // ═══════════════════ 工具方法 ═══════════════════ + + private fun hasRecordPermission(): Boolean { + return ActivityCompat.checkSelfPermission( + context, Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + } + + private fun postToJs(script: String) { + webView.post { + webView.evaluateJavascript(script, null) + } + } + + private fun escapeJs(text: String): String { + return text + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r") + } + + fun destroy() { + stopRecordingInternal() + scope.cancel() + releaseEngine() + } +} diff --git a/android-webview-app/app/src/main/res/drawable/ic_bot_icon.xml b/android-webview-app/app/src/main/res/drawable/ic_bot_icon.xml new file mode 100644 index 00000000..a3ead3e5 --- /dev/null +++ b/android-webview-app/app/src/main/res/drawable/ic_bot_icon.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android-webview-app/app/src/main/res/drawable/ic_launcher_photo.png b/android-webview-app/app/src/main/res/drawable/ic_launcher_photo.png new file mode 100644 index 00000000..19f1c7e5 Binary files /dev/null and b/android-webview-app/app/src/main/res/drawable/ic_launcher_photo.png differ diff --git a/android-webview-app/app/src/main/res/drawable/ic_launcher_photo_v2.png b/android-webview-app/app/src/main/res/drawable/ic_launcher_photo_v2.png new file mode 100644 index 00000000..19f1c7e5 Binary files /dev/null and b/android-webview-app/app/src/main/res/drawable/ic_launcher_photo_v2.png differ diff --git a/android-webview-app/app/src/main/res/layout/activity_pdf_preview.xml b/android-webview-app/app/src/main/res/layout/activity_pdf_preview.xml new file mode 100644 index 00000000..b403f5eb --- /dev/null +++ b/android-webview-app/app/src/main/res/layout/activity_pdf_preview.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/android-webview-app/app/src/main/res/values/strings.xml b/android-webview-app/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..fe0c6fe2 --- /dev/null +++ b/android-webview-app/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Astrion + diff --git a/android-webview-app/app/src/main/res/values/themes.xml b/android-webview-app/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..3a2592dc --- /dev/null +++ b/android-webview-app/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android-webview-app/app/src/main/res/xml/file_paths.xml b/android-webview-app/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..d9c45e87 --- /dev/null +++ b/android-webview-app/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android-webview-app/app/src/main/res/xml/network_security_config.xml b/android-webview-app/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..61159508 --- /dev/null +++ b/android-webview-app/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android-webview-app/build.gradle.kts b/android-webview-app/build.gradle.kts new file mode 100644 index 00000000..017d9099 --- /dev/null +++ b/android-webview-app/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/android-webview-app/gradle.properties b/android-webview-app/gradle.properties new file mode 100644 index 00000000..f0a2e55f --- /dev/null +++ b/android-webview-app/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android-webview-app/gradle/wrapper/gradle-wrapper.jar b/android-webview-app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..980502d1 Binary files /dev/null and b/android-webview-app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-webview-app/gradle/wrapper/gradle-wrapper.properties b/android-webview-app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..128196a7 --- /dev/null +++ b/android-webview-app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-webview-app/gradlew b/android-webview-app/gradlew new file mode 100755 index 00000000..faf93008 --- /dev/null +++ b/android-webview-app/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android-webview-app/gradlew.bat b/android-webview-app/gradlew.bat new file mode 100644 index 00000000..9b42019c --- /dev/null +++ b/android-webview-app/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android-webview-app/settings.gradle.kts b/android-webview-app/settings.gradle.kts new file mode 100644 index 00000000..c6d5a5d8 --- /dev/null +++ b/android-webview-app/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "AgentWebView" +include(":app")