├── app ├── .gitignore ├── src │ ├── main │ │ ├── ic_launcher-playstore.png │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ ├── ic_launcher_background.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ ├── ic_launcher_background.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ ├── ic_launcher_background.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ ├── ic_launcher_background.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ ├── ic_launcher_background.webp │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ ├── themes.xml │ │ │ │ └── strings.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── layout │ │ │ │ └── activity_main.xml │ │ │ ├── drawable │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ └── values-en │ │ │ │ └── strings.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── hwb │ │ │ │ └── aianswerer │ │ │ │ ├── utils │ │ │ │ ├── ClipboardUtil.kt │ │ │ │ ├── ImageCropUtil.kt │ │ │ │ └── LanguageUtil.kt │ │ │ │ ├── models │ │ │ │ ├── CropRect.kt │ │ │ │ └── OpenAIModels.kt │ │ │ │ ├── ui │ │ │ │ ├── dialogs │ │ │ │ │ ├── ModelSetupReminderDialog.kt │ │ │ │ │ └── LanguageSelectionDialog.kt │ │ │ │ ├── theme │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── Type.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── icons │ │ │ │ │ └── LocalIcons.kt │ │ │ │ └── components │ │ │ │ │ └── CommonComponents.kt │ │ │ │ ├── MyApplication.kt │ │ │ │ ├── Constants.kt │ │ │ │ ├── TextRecognitionManager.kt │ │ │ │ ├── ScreenCaptureManager.kt │ │ │ │ ├── config │ │ │ │ └── AppConfig.kt │ │ │ │ ├── ConfirmTextActivity.kt │ │ │ │ ├── AboutActivity.kt │ │ │ │ ├── ModelSettingsActivity.kt │ │ │ │ ├── api │ │ │ │ └── OpenAIClient.kt │ │ │ │ └── SettingsActivity.kt │ │ └── AndroidManifest.xml │ ├── debug │ │ └── res │ │ │ ├── values │ │ │ └── strings.xml │ │ │ └── values-en │ │ │ └── strings.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── hwb │ │ │ └── aianswerer │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── hwb │ │ └── aianswerer │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle.kts ├── image ├── main.png ├── ai_setting.jpg ├── answerer.jpg └── crop_demo.jpg ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── local.properties.template ├── settings.gradle.kts ├── LICENSE ├── gradle.properties ├── README.md ├── .gitignore ├── gradlew.bat ├── README_EN.md └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /image/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/image/main.png -------------------------------------------------------------------------------- /image/ai_setting.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/image/ai_setting.jpg -------------------------------------------------------------------------------- /image/answerer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/image/answerer.jpg -------------------------------------------------------------------------------- /image/crop_demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/image/crop_demo.jpg -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | AI答题助手_debug 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/debug/res/values-en/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | AI Answerer_debug 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb-hwang/AIAnswerer-Android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FF000000 4 | #FFFFFFFF 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Oct 22 16:36:49 CST 2025 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /app/src/test/java/com/hwb/aianswerer/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } -------------------------------------------------------------------------------- /local.properties.template: -------------------------------------------------------------------------------- 1 | # Copy this file to local.properties and fill in values appropriate for your environment. 2 | # Gradle ignores this file by default, but keep it out of version control if it contains secrets. 3 | 4 | ## Android SDK location 5 | sdk.dir=xxx 6 | 7 | ## AI API configuration 8 | api.url=https://integrate.api.nvidia.com/v1/chat/completions 9 | api.key=YOUR_API_KEY 10 | api.model=deepseek-ai/deepseek-v3.1 11 | 12 | ## Release signing configuration 13 | signing.storeFile=../keystore/release.jks 14 | signing.storePassword=your_store_password 15 | signing.keyAlias=your_key_alias 16 | signing.keyPassword=your_key_password 17 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google { 4 | content { 5 | includeGroupByRegex("com\\.android.*") 6 | includeGroupByRegex("com\\.google.*") 7 | includeGroupByRegex("androidx.*") 8 | } 9 | } 10 | mavenCentral() 11 | gradlePluginPortal() 12 | } 13 | } 14 | dependencyResolutionManagement { 15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 16 | repositories { 17 | google() 18 | mavenCentral() 19 | } 20 | } 21 | 22 | rootProject.name = "AI Answerer" 23 | include(":app") 24 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/hwb/aianswerer/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("com.hwb.aianswerer", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hwang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. For more details, visit 12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/utils/ClipboardUtil.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.utils 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | import android.content.Context 6 | 7 | /** 8 | * 剪贴板工具类 9 | */ 10 | object ClipboardUtil { 11 | 12 | /** 13 | * 复制文本到剪贴板 14 | */ 15 | fun copyToClipboard(context: Context, text: String, label: String = "AI答案"): Boolean { 16 | return try { 17 | val clipboardManager = 18 | context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager 19 | val clip = ClipData.newPlainText(label, text) 20 | clipboardManager?.setPrimaryClip(clip) 21 | true 22 | } catch (e: Exception) { 23 | e.printStackTrace() 24 | false 25 | } 26 | } 27 | 28 | /** 29 | * 从剪贴板获取文本 30 | */ 31 | fun getFromClipboard(context: Context): String? { 32 | return try { 33 | val clipboardManager = 34 | context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager 35 | val clip = clipboardManager?.primaryClip 36 | if (clip != null && clip.itemCount > 0) { 37 | clip.getItemAt(0).text?.toString() 38 | } else { 39 | null 40 | } 41 | } catch (e: Exception) { 42 | e.printStackTrace() 43 | null 44 | } 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.13.0" 3 | kotlin = "2.0.21" 4 | coreKtx = "1.10.1" 5 | junit = "4.13.2" 6 | junitVersion = "1.1.5" 7 | espressoCore = "3.5.1" 8 | appcompat = "1.6.1" 9 | material = "1.10.0" 10 | activity = "1.8.0" 11 | constraintlayout = "2.1.4" 12 | mmkv = "1.3.9" 13 | 14 | [libraries] 15 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 16 | junit = { group = "junit", name = "junit", version.ref = "junit" } 17 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 18 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 19 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } 20 | material = { group = "com.google.android.material", name = "material", version.ref = "material" } 21 | androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } 22 | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } 23 | mmkv = { group = "com.tencent", name = "mmkv", version.ref = "mmkv" } 24 | 25 | [plugins] 26 | android-application = { id = "com.android.application", version.ref = "agp" } 27 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 28 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/models/CropRect.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.models 2 | 3 | import android.graphics.PointF 4 | 5 | /** 6 | * 裁剪矩形数据类 7 | * 简化版:只存储左上角和右下角坐标(相对于原始图片尺寸) 8 | * 注意:坐标仅在运行时内存中保持,不进行持久化 9 | */ 10 | data class CropRect( 11 | val topLeft: PointF, 12 | val bottomRight: PointF 13 | ) { 14 | /** 15 | * 获取矩形的宽度 16 | */ 17 | val width: Float 18 | get() = bottomRight.x - topLeft.x 19 | 20 | /** 21 | * 获取矩形的高度 22 | */ 23 | val height: Float 24 | get() = bottomRight.y - topLeft.y 25 | 26 | /** 27 | * 验证坐标是否有效 28 | */ 29 | fun isValid(imageWidth: Int, imageHeight: Int): Boolean { 30 | return topLeft.x >= 0 && topLeft.x < bottomRight.x && 31 | topLeft.y >= 0 && topLeft.y < bottomRight.y && 32 | bottomRight.x <= imageWidth && bottomRight.y <= imageHeight && 33 | width > 0 && height > 0 34 | } 35 | 36 | companion object { 37 | /** 38 | * 创建默认的裁剪矩形(覆盖整个图片的80%中心区域) 39 | */ 40 | fun createDefault(imageWidth: Int, imageHeight: Int): CropRect { 41 | val margin = 0.1f 42 | val left = imageWidth * margin 43 | val top = imageHeight * margin 44 | val right = imageWidth * (1 - margin) 45 | val bottom = imageHeight * (1 - margin) 46 | 47 | return CropRect( 48 | topLeft = PointF(left, top), 49 | bottomRight = PointF(right, bottom) 50 | ) 51 | } 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ui/dialogs/ModelSetupReminderDialog.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.ui.dialogs 2 | 3 | import androidx.compose.material3.AlertDialog 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.material3.TextButton 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import com.hwb.aianswerer.R 10 | 11 | /** 12 | * 使用方法:在Compose中调用 ModelSetupReminderDialog() 13 | * 功能:提醒用户设置AI模型配置,引导到设置页面 14 | */ 15 | @Composable 16 | fun ModelSetupReminderDialog( 17 | onDismiss: () -> Unit, 18 | onGoToSettings: () -> Unit 19 | ) { 20 | AlertDialog( 21 | onDismissRequest = onDismiss, 22 | title = { 23 | Text( 24 | text = stringResource(R.string.dialog_model_setup_title), 25 | style = MaterialTheme.typography.titleLarge 26 | ) 27 | }, 28 | text = { 29 | Text( 30 | text = stringResource(R.string.dialog_model_setup_message), 31 | style = MaterialTheme.typography.bodyMedium 32 | ) 33 | }, 34 | confirmButton = { 35 | TextButton( 36 | onClick = { 37 | onGoToSettings() 38 | } 39 | ) { 40 | Text(stringResource(R.string.dialog_model_setup_confirm)) 41 | } 42 | }, 43 | dismissButton = { 44 | TextButton( 45 | onClick = { 46 | onDismiss() 47 | } 48 | ) { 49 | Text(stringResource(R.string.dialog_model_setup_cancel)) 50 | } 51 | } 52 | ) 53 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI答题助手 (AIAnswerer) 2 | 3 | [中文](#中文使用指南) | [English](README_EN.md) 4 | 5 | ## 中文使用指南 6 | 7 | ### 应用简介 8 | AI答题助手是一款基于 OCR 与大语言模型的安卓答题工具。通过悬浮窗截图识别题目,并调用 DeepSeek AI 等兼容 OpenAI 接口的模型为你快速给出答案,适用于练习、查缺补漏或自测场景。 9 | 10 | 11 | 12 | 13 | ### 功能亮点 14 | - 🖼️ 屏幕快速截取:一键截取当前屏幕,自动聚焦题目区域 15 | - 📝 智能文字识别:支持中英文识别,可在提交前编辑校正 16 | - 🤖 AI 实时答题:根据题型生成解析,并自动复制答案 17 | - 💬 悬浮窗操作:无需切换应用即可完成截屏、预览、提交 18 | - 🔒 本地可控:自定义 API Key,随时启停网络请求 19 | 20 | ### 安装与准备 21 | 1. 使用 Android 11 及以上系统的设备,并保持网络通畅。 22 | 2. 安装提供的 APK 文件;首次安装需按照系统提示允许来自未知来源的应用。 23 | 3. 设置 LLM 模型信息。 24 | 4. 首次启动时,按照屏幕提示授予悬浮窗、截屏和通知等必要权限。 25 | 26 | ### 快速上手 27 | 1. 参考应用内说明 28 | 29 | ### 支持的题型 30 | - 选择题:识别题干与选项,标记推荐答案并给出理由 31 | - 填空题:生成精炼答案,适用于多空位题目 32 | - 问答题:提供结构化解答或要点式分析 33 | 34 | ### 使用小贴士 35 | - 保持截图清晰、居中,避免复杂背景,以提升 OCR 准确率。 36 | - 如需暂停网络请求,可暂时断网或在设置页关闭 AI 回答。 37 | - 答案生成后可再次点击悬浮按钮刷新题目,便于连续练习。 38 | 39 | ### 常见问题 40 | - **提示缺少权限?** 前往系统设置搜索“悬浮窗”“屏幕录制”等选项,手动开启相关权限。 41 | - **识别不准确?** 在确认页手动修正文本,或重新截图后再提交。 42 | - **AI 没有回应?** 检查网络、确认 API Key 有效,并确保 DeepSeek 账户余额充足。 43 | 44 | ### 待完成 45 | - ~~精准框选答题区域(微信读书等)~~ 46 | - 自定义题库/知识库(内部文档、技术文档、网站等) 47 | - github action 自动打包 48 | - ~~优化prompt~~、自定义prompt 49 | 50 | ### 隐私与免责声明 51 | - 应用会将识别出的文字发送至所选 AI 服务,请避免上传敏感或受限内容。 52 | - DeepSeek API 请求可能产生费用,请留意使用频率。 53 | - 本应用仅用于学习与研究,请遵守考试纪律和法律法规,任何违规使用后果自负。 54 | 55 | ### 更新说明 56 | 57 | #### v0.4 58 | * 优化了prompt 59 | * 兼容了GPT-5传回的markdown 格式 60 | 61 | #### v0.3 62 | * 加入COR 前裁剪功能,提高题目识别能力 63 | 64 | #### v0.2 65 | * 修复release 包无法请求ai api 的问题 66 | 67 | #### v0.1 68 | * 初次发版 69 | 70 | ### License 71 | This project is released under the [MIT License](/LICENSE) 72 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/MyApplication.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import com.hwb.aianswerer.config.AppConfig 6 | import com.hwb.aianswerer.utils.LanguageUtil 7 | 8 | class MyApplication : Application() { 9 | 10 | override fun attachBaseContext(base: Context) { 11 | // 在attachBaseContext中初始化MMKV(必须在使用AppConfig之前) 12 | AppConfig.init(base) 13 | 14 | // 应用语言配置并获取新的Context 15 | val context = LanguageUtil.attachBaseContext(base) 16 | 17 | super.attachBaseContext(context) 18 | } 19 | 20 | override fun onCreate() { 21 | super.onCreate() 22 | 23 | // 保存Application实例 24 | instance = this 25 | 26 | // MMKV已在attachBaseContext中初始化 27 | // 语言配置已在attachBaseContext中应用 28 | } 29 | 30 | companion object { 31 | private lateinit var instance: MyApplication 32 | 33 | /** 34 | * 获取Application实例 35 | */ 36 | fun getInstance(): MyApplication = instance 37 | 38 | /** 39 | * 获取Application Context 40 | * 使用 applicationContext 确保获取到最新的配置(包括语言切换后的) 41 | */ 42 | fun getAppContext(): Context = instance.applicationContext 43 | 44 | /** 45 | * 便捷方法:获取字符串资源 46 | * @param resId 字符串资源ID 47 | * @return 字符串 48 | */ 49 | fun getString(resId: Int): String { 50 | return getAppContext().getString(resId) 51 | } 52 | 53 | /** 54 | * 便捷方法:获取带格式化参数的字符串资源 55 | * @param resId 字符串资源ID 56 | * @param formatArgs 格式化参数 57 | * @return 格式化后的字符串 58 | */ 59 | fun getString(resId: Int, vararg formatArgs: Any): String { 60 | return getAppContext().getString(resId, *formatArgs) 61 | } 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | # Uncomment the following line in case you need and you don't have the release build type files in your app 18 | # release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/workspace.xml 42 | .idea/tasks.xml 43 | .idea/gradle.xml 44 | .idea/assetWizardSettings.xml 45 | .idea/dictionaries 46 | .idea/libraries 47 | # Android Studio 3 in .gitignore file. 48 | .idea/caches 49 | .idea/modules.xml 50 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 51 | .idea/navEditor.xml 52 | 53 | # Keystore files 54 | # Uncomment the following lines if you do not want to check your keystore files in. 55 | #*.jks 56 | #*.keystore 57 | 58 | # External native build folder generated in Android Studio 2.2 and later 59 | .externalNativeBuild 60 | .cxx/ 61 | 62 | # Google Services (e.g. APIs or Firebase) 63 | # google-services.json 64 | 65 | # Freeline 66 | freeline.py 67 | freeline/ 68 | freeline_project_description.json 69 | 70 | # fastlane 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots 74 | fastlane/test_output 75 | fastlane/readme.md 76 | 77 | # Version control 78 | vcs.xml 79 | 80 | # lint 81 | lint/intermediates/ 82 | lint/generated/ 83 | lint/outputs/ 84 | lint/tmp/ 85 | # lint/reports/ 86 | 87 | # Android Profiling 88 | *.hprof 89 | 90 | # macOS 91 | .DS_Store 92 | 93 | # Windows 94 | Thumbs.db 95 | 96 | # Plan files 97 | *.plan.md 98 | /.idea/ 99 | /.kotlin/ 100 | *.jks 101 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | /** 4 | * 应用常量配置 5 | */ 6 | object Constants { 7 | // 通知渠道配置 8 | const val NOTIFICATION_CHANNEL_ID = "ai_answerer_service" 9 | const val NOTIFICATION_CHANNEL_NAME = "AI答题助手服务" 10 | const val NOTIFICATION_ID = 1001 11 | 12 | // Intent Actions 13 | const val ACTION_SHOW_ANSWER = "com.hwb.aianswerer.SHOW_ANSWER" 14 | const val ACTION_REQUEST_ANSWER = "com.hwb.aianswerer.REQUEST_ANSWER" 15 | const val EXTRA_ANSWER_TEXT = "answer_text" 16 | const val EXTRA_RECOGNIZED_TEXT = "recognized_text" 17 | const val EXTRA_QUESTION_TEXT = "question_text" 18 | 19 | 20 | /** 21 | * 根据设置构建系统提示词 22 | * 23 | * @param questionTypes 题型集合(如:单选题、多选题等) 24 | * @param questionScope 题目内容范围 25 | * @return 优化后的系统提示词 26 | */ 27 | fun buildSystemPrompt(questionTypes: Set, questionScope: String): String { 28 | val basePrompt = getBaseSystemPrompt() 29 | if (questionTypes.isEmpty() && questionScope.isBlank()) { 30 | return basePrompt 31 | } 32 | 33 | val promptBuilder = StringBuilder(basePrompt) 34 | promptBuilder.append("\n\n") 35 | promptBuilder.append(MyApplication.getString(R.string.system_prompt_limit_header)) 36 | promptBuilder.append('\n') 37 | 38 | val typeSeparator = MyApplication.getString(R.string.system_prompt_type_separator) 39 | val essayType = MyApplication.getString(R.string.ai_question_type_essay) 40 | var hasConstraint = false 41 | 42 | // 添加题型限制 43 | if (questionTypes.isNotEmpty()) { 44 | promptBuilder.append( 45 | MyApplication.getString( 46 | R.string.system_prompt_type_template, 47 | questionTypes.joinToString(typeSeparator), 48 | essayType 49 | ) 50 | ) 51 | hasConstraint = true 52 | } 53 | 54 | // 添加题目内容范围限制 55 | if (questionScope.isNotBlank()) { 56 | if (hasConstraint) { 57 | promptBuilder.append('\n') 58 | } 59 | promptBuilder.append( 60 | MyApplication.getString(R.string.system_prompt_scope_template, questionScope) 61 | ) 62 | } 63 | return promptBuilder.toString() 64 | } 65 | 66 | private fun getBaseSystemPrompt(): String { 67 | val choiceType = MyApplication.getString(R.string.ai_question_type_choice) 68 | val essayType = MyApplication.getString(R.string.ai_question_type_essay) 69 | val blankType = MyApplication.getString(R.string.ai_question_type_blank) 70 | return MyApplication.getString( 71 | R.string.system_prompt_base, 72 | choiceType, 73 | essayType, 74 | blankType 75 | ) 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/utils/ImageCropUtil.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.utils 2 | 3 | import android.graphics.Bitmap 4 | import com.hwb.aianswerer.models.CropRect 5 | import kotlin.math.min 6 | 7 | /** 8 | * 图片裁剪工具类 9 | * 提供基于矩形坐标的图片裁剪功能 10 | */ 11 | object ImageCropUtil { 12 | 13 | /** 14 | * 根据矩形坐标裁剪图片 15 | * @param bitmap 原始图片 16 | * @param cropRect 裁剪矩形(坐标相对于原始图片尺寸) 17 | * @return 裁剪后的图片 18 | */ 19 | fun cropBitmap(bitmap: Bitmap, cropRect: CropRect): Bitmap { 20 | // 验证坐标有效性 21 | if (!cropRect.isValid(bitmap.width, bitmap.height)) { 22 | throw IllegalArgumentException("Invalid crop coordinates") 23 | } 24 | 25 | val left = cropRect.topLeft.x.toInt().coerceIn(0, bitmap.width) 26 | val top = cropRect.topLeft.y.toInt().coerceIn(0, bitmap.height) 27 | val right = cropRect.bottomRight.x.toInt().coerceIn(0, bitmap.width) 28 | val bottom = cropRect.bottomRight.y.toInt().coerceIn(0, bitmap.height) 29 | 30 | val width = right - left 31 | val height = bottom - top 32 | 33 | if (width <= 0 || height <= 0) { 34 | throw IllegalArgumentException("Invalid crop dimensions") 35 | } 36 | 37 | // 使用简单的矩形裁剪(性能最优) 38 | return Bitmap.createBitmap(bitmap, left, top, width, height) 39 | } 40 | 41 | /** 42 | * 保存 Bitmap 到临时文件 43 | * @param bitmap 要保存的图片 44 | * @param cacheDir 缓存目录 45 | * @return 临时文件路径 46 | */ 47 | fun saveBitmapToTempFile(bitmap: Bitmap, cacheDir: java.io.File): String { 48 | val tempFile = java.io.File(cacheDir, "temp_crop_${System.currentTimeMillis()}.jpg") 49 | tempFile.outputStream().use { out -> 50 | bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) 51 | } 52 | return tempFile.absolutePath 53 | } 54 | 55 | /** 56 | * 从文件加载 Bitmap 57 | * @param filePath 文件路径 58 | * @return Bitmap 对象 59 | */ 60 | fun loadBitmapFromFile(filePath: String): Bitmap { 61 | return android.graphics.BitmapFactory.decodeFile(filePath) 62 | ?: throw IllegalArgumentException("Failed to load bitmap from file: $filePath") 63 | } 64 | 65 | /** 66 | * 删除临时文件 67 | * @param filePath 文件路径 68 | */ 69 | fun deleteTempFile(filePath: String) { 70 | try { 71 | java.io.File(filePath).delete() 72 | } catch (e: Exception) { 73 | e.printStackTrace() 74 | } 75 | } 76 | 77 | /** 78 | * 计算适合屏幕显示的缩放比例 79 | * @param imageWidth 图片宽度 80 | * @param imageHeight 图片高度 81 | * @param screenWidth 屏幕宽度 82 | * @param screenHeight 屏幕高度 83 | * @return 缩放比例 84 | */ 85 | fun calculateFitScale( 86 | imageWidth: Int, 87 | imageHeight: Int, 88 | screenWidth: Int, 89 | screenHeight: Int 90 | ): Float { 91 | val scaleX = screenWidth.toFloat() / imageWidth 92 | val scaleY = screenHeight.toFloat() / imageHeight 93 | return min(scaleX, scaleY) 94 | } 95 | } 96 | 97 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 19 | 20 | 21 | 24 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 | 49 | 50 | 51 | 55 | 56 | 57 | 64 | 65 | 66 | 73 | 74 | 75 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | /** 6 | * 应用颜色定义 7 | */ 8 | // 亮色主题颜色 9 | val md_theme_light_primary = Color(0xFF1976D2) 10 | val md_theme_light_onPrimary = Color(0xFFFFFFFF) 11 | val md_theme_light_primaryContainer = Color(0xFFD3E4FD) 12 | val md_theme_light_onPrimaryContainer = Color(0xFF001B3D) 13 | val md_theme_light_secondary = Color(0xFF535E71) 14 | val md_theme_light_onSecondary = Color(0xFFFFFFFF) 15 | val md_theme_light_secondaryContainer = Color(0xFFD7E3F8) 16 | val md_theme_light_onSecondaryContainer = Color(0xFF101C2B) 17 | val md_theme_light_tertiary = Color(0xFF6B5778) 18 | val md_theme_light_onTertiary = Color(0xFFFFFFFF) 19 | val md_theme_light_tertiaryContainer = Color(0xFFF3DAFF) 20 | val md_theme_light_onTertiaryContainer = Color(0xFF251431) 21 | val md_theme_light_error = Color(0xFFB3261E) 22 | val md_theme_light_onError = Color(0xFFFFFFFF) 23 | val md_theme_light_errorContainer = Color(0xFFF9DEDC) 24 | val md_theme_light_onErrorContainer = Color(0xFF410E0B) 25 | val md_theme_light_background = Color(0xFFFDFCFF) 26 | val md_theme_light_onBackground = Color(0xFF1A1C1E) 27 | val md_theme_light_surface = Color(0xFFFDFCFF) 28 | val md_theme_light_onSurface = Color(0xFF1A1C1E) 29 | val md_theme_light_surfaceVariant = Color(0xFFE1E2EC) 30 | val md_theme_light_onSurfaceVariant = Color(0xFF44474F) 31 | val md_theme_light_outline = Color(0xFF74777F) 32 | val md_theme_light_outlineVariant = Color(0xFFC4C6D0) 33 | val md_theme_light_scrim = Color(0xFF000000) 34 | val md_theme_light_inverseSurface = Color(0xFF2F3033) 35 | val md_theme_light_inverseOnSurface = Color(0xFFF1F0F4) 36 | val md_theme_light_inversePrimary = Color(0xFFA4C8FF) 37 | 38 | // 暗色主题颜色 39 | val md_theme_dark_primary = Color(0xFFA4C8FF) 40 | val md_theme_dark_onPrimary = Color(0xFF003062) 41 | val md_theme_dark_primaryContainer = Color(0xFF00468B) 42 | val md_theme_dark_onPrimaryContainer = Color(0xFFD3E4FD) 43 | val md_theme_dark_secondary = Color(0xFFBBC7DB) 44 | val md_theme_dark_onSecondary = Color(0xFF253140) 45 | val md_theme_dark_secondaryContainer = Color(0xFF3C4758) 46 | val md_theme_dark_onSecondaryContainer = Color(0xFFD7E3F8) 47 | val md_theme_dark_tertiary = Color(0xFFD6BEE3) 48 | val md_theme_dark_onTertiary = Color(0xFF3B2948) 49 | val md_theme_dark_tertiaryContainer = Color(0xFF52405F) 50 | val md_theme_dark_onTertiaryContainer = Color(0xFFF3DAFF) 51 | val md_theme_dark_error = Color(0xFFF2B8B5) 52 | val md_theme_dark_onError = Color(0xFF601410) 53 | val md_theme_dark_errorContainer = Color(0xFF8C1D18) 54 | val md_theme_dark_onErrorContainer = Color(0xFFF9DEDC) 55 | val md_theme_dark_background = Color(0xFF1A1C1E) 56 | val md_theme_dark_onBackground = Color(0xFFE3E2E6) 57 | val md_theme_dark_surface = Color(0xFF1A1C1E) 58 | val md_theme_dark_onSurface = Color(0xFFE3E2E6) 59 | val md_theme_dark_surfaceVariant = Color(0xFF44474F) 60 | val md_theme_dark_onSurfaceVariant = Color(0xFFC4C6D0) 61 | val md_theme_dark_outline = Color(0xFF8E9099) 62 | val md_theme_dark_outlineVariant = Color(0xFF44474F) 63 | val md_theme_dark_scrim = Color(0xFF000000) 64 | val md_theme_dark_inverseSurface = Color(0xFFE3E2E6) 65 | val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E) 66 | val md_theme_dark_inversePrimary = Color(0xFF1976D2) 67 | 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/TextRecognitionManager.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import android.graphics.Bitmap 4 | import com.google.mlkit.vision.common.InputImage 5 | import com.google.mlkit.vision.text.TextRecognition 6 | import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions 7 | import kotlinx.coroutines.suspendCancellableCoroutine 8 | import kotlin.coroutines.resume 9 | import kotlin.coroutines.resumeWithException 10 | 11 | /** 12 | * 文本识别管理器 13 | * 使用ML Kit进行OCR文本识别 14 | */ 15 | class TextRecognitionManager { 16 | 17 | // 使用中文文本识别器(也支持英文) 18 | private val recognizer = 19 | TextRecognition.getClient(ChineseTextRecognizerOptions.Builder().build()) 20 | 21 | /** 22 | * 识别图片中的文本 23 | * @param bitmap 要识别的图片 24 | * @return 识别出的文本 25 | */ 26 | suspend fun recognizeText(bitmap: Bitmap): Result = 27 | suspendCancellableCoroutine { continuation -> 28 | try { 29 | val image = InputImage.fromBitmap(bitmap, 0) 30 | 31 | recognizer.process(image) 32 | .addOnSuccessListener { visionText -> 33 | val recognizedText = visionText.text 34 | if (recognizedText.isNotBlank()) { 35 | if (!continuation.isCompleted) { 36 | continuation.resume(Result.success(recognizedText)) 37 | } 38 | } else { 39 | if (!continuation.isCompleted) { 40 | continuation.resume( 41 | Result.failure( 42 | Exception( 43 | MyApplication.getString(R.string.error_no_text_recognized) 44 | ) 45 | ) 46 | ) 47 | } 48 | } 49 | } 50 | .addOnFailureListener { e -> 51 | if (!continuation.isCompleted) { 52 | continuation.resumeWithException(e) 53 | } 54 | } 55 | .addOnCanceledListener { 56 | if (!continuation.isCompleted) { 57 | continuation.resume( 58 | Result.failure( 59 | Exception( 60 | MyApplication.getString(R.string.error_recognition_cancelled) 61 | ) 62 | ) 63 | ) 64 | } 65 | } 66 | } catch (e: Exception) { 67 | if (!continuation.isCompleted) { 68 | continuation.resumeWithException(e) 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * 关闭识别器 75 | */ 76 | fun close() { 77 | recognizer.close() 78 | } 79 | 80 | companion object { 81 | @Volatile 82 | private var instance: TextRecognitionManager? = null 83 | 84 | fun getInstance(): TextRecognitionManager { 85 | return instance ?: synchronized(this) { 86 | instance ?: TextRecognitionManager().also { instance = it } 87 | } 88 | } 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 | # AIAnswerer 2 | 3 | [中文指南](README.md#中文使用指南) | [English Guide](#english-guide) 4 | 5 | ## English Guide 6 | 7 | ### Overview 8 | AIAnswerer combines on-device OCR with large language models on Android. Capture questions through a floating overlay, let DeepSeek AI (OpenAI-compatible) analyze them, and receive instant answers—ideal for practice, review, or self-testing. 9 | 10 | 11 | 12 | ### Key Features 13 | - 🖼️ Quick capture: Snap the current screen and auto-focus on the question area 14 | - 📝 Smart OCR: Recognizes Chinese and English text with manual editing before submission 15 | - 🤖 Instant AI answers: Generates explanations and copies the result to the clipboard 16 | - 💬 Floating workflow: Control capture, preview, and submit without leaving your app 17 | - 🔒 Full control: Bring your own API Key and pause network requests whenever needed 18 | 19 | ### Getting Ready 20 | 1. Use a device running Android 11 or later and ensure a stable internet connection. 21 | 2. Install the provided APK; enable “unknown sources” when prompted during the first install. 22 | 3. Prepare your LLM API Key 23 | 4. During the first launch, grant overlay, screenshot, and notification permissions as requested. 24 | 25 | ### Quick Start 26 | 1. Launch the app, tap “Enter answering mode,” and confirm that all permissions are granted. 27 | > Screenshot placeholder: Permission dialogs 28 | 2. Open the screen with your question and tap the floating button to capture it. 29 | > Screenshot placeholder: Floating button guide 30 | 3. Review the recognized text in the confirmation view; adjust it if anything looks off. 31 | 4. Tap “Confirm & solve” to receive the AI-generated answer, which is also copied to your clipboard. 32 | 5. To exit, return to the main screen and tap “Stop service.” 33 | 34 | ### Supported Question Types 35 | - Multiple choice: Extracts question and options, highlights a recommended answer with reasoning 36 | - Fill in the blank: Produces concise entries for each blank slot 37 | - Short/long answer: Supplies structured explanations or outline-style responses 38 | 39 | ### Tips & Tricks 40 | - Keep screenshots sharp and centered, and avoid busy backgrounds to improve OCR accuracy. 41 | - Pause AI requests by toggling the setting in-app or briefly disconnecting from the network. 42 | - Capture again at any time via the floating button to continue practicing new questions. 43 | 44 | ### FAQ 45 | - **Missing permissions?** Open system settings, search for “overlay” or “screen capture,” and enable the required entries manually. 46 | - **Incorrect recognition?** Edit the text on the confirmation screen or retake the screenshot. 47 | - **No AI response?** Verify connectivity, confirm your API Key, and make sure your DeepSeek balance covers the request. 48 | 49 | ### Privacy & Disclaimer 50 | - Recognized text is sent to your chosen AI provider; avoid sensitive or restricted content. 51 | - DeepSeek usage may incur costs—monitor your API consumption responsibly. 52 | - AIAnswerer is for learning and research purposes only. Respect exam rules and local regulations; you are accountable for any misuse. 53 | 54 | ### Update instructions 55 | #### v0.3 56 | Added the pre-COR cropping function to improve the ability to recognize questions 57 | 58 | #### v0.2 59 | Fixed an issue where the release package could not request the AI API 60 | 61 | #### v0.1 62 | First edition 63 | 64 | ### License 65 | This project is released under the [MIT License](/LICENSE) -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | /** 10 | * 应用字体排版定义 11 | * 12 | * 遵循Material Design 3规范 13 | * 提供统一的字体样式系统 14 | */ 15 | val AppTypography = Typography( 16 | // Display styles 17 | displayLarge = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.Normal, 20 | fontSize = 57.sp, 21 | lineHeight = 64.sp, 22 | letterSpacing = 0.sp 23 | ), 24 | displayMedium = TextStyle( 25 | fontFamily = FontFamily.Default, 26 | fontWeight = FontWeight.Normal, 27 | fontSize = 45.sp, 28 | lineHeight = 52.sp, 29 | letterSpacing = 0.sp 30 | ), 31 | displaySmall = TextStyle( 32 | fontFamily = FontFamily.Default, 33 | fontWeight = FontWeight.Normal, 34 | fontSize = 36.sp, 35 | lineHeight = 44.sp, 36 | letterSpacing = 0.sp 37 | ), 38 | 39 | // Headline styles 40 | headlineLarge = TextStyle( 41 | fontFamily = FontFamily.Default, 42 | fontWeight = FontWeight.Bold, 43 | fontSize = 32.sp, 44 | lineHeight = 40.sp, 45 | letterSpacing = 0.sp 46 | ), 47 | headlineMedium = TextStyle( 48 | fontFamily = FontFamily.Default, 49 | fontWeight = FontWeight.Bold, 50 | fontSize = 28.sp, 51 | lineHeight = 36.sp, 52 | letterSpacing = 0.sp 53 | ), 54 | headlineSmall = TextStyle( 55 | fontFamily = FontFamily.Default, 56 | fontWeight = FontWeight.Bold, 57 | fontSize = 24.sp, 58 | lineHeight = 32.sp, 59 | letterSpacing = 0.sp 60 | ), 61 | 62 | // Title styles 63 | titleLarge = TextStyle( 64 | fontFamily = FontFamily.Default, 65 | fontWeight = FontWeight.Bold, 66 | fontSize = 22.sp, 67 | lineHeight = 28.sp, 68 | letterSpacing = 0.sp 69 | ), 70 | titleMedium = TextStyle( 71 | fontFamily = FontFamily.Default, 72 | fontWeight = FontWeight.Medium, 73 | fontSize = 16.sp, 74 | lineHeight = 24.sp, 75 | letterSpacing = 0.15.sp 76 | ), 77 | titleSmall = TextStyle( 78 | fontFamily = FontFamily.Default, 79 | fontWeight = FontWeight.Medium, 80 | fontSize = 14.sp, 81 | lineHeight = 20.sp, 82 | letterSpacing = 0.1.sp 83 | ), 84 | 85 | // Body styles 86 | bodyLarge = TextStyle( 87 | fontFamily = FontFamily.Default, 88 | fontWeight = FontWeight.Normal, 89 | fontSize = 16.sp, 90 | lineHeight = 24.sp, 91 | letterSpacing = 0.5.sp 92 | ), 93 | bodyMedium = TextStyle( 94 | fontFamily = FontFamily.Default, 95 | fontWeight = FontWeight.Normal, 96 | fontSize = 14.sp, 97 | lineHeight = 20.sp, 98 | letterSpacing = 0.25.sp 99 | ), 100 | bodySmall = TextStyle( 101 | fontFamily = FontFamily.Default, 102 | fontWeight = FontWeight.Normal, 103 | fontSize = 12.sp, 104 | lineHeight = 16.sp, 105 | letterSpacing = 0.4.sp 106 | ), 107 | 108 | // Label styles 109 | labelLarge = TextStyle( 110 | fontFamily = FontFamily.Default, 111 | fontWeight = FontWeight.Bold, 112 | fontSize = 14.sp, 113 | lineHeight = 20.sp, 114 | letterSpacing = 0.1.sp 115 | ), 116 | labelMedium = TextStyle( 117 | fontFamily = FontFamily.Default, 118 | fontWeight = FontWeight.Medium, 119 | fontSize = 12.sp, 120 | lineHeight = 16.sp, 121 | letterSpacing = 0.5.sp 122 | ), 123 | labelSmall = TextStyle( 124 | fontFamily = FontFamily.Default, 125 | fontWeight = FontWeight.Medium, 126 | fontSize = 11.sp, 127 | lineHeight = 16.sp, 128 | letterSpacing = 0.5.sp 129 | ) 130 | ) 131 | 132 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | 9 | # ======================================== 10 | # 通用规则 11 | # ======================================== 12 | 13 | # 保留源文件名和行号信息,便于调试崩溃堆栈 14 | -keepattributes SourceFile,LineNumberTable 15 | -renamesourcefileattribute SourceFile 16 | 17 | # 保留注解信息 18 | -keepattributes *Annotation* 19 | 20 | # 保留泛型签名信息 21 | -keepattributes Signature 22 | 23 | # 保留异常信息 24 | -keepattributes Exceptions 25 | 26 | # ======================================== 27 | # MMKV 相关规则 28 | # ======================================== 29 | # MMKV 使用 JNI 和反射,需要保护相关类 30 | -keep class com.tencent.mmkv.** { *; } 31 | -dontwarn com.tencent.mmkv.** 32 | 33 | # ======================================== 34 | # Google ML Kit 相关规则 35 | # ======================================== 36 | # ML Kit 文字识别需要保护模型和相关类 37 | -keep class com.google.mlkit.** { *; } 38 | -dontwarn com.google.mlkit.** 39 | 40 | -keep class com.google.android.gms.** { *; } 41 | -dontwarn com.google.android.gms.** 42 | 43 | # TensorFlow Lite(ML Kit 底层) 44 | -keep class org.tensorflow.** { *; } 45 | -dontwarn org.tensorflow.** 46 | 47 | # ======================================== 48 | # OkHttp 相关规则 49 | # ======================================== 50 | # OkHttp 使用反射和平台特定代码 51 | -dontwarn okhttp3.** 52 | -dontwarn okio.** 53 | -keep class okhttp3.** { *; } 54 | -keep class okio.** { *; } 55 | -keep interface okhttp3.** { *; } 56 | 57 | # OkHttp 的平台特定实现 58 | -dontwarn org.conscrypt.** 59 | -dontwarn org.bouncycastle.** 60 | -dontwarn org.openjsse.** 61 | 62 | # ======================================== 63 | # Gson 相关规则 64 | # ======================================== 65 | # Gson 使用反射进行序列化和反序列化 66 | -keep class com.google.gson.** { *; } 67 | -keepattributes Signature 68 | -keepattributes *Annotation* 69 | 70 | # 保护自定义数据类(Gson 序列化/反序列化使用) 71 | # 根据实际使用的数据类调整 72 | -keep class com.hwb.aianswerer.api.** { *; } 73 | -keep class com.hwb.aianswerer.config.** { *; } 74 | -keep class com.hwb.aianswerer.models.** { *; } 75 | 76 | # Gson 的内部类 77 | -keep class * implements com.google.gson.TypeAdapterFactory 78 | -keep class * implements com.google.gson.JsonSerializer 79 | -keep class * implements com.google.gson.JsonDeserializer 80 | 81 | # ======================================== 82 | # Kotlin 相关规则 83 | # ======================================== 84 | # Kotlin 协程 85 | -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} 86 | -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} 87 | -keepclassmembers class kotlinx.coroutines.** { 88 | volatile ; 89 | } 90 | 91 | # Kotlin 反射 92 | -keep class kotlin.Metadata { *; } 93 | -keep class kotlin.reflect.** { *; } 94 | -dontwarn kotlin.reflect.** 95 | 96 | # ======================================== 97 | # Jetpack Compose 相关规则 98 | # ======================================== 99 | # Compose 运行时需要保护 100 | -keep class androidx.compose.** { *; } 101 | -dontwarn androidx.compose.** 102 | 103 | # ======================================== 104 | # Android 平台相关规则 105 | # ======================================== 106 | # 保护 Service 和 BroadcastReceiver 107 | -keep public class * extends android.app.Service 108 | -keep public class * extends android.content.BroadcastReceiver 109 | 110 | # 保护自定义 View 111 | -keep public class * extends android.view.View { 112 | public (android.content.Context); 113 | public (android.content.Context, android.util.AttributeSet); 114 | public (android.content.Context, android.util.AttributeSet, int); 115 | } 116 | 117 | # ======================================== 118 | # 项目特定规则 119 | # ======================================== 120 | # 保护应用的主要类 121 | -keep class com.hwb.aianswerer.MainActivity { *; } 122 | -keep class com.hwb.aianswerer.FloatingWindowService { *; } 123 | -keep class com.hwb.aianswerer.MyApplication { *; } 124 | 125 | # 保护配置类 126 | -keep class com.hwb.aianswerer.Constants { *; } 127 | 128 | # ======================================== 129 | # 调试和警告抑制 130 | # ======================================== 131 | # 忽略一些无害的警告 132 | -dontwarn javax.annotation.** 133 | -dontwarn javax.inject.** 134 | -dontwarn sun.misc.** 135 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/models/OpenAIModels.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.models 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import com.hwb.aianswerer.MyApplication 5 | import com.hwb.aianswerer.R 6 | 7 | /** 8 | * OpenAI API请求模型 9 | */ 10 | data class ChatRequest( 11 | @SerializedName("model") 12 | val model: String, 13 | 14 | @SerializedName("messages") 15 | val messages: List, 16 | 17 | @SerializedName("temperature") 18 | val temperature: Double = 0.7, 19 | 20 | @SerializedName("max_tokens") 21 | val maxTokens: Int? = null, 22 | 23 | @SerializedName("response_format") 24 | val responseFormat: ResponseFormat? = null 25 | ) 26 | 27 | /** 28 | * 聊天消息 29 | */ 30 | data class ChatMessage( 31 | @SerializedName("role") 32 | val role: String, // "system", "user", "assistant" 33 | 34 | @SerializedName("content") 35 | val content: String 36 | ) 37 | 38 | /** 39 | * 响应格式配置 40 | */ 41 | data class ResponseFormat( 42 | @SerializedName("type") 43 | val type: String = "json_object" 44 | ) 45 | 46 | /** 47 | * OpenAI API响应模型 48 | */ 49 | data class ChatResponse( 50 | @SerializedName("id") 51 | val id: String, 52 | 53 | @SerializedName("object") 54 | val objectType: String, 55 | 56 | @SerializedName("created") 57 | val created: Long, 58 | 59 | @SerializedName("model") 60 | val model: String, 61 | 62 | @SerializedName("choices") 63 | val choices: List, 64 | 65 | @SerializedName("usage") 66 | val usage: Usage? = null 67 | ) 68 | 69 | /** 70 | * 选择项 71 | */ 72 | data class Choice( 73 | @SerializedName("index") 74 | val index: Int, 75 | 76 | @SerializedName("message") 77 | val message: ChatMessage, 78 | 79 | @SerializedName("finish_reason") 80 | val finishReason: String 81 | ) 82 | 83 | /** 84 | * Token使用情况 85 | */ 86 | data class Usage( 87 | @SerializedName("prompt_tokens") 88 | val promptTokens: Int, 89 | 90 | @SerializedName("completion_tokens") 91 | val completionTokens: Int, 92 | 93 | @SerializedName("total_tokens") 94 | val totalTokens: Int 95 | ) 96 | 97 | /** 98 | * AI答案解析结果 99 | */ 100 | data class AIAnswer( 101 | val question: String, 102 | val questionType: String, // 选择题/问答题/填空题 103 | val answer: String, 104 | val options: List? = null 105 | ) { 106 | /** 107 | * 格式化显示答案 108 | */ 109 | fun formatAnswer(): String { 110 | val labels = AnswerSectionLabels 111 | return buildString { 112 | appendLine(labels.question) 113 | appendLine(question) 114 | appendLine() 115 | 116 | if (!options.isNullOrEmpty()) { 117 | appendLine(labels.options) 118 | options.forEach { appendLine(it) } 119 | appendLine() 120 | } 121 | 122 | appendLine(labels.answer) 123 | append(answer) 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * 根据显示配置格式化答案 130 | * 131 | * 此扩展函数提供了可配置的答案格式化功能,允许根据用户设置选择性地显示题目、选项和答案。 132 | * 保持与原formatAnswer()方法相同的格式风格,确保UI显示一致性。 133 | * 134 | * @param showQuestion 是否显示题目内容,默认为true 135 | * @param showOptions 是否显示选项内容,默认为true。注意:仅当options非空时此参数才有效 136 | * @return 格式化后的答案字符串,至少包含【答案】部分 137 | * 138 | * @see formatAnswer 原始的完整格式化方法 139 | */ 140 | fun AIAnswer.formatAnswerWithConfig( 141 | showQuestion: Boolean = true, 142 | showOptions: Boolean = true 143 | ): String { 144 | val labels = AnswerSectionLabels 145 | return buildString { 146 | // 根据配置显示题目 147 | if (showQuestion) { 148 | appendLine(labels.question) 149 | appendLine(question) 150 | appendLine() 151 | } 152 | 153 | // 根据配置和选项存在性显示选项 154 | if (showOptions && !options.isNullOrEmpty()) { 155 | appendLine(labels.options) 156 | options.forEach { appendLine(it) } 157 | appendLine() 158 | } 159 | 160 | // 始终显示答案 161 | appendLine(labels.answer) 162 | append(answer) 163 | } 164 | } 165 | 166 | private object AnswerSectionLabels { 167 | val question: String 168 | get() = MyApplication.getString(R.string.answer_section_question_title) 169 | val options: String 170 | get() = MyApplication.getString(R.string.answer_section_options_title) 171 | val answer: String 172 | get() = MyApplication.getString(R.string.answer_section_answer_title) 173 | } 174 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.platform.LocalContext 12 | 13 | /** 14 | * 应用主题定义 15 | */ 16 | private val LightColorScheme = lightColorScheme( 17 | primary = md_theme_light_primary, 18 | onPrimary = md_theme_light_onPrimary, 19 | primaryContainer = md_theme_light_primaryContainer, 20 | onPrimaryContainer = md_theme_light_onPrimaryContainer, 21 | secondary = md_theme_light_secondary, 22 | onSecondary = md_theme_light_onSecondary, 23 | secondaryContainer = md_theme_light_secondaryContainer, 24 | onSecondaryContainer = md_theme_light_onSecondaryContainer, 25 | tertiary = md_theme_light_tertiary, 26 | onTertiary = md_theme_light_onTertiary, 27 | tertiaryContainer = md_theme_light_tertiaryContainer, 28 | onTertiaryContainer = md_theme_light_onTertiaryContainer, 29 | error = md_theme_light_error, 30 | onError = md_theme_light_onError, 31 | errorContainer = md_theme_light_errorContainer, 32 | onErrorContainer = md_theme_light_onErrorContainer, 33 | background = md_theme_light_background, 34 | onBackground = md_theme_light_onBackground, 35 | surface = md_theme_light_surface, 36 | onSurface = md_theme_light_onSurface, 37 | surfaceVariant = md_theme_light_surfaceVariant, 38 | onSurfaceVariant = md_theme_light_onSurfaceVariant, 39 | outline = md_theme_light_outline, 40 | outlineVariant = md_theme_light_outlineVariant, 41 | scrim = md_theme_light_scrim, 42 | inverseSurface = md_theme_light_inverseSurface, 43 | inverseOnSurface = md_theme_light_inverseOnSurface, 44 | inversePrimary = md_theme_light_inversePrimary, 45 | ) 46 | 47 | private val DarkColorScheme = darkColorScheme( 48 | primary = md_theme_dark_primary, 49 | onPrimary = md_theme_dark_onPrimary, 50 | primaryContainer = md_theme_dark_primaryContainer, 51 | onPrimaryContainer = md_theme_dark_onPrimaryContainer, 52 | secondary = md_theme_dark_secondary, 53 | onSecondary = md_theme_dark_onSecondary, 54 | secondaryContainer = md_theme_dark_secondaryContainer, 55 | onSecondaryContainer = md_theme_dark_onSecondaryContainer, 56 | tertiary = md_theme_dark_tertiary, 57 | onTertiary = md_theme_dark_onTertiary, 58 | tertiaryContainer = md_theme_dark_tertiaryContainer, 59 | onTertiaryContainer = md_theme_dark_onTertiaryContainer, 60 | error = md_theme_dark_error, 61 | onError = md_theme_dark_onError, 62 | errorContainer = md_theme_dark_errorContainer, 63 | onErrorContainer = md_theme_dark_onErrorContainer, 64 | background = md_theme_dark_background, 65 | onBackground = md_theme_dark_onBackground, 66 | surface = md_theme_dark_surface, 67 | onSurface = md_theme_dark_onSurface, 68 | surfaceVariant = md_theme_dark_surfaceVariant, 69 | onSurfaceVariant = md_theme_dark_onSurfaceVariant, 70 | outline = md_theme_dark_outline, 71 | outlineVariant = md_theme_dark_outlineVariant, 72 | scrim = md_theme_dark_scrim, 73 | inverseSurface = md_theme_dark_inverseSurface, 74 | inverseOnSurface = md_theme_dark_inverseOnSurface, 75 | inversePrimary = md_theme_dark_inversePrimary, 76 | ) 77 | 78 | /** 79 | * AI答题助手应用主题 80 | * 81 | * @param darkTheme 是否使用暗色主题,默认跟随系统 82 | * @param dynamicColor 是否使用动态颜色(Android 12+),默认true 83 | * @param content 子组件内容 84 | */ 85 | @Composable 86 | fun AIAnswererTheme( 87 | darkTheme: Boolean = isSystemInDarkTheme(), 88 | dynamicColor: Boolean = true, 89 | content: @Composable () -> Unit 90 | ) { 91 | val colorScheme = when { 92 | // 动态颜色支持(Android 12+) 93 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 94 | val context = LocalContext.current 95 | if (darkTheme) dynamicDarkColorScheme(context) 96 | else dynamicLightColorScheme(context) 97 | } 98 | // 暗色主题 99 | darkTheme -> DarkColorScheme 100 | // 亮色主题 101 | else -> LightColorScheme 102 | } 103 | 104 | MaterialTheme( 105 | colorScheme = colorScheme, 106 | typography = AppTypography, 107 | content = content 108 | ) 109 | } 110 | 111 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/utils/LanguageUtil.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.utils 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.res.Configuration 7 | import android.os.Build 8 | import android.os.LocaleList 9 | import com.hwb.aianswerer.config.AppConfig 10 | import java.util.Locale 11 | 12 | /** 13 | * 语言管理工具类 14 | * 15 | * 负责应用语言的切换和配置更新 16 | * 支持中文和英文两种语言 17 | */ 18 | object LanguageUtil { 19 | 20 | /** 21 | * 应用语言设置 22 | * 23 | * 更新系统Configuration并保存到配置中 24 | * 不会立即生效,需要重启Activity或应用 25 | * 26 | * @param context 上下文 27 | * @param languageCode 语言代码 ("zh" 或 "en") 28 | */ 29 | fun applyLanguage(context: Context, languageCode: String) { 30 | // 保存语言设置 31 | AppConfig.saveLanguage(languageCode) 32 | } 33 | 34 | /** 35 | * 在attachBaseContext中应用语言配置 36 | * 37 | * 这是Android官方推荐的方式,特别是对于Android N及以上版本 38 | * 应该在Application和Activity的attachBaseContext方法中调用 39 | * 40 | * @param context 基础上下文 41 | * @return 应用了语言配置的新Context 42 | */ 43 | fun attachBaseContext(context: Context): Context { 44 | val languageCode = AppConfig.getLanguage() 45 | return updateConfigurationContext(context, languageCode) 46 | } 47 | 48 | /** 49 | * 更新Configuration并返回新的Context 50 | * 51 | * @param context 原始上下文 52 | * @param languageCode 语言代码 53 | * @return 应用了新配置的Context 54 | */ 55 | private fun updateConfigurationContext(context: Context, languageCode: String): Context { 56 | val locale = when (languageCode) { 57 | AppConfig.LANGUAGE_EN -> Locale.ENGLISH 58 | AppConfig.LANGUAGE_ZH -> Locale.SIMPLIFIED_CHINESE 59 | else -> Locale.SIMPLIFIED_CHINESE 60 | } 61 | 62 | Locale.setDefault(locale) 63 | 64 | val configuration = Configuration(context.resources.configuration) 65 | 66 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 67 | // Android N及以上版本使用LocaleList 68 | configuration.setLocale(locale) 69 | val localeList = LocaleList(locale) 70 | LocaleList.setDefault(localeList) 71 | configuration.setLocales(localeList) 72 | 73 | // 返回新的Context 74 | return context.createConfigurationContext(configuration) 75 | } else { 76 | // Android N以下版本使用旧的方式 77 | @Suppress("DEPRECATION") 78 | configuration.locale = locale 79 | @Suppress("DEPRECATION") 80 | context.resources.updateConfiguration(configuration, context.resources.displayMetrics) 81 | return context 82 | } 83 | } 84 | 85 | /** 86 | * 从保存的配置中加载语言设置 87 | * 88 | * 已废弃:请使用attachBaseContext方法代替 89 | * 保留此方法仅为向后兼容 90 | * 91 | * @param context 上下文 92 | */ 93 | @Deprecated("使用attachBaseContext方法代替", ReplaceWith("attachBaseContext(context)")) 94 | fun loadLanguageConfig(context: Context) { 95 | val languageCode = AppConfig.getLanguage() 96 | updateConfigurationContext(context, languageCode) 97 | } 98 | 99 | /** 100 | * 获取当前语言代码 101 | * 102 | * @return 语言代码 ("zh" 或 "en") 103 | */ 104 | fun getCurrentLanguage(): String { 105 | return AppConfig.getLanguage() 106 | } 107 | 108 | /** 109 | * 重启Activity以应用新的语言设置 110 | * 111 | * @param activity 要重启的Activity 112 | */ 113 | fun restartActivity(activity: Activity) { 114 | val intent = activity.intent 115 | activity.finish() 116 | activity.startActivity(intent) 117 | // 添加淡入淡出动画 118 | activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) 119 | } 120 | 121 | /** 122 | * 重启应用以应用新的语言设置 123 | * 124 | * 这种方式会完全重启应用,返回到启动Activity 125 | * 126 | * @param context 上下文 127 | */ 128 | fun restartApp(context: Context) { 129 | val packageManager = context.packageManager 130 | val intent = packageManager.getLaunchIntentForPackage(context.packageName) 131 | intent?.let { 132 | it.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) 133 | it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 134 | context.startActivity(it) 135 | 136 | // 如果是Activity,结束当前Activity 137 | if (context is Activity) { 138 | context.finish() 139 | } 140 | 141 | // 杀死当前进程以完全重启 142 | android.os.Process.killProcess(android.os.Process.myPid()) 143 | } 144 | } 145 | } 146 | 147 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ui/dialogs/LanguageSelectionDialog.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.ui.dialogs 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.layout.width 10 | import androidx.compose.material3.AlertDialog 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.RadioButton 13 | import androidx.compose.material3.Text 14 | import androidx.compose.material3.TextButton 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.mutableStateOf 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.runtime.setValue 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.platform.LocalContext 23 | import androidx.compose.ui.res.stringResource 24 | import androidx.compose.ui.unit.dp 25 | import com.hwb.aianswerer.R 26 | import com.hwb.aianswerer.config.AppConfig 27 | 28 | /** 29 | * 使用方法:在Compose中调用 LanguageSelectionDialog() 30 | * 功能:首次启动时提供语言选择功能,选择后重启Activity生效 31 | */ 32 | @Composable 33 | fun LanguageSelectionDialog( 34 | onDismiss: () -> Unit, 35 | onLanguageConfirmed: () -> Unit 36 | ) { 37 | LocalContext.current 38 | val currentLanguage = AppConfig.getLanguage() 39 | var selectedLanguage by remember { mutableStateOf(currentLanguage) } 40 | 41 | AlertDialog( 42 | onDismissRequest = onDismiss, 43 | title = { 44 | Text( 45 | text = stringResource(R.string.dialog_language_title), 46 | style = MaterialTheme.typography.titleLarge 47 | ) 48 | }, 49 | text = { 50 | Column { 51 | Text( 52 | text = stringResource(R.string.dialog_language_message), 53 | style = MaterialTheme.typography.bodyMedium, 54 | modifier = Modifier.padding(bottom = 16.dp) 55 | ) 56 | 57 | // 中文选项 58 | Row( 59 | modifier = Modifier 60 | .fillMaxWidth() 61 | .clickable { selectedLanguage = AppConfig.LANGUAGE_ZH } 62 | .padding(vertical = 8.dp), 63 | verticalAlignment = Alignment.CenterVertically 64 | ) { 65 | RadioButton( 66 | selected = selectedLanguage == AppConfig.LANGUAGE_ZH, 67 | onClick = { selectedLanguage = AppConfig.LANGUAGE_ZH } 68 | ) 69 | Spacer(modifier = Modifier.width(8.dp)) 70 | Text( 71 | text = "中文", 72 | style = MaterialTheme.typography.bodyLarge 73 | ) 74 | } 75 | 76 | // 英文选项 77 | Row( 78 | modifier = Modifier 79 | .fillMaxWidth() 80 | .clickable { selectedLanguage = AppConfig.LANGUAGE_EN } 81 | .padding(vertical = 8.dp), 82 | verticalAlignment = Alignment.CenterVertically 83 | ) { 84 | RadioButton( 85 | selected = selectedLanguage == AppConfig.LANGUAGE_EN, 86 | onClick = { selectedLanguage = AppConfig.LANGUAGE_EN } 87 | ) 88 | Spacer(modifier = Modifier.width(8.dp)) 89 | Text( 90 | text = "English", 91 | style = MaterialTheme.typography.bodyLarge 92 | ) 93 | } 94 | } 95 | }, 96 | confirmButton = { 97 | TextButton( 98 | onClick = { 99 | // 保存语言设置 100 | AppConfig.saveLanguage(selectedLanguage) 101 | AppConfig.setFirstLaunchComplete() 102 | 103 | // 触发确认回调 104 | onLanguageConfirmed() 105 | } 106 | ) { 107 | Text(stringResource(R.string.dialog_language_confirm)) 108 | } 109 | }, 110 | dismissButton = { 111 | TextButton( 112 | onClick = { 113 | // 设置默认语言并标记首次启动完成 114 | AppConfig.saveLanguage(AppConfig.LANGUAGE_ZH) 115 | AppConfig.setFirstLaunchComplete() 116 | onDismiss() 117 | } 118 | ) { 119 | Text(stringResource(R.string.dialog_language_cancel)) 120 | } 121 | } 122 | ) 123 | } -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import java.io.FileInputStream 2 | import java.text.SimpleDateFormat 3 | import java.util.Date 4 | import java.util.Properties 5 | 6 | plugins { 7 | alias(libs.plugins.android.application) 8 | alias(libs.plugins.kotlin.android) 9 | alias(libs.plugins.kotlin.compose) 10 | } 11 | 12 | // 读取local.properties 13 | val localProperties = Properties() 14 | val localPropertiesFile = rootProject.file("local.properties") 15 | if (localPropertiesFile.exists()) { 16 | FileInputStream(localPropertiesFile).use(localProperties::load) 17 | } 18 | 19 | // Helper to read properties while providing a default fallback 20 | fun getProperty(key: String, defaultValue: String = ""): String = 21 | localProperties.getProperty(key) ?: defaultValue 22 | 23 | android { 24 | namespace = "com.hwb.aianswerer" 25 | compileSdk = 34 26 | 27 | defaultConfig { 28 | applicationId = "com.hwb.aianswerer" 29 | minSdk = 30 30 | targetSdk = 34 31 | versionCode = 4 32 | versionName = "0.0.4" 33 | 34 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 35 | 36 | ndk { 37 | //noinspection ChromeOsAbiSupport 38 | abiFilters += setOf("arm64-v8a") 39 | } 40 | 41 | // BuildConfig字段 - 从local.properties读取 42 | val apiUrl = getProperty("api.url", "https://api.openai.com/v1/chat/completions") 43 | val apiKey = getProperty("api.key", "") 44 | val apiModel = getProperty("api.model", "gpt-4") 45 | buildConfigField("String", "API_URL", "\"$apiUrl\"") 46 | buildConfigField("String", "API_KEY", "\"$apiKey\"") 47 | buildConfigField("String", "API_MODEL", "\"$apiModel\"") 48 | } 49 | 50 | // Release签名配置 51 | signingConfigs { 52 | create("release") { 53 | val storeFile = getProperty("signing.storeFile") 54 | val storePassword = getProperty("signing.storePassword") 55 | val keyAlias = getProperty("signing.keyAlias") 56 | val keyPassword = getProperty("signing.keyPassword") 57 | 58 | if (storeFile.isNotEmpty() && storePassword.isNotEmpty() && keyAlias.isNotEmpty() && keyPassword.isNotEmpty()) { 59 | this.storeFile = file(storeFile) 60 | this.storePassword = storePassword 61 | this.keyAlias = keyAlias 62 | this.keyPassword = keyPassword 63 | println("Release signing configuration loaded from local.properties") 64 | } else { 65 | println("Warning: Release signing configuration incomplete, using debug key") 66 | } 67 | } 68 | } 69 | 70 | // APK命名规则 71 | applicationVariants.all { 72 | val buildTypeName = buildType.name 73 | val versionNameValue = versionName 74 | outputs.all { 75 | val outputImpl = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl 76 | val date = SimpleDateFormat("yyyyMMdd-HHmm").format(Date()) 77 | outputImpl.outputFileName = 78 | "${date}_AIAnswerer_v${versionNameValue}.apk" 79 | } 80 | } 81 | 82 | buildTypes { 83 | debug { 84 | isDebuggable = true 85 | applicationIdSuffix = ".debug" 86 | versionNameSuffix = "-debug" 87 | } 88 | release { 89 | isMinifyEnabled = true // 启用R8代码混淆和优化 90 | isShrinkResources = true // 启用资源压缩 91 | proguardFiles( 92 | getDefaultProguardFile("proguard-android-optimize.txt"), 93 | "proguard-rules.pro" 94 | ) 95 | // Release签名配置 96 | signingConfig = signingConfigs.getByName("release") 97 | } 98 | } 99 | compileOptions { 100 | sourceCompatibility = JavaVersion.VERSION_11 101 | targetCompatibility = JavaVersion.VERSION_11 102 | } 103 | kotlinOptions { 104 | jvmTarget = "11" 105 | } 106 | buildFeatures { 107 | compose = true 108 | buildConfig = true 109 | } 110 | } 111 | 112 | dependencies { 113 | implementation(libs.androidx.core.ktx) 114 | implementation(libs.androidx.appcompat) 115 | implementation(libs.material) 116 | implementation(libs.androidx.activity) 117 | implementation(libs.androidx.constraintlayout) 118 | testImplementation(libs.junit) 119 | androidTestImplementation(libs.androidx.junit) 120 | androidTestImplementation(libs.androidx.espresso.core) 121 | 122 | // ML Kit for text recognition 123 | implementation("com.google.mlkit:text-recognition:16.0.1") 124 | implementation("com.google.mlkit:text-recognition-chinese:16.0.1") 125 | 126 | // OkHttp for HTTP requests 127 | implementation("com.squareup.okhttp3:okhttp:4.12.0") 128 | implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") 129 | 130 | // Gson for JSON parsing 131 | implementation("com.google.code.gson:gson:2.10.1") 132 | 133 | // Kotlin Coroutines 134 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") 135 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") 136 | 137 | // Lifecycle components 138 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") 139 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2") 140 | 141 | // Jetpack Compose 142 | val composeBom = platform("androidx.compose:compose-bom:2024.02.00") 143 | implementation(composeBom) 144 | implementation("androidx.compose.ui:ui") 145 | implementation("androidx.compose.ui:ui-graphics") 146 | implementation("androidx.compose.ui:ui-tooling-preview") 147 | implementation("androidx.compose.material3:material3") 148 | // implementation("androidx.compose.material:material-icons-extended") // 移除:使用本地图标定义,减少13.1 MB 149 | implementation("androidx.activity:activity-compose:1.8.2") 150 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") 151 | debugImplementation("androidx.compose.ui:ui-tooling") 152 | debugImplementation("androidx.compose.ui:ui-test-manifest") 153 | 154 | implementation(libs.mmkv) 155 | } 156 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ScreenCaptureManager.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.graphics.Bitmap 6 | import android.graphics.PixelFormat 7 | import android.hardware.display.DisplayManager 8 | import android.hardware.display.VirtualDisplay 9 | import android.media.Image 10 | import android.media.ImageReader 11 | import android.media.projection.MediaProjection 12 | import android.media.projection.MediaProjectionManager 13 | import android.os.Handler 14 | import android.os.Looper 15 | import android.util.DisplayMetrics 16 | import android.view.WindowManager 17 | import kotlinx.coroutines.suspendCancellableCoroutine 18 | import kotlin.coroutines.resume 19 | import kotlin.coroutines.resumeWithException 20 | 21 | /** 22 | * 截图管理器 23 | * 使用MediaProjection API进行屏幕截图 24 | */ 25 | class ScreenCaptureManager(private val context: Context) { 26 | 27 | private var mediaProjection: MediaProjection? = null 28 | private var virtualDisplay: VirtualDisplay? = null 29 | private var imageReader: ImageReader? = null 30 | 31 | private val projectionManager: MediaProjectionManager by lazy { 32 | context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager 33 | } 34 | 35 | /** 36 | * 创建截图Intent,用于请求权限 37 | */ 38 | fun createScreenCaptureIntent(): Intent { 39 | return projectionManager.createScreenCaptureIntent() 40 | } 41 | 42 | // 保存权限数据,用于重新创建MediaProjection 43 | private var savedResultCode: Int? = null 44 | private var savedData: Intent? = null 45 | 46 | /** 47 | * 初始化MediaProjection(保存权限数据) 48 | */ 49 | fun initMediaProjection(resultCode: Int, data: Intent?) { 50 | // 保存权限数据 51 | savedResultCode = resultCode 52 | savedData = data 53 | 54 | // 清理旧的MediaProjection 55 | release() 56 | 57 | // 创建新的MediaProjection 58 | createMediaProjection() 59 | } 60 | 61 | /** 62 | * 创建MediaProjection实例(只创建一次) 63 | */ 64 | private fun createMediaProjection() { 65 | if (savedResultCode == null || savedData == null) { 66 | return 67 | } 68 | 69 | try { 70 | mediaProjection = 71 | projectionManager.getMediaProjection(savedResultCode!!, savedData!!).also { 72 | // 注册回调以管理资源(Android 14+强制要求,但所有版本都支持) 73 | it!!.registerCallback(object : MediaProjection.Callback() { 74 | override fun onStop() { 75 | super.onStop() 76 | // MediaProjection停止时清理所有资源 77 | cleanUpVirtualDisplay() 78 | } 79 | }, Handler(Looper.getMainLooper())) 80 | } 81 | } catch (e: Exception) { 82 | e.printStackTrace() 83 | mediaProjection = null 84 | } 85 | } 86 | 87 | /** 88 | * 执行截图 89 | */ 90 | suspend fun captureScreen(): Bitmap = suspendCancellableCoroutine { continuation -> 91 | try { 92 | // 检查MediaProjection是否已初始化 93 | if (mediaProjection == null) { 94 | if (!continuation.isCompleted) { 95 | continuation.resumeWithException(Exception("MediaProjection未初始化,请先授权截图权限")) 96 | } 97 | return@suspendCancellableCoroutine 98 | } 99 | 100 | val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager 101 | val metrics = DisplayMetrics() 102 | windowManager.defaultDisplay.getRealMetrics(metrics) 103 | 104 | val width = metrics.widthPixels 105 | val height = metrics.heightPixels 106 | val density = metrics.densityDpi 107 | 108 | // 如果VirtualDisplay和ImageReader不存在,创建它们(只创建一次) 109 | if (virtualDisplay == null || imageReader == null) { 110 | // 创建ImageReader 111 | imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2) 112 | 113 | // 创建虚拟显示(保持一直存在) 114 | virtualDisplay = mediaProjection?.createVirtualDisplay( 115 | "ScreenCapture", 116 | width, 117 | height, 118 | density, 119 | DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, 120 | imageReader?.surface, 121 | null, 122 | null 123 | ) 124 | } 125 | 126 | // 设置图像可用监听器(每次截图都设置,确保回调正确) 127 | imageReader?.setOnImageAvailableListener({ reader -> 128 | try { 129 | val image: Image? = reader.acquireLatestImage() 130 | if (image != null) { 131 | val bitmap = imageToBitmap(image, width, height) 132 | image.close() 133 | 134 | // 不清理VirtualDisplay,保留它供下次使用 135 | 136 | if (!continuation.isCompleted) { 137 | continuation.resume(bitmap) 138 | } 139 | } 140 | } catch (e: Exception) { 141 | if (!continuation.isCompleted) { 142 | continuation.resumeWithException(e) 143 | } 144 | } 145 | }, null) 146 | 147 | // 设置取消回调 148 | continuation.invokeOnCancellation { 149 | // 取消时也不清理,保留VirtualDisplay 150 | } 151 | } catch (e: Exception) { 152 | if (!continuation.isCompleted) { 153 | continuation.resumeWithException(e) 154 | } 155 | } 156 | } 157 | 158 | /** 159 | * 将Image转换为Bitmap 160 | */ 161 | private fun imageToBitmap(image: Image, width: Int, height: Int): Bitmap { 162 | val planes = image.planes 163 | val buffer = planes[0].buffer 164 | val pixelStride = planes[0].pixelStride 165 | val rowStride = planes[0].rowStride 166 | val rowPadding = rowStride - pixelStride * width 167 | 168 | val bitmap = Bitmap.createBitmap( 169 | width + rowPadding / pixelStride, 170 | height, 171 | Bitmap.Config.ARGB_8888 172 | ) 173 | bitmap.copyPixelsFromBuffer(buffer) 174 | 175 | // 裁剪多余的部分 176 | return if (rowPadding == 0) { 177 | bitmap 178 | } else { 179 | Bitmap.createBitmap(bitmap, 0, 0, width, height) 180 | } 181 | } 182 | 183 | /** 184 | * 清理VirtualDisplay和ImageReader(保留MediaProjection) 185 | */ 186 | private fun cleanUpVirtualDisplay() { 187 | virtualDisplay?.release() 188 | imageReader?.close() 189 | virtualDisplay = null 190 | imageReader = null 191 | } 192 | 193 | /** 194 | * 释放所有资源(包括MediaProjection和保存的权限数据) 195 | */ 196 | fun release() { 197 | cleanUpVirtualDisplay() 198 | mediaProjection?.stop() 199 | mediaProjection = null 200 | // 不清理savedResultCode和savedData,以便重新创建 201 | } 202 | 203 | /** 204 | * 完全清理(包括权限数据) 205 | */ 206 | fun releaseAll() { 207 | release() 208 | savedResultCode = null 209 | savedData = null 210 | } 211 | } 212 | 213 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/config/AppConfig.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.config 2 | 3 | import android.content.Context 4 | import com.hwb.aianswerer.BuildConfig 5 | import com.tencent.mmkv.MMKV 6 | 7 | /** 8 | * 应用配置管理类 9 | * 负责保存和读取用户的API配置、语言设置等 10 | * 使用MMKV作为底层存储,提供高性能的key-value数据持久化 11 | */ 12 | object AppConfig { 13 | 14 | // MMKV存储键名 15 | private const val KEY_API_URL = "api_url" 16 | private const val KEY_API_KEY = "api_key" 17 | private const val KEY_MODEL_NAME = "model_name" 18 | private const val KEY_LANGUAGE = "language" 19 | private const val KEY_AUTO_SUBMIT = "auto_submit" 20 | private const val KEY_AUTO_COPY = "auto_copy" 21 | private const val KEY_QUESTION_TYPES = "question_types" 22 | private const val KEY_QUESTION_SCOPE = "question_scope" 23 | private const val KEY_IS_FIRST_LAUNCH = "is_first_launch" 24 | private const val KEY_CROP_MODE = "crop_mode" 25 | private const val KEY_SHOW_ANSWER_CARD_QUESTION = "show_answer_card_question" 26 | private const val KEY_SHOW_ANSWER_CARD_OPTIONS = "show_answer_card_options" 27 | 28 | // 语言代码常量 29 | const val LANGUAGE_ZH = "zh" 30 | const val LANGUAGE_EN = "en" 31 | 32 | // 截图识别模式常量 33 | const val CROP_MODE_FULL = "full" // 全屏 34 | const val CROP_MODE_EACH = "each" // 部分识别(每次) 35 | const val CROP_MODE_ONCE = "once" // 部分识别(单次) 36 | 37 | private lateinit var mmkv: MMKV 38 | 39 | /** 40 | * 初始化MMKV 41 | * 应该在Application.onCreate()中调用 42 | */ 43 | fun init(context: Context) { 44 | MMKV.initialize(context) 45 | mmkv = MMKV.defaultMMKV() 46 | } 47 | 48 | // ========== API配置相关 ========== 49 | 50 | /** 51 | * 保存API URL 52 | */ 53 | fun saveApiUrl(url: String) { 54 | mmkv.encode(KEY_API_URL, url) 55 | } 56 | 57 | /** 58 | * 获取API URL 59 | * @return API URL,优先返回BuildConfig配置,其次返回用户设置值,最后返回默认值 60 | */ 61 | fun getApiUrl(): String { 62 | return mmkv.decodeString(KEY_API_URL, BuildConfig.API_URL) ?: "" 63 | } 64 | 65 | /** 66 | * 保存API Key 67 | */ 68 | fun saveApiKey(key: String) { 69 | mmkv.encode(KEY_API_KEY, key) 70 | } 71 | 72 | /** 73 | * 获取API Key 74 | * @return API Key,优先返回BuildConfig配置,其次返回用户设置值,最后返回空值 75 | */ 76 | fun getApiKey(): String { 77 | return mmkv.decodeString(KEY_API_KEY, BuildConfig.API_KEY) ?: "" 78 | } 79 | 80 | /** 81 | * 保存模型名称 82 | */ 83 | fun saveModelName(model: String) { 84 | mmkv.encode(KEY_MODEL_NAME, model) 85 | } 86 | 87 | /** 88 | * 获取模型名称 89 | * @return 模型名称,优先返回BuildConfig配置,其次返回用户设置值,最后返回默认值 90 | */ 91 | fun getModelName(): String { 92 | return mmkv.decodeString(KEY_MODEL_NAME, BuildConfig.API_MODEL) ?: "" 93 | } 94 | 95 | /** 96 | * 验证API配置是否完整 97 | * @return true表示配置完整,false表示缺少必要配置 98 | */ 99 | fun isApiConfigValid( 100 | url: String = getApiUrl(), 101 | key: String = getApiKey(), 102 | model: String = getModelName() 103 | ): Boolean { 104 | 105 | return url.isNotBlank() && key.isNotBlank() && model.isNotBlank() && url.startsWith("http") 106 | } 107 | 108 | // ========== 语言设置相关 ========== 109 | 110 | /** 111 | * 保存语言设置 112 | * @param languageCode 语言代码 (zh/en) 113 | */ 114 | fun saveLanguage(languageCode: String) { 115 | mmkv.encode(KEY_LANGUAGE, languageCode) 116 | } 117 | 118 | /** 119 | * 获取当前设置的语言 120 | * @return 语言代码,默认为中文 121 | */ 122 | fun getLanguage(): String { 123 | return mmkv.decodeString(KEY_LANGUAGE, LANGUAGE_ZH) ?: LANGUAGE_ZH 124 | } 125 | 126 | // ========== 应用设置相关 ========== 127 | 128 | /** 129 | * 保存自动提交设置 130 | * @param enabled 是否启用自动提交(识别后直接获取答案,不显示确认对话框) 131 | */ 132 | fun saveAutoSubmit(enabled: Boolean) { 133 | mmkv.encode(KEY_AUTO_SUBMIT, enabled) 134 | } 135 | 136 | /** 137 | * 获取自动提交设置 138 | * @return 是否启用自动提交,默认为false 139 | */ 140 | fun getAutoSubmit(): Boolean { 141 | return mmkv.decodeBool(KEY_AUTO_SUBMIT, true) 142 | } 143 | 144 | /** 145 | * 保存自动复制到剪贴板设置 146 | * @param enabled 是否启用自动复制(生成答案后自动复制到剪贴板) 147 | */ 148 | fun saveAutoCopy(enabled: Boolean) { 149 | mmkv.encode(KEY_AUTO_COPY, enabled) 150 | } 151 | 152 | /** 153 | * 获取自动复制到剪贴板设置 154 | * @return 是否启用自动复制,默认为true(提升用户体验) 155 | */ 156 | fun getAutoCopy(): Boolean { 157 | return mmkv.decodeBool(KEY_AUTO_COPY, false) 158 | } 159 | 160 | // ========== 答题设置相关 ========== 161 | 162 | /** 163 | * 保存题型设置 164 | * @param types 题型集合(如:单选题、多选题等) 165 | */ 166 | fun saveQuestionTypes(types: Set) { 167 | val typesString = types.joinToString(",") 168 | mmkv.encode(KEY_QUESTION_TYPES, typesString) 169 | } 170 | 171 | /** 172 | * 获取题型设置 173 | * @return 题型集合,默认为单选题 174 | */ 175 | fun getQuestionTypes(): Set { 176 | val typesString = mmkv.decodeString(KEY_QUESTION_TYPES, "单选题") ?: "单选题" 177 | return if (typesString.isBlank()) { 178 | setOf("单选题") 179 | } else { 180 | typesString.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() 181 | } 182 | } 183 | 184 | /** 185 | * 保存题目内容范围 186 | * @param scope 题目内容范围描述 187 | */ 188 | fun saveQuestionScope(scope: String) { 189 | mmkv.encode(KEY_QUESTION_SCOPE, scope) 190 | } 191 | 192 | /** 193 | * 获取题目内容范围 194 | * @return 题目内容范围,默认为空字符串(不限制) 195 | */ 196 | fun getQuestionScope(): String { 197 | return mmkv.decodeString(KEY_QUESTION_SCOPE, "") ?: "" 198 | } 199 | 200 | // ========== 答题卡片显示控制相关 ========== 201 | /** 202 | * 保存答题卡片是否显示题目设置 203 | * @param show 是否显示题目 204 | */ 205 | fun saveShowAnswerCardQuestion(show: Boolean) { 206 | mmkv.encode(KEY_SHOW_ANSWER_CARD_QUESTION, show) 207 | } 208 | 209 | /** 210 | * 获取答题卡片是否显示题目设置 211 | * @return 是否显示题目,默认为true 212 | */ 213 | fun getShowAnswerCardQuestion(): Boolean { 214 | return mmkv.decodeBool(KEY_SHOW_ANSWER_CARD_QUESTION, true) 215 | } 216 | 217 | /** 218 | * 保存答题卡片是否显示选项设置 219 | * @param show 是否显示选项 220 | */ 221 | fun saveShowAnswerCardOptions(show: Boolean) { 222 | mmkv.encode(KEY_SHOW_ANSWER_CARD_OPTIONS, show) 223 | } 224 | 225 | /** 226 | * 获取答题卡片是否显示选项设置 227 | * @return 是否显示选项,默认为true 228 | */ 229 | fun getShowAnswerCardOptions(): Boolean { 230 | return mmkv.decodeBool(KEY_SHOW_ANSWER_CARD_OPTIONS, true) 231 | } 232 | 233 | // ========== 截图识别模式相关 ========== 234 | 235 | /** 236 | * 保存截图识别模式 237 | * @param mode 识别模式(CROP_MODE_FULL/CROP_MODE_EACH/CROP_MODE_ONCE) 238 | */ 239 | fun saveCropMode(mode: String) { 240 | mmkv.encode(KEY_CROP_MODE, mode) 241 | } 242 | 243 | /** 244 | * 获取截图识别模式 245 | * @return 识别模式,默认为全屏模式 246 | */ 247 | fun getCropMode(): String { 248 | return mmkv.decodeString(KEY_CROP_MODE, CROP_MODE_FULL) ?: CROP_MODE_FULL 249 | } 250 | 251 | // ========== 首次启动相关 ========== 252 | 253 | /** 254 | * 检查是否为首次启动 255 | * @return true表示首次启动,false表示已启动过 256 | */ 257 | fun isFirstLaunch(): Boolean { 258 | return mmkv.decodeBool(KEY_IS_FIRST_LAUNCH, true) 259 | } 260 | 261 | /** 262 | * 标记首次启动完成 263 | */ 264 | fun setFirstLaunchComplete() { 265 | mmkv.encode(KEY_IS_FIRST_LAUNCH, false) 266 | } 267 | } 268 | 269 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ConfirmTextActivity.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.util.Log 7 | import android.widget.Toast 8 | import androidx.activity.ComponentActivity 9 | import androidx.activity.compose.setContent 10 | import androidx.compose.foundation.background 11 | import androidx.compose.foundation.layout.Arrangement 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.Column 14 | import androidx.compose.foundation.layout.Row 15 | import androidx.compose.foundation.layout.Spacer 16 | import androidx.compose.foundation.layout.fillMaxHeight 17 | import androidx.compose.foundation.layout.fillMaxSize 18 | import androidx.compose.foundation.layout.fillMaxWidth 19 | import androidx.compose.foundation.layout.height 20 | import androidx.compose.foundation.layout.padding 21 | import androidx.compose.foundation.shape.RoundedCornerShape 22 | import androidx.compose.material3.Button 23 | import androidx.compose.material3.Card 24 | import androidx.compose.material3.CardDefaults 25 | import androidx.compose.material3.LocalTextStyle 26 | import androidx.compose.material3.MaterialTheme 27 | import androidx.compose.material3.OutlinedButton 28 | import androidx.compose.material3.OutlinedTextField 29 | import androidx.compose.material3.Text 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.runtime.mutableStateOf 33 | import androidx.compose.runtime.remember 34 | import androidx.compose.runtime.setValue 35 | import androidx.compose.ui.Alignment 36 | import androidx.compose.ui.Modifier 37 | import androidx.compose.ui.graphics.Color 38 | import androidx.compose.ui.text.font.FontWeight 39 | import androidx.compose.ui.unit.dp 40 | import androidx.compose.ui.unit.sp 41 | import com.hwb.aianswerer.config.AppConfig 42 | import com.hwb.aianswerer.utils.LanguageUtil 43 | 44 | 45 | /** 46 | * 透明确认Activity 47 | * 显示OCR识别的文本,让用户确认后调用AI获取答案 48 | */ 49 | class ConfirmTextActivity : ComponentActivity() { 50 | 51 | companion object { 52 | private const val TAG = "ConfirmTextActivity" 53 | } 54 | 55 | override fun attachBaseContext(newBase: Context) { 56 | super.attachBaseContext(LanguageUtil.attachBaseContext(newBase)) 57 | } 58 | 59 | override fun onCreate(savedInstanceState: Bundle?) { 60 | super.onCreate(savedInstanceState) 61 | 62 | val recognizedText = intent.getStringExtra(Constants.EXTRA_RECOGNIZED_TEXT) ?: "" 63 | 64 | setContent { 65 | MaterialTheme { 66 | ConfirmTextScreen( 67 | recognizedText = recognizedText, 68 | onConfirm = { editedText -> 69 | handleConfirm(editedText) 70 | }, 71 | onCancel = { 72 | finish() 73 | } 74 | ) 75 | } 76 | } 77 | } 78 | 79 | private fun handleConfirm(text: String) { 80 | if (text.isBlank()) { 81 | Toast.makeText(this, getString(R.string.toast_text_empty), Toast.LENGTH_SHORT).show() 82 | return 83 | } 84 | 85 | // 显示提示 86 | Toast.makeText(this, getString(R.string.toast_getting_answer), Toast.LENGTH_SHORT).show() 87 | 88 | // 发送请求答案的广播到FloatingWindowService 89 | val intent = Intent(Constants.ACTION_REQUEST_ANSWER).apply { 90 | setPackage(packageName) // 指定应用包名,确保广播能被接收(Android 8.0+要求) 91 | putExtra(Constants.EXTRA_QUESTION_TEXT, text) 92 | } 93 | Log.d( 94 | TAG, 95 | "发送请求答案广播: Action=${intent.action}, Package=${intent.`package`}, 文本长度=${text.length}" 96 | ) 97 | sendBroadcast(intent) 98 | Log.d(TAG, "广播已发送,即将关闭Activity") 99 | 100 | // 立即关闭Activity 101 | finish() 102 | } 103 | } 104 | 105 | @Composable 106 | fun ConfirmTextScreen( 107 | recognizedText: String, 108 | onConfirm: (String) -> Unit, 109 | onCancel: () -> Unit 110 | ) { 111 | var text by remember { mutableStateOf(recognizedText) } 112 | 113 | // 获取当前设置信息 114 | val questionTypes = AppConfig.getQuestionTypes() 115 | val questionScope = AppConfig.getQuestionScope() 116 | val settingsText = buildString { 117 | append(questionTypes.joinToString("、")) 118 | if (questionScope.isNotBlank()) { 119 | append(" | ") 120 | append(questionScope) 121 | } 122 | } 123 | 124 | Box( 125 | modifier = Modifier 126 | .fillMaxSize() 127 | .background(Color.Black.copy(alpha = 0.5f)), 128 | contentAlignment = Alignment.Center 129 | ) { 130 | Card( 131 | modifier = Modifier 132 | .fillMaxWidth(0.9f) 133 | .fillMaxHeight(0.7f), 134 | shape = RoundedCornerShape(16.dp), 135 | colors = CardDefaults.cardColors( 136 | containerColor = MaterialTheme.colorScheme.surface 137 | ), 138 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) 139 | ) { 140 | Column( 141 | modifier = Modifier 142 | .fillMaxSize() 143 | .padding(16.dp) 144 | ) { 145 | // 标题 146 | Text( 147 | text = MyApplication.getString(R.string.confirm_text_title), 148 | fontSize = 20.sp, 149 | fontWeight = FontWeight.Bold, 150 | modifier = Modifier.padding(bottom = 8.dp) 151 | ) 152 | 153 | // 显示当前设置 154 | Text( 155 | text = MyApplication.getString(R.string.current_settings, settingsText), 156 | fontSize = 12.sp, 157 | color = MaterialTheme.colorScheme.onSurfaceVariant, 158 | modifier = Modifier.padding(bottom = 16.dp) 159 | ) 160 | 161 | // 文本输入框 162 | OutlinedTextField( 163 | value = text, 164 | onValueChange = { text = it }, 165 | modifier = Modifier 166 | .weight(1f) 167 | .fillMaxWidth(), 168 | label = { Text(MyApplication.getString(R.string.confirm_text_label)) }, 169 | placeholder = { Text(MyApplication.getString(R.string.confirm_text_placeholder)) }, 170 | maxLines = Int.MAX_VALUE, 171 | textStyle = LocalTextStyle.current.copy(fontSize = 16.sp) 172 | ) 173 | 174 | Spacer(modifier = Modifier.height(16.dp)) 175 | 176 | // 按钮行 177 | Row( 178 | modifier = Modifier.fillMaxWidth(), 179 | horizontalArrangement = Arrangement.spacedBy(12.dp) 180 | ) { 181 | // 取消按钮 182 | OutlinedButton( 183 | onClick = onCancel, 184 | modifier = Modifier.weight(1f) 185 | ) { 186 | Text(MyApplication.getString(R.string.button_cancel)) 187 | } 188 | 189 | // 确认按钮 190 | Button( 191 | onClick = { onConfirm(text) }, 192 | modifier = Modifier.weight(1f), 193 | enabled = text.isNotBlank() 194 | ) { 195 | Text(MyApplication.getString(R.string.button_confirm_and_answer)) 196 | } 197 | } 198 | } 199 | } 200 | } 201 | } 202 | 203 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ui/icons/LocalIcons.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.ui.icons 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.graphics.SolidColor 5 | import androidx.compose.ui.graphics.vector.ImageVector 6 | import androidx.compose.ui.graphics.vector.path 7 | import androidx.compose.ui.unit.dp 8 | 9 | /** 10 | * 本地图标定义 11 | */ 12 | @Suppress("unused") 13 | object LocalIcons { 14 | 15 | /** 16 | * 返回箭头图标 17 | * 用于: TopAppBar 的返回按钮 18 | */ 19 | val ArrowBack: ImageVector by lazy { 20 | ImageVector.Builder( 21 | name = "ArrowBack", 22 | defaultWidth = 24.dp, 23 | defaultHeight = 24.dp, 24 | viewportWidth = 24f, 25 | viewportHeight = 24f 26 | ).apply { 27 | path(fill = SolidColor(Color.Black)) { 28 | moveTo(20f, 11f) 29 | horizontalLineTo(7.83f) 30 | lineToRelative(5.59f, -5.59f) 31 | lineTo(12f, 4f) 32 | lineToRelative(-8f, 8f) 33 | lineToRelative(8f, 8f) 34 | lineToRelative(1.41f, -1.41f) 35 | lineTo(7.83f, 13f) 36 | horizontalLineTo(20f) 37 | verticalLineToRelative(-2f) 38 | close() 39 | } 40 | }.build() 41 | } 42 | 43 | /** 44 | * 更多选项图标(垂直三点) 45 | * 用于: TopAppBar 的菜单按钮 46 | */ 47 | val MoreVert: ImageVector by lazy { 48 | ImageVector.Builder( 49 | name = "MoreVert", 50 | defaultWidth = 24.dp, 51 | defaultHeight = 24.dp, 52 | viewportWidth = 24f, 53 | viewportHeight = 24f 54 | ).apply { 55 | path(fill = SolidColor(Color.Black)) { 56 | moveTo(12f, 8f) 57 | curveToRelative(1.1f, 0f, 2f, -0.9f, 2f, -2f) 58 | reflectiveCurveToRelative(-0.9f, -2f, -2f, -2f) 59 | reflectiveCurveToRelative(-2f, 0.9f, -2f, 2f) 60 | reflectiveCurveToRelative(0.9f, 2f, 2f, 2f) 61 | close() 62 | moveTo(12f, 10f) 63 | curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) 64 | reflectiveCurveToRelative(0.9f, 2f, 2f, 2f) 65 | reflectiveCurveToRelative(2f, -0.9f, 2f, -2f) 66 | reflectiveCurveToRelative(-0.9f, -2f, -2f, -2f) 67 | close() 68 | moveTo(12f, 16f) 69 | curveToRelative(-1.1f, 0f, -2f, 0.9f, -2f, 2f) 70 | reflectiveCurveToRelative(0.9f, 2f, 2f, 2f) 71 | reflectiveCurveToRelative(2f, -0.9f, 2f, -2f) 72 | reflectiveCurveToRelative(-0.9f, -2f, -2f, -2f) 73 | close() 74 | } 75 | }.build() 76 | } 77 | 78 | /** 79 | * 可见图标(眼睛) 80 | * 用于: 密码输入框的显示密码按钮 81 | */ 82 | val Visibility: ImageVector by lazy { 83 | ImageVector.Builder( 84 | name = "Visibility", 85 | defaultWidth = 24.dp, 86 | defaultHeight = 24.dp, 87 | viewportWidth = 24f, 88 | viewportHeight = 24f 89 | ).apply { 90 | path(fill = SolidColor(Color.Black)) { 91 | moveTo(12f, 4.5f) 92 | curveTo(7f, 4.5f, 2.73f, 7.61f, 1f, 12f) 93 | curveToRelative(1.73f, 4.39f, 6f, 7.5f, 11f, 7.5f) 94 | reflectiveCurveToRelative(9.27f, -3.11f, 11f, -7.5f) 95 | curveToRelative(-1.73f, -4.39f, -6f, -7.5f, -11f, -7.5f) 96 | close() 97 | moveTo(12f, 17f) 98 | curveToRelative(-2.76f, 0f, -5f, -2.24f, -5f, -5f) 99 | reflectiveCurveToRelative(2.24f, -5f, 5f, -5f) 100 | reflectiveCurveToRelative(5f, 2.24f, 5f, 5f) 101 | reflectiveCurveToRelative(-2.24f, 5f, -5f, 5f) 102 | close() 103 | moveTo(12f, 9f) 104 | curveToRelative(-1.66f, 0f, -3f, 1.34f, -3f, 3f) 105 | reflectiveCurveToRelative(1.34f, 3f, 3f, 3f) 106 | reflectiveCurveToRelative(3f, -1.34f, 3f, -3f) 107 | reflectiveCurveToRelative(-1.34f, -3f, -3f, -3f) 108 | close() 109 | } 110 | }.build() 111 | } 112 | 113 | /** 114 | * 不可见图标(眼睛带斜线) 115 | * 用于: 密码输入框的隐藏密码按钮 116 | */ 117 | val VisibilityOff: ImageVector by lazy { 118 | ImageVector.Builder( 119 | name = "VisibilityOff", 120 | defaultWidth = 24.dp, 121 | defaultHeight = 24.dp, 122 | viewportWidth = 24f, 123 | viewportHeight = 24f 124 | ).apply { 125 | path(fill = SolidColor(Color.Black)) { 126 | moveTo(12f, 7f) 127 | curveToRelative(2.76f, 0f, 5f, 2.24f, 5f, 5f) 128 | curveToRelative(0f, 0.65f, -0.13f, 1.26f, -0.36f, 1.83f) 129 | lineToRelative(2.92f, 2.92f) 130 | curveToRelative(1.51f, -1.26f, 2.7f, -2.89f, 3.43f, -4.75f) 131 | curveToRelative(-1.73f, -4.39f, -6f, -7.5f, -11f, -7.5f) 132 | curveToRelative(-1.4f, 0f, -2.74f, 0.25f, -3.98f, 0.7f) 133 | lineToRelative(2.16f, 2.16f) 134 | curveTo(10.74f, 7.13f, 11.35f, 7f, 12f, 7f) 135 | close() 136 | moveTo(2f, 4.27f) 137 | lineToRelative(2.28f, 2.28f) 138 | lineToRelative(0.46f, 0.46f) 139 | curveTo(3.08f, 8.3f, 1.78f, 10.02f, 1f, 12f) 140 | curveToRelative(1.73f, 4.39f, 6f, 7.5f, 11f, 7.5f) 141 | curveToRelative(1.55f, 0f, 3.03f, -0.3f, 4.38f, -0.84f) 142 | lineToRelative(0.42f, 0.42f) 143 | lineTo(19.73f, 22f) 144 | lineTo(21f, 20.73f) 145 | lineTo(3.27f, 3f) 146 | lineTo(2f, 4.27f) 147 | close() 148 | moveTo(7.53f, 9.8f) 149 | lineToRelative(1.55f, 1.55f) 150 | curveToRelative(-0.05f, 0.21f, -0.08f, 0.43f, -0.08f, 0.65f) 151 | curveToRelative(0f, 1.66f, 1.34f, 3f, 3f, 3f) 152 | curveToRelative(0.22f, 0f, 0.44f, -0.03f, 0.65f, -0.08f) 153 | lineToRelative(1.55f, 1.55f) 154 | curveToRelative(-0.67f, 0.33f, -1.41f, 0.53f, -2.2f, 0.53f) 155 | curveToRelative(-2.76f, 0f, -5f, -2.24f, -5f, -5f) 156 | curveToRelative(0f, -0.79f, 0.2f, -1.53f, 0.53f, -2.2f) 157 | close() 158 | moveTo(11.84f, 9.02f) 159 | lineToRelative(3.15f, 3.15f) 160 | lineToRelative(0.02f, -0.16f) 161 | curveToRelative(0f, -1.66f, -1.34f, -3f, -3f, -3f) 162 | lineToRelative(-0.17f, 0.01f) 163 | close() 164 | } 165 | }.build() 166 | } 167 | 168 | /** 169 | * 搜索图标 170 | * 用于: 悬浮窗的截图按钮 171 | */ 172 | val Search: ImageVector by lazy { 173 | ImageVector.Builder( 174 | name = "Search", 175 | defaultWidth = 24.dp, 176 | defaultHeight = 24.dp, 177 | viewportWidth = 24f, 178 | viewportHeight = 24f 179 | ).apply { 180 | path(fill = SolidColor(Color.Black)) { 181 | moveTo(15.5f, 14f) 182 | horizontalLineToRelative(-0.79f) 183 | lineToRelative(-0.28f, -0.27f) 184 | curveTo(15.41f, 12.59f, 16f, 11.11f, 16f, 9.5f) 185 | curveTo(16f, 5.91f, 13.09f, 3f, 9.5f, 3f) 186 | reflectiveCurveTo(3f, 5.91f, 3f, 9.5f) 187 | reflectiveCurveTo(5.91f, 16f, 9.5f, 16f) 188 | curveToRelative(1.61f, 0f, 3.09f, -0.59f, 4.23f, -1.57f) 189 | lineToRelative(0.27f, 0.28f) 190 | verticalLineToRelative(0.79f) 191 | lineToRelative(5f, 4.99f) 192 | lineTo(20.49f, 19f) 193 | lineToRelative(-4.99f, -5f) 194 | close() 195 | moveTo(9.5f, 14f) 196 | curveTo(7.01f, 14f, 5f, 11.99f, 5f, 9.5f) 197 | reflectiveCurveTo(7.01f, 5f, 9.5f, 5f) 198 | reflectiveCurveTo(14f, 7.01f, 14f, 9.5f) 199 | reflectiveCurveTo(11.99f, 14f, 9.5f, 14f) 200 | close() 201 | } 202 | }.build() 203 | } 204 | 205 | /** 206 | * 关闭图标(X) 207 | * 用于: 悬浮窗和对话框的关闭按钮 208 | */ 209 | val Close: ImageVector by lazy { 210 | ImageVector.Builder( 211 | name = "Close", 212 | defaultWidth = 24.dp, 213 | defaultHeight = 24.dp, 214 | viewportWidth = 24f, 215 | viewportHeight = 24f 216 | ).apply { 217 | path(fill = SolidColor(Color.Black)) { 218 | moveTo(19f, 6.41f) 219 | lineTo(17.59f, 5f) 220 | lineTo(12f, 10.59f) 221 | lineTo(6.41f, 5f) 222 | lineTo(5f, 6.41f) 223 | lineTo(10.59f, 12f) 224 | lineTo(5f, 17.59f) 225 | lineTo(6.41f, 19f) 226 | lineTo(12f, 13.41f) 227 | lineTo(17.59f, 19f) 228 | lineTo(19f, 17.59f) 229 | lineTo(13.41f, 12f) 230 | close() 231 | } 232 | }.build() 233 | } 234 | } 235 | 236 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/AboutActivity.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import android.content.ActivityNotFoundException 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import android.widget.Toast 8 | import androidx.activity.ComponentActivity 9 | import androidx.activity.compose.setContent 10 | import androidx.compose.foundation.clickable 11 | import androidx.compose.foundation.layout.Column 12 | import androidx.compose.foundation.layout.Row 13 | import androidx.compose.foundation.layout.Spacer 14 | import androidx.compose.foundation.layout.fillMaxSize 15 | import androidx.compose.foundation.layout.fillMaxWidth 16 | import androidx.compose.foundation.layout.height 17 | import androidx.compose.foundation.layout.padding 18 | import androidx.compose.foundation.rememberScrollState 19 | import androidx.compose.foundation.verticalScroll 20 | import androidx.compose.material3.ExperimentalMaterial3Api 21 | import androidx.compose.material3.HorizontalDivider 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.Scaffold 24 | import androidx.compose.material3.Text 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.ui.Alignment 27 | import androidx.compose.ui.Modifier 28 | import androidx.compose.ui.res.stringResource 29 | import androidx.compose.ui.tooling.preview.Preview 30 | import androidx.compose.ui.unit.dp 31 | import androidx.core.net.toUri 32 | import com.hwb.aianswerer.ui.components.InfoCard 33 | import com.hwb.aianswerer.ui.components.LibraryItem 34 | import com.hwb.aianswerer.ui.components.TopBarWithBack 35 | import com.hwb.aianswerer.ui.theme.AIAnswererTheme 36 | import com.hwb.aianswerer.utils.ClipboardUtil 37 | import com.hwb.aianswerer.utils.LanguageUtil 38 | 39 | /** 40 | * 关于页面Activity 41 | */ 42 | class AboutActivity : ComponentActivity() { 43 | 44 | override fun attachBaseContext(newBase: Context) { 45 | super.attachBaseContext(LanguageUtil.attachBaseContext(newBase)) 46 | } 47 | 48 | override fun onCreate(savedInstanceState: Bundle?) { 49 | super.onCreate(savedInstanceState) 50 | 51 | setContent { 52 | AIAnswererTheme { 53 | AboutScreen( 54 | this, 55 | onBackClick = { finish() } 56 | ) 57 | } 58 | } 59 | } 60 | } 61 | 62 | private fun handleContactFallback( 63 | context: Context, 64 | email: String, 65 | label: String, 66 | toastMessage: String 67 | ) { 68 | ClipboardUtil.copyToClipboard(context, email, label) 69 | Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show() 70 | } 71 | 72 | /** 73 | * 关于页面界面 74 | * 75 | * @param onBackClick 返回按钮点击事件 76 | */ 77 | @OptIn(ExperimentalMaterial3Api::class) 78 | @Composable 79 | @Preview 80 | fun AboutScreen( 81 | context: Context? = null, 82 | onBackClick: () -> Unit = {} 83 | ) { 84 | 85 | Scaffold( 86 | topBar = { 87 | TopBarWithBack( 88 | title = stringResource(R.string.about_title), 89 | onBackClick = onBackClick 90 | ) 91 | } 92 | ) { paddingValues -> 93 | Column( 94 | modifier = Modifier 95 | .fillMaxSize() 96 | .padding(paddingValues) 97 | .padding(horizontal = 16.dp) 98 | .verticalScroll(rememberScrollState()) 99 | ) { 100 | Spacer(modifier = Modifier.height(16.dp)) 101 | 102 | // 应用简介卡片 103 | InfoCard( 104 | title = stringResource(R.string.about_app_intro_title), 105 | modifier = Modifier.padding(bottom = 16.dp) 106 | ) { 107 | Text( 108 | text = stringResource(R.string.about_app_intro), 109 | style = MaterialTheme.typography.bodyMedium, 110 | color = MaterialTheme.colorScheme.onSurface 111 | ) 112 | } 113 | 114 | // 版本信息卡片 115 | InfoCard( 116 | title = stringResource(R.string.about_version_title), 117 | modifier = Modifier.padding(bottom = 16.dp) 118 | ) { 119 | Text( 120 | text = stringResource( 121 | R.string.about_version, 122 | BuildConfig.VERSION_NAME, 123 | BuildConfig.VERSION_CODE 124 | ), 125 | style = MaterialTheme.typography.bodyMedium, 126 | color = MaterialTheme.colorScheme.onSurface 127 | ) 128 | } 129 | 130 | // 核心第三方库卡片 131 | InfoCard( 132 | title = stringResource(R.string.about_libraries_title), 133 | modifier = Modifier.padding(bottom = 16.dp) 134 | ) { 135 | LibraryItem( 136 | name = "Jetpack Compose", 137 | description = "Modern UI toolkit for Android" 138 | ) 139 | HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) 140 | 141 | LibraryItem( 142 | name = "ML Kit Text Recognition", 143 | description = "Google's OCR technology" 144 | ) 145 | HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) 146 | 147 | LibraryItem( 148 | name = "MMKV", 149 | description = "High-performance key-value storage" 150 | ) 151 | } 152 | 153 | // GitHub链接卡片 154 | InfoCard( 155 | title = stringResource(R.string.about_github_title), 156 | modifier = Modifier.padding(bottom = 16.dp) 157 | ) { 158 | Row( 159 | modifier = Modifier 160 | .fillMaxWidth() 161 | .clickable { 162 | context?.let { 163 | val intent = Intent( 164 | Intent.ACTION_VIEW, 165 | it.getString(R.string.about_github_link).toUri() 166 | ) 167 | it.startActivity(intent) 168 | 169 | } 170 | } 171 | .padding(vertical = 8.dp), 172 | verticalAlignment = Alignment.CenterVertically 173 | ) { 174 | Text( 175 | text = stringResource(R.string.about_github_link), 176 | style = MaterialTheme.typography.bodyMedium, 177 | color = MaterialTheme.colorScheme.primary 178 | ) 179 | } 180 | } 181 | 182 | InfoCard( 183 | title = stringResource(R.string.about_contact_title), 184 | modifier = Modifier.padding(bottom = 16.dp) 185 | ) { 186 | val email = stringResource(R.string.about_contact_email) 187 | val emailIntentTitle = stringResource(R.string.about_contact_email_intent_title) 188 | val emailCopiedMessage = stringResource(R.string.about_contact_email_copied, email) 189 | val emailLabel = stringResource(R.string.about_contact_title) 190 | Row( 191 | modifier = Modifier 192 | .fillMaxWidth() 193 | .clickable { 194 | context?.let { ctx -> 195 | val intent = Intent( 196 | Intent.ACTION_SENDTO, 197 | "mailto:$email".toUri() 198 | ) 199 | try { 200 | val chooser = Intent.createChooser(intent, emailIntentTitle) 201 | ctx.startActivity(chooser) 202 | } catch (e: ActivityNotFoundException) { 203 | handleContactFallback( 204 | ctx, 205 | email, 206 | emailLabel, 207 | emailCopiedMessage 208 | ) 209 | } catch (e: Exception) { 210 | handleContactFallback( 211 | ctx, 212 | email, 213 | emailLabel, 214 | emailCopiedMessage 215 | ) 216 | } 217 | } 218 | } 219 | .padding(vertical = 8.dp), 220 | verticalAlignment = Alignment.CenterVertically 221 | ) { 222 | Text( 223 | text = email, 224 | style = MaterialTheme.typography.bodyMedium, 225 | color = MaterialTheme.colorScheme.primary 226 | ) 227 | } 228 | } 229 | 230 | Spacer(modifier = Modifier.height(16.dp)) 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | AI答题助手 4 | 5 | 6 | AI答题助手 7 | 当前状态 8 | 答题模式运行中 9 | 答题模式未启动 10 | 进入答题模式 11 | 退出答题模式 12 | 13 | 14 | 使用说明及常见问题 15 | 0. 配置好AI模型 16 | 1. 点击下方按钮进入答题模式 17 | 2. 应用将在后台运行,显示悬浮按钮 18 | 3. 点击悬浮按钮进行截图 19 | 4. 确认识别的题目文本 20 | 5. AI将自动分析并给出答案 21 | 6. 如果识别不出来,请考虑 22 | https://jingyan.baidu.com/article/7f41ecec6bf8de183c095c29.html 23 | 7. 获取悬浮窗权限的时候,提示"系统已拒绝向此应用授予访问权限" 24 | 请手动设置"显示其它应用的上层" 25 | https://bbs.oneplus.com/thread/5077724 26 | 8. 为了提高识别率,尽量裁剪到单题目。目前不兼容多题目 27 | 28 | 29 | 设置 30 | 自动提交答案 31 | 识别后直接获取答案,不显示确认对话框 32 | 自动复制到剪贴板 33 | 生成答案后自动复制到剪贴板 34 | 答题卡片显示内容 35 | 自定义答题卡片显示的内容 36 | 显示题目 37 | 在答题卡片中显示题目内容 38 | 显示选项 39 | 在答题卡片中显示选项内容 40 | 41 | 42 | 本次答题设置 43 | 根据题型和范围设置,提高答题准确率 44 | 题型选择(可多选) 45 | 单选题 46 | 多选题 47 | 不定项 48 | 填空题 49 | 问答题 50 | 题目内容范围(可选) 51 | 输入题目关键词或范围,留空则不限制 52 | 截图识别模式 53 | 全屏识别 54 | 部分识别(每次) 55 | 部分识别(单次) 56 | 模型设置 57 | 配置API和模型参数 58 | 当前设置:%s 59 | 60 | 61 | 设置 62 | 模型设置 63 | 关于 64 | 更多 65 | 66 | 67 | 模型设置 68 | 目前仅支持OpenAI格式的API 69 | API地址 70 | 例如: https://api.openai.com/v1/chat/completions 71 | API密钥 72 | 例如: sk-xxxxxxxxxxxxxxxxxxxx 73 | 模型名称 74 | 例如: gpt-4 或 deepseek-v3.1 75 | 保存 76 | 测试连接 77 | 测试中... 78 | 连接成功 79 | 连接失败:%s 80 | 设置已保存 81 | 保存失败,请检查输入 82 | 83 | 84 | 关于 85 | 应用简介 86 | 一个基于AI的Android答题助手应用,使用截图和OCR技术识别题目,通过AI提供答案。 87 | 版本信息 88 | 版本 %1$s (%2$d) 89 | 核心第三方库 90 | GitHub 91 | https://github.com/wb-hwang/AIAnswerer-Android 92 | 语言设置 93 | 简体中文 94 | English 95 | 联系邮箱 96 | hwang@linux.do 97 | 邮件联系 98 | 邮箱已复制:%s 99 | 重启应用 100 | 需要重启应用才能切换语言,确定要重启吗? 101 | 确定 102 | 取消 103 | 104 | 105 | 返回 106 | 关闭 107 | 108 | 109 | 请先配置AI模型\n右上角-设置-模型设置 110 | 需要悬浮窗权限才能使用答题模式 111 | 需要截图权限才能使用答题模式 112 | 答题模式已启动 113 | 答题模式已停止 114 | 115 | 116 | 返回按钮 117 | 菜单按钮 118 | 应用图标 119 | 截图 120 | 关闭 121 | 隐藏密码 122 | 显示密码 123 | 124 | 125 | 确认识别内容 126 | 识别的文本(可编辑) 127 | 请编辑或确认识别的文本 128 | 确认并获取答案 129 | 文本为空 130 | 正在获取答案... 131 | 132 | 133 | AI 响应 134 | 答题模式运行中 135 | 136 | 137 | 响应体为空 138 | 未获取到答案内容 139 | 无法解析题目 140 | 连接测试失败: %s 141 | 未知错误 142 | API配置无效,请在设置中配置API信息 143 | API请求失败: %1$d %2$s 144 | API配置无效,请先完整配置API信息 145 | API密钥无效或已过期 146 | 无权访问该API 147 | API地址错误 148 | 请求过于频繁,请稍后再试 149 | API服务器错误 150 | HTTP %1$d: %2$s 151 | API返回空响应 152 | API响应格式异常 153 | API响应格式错误 154 | 无法连接服务器,请检查网络和API地址 155 | 连接超时,请检查网络 156 | SSL连接错误,请检查API地址 157 | 158 | 159 | 未识别到文本 160 | 识别被取消 161 | 162 | 163 | 关闭屏幕共享防护 164 | 无法打开链接 165 | 166 | 167 | 单选题 168 | 169 | 170 | 【题目】 171 | 【选项】 172 | 【答案】 173 | 174 | 175 | 选择语言 / Select Language 176 | 请选择您偏好的应用语言: 177 | 确定 178 | 取消 179 | 180 | AI模型设置 181 | 为了使用AI答题功能,您需要先配置API参数。是否立即前往设置? 182 | 前往设置 183 | 取消 184 | 185 | 186 | 选择识别区域 187 | 拖动四个角调整识别区域 188 | 双指缩放和拖动图片 189 | 确认 190 | 取消 191 | 重置 192 | 193 | 194 | 选择题 195 | 问答题 196 | 填空题 197 | 198 | ## 限制条件 199 | 仅处理以下题型:%1$s。请尽量将题目解析为这些类型之一,若不符合则输出 questionType="%2$s"。 200 | 题目范围限定在:%1$s。答案时请优先考虑该范围内的知识点。 201 | 请分析以下题目:\n\n%1$s 202 | 242 | 243 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ModelSettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import android.widget.Toast 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.size 15 | import androidx.compose.foundation.layout.width 16 | import androidx.compose.material3.Button 17 | import androidx.compose.material3.ButtonDefaults 18 | import androidx.compose.material3.Card 19 | import androidx.compose.material3.CardDefaults 20 | import androidx.compose.material3.CircularProgressIndicator 21 | import androidx.compose.material3.ExperimentalMaterial3Api 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.OutlinedButton 24 | import androidx.compose.material3.Scaffold 25 | import androidx.compose.material3.SnackbarDuration 26 | import androidx.compose.material3.SnackbarHost 27 | import androidx.compose.material3.SnackbarHostState 28 | import androidx.compose.material3.Text 29 | import androidx.compose.runtime.Composable 30 | import androidx.compose.runtime.getValue 31 | import androidx.compose.runtime.mutableStateOf 32 | import androidx.compose.runtime.remember 33 | import androidx.compose.runtime.rememberCoroutineScope 34 | import androidx.compose.runtime.setValue 35 | import androidx.compose.ui.Modifier 36 | import androidx.compose.ui.res.stringResource 37 | import androidx.compose.ui.text.font.FontWeight 38 | import androidx.compose.ui.unit.dp 39 | import com.hwb.aianswerer.api.OpenAIClient 40 | import com.hwb.aianswerer.config.AppConfig 41 | import com.hwb.aianswerer.ui.components.AppTextField 42 | import com.hwb.aianswerer.ui.components.PasswordTextField 43 | import com.hwb.aianswerer.ui.components.TopBarWithBack 44 | import com.hwb.aianswerer.ui.theme.AIAnswererTheme 45 | import com.hwb.aianswerer.utils.LanguageUtil 46 | import kotlinx.coroutines.launch 47 | 48 | /** 49 | * 模型设置Activity 50 | * 51 | * 配置立即保存到MMKV,下次API调用时生效 52 | */ 53 | class ModelSettingsActivity : ComponentActivity() { 54 | 55 | override fun attachBaseContext(newBase: Context) { 56 | super.attachBaseContext(LanguageUtil.attachBaseContext(newBase)) 57 | } 58 | 59 | override fun onCreate(savedInstanceState: Bundle?) { 60 | super.onCreate(savedInstanceState) 61 | 62 | setContent { 63 | AIAnswererTheme { 64 | ModelSettingsScreen( 65 | onBackClick = { finish() }, 66 | onSaveSuccess = { 67 | Toast.makeText( 68 | this, 69 | getString(R.string.toast_settings_saved), 70 | Toast.LENGTH_SHORT 71 | ).show() 72 | }, 73 | onSaveError = { 74 | Toast.makeText( 75 | this, 76 | getString(R.string.toast_settings_error), 77 | Toast.LENGTH_SHORT 78 | ).show() 79 | } 80 | ) 81 | } 82 | } 83 | } 84 | } 85 | 86 | /** 87 | * 测试连接状态 88 | * 89 | * 使用密封类清晰表达测试的各种状态 90 | */ 91 | sealed class TestConnectionState { 92 | object Idle : TestConnectionState() // 初始状态 93 | object Testing : TestConnectionState() // 测试中 94 | object Success : TestConnectionState() // 测试成功 95 | data class Error(val message: String) : TestConnectionState() // 测试失败 96 | } 97 | 98 | /** 99 | * 模型设置界面 100 | * 101 | * @param onBackClick 返回按钮点击事件 102 | * @param onSaveSuccess 保存成功回调 103 | * @param onSaveError 保存失败回调 104 | */ 105 | @OptIn(ExperimentalMaterial3Api::class) 106 | @Composable 107 | fun ModelSettingsScreen( 108 | onBackClick: () -> Unit, 109 | onSaveSuccess: () -> Unit, 110 | onSaveError: () -> Unit 111 | ) { 112 | // 从配置中加载当前值 113 | var apiUrl by remember { mutableStateOf(AppConfig.getApiUrl()) } 114 | var apiKey by remember { mutableStateOf(AppConfig.getApiKey()) } 115 | var modelName by remember { mutableStateOf(AppConfig.getModelName()) } 116 | 117 | // 测试连接状态管理 118 | var testState by remember { mutableStateOf(TestConnectionState.Idle) } 119 | val snackbarHostState = remember { SnackbarHostState() } 120 | val coroutineScope = rememberCoroutineScope() 121 | 122 | // 在Composable作用域中获取字符串资源(不能在coroutine中调用stringResource) 123 | val successMessage = stringResource(R.string.toast_connection_success) 124 | val failedMessageTemplate = stringResource(R.string.toast_connection_failed) 125 | 126 | Scaffold( 127 | topBar = { 128 | TopBarWithBack( 129 | title = stringResource(R.string.model_settings_title), 130 | onBackClick = onBackClick 131 | ) 132 | }, 133 | snackbarHost = { 134 | SnackbarHost(hostState = snackbarHostState) 135 | } 136 | ) { paddingValues -> 137 | Column( 138 | modifier = Modifier 139 | .fillMaxSize() 140 | .padding(paddingValues) 141 | .padding(horizontal = 16.dp) 142 | ) { 143 | // 顶部说明 144 | Card( 145 | modifier = Modifier 146 | .fillMaxWidth() 147 | .padding(vertical = 16.dp), 148 | colors = CardDefaults.cardColors( 149 | containerColor = MaterialTheme.colorScheme.secondaryContainer 150 | ) 151 | ) { 152 | Text( 153 | text = stringResource(R.string.model_settings_notice), 154 | style = MaterialTheme.typography.bodyMedium, 155 | color = MaterialTheme.colorScheme.onSecondaryContainer, 156 | modifier = Modifier.padding(16.dp) 157 | ) 158 | } 159 | 160 | Spacer(modifier = Modifier.height(8.dp)) 161 | 162 | // API URL输入框(支持多行显示) 163 | AppTextField( 164 | value = apiUrl, 165 | onValueChange = { apiUrl = it }, 166 | label = stringResource(R.string.label_api_url), 167 | placeholder = stringResource(R.string.hint_api_url), 168 | isPassword = false, 169 | singleLine = false, 170 | maxLines = 3, 171 | modifier = Modifier.fillMaxWidth() 172 | ) 173 | 174 | Spacer(modifier = Modifier.height(16.dp)) 175 | 176 | // API Key输入框(密码类型,带显示/隐藏切换,支持多行) 177 | PasswordTextField( 178 | value = apiKey, 179 | onValueChange = { apiKey = it }, 180 | label = stringResource(R.string.label_api_key), 181 | placeholder = stringResource(R.string.hint_api_key), 182 | singleLine = false, 183 | maxLines = 3, 184 | modifier = Modifier.fillMaxWidth() 185 | ) 186 | 187 | Spacer(modifier = Modifier.height(16.dp)) 188 | 189 | // 模型名称输入框 190 | AppTextField( 191 | value = modelName, 192 | onValueChange = { modelName = it }, 193 | label = stringResource(R.string.label_model_name), 194 | placeholder = stringResource(R.string.hint_model_name), 195 | isPassword = false, 196 | modifier = Modifier.fillMaxWidth() 197 | ) 198 | 199 | Spacer(modifier = Modifier.height(32.dp)) 200 | 201 | // 测试连接按钮 202 | OutlinedButton( 203 | onClick = { 204 | // 启动测试流程 205 | coroutineScope.launch { 206 | testState = TestConnectionState.Testing 207 | 208 | // 调用API测试方法 209 | val result = OpenAIClient.getInstance().testConnection( 210 | apiUrl, 211 | apiKey, 212 | modelName 213 | ) 214 | 215 | result.onSuccess { 216 | testState = TestConnectionState.Success 217 | // 显示成功Snackbar 218 | snackbarHostState.showSnackbar( 219 | message = successMessage, 220 | duration = SnackbarDuration.Short 221 | ) 222 | testState = TestConnectionState.Idle 223 | }.onFailure { error -> 224 | val errorMsg = 225 | error.message ?: MyApplication.getString(R.string.error_unknown) 226 | testState = TestConnectionState.Error(errorMsg) 227 | // 显示失败Snackbar 228 | snackbarHostState.showSnackbar( 229 | message = failedMessageTemplate.format(errorMsg), 230 | duration = SnackbarDuration.Long 231 | ) 232 | testState = TestConnectionState.Idle 233 | } 234 | } 235 | }, 236 | modifier = Modifier 237 | .fillMaxWidth() 238 | .height(56.dp), 239 | enabled = testState !is TestConnectionState.Testing, // 测试中禁用 240 | colors = ButtonDefaults.outlinedButtonColors() 241 | ) { 242 | if (testState is TestConnectionState.Testing) { 243 | // 测试中显示加载指示器 244 | CircularProgressIndicator( 245 | modifier = Modifier.size(20.dp), 246 | strokeWidth = 2.dp 247 | ) 248 | Spacer(modifier = Modifier.width(8.dp)) 249 | Text( 250 | text = stringResource(R.string.button_testing), 251 | style = MaterialTheme.typography.labelLarge 252 | ) 253 | } else { 254 | Text( 255 | text = stringResource(R.string.button_test_connection), 256 | style = MaterialTheme.typography.labelLarge, 257 | fontWeight = FontWeight.Medium 258 | ) 259 | } 260 | } 261 | 262 | Spacer(modifier = Modifier.height(16.dp)) 263 | 264 | // 保存按钮 265 | Button( 266 | onClick = { 267 | // 验证输入 268 | if (apiUrl.isBlank() || apiKey.isBlank() || modelName.isBlank()) { 269 | onSaveError() 270 | return@Button 271 | } 272 | 273 | if (!apiUrl.startsWith("http")) { 274 | onSaveError() 275 | return@Button 276 | } 277 | 278 | // 保存配置 279 | AppConfig.saveApiUrl(apiUrl) 280 | AppConfig.saveApiKey(apiKey) 281 | AppConfig.saveModelName(modelName) 282 | 283 | onSaveSuccess() 284 | }, 285 | modifier = Modifier 286 | .fillMaxWidth() 287 | .height(56.dp), 288 | colors = ButtonDefaults.buttonColors( 289 | containerColor = MaterialTheme.colorScheme.primary 290 | ) 291 | ) { 292 | Text( 293 | text = stringResource(R.string.button_save), 294 | style = MaterialTheme.typography.labelLarge, 295 | fontWeight = FontWeight.Bold 296 | ) 297 | } 298 | } 299 | } 300 | } 301 | 302 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/api/OpenAIClient.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.api 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.JsonSyntaxException 5 | import com.hwb.aianswerer.Constants 6 | import com.hwb.aianswerer.MyApplication 7 | import com.hwb.aianswerer.R 8 | import com.hwb.aianswerer.config.AppConfig 9 | import com.hwb.aianswerer.models.AIAnswer 10 | import com.hwb.aianswerer.models.ChatMessage 11 | import com.hwb.aianswerer.models.ChatRequest 12 | import com.hwb.aianswerer.models.ChatResponse 13 | import com.hwb.aianswerer.models.ResponseFormat 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.withContext 16 | import okhttp3.MediaType.Companion.toMediaType 17 | import okhttp3.OkHttpClient 18 | import okhttp3.Request 19 | import okhttp3.RequestBody.Companion.toRequestBody 20 | import okhttp3.logging.HttpLoggingInterceptor 21 | import java.util.concurrent.TimeUnit 22 | 23 | /** 24 | * OpenAI API客户端 25 | * 26 | * 负责与OpenAI格式的API进行通信 27 | * 支持动态配置,从AppConfig读取最新的API设置 28 | */ 29 | class OpenAIClient { 30 | 31 | private val gson = Gson() 32 | 33 | private val client: OkHttpClient by lazy { 34 | val logging = HttpLoggingInterceptor().apply { 35 | level = HttpLoggingInterceptor.Level.BODY 36 | } 37 | 38 | OkHttpClient.Builder() 39 | .addInterceptor(logging) 40 | .connectTimeout(30, TimeUnit.SECONDS) 41 | .readTimeout(60, TimeUnit.SECONDS) 42 | .writeTimeout(30, TimeUnit.SECONDS) 43 | .build() 44 | } 45 | 46 | /** 47 | * 分析题目并获取答案 48 | * 49 | * 动态从AppConfig读取最新的API配置 50 | * 51 | * @param recognizedText OCR识别的文本 52 | * @param questionTypes 题型集合(如:单选题、多选题等) 53 | * @param questionScope 题目内容范围 54 | * @return AI解析的答案,包装在Result中 55 | */ 56 | suspend fun analyzeQuestion( 57 | recognizedText: String, 58 | questionTypes: Set = emptySet(), 59 | questionScope: String = "" 60 | ): Result = withContext(Dispatchers.IO) { 61 | try { 62 | // 从配置中读取最新的API设置 63 | val apiUrl = AppConfig.getApiUrl() 64 | val apiKey = AppConfig.getApiKey() 65 | val modelName = AppConfig.getModelName() 66 | 67 | // 验证配置有效性 68 | if (!AppConfig.isApiConfigValid()) { 69 | return@withContext Result.failure( 70 | Exception(MyApplication.getString(R.string.error_api_config_invalid)) 71 | ) 72 | } 73 | 74 | // 构建请求,使用动态系统提示词 75 | val systemPrompt = Constants.buildSystemPrompt(questionTypes, questionScope) 76 | val messages = listOf( 77 | ChatMessage(role = "system", content = systemPrompt), 78 | ChatMessage( 79 | role = "user", 80 | content = MyApplication.getString( 81 | R.string.system_prompt_user_message, 82 | recognizedText 83 | ) 84 | ) 85 | ) 86 | 87 | val chatRequest = ChatRequest( 88 | model = modelName, 89 | messages = messages, 90 | temperature = 0.3, // 较低的温度以获得更确定的答案 91 | responseFormat = ResponseFormat(type = "json_object") 92 | ) 93 | 94 | val requestBody = gson.toJson(chatRequest) 95 | .toRequestBody("application/json; charset=utf-8".toMediaType()) 96 | 97 | val request = Request.Builder() 98 | .url(apiUrl) 99 | .addHeader("Authorization", "Bearer $apiKey") 100 | .addHeader("Content-Type", "application/json") 101 | .post(requestBody) 102 | .build() 103 | 104 | // 发送请求 105 | val response = client.newCall(request).execute() 106 | 107 | if (!response.isSuccessful) { 108 | return@withContext Result.failure( 109 | Exception( 110 | MyApplication.getString( 111 | R.string.error_api_request_failed, 112 | response.code, 113 | response.message 114 | ) 115 | ) 116 | ) 117 | } 118 | 119 | val responseBody = response.body?.string() 120 | ?: return@withContext Result.failure( 121 | Exception( 122 | MyApplication.getString(R.string.error_empty_response) 123 | ) 124 | ) 125 | 126 | // 解析响应 127 | val chatResponse = gson.fromJson(responseBody, ChatResponse::class.java) 128 | val answerContent = chatResponse.choices.firstOrNull()?.message?.content 129 | ?: return@withContext Result.failure( 130 | Exception( 131 | MyApplication.getString(R.string.error_no_answer_content) 132 | ) 133 | ) 134 | 135 | // 解析AI返回的JSON答案 136 | val aiAnswer = try { 137 | gson.fromJson(extractJsonPayload(answerContent), AIAnswer::class.java) 138 | } catch (e: JsonSyntaxException) { 139 | // 如果解析失败,尝试提取文本作为答案 140 | AIAnswer( 141 | question = MyApplication.getString(R.string.error_parse_question_failed), 142 | questionType = MyApplication.getString(R.string.question_type_essay), 143 | answer = answerContent 144 | ) 145 | } 146 | 147 | Result.success(aiAnswer) 148 | 149 | } catch (e: Exception) { 150 | Result.failure(e) 151 | } 152 | } 153 | 154 | /** 155 | * 提取 content 中的首个 JSON 负载(兼容 ```json 代码块 或 纯文本里含 JSON). 156 | * 返回去壳后的纯 JSON 字符串(对象或数组)。 157 | */ 158 | fun extractJsonPayload(content: String): String { 159 | val s = content.trim() 160 | 161 | // 1) 优先匹配 Markdown 代码块 ```...```,含可选语言标记 162 | val fenceRegex = Regex("(?s)```\\s*([a-zA-Z0-9_-]+)?\\s*(\\{.*?\\}|\\[.*?\\])\\s*```") 163 | fenceRegex.find(s)?.let { m -> 164 | return m.groupValues[2].trim() 165 | } 166 | 167 | // 2) 无代码块:从首个 '{' 或 '[' 开始,做括号配对提取 168 | val start = sequenceOf(s.indexOf('{'), s.indexOf('[')) 169 | .filter { it >= 0 } 170 | .minOrNull() ?: return s // 找不到就原样返回(让 Gson 去判断) 171 | 172 | val openChar = s[start] 173 | val closeChar = if (openChar == '{') '}' else ']' 174 | 175 | var depth = 0 176 | var inString = false 177 | var escape = false 178 | var end = -1 179 | 180 | for (i in start until s.length) { 181 | val c = s[i] 182 | if (inString) { 183 | if (escape) { 184 | escape = false 185 | } else { 186 | if (c == '\\') escape = true 187 | else if (c == '"') inString = false 188 | } 189 | } else { 190 | if (c == '"') inString = true 191 | else if (c == openChar) depth++ 192 | else if (c == closeChar) { 193 | depth-- 194 | if (depth == 0) { 195 | end = i 196 | break 197 | } 198 | } 199 | } 200 | } 201 | if (end != -1) { 202 | return s.substring(start, end + 1).trim() 203 | } 204 | 205 | // 3) 兜底:返回原文(可能是已是纯 JSON) 206 | return s 207 | } 208 | 209 | /** 210 | * 测试API连接,支持传入未保存的配置参数 211 | */ 212 | suspend fun testConnection( 213 | apiUrl: String, 214 | apiKey: String, 215 | modelName: String 216 | ): Result = withContext(Dispatchers.IO) { 217 | try { 218 | // 验证配置有效性 219 | if (!AppConfig.isApiConfigValid(apiUrl, apiKey, modelName)) { 220 | return@withContext Result.failure( 221 | Exception(MyApplication.getString(R.string.error_api_config_incomplete)) 222 | ) 223 | } 224 | 225 | // 构建最简单的测试请求 226 | val messages = listOf( 227 | ChatMessage(role = "user", content = "hello") 228 | ) 229 | 230 | val chatRequest = ChatRequest( 231 | model = modelName, 232 | messages = messages, 233 | ) 234 | 235 | val requestBody = gson.toJson(chatRequest) 236 | .toRequestBody("application/json; charset=utf-8".toMediaType()) 237 | 238 | val request = Request.Builder() 239 | .url(apiUrl) 240 | .addHeader("Authorization", "Bearer $apiKey") 241 | .addHeader("Content-Type", "application/json") 242 | .post(requestBody) 243 | .build() 244 | 245 | // 发送请求 246 | val response = client.newCall(request).execute() 247 | 248 | // 检查响应状态 249 | if (!response.isSuccessful) { 250 | val errorMessage = when (response.code) { 251 | 401 -> R.string.error_api_key_invalid 252 | 403 -> R.string.error_api_forbidden 253 | 404 -> R.string.error_api_not_found 254 | 429 -> R.string.error_api_rate_limited 255 | 500, 502, 503 -> R.string.error_api_server_error 256 | else -> null 257 | }?.let { MyApplication.getString(it) } 258 | ?: MyApplication.getString( 259 | R.string.error_http_status_generic, 260 | response.code, 261 | response.message 262 | ) 263 | return@withContext Result.failure(Exception(errorMessage)) 264 | } 265 | 266 | // 验证响应体存在 267 | val responseBody = response.body?.string() 268 | if (responseBody.isNullOrBlank()) { 269 | return@withContext Result.failure( 270 | Exception(MyApplication.getString(R.string.error_api_empty_response)) 271 | ) 272 | } 273 | 274 | // 尝试解析响应以验证格式正确 275 | try { 276 | val chatResponse = gson.fromJson(responseBody, ChatResponse::class.java) 277 | if (chatResponse.choices.isEmpty()) { 278 | return@withContext Result.failure( 279 | Exception(MyApplication.getString(R.string.error_api_response_invalid)) 280 | ) 281 | } 282 | } catch (e: JsonSyntaxException) { 283 | return@withContext Result.failure( 284 | Exception(MyApplication.getString(R.string.error_api_response_error)) 285 | ) 286 | } 287 | 288 | // 测试成功 289 | Result.success(MyApplication.getString(R.string.toast_connection_success)) 290 | 291 | } catch (e: java.net.UnknownHostException) { 292 | Result.failure(Exception(MyApplication.getString(R.string.error_api_unknown_host))) 293 | } catch (e: java.net.SocketTimeoutException) { 294 | Result.failure(Exception(MyApplication.getString(R.string.error_api_timeout))) 295 | } catch (e: javax.net.ssl.SSLException) { 296 | Result.failure(Exception(MyApplication.getString(R.string.error_api_ssl))) 297 | } catch (e: Exception) { 298 | val unknownError = MyApplication.getString(R.string.error_unknown) 299 | Result.failure( 300 | Exception( 301 | MyApplication.getString( 302 | R.string.error_connection_test_failed, 303 | e.message ?: unknownError 304 | ) 305 | ) 306 | ) 307 | } 308 | } 309 | 310 | companion object { 311 | @Volatile 312 | private var instance: OpenAIClient? = null 313 | 314 | fun getInstance(): OpenAIClient { 315 | return instance ?: synchronized(this) { 316 | instance ?: OpenAIClient().also { instance = it } 317 | } 318 | } 319 | } 320 | } 321 | 322 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/ui/components/CommonComponents.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer.ui.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.ColumnScope 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.RowScope 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.width 15 | import androidx.compose.material3.Card 16 | import androidx.compose.material3.CardDefaults 17 | import androidx.compose.material3.DropdownMenu 18 | import androidx.compose.material3.ExperimentalMaterial3Api 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.IconButton 21 | import androidx.compose.material3.MaterialTheme 22 | import androidx.compose.material3.OutlinedTextField 23 | import androidx.compose.material3.OutlinedTextFieldDefaults 24 | import androidx.compose.material3.Switch 25 | import androidx.compose.material3.Text 26 | import androidx.compose.material3.TopAppBar 27 | import androidx.compose.material3.TopAppBarDefaults 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.runtime.getValue 30 | import androidx.compose.runtime.mutableStateOf 31 | import androidx.compose.runtime.remember 32 | import androidx.compose.runtime.setValue 33 | import androidx.compose.ui.Alignment 34 | import androidx.compose.ui.Modifier 35 | import androidx.compose.ui.res.stringResource 36 | import androidx.compose.ui.text.font.FontWeight 37 | import androidx.compose.ui.text.input.PasswordVisualTransformation 38 | import androidx.compose.ui.text.input.VisualTransformation 39 | import androidx.compose.ui.unit.dp 40 | import com.hwb.aianswerer.MyApplication 41 | import com.hwb.aianswerer.R 42 | import com.hwb.aianswerer.ui.icons.LocalIcons 43 | 44 | /** 45 | * 共享UI组件库 46 | */ 47 | 48 | /** 49 | * 带返回按钮的顶部栏 50 | * 51 | * @param title 标题 52 | * @param onBackClick 返回按钮点击事件 53 | * @param actions 右侧操作按钮 54 | */ 55 | @OptIn(ExperimentalMaterial3Api::class) 56 | @Composable 57 | fun TopBarWithBack( 58 | title: String, 59 | onBackClick: () -> Unit, 60 | actions: @Composable RowScope.() -> Unit = {} 61 | ) { 62 | TopAppBar( 63 | title = { 64 | Text( 65 | text = title, 66 | style = MaterialTheme.typography.titleLarge, 67 | fontWeight = FontWeight.Bold 68 | ) 69 | }, 70 | navigationIcon = { 71 | IconButton(onClick = onBackClick) { 72 | Icon( 73 | imageVector = LocalIcons.ArrowBack, 74 | contentDescription = stringResource(R.string.cd_back_button) 75 | ) 76 | } 77 | }, 78 | actions = actions, 79 | colors = TopAppBarDefaults.topAppBarColors( 80 | containerColor = MaterialTheme.colorScheme.surface, 81 | titleContentColor = MaterialTheme.colorScheme.onSurface 82 | ) 83 | ) 84 | } 85 | 86 | /** 87 | * 带菜单按钮和下拉菜单的顶部栏 88 | * 89 | * @param title 标题 90 | * @param menuContent 菜单内容(Composable lambda) 91 | */ 92 | @OptIn(ExperimentalMaterial3Api::class) 93 | @Composable 94 | fun TopBarWithMenu( 95 | title: String, 96 | menuContent: @Composable ColumnScope.() -> Unit 97 | ) { 98 | var showMenu by remember { mutableStateOf(false) } 99 | 100 | TopAppBar( 101 | title = { 102 | Text( 103 | text = title, 104 | style = MaterialTheme.typography.titleLarge, 105 | fontWeight = FontWeight.Bold 106 | ) 107 | }, 108 | actions = { 109 | Box { 110 | IconButton(onClick = { showMenu = true }) { 111 | Icon( 112 | imageVector = LocalIcons.MoreVert, 113 | contentDescription = stringResource(R.string.cd_menu_button) 114 | ) 115 | } 116 | 117 | DropdownMenu( 118 | expanded = showMenu, 119 | onDismissRequest = { showMenu = false } 120 | ) { 121 | menuContent() 122 | } 123 | } 124 | }, 125 | colors = TopAppBarDefaults.topAppBarColors( 126 | containerColor = MaterialTheme.colorScheme.surface, 127 | titleContentColor = MaterialTheme.colorScheme.onSurface 128 | ) 129 | ) 130 | } 131 | 132 | /** 133 | * 设置项组件 134 | * 135 | * 用于显示一个设置项,包含标题、描述和开关 136 | * 137 | * @param title 设置项标题 138 | * @param description 设置项描述 139 | * @param checked 开关状态 140 | * @param onCheckedChange 开关状态改变回调 141 | * @param enabled 是否启用 142 | */ 143 | @Composable 144 | fun SettingItem( 145 | title: String, 146 | description: String, 147 | checked: Boolean, 148 | onCheckedChange: (Boolean) -> Unit, 149 | enabled: Boolean = true 150 | ) { 151 | Row( 152 | modifier = Modifier 153 | .fillMaxWidth() 154 | .padding(vertical = 12.dp), 155 | horizontalArrangement = Arrangement.SpaceBetween, 156 | verticalAlignment = Alignment.CenterVertically 157 | ) { 158 | Column( 159 | modifier = Modifier.weight(1f) 160 | ) { 161 | Text( 162 | text = title, 163 | style = MaterialTheme.typography.titleMedium, 164 | fontWeight = FontWeight.Medium, 165 | color = if (enabled) MaterialTheme.colorScheme.onSurface 166 | else MaterialTheme.colorScheme.onSurfaceVariant 167 | ) 168 | if (description.isNotBlank()) { 169 | Spacer(modifier = Modifier.height(4.dp)) 170 | Text( 171 | text = description, 172 | style = MaterialTheme.typography.bodySmall, 173 | color = MaterialTheme.colorScheme.onSurfaceVariant 174 | ) 175 | } 176 | } 177 | Spacer(modifier = Modifier.width(16.dp)) 178 | Switch( 179 | checked = checked, 180 | onCheckedChange = onCheckedChange, 181 | enabled = enabled 182 | ) 183 | } 184 | } 185 | 186 | /** 187 | * 信息展示项组件 188 | * 189 | * 用于展示标题和内容的信息项 190 | * 191 | * @param title 标题 192 | * @param content 内容 193 | * @param onClick 点击事件(可选) 194 | */ 195 | @Composable 196 | fun InfoItem( 197 | title: String, 198 | content: String, 199 | onClick: (() -> Unit)? = null 200 | ) { 201 | val modifier = if (onClick != null) { 202 | Modifier 203 | .fillMaxWidth() 204 | .clickable(onClick = onClick) 205 | .padding(vertical = 12.dp) 206 | } else { 207 | Modifier 208 | .fillMaxWidth() 209 | .padding(vertical = 12.dp) 210 | } 211 | 212 | Column(modifier = modifier) { 213 | Text( 214 | text = title, 215 | style = MaterialTheme.typography.titleSmall, 216 | color = MaterialTheme.colorScheme.onSurfaceVariant 217 | ) 218 | Spacer(modifier = Modifier.height(4.dp)) 219 | Text( 220 | text = content, 221 | style = MaterialTheme.typography.bodyMedium, 222 | color = MaterialTheme.colorScheme.onSurface 223 | ) 224 | } 225 | } 226 | 227 | /** 228 | * 统一的文本输入框 229 | * 230 | * @param value 当前值 231 | * @param onValueChange 值改变回调 232 | * @param label 标签 233 | * @param placeholder 占位符 234 | * @param isPassword 是否为密码输入框 235 | * @param singleLine 是否单行 236 | * @param maxLines 最大行数 237 | * @param modifier Modifier 238 | */ 239 | @Composable 240 | fun AppTextField( 241 | value: String, 242 | onValueChange: (String) -> Unit, 243 | label: String, 244 | placeholder: String = "", 245 | isPassword: Boolean = false, 246 | singleLine: Boolean = true, 247 | maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, 248 | modifier: Modifier = Modifier 249 | ) { 250 | OutlinedTextField( 251 | value = value, 252 | onValueChange = onValueChange, 253 | label = { Text(label) }, 254 | placeholder = { Text(placeholder) }, 255 | modifier = modifier.fillMaxWidth(), 256 | singleLine = singleLine, 257 | maxLines = maxLines, 258 | visualTransformation = if (isPassword) PasswordVisualTransformation() 259 | else VisualTransformation.None, 260 | colors = OutlinedTextFieldDefaults.colors( 261 | focusedBorderColor = MaterialTheme.colorScheme.primary, 262 | unfocusedBorderColor = MaterialTheme.colorScheme.outline 263 | ) 264 | ) 265 | } 266 | 267 | /** 268 | * 带可见性切换的密码输入框 269 | * 270 | * @param value 当前值 271 | * @param onValueChange 值改变回调 272 | * @param label 标签 273 | * @param placeholder 占位符 274 | * @param singleLine 是否单行 275 | * @param maxLines 最大行数 276 | * @param modifier Modifier 277 | */ 278 | @Composable 279 | fun PasswordTextField( 280 | value: String, 281 | onValueChange: (String) -> Unit, 282 | label: String, 283 | placeholder: String = "", 284 | singleLine: Boolean = true, 285 | maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, 286 | modifier: Modifier = Modifier 287 | ) { 288 | var passwordVisible by remember { mutableStateOf(false) } 289 | 290 | OutlinedTextField( 291 | value = value, 292 | onValueChange = onValueChange, 293 | label = { Text(label) }, 294 | placeholder = { Text(placeholder) }, 295 | modifier = modifier.fillMaxWidth(), 296 | singleLine = singleLine, 297 | maxLines = maxLines, 298 | visualTransformation = if (passwordVisible) VisualTransformation.None 299 | else PasswordVisualTransformation(), 300 | trailingIcon = { 301 | IconButton(onClick = { passwordVisible = !passwordVisible }) { 302 | Icon( 303 | imageVector = if (passwordVisible) LocalIcons.Visibility 304 | else LocalIcons.VisibilityOff, 305 | contentDescription = if (passwordVisible) 306 | MyApplication.getString(R.string.cd_hide_password) 307 | else 308 | MyApplication.getString(R.string.cd_show_password) 309 | ) 310 | } 311 | }, 312 | colors = OutlinedTextFieldDefaults.colors( 313 | focusedBorderColor = MaterialTheme.colorScheme.primary, 314 | unfocusedBorderColor = MaterialTheme.colorScheme.outline 315 | ) 316 | ) 317 | } 318 | 319 | /** 320 | * 信息卡片 321 | * 322 | * 用于包装一组相关信息 323 | * 324 | * @param title 卡片标题(可选) 325 | * @param modifier Modifier 326 | * @param content 卡片内容 327 | */ 328 | @Composable 329 | fun InfoCard( 330 | title: String? = null, 331 | modifier: Modifier = Modifier, 332 | content: @Composable ColumnScope.() -> Unit 333 | ) { 334 | Card( 335 | modifier = modifier.fillMaxWidth(), 336 | colors = CardDefaults.cardColors( 337 | containerColor = MaterialTheme.colorScheme.surface 338 | ), 339 | elevation = CardDefaults.cardElevation( 340 | defaultElevation = 2.dp 341 | ) 342 | ) { 343 | Column( 344 | modifier = Modifier 345 | .fillMaxWidth() 346 | .padding(16.dp) 347 | ) { 348 | if (title != null) { 349 | Text( 350 | text = title, 351 | style = MaterialTheme.typography.titleMedium, 352 | fontWeight = FontWeight.Bold, 353 | modifier = Modifier.padding(bottom = 12.dp) 354 | ) 355 | } 356 | content() 357 | } 358 | } 359 | } 360 | 361 | /** 362 | * 列表项 363 | * 364 | * 用于显示一个可点击的列表项 365 | * 366 | * @param text 文本内容 367 | * @param modifier Modifier 368 | */ 369 | @Composable 370 | fun FeatureItem( 371 | text: String, 372 | modifier: Modifier = Modifier 373 | ) { 374 | Row( 375 | modifier = modifier 376 | .fillMaxWidth() 377 | .padding(vertical = 4.dp), 378 | verticalAlignment = Alignment.Top 379 | ) { 380 | Text( 381 | text = "•", 382 | modifier = Modifier.padding(end = 8.dp), 383 | style = MaterialTheme.typography.bodyMedium 384 | ) 385 | Text( 386 | text = text, 387 | style = MaterialTheme.typography.bodyMedium, 388 | color = MaterialTheme.colorScheme.onSurface 389 | ) 390 | } 391 | } 392 | 393 | /** 394 | * 库信息项 395 | * 396 | * 用于显示第三方库信息 397 | * 398 | * @param name 库名称 399 | * @param description 库描述 400 | */ 401 | @Composable 402 | fun LibraryItem( 403 | name: String, 404 | description: String 405 | ) { 406 | Column( 407 | modifier = Modifier 408 | .fillMaxWidth() 409 | .padding(vertical = 8.dp) 410 | ) { 411 | Text( 412 | text = name, 413 | style = MaterialTheme.typography.titleSmall, 414 | fontWeight = FontWeight.Medium, 415 | color = MaterialTheme.colorScheme.onSurface 416 | ) 417 | Spacer(modifier = Modifier.height(2.dp)) 418 | Text( 419 | text = description, 420 | style = MaterialTheme.typography.bodySmall, 421 | color = MaterialTheme.colorScheme.onSurfaceVariant 422 | ) 423 | } 424 | } 425 | 426 | -------------------------------------------------------------------------------- /app/src/main/res/values-en/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | AI Answerer 4 | 5 | 6 | AI Answerer 7 | Current Status 8 | Answer Mode Running 9 | Answer Mode Stopped 10 | Start Answer Mode 11 | Stop Answer Mode 12 | 13 | 14 | Usage Guide & FAQ 15 | 0. Configure AI Model 16 | 1. Click the button below to start answer mode 17 | 2. App will run in background with floating button 18 | 3. Click floating button to capture screen 19 | 4. Confirm the recognized question text 20 | 5. AI will analyze and provide answer 21 | 6. If recognition fails, consider 22 | https://jingyan.baidu.com/article/7f41ecec6bf8de183c095c29.html 23 | 7. If you see "System has denied access permission to this app" when granting overlay permission 24 | Please manually enable "Display over other apps" 25 | https://bbs.oneplus.com/thread/5077724 26 | 8. For better recognition, crop to a single question. Multiple-question images are not supported yet 27 | 28 | 29 | Settings 30 | Auto Submit Answer 31 | Get answer directly without confirmation dialog 32 | Auto Copy to Clipboard 33 | Automatically copy answer to clipboard after generation 34 | Answer Card Display Content 35 | Customize answer card display content 36 | Show Question 37 | Display question content in answer card 38 | Show Options 39 | Display options in answer card 40 | 41 | 42 | Session Settings 43 | Configure question types and scope to improve accuracy 44 | Question Types (Multiple) 45 | Single Choice 46 | Multiple Choice 47 | Uncertain 48 | Fill in Blank 49 | Essay 50 | Question Scope (Optional) 51 | Enter keywords or scope, leave blank for no limit 52 | Screenshot Recognition Mode 53 | Full Screen 54 | Partial (Each Time) 55 | Partial (Once) 56 | Model Settings 57 | Configure API and model parameters 58 | Current: %s 59 | 60 | 61 | Settings 62 | Model Settings 63 | About 64 | More 65 | 66 | 67 | Model Settings 68 | Currently only supports OpenAI format API 69 | API URL 70 | e.g. https://api.openai.com/v1/chat/completions 71 | API Key 72 | e.g. sk-xxxxxxxxxxxxxxxxxxxx 73 | Model Name 74 | e.g. gpt-4 or deepseek-v3.1 75 | Save 76 | Test Connection 77 | Testing... 78 | Connection successful 79 | Connection failed: %s 80 | Settings saved 81 | Save failed, please check input 82 | 83 | 84 | About 85 | App Introduction 86 | An AI-powered Android answering assistant that uses screenshots and OCR to recognize questions and provides answers through AI. 87 | Version Information 88 | Version %1$s (%2$d) 89 | Core Libraries 90 | GitHub 91 | Language Settings 92 | 简体中文 93 | English 94 | Contact Email 95 | hwang@linux.do 96 | Contact via Email 97 | Email copied to clipboard: %s 98 | Restart App 99 | App needs to restart to switch language. Confirm restart? 100 | Confirm 101 | Cancel 102 | 103 | 104 | Back 105 | Close 106 | 107 | 108 | Please configure AI model first 109 | Overlay permission required for answer mode 110 | Screen capture permission required for answer mode 111 | Answer mode started 112 | Answer mode stopped 113 | 114 | 115 | Back button 116 | Menu button 117 | App icon 118 | Capture 119 | Close 120 | Hide password 121 | Show password 122 | 123 | 124 | Confirm Recognized Text 125 | Recognized Text (Editable) 126 | Edit or confirm the recognized text 127 | Confirm and Get Answer 128 | Text is empty 129 | Getting answer... 130 | 131 | 132 | AI Response 133 | Answer mode is running 134 | 135 | 136 | Empty response body 137 | No answer content received 138 | Failed to parse question 139 | Connection test failed: %s 140 | Unknown error 141 | API configuration is invalid. Please configure the API in settings. 142 | API request failed: %1$d %2$s 143 | API configuration is invalid. Please complete all API settings first. 144 | API key is invalid or expired. 145 | Access to this API is forbidden. 146 | API endpoint not found. 147 | Too many requests. Please try again later. 148 | API server error. 149 | HTTP %1$d: %2$s 150 | API returned an empty response. 151 | API response format is invalid. 152 | API response format error. 153 | Unable to connect to the server. Please check the network and API URL. 154 | Connection timed out. Please check the network. 155 | SSL connection error. Please check the API URL. 156 | 157 | 158 | No text recognized 159 | Recognition cancelled 160 | 161 | 162 | Close Screen Share Protection 163 | Unable to open link 164 | 165 | 166 | Single Choice 167 | 168 | 169 | Question 170 | Options 171 | Answer 172 | 173 | 174 | 选择语言 / Select Language 175 | Please select your preferred language: 176 | Confirm 177 | Cancel 178 | 179 | AI Model Settings 180 | To use the AI answering feature, you need to configure API parameters first. Go to settings now? 181 | Go to Settings 182 | Cancel 183 | 184 | 185 | Select Recognition Area 186 | Drag corners to adjust recognition area 187 | Pinch to zoom and drag image 188 | Confirm 189 | Cancel 190 | Reset 191 | 192 | 193 | Multiple Choice 194 | Essay 195 | Fill in the Blank 196 | , 197 | ## Constraints 198 | Only handle the following question types: %1$s. If a question does not match, set questionType="%2$s". 199 | Limit the question scope to: %1$s. Prioritize knowledge within this scope when answering. 200 | Please analyze the following question:\n\n%1$s 201 | 241 | 242 | -------------------------------------------------------------------------------- /app/src/main/java/com/hwb/aianswerer/SettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.hwb.aianswerer 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.Spacer 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.foundation.layout.fillMaxWidth 15 | import androidx.compose.foundation.layout.height 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.layout.width 18 | import androidx.compose.material3.AlertDialog 19 | import androidx.compose.material3.Card 20 | import androidx.compose.material3.CardDefaults 21 | import androidx.compose.material3.ExperimentalMaterial3Api 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.RadioButton 24 | import androidx.compose.material3.Scaffold 25 | import androidx.compose.material3.Switch 26 | import androidx.compose.material3.Text 27 | import androidx.compose.material3.TextButton 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.runtime.getValue 30 | import androidx.compose.runtime.mutableStateOf 31 | import androidx.compose.runtime.remember 32 | import androidx.compose.runtime.setValue 33 | import androidx.compose.ui.Alignment 34 | import androidx.compose.ui.Modifier 35 | import androidx.compose.ui.res.stringResource 36 | import androidx.compose.ui.text.font.FontWeight 37 | import androidx.compose.ui.unit.dp 38 | import com.hwb.aianswerer.config.AppConfig 39 | import com.hwb.aianswerer.ui.components.TopBarWithBack 40 | import com.hwb.aianswerer.ui.theme.AIAnswererTheme 41 | import com.hwb.aianswerer.utils.LanguageUtil 42 | 43 | /** 44 | * 设置Activity 45 | */ 46 | class SettingsActivity : ComponentActivity() { 47 | 48 | override fun attachBaseContext(newBase: Context) { 49 | super.attachBaseContext(LanguageUtil.attachBaseContext(newBase)) 50 | } 51 | 52 | override fun onCreate(savedInstanceState: Bundle?) { 53 | super.onCreate(savedInstanceState) 54 | 55 | setContent { 56 | AIAnswererTheme { 57 | SettingsScreen( 58 | onBackClick = { finish() }, 59 | onModelSettingsClick = { 60 | startActivity(Intent(this, ModelSettingsActivity::class.java)) 61 | }, 62 | onLanguageChange = { languageCode -> 63 | // 应用新的语言设置 64 | LanguageUtil.applyLanguage(this, languageCode) 65 | // 重启应用以应用语言 66 | LanguageUtil.restartApp(this) 67 | } 68 | ) 69 | } 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * 设置界面 76 | * 77 | * @param onBackClick 返回按钮点击事件 78 | * @param onModelSettingsClick 模型设置按钮点击事件 79 | * @param onLanguageChange 语言切换回调 80 | */ 81 | @OptIn(ExperimentalMaterial3Api::class) 82 | @Composable 83 | fun SettingsScreen( 84 | onBackClick: () -> Unit, 85 | onModelSettingsClick: () -> Unit, 86 | onLanguageChange: (String) -> Unit 87 | ) { 88 | // 从配置中加载当前值 89 | var autoSubmit by remember { mutableStateOf(AppConfig.getAutoSubmit()) } 90 | var autoCopy by remember { mutableStateOf(AppConfig.getAutoCopy()) } 91 | var showQuestion by remember { mutableStateOf(AppConfig.getShowAnswerCardQuestion()) } 92 | var showOptions by remember { mutableStateOf(AppConfig.getShowAnswerCardOptions()) } 93 | 94 | // 语言设置状态 95 | var showRestartDialog by remember { mutableStateOf(false) } 96 | var selectedLanguage by remember { mutableStateOf(null) } 97 | val currentLanguage = LanguageUtil.getCurrentLanguage() 98 | 99 | Scaffold( 100 | topBar = { 101 | TopBarWithBack( 102 | title = stringResource(R.string.settings_title), 103 | onBackClick = onBackClick 104 | ) 105 | } 106 | ) { paddingValues -> 107 | Column( 108 | modifier = Modifier 109 | .fillMaxSize() 110 | .padding(paddingValues) 111 | .padding(horizontal = 16.dp) 112 | ) { 113 | Spacer(modifier = Modifier.height(16.dp)) 114 | 115 | // 模型设置卡片(显眼位置) 116 | Card( 117 | modifier = Modifier 118 | .fillMaxWidth() 119 | .clickable { onModelSettingsClick() }, 120 | colors = CardDefaults.cardColors( 121 | containerColor = MaterialTheme.colorScheme.primaryContainer 122 | ), 123 | elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) 124 | ) { 125 | Row( 126 | modifier = Modifier 127 | .fillMaxWidth() 128 | .padding(20.dp), 129 | verticalAlignment = Alignment.CenterVertically 130 | ) { 131 | Column(modifier = Modifier.weight(1f)) { 132 | Text( 133 | text = stringResource(R.string.model_settings_card_title), 134 | style = MaterialTheme.typography.titleLarge, 135 | fontWeight = FontWeight.Bold, 136 | color = MaterialTheme.colorScheme.onPrimaryContainer 137 | ) 138 | Spacer(modifier = Modifier.height(4.dp)) 139 | Text( 140 | text = stringResource(R.string.model_settings_card_desc), 141 | style = MaterialTheme.typography.bodyMedium, 142 | color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) 143 | ) 144 | } 145 | } 146 | } 147 | 148 | Spacer(modifier = Modifier.height(24.dp)) 149 | 150 | // 通用设置卡片 151 | Card( 152 | modifier = Modifier.fillMaxWidth() 153 | ) { 154 | Column( 155 | modifier = Modifier 156 | .fillMaxWidth() 157 | .padding(16.dp) 158 | ) { 159 | Text( 160 | text = stringResource(R.string.settings_title), 161 | style = MaterialTheme.typography.titleMedium, 162 | fontWeight = FontWeight.Bold, 163 | modifier = Modifier.padding(bottom = 12.dp) 164 | ) 165 | 166 | // 自动提交开关 167 | Row( 168 | modifier = Modifier.fillMaxWidth(), 169 | horizontalArrangement = Arrangement.SpaceBetween, 170 | verticalAlignment = Alignment.CenterVertically 171 | ) { 172 | Column(modifier = Modifier.weight(1f)) { 173 | Text( 174 | text = stringResource(R.string.setting_auto_submit), 175 | style = MaterialTheme.typography.titleSmall, 176 | fontWeight = FontWeight.Medium 177 | ) 178 | Text( 179 | text = stringResource(R.string.setting_auto_submit_desc), 180 | style = MaterialTheme.typography.bodySmall, 181 | color = MaterialTheme.colorScheme.onSurfaceVariant, 182 | modifier = Modifier.padding(top = 4.dp) 183 | ) 184 | } 185 | Switch( 186 | checked = autoSubmit, 187 | onCheckedChange = { 188 | autoSubmit = it 189 | AppConfig.saveAutoSubmit(it) 190 | } 191 | ) 192 | } 193 | 194 | Spacer(modifier = Modifier.height(16.dp)) 195 | 196 | // 自动复制开关 197 | Row( 198 | modifier = Modifier.fillMaxWidth(), 199 | horizontalArrangement = Arrangement.SpaceBetween, 200 | verticalAlignment = Alignment.CenterVertically 201 | ) { 202 | Column(modifier = Modifier.weight(1f)) { 203 | Text( 204 | text = stringResource(R.string.setting_auto_copy), 205 | style = MaterialTheme.typography.titleSmall, 206 | fontWeight = FontWeight.Medium 207 | ) 208 | Text( 209 | text = stringResource(R.string.setting_auto_copy_desc), 210 | style = MaterialTheme.typography.bodySmall, 211 | color = MaterialTheme.colorScheme.onSurfaceVariant, 212 | modifier = Modifier.padding(top = 4.dp) 213 | ) 214 | } 215 | Switch( 216 | checked = autoCopy, 217 | onCheckedChange = { 218 | autoCopy = it 219 | AppConfig.saveAutoCopy(it) 220 | } 221 | ) 222 | } 223 | } 224 | } 225 | 226 | Spacer(modifier = Modifier.height(16.dp)) 227 | 228 | // 答题卡片显示控制卡片 229 | Card( 230 | modifier = Modifier.fillMaxWidth() 231 | ) { 232 | Column( 233 | modifier = Modifier 234 | .fillMaxWidth() 235 | .padding(16.dp) 236 | ) { 237 | Text( 238 | text = stringResource(R.string.setting_display_control_title), 239 | style = MaterialTheme.typography.titleMedium, 240 | fontWeight = FontWeight.Bold, 241 | modifier = Modifier.padding(bottom = 4.dp) 242 | ) 243 | Text( 244 | text = stringResource(R.string.setting_display_control_desc), 245 | style = MaterialTheme.typography.bodySmall, 246 | color = MaterialTheme.colorScheme.onSurfaceVariant, 247 | modifier = Modifier.padding(bottom = 12.dp) 248 | ) 249 | 250 | // 显示题目开关 251 | Row( 252 | modifier = Modifier.fillMaxWidth(), 253 | horizontalArrangement = Arrangement.SpaceBetween, 254 | verticalAlignment = Alignment.CenterVertically 255 | ) { 256 | Column(modifier = Modifier.weight(1f)) { 257 | Text( 258 | text = stringResource(R.string.setting_show_question), 259 | style = MaterialTheme.typography.titleSmall, 260 | fontWeight = FontWeight.Medium 261 | ) 262 | Text( 263 | text = stringResource(R.string.setting_show_question_desc), 264 | style = MaterialTheme.typography.bodySmall, 265 | color = MaterialTheme.colorScheme.onSurfaceVariant, 266 | modifier = Modifier.padding(top = 4.dp) 267 | ) 268 | } 269 | Switch( 270 | checked = showQuestion, 271 | onCheckedChange = { 272 | showQuestion = it 273 | AppConfig.saveShowAnswerCardQuestion(it) 274 | } 275 | ) 276 | } 277 | 278 | Spacer(modifier = Modifier.height(16.dp)) 279 | 280 | // 显示选项开关 281 | Row( 282 | modifier = Modifier.fillMaxWidth(), 283 | horizontalArrangement = Arrangement.SpaceBetween, 284 | verticalAlignment = Alignment.CenterVertically 285 | ) { 286 | Column(modifier = Modifier.weight(1f)) { 287 | Text( 288 | text = stringResource(R.string.setting_show_options), 289 | style = MaterialTheme.typography.titleSmall, 290 | fontWeight = FontWeight.Medium 291 | ) 292 | Text( 293 | text = stringResource(R.string.setting_show_options_desc), 294 | style = MaterialTheme.typography.bodySmall, 295 | color = MaterialTheme.colorScheme.onSurfaceVariant, 296 | modifier = Modifier.padding(top = 4.dp) 297 | ) 298 | } 299 | Switch( 300 | checked = showOptions, 301 | onCheckedChange = { 302 | showOptions = it 303 | AppConfig.saveShowAnswerCardOptions(it) 304 | } 305 | ) 306 | } 307 | } 308 | } 309 | 310 | Spacer(modifier = Modifier.height(16.dp)) 311 | 312 | // 语言设置卡片 313 | Card( 314 | modifier = Modifier.fillMaxWidth() 315 | ) { 316 | Column( 317 | modifier = Modifier 318 | .fillMaxWidth() 319 | .padding(16.dp) 320 | ) { 321 | Text( 322 | text = stringResource(R.string.about_language_title), 323 | style = MaterialTheme.typography.titleMedium, 324 | fontWeight = FontWeight.Bold, 325 | modifier = Modifier.padding(bottom = 12.dp) 326 | ) 327 | 328 | // 中文选项 329 | Row( 330 | modifier = Modifier 331 | .fillMaxWidth() 332 | .clickable { 333 | if (currentLanguage != AppConfig.LANGUAGE_ZH) { 334 | selectedLanguage = AppConfig.LANGUAGE_ZH 335 | showRestartDialog = true 336 | } 337 | } 338 | .padding(vertical = 12.dp), 339 | verticalAlignment = Alignment.CenterVertically 340 | ) { 341 | RadioButton( 342 | selected = currentLanguage == AppConfig.LANGUAGE_ZH, 343 | onClick = { 344 | if (currentLanguage != AppConfig.LANGUAGE_ZH) { 345 | selectedLanguage = AppConfig.LANGUAGE_ZH 346 | showRestartDialog = true 347 | } 348 | } 349 | ) 350 | Spacer(modifier = Modifier.width(12.dp)) 351 | Text( 352 | text = stringResource(R.string.about_language_chinese), 353 | style = MaterialTheme.typography.bodyMedium 354 | ) 355 | } 356 | 357 | // 英文选项 358 | Row( 359 | modifier = Modifier 360 | .fillMaxWidth() 361 | .clickable { 362 | if (currentLanguage != AppConfig.LANGUAGE_EN) { 363 | selectedLanguage = AppConfig.LANGUAGE_EN 364 | showRestartDialog = true 365 | } 366 | } 367 | .padding(vertical = 12.dp), 368 | verticalAlignment = Alignment.CenterVertically 369 | ) { 370 | RadioButton( 371 | selected = currentLanguage == AppConfig.LANGUAGE_EN, 372 | onClick = { 373 | if (currentLanguage != AppConfig.LANGUAGE_EN) { 374 | selectedLanguage = AppConfig.LANGUAGE_EN 375 | showRestartDialog = true 376 | } 377 | } 378 | ) 379 | Spacer(modifier = Modifier.width(12.dp)) 380 | Text( 381 | text = stringResource(R.string.about_language_english), 382 | style = MaterialTheme.typography.bodyMedium 383 | ) 384 | } 385 | } 386 | } 387 | } 388 | } 389 | 390 | // 重启确认对话框 391 | if (showRestartDialog && selectedLanguage != null) { 392 | AlertDialog( 393 | onDismissRequest = { 394 | showRestartDialog = false 395 | selectedLanguage = null 396 | }, 397 | title = { 398 | Text( 399 | text = stringResource(R.string.about_restart_dialog_title), 400 | style = MaterialTheme.typography.titleMedium, 401 | fontWeight = FontWeight.Bold 402 | ) 403 | }, 404 | text = { 405 | Text( 406 | text = stringResource(R.string.about_restart_dialog_message), 407 | style = MaterialTheme.typography.bodyMedium 408 | ) 409 | }, 410 | confirmButton = { 411 | TextButton( 412 | onClick = { 413 | selectedLanguage?.let { lang -> 414 | onLanguageChange(lang) 415 | } 416 | } 417 | ) { 418 | Text(stringResource(R.string.button_confirm)) 419 | } 420 | }, 421 | dismissButton = { 422 | TextButton( 423 | onClick = { 424 | showRestartDialog = false 425 | selectedLanguage = null 426 | } 427 | ) { 428 | Text(stringResource(R.string.button_cancel)) 429 | } 430 | } 431 | ) 432 | } 433 | } 434 | 435 | --------------------------------------------------------------------------------