├── 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 |
9 |
10 |
11 |
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 |
--------------------------------------------------------------------------------