├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── ic_launcher-playstore.png
│ │ ├── res
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_background.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_background.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_background.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_background.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_background.webp
│ │ │ ├── values
│ │ │ │ ├── themes.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── strings.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── xml
│ │ │ │ ├── provider_paths.xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ ├── data_extraction_rules.xml
│ │ │ │ └── accessibility_service_config.xml
│ │ │ └── drawable
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── me
│ │ │ │ └── wjz
│ │ │ │ └── nekocrypt
│ │ │ │ ├── ui
│ │ │ │ ├── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── dialog
│ │ │ │ │ ├── NCDialog.kt
│ │ │ │ │ ├── PermissionDialog.kt
│ │ │ │ │ └── AppHandlerInfoDialog.kt
│ │ │ │ ├── screen
│ │ │ │ │ ├── Screen.kt
│ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ └── SettingsScreen.kt
│ │ │ │ ├── MainMenu.kt
│ │ │ │ ├── activity
│ │ │ │ │ ├── ScannerActivity.kt
│ │ │ │ │ └── AttachmentPickerActivity.kt
│ │ │ │ └── component
│ │ │ │ │ ├── DecryptionPopup.kt
│ │ │ │ │ └── CapPawButton.kt
│ │ │ │ ├── util
│ │ │ │ ├── ResultRelay.kt
│ │ │ │ ├── PermissionUtil.kt
│ │ │ │ ├── NekoNotification.kt
│ │ │ │ ├── NCFileProtocol.kt
│ │ │ │ ├── AccessibilityManager.kt
│ │ │ │ ├── CryptoDownloader.kt
│ │ │ │ ├── PermissionGuard.kt
│ │ │ │ ├── LifecycleOwnerProvider.kt
│ │ │ │ ├── helper.kt
│ │ │ │ ├── NodeFinder.kt
│ │ │ │ ├── NCWindowManager.kt
│ │ │ │ └── CryptoManager.kt
│ │ │ │ ├── service
│ │ │ │ ├── handler
│ │ │ │ │ ├── CustomAppHandler.kt
│ │ │ │ │ ├── WeChatHandler.kt
│ │ │ │ │ ├── QQHandler.kt
│ │ │ │ │ ├── ChatAppHandler.kt
│ │ │ │ │ └── FileActionHandler.kt
│ │ │ │ └── KeepAliveService.kt
│ │ │ │ ├── hook
│ │ │ │ ├── ServiceStateDelegate.kt
│ │ │ │ └── DataStoreStateHook.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── NekoCryptApp.kt
│ │ │ │ ├── Constant.kt
│ │ │ │ └── data
│ │ │ │ └── DataStoreManager.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── me
│ │ │ └── wjz
│ │ │ └── nekocrypt
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── me
│ │ └── wjz
│ │ └── nekocrypt
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── markdown
├── QQ群.jpg
├── 小约翰.png
├── 纯蓝.jpg
├── 发送附件.png
├── 成功开启.png
├── 扫描结果.png
├── 无障碍入口.png
├── 无障碍开关.png
├── 自定义应用.jpg
├── 输入区域.png
├── 已下载的应用.png
├── 解密效果展示.png
├── mainScreen.jpg
├── background (1).png
├── foreground (1).png
├── foreground.svg
├── background.svg
└── cat-avatar-full.svg
├── .gitattributes
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── settings.gradle.kts
├── .gitignore
├── gradle.properties
├── gradlew.bat
├── gradlew
└── README.md
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/markdown/QQ群.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/QQ群.jpg
--------------------------------------------------------------------------------
/markdown/小约翰.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/小约翰.png
--------------------------------------------------------------------------------
/markdown/纯蓝.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/纯蓝.jpg
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/markdown/发送附件.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/发送附件.png
--------------------------------------------------------------------------------
/markdown/成功开启.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/成功开启.png
--------------------------------------------------------------------------------
/markdown/扫描结果.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/扫描结果.png
--------------------------------------------------------------------------------
/markdown/无障碍入口.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/无障碍入口.png
--------------------------------------------------------------------------------
/markdown/无障碍开关.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/无障碍开关.png
--------------------------------------------------------------------------------
/markdown/自定义应用.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/自定义应用.jpg
--------------------------------------------------------------------------------
/markdown/输入区域.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/输入区域.png
--------------------------------------------------------------------------------
/markdown/已下载的应用.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/已下载的应用.png
--------------------------------------------------------------------------------
/markdown/解密效果展示.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/解密效果展示.png
--------------------------------------------------------------------------------
/markdown/mainScreen.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/mainScreen.jpg
--------------------------------------------------------------------------------
/markdown/background (1).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/background (1).png
--------------------------------------------------------------------------------
/markdown/foreground (1).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/markdown/foreground (1).png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/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/WJZ-P/NekoCrypt/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/WJZ-P/NekoCrypt/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/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WJZ-P/NekoCrypt/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Jun 27 23:34: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 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/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/java/me/wjz/nekocrypt/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/app/src/main/res/xml/provider_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
12 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/ResultRelay.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import android.net.Uri
4 | import kotlinx.coroutines.flow.MutableSharedFlow
5 | import kotlinx.coroutines.flow.asSharedFlow
6 |
7 | object ResultRelay {
8 | private val _flow = MutableSharedFlow()
9 | val flow = _flow.asSharedFlow()
10 |
11 | suspend fun send(uri: Uri) {
12 | _flow.emit(uri)
13 | }
14 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/dialog/NCDialog.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.dialog
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.graphics.vector.ImageVector
5 |
6 | /**
7 | * 弹窗对话栏
8 | */
9 | interface NCDialog {
10 | val icon: ImageVector
11 | val title: String
12 | val text: String
13 | val onDismiss: () -> Unit
14 | val onConfirm: () -> Unit
15 | @Composable
16 | fun Content()
17 | }
--------------------------------------------------------------------------------
/app/src/test/java/me/wjz/nekocrypt/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/markdown/foreground.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/markdown/background.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/service/handler/CustomAppHandler.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.service.handler
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | /**
6 | * 一个数据类,用于表示用户自定义的应用配置。
7 | * @Serializable 注解是必须的,它告诉 kotlinx.serialization 库这个类可以被转换成JSON。
8 | */
9 | @Serializable
10 | data class CustomAppHandler(
11 | // 需要重写 ChatAppHandler 接口中的所有属性
12 | override val packageName: String,
13 | override val inputId: String,
14 | override val sendBtnId: String,
15 | override val messageTextId: String,
16 | override val messageListClassName: String
17 |
18 | ) : BaseChatAppHandler()
19 |
--------------------------------------------------------------------------------
/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 = "NekoCrypt"
23 | include(":app")
24 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Gradle files
2 | .gradle/
3 | build/
4 |
5 | # Local configuration file (sdk path, etc)
6 | local.properties
7 |
8 | # Log/OS Files
9 | *.log
10 |
11 | # Android Studio generated files and folders
12 | captures/
13 | .externalNativeBuild/
14 | .cxx/
15 | *.apk
16 | output.json
17 |
18 | # IntelliJ
19 | *.iml
20 | .idea/
21 | misc.xml
22 | deploymentTargetDropDown.xml
23 | render.experimental.xml
24 |
25 | # Keystore files
26 | *.jks
27 | *.keystore
28 |
29 | # Google Services (e.g. APIs or Firebase)
30 | google-services.json
31 |
32 | # Android Profiling
33 | *.hprof
34 |
35 | .kotlin
36 | /.kotlin
37 | app/release/output-metadata.json
38 | app/src/main/java/me/wjz/nekocrypt/test/
39 | app/release
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
7 |
9 |
10 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/me/wjz/nekocrypt/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("me.wjz.nekocrypt", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/service/handler/WeChatHandler.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.service.handler
2 |
3 | class WeChatHandler : BaseChatAppHandler() {
4 | companion object{
5 | const val ID_SEND_BTN="com.tencent.mm:id/bql"
6 | const val ID_INPUT="com.tencent.mm:id/bkk"
7 | const val ID_MESSAGE_TEXT="com.tencent.mm:id/bkl"
8 | const val PACKAGE_NAME ="com.tencent.mm"
9 | const val CLASS_NAME_RECYCLER_VIEW = "com.tencent.mm:id/bp0"
10 | const val APP_NAME ="微信"
11 | }
12 |
13 | override val packageName: String
14 | get() = PACKAGE_NAME
15 |
16 | override val inputId: String
17 | get() = ID_INPUT
18 |
19 | override val sendBtnId: String
20 | get() = ID_SEND_BTN
21 |
22 | override val messageTextId: String
23 | get() = ID_MESSAGE_TEXT
24 |
25 | override val messageListClassName: String
26 | get() = CLASS_NAME_RECYCLER_VIEW
27 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/service/handler/QQHandler.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.service.handler
2 |
3 | /**
4 | * 针对 QQ 的具体处理器实现。
5 | */
6 | class QQHandler : BaseChatAppHandler() {
7 | companion object{
8 | const val ID_SEND_BTN="com.tencent.mobileqq:id/send_btn"
9 | const val ID_INPUT="com.tencent.mobileqq:id/input"
10 | // 某些版本ID_MESSAGE_TEXT是SQB
11 | const val ID_MESSAGE_TEXT="com.tencent.mobileqq:id/sbl"
12 | const val PACKAGE_NAME ="com.tencent.mobileqq"
13 | const val APP_NAME ="QQ"
14 | const val CLASS_NAME_RECYCLER_VIEW="RecyclerView"
15 | }
16 |
17 | override val packageName: String get() = PACKAGE_NAME
18 | override val inputId: String get() = ID_INPUT
19 |
20 | override val sendBtnId: String get() = ID_SEND_BTN
21 |
22 | override val messageTextId: String get() = ID_MESSAGE_TEXT
23 | override val messageListClassName: String get() = CLASS_NAME_RECYCLER_VIEW
24 | }
--------------------------------------------------------------------------------
/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 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
23 | -dontwarn javax.annotation.processing.AbstractProcessor
24 | -dontwarn javax.annotation.processing.SupportedAnnotationTypes
--------------------------------------------------------------------------------
/app/src/main/res/xml/accessibility_service_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
16 |
--------------------------------------------------------------------------------
/markdown/cat-avatar-full.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
9 |
10 |
16 |
17 |
18 |
19 |
20 |
21 |
28 |
29 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/hook/ServiceStateDelegate.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.hook
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.collectLatest
6 | import kotlinx.coroutines.launch
7 | import kotlin.properties.ReadOnlyProperty
8 | import kotlin.reflect.KProperty
9 |
10 | /**
11 | * 一个自定义的属性委托类,接收一个Flow,在指定的协程作用域自动订阅。
12 | */
13 | class ServiceStateDelegate(
14 | private val flowProvider:()-> Flow,
15 | scope: CoroutineScope,
16 | initialValue: T,
17 | ) : ReadOnlyProperty {
18 | private var currentValue: T = initialValue
19 |
20 | init {
21 | scope.launch {
22 | flowProvider().collectLatest { newValue ->
23 | currentValue = newValue
24 | }
25 | }
26 | }
27 |
28 | override fun getValue(thisRef: Any?, property: KProperty<*>): T {
29 | return currentValue
30 | }
31 | }
32 |
33 | fun CoroutineScope.observeAsState(
34 | flowProvider: ()-> Flow,
35 | initialValue: T,
36 | ): ReadOnlyProperty {
37 | return ServiceStateDelegate(flowProvider, this, initialValue)
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.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 | // 这个对象控制着整个APP的文字样式
10 | val Typography = Typography(
11 | // 我们在这里定义 bodyLarge (大号正文) 的具体样式
12 | bodyLarge = TextStyle(
13 | fontFamily = FontFamily.Default,
14 | fontWeight = FontWeight.Normal,
15 | fontSize = 16.sp,
16 | lineHeight = 24.sp,
17 | letterSpacing = 0.5.sp// letterSpacing: 字间距
18 | )
19 | /* Other default text styles to override
20 | titleLarge = TextStyle(
21 | fontFamily = FontFamily.Default,
22 | fontWeight = FontWeight.Normal,
23 | fontSize = 22.sp,
24 | lineHeight = 28.sp,
25 | letterSpacing = 0.sp
26 | ),
27 | labelSmall = TextStyle(
28 | fontFamily = FontFamily.Default,
29 | fontWeight = FontWeight.Medium,
30 | fontSize = 11.sp,
31 | lineHeight = 16.sp,
32 | letterSpacing = 0.5.sp
33 | )
34 | */
35 | )
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/service/handler/ChatAppHandler.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.service.handler
2 |
3 | import android.view.accessibility.AccessibilityEvent
4 | import com.dianming.phoneapp.MyAccessibilityService
5 |
6 |
7 | /**
8 | * 聊天应用处理器的通用接口。
9 | * 定义了所有受支持的聊天应用都需要提供的基本信息和逻辑。
10 | */
11 | interface ChatAppHandler {
12 | /**
13 | * 该处理器对应的应用包名。
14 | */
15 | val packageName: String
16 |
17 | /**
18 | * 聊天界面输入框的资源ID。
19 | */
20 | val inputId: String
21 |
22 | /**
23 | * 聊天界面发送按钮的资源ID。
24 | */
25 | val sendBtnId: String
26 |
27 | /**
28 | * 气泡消息的ID
29 | */
30 | val messageTextId: String
31 |
32 | /**
33 | * 存放消息列表的className,QQ的这个class无ID,则不提供
34 | */
35 | val messageListClassName: String
36 |
37 | /**
38 | * 当该处理器被激活时调用(例如,用户打开了对应的App)。
39 | * @param service 无障碍服务的实例,用于获取上下文、协程作用域等。
40 | */
41 | fun onHandlerActivated(service: MyAccessibilityService)
42 |
43 | /**
44 | * 当该处理器被停用时调用(例如,用户离开了对应的App)。
45 | */
46 | fun onHandlerDeactivated()
47 |
48 | /**
49 | * 处理该应用相关的无障碍事件。
50 | * @param event 接收到的事件。
51 | * @param service 无障碍服务的实例。
52 | */
53 | fun onAccessibilityEvent(event: AccessibilityEvent, service: MyAccessibilityService)
54 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.runtime.CompositionLocalProvider
8 | import me.wjz.nekocrypt.data.LocalDataStoreManager
9 | import me.wjz.nekocrypt.ui.MainMenu
10 | import me.wjz.nekocrypt.ui.theme.NekoCryptTheme
11 | import me.wjz.nekocrypt.util.PermissionGuard
12 |
13 | class MainActivity : ComponentActivity() {
14 | override fun onCreate(savedInstanceState: Bundle?) {
15 | super.onCreate(savedInstanceState)
16 | enableEdgeToEdge()//让App可以上下扩展到最顶端和最低端
17 | //这是从传统 Android 视图系统切换到 Jetpack Compose 世界的“传送门”!
18 | // 一旦调用了它,你就可以在这个大括号 {} 里面,用我们之前学过的 @Composable 函数来描绘你的 App 界面了。
19 | setContent {
20 | //这里不要在Compose UI中直接引用dataStoreManager,而是在这里注入一个,这样可以方便替换不同的manager,解耦方便复用
21 | val app = application as NekoCryptApp
22 | NekoCryptTheme {
23 | // 权限检查
24 | PermissionGuard {
25 | CompositionLocalProvider(LocalDataStoreManager provides app.dataStoreManager) {
26 | MainMenu()
27 | }
28 | }
29 |
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/NekoCryptApp.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt
2 |
3 | import android.app.Application
4 | import android.app.NotificationChannel
5 | import android.app.NotificationManager
6 | import android.util.Log
7 | import me.wjz.nekocrypt.data.DataStoreManager
8 |
9 | class NekoCryptApp : Application() {
10 | // 在 Application 创建时,我们懒加载地创建 DataStoreManager 的实例。
11 | // 它只会被创建一次!
12 | val dataStoreManager: DataStoreManager by lazy {
13 | DataStoreManager(this)
14 | }
15 |
16 | override fun onCreate() {
17 | super.onCreate()
18 | createNotificationChannel() // 创建通知渠道,用于在 Android 8.0 及以上版本上显示通知
19 | instance = this
20 | Log.d(TAG, "NekoCryptApp onCreate")
21 | }
22 |
23 |
24 | companion object {
25 | const val SERVICE_CHANNEL_ID = "NekoCryptServiceChannel"
26 | const val TAG = "NekoCrypt"
27 | lateinit var instance: NekoCryptApp private set
28 | }
29 |
30 | private fun createNotificationChannel() {
31 | val serviceChannel = NotificationChannel(
32 | SERVICE_CHANNEL_ID,
33 | getString(R.string.notification_title),
34 | NotificationManager.IMPORTANCE_LOW // 使用较低的重要性,避免打扰用户
35 | )
36 | val manager = getSystemService(NotificationManager::class.java)
37 | manager.createNotificationChannel(serviceChannel)
38 | }
39 | }
--------------------------------------------------------------------------------
/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=-Xmx4g -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/me/wjz/nekocrypt/util/PermissionUtil.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import android.content.Context
4 | import android.provider.Settings
5 | import android.text.TextUtils
6 | import com.dianming.phoneapp.MyAccessibilityService
7 |
8 | object PermissionUtil {
9 | /**
10 | * 检查“显示在其他应用上层”(悬浮窗)权限是否已授予。
11 | */
12 | fun isOverlayPermissionGranted(context: Context): Boolean {
13 | return Settings.canDrawOverlays(context)
14 | }
15 |
16 | /**
17 | * 检查我们的无障碍服务是否已启用。
18 | */
19 | fun isAccessibilityServiceEnabled(context: Context): Boolean {
20 | val serviceName = context.packageName + "/" + MyAccessibilityService::class.java.name
21 | try {
22 | val enabledServices = Settings.Secure.getString(
23 | context.contentResolver,
24 | Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
25 | )
26 | val stringColonSplitter = TextUtils.SimpleStringSplitter(':')
27 | stringColonSplitter.setString(enabledServices)
28 | while (stringColonSplitter.hasNext()) {
29 | val componentName = stringColonSplitter.next()
30 | if (componentName.equals(serviceName, ignoreCase = true)) {
31 | return true
32 | }
33 | }
34 | } catch (e: Exception) {
35 | // 忽略异常
36 | }
37 | return false
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/NekoNotification.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import android.app.Notification
4 | import android.app.NotificationChannel
5 | import android.app.NotificationManager
6 | import android.content.Context
7 | import androidx.core.app.NotificationCompat
8 | import me.wjz.nekocrypt.R
9 |
10 | // 用于创建通知的对象
11 | object NekoNotification {
12 |
13 | const val NEKO_NOTIFICATION_ID = 20040821
14 | private const val CHANNEL_ID = "NekoCryptKeepAlive"
15 | private const val CHANNEL_NAME = "NekoCrypt 服务状态"
16 |
17 | /**
18 | * 创建通知渠道(仅在Android 8.0+需要)。
19 | */
20 | fun createChannel(context: Context) {
21 | val channel = NotificationChannel(
22 | CHANNEL_ID,
23 | CHANNEL_NAME,
24 | NotificationManager.IMPORTANCE_MIN // 设置为最低重要性,用户不会被打扰
25 | ).apply {
26 | description = "用于保持NekoCrypt服务在后台稳定运行"
27 | setShowBadge(false) // 不在桌面图标上显示角标
28 | }
29 | val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
30 | manager.createNotificationChannel(channel)
31 | }
32 |
33 | /**
34 | * 构建前台服务的常驻通知。
35 | */
36 | fun build(context: Context): Notification {
37 | return NotificationCompat.Builder(context, CHANNEL_ID)
38 | .setContentTitle("NekoCrypt 正在守护中")
39 | .setContentText("加密服务正在后台运行...")
40 | .setSmallIcon(R.drawable.ic_launcher_foreground) // ✨ 请确保你有一个图标资源
41 | .setPriority(NotificationCompat.PRIORITY_MIN) // 设置为最低优先级
42 | .setOngoing(true) // 设置为常驻通知,用户无法划掉,被划掉了会被降级。
43 | .build()
44 | }
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/screen/Screen.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.screen
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.material.icons.Icons
5 | import androidx.compose.material.icons.outlined.Home
6 | import androidx.compose.material.icons.outlined.Key
7 | import androidx.compose.material.icons.outlined.Lock
8 | import androidx.compose.material.icons.outlined.Settings
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.graphics.vector.ImageVector
11 | import me.wjz.nekocrypt.R
12 |
13 | /**
14 | * App的所有页面来源。
15 | */
16 | sealed class Screen (
17 | val route: String,
18 | @StringRes val titleResId: Int,
19 | val icon: ImageVector,
20 | val content: @Composable () -> Unit
21 | ){
22 | // 主页
23 | data object Home : Screen(
24 | route = "home",
25 | titleResId = R.string.home,
26 | icon = Icons.Outlined.Home,
27 | content = { HomeScreen() }
28 | )
29 | // 加解密页
30 | data object Crypto : Screen(
31 | route = "crypto",
32 | titleResId = R.string.crypto,
33 | icon = Icons.Outlined.Lock,
34 | content = { CryptoScreen() }
35 | )
36 |
37 | // 密钥管理页
38 | data object Key : Screen(
39 | route = "key",
40 | titleResId = R.string.key,
41 | icon = Icons.Outlined.Key,
42 | content = { KeyScreen() }
43 | )
44 |
45 | // 设置页
46 | data object Setting : Screen(
47 | route = "setting",
48 | titleResId = R.string.settings,
49 | icon = Icons.Outlined.Settings,
50 | content = { SettingsScreen() }
51 | )
52 | companion object {
53 | val allScreens: List = listOf(Home, Crypto, Key, Setting)
54 | }
55 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/dialog/PermissionDialog.kt:
--------------------------------------------------------------------------------
1 |
2 | import androidx.compose.material3.AlertDialog
3 | import androidx.compose.material3.Icon
4 | import androidx.compose.material3.Text
5 | import androidx.compose.material3.TextButton
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.graphics.vector.ImageVector
8 | import androidx.compose.ui.res.stringResource
9 | import me.wjz.nekocrypt.R
10 | import me.wjz.nekocrypt.ui.dialog.NCDialog
11 |
12 | /**
13 | * 专门用于请求权限的对话框实现。
14 | * 它在构造时接收所有必要信息,并直接在 Content() 方法中定义自己的UI。
15 | */
16 | class PermissionDialog(
17 | private val dialogIcon: ImageVector,
18 | private val dialogTitle: String,
19 | private val dialogText: String,
20 | private val onDismissRequest: () -> Unit,
21 | private val onConfirmRequest: () -> Unit
22 | ) : NCDialog {
23 |
24 | // 将接口属性映射到构造函数参数
25 | override val icon: ImageVector get() = dialogIcon
26 | override val title: String get() = dialogTitle
27 | override val text: String get() = dialogText
28 | override val onDismiss: () -> Unit get() = onDismissRequest
29 | override val onConfirm: () -> Unit get() = onConfirmRequest
30 |
31 | @Composable
32 | override fun Content() {
33 | AlertDialog(
34 | onDismissRequest = onDismiss,
35 | icon = { Icon(imageVector = icon, contentDescription = title) },
36 | title = { Text(text = title) },
37 | text = { Text(text = text) },
38 | confirmButton = {
39 | TextButton(onClick = onConfirm) {
40 | Text(stringResource(R.string.permission_go_to_settings))
41 | }
42 | },
43 | dismissButton = {
44 | TextButton(onClick = onDismiss) {
45 | Text(stringResource(R.string.cancel))
46 | }
47 | }
48 | )
49 | }
50 | }
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.11.0"
3 | kotlin = "2.2.0"
4 | coreKtx = "1.10.1"
5 | junit = "4.13.2"
6 | junitVersion = "1.1.5"
7 | espressoCore = "3.5.1"
8 | lifecycleRuntimeKtx = "2.6.1"
9 | activityCompose = "1.8.0"
10 | composeBom = "2024.09.00"
11 | compiler = "3.2.0-alpha11"
12 |
13 | [libraries]
14 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
15 | junit = { group = "junit", name = "junit", version.ref = "junit" }
16 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
17 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
18 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
19 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
20 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
21 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
22 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
23 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
24 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
25 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
26 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
27 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
28 | androidx-compiler = { group = "androidx.databinding", name = "compiler", version.ref = "compiler" }
29 |
30 | [plugins]
31 | android-application = { id = "com.android.application", version.ref = "agp" }
32 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
33 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/NCFileProtocol.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import android.util.Log
4 | import kotlinx.serialization.Serializable
5 | import kotlinx.serialization.encodeToString
6 | import kotlinx.serialization.json.Json
7 | import me.wjz.nekocrypt.util.CryptoManager.applyCiphertextStyle
8 | import org.json.JSONException
9 |
10 | enum class NCFileType{
11 | IMAGE,FILE;
12 | }
13 |
14 | const val NC_FILE_PROTOCOL_PREFIX = "NCFile://"
15 |
16 | @Serializable
17 | data class NCFileProtocol(
18 | val url: String,
19 | val size: Long,
20 | val name: String,
21 | val type: NCFileType,
22 | val encryptionKey: String
23 | ) {
24 | companion object {
25 | /**
26 | * ✨ [反序列化] (使用Fastjson)
27 | * @return 如果解密和解析成功,返回NCFileProtocol对象;否则返回null。
28 | */
29 | fun fromString(decryptedString: String): NCFileProtocol? {
30 | return try {
31 | if (!decryptedString.startsWith(NC_FILE_PROTOCOL_PREFIX)) return null
32 |
33 | val jsonPayload = decryptedString.substringAfter(NC_FILE_PROTOCOL_PREFIX)
34 | // ✨ 使用Fastjson进行解析
35 | Json.decodeFromString(jsonPayload)
36 | } catch (e: JSONException) {
37 | // Fastjson解析失败
38 | null
39 | } catch (e: Exception) {
40 | // 捕获其他所有可能的异常(如枚举转换失败)
41 | null
42 | }
43 | }
44 | }
45 |
46 | /**
47 | * ✨ [加密 & 序列化] (使用Fastjson)
48 | * 将当前的NCFileProtocol对象,转换为一个完整的、加密的协议字符串。
49 | * @param encryptionKey 用于加密的密钥。
50 | * @return 格式为 "NCFile://[加密并隐写编码后的JSON载荷]" 的字符串。
51 | */
52 | fun toEncryptedString(encryptionKey: String): String {
53 | Log.d("NekoAccessibility", "protocol本身结果结果:$this")
54 | val payloadJson = Json.encodeToString(this)
55 | Log.d("NekoAccessibility", "protocol转json结果:$payloadJson")
56 | return CryptoManager.encrypt(NC_FILE_PROTOCOL_PREFIX + payloadJson, encryptionKey).applyCiphertextStyle()
57 | }
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.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 | private val DarkColorScheme = darkColorScheme(
14 | primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80
15 | )
16 |
17 | private val LightColorScheme = lightColorScheme(
18 | primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40
19 |
20 | /* Other default colors to override
21 | background = Color(0xFFFFFBFE),
22 | surface = Color(0xFFFFFBFE),
23 | onPrimary = Color.White,
24 | onSecondary = Color.White,
25 | onTertiary = Color.White,
26 | onBackground = Color(0xFF1C1B1F),
27 | onSurface = Color(0xFF1C1B1F),
28 | */
29 | )
30 |
31 | @Composable
32 | fun NekoCryptTheme(
33 | darkTheme: Boolean = isSystemInDarkTheme(),
34 | // Dynamic color is available on Android 12+
35 | dynamicColor: Boolean = true,
36 | content: @Composable () -> Unit
37 | ) {
38 | //通过when决定什么时候用哪种调色盘
39 |
40 | val colorScheme = when {
41 | // 条件1: 如果“动态颜色”开启了,并且安卓版本大于等于12 (S)
42 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
43 | // LocalContext.current 用来获取当前App的上下文环境,是安卓开发里的老朋友啦
44 | val context = LocalContext.current
45 | if (darkTheme) dynamicDarkColorScheme(context)
46 | else dynamicLightColorScheme(context)
47 | }
48 | // 条件2: 如果不满足条件1,但用户开启了深色模式
49 | darkTheme -> DarkColorScheme
50 | // 条件3: 否则(也就是浅色模式)
51 | else -> LightColorScheme
52 | }
53 |
54 | MaterialTheme(
55 | colorScheme = colorScheme, typography = Typography, content = content
56 | )
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/hook/DataStoreStateHook.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.hook
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.State
5 | import androidx.compose.runtime.remember
6 | import androidx.compose.runtime.rememberCoroutineScope
7 | import androidx.datastore.preferences.core.Preferences
8 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.launch
11 | import me.wjz.nekocrypt.data.DataStoreManager
12 | import me.wjz.nekocrypt.data.LocalDataStoreManager
13 | import kotlin.reflect.KProperty
14 |
15 | class DataStoreStateDelegate(
16 | private val state: State,
17 | private val scope: CoroutineScope,
18 | private val saver: suspend (T) -> Unit
19 | ){
20 | /**
21 | * `operator fun getValue`
22 | * 当你读取属性时(如 `if (isChecked)`),Kotlin 会调用这个函数。
23 | * 我们只需返回内部 `State` 的当前值。
24 | */
25 | operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
26 | return state.value
27 | }
28 |
29 | /**
30 | * `operator fun setValue`
31 | * 当你写入属性时(如 `isChecked = false`),Kotlin 会调用这个函数。
32 | * 我们在这里启动一个协程,调用 `saver` lambda 将新值保存到 DataStore。
33 | */
34 | operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
35 | scope.launch {
36 | saver(value)
37 | }
38 | }
39 | }
40 |
41 | @Composable
42 | fun rememberDataStoreState(
43 | key: Preferences.Key,
44 | defaultValue: T
45 | ): DataStoreStateDelegate {
46 | // 1. 获取全局唯一的 DataStoreManager 和协程作用域
47 | val dataStoreManager: DataStoreManager = LocalDataStoreManager.current
48 | val scope: CoroutineScope = rememberCoroutineScope()
49 | //2. 从Flow里面拿数据并转化成Compose的State
50 | val state: State = dataStoreManager.getSettingFlow(key, defaultValue)
51 | .collectAsStateWithLifecycle(initialValue = defaultValue)
52 |
53 | // 用remember来创建并记住委托类实例
54 | return remember(dataStoreManager, scope, key) {
55 | DataStoreStateDelegate(
56 | state = state,
57 | scope = scope,
58 | saver = { newValue -> dataStoreManager.saveSetting(key, newValue) }
59 | )
60 | }
61 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/AccessibilityManager.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import android.accessibilityservice.AccessibilityService
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.provider.Settings
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.DisposableEffect
9 | import androidx.compose.runtime.State
10 | import androidx.compose.runtime.mutableStateOf
11 | import androidx.compose.runtime.remember
12 | import androidx.lifecycle.Lifecycle
13 | import androidx.lifecycle.LifecycleEventObserver
14 | import androidx.lifecycle.compose.LocalLifecycleOwner
15 | import me.wjz.nekocrypt.util.PermissionUtil.isAccessibilityServiceEnabled
16 |
17 | /**
18 | * 一个 Composable 函数,用于记住并监听无障碍服务的开启状态。
19 | * 当应用从后台返回前台时(例如,用户在设置页开启权限后返回),它会自动刷新状态。
20 | *
21 | * @param context 上下文环境。
22 | * @param serviceClass 你的无障碍服务的类名,例如:MyAccessibilityService::class.java。
23 | * @return 一个 State 对象,实时代表着服务是否开启。
24 | */
25 | @Composable
26 | fun rememberAccessibilityServiceState(
27 | context: Context,
28 | serviceClass: Class
29 | ): State {
30 | val accessibilityState= remember{ mutableStateOf(isAccessibilityServiceEnabled(context)) }
31 | // 2. 获取当前 Composable 的生命周期所有者
32 | val lifecycleOwner = LocalLifecycleOwner.current
33 | // 3. 使用 DisposableEffect 来添加和移除生命周期观察者,防止内存泄漏
34 | DisposableEffect(lifecycleOwner) {
35 | // 创建一个观察者
36 | val observer = LifecycleEventObserver { _, event ->
37 | // 当生命周期事件为 ON_RESUME (恢复) 时,说明界面回到了前台
38 | if (event == Lifecycle.Event.ON_RESUME) {
39 | // 重新检查一次无障碍权限的状态,并更新 state
40 | accessibilityState.value = isAccessibilityServiceEnabled(context)
41 | }
42 | }
43 |
44 | // 将观察者添加到生命周期中
45 | lifecycleOwner.lifecycle.addObserver(observer)
46 |
47 | // onDispose 会在 Composable 离开屏幕时被调用
48 | onDispose {
49 | // 从生命周期中移除观察者,避免内存泄漏
50 | lifecycleOwner.lifecycle.removeObserver(observer)
51 | }
52 | }
53 | // 4. 返回这个 state,UI 可以订阅它的变化
54 | return accessibilityState
55 | }
56 |
57 | /**
58 | * 创建一个意图(Intent)并跳转到系统的无障碍功能设置页面。
59 | *
60 | * @param context 上下文环境。
61 | */
62 | fun openAccessibilitySettings(context: Context) {
63 | val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
64 | // 确保在 Activity 栈外启动新的任务
65 | intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
66 | context.startActivity(intent)
67 | }
68 |
--------------------------------------------------------------------------------
/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 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/CryptoDownloader.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.withContext
5 | import okhttp3.OkHttpClient
6 | import okhttp3.Request
7 | import java.io.File
8 | import java.io.FilterInputStream
9 | import java.io.IOException
10 | import java.io.InputStream
11 | import java.util.concurrent.TimeUnit
12 |
13 | /**
14 | * 专门用于下载并解密文件的工具类
15 | */
16 | object CryptoDownloader {
17 | private val client = OkHttpClient.Builder()
18 | .connectTimeout(30, TimeUnit.SECONDS)
19 | .readTimeout(30, TimeUnit.SECONDS)
20 | .build()
21 |
22 |
23 | suspend fun download(
24 | fileInfo: NCFileProtocol,
25 | targetFile: File, // ✨ 接收一个目标文件
26 | onProgress: (Int) -> Unit,
27 | ): Result =
28 | withContext(Dispatchers.IO) {
29 | runCatching {
30 | val url = fileInfo.url
31 | val encryptionKey = fileInfo.encryptionKey
32 |
33 | val request = Request.Builder().url(url).build()
34 |
35 | // execute 会 suspend
36 | client.newCall(request).execute().use { response ->
37 | if (!response.isSuccessful) throw IOException("下载失败,响应码: ${response.code}")
38 |
39 | ProgressInputStream(response.body.byteStream()) { bytesRead ->
40 | // 这里处理下载回调
41 | val estimatedTotalRead = (bytesRead - CryptoUploader.SINGLE_PIXEL_GIF_BUFFER.size).coerceAtLeast(0)
42 | val progress = (estimatedTotalRead * 100 / fileInfo.size).toInt()
43 | onProgress(progress.coerceIn(0, 100))
44 |
45 | }.use { networkStream ->
46 | // 使用循环确保跳过完整的GIF头
47 | val skipSize = CryptoUploader.SINGLE_PIXEL_GIF_BUFFER.size.toLong()
48 | var skipped = 0L
49 | while (skipped < skipSize) {
50 | // 这里用while循环是因为这个skip可能会跳过少于预期的字节数量。
51 | val n = networkStream.skip(skipSize - skipped)
52 | if (n <= 0) throw IOException("无法跳过GIF头部,文件可能已损坏。")
53 | skipped += n
54 | }
55 | // 流式解密 && 下载
56 | targetFile.outputStream().use { outputStream ->
57 | CryptoManager.decryptStream(networkStream, outputStream, encryptionKey)
58 | }
59 | }
60 | }
61 | targetFile
62 | }
63 | }
64 | }
65 |
66 | /**
67 | * 用来追踪 InputStream读取进度的辅助类
68 | * 它通过包裹一个现有的 InputStream,来监听数据的读取过程。
69 | */
70 | class ProgressInputStream(
71 | inStream: InputStream,
72 | private val onProgress: (Long) -> Unit,
73 | ) : FilterInputStream(inStream) {
74 |
75 | private var bytesRead: Long = 0 // 已经读取的字节数
76 |
77 | /**
78 | * 只重写这一个 read 方法就足够了。
79 | * 因为单字节的 read() 在内部会自动调用这个方法,
80 | * 这样可以避免重复计算进度。
81 | */
82 | override fun read(b: ByteArray, off: Int, len: Int): Int {
83 | val read = super.read(b, off, len)
84 | if (read > 0) {
85 | bytesRead += read
86 | // 安全地调用监听器,报告当前的进度
87 | onProgress(bytesRead)
88 | }
89 | return read
90 | }
91 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/PermissionGuard.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import PermissionDialog
4 | import android.content.Intent
5 | import android.provider.Settings
6 | import androidx.activity.compose.rememberLauncherForActivityResult
7 | import androidx.activity.result.contract.ActivityResultContracts
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.outlined.Layers
10 | import androidx.compose.material.icons.outlined.SyncProblem
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.LaunchedEffect
13 | import androidx.compose.runtime.getValue
14 | import androidx.compose.runtime.mutableStateOf
15 | import androidx.compose.runtime.remember
16 | import androidx.compose.runtime.setValue
17 | import androidx.compose.ui.platform.LocalContext
18 | import androidx.core.net.toUri
19 | import me.wjz.nekocrypt.R
20 | import me.wjz.nekocrypt.ui.dialog.NCDialog
21 |
22 | @Composable
23 | fun PermissionGuard(content: @Composable () -> Unit) {
24 | val context = LocalContext.current
25 | var activeDialog by remember { mutableStateOf(null) }
26 |
27 | // 用于跳转到应用详细信息,方便设置权限
28 | val appDetailsLauncher = rememberLauncherForActivityResult(
29 | contract = ActivityResultContracts.StartActivityForResult()
30 | ) {}
31 | // 跳转到“显示在其他应用上层”权限设置页面
32 | val overlayPermissionLauncher = rememberLauncherForActivityResult(
33 | contract = ActivityResultContracts.StartActivityForResult()
34 | ) {
35 | if (!PermissionUtil.isOverlayPermissionGranted(context)) {
36 | // 弹出对话引导用户跳转到权限设置页面
37 | activeDialog = PermissionDialog(
38 | dialogIcon = Icons.Outlined.SyncProblem,
39 | dialogTitle = context.getString(R.string.permission_still_missing_title),
40 | dialogText = context.getString(R.string.permission_still_missing_text),
41 | onDismissRequest = { activeDialog = null },
42 | onConfirmRequest = {
43 | appDetailsLauncher.launch(
44 | Intent(
45 | Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
46 | "package:${context.packageName}".toUri()
47 | )
48 | )
49 | activeDialog = null
50 | }
51 | )
52 | }
53 | }
54 |
55 | // UI首次加载的时候做一次权限检查
56 | LaunchedEffect(Unit) {
57 | if (!PermissionUtil.isOverlayPermissionGranted(context)) {
58 | // 弹出对话引导用户跳转到权限设置页面
59 | activeDialog = PermissionDialog(
60 | dialogIcon = Icons.Outlined.Layers,
61 | dialogTitle = context.getString(R.string.permission_overlay_title),
62 | dialogText = context.getString(R.string.permission_overlay_text),
63 | onDismissRequest = { activeDialog = null },
64 | onConfirmRequest = {
65 | overlayPermissionLauncher.launch(
66 | Intent(
67 | Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
68 | "package:${context.packageName}".toUri()
69 | )
70 | )
71 | activeDialog = null
72 | }
73 |
74 | )
75 | }
76 | }
77 |
78 | content()// 渲染传入的页面
79 |
80 | activeDialog?.Content()// 根据权限状态拉起对话框。
81 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/Constant.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.datastore.preferences.core.booleanPreferencesKey
5 | import androidx.datastore.preferences.core.intPreferencesKey
6 | import androidx.datastore.preferences.core.longPreferencesKey
7 | import androidx.datastore.preferences.core.stringPreferencesKey
8 | import me.wjz.nekocrypt.service.handler.ChatAppHandler
9 | import me.wjz.nekocrypt.service.handler.QQHandler
10 | import me.wjz.nekocrypt.service.handler.WeChatHandler
11 |
12 | object Constant {
13 | const val APP_NAME = "NekoCrypt"
14 | const val DEFAULT_SECRET_KEY = "20040821"//You know what it means...
15 |
16 | // ---- 其他 ----
17 | const val EDIT_TEXT="EditText"
18 | const val VIEW_ID_BTN = "Button"
19 |
20 | // 扫描intent额外字段的key
21 | const val SCAN_RESULT = "scan_result"
22 | }
23 |
24 | object SettingKeys {
25 | val CURRENT_KEY = stringPreferencesKey("current_key")
26 | // 用 String 类型的 Key 来存储序列化后的密钥数组
27 | val ALL_THE_KEYS = stringPreferencesKey("all_the_keys")
28 | val USE_AUTO_ENCRYPTION = booleanPreferencesKey("use_auto_encryption")
29 | val USE_AUTO_DECRYPTION = booleanPreferencesKey("use_auto_decryption")
30 | val SCAN_BTN_ACTIVE = booleanPreferencesKey("scan_btn_active")
31 | val ENCRYPTION_MODE = stringPreferencesKey("encryption_mode")
32 | val DECRYPTION_MODE = stringPreferencesKey("decryption_mode")
33 | // 标准加密模式下,长按时间设置
34 | val ENCRYPTION_LONG_PRESS_DELAY = longPreferencesKey("encryption_long_press_delay")
35 | // 标准解密模式下,悬浮窗的显示时间设置
36 | val DECRYPTION_WINDOW_SHOW_TIME = longPreferencesKey("decryption_window_show_time")
37 | // 沉浸式解密下密文弹窗位置更新间隔
38 | val DECRYPTION_WINDOW_POSITION_UPDATE_DELAY = longPreferencesKey("decryption_window_position_update_delay")
39 | // 按钮遮罩的颜色
40 | val SEND_BTN_OVERLAY_COLOR = stringPreferencesKey("send_btn_overlay_color")
41 | // 控制弹出发送图片or文件视图的双击最大间隔时间
42 | val SHOW_ATTACHMENT_VIEW_DOUBLE_CLICK_THRESHOLD = longPreferencesKey("show_attachment_view_double_click_threshold")
43 | val CUSTOM_APPS = stringPreferencesKey("custom_apps")
44 | // 当前密文风格
45 | val CIPHERTEXT_STYLE = stringPreferencesKey("ciphertext_style")
46 | // 存储风格文本的最小和最大词语数
47 | val CIPHERTEXT_STYLE_LENGTH_MIN = intPreferencesKey("ciphertext_style_length_min")
48 | val CIPHERTEXT_STYLE_LENGTH_MAX = intPreferencesKey("ciphertext_style_length_max")
49 | }
50 |
51 | object CommonKeys {
52 | const val ENCRYPTION_MODE_STANDARD = "standard"
53 | const val ENCRYPTION_MODE_IMMERSIVE = "immersive"
54 | const val DECRYPTION_MODE_STANDARD = "standard"
55 | const val DECRYPTION_MODE_IMMERSIVE = "immersive"
56 | }
57 |
58 | object AppRegistry {
59 | /**
60 | * 包含所有受支持应用处理器实例的权威列表。
61 | * 未来要支持新的App,只需要在这里新增一行即可!
62 | * UI 和 Service 都会从这里读取信息。
63 | */
64 | val allHandlers: List = listOf(
65 | QQHandler(),
66 | WeChatHandler()
67 | // TelegramHandler(),
68 | // ... 以后在这里添加更多
69 | )
70 | }
71 |
72 | enum class CryptoMode(val key: String, @StringRes val labelResId: Int){
73 | STANDARD("standard", R.string.mode_standard),
74 | IMMERSIVE("immersive", R.string.mode_immersive);
75 |
76 | companion object {
77 | /**
78 | * 一个辅助函数,可以根据存储的 key 安全地找回对应的枚举实例。
79 | * 如果找不到,就返回一个默认值。
80 | */
81 | fun fromKey(key: String?): CryptoMode {
82 | // entries 是一个由编译器自动生成的属性,包含了枚举的所有实例
83 | return entries.find { it.key == key } ?: STANDARD
84 | }
85 | }
86 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.compose)
5 | id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22"
6 | id("kotlin-parcelize")
7 | }
8 |
9 | android {
10 | namespace = "me.wjz.nekocrypt"
11 | compileSdk = 35
12 |
13 | defaultConfig {
14 | applicationId = "me.wjz.nekocrypt"
15 | minSdk = 26
16 | targetSdk = 35
17 | versionCode = 13 // 唯一版本识别码,每次打包记得+1!!
18 | versionName = "1.4.0"
19 |
20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
21 |
22 | setProperty("archivesBaseName", "NekoCrypt-v$versionName")
23 | }
24 |
25 | buildTypes {
26 | release {
27 | isMinifyEnabled = true //开启代码压缩、混淆、优化
28 | isShrinkResources = true //删除代码中没有用到的资源
29 | // ✨ 指定混淆规则文件
30 | // proguard-android-optimize.txt 是安卓SDK自带的默认规则
31 | // proguard-rules.pro 是你项目里自己的规则文件,你可以添加不想被混淆的类
32 | proguardFiles(
33 | getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
34 | )
35 | }
36 | }
37 | compileOptions {
38 | sourceCompatibility = JavaVersion.VERSION_11
39 | targetCompatibility = JavaVersion.VERSION_11
40 | }
41 | kotlinOptions {
42 | jvmTarget = "11"
43 | }
44 | buildFeatures {
45 | compose = true
46 | }
47 | }
48 |
49 | dependencies {
50 |
51 | implementation(libs.androidx.core.ktx)
52 | implementation(libs.androidx.lifecycle.runtime.ktx)
53 | implementation(libs.androidx.activity.compose)
54 | implementation(platform(libs.androidx.compose.bom))
55 | implementation(libs.androidx.ui)
56 | implementation(libs.androidx.ui.graphics)
57 | implementation(libs.androidx.ui.tooling.preview)
58 | implementation(libs.androidx.material3)
59 | implementation("androidx.datastore:datastore-preferences:1.1.7")
60 | implementation("com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava")
61 | implementation("androidx.compose.material:material-icons-extended:1.7.8")
62 | // 用于 viewModel() 委托
63 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
64 |
65 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") // 包含 ViewTreeLifecycleOwner
66 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") // 包含 ViewTreeViewModelStoreOwner
67 | implementation("androidx.savedstate:savedstate-ktx:1.2.0") // 包含 ViewTreeSavedStateRegistryOwner
68 |
69 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")// json解析
70 | implementation("com.squareup.okhttp3:okhttp:5.1.0") //http
71 | implementation(libs.androidx.compiler)//安装preferences datastore 插件
72 |
73 | implementation("androidx.navigation:navigation-compose:2.9.1") // 导航
74 | implementation("androidx.activity:activity-compose:1.9.0") // 拉起系统相册要用
75 | implementation("io.coil-kt:coil-compose:2.6.0") // 显示图片预览
76 | implementation("com.google.accompanist:accompanist-drawablepainter:0.35.0-alpha")// 把Drawable 转换为 Compose 可用的 Painter
77 |
78 | testImplementation(libs.junit)
79 | androidTestImplementation(libs.androidx.junit)
80 | androidTestImplementation(libs.androidx.espresso.core)
81 | androidTestImplementation(platform(libs.androidx.compose.bom))
82 | androidTestImplementation(libs.androidx.ui.test.junit4)
83 | debugImplementation(libs.androidx.ui.tooling)
84 | debugImplementation(libs.androidx.ui.test.manifest)
85 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/service/KeepAliveService.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.service
2 |
3 | import android.app.Service
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.graphics.PixelFormat
7 | import android.os.IBinder
8 | import android.util.Log
9 | import android.view.Gravity
10 | import android.view.View
11 | import android.view.WindowManager
12 | import me.wjz.nekocrypt.NekoCryptApp
13 | import me.wjz.nekocrypt.util.NekoNotification
14 |
15 | /**
16 | * ✨ 一个专门用于保活的前台服务。
17 | * 它的唯一职责就是通过一个常驻通知,告诉系统我们的App正在运行重要任务。
18 | */
19 | class KeepAliveService : Service() {
20 | // 保活窗口
21 | private var keepAliveOverlay: View? = null
22 | private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }
23 | companion object {
24 | private const val TAG = NekoCryptApp.TAG
25 |
26 | // ✨ 提供一个标准的启动方法,方便外部调用
27 | fun start(context: Context) {
28 | val intent = Intent(context, KeepAliveService::class.java)
29 | context.startService(intent)
30 | }
31 |
32 | // ✨ 提供一个标准的停止方法
33 | fun stop(context: Context) {
34 | val intent = Intent(context, KeepAliveService::class.java)
35 | context.stopService(intent)
36 | }
37 | }
38 |
39 | private fun createKeepAliveOverlay() {
40 | if (keepAliveOverlay != null) return
41 | keepAliveOverlay = View(this)
42 | val layoutFlag = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
43 | val params = WindowManager.LayoutParams(
44 | 0, 0, 0, 0, layoutFlag,
45 | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
46 | PixelFormat.TRANSPARENT
47 | ).apply {
48 | gravity = Gravity.TOP or Gravity.START
49 | }
50 | try {
51 | windowManager.addView(keepAliveOverlay, params)
52 | Log.d(TAG, "“保活”悬浮窗创建成功!")
53 | } catch (e: Exception) {
54 | Log.e(TAG, "创建“保活”悬浮窗失败", e)
55 | }
56 | }
57 |
58 | private fun removeKeepAliveOverlay() {
59 | keepAliveOverlay?.let {
60 | try {
61 | windowManager.removeView(it)
62 | Log.d(TAG, "“保活”悬浮窗已移除。")
63 | } catch (e: Exception) {
64 | // 忽略窗口已经不存在等异常
65 | } finally {
66 | keepAliveOverlay = null
67 | }
68 | }
69 | }
70 |
71 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
72 | Log.d(TAG, "保活服务已启动。")
73 | // 1. 创建通知渠道(在Android 8.0及以上版本是必需的)
74 | NekoNotification.createChannel(this)
75 |
76 | // 2. 创建一个通知
77 | val notification = NekoNotification.build(this)
78 |
79 | // 3. ✨ 最关键的一步:将服务推到前台!
80 | // 第一个参数是一个唯一的通知ID,第二个参数是我们创建的通知。
81 | startForeground(NekoNotification.NEKO_NOTIFICATION_ID, notification)
82 | // 我们同时创一个保活悬浮窗
83 | createKeepAliveOverlay()
84 | // START_STICKY 表示如果服务被系统意外杀死,系统会尝试重新启动它
85 | return START_STICKY
86 | }
87 |
88 | override fun onDestroy() {
89 | super.onDestroy()
90 | removeKeepAliveOverlay()
91 | Log.d(TAG, "保活服务已销毁,保活悬浮窗已销毁。")
92 | stopForeground(true)
93 | }
94 |
95 | /**
96 | * ✨ 实现 onBind 方法。
97 | * 因为我们这是一个启动服务(Started Service),而不是绑定服务(Bound Service),
98 | * 所以我们不需要处理绑定逻辑,直接返回 null 即可。
99 | */
100 | override fun onBind(intent: Intent?): IBinder? {
101 | return null
102 | }
103 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/LifecycleOwnerProvider.kt:
--------------------------------------------------------------------------------
1 | // 文件路径: me/wjz/nekocrypt/util/LifecycleOwnerProvider.kt
2 | package me.wjz.nekocrypt.util
3 |
4 | import androidx.lifecycle.Lifecycle
5 | import androidx.lifecycle.LifecycleOwner
6 | import androidx.lifecycle.LifecycleRegistry
7 | import androidx.lifecycle.ViewModelStore
8 | import androidx.lifecycle.ViewModelStoreOwner
9 | import androidx.savedstate.SavedStateRegistry
10 | import androidx.savedstate.SavedStateRegistryController
11 | import androidx.savedstate.SavedStateRegistryOwner
12 | /**
13 | * 这是一个文档注释,用来解释这个类的作用。
14 | * 它是一个实现了所有生命周期相关接口的“便携式电源包”。
15 | * 它可以为任何没有自带生命周期的View(比如添加到WindowManager的View)提供一个完整的、可控的生命周期。
16 | */
17 | // --- 类声明 ---
18 | // 定义一个名为 LifecycleOwnerProvider 的类。
19 | // 它通过冒号":"实现了三个重要的接口,意味着它承诺会提供这三个接口所要求的所有功能。
20 | class LifecycleOwnerProvider : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
21 |
22 | // --- “发电机”部分:提供 Lifecycle ---
23 |
24 | // 创建一个 LifecycleRegistry 实例。这是真正管理生命周期状态(CREATED, STARTED, RESUMED...)的核心引擎。
25 | // `this` 指的是 LifecycleOwnerProvider 本身,因为它实现了 LifecycleOwner 接口。
26 | private val lifecycleRegistry = LifecycleRegistry(this)
27 |
28 | // 这是对 LifecycleOwner 接口的实现。当外部需要一个 Lifecycle 对象时,我们把内部的 lifecycleRegistry 提供给它。
29 | // `override` 关键字表示我们正在重写父接口的方法/属性。
30 | override val lifecycle: Lifecycle get() = lifecycleRegistry
31 |
32 | // --- “遥控器中枢”部分:提供 ViewModelStore ---
33 |
34 | // 创建一个 ViewModelStore 实例。这是真正存放所有 ViewModel 的“容器”或“仓库”。
35 | // `_viewModelStore` 的下划线是Kotlin的惯例,表示这是一个私有的、用于支持公开属性的“幕后字段”。
36 | private val _viewModelStore = ViewModelStore()
37 |
38 | // 这是对 ViewModelStoreOwner 接口的实现。它对外提供一个只读的 viewModelStore。
39 | // 当外部代码(比如ViewModelProvider)需要存储ViewModel时,就会访问这个属性。
40 | override val viewModelStore: ViewModelStore get() = _viewModelStore
41 |
42 | // --- “信号源”部分:提供 SavedStateRegistry ---
43 |
44 | // 创建一个 SavedStateRegistryController 实例,它是管理状态保存和恢复的“总控制器”。
45 | // `SavedStateRegistryController.create(this)` 将这个控制器与我们这个 Owner 绑定。
46 | private val savedStateRegistryController = SavedStateRegistryController.create(this)
47 |
48 | // 这是对 SavedStateRegistryOwner 接口的实现。它对外提供一个用于注册和读取状态的 SavedStateRegistry。
49 | override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
50 |
51 | /**
52 | * 初始化“电源包”,把它和自己的各个部件连接起来。
53 | */
54 | // `init` 块是类的构造函数的一部分。当一个 LifecycleOwnerProvider 实例被创建时,这里的代码会立刻执行。
55 | init {
56 | // 调用控制器的 performRestore 方法,尝试从之前保存的状态中恢复数据。
57 | // 在我们这个场景下,因为是凭空创建,所以通常没有状态可恢复,传入 `null` 即可。
58 | // 这是完成初始化所必需的步骤。
59 | savedStateRegistryController.performRestore(null)
60 | }
61 |
62 | /**
63 | * 手动“开机”!将生命周期推进到 RESUMED 状态。
64 | */
65 | // 这是一个我们自己定义的公共方法,用于手动启动生命周期。
66 | fun resume() {
67 | // 通过 handleLifecycleEvent 方法,手动将生命周期状态依次推进。
68 | // 这三行代码模拟了一个Activity/Fragment从创建到完全可见的完整过程。
69 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
70 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
71 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
72 | }
73 |
74 | /**
75 | * 手动“关机”!将生命周期推进到 DESTROYED 状态,并清理所有资源。
76 | */
77 | // 这是一个我们自己定义的公共方法,用于手动销毁生命周期并释放资源。
78 | fun destroy() {
79 | // 将生命周期状态反向推进,模拟一个Activity/Fragment从可见到完全销毁的过程。
80 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
81 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
82 | lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
83 |
84 | // 这是至关重要的一步!当生命周期结束时,清空 ViewModelStore 中的所有 ViewModel。
85 | // 如果没有这一步,所有创建的 ViewModel 都会永远留在内存中,造成严重的内存泄漏。
86 | _viewModelStore.clear()
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
34 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
51 |
52 |
59 |
60 |
65 |
66 |
67 |
68 |
71 |
72 |
73 |
78 |
79 |
84 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/MainMenu.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.foundation.layout.size
6 | import androidx.compose.foundation.pager.HorizontalPager
7 | import androidx.compose.foundation.pager.rememberPagerState
8 | import androidx.compose.material3.ExperimentalMaterial3Api
9 | import androidx.compose.material3.Icon
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.NavigationBar
12 | import androidx.compose.material3.NavigationBarItem
13 | import androidx.compose.material3.NavigationBarItemDefaults
14 | import androidx.compose.material3.Scaffold
15 | import androidx.compose.material3.Text
16 | import androidx.compose.material3.TopAppBar
17 | import androidx.compose.material3.TopAppBarDefaults
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.remember
20 | import androidx.compose.runtime.rememberCoroutineScope
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.res.stringResource
23 | import androidx.compose.ui.unit.dp
24 | import kotlinx.coroutines.launch
25 | import me.wjz.nekocrypt.R
26 | import me.wjz.nekocrypt.ui.screen.Screen
27 |
28 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
29 | @Composable
30 | fun MainMenu() {
31 | val navItems = remember { Screen.allScreens } // 所有的屏幕
32 | // 创建一个 PagerState,记住当前页面索引
33 | //pagerState 是 Jetpack Compose 中用于控制和观察
34 | //HorizontalPager 或 VerticalPager 状态的对象。
35 | val pagerState = rememberPagerState(pageCount = { navItems.size })
36 | // 用自己的协程作用域
37 | val scope = rememberCoroutineScope()
38 |
39 | Scaffold(
40 | topBar = {
41 | TopAppBar(
42 | title = { Text(text = stringResource(id = R.string.app_name)) },
43 | colors = TopAppBarDefaults.topAppBarColors(
44 | containerColor = MaterialTheme.colorScheme.primaryContainer,
45 | titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
46 | )
47 | )
48 | },
49 | bottomBar = {
50 | NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainer) {
51 | // ✨ 关键修正 1: 遍历时需要索引
52 | navItems.forEachIndexed { index, screen ->
53 | NavigationBarItem(
54 | icon = {
55 | Icon(
56 | imageVector = screen.icon,
57 | contentDescription = stringResource(id = screen.titleResId),
58 | modifier = Modifier.size(28.dp)
59 | )
60 | },
61 | label = { Text(stringResource(id = screen.titleResId)) },
62 | // ✨ 核心二:直接从 pagerState 读取当前页面,不再需要 selectedTabIndex
63 | selected = (index == pagerState.currentPage),
64 | onClick = {
65 | scope.launch {
66 | pagerState.animateScrollToPage(index)
67 | }
68 | },
69 | colors = NavigationBarItemDefaults.colors(
70 | indicatorColor = MaterialTheme.colorScheme.secondaryContainer
71 | )
72 | )
73 | }
74 | }
75 | },
76 | // 暂时不要悬浮按钮
77 |
78 | // floatingActionButton =
79 | // {
80 | // FloatingActionButton(
81 | // onClick = { },
82 | // containerColor = MaterialTheme.colorScheme.primary,
83 | // contentColor = MaterialTheme.colorScheme.onPrimary
84 | // ) {
85 | // Icon(Icons.Default.Add, contentDescription = "Add")
86 | // }
87 | // }
88 | )
89 | { innerPadding ->
90 | HorizontalPager(
91 | state = pagerState,
92 | modifier = Modifier.padding(innerPadding),
93 | // key 的作用是帮助 Compose 识别每个页面的唯一性,提高性能
94 | key = { index -> navItems[index].route }
95 | ) { pageIndex ->
96 | // 直接根据 Pager 提供的页面索引,从列表里找到对应的 Screen 对象,
97 | // 然后调用它的 content() 方法来显示界面。
98 | navItems[pageIndex].content()
99 | }
100 | }
101 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/activity/ScannerActivity.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.activity
2 |
3 | import android.os.Build
4 | import android.os.Bundle
5 | import android.os.Parcelable
6 | import android.widget.Toast
7 | import androidx.activity.ComponentActivity
8 | import androidx.activity.compose.setContent
9 | import androidx.lifecycle.lifecycleScope
10 | import kotlinx.coroutines.launch
11 | import kotlinx.parcelize.Parcelize
12 | import me.wjz.nekocrypt.Constant.SCAN_RESULT
13 | import me.wjz.nekocrypt.NekoCryptApp
14 | import me.wjz.nekocrypt.R
15 | import me.wjz.nekocrypt.service.handler.CustomAppHandler
16 | import me.wjz.nekocrypt.ui.dialog.ScannerDialog
17 | import me.wjz.nekocrypt.ui.theme.NekoCryptTheme
18 |
19 |
20 | /**
21 | * 一个用于封装单个被找到的节点信息的数据类。
22 | * @param className 节点的类名 (e.g., "android.widget.EditText")。
23 | * @param resourceId 节点的资源 ID (e.g., "com.tencent.mm:id/input_editor"),可能为空。
24 | * @param text 节点的文本内容,可能为空。
25 | * @param contentDescription 节点的内容描述(常用于无障碍),可能为空。
26 | */
27 | @Parcelize
28 | data class FoundNodeInfo(
29 | val className: String,
30 | val resourceId: String?,
31 | val text: String?,
32 | val contentDescription: String?,
33 | ) : Parcelable
34 |
35 | /**
36 | * ✨ 全新:用于封装单个消息列表及其内部消息文本的数据类。
37 | * 这就是我们的“房子和居民”情报。
38 | * @param listContainerInfo 消息列表容器节点本身的信息。
39 | * @param messageTexts 在这个容器内部找到的所有消息文本节点列表。
40 | */
41 | @Parcelize
42 | data class MessageListScanResult(
43 | val listContainerInfo: FoundNodeInfo,
44 | val messageTexts: List
45 | ) : Parcelable
46 |
47 | /**
48 | * ✨ 升级版:用于封装扫描结果的数据类。
49 | * @param packageName 当前应用的包名。
50 | * @param name 当前应用的可读名称 (e.g., "xx聊天")。
51 | * @param foundInputNodes 扫描到的所有可能的输入框节点列表。
52 | * @param foundSendBtnNodes 扫描到的所有可能的发送按钮节点列表。
53 | * @param foundMessageLists 扫描到的所有消息列表及其内部消息的集合。
54 | */
55 | @Parcelize
56 | data class ScanResult(
57 | val packageName: String,
58 | val name: String,
59 | val foundInputNodes: List,
60 | val foundSendBtnNodes: List,
61 | val foundMessageLists: List, // ✨ 结构变更
62 | ) : Parcelable
63 |
64 |
65 | class ScannerDialogActivity: ComponentActivity() {
66 | override fun onCreate(savedInstanceState: Bundle?) {
67 | super.onCreate(savedInstanceState)
68 |
69 | val dataStoreManager = (application as NekoCryptApp).dataStoreManager
70 |
71 | // ✨ 核心魔法:从送来的“快递盒”(Intent)中,把名叫"scan_result"的“包裹”取出来
72 | val scanResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
73 | // 对于 Android 13 (API 33) 及以上版本,使用新的、类型安全的方法
74 | // 我们需要明确告诉系统,我们想取出来的是一个 ScanResult 类型的包裹
75 | intent.getParcelableExtra(SCAN_RESULT, ScanResult::class.java)
76 | } else {
77 | // 对于旧版本,使用传统的方法
78 | @Suppress("DEPRECATION") // 告诉编译器,我们知道这个方法过时了,但为了兼容性还是要用
79 | intent.getParcelableExtra(SCAN_RESULT) // 这里保留一下类型指定?看日志似乎是类型不确定导致的崩溃
80 | }
81 |
82 | if(scanResult == null){
83 | //
84 | Toast.makeText(this, getString(R.string.scanner_get_result_fail), Toast.LENGTH_SHORT).show()
85 | finish()
86 | return
87 | }
88 |
89 | setContent {
90 | NekoCryptTheme {
91 |
92 | // 在这里显示我们的对话框
93 | // 当对话框请求关闭时,我们直接结束这个透明的 Activity
94 | ScannerDialog(scanResult,onDismissRequest = { finish() }, onConfirm ={ scanSelections,scanResult ->
95 | lifecycleScope.launch {
96 | val newHandler = CustomAppHandler(
97 | packageName = scanResult.packageName,
98 | inputId = scanSelections.inputNode.resourceId ?: "",
99 | sendBtnId = scanSelections.sendBtnNode.resourceId ?: "",
100 | messageTextId = scanSelections.messageText.resourceId ?: "",
101 | messageListClassName = scanSelections.messageList.className
102 | )
103 |
104 | dataStoreManager.addCustomApp(newHandler)
105 | // 3. 给出成功提示并关闭窗口
106 | Toast.makeText(
107 | this@ScannerDialogActivity,
108 | getString(R.string.scanner_config_saved_toast),
109 | Toast.LENGTH_SHORT
110 | ).show()
111 | finish()
112 | }
113 | })
114 | }
115 | }
116 | }
117 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/dialog/AppHandlerInfoDialog.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.dialog
2 |
3 | import android.widget.Toast
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.layout.width
12 | import androidx.compose.foundation.shape.RoundedCornerShape
13 | import androidx.compose.material3.AlertDialog
14 | import androidx.compose.material3.Button
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.Surface
17 | import androidx.compose.material3.Text
18 | import androidx.compose.material3.TextButton
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.platform.LocalClipboardManager
23 | import androidx.compose.ui.platform.LocalContext
24 | import androidx.compose.ui.res.stringResource
25 | import androidx.compose.ui.text.AnnotatedString
26 | import androidx.compose.ui.text.font.FontFamily
27 | import androidx.compose.ui.text.font.FontWeight
28 | import androidx.compose.ui.unit.dp
29 | import androidx.compose.ui.unit.sp
30 | import me.wjz.nekocrypt.R
31 | import me.wjz.nekocrypt.service.handler.ChatAppHandler
32 |
33 |
34 | @Composable
35 | fun AppHandlerInfoDialog(
36 | appName:String,
37 | handler: ChatAppHandler,
38 | onDismissRequest: () -> Unit,
39 | onDeleteRequest: (() -> Unit)? = null
40 | ){
41 | AlertDialog(
42 | onDismissRequest = onDismissRequest,
43 | // 标题
44 | title = { Text(text = "${appName} - 配置详情") },
45 | // 内容
46 | text = {
47 | Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
48 | InfoRow(label = stringResource(R.string.key_screen_supported_app_input_id), value = handler.inputId)
49 | InfoRow(label = stringResource(R.string.key_screen_supported_app_send_btn_id), value = handler.sendBtnId)
50 | InfoRow(label = stringResource(R.string.key_screen_supported_app_message_text_id), value = handler.messageTextId)
51 | InfoRow(label = stringResource(R.string.key_screen_supported_app_message_list_class_name), value = handler.messageListClassName)
52 | }
53 | },
54 | // 确认按钮
55 | confirmButton = {
56 | Row(
57 | modifier = Modifier.fillMaxWidth(),
58 | horizontalArrangement = Arrangement.End
59 | ) {
60 | // 只有当 onDeleteRequest 被提供时,才显示删除按钮
61 | if (onDeleteRequest != null) {
62 | Button(
63 | onClick = {
64 | // 先执行删除操作,然后关闭对话框
65 | onDeleteRequest()
66 | onDismissRequest()
67 | }
68 | ) {
69 | Text(
70 | stringResource(R.string.delete)
71 | )
72 | }
73 | }
74 | // 关闭按钮
75 | TextButton(onClick = onDismissRequest) {
76 | Text(stringResource(R.string.cancel)) // 将“取消”改为“关闭”,语义更清晰
77 | }
78 | }
79 | }
80 | )
81 | }
82 |
83 | @Composable
84 | private fun InfoRow(label: String, value: String) {
85 | val clipboardManager = LocalClipboardManager.current
86 | val context = LocalContext.current
87 | val hasCopyHint = stringResource(R.string.has_copy)
88 | Column {
89 | // 标签
90 | Text(
91 | text = label,
92 | fontWeight = FontWeight.ExtraBold,
93 | fontSize = 16.sp,
94 | style = MaterialTheme.typography.labelMedium,
95 | color = MaterialTheme.colorScheme.primary,
96 | modifier = Modifier.padding(start = 12.dp)
97 | )
98 | Spacer(modifier = Modifier.height(4.dp))
99 | // 内容和复制按钮
100 | Row(
101 | modifier = Modifier.fillMaxWidth(),
102 | verticalAlignment = Alignment.CenterVertically
103 | ) {
104 | // 用 Surface 包裹,创造代码块效果
105 | Surface(
106 | onClick = {
107 | if(value.isNotEmpty()){
108 | clipboardManager.setText(AnnotatedString(value))
109 | // 显示一个短暂的提示
110 | Toast.makeText(context, hasCopyHint, Toast.LENGTH_SHORT).show()
111 | }
112 | },
113 | modifier = Modifier.weight(1f),
114 | shape = RoundedCornerShape(8.dp),
115 | color = MaterialTheme.colorScheme.surface,
116 | tonalElevation = 4.dp, // 增加一点色调深度
117 | ) {
118 | Text(
119 | text = value.ifEmpty { "N/A" }, // 如果值为空,显示 N/A
120 | style = MaterialTheme.typography.bodyMedium,
121 | fontFamily = FontFamily.Monospace, // ✨ 使用等宽字体!
122 | modifier = Modifier
123 | .fillMaxWidth()
124 | .padding(horizontal = 12.dp, vertical = 8.dp)
125 | )
126 | }
127 | Spacer(modifier = Modifier.width(8.dp))
128 | }
129 | }
130 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/data/DataStoreManager.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.data
2 |
3 | import android.content.Context
4 | import android.util.Log
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.State
7 | import androidx.compose.runtime.collectAsState
8 | import androidx.compose.runtime.staticCompositionLocalOf
9 | import androidx.datastore.core.DataStore
10 | import androidx.datastore.preferences.core.Preferences
11 | import androidx.datastore.preferences.core.edit
12 | import androidx.datastore.preferences.preferencesDataStore
13 | import kotlinx.coroutines.flow.Flow
14 | import kotlinx.coroutines.flow.catch
15 | import kotlinx.coroutines.flow.first
16 | import kotlinx.coroutines.flow.map
17 | import kotlinx.serialization.encodeToString
18 | import kotlinx.serialization.json.Json
19 | import me.wjz.nekocrypt.Constant
20 | import me.wjz.nekocrypt.SettingKeys
21 | import me.wjz.nekocrypt.service.handler.CustomAppHandler
22 |
23 | val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
24 |
25 | //建立一个LocalDataStoreManager的CompositionLocal,专门给ComposeUI用的
26 | val LocalDataStoreManager = staticCompositionLocalOf {
27 | error("No DataStoreManager provided")
28 | }
29 |
30 | /**
31 | * ✨ [新增] 一个专门用于在Compose上下文中,以State的形式订阅密钥数组变化的Hook。
32 | *
33 | * @param initialValue 当Flow还在加载时的初始默认值。
34 | * @return 一个 State> 对象,它的 .value 会随着DataStore的变化而自动更新。
35 | */
36 | @Composable
37 | fun rememberKeyArrayState(initialValue: Array = emptyArray()): State> {
38 | val dataStoreManager = LocalDataStoreManager.current
39 | return dataStoreManager.getKeyArrayFlow().collectAsState(initial = initialValue)
40 | }
41 |
42 |
43 | /**
44 | * ✨ [新增] 一个专门用于在Compose上下文中,以State的形式订阅customApp变化的Hook。
45 | *
46 | * @param initialValue 当Flow还在加载时的初始默认值。
47 | * @return 一个 State> 对象,它的 .value 会随着DataStore的变化而自动更新。
48 | */
49 | @Composable
50 | fun rememberCustomAppListState(initialValue: List = emptyList()): State> {
51 | val dataStoreManager = LocalDataStoreManager.current
52 | return dataStoreManager.getCustomAppsFlow().collectAsState(initial = initialValue)
53 | }
54 |
55 |
56 | class DataStoreManager(private val context: Context) {
57 |
58 | //通用的读取方法 (使用泛型)
59 | fun getSettingFlow(key: Preferences.Key, defaultValue: T): Flow {
60 | return context.dataStore.data.map { preferences ->
61 | preferences[key] ?: defaultValue
62 | }.catch { exception -> throw exception }
63 | }
64 |
65 | //提供一个一次性的读取方法
66 | suspend fun readSetting(key: Preferences.Key, defaultValue: T): T {
67 | // .first() 是一个来自 kotlinx-coroutines-core 的魔法,
68 | // 它会等待 Flow 发射第一个值,然后就返回,不再继续监听。
69 | return getSettingFlow(key, defaultValue).first()
70 | }
71 |
72 | //通用的写入方法
73 | suspend fun saveSetting(key: Preferences.Key, value: T) {
74 | context.dataStore.edit { preferences -> preferences[key] = value }
75 | }
76 |
77 | /**
78 | * (可选) 通用的清除单个设置的方法
79 | */
80 | suspend fun clearSetting(key: Preferences.Key) {
81 | context.dataStore.edit { preferences -> preferences.remove(key) }
82 | }
83 |
84 | /**
85 | * (可选) 清除所有设置的方法
86 | */
87 | suspend fun clearAllSettings() {
88 | context.dataStore.edit { preferences -> preferences.clear() }
89 | }
90 |
91 | /**
92 | * 保存密钥数组。
93 | * 调用者只需要传入一个数组,无需关心JSON转换的细节。
94 | */
95 | suspend fun saveKeyArray(keys: Array) {
96 | val jsonString = Json.encodeToString(keys)
97 | saveSetting(SettingKeys.ALL_THE_KEYS, jsonString)
98 | }
99 |
100 | /**
101 | * 获取密钥数组,用于后台的上下文。
102 | */
103 | fun getKeyArrayFlow(): Flow> {
104 | return getSettingFlow(SettingKeys.ALL_THE_KEYS, "[]").map { jsonString ->
105 | if (jsonString.isEmpty()) arrayOf(Constant.DEFAULT_SECRET_KEY)
106 | else {
107 | try {
108 | val keys = Json.decodeFromString>(jsonString)
109 | if (keys.isEmpty()) arrayOf(Constant.DEFAULT_SECRET_KEY) else keys
110 | } catch (e: Exception) {
111 | Log.e("Neko", "解析密钥数组失败!", e)
112 | arrayOf(Constant.DEFAULT_SECRET_KEY) //解析失败返回默认值
113 | }
114 | }
115 | }
116 | }
117 |
118 | /**
119 | * 保存自定义应用列表。
120 | * 追加形式保存
121 | */
122 | suspend fun addCustomApp(newApp: CustomAppHandler) {
123 | // 1. 读取当前的列表
124 | val currentApps = getCustomAppsFlow().first().toMutableList()
125 | // 2. 添加新的配置
126 | currentApps.add(newApp)
127 | // 3. 将更新后的列表序列化成 JSON 字符串
128 | val jsonString = Json.encodeToString(currentApps)
129 | // 4. 保存回 DataStore
130 | saveSetting(SettingKeys.CUSTOM_APPS, jsonString)
131 | }
132 |
133 | /**
134 | * 删除包名对应的自定义handler
135 | */
136 | suspend fun deleteCustomApp(packageName:String){
137 | val currentApps = getCustomAppsFlow().first().toMutableList()
138 | currentApps.removeAll { it.packageName == packageName }
139 | val jsonString = Json.encodeToString(currentApps)
140 | saveSetting(SettingKeys.CUSTOM_APPS, jsonString)
141 | }
142 |
143 | /**
144 | * ✨ 新增:获取自定义应用列表的 Flow。
145 | * 它从 DataStore 读取JSON字符串,并将其反序列化为 CustomAppHandler 列表。
146 | * 如果解析失败或没有数据,返回一个空列表。
147 | */
148 | fun getCustomAppsFlow(): Flow> {
149 | // "[]" 是一个空的JSON数组,作为安全的默认值
150 | return getSettingFlow(SettingKeys.CUSTOM_APPS, "[]").map { jsonString ->
151 | try {
152 | Json.decodeFromString>(jsonString)
153 | } catch (e: Exception) {
154 | Log.e("NekoCrypt", "解析自定义应用列表失败!", e)
155 | emptyList() // 解析失败时返回空列表
156 | }
157 | }
158 | }
159 |
160 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/helper.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import android.content.Context
4 | import android.graphics.BitmapFactory
5 | import android.net.Uri
6 | import android.provider.OpenableColumns
7 | import android.view.accessibility.AccessibilityNodeInfo
8 | import androidx.compose.ui.graphics.Color
9 | import androidx.core.content.FileProvider
10 | import me.wjz.nekocrypt.NekoCryptApp
11 | import java.io.File
12 | import java.io.IOException
13 | import java.util.Locale
14 | import kotlin.math.log10
15 | import kotlin.math.pow
16 |
17 | // 取反色
18 | fun Color.inverse(): Color {
19 | return Color(
20 | red = 1.0f - this.red,
21 | green = 1.0f - this.green,
22 | blue = 1.0f - this.blue,
23 | alpha = this.alpha
24 | )
25 | }
26 |
27 | /**
28 | * 根据uri查询文件大小。-1表示未知。
29 | */
30 | fun getFileSize(uri: Uri): Long {
31 | NekoCryptApp.instance.contentResolver.query(
32 | uri,
33 | null, // ④ projection = null → 返回所有列
34 | null, null, null // ⑤ selection/args/sortOrder 均不需要
35 | )?.use {
36 | if (it.moveToFirst()) {
37 | val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
38 | return if (it.isNull(sizeIndex)) -1 else it.getLong(sizeIndex)
39 | }
40 | }
41 | return -1
42 | }
43 |
44 | /**
45 | * 获取文件名
46 | */
47 | fun getFileName(uri: Uri): String {
48 | var fileName = "unknown"
49 | NekoCryptApp.instance.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
50 | if (cursor.moveToFirst()) {
51 | val displayNameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
52 | if (displayNameColumn != -1) { // 检查 DISPLAY_NAME 列是否存在
53 | fileName = cursor.getString(displayNameColumn)
54 | }
55 | }
56 | }
57 | return fileName
58 | }
59 |
60 | /**
61 | * 检查给定的包名是否属于系统应用。
62 | * @param packageName 需要检查的应用包名。
63 | * @return 如果是系统应用或核心应用,则返回 true,否则返回 false。
64 | */
65 | fun isSystemApp(packageName: String?): Boolean {
66 | if (packageName.isNullOrBlank()) {
67 | return false
68 | }
69 | // 相册属于前者,文件选择器属于后者。
70 | return packageName.startsWith("com.android.providers") || packageName.startsWith("com.google.android")
71 | }
72 |
73 | /**
74 | * 格式化文件大小,入参单位为bytes
75 | */
76 | fun Long.formatFileSize(): String {
77 | if (this <= 0) return "0 B"
78 | val units = arrayOf("B", "KB", "MB", "GB", "TB")
79 | val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt()
80 | return String.format(
81 | Locale.US,
82 | "%.1f %s",
83 | this / 1024.0.pow(digitGroups.toDouble()),
84 | units[digitGroups]
85 | )
86 | }
87 |
88 | // 定义图片文件的常见扩展名
89 | val imageExtensions =
90 | setOf("jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "svg", "tiff", "psdng")
91 |
92 | fun isFileImage(uri: Uri): Boolean {
93 | val fileName = getFileName(uri)
94 | // 获取文件的扩展名
95 | val extension = fileName.substringAfterLast(".").lowercase()
96 | // 检查扩展名是否在图片扩展名集合中
97 | return imageExtensions.contains(extension)
98 | }
99 |
100 | private val IMAGE_HEADERS = setOf(
101 | "FFD8", // JPEG
102 | "89504E47", // PNG
103 | "47494638", // GIF
104 | "49492A00", "4D4D002A", // TIFF 两种字节序
105 | "424D", // BMP
106 | "52494646", // WEBP 的前 4 字节
107 | "000000" // ICO
108 | )
109 |
110 | fun ByteArray.isImage(): Boolean {
111 | if (isEmpty()) return false
112 | // 前 8 字节足够覆盖上面所有魔数
113 | val prefix = take(8)
114 | .joinToString("") { "%02X".format(it) }
115 | return IMAGE_HEADERS.any { prefix.startsWith(it) }
116 | }
117 |
118 | /**
119 | * ✨ [新增] 专门获取图片宽高比的辅助函数
120 | * 它只解码图片的边界信息,不加载整个图片到内存,因此非常高效。
121 | * @param uri 图片的Uri
122 | * @return Float类型的宽高比,如果无法获取则返回null
123 | */
124 | fun getImageAspectRatio(uri: Uri): Float? {
125 | val currentService = NekoCryptApp.instance
126 | return try {
127 | // 使用 contentResolver 打开输入流
128 | currentService.contentResolver.openInputStream(uri)?.use { inputStream ->
129 | // 创建一个 BitmapFactory.Options 对象
130 | val options = BitmapFactory.Options().apply {
131 | // 设置 inJustDecodeBounds = true 是关键!
132 | // 这会告诉解码器只解析图片的元数据(包括尺寸),而不真正加载像素数据到内存。
133 | inJustDecodeBounds = true
134 | }
135 | // 执行解码操作(实际上只解码了边界)
136 | BitmapFactory.decodeStream(inputStream, null, options)
137 |
138 | // 检查是否成功获取了有效的宽度和高度
139 | if (options.outWidth > 0 && options.outHeight > 0) {
140 | // 计算并返回宽高比
141 | options.outWidth.toFloat() / options.outHeight.toFloat()
142 | } else {
143 | // 如果尺寸无效,返回null
144 | null
145 | }
146 | }
147 | } catch (e: IOException) {
148 | print(e.stackTraceToString())
149 | null
150 | }
151 | }
152 |
153 | /**
154 | * 判断节点是否为空,为空依据:无子节点|无包名|无viewIdResourceName
155 | */
156 | fun AccessibilityNodeInfo.isEmpty(): Boolean {
157 | return this.packageName == null || this.viewIdResourceName == null
158 | }
159 |
160 | // 获取文件缓存
161 | fun getCacheFileFor(context: Context, fileInfo: NCFileProtocol): File{
162 | // 总是用外部缓存,方便分享之类的操作
163 | val baseDir = context.externalCacheDir ?: context.cacheDir
164 | val downloadDir = File(baseDir,"download").apply { mkdirs() }
165 | // 用唯一文件名,避免重名之类的
166 | val fileName = fileInfo.name.let { name ->
167 | val dot = name.lastIndexOf('.')
168 | if (dot == -1) "${name}-${fileInfo.url.hashCode()}"
169 | else "${name.substring(0, dot)}-${fileInfo.url.hashCode()}${name.substring(dot)}"
170 | }
171 | return File(downloadDir,fileName)
172 | }
173 |
174 | // 根据File拿Uri
175 | fun getUriForFile(context: Context, file: File):Uri{
176 | return FileProvider.getUriForFile(
177 | context,
178 | "${context.packageName}.provider", // 这个地方的authority一定要和manifest里面配置的一样
179 | file
180 | )
181 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/activity/AttachmentPickerActivity.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.activity
2 |
3 | import android.content.Intent
4 | import android.net.Uri
5 | import android.os.Bundle
6 | import android.util.Log
7 | import android.view.WindowManager
8 | import androidx.activity.ComponentActivity
9 | import androidx.activity.result.ActivityResultLauncher
10 | import androidx.activity.result.PickVisualMediaRequest
11 | import androidx.activity.result.contract.ActivityResultContracts
12 | import androidx.lifecycle.lifecycleScope
13 | import kotlinx.coroutines.delay
14 | import kotlinx.coroutines.launch
15 | import me.wjz.nekocrypt.util.ResultRelay
16 | import java.io.File
17 | import java.io.IOException
18 |
19 | class AttachmentPickerActivity : ComponentActivity() {
20 | private val tag = "AttachmentPickerActivity"
21 |
22 | companion object {
23 | const val EXTRA_PICK_TYPE = "pick_type"
24 | const val TYPE_MEDIA = "media" // 图+视频
25 | const val TYPE_FILE = "file" // 任意文件
26 | }
27 |
28 | private lateinit var mediaPicker: ActivityResultLauncher
29 | private lateinit var filePicker: ActivityResultLauncher
30 |
31 | override fun onCreate(savedInstanceState: Bundle?) {
32 | super.onCreate(savedInstanceState)
33 | // 必须不能抢占焦点,否则handler检测到不是目标应用界面就会杀掉自己
34 | window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
35 |
36 | /**
37 | * 这里的逻辑演进说明:
38 | * 1. 最初方案是直接返回用户选择的Uri。这在文件较小时可行,因为当时的做法是立刻将文件完整读入内存。
39 | * 2. 为了支持大文件并避免内存溢出,我们改用了流式上传。但流式读取过程较慢。
40 | * 3. 这就暴露了安卓的临时Uri权限问题:当Activity在返回Uri后立刻finish(),它获得的临时访问权限很快就会失效。
41 | * 导致后台的Service在稍后进行流式读取时,会因为权限丢失而失败 (SecurityException)。
42 | * 4. 因此,最终方案是:在本Activity中,趁着临时权限还生效,立刻将文件复制一份到我们App自己的私有缓存目录。
43 | * 然后返回这个缓存文件的、我们拥有永久访问权的Uri。这样后台服务就可以随时、安全地进行流式读取了。
44 | */
45 | val onResult = { uri: Uri? ->
46 | if (uri != null) {
47 | // 当拿到结果时,在IO线程中复制一份文件,然后发回我们复制出来的文件的uri。
48 | // lifecycleScope.launch(Dispatchers.IO) {
49 | // try {
50 | // val newCacheUri = copyFileToCache(uri)
51 | // ResultRelay.send(newCacheUri)
52 | // Log.d(tag, "已发送缓存文件的Uri:$newCacheUri")
53 | // } catch (e: Exception) {
54 | // Log.e(tag, "复制文件到缓存失败", e)
55 | // // 确保UI操作在主线程
56 | // withContext(Dispatchers.Main) {
57 | // Toast.makeText(
58 | // this@AttachmentPickerActivity,
59 | // getString(R.string.crypto_attachment_file_not_accessible),
60 | // Toast.LENGTH_SHORT
61 | // ).show()
62 | // }
63 | // } finally {
64 | // // 无论成功或失败,最后都关闭Activity
65 | // withContext(Dispatchers.Main) {
66 | // finish()
67 | // }
68 | // }
69 | // }
70 |
71 | lifecycleScope.launch {
72 | try {
73 | // 在拿到Uri后,立刻申请持久化读取权限
74 | val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION
75 | contentResolver.takePersistableUriPermission(uri, takeFlags)
76 | Log.d(tag, "已成功获取持久化权限: $uri")
77 | // 将这个现在拥有持久权限的Uri发送出去
78 | ResultRelay.send(uri)
79 |
80 | } catch (e: SecurityException) {
81 | Log.e(tag, "申请持久化权限失败,硬发Uri", e)
82 | ResultRelay.send(uri)
83 | } finally {
84 | // ✨ 关键修复:在关闭Activity前增加一个微小的延迟
85 | // 这给了系统足够的时间来处理持久化权限的授予,
86 | // 防止在Service尝试访问Uri之前,权限就因Activity销毁而失效。
87 | delay(200)
88 | finish()
89 | }
90 | }
91 | Unit
92 | }
93 | else{
94 | Log.d(tag, "用户取消了文件选择,关闭Activity。")
95 | finish()
96 | }
97 | }
98 |
99 | // 注册文件选择器,并绑定我们统一的 `onResult` 处理逻辑
100 | mediaPicker = registerForActivityResult(
101 | ActivityResultContracts.PickVisualMedia(),
102 | onResult
103 | )
104 | filePicker = registerForActivityResult(
105 | ActivityResultContracts.GetContent(),
106 | onResult
107 | )
108 |
109 | // 根据启动意图,调用对应的文件选择器
110 | when (intent.getStringExtra(EXTRA_PICK_TYPE)) {
111 | TYPE_MEDIA -> mediaPicker.launch(
112 | PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
113 | )
114 |
115 | TYPE_FILE -> filePicker.launch("*/*")
116 | else -> {
117 | // 如果没有指定类型,默认关闭
118 | Log.w(tag, "未指定有效的PICK_TYPE,Activity将关闭。")
119 | finish()
120 | }
121 | }
122 | }
123 |
124 | /**
125 | * 将给定的Uri指向的文件复制到应用的内部缓存目录。
126 | * @param sourceUri 用户选择的文件的临时Uri。
127 | * @return 指向缓存目录中新文件的、我们拥有永久权限的Uri。
128 | * @throws IOException 如果文件读写失败。
129 | */
130 | @Throws(IOException::class)
131 | private fun copyFileToCache(sourceUri: Uri): Uri {
132 | // 通过ContentResolver打开源文件的输入流
133 | val inputStream = contentResolver.openInputStream(sourceUri)
134 | ?: throw IOException("无法为所选文件打开输入流。")
135 |
136 | // 在我们的缓存目录里创建一个唯一的文件名
137 | val fileName = "upload_cache_${System.currentTimeMillis()}"
138 | val tempFile = File(cacheDir, fileName)
139 |
140 | // 使用Kotlin的扩展函数,安全地将输入流复制到输出流,并自动关闭它们
141 | inputStream.use { input ->
142 | tempFile.outputStream().use { output ->
143 | input.copyTo(output)
144 | }
145 | }
146 |
147 | // 返回我们新创建的、拥有完全权限的文件的Uri
148 | return Uri.fromFile(tempFile)
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/NodeFinder.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import android.util.Log
4 | import android.view.accessibility.AccessibilityNodeInfo
5 | import me.wjz.nekocrypt.NekoCryptApp
6 |
7 | private const val TAG = NekoCryptApp.TAG
8 |
9 | /**
10 | * 检查节点是否仍然有效,这是操作缓存节点前的“金标准”。
11 | * @param node 要检查的节点。
12 | * @return 如果节点有效则返回 true,否则返回 false。
13 | */
14 | fun isNodeValid(node: AccessibilityNodeInfo?): Boolean {
15 | return node?.refresh() ?: false
16 | }
17 |
18 | /**
19 | * ✨ [核心] 查找符合所有指定条件的第一个节点。
20 | *
21 | * @param rootNode 查找的起始节点。
22 | * @param viewId 节点的资源ID (e.g., "com.tencent.mobileqq:id/input")。
23 | * @param className 节点的类名 (e.g., "android.widget.EditText"),支持部分匹配。
24 | * @param text 节点显示的文本,支持部分匹配。
25 | * @param contentDescription 节点的内容描述,支持部分匹配。
26 | * @param predicate 一个自定义的检查函数,返回 true 表示匹配。
27 | * @return 返回第一个匹配的 AccessibilityNodeInfo,如果找不到则返回 null。
28 | */
29 | fun findSingleNode(
30 | rootNode: AccessibilityNodeInfo,
31 | viewId: String? = null,
32 | className: String? = null,
33 | text: String? = null,
34 | contentDescription: String? = null,
35 | predicate: ((AccessibilityNodeInfo) -> Boolean)? = null
36 | ): AccessibilityNodeInfo? {
37 | // 策略1: 如果提供了viewId,以此为主要查找方式,因为最高效。
38 | if (!viewId.isNullOrEmpty()) {
39 | val candidates = rootNode.findAccessibilityNodeInfosByViewId(viewId)
40 | // 在通过ID找到的候选中,进一步筛选出符合所有其他条件的第一个
41 | return candidates.firstOrNull { node ->
42 | matchesAllConditions(node, className, text, contentDescription, predicate)
43 | }
44 | }
45 |
46 | // 策略2: 如果没有提供 viewId,则进行递归查找。
47 | // 递归查找时,必须提供至少一个其他条件,以防止错误地匹配到根节点。
48 | if (className != null || text != null || contentDescription != null || predicate != null) {
49 | return findNodeRecursively(rootNode) { node ->
50 | matchesAllConditions(node, className, text, contentDescription, predicate)
51 | }
52 | }
53 |
54 | // 如果只提供了rootNode而没有其他任何条件,直接返回null,防止出错。
55 | Log.w(TAG, "NodeFinder: 查找条件不足,已跳过搜索。")
56 | return null
57 | }
58 |
59 | /**
60 | * ✨ [核心] 查找符合所有指定条件的全部节点。
61 | *
62 | * @return 返回所有匹配的 AccessibilityNodeInfo 列表,可能为空。
63 | */
64 | fun findMultipleNodes(
65 | rootNode: AccessibilityNodeInfo,
66 | viewId: String? = null,
67 | className: String? = null,
68 | text: String? = null,
69 | contentDescription: String? = null,
70 | predicate: ((AccessibilityNodeInfo) -> Boolean)? = null
71 | ): List {
72 | val results = mutableListOf()
73 |
74 | // 策略1: 如果提供了viewId,以此为主要查找方式。
75 | if (!viewId.isNullOrEmpty()) {
76 | val candidates = rootNode.findAccessibilityNodeInfosByViewId(viewId)
77 | // 筛选出所有符合其他条件的节点
78 | candidates.filterTo(results) { node ->
79 | matchesAllConditions(node, className, text, contentDescription, predicate)
80 | }
81 | // 找到后直接返回,不再进行递归。
82 | return results
83 | }
84 |
85 | // 策略2: 如果没有提供 viewId,则进行递归查找。
86 | if (className != null || text != null || contentDescription != null || predicate != null) {
87 | findAllNodesRecursively(rootNode, results) { node ->
88 | matchesAllConditions(node, className, text, contentDescription, predicate)
89 | }
90 | }
91 |
92 | return results
93 | }
94 |
95 |
96 | /**
97 | * 🎯 核心匹配逻辑:检查一个节点是否满足所有非null的条件。
98 | * @return 如果所有提供的条件都满足,则返回 true。
99 | */
100 | private fun matchesAllConditions(
101 | node: AccessibilityNodeInfo,
102 | className: String?,
103 | text: String?,
104 | contentDescription: String?,
105 | predicate: ((AccessibilityNodeInfo) -> Boolean)?
106 | ): Boolean {
107 | // 这种写法保证了只有所有非null的条件都为true时,最终结果才为true。
108 | return (className == null || node.className?.toString()?.contains(className, ignoreCase = true) == true) &&
109 | (text == null || node.text?.toString()?.contains(text, ignoreCase = true) == true) &&
110 | (contentDescription == null || node.contentDescription?.toString()?.contains(contentDescription, ignoreCase = true) == true) &&
111 | (predicate == null || predicate(node))
112 | }
113 |
114 | /**
115 | * 🔍 递归查找第一个满足条件的节点。
116 | * @param node 当前遍历的节点。
117 | * @param condition 匹配条件的函数。
118 | * @return 找到的节点或null。
119 | */
120 | private fun findNodeRecursively(
121 | node: AccessibilityNodeInfo,
122 | condition: (AccessibilityNodeInfo) -> Boolean
123 | ): AccessibilityNodeInfo? {
124 | // 检查当前节点
125 | if (condition(node)) {
126 | return node
127 | }
128 | // 递归检查子节点
129 | for (i in 0 until node.childCount) {
130 | val child = node.getChild(i) ?: continue
131 | val found = findNodeRecursively(child, condition)
132 | if (found != null) {
133 | // 一旦找到,立刻层层返回,停止搜索
134 | return found
135 | }
136 | }
137 | return null
138 | }
139 |
140 | /**
141 | * 🔍 递归查找所有满足条件的节点。
142 | * @param node 当前遍历的节点。
143 | * @param results 用于存储结果的列表。
144 | * @param condition 匹配条件的函数。
145 | */
146 | private fun findAllNodesRecursively(
147 | node: AccessibilityNodeInfo,
148 | results: MutableList,
149 | condition: (AccessibilityNodeInfo) -> Boolean
150 | ) {
151 | // 检查当前节点
152 | if (condition(node)) {
153 | results.add(node)
154 | }
155 | // 递归检查子节点
156 | for (i in 0 until node.childCount) {
157 | val child = node.getChild(i) ?: continue
158 | findAllNodesRecursively(child, results, condition)
159 | }
160 | }
161 |
162 |
163 | /**
164 | * 🐾 调试用:打印节点树结构
165 | */
166 | fun debugNodeTree(
167 | node: AccessibilityNodeInfo?,
168 | maxDepth: Int = 5,
169 | currentDepth: Int = 0,
170 | ) {
171 | if (node == null || currentDepth > maxDepth) return
172 |
173 | val indent = " ".repeat(currentDepth)
174 | val className = node.className?.toString() ?: "null"
175 | val text = node.text?.toString()?.take(20) ?: ""
176 | val desc = node.contentDescription?.toString()?.take(20) ?: ""
177 |
178 | Log.d(TAG, "$indent[$currentDepth] $className | ID: ${node.viewIdResourceName}")
179 | if (text.isNotEmpty()) Log.d(TAG, "$indent 文本: '$text'")
180 | if (desc.isNotEmpty()) Log.d(TAG, "$indent 描述: '$desc'")
181 | }
182 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or 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 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/NCWindowManager.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import android.animation.ValueAnimator
4 | import android.content.Context
5 | import android.graphics.PixelFormat
6 | import android.graphics.Rect
7 | import android.os.Build
8 | import android.view.Gravity
9 | import android.view.MotionEvent
10 | import android.view.View
11 | import android.view.ViewConfiguration
12 | import android.view.WindowManager
13 | import android.view.animation.DecelerateInterpolator
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.ui.platform.ComposeView
16 | import androidx.compose.ui.platform.ViewCompositionStrategy
17 | import androidx.lifecycle.setViewTreeLifecycleOwner
18 | import androidx.lifecycle.setViewTreeViewModelStoreOwner
19 | import androidx.savedstate.setViewTreeSavedStateRegistryOwner
20 | import kotlin.math.abs
21 |
22 | /**
23 | * 一个通用的、用于在 WindowManager 上显示 Compose UI 的弹窗工具类。
24 | * 它封装了所有创建、显示和销毁悬浮窗的复杂逻辑。
25 | *
26 | * @param context 上下文环境。
27 | * @param onDismissRequest 当弹窗被请求关闭时(例如,通过代码调用dismiss)的回调。
28 | * @param content 要在弹窗中显示的 Composable 内容。
29 | */
30 | class NCWindowManager(
31 | private val context: Context,
32 | private val onDismissRequest: () -> Unit = {},
33 | private val anchorRect: Rect? = null,
34 | private val isDraggable: Boolean = false,
35 | private val content: @Composable () -> Unit,
36 | ) {
37 | private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
38 | private var popupView: View? = null
39 | private var lifecycleOwnerProvider: LifecycleOwnerProvider? = null
40 | private var positionAnimator: ValueAnimator? = null
41 |
42 | /**
43 | * 显示弹窗。
44 | * @param anchorRect 一个可选的矩形,用于定位弹窗。如果为null,弹窗会居中。
45 | */
46 | fun show() {
47 | // 防止重复显示
48 | if (popupView != null) return
49 | // 创建并启动生命周期
50 | lifecycleOwnerProvider = LifecycleOwnerProvider().also { it.resume() }
51 |
52 | // 创建ComposeView并设置内容
53 | popupView = ComposeView(context).apply {
54 | // ✨ 在设置内容之前,先给它“通上电”!
55 | setViewTreeLifecycleOwner(lifecycleOwnerProvider)
56 | setViewTreeViewModelStoreOwner(lifecycleOwnerProvider)
57 | setViewTreeSavedStateRegistryOwner(lifecycleOwnerProvider)
58 | // 不要裁剪子视图,避免子视图做动画时超出边界被裁剪
59 | clipChildren = false
60 | clipToPadding = false
61 |
62 | // 使用最安全通用的生命周期策略
63 | setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
64 | setContent(content)
65 | }
66 | if (isDraggable) {
67 | popupView?.setOnTouchListener(createDragTouchListener())
68 | }
69 | // 创建 WindowManager 参数
70 | val params = createLayoutParams(anchorRect)
71 | // 添加到窗口
72 | windowManager.addView(popupView, params)
73 | }
74 |
75 | /**
76 | * 关闭并销毁弹窗。
77 | */
78 | fun dismiss() {
79 | if (popupView != null) {
80 | try {
81 | windowManager.removeView(popupView)
82 | } catch (e: Exception) {
83 | // 忽略窗口已经不存在等异常
84 | } finally {
85 | popupView = null
86 | lifecycleOwnerProvider?.destroy()
87 | lifecycleOwnerProvider = null
88 | // 调用外部传入的关闭回调
89 | onDismissRequest()
90 | }
91 | }
92 | }
93 |
94 | private fun createLayoutParams(anchorRect: Rect?): WindowManager.LayoutParams {
95 | // 这个accessibility不需要用户授权,比application的好
96 | val layoutFlag = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
97 |
98 | val params = WindowManager.LayoutParams(
99 | WindowManager.LayoutParams.WRAP_CONTENT,
100 | WindowManager.LayoutParams.WRAP_CONTENT,
101 | layoutFlag,
102 | // ✨ 核心修正:加上 FLAG_LAYOUT_IN_SCREEN 这句关键的“咒语”!
103 | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,// 这句是关键
104 | PixelFormat.TRANSLUCENT
105 | )
106 |
107 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
108 | params.blurBehindRadius = 30
109 | }
110 |
111 | if (anchorRect != null) {
112 | params.gravity = Gravity.TOP or Gravity.START
113 | params.x = anchorRect.left
114 | params.y = anchorRect.top
115 | }
116 | return params
117 | }
118 |
119 | // 带有平滑效果的位置更新函数
120 | fun updatePosition(targetRect: Rect) {
121 | // 如果视图不存在直接返回
122 | val view = popupView ?: return
123 | // 取消正在进行的任何旧动画
124 | positionAnimator?.cancel()
125 |
126 | val currentParams = view.layoutParams as? WindowManager.LayoutParams ?: return
127 | val startX = currentParams.x
128 | val startY = currentParams.y
129 | val endX = targetRect.left
130 | val endY = targetRect.top
131 |
132 | // 如果位置没有变化,就没必要执行动画
133 | if (startX == endX && startY == endY) return
134 |
135 | positionAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
136 | duration = 250 //动画时长
137 | interpolator = DecelerateInterpolator() // 减速插值器,动画效果更自然
138 | // 根据动画进度计算当前帧的x,y坐标
139 | addUpdateListener { animation ->
140 | val fraction = animation.animatedFraction
141 | // 根据动画进度计算当前帧的x, y坐标
142 | currentParams.x = (startX + (endX - startX) * fraction).toInt()
143 | currentParams.y = (startY + (endY - startY) * fraction).toInt()
144 |
145 | // 只有当视图还附着在窗口上时才更新布局
146 | if (view.isAttachedToWindow) {
147 | windowManager.updateViewLayout(view, currentParams)
148 | }
149 | }
150 | start()
151 | }
152 | }
153 |
154 | /**
155 | * ✨ 全新函数:创建一个处理拖动逻辑的 OnTouchListener。
156 | * 它能智能地区分用户的“轻点”和“拖动”手势。
157 | */
158 | private fun createDragTouchListener():View.OnTouchListener{
159 | // 手指按下时的初始位置
160 | var initialX = 0
161 | var initialY = 0
162 | var initialTouchX = 0f
163 | var initialTouchY = 0f
164 | val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
165 | var isDragging = false
166 |
167 | return View.OnTouchListener{ view, event ->
168 | val params = view.layoutParams as? WindowManager.LayoutParams ?: return@OnTouchListener false
169 |
170 | when (event.action) {
171 | MotionEvent.ACTION_DOWN -> {
172 | isDragging = false // 每次按下都重置拖动状态
173 | // 记录下当前窗口的位置
174 | initialX = params.x
175 | initialY = params.y
176 | // 记录下手指在屏幕上的位置
177 | initialTouchX = event.rawX
178 | initialTouchY = event.rawY
179 | true // 返回 true,表示我们要继续处理后续的 MOVE 和 UP 事件
180 | }
181 | MotionEvent.ACTION_MOVE -> {
182 | // 计算手指滑动的距离
183 | val dx = event.rawX - initialTouchX
184 | val dy = event.rawY - initialTouchY
185 |
186 | // 如果还没开始拖动,并且滑动的距离已经超过了系统阈值,那么就判定为开始拖动
187 | if (!isDragging && (abs(dx) > touchSlop || abs(dy) > touchSlop)) {
188 | isDragging = true
189 | }
190 |
191 | // 如果正在拖动,就更新窗口的位置
192 | if (isDragging) {
193 | params.x = (initialX + dx).toInt()
194 | params.y = (initialY + dy).toInt()
195 | windowManager.updateViewLayout(view, params)
196 | }
197 | true
198 | }
199 | MotionEvent.ACTION_UP -> {
200 | // 如果手指抬起时,我们判定这并非一次拖动...
201 | if (!isDragging) {
202 | // ...那就当它是一次点击!
203 | view.performClick()
204 | }
205 | true
206 | }
207 | else -> false // 其他事件我们不关心
208 | }
209 | }
210 | }
211 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## 一款神奇又好用的全局消息加解密软件 —— 喵密!
2 |
3 |
4 |
5 |
6 |
7 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Neko Crypt
44 |
45 | 查看Demo
46 | ·
47 | 报告Bug
48 | ·
49 | 提出新特性
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | "喜悦也好 悲伤也好 阴晴雨雪 欢聚离别
59 |
世界上所有美好与苦难, 通通都坠入那片纯蓝。"
60 |
61 | ## 目录
62 |
63 | - [Neko Crypt](#projectname)
64 | - [目录](#目录)
65 | - [NekoCrypt 的传说](#nekocrypt-的传说)
66 | - [**使用教程**](#使用教程)
67 | - [**下载链接**](#下载链接)
68 | - [支持软件](#支持软件)
69 | - [交流群](#交流群)
70 | - [版权说明](#版权说明)
71 | - [鸣谢](#鸣谢)
72 | - [重要声明](#重要声明)
73 |
74 | ## NekoCrypt 的传说
75 |
76 | 在数字世界的喧嚣背后,存在着一个由猫咪们维护的古老通讯系统。它们是信息的守护者,用呼噜声加密,用尾巴的摇摆解密。它们的网络,无形、优雅,且绝对安全。
77 |
78 | 然而,这个充满了噪音和窥探的数字世界,也充满了遗憾。无数珍贵的话语,在冰冷的数据洪流中漂泊、失散,再也无法抵达它们本应去往的地方。
79 |
80 | WJZ_P 的故事很神秘。在他心中,有一段对话,一缕星光,是他希望能永远守护的秘密。那是一段本应继续,却归于沉寂的私语。
81 |
82 | 一只名为“Kitten”的智者狮子猫,仿佛感受到了这份深藏心底的思念。它悄然出现在 WJZ_P 的窗台,带来了一丝慰藉,和一则来自古老猫咪网络的启示。Kitten 并非普通的猫,它更像一个信使,一个连接着此地与星辰的守护者。
83 |
84 | 于是,在 Kitten 的陪伴下,WJZ_P 将这份守护的执念,与猫咪一族加密的奥秘——那种将信息变得如猫步般轻盈、如星光般静谧的魔法——融合,翻译成了人类可以理解的代码。
85 |
86 | NekoCrypt 就此诞生。
87 |
88 | 它不仅仅是一个加密工具。它是一种承诺,一种将最重要的心声,送往那个你最想念的地方的仪式。它体现了一种猫咪的哲学:真正的沟通,可以跨越喧嚣,甚至跨越时空,抵达永恒。
89 |
90 | 现在,当你使用 NekoCrypt 时,想象一下,那只名为 Kitten 的猫咪伙伴,正用它毛茸茸的尾巴,温柔地为你守护着每一条信息。它不仅仅是保护信息不被窥探,更是确保那些承载着思念的低语,能够穿过数字世界的迷雾,抵达那片属于它的星空。
91 |
92 | 呼噜噜...(舔爪爪)蹭︉︎︆︅︊︃︊️︃︎︎️︈︀︉︄︈︋︊︎︎︊︎︍︉︍︉︁︄︊︅︃︅︇︁︋︇︍︂︅︅︋︎︅︉︋︌️︉︍️︌︁️︅︃︃︎️︎︍︊︂︆︈︃︉︍︍︌︅︌︉︍︋︁︁︆︊︄︉︉︉︈︊︍︈︁︊︎︄︇︉︅︌︊︋︍︋︍︇︎︉︂︅︎︍︅︉︈︄︀︊︋️︀︃︁︌︅︂︊︂︄️︎︄︄︉️︁︂︇︊︄︌︂︀︎︉︉︃︅︁︉︊︎︉︈️︅︁︅️︎︄︎︀︌︌︃️︂︂︍︍︄︇︇︇︆︂︇︌︈︀︊︉︆︆蹭~(๑•̀ㅂ•́)و✧
93 |
94 | # 使用教程
95 |
96 | ## 1. 打开无障碍权限
97 |
98 |
99 |
100 |
101 | 看到那个巨大猫爪了吗?点击它!会跳转到无障碍权限页面列表。
102 |
103 |
104 |
105 |
106 |
107 | 点击“已下载的应用”
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | 开启后,返回主界面,看到猫爪变为深色就大功告成啦!
118 |
119 |
120 |
121 |
122 |
123 | ## 2. 使用过程
124 |
125 | #### 下面以QQ为例
126 |
127 | ### 进入群聊,可以看到输入框的发送按钮有浅蓝色遮罩
128 |
129 |
130 |
131 |
132 |
133 | #### 看到有遮罩即为功能正常,如果想关闭遮罩,可以在设置中选择遮罩颜色,默认配色板的最后一个即为纯透明。
134 |
135 |
136 |
137 | | NekoCrypt | 标准模式 | 沉浸模式 |
138 | |:---------:|:----------:|:----------:|
139 | | 加密模式 | 长按发送按钮发送密文 | 点击发送直接发出密文 |
140 | | 解密模式 | 点击含密文消息解密 | 自动解密,耗电增加 |
141 |
142 |
143 |
144 | #### 解密效果展示如下:
145 |
146 |
147 |
148 |
149 |
150 |
151 | ### 双击输入框,拉起附件发送界面
152 |
153 |
154 |
155 |
156 |
157 |
158 | #### 让我们来发送一个小约翰吧!
159 |
160 |
161 |
162 |
163 |
164 |
165 | 条件限制,目前只支持10M以内的图片、文件发送,将来会扩展。
166 |
167 | ### 适配额外聊天软件
168 |
169 | 设置页面,可以打开扫描开关
170 |
171 |
172 |
173 |
174 |
175 |
176 | 切到你想要适配的聊天软件,确保界面上显示了发送按钮(有的软件只有输入框有字才会显示发送按钮),点击猫爪悬浮窗自动扫描。
177 |
178 |
179 |
180 |
181 |
182 |
183 | 必须选择四个要素:输入框、发送按钮、消息列表、消息节点后,才可以点击确认。确认后自动保存配置,就可以使用了。这里特别要注意的是,选择消息节点时必须注意内容是你发送的文本,不要误选成昵称、群等级之类的错误节点。
184 |
185 |
186 | ## 下载链接
187 |
188 | #### [点击高速下载v1.0.1(并不总是最新,建议从release下载)](https://beisudianxueuser.oss-cn-beijing.aliyuncs.com/storage/user_avatar/ciallo/2025/08/23/a911aef0a0c2018ad23489ffd263f581/NekoCrypt-v1.0.1-release.apk)
189 | #### 右侧release内也可下载
190 |
191 | ## 支持软件
192 |
193 |
194 | | NekoCrypt | 是否支持 | 备注 |
195 | | :---: | :----: |:----------:|
196 | | QQ |✅ | 完全支持 |
197 | | 微信 | ✅ | 完全支持 |
198 | | 更多 | ✅ | 使用扫描功能自助添加 |
199 |
200 |
201 |
202 | ## 交流群
203 |
204 |
205 |
206 |
207 |
208 |
209 | ## 版权说明
210 |
211 | 该项目签署了EPL-2.0 license
212 | 授权许可,详情请参阅 [LICENSE](https://github.com/WJZ-P/NekoCrypt/blob/main/LICENSE)
213 |
214 | ## 鸣谢
215 |
216 | - 一位不愿透露姓名的神秘人士。
217 |
218 |
219 | ## 重要声明
220 | ### 本项目仅供交流学习使用,**禁止**用于一切非法用途!任何问题概不负责。(。•́︿•̀。)
221 |
222 | ## 📝 To Do List
223 |
224 | - [x] **完全支持微信**
225 |
226 | - [x] **支持更换密钥**
227 |
228 | - [ ] **支持更大文件的发送**
229 |
230 | - [ ] **支持修改主题色**
231 |
232 | - [ ] **支持更多加密语种**
233 |
234 | - [ ] **支持时间轮转密钥,使得加密消息有时间限制,无法查看之前时间段的加密内容**
235 |
236 | ## 如果您喜欢本项目,请给我点个⭐吧(๑>◡<๑)!
237 |
238 | ## ⭐ Star 历史
239 |
240 | [](https://starchart.cc/WJZ-P/NekoCrypt)
241 |
242 |
243 | [your-project-path]:WJZ-P/NekoCrypt
244 |
245 | [contributors-shield]: https://img.shields.io/github/contributors/WJZ-P/NekoCrypt.svg?style=flat-square
246 |
247 | [contributors-url]: https://github.com/WJZ-P/NekoCrypt/graphs/contributors
248 |
249 | [forks-shield]: https://img.shields.io/github/forks/WJZ-P/NekoCrypt.svg?style=flat-square
250 |
251 | [forks-url]: https://github.com/WJZ-P/NekoCrypt/network/members
252 |
253 | [stars-shield]: https://img.shields.io/github/stars/WJZ-P/NekoCrypt.svg?style=flat-square
254 |
255 | [stars-url]: https://github.com/WJZ-P/NekoCrypt/stargazers
256 |
257 | [issues-shield]: https://img.shields.io/github/issues/WJZ-P/NekoCrypt.svg?style=flat-square
258 |
259 | [issues-url]: https://img.shields.io/github/issues/WJZ-P/NekoCrypt.svg
260 |
261 | [license-shield]: https://img.shields.io/github/license/WJZ-P/NekoCrypt.svg?style=flat-square
262 |
263 | [license-url]: https://github.com/WJZ-P/NekoCrypt/blob/main/LICENSE
264 |
265 | [linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
266 |
267 | [linkedin-url]: https://linkedin.com/in/shaojintian
268 |
269 | [oldQQ-download-link]:https://dldir1.qq.com/qqfile/qq/QQNT/448e164c/QQ9.9.15.26909_x64.exe
270 |
271 | [LL-installer-link]:https://ats-prod.oss-accelerate.aliyuncs.com/18734247705198dcb594916e8ba1facc
272 |
273 | [//]: # (不知道写点啥)
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/service/handler/FileActionHandler.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.service.handler
2 |
3 | import android.content.ContentValues
4 | import android.content.Intent
5 | import android.net.Uri
6 | import android.os.Build
7 | import android.os.Environment
8 | import android.provider.MediaStore
9 | import android.util.Log
10 | import android.webkit.MimeTypeMap
11 | import android.widget.Toast
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.mutableStateOf
14 | import androidx.compose.runtime.setValue
15 | import com.dianming.phoneapp.MyAccessibilityService
16 | import kotlinx.coroutines.Dispatchers
17 | import kotlinx.coroutines.launch
18 | import kotlinx.coroutines.withContext
19 | import me.wjz.nekocrypt.R
20 | import me.wjz.nekocrypt.ui.dialog.FilePreviewDialog
21 | import me.wjz.nekocrypt.util.CryptoDownloader
22 | import me.wjz.nekocrypt.util.NCFileProtocol
23 | import me.wjz.nekocrypt.util.NCWindowManager
24 | import me.wjz.nekocrypt.util.getCacheFileFor
25 | import me.wjz.nekocrypt.util.getUriForFile
26 | import java.io.IOException
27 |
28 | /**
29 | * 点击文件or图片按钮后的处理类,负责控制悬浮窗的生命周期,并负责下载,展示等逻辑
30 | */
31 | class FileActionHandler(private val service: MyAccessibilityService) {
32 | private val tag ="NCFileActionHandler"
33 | private var dialogManager: NCWindowManager? = null
34 | private var downloadProgress by mutableStateOf(null)
35 | private var downloadedFileUri by mutableStateOf(null)
36 | private var isImageSavedThisTime by mutableStateOf(false)
37 |
38 | /**
39 | * 显示文件预览对话框
40 | */
41 | fun show(fileInfo: NCFileProtocol) {
42 | dismiss() // 先关闭旧的
43 |
44 | // 根据文件信息生成本地缓存的唯一路径
45 | val targetFile = getCacheFileFor(service,fileInfo)
46 |
47 | // 检查缓存文件是否完整
48 | if (targetFile.exists() && targetFile.length() == fileInfo.size) {
49 | Log.d(tag, "文件已在缓存中找到: ${targetFile.path}")
50 | // ✨ 如果缓存命中,直接为文件生成安全的Uri
51 | downloadedFileUri = getUriForFile(service,targetFile)
52 | downloadProgress = null
53 | } else {
54 | Log.d(tag, "文件未缓存或不完整,准备下载。")
55 | downloadedFileUri = null // 未缓存,重置状态
56 | downloadProgress = null
57 | }
58 | // 创建视图
59 | dialogManager = NCWindowManager(
60 | context = service,
61 | onDismissRequest = { dialogManager = null },
62 | anchorRect = null
63 | ) {
64 | FilePreviewDialog(
65 | fileInfo = fileInfo,
66 | downloadProgress = downloadProgress, // ✨ 将进度状态传递给UI
67 | downloadedFileUri = downloadedFileUri, // nullable
68 | isImageSavedThisTime = isImageSavedThisTime, // 本次会话中是否把图片保存到了系统相册
69 | onDismissRequest = { dismiss() },
70 | onDownloadRequest = { info ->
71 | startDownload(info)
72 | },
73 | onOpenRequest = { uri ->
74 | openFile(uri,fileInfo) // ✨ 回调现在直接使用 Uri
75 | },
76 | onSaveToGalleryRequest = {uri ->
77 | service.serviceScope.launch {
78 | isImageSavedThisTime = saveImageToGallery(uri, fileInfo)
79 | }
80 | }
81 | )
82 | }
83 | dialogManager?.show()
84 | }
85 |
86 | /**
87 | * 关闭对话框
88 | */
89 | fun dismiss() {
90 | dialogManager?.dismiss()
91 | dialogManager = null
92 | }
93 |
94 | /**
95 | * 启动文件下载
96 | */
97 | private fun startDownload(fileInfo: NCFileProtocol) {
98 | if(downloadProgress != null) return // 保证健壮性,防止重复点击
99 |
100 | service.serviceScope.launch {
101 | val targetFile = getCacheFileFor(service,fileInfo)
102 | try{
103 | downloadProgress = 0
104 | // download会suspend。
105 | val result = CryptoDownloader.download(
106 | fileInfo = fileInfo,
107 | targetFile = targetFile,
108 | onProgress = { progress -> downloadProgress = progress }
109 | )
110 |
111 | if(result.isSuccess){
112 | val file = result.getOrThrow()
113 | // ✨ 下载成功后,为新文件生成安全的Uri并更新状态
114 | downloadedFileUri = getUriForFile(service,file)
115 | Log.d(tag, "文件下载成功,Uri: $downloadedFileUri")
116 | }else{
117 | val error = result.exceptionOrNull()?.message ?: "未知错误"
118 | Log.e(tag, "文件下载失败: $error")
119 | showToast(service.getString(R.string.dialog_download_file_download_failed, error))
120 | }
121 | } finally {
122 | downloadProgress = null
123 | }
124 | }
125 | }
126 |
127 | suspend fun showToast(string: String, duration: Int = Toast.LENGTH_SHORT) {
128 | Log.d(tag, "showToast: $string")
129 | withContext(Dispatchers.Main) {
130 | Toast.makeText(service.applicationContext, string, duration).show()
131 | }
132 | }
133 |
134 | private fun openFile(uri: Uri,fileInfo: NCFileProtocol){
135 | service.serviceScope.launch {
136 | try{
137 | // 1. ✨ 从原始文件名中获取文件后缀
138 | val extension = fileInfo.name.substringAfterLast('.', "")
139 | // 2. ✨ 使用 MimeTypeMap 将后缀转换为标准的MIME类型
140 | val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase())
141 | ?: "*/*" // 如果找不到,使用通用类型
142 |
143 | val intent = Intent(Intent.ACTION_VIEW).apply {
144 | setDataAndType(uri,mimeType)
145 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
146 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
147 | }
148 | service.startActivity(intent)
149 | dismiss() // 选择打开文件的话,就要关闭当前的悬浮窗
150 |
151 | } catch (e: Exception) {
152 | Log.e(tag, "打开文件失败", e)
153 | showToast(service.getString(R.string.cannot_open_file))
154 | }
155 | }
156 | }
157 |
158 | // 根据uri和文件名保存到系统相册,并返回操作结果。
159 | private suspend fun saveImageToGallery(uri: Uri, fileInfo: NCFileProtocol): Boolean {
160 |
161 | val success = withContext(Dispatchers.IO) {
162 | runCatching {
163 | val extension = fileInfo.name.substringAfterLast('.', "")
164 | val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
165 |
166 | // ContentValues 就像一个“档案袋”,我们把新文件的所有信息(元数据)都放进去。
167 | val contentValues = ContentValues().apply {
168 | put(MediaStore.MediaColumns.DISPLAY_NAME, fileInfo.name) // 文件在相册里显示的名字。
169 | put(MediaStore.MediaColumns.MIME_TYPE, mimeType) // 文件的mime类型
170 | // 档案3 & 4 (仅限 Android 10 及以上):
171 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
172 | // 告诉系统要把这个文件放在公共的“相册”文件夹里。
173 | put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
174 | // 先把文件标记为“待定”状态。这意味着在文件内容被完全写入之前,
175 | // 其他应用(包括相册自己)是看不到这个文件的,可以防止出现损坏的半成品文件。
176 | put(MediaStore.MediaColumns.IS_PENDING, 1)
177 | }
178 | }
179 |
180 | // 用我们写好的信息,去申请一个URI
181 | val imageUri = service.contentResolver.insert(
182 | MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
183 | contentValues
184 | )
185 | ?: throw IOException("无法在相册中创建新文件。")
186 |
187 | // 使用我们新的imageUri,写入文件
188 | service.contentResolver.openOutputStream(imageUri).use { outputStream ->
189 | service.contentResolver.openInputStream(uri).use { inputStream ->
190 | requireNotNull(inputStream) { "无法打开缓存文件的输入流" }
191 | requireNotNull(outputStream) { "无法打开相册文件的输出流" }
192 | inputStream.copyTo(outputStream)
193 | }
194 | }
195 |
196 | // (仅限 Android 10 及以上) 文件内容已经写完,我们再次更新档案,
197 | // 把“待定”状态改为0,正式通知系统:“文件已准备就绪,可以对外展示了!”
198 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
199 | contentValues.clear()
200 | contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
201 | service.contentResolver.update(imageUri, contentValues, null, null)
202 | }
203 |
204 | //顺利完成,返回true
205 | true
206 | }.onFailure { e ->
207 | Log.e(tag, "保存图片到相册失败", e)
208 | false // 返回失败
209 | }.getOrDefault(false) // 拿不到,默认就返回false
210 | }
211 | if (success) showToast(service.getString(R.string.image_saved_to_gallery_success))
212 | else showToast(service.getString(R.string.image_saved_to_gallery_failed))
213 | return success
214 | }
215 | }
216 |
217 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/screen/HomeScreen.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.screen
2 |
3 | import android.content.Context
4 | import androidx.compose.animation.AnimatedVisibility
5 | import androidx.compose.animation.core.tween
6 | import androidx.compose.animation.expandVertically
7 | import androidx.compose.animation.fadeIn
8 | import androidx.compose.animation.fadeOut
9 | import androidx.compose.animation.shrinkVertically
10 | import androidx.compose.foundation.layout.Arrangement
11 | import androidx.compose.foundation.layout.Box
12 | import androidx.compose.foundation.layout.Column
13 | import androidx.compose.foundation.layout.Spacer
14 | import androidx.compose.foundation.layout.aspectRatio
15 | import androidx.compose.foundation.layout.fillMaxSize
16 | import androidx.compose.foundation.layout.fillMaxWidth
17 | import androidx.compose.foundation.layout.padding
18 | import androidx.compose.foundation.shape.RoundedCornerShape
19 | import androidx.compose.material3.Card
20 | import androidx.compose.material3.CardDefaults
21 | import androidx.compose.material3.MaterialTheme
22 | import androidx.compose.runtime.Composable
23 | import androidx.compose.runtime.getValue
24 | import androidx.compose.runtime.remember
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.platform.LocalContext
28 | import androidx.compose.ui.res.stringResource
29 | import androidx.compose.ui.unit.dp
30 | import me.wjz.nekocrypt.CommonKeys.DECRYPTION_MODE_IMMERSIVE
31 | import me.wjz.nekocrypt.CommonKeys.DECRYPTION_MODE_STANDARD
32 | import me.wjz.nekocrypt.CommonKeys.ENCRYPTION_MODE_IMMERSIVE
33 | import me.wjz.nekocrypt.CommonKeys.ENCRYPTION_MODE_STANDARD
34 | import me.wjz.nekocrypt.R
35 | import me.wjz.nekocrypt.SettingKeys
36 | import me.wjz.nekocrypt.hook.rememberDataStoreState
37 | import com.dianming.phoneapp.MyAccessibilityService
38 | import me.wjz.nekocrypt.ui.InfoDialogIcon
39 | import me.wjz.nekocrypt.ui.RadioOption
40 | import me.wjz.nekocrypt.ui.SegmentedButtonSetting
41 | import me.wjz.nekocrypt.ui.SwitchSettingCard
42 | import me.wjz.nekocrypt.ui.component.CatPawButton
43 | import me.wjz.nekocrypt.util.openAccessibilitySettings
44 | import me.wjz.nekocrypt.util.rememberAccessibilityServiceState
45 |
46 | // --- 主屏幕代码 ---
47 |
48 | @Composable
49 | fun HomeScreen(modifier: Modifier = Modifier) {
50 | // 1. 获取当前上下文
51 | val context: Context = LocalContext.current
52 |
53 | // 2. 使用我们新的 Composable 函数来获取并监听无障碍服务的状态
54 | val isAccessibilityEnabled by rememberAccessibilityServiceState(
55 | context,
56 | MyAccessibilityService::class.java
57 | )
58 |
59 | val useAutoEncryption by rememberDataStoreState(SettingKeys.USE_AUTO_ENCRYPTION, false)
60 | val useAutoDecryption by rememberDataStoreState(SettingKeys.USE_AUTO_DECRYPTION, false)
61 |
62 | // 使用 Column 作为根布局,以垂直排列组件
63 | Column(
64 | modifier = modifier.fillMaxSize(),
65 | horizontalAlignment = Alignment.CenterHorizontally
66 | ) {
67 | // 使用带权重的 Column 来包裹原有的猫爪UI,使其占据大部分空间并保持居中
68 | Column(
69 | modifier = Modifier.weight(1f).fillMaxWidth(),
70 | verticalArrangement = Arrangement.Center,
71 | horizontalAlignment = Alignment.CenterHorizontally
72 | ) {
73 | // ✨ 核心修正:用一个 Box 把猫爪按钮包裹起来,并给这个 Box 一个“方形模具”
74 | Box(
75 | modifier = Modifier
76 | .fillMaxWidth() // 让 Box 尽可能宽
77 | .padding(52.dp) // 给猫爪留出一些呼吸空间
78 | .aspectRatio(1f), // ✨ 魔法!强制这个 Box 的高度等于它的宽度,永远保持正方形
79 | contentAlignment = Alignment.Center
80 | ) {
81 | CatPawButton(
82 | modifier = Modifier.fillMaxSize(), // 让猫爪按钮填满这个完美的正方形
83 | isEnabled = isAccessibilityEnabled,
84 | statusText = if (isAccessibilityEnabled)
85 | stringResource(id = R.string.accessibility_service_enabled)
86 | else
87 | stringResource(id = R.string.accessibility_service_disabled),
88 | onClick = { openAccessibilitySettings(context) }
89 | )
90 | }
91 | }
92 |
93 | // 在底部添加我们的设置卡片
94 |
95 | // 加密选项
96 | Card(
97 | modifier = Modifier
98 | .fillMaxWidth()
99 | .padding(horizontal = 16.dp, vertical = 8.dp),
100 | shape = RoundedCornerShape(16.dp),
101 | colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
102 | elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
103 | ) {
104 | Column {
105 | SwitchSettingCard(
106 | key = SettingKeys.USE_AUTO_ENCRYPTION,
107 | defaultValue = false,
108 | title = stringResource(id = R.string.setting_encrypt_on_send_title),
109 | subtitle = stringResource(id = R.string.setting_encrypt_on_send_subtitle)
110 | )
111 |
112 | // 2. 模式选择
113 | AnimatedVisibility(
114 | visible = useAutoEncryption,
115 | enter = expandVertically(animationSpec = tween(400)) + fadeIn(),
116 | exit = shrinkVertically(animationSpec = tween(400)) + fadeOut()
117 | ) {
118 | val modeStandardText = stringResource(R.string.mode_standard)
119 | val modeImmersiveText = stringResource(R.string.mode_immersive)
120 |
121 | val encryptionModeOptions = remember {
122 | listOf(
123 | RadioOption(ENCRYPTION_MODE_STANDARD, modeStandardText),
124 | RadioOption(ENCRYPTION_MODE_IMMERSIVE, modeImmersiveText)
125 | )
126 | }
127 | SegmentedButtonSetting(
128 | settingKey = SettingKeys.ENCRYPTION_MODE,
129 | defaultOptionKey = ENCRYPTION_MODE_STANDARD,
130 | title = stringResource(id = R.string.setting_encryption_mode_info_title),
131 | options = encryptionModeOptions,
132 | titleExtraContent = { // 标题旁边的额外内容。
133 | InfoDialogIcon(
134 | title = stringResource(R.string.setting_encryption_mode_info_text),
135 | text = stringResource(R.string.setting_encryption_mode_info_desc),
136 | contentDescription = stringResource(R.string.setting_encryption_mode_info_desc)
137 | )
138 | }
139 | )
140 | }
141 | }
142 | }
143 |
144 | // 解密选项
145 | Card(
146 | modifier = Modifier
147 | .fillMaxWidth()
148 | .padding(horizontal = 16.dp, vertical = 8.dp),
149 | shape = RoundedCornerShape(16.dp),
150 | colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
151 | elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
152 | ) {
153 | Column {
154 | //解密开关
155 | SwitchSettingCard(
156 | key = SettingKeys.USE_AUTO_DECRYPTION,
157 | defaultValue = false,
158 | title = stringResource(id = R.string.setting_decrypt_immersive_mod_title),
159 | subtitle = stringResource(id = R.string.setting_decrypt_immersive_mod_subtitle),
160 | )
161 | AnimatedVisibility(
162 | visible = useAutoDecryption,
163 | enter = expandVertically(animationSpec = tween(400)) + fadeIn(),
164 | exit = shrinkVertically(animationSpec = tween(400)) + fadeOut()
165 | ) {
166 | val decryptionModeOptions = remember {
167 | listOf(
168 | RadioOption(DECRYPTION_MODE_STANDARD, "标准模式"),
169 | RadioOption(DECRYPTION_MODE_IMMERSIVE, "沉浸模式")
170 | )
171 | }
172 | SegmentedButtonSetting(
173 | settingKey = SettingKeys.DECRYPTION_MODE,
174 | defaultOptionKey = DECRYPTION_MODE_STANDARD,
175 | title = stringResource(id = R.string.setting_decryption_mode_info_title),
176 | options = decryptionModeOptions,
177 | titleExtraContent = {
178 | // ✨ 看!之前所有复杂的 TooltipBox 代码,
179 | // 现在都变成了这一行极其清晰的调用!
180 | InfoDialogIcon(
181 | title = stringResource(R.string.setting_decryption_mode_info_text),
182 | text = stringResource(R.string.setting_decryption_mode_info_desc),
183 | contentDescription = stringResource(R.string.setting_decryption_mode_info_desc)
184 | )
185 | }
186 | )
187 | }
188 | }
189 |
190 | }
191 | Spacer(Modifier.padding(4.dp))
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | NekoCrypt
3 |
4 |
5 | 原来是这样,现在我完全搞懂了
6 | 关闭
7 | 发送
8 | 确认
9 | 文件大小
10 | URL
11 | 下载
12 | 完成
13 | 删除
14 | 下载中
15 | 未安装
16 | 打开文件
17 | 打开文件失败
18 | 检查更新中...
19 | 检查更新失败,请检查网络连接
20 | "已经是最新版本啦 (ฅ´ω`ฅ)"
21 | 发现新版本: %s!
22 | 保存到相册
23 | 成功保存到相册
24 | 保存到相册失败
25 | 已保存到相册
26 | 已复制到剪切板
27 | 请打开悬浮窗权限!
28 | 请开启无障碍服务!
29 |
30 |
31 | 主页
32 | 加密
33 | 密钥
34 | 设置
35 |
36 |
37 |
38 |
39 | 标准模式
40 | 沉浸模式
41 |
42 | 输入原文或密文
43 | 在此处输入或粘贴文本…
44 | 解密失败!密文被篡改或密钥错误.\n 。゚(゚´Д`゚)゚。
45 | 输入图标
46 | 已从剪贴板粘贴
47 | 粘贴
48 | 清空输入
49 | 加密图标
50 | 交换输入与输出
51 | 解密
52 | 解密图标
53 | 加密结果
54 | 解密结果
55 | 已复制到剪贴板
56 | 复制结果
57 | 密钥图标
58 | 当前密钥
59 | 选择密钥
60 |
61 | 密文字符数
62 | 总处理耗时
63 | %1$d ms
64 |
65 | 喵密服务,用于智能加解密消息
66 | 为保证功能正常使用,需要申请无障碍权限哦 (๑•̀ㅂ•́)و✧
67 |
68 | 已获取无障碍权限
69 | 点击开启无障碍权限
70 | 加密开关
71 | 开启喵密世界的大门。
72 | 解密开关
73 | 读懂猫咪语言的必选项!
74 | 设置加密模式
75 | 加密模式说明
76 | 标准模式:\n长按发送按钮才会发送密文。\n\n沉浸模式:\n点按直接发送密文。
77 | 设置解密模式
78 | 解密模式说明
79 | 标准模式:\n点击密文弹出悬浮窗显示解密结果。\n\n沉浸模式:\n无需点击,直接以悬浮窗显示解密后的密文,但会增加一定耗电量。
80 |
81 |
82 | NekoCrypt 喵密服务运行中
83 | 密文设置失败喵!可能是字数过多喵… (。◕ˇ‸ˇ◕。):%d 字
84 |
85 |
86 | 前往设置
87 | 需要悬浮窗权限
88 | 为了功能正常使用,请您手动授予权限。\n\n在设置页面,请寻找并开启“显示在其他应用上层”选项。\n\n在部分手机(如小米、华为)上,还可能额外有“悬浮窗权限”,请一同在应用的权限管理中寻找并开启它。
89 | 仍未检测到悬浮窗权限
90 | 喵密检测到悬浮窗权限仍未开启。别担心,这是因为各大手机厂商有更高的悬浮窗权限控制。\n\n请在接下来打开的应用设置页面中,手动找到并开启“悬浮窗”或“显示在其他应用上层”的相关权限。
91 |
92 | 需要无障碍服务
93 | 为了实现自动加解密和读取聊天内容,请在设置列表中找到“NekoCrypt”并开启它的无障碍服务。
94 |
95 | 加解密设置
96 | 长按解密延时
97 | 设置触发长按加密所需的时间
98 | 解密密文展示时长
99 | 设置解密密文悬浮窗的显示时间
100 | 关于
101 | 关于NekoCrypt
102 | 软件版本:%s
103 | Github 项目地址
104 | NekoCrypt (喵密) 是一款方便好用的全局APP加解密软件!(ฅ´ω`ฅ)\n\n双击输入框可以拉起附件发送界面哦!✨\n\n如果您喜欢本软件,请到Github项目主页为我点上一个Star吧!ପ(๑•̀ㅂ•́)و✧\n\n本项目仅供学习交流,任何法律问题均需用户自行负责哦~ ( •̀ ω •́ )✧ 使用NekoCrypt即表示您同意该条款。\n\n ——Made by WJZ_P with love❤️.
105 |
106 | 功能界面设置
107 | 弹窗位置更新延时
108 | 在沉浸式解密下,密文弹窗位置更新的时间间隔,越短越实时,但可能导致卡顿
109 | 选择颜色
110 | 按钮遮罩颜色
111 | 支持ARGB和RGB格式,用于设置盖在发送按钮上遮罩的颜色。最后一个是纯透明
112 | 拉起多媒体弹窗的双击间隔
113 | 在间隔时间内双击输入框,会拉起发送多媒体消息的弹窗
114 |
115 | 发送加密附件
116 | 相册
117 | 文件
118 | 文件上传失败:%s
119 | 文件不存在!
120 | 文件无法访问!
121 | 文件过大!当前最大支持:%d MB
122 | 输入框或发送按钮节点未找到!
123 | 已选择: %s
124 | "正在加密上传..."
125 |
126 |
127 | 文件下载失败:%s
128 | 文件详情
129 |
130 |
131 | 密钥管理
132 | 添加
133 | 默认支持应用
134 | 可能失效,推荐手动扫描添加
135 | 自定义应用
136 | 暂未添加自定义APP
137 | 输入框 ID:
138 | 发送按钮 ID:
139 | 消息气泡 ID:
140 | 消息列表类名:
141 | 添加自定义APP
142 | 扫描模式
143 | 屏幕上会出现用于扫描当前界面的按钮
144 | APP配置已删除
145 | 回车以保存新密钥...
146 |
147 |
148 | 扫描结果
149 | 获取扫描结果失败!
150 | 输入框
151 | 发送按钮
152 | 消息列表
153 | 文本节点
154 | ID
155 | 类名
156 | 文本
157 | 描述
158 | 描述
159 | 扫描功能介绍
160 | 必须选择正确的输入框、发送按钮、消息列表、消息节点。
161 | 点击即可选中,只有四种全部选择完才可以点击确认,之后就自动保存当前软件的节点信息用于加解密。可通过节点的具体内容确认是否是所需节点。特别注意的是消息节点,必须正确选择包含正确消息内容的节点!
162 | 扫描配置已保存
163 |
164 |
165 | 密文语种选择
166 | 加密后的文本可以选择不同风格!
167 | 猫娘语
168 | 尼尔语
169 | 曼波语
170 | 邦布语
171 | 丘丘语
172 |
173 | 密文语种选择
174 | 密文长度选择
175 | 密文词组数为闭区间内的随机值
176 |
177 |
178 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/util/CryptoManager.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.util
2 |
3 | import kotlinx.coroutines.CoroutineScope
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.SupervisorJob
6 | import me.wjz.nekocrypt.NekoCryptApp
7 | import me.wjz.nekocrypt.R
8 | import me.wjz.nekocrypt.SettingKeys
9 | import me.wjz.nekocrypt.data.DataStoreManager
10 | import me.wjz.nekocrypt.hook.observeAsState
11 | import java.io.InputStream
12 | import java.io.OutputStream
13 | import java.math.BigInteger
14 | import java.security.MessageDigest
15 | import java.security.SecureRandom
16 | import java.util.Locale.getDefault
17 | import javax.crypto.AEADBadTagException
18 | import javax.crypto.Cipher
19 | import javax.crypto.KeyGenerator
20 | import javax.crypto.SecretKey
21 | import javax.crypto.spec.GCMParameterSpec
22 | import javax.crypto.spec.SecretKeySpec
23 | import kotlin.random.Random
24 |
25 | /**
26 | * 加密工具类,有相关的加密算法。
27 | */
28 | object CryptoManager {
29 | val dataStoreManager: DataStoreManager by lazy { NekoCryptApp.instance.dataStoreManager }
30 | val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
31 |
32 | // 当前使用的密文语种
33 | val ciphertextStyleType: String by scope.observeAsState(flowProvider = {
34 | dataStoreManager.getSettingFlow(SettingKeys.CIPHERTEXT_STYLE, CiphertextStyleType.NEKO.toString())
35 | },initialValue = CiphertextStyleType.NEKO.toString())
36 | // 密文长度词组最小值
37 | val ciphertextStyleLengthMin by scope.observeAsState(flowProvider = {
38 | dataStoreManager.getSettingFlow(SettingKeys.CIPHERTEXT_STYLE_LENGTH_MIN, 3)
39 | },initialValue = 1)
40 | // 密文长度词组最大值
41 | val ciphertextStyleLengthMax by scope.observeAsState(flowProvider = {
42 | dataStoreManager.getSettingFlow(SettingKeys.CIPHERTEXT_STYLE_LENGTH_MAX, 7)
43 | },initialValue = 1)
44 |
45 | private const val ALGORITHM = "AES"
46 | const val TRANSFORMATION = "AES/GCM/NoPadding"
47 | private const val KEY_SIZE_BITS = 256 // AES-256
48 | const val IV_LENGTH_BYTES = 16 // GCM 推荐的IV长度是12,为了该死的兼容改成16
49 | const val TAG_LENGTH_BITS = 128 // GCM 推荐的认证标签长度
50 |
51 | // 下面是一些映射表
52 | private val STEALTH_ALPHABET = (0xFE00..0xFE0F).map { it.toChar() }.joinToString("")
53 |
54 | /**
55 | * 为了高效解码,预先创建一个从“猫语”字符到其在字母表中索引位置的映射。
56 | * 这是一个关键的性能优化。
57 | */
58 | private val STEALTH_CHAR_TO_INDEX_MAP = STEALTH_ALPHABET.withIndex().associate { (index, char) -> char to index }
59 |
60 | /**
61 | * 生成一个符合 AES-256 要求的随机密钥。
62 | *
63 | * @return 一个 SecretKey 对象,包含了256位的密钥数据。
64 | */
65 | fun generateKey(): SecretKey {
66 | val keyGenerator = KeyGenerator.getInstance(ALGORITHM)
67 | keyGenerator.init(KEY_SIZE_BITS)
68 | return keyGenerator.generateKey()
69 | }
70 |
71 | /**
72 | * 加密一个消息,使用给定的密钥,返回的直接是隐写字符串
73 | */
74 | fun encrypt(message: String, key: String): String {
75 | val plaintextBytes = message.toByteArray(Charsets.UTF_8)
76 | val encryptedBytes = encryptBytes(plaintextBytes, key)
77 | return baseNEncode(encryptedBytes)
78 | }
79 | // 提供一个重载
80 | fun encrypt(data: ByteArray, key: String): ByteArray {
81 | return encryptBytes(data, key)
82 | }
83 |
84 | //消息解密,智能地从含密文的混合字符串中解密
85 | fun decrypt(stealthCiphertext: String, key: String): String? {
86 | val combinedBytes = baseNDecode(stealthCiphertext)
87 | val decryptedBytes = decryptBytes(combinedBytes, key)
88 | return decryptedBytes?.toString(Charsets.UTF_8)
89 | }
90 |
91 | fun decrypt(data: ByteArray, key: String): ByteArray? {
92 | return decryptBytes(data, key)
93 | }
94 |
95 | /**
96 | * ✨ [私有核心] 真正执行加密操作的函数
97 | */
98 | private fun encryptBytes(plaintextBytes: ByteArray, key: String): ByteArray {
99 | val iv = ByteArray(IV_LENGTH_BYTES)
100 | SecureRandom().nextBytes(iv) //填充随机内容
101 | val cipher = Cipher.getInstance(TRANSFORMATION)
102 | val parameterSpec = GCMParameterSpec(TAG_LENGTH_BITS, iv)
103 | cipher.init(Cipher.ENCRYPT_MODE, deriveKeyFromString(key), parameterSpec)
104 | val ciphertextBytes = cipher.doFinal(plaintextBytes)
105 | // 返回拼接了IV和密文的完整数据
106 | return iv + ciphertextBytes
107 | }
108 |
109 | /**
110 | * ✨ [私有核心] 真正执行解密操作的函数
111 | */
112 | private fun decryptBytes(combinedBytes: ByteArray, key: String): ByteArray? {
113 | try {
114 | if (combinedBytes.size < IV_LENGTH_BYTES) return null
115 |
116 | val iv = combinedBytes.copyOfRange(0, IV_LENGTH_BYTES)
117 | val ciphertextBytes = combinedBytes.copyOfRange(IV_LENGTH_BYTES, combinedBytes.size)
118 | val cipher = Cipher.getInstance(TRANSFORMATION)
119 | val parameterSpec = GCMParameterSpec(TAG_LENGTH_BITS, iv)
120 | cipher.init(Cipher.DECRYPT_MODE, deriveKeyFromString(key), parameterSpec)
121 |
122 | return cipher.doFinal(ciphertextBytes)
123 | } catch (e: AEADBadTagException) {
124 | println("解密失败:数据认证失败,可能已被篡改或密钥错误。\n" + e.message)
125 | return null
126 | } catch (e: Exception) {
127 | println("解密时发生未知错误: ${e.message}")
128 | return null
129 | }
130 | }
131 |
132 | /**
133 | * 判断给定字符串是否包含密文
134 | */
135 | fun String.containsCiphertext(): Boolean{
136 | return this.any { STEALTH_CHAR_TO_INDEX_MAP.containsKey(it) }
137 | }
138 |
139 | fun deriveKeyFromString(keyString: String): SecretKey {
140 | val digest = MessageDigest.getInstance("SHA-256")
141 | val keyBytes = digest.digest(keyString.toByteArray(Charsets.UTF_8))
142 | return SecretKeySpec(keyBytes, ALGORITHM)
143 | }
144 |
145 | // -----------------关键的baseN方法---------------------
146 |
147 | /**
148 | * 将字节数组编码为我们自定义的 BaseN 字符串。
149 | * 算法核心:通过大数运算,将 Base256 的数据转换为 BaseN。
150 | * @param data 原始二进制数据。
151 | * @return 编码后的“猫语”字符串。
152 | */
153 | private fun baseNEncode(data: ByteArray): String {
154 | if (data.isEmpty()) return ""
155 | // 使用 BigInteger 来处理任意长度的二进制数据,避免溢出。
156 | // 构造函数 `BigInteger(1, data)` 确保数字被解释为正数。
157 | var bigInt = BigInteger(1, data)
158 | val base = BigInteger.valueOf(STEALTH_ALPHABET.length.toLong())
159 | val builder = StringBuilder()
160 | while (bigInt > BigInteger.ZERO) {
161 | // 除基取余法
162 | val (quotient, remainder) = bigInt.divideAndRemainder(base)
163 | bigInt = quotient
164 | builder.append(STEALTH_ALPHABET[remainder.toInt()])
165 | }
166 | // 因为是从低位开始添加的,所以需要反转得到正确的顺序
167 | return builder.reverse().toString()
168 | }
169 |
170 | /**
171 | * 将我们自定义的 BaseN 字符串解码回字节数组。
172 | * 算法核心:通过大数运算,将 BaseN 的数据转换回 Base256。
173 | * @param encodedString 编码后的“猫语”字符串,可能混杂有其他字符。
174 | * @return 原始二进制数据。
175 | */
176 | private fun baseNDecode(encodedString: String): ByteArray {
177 | var bigInt = BigInteger.ZERO
178 | val base = BigInteger.valueOf(STEALTH_ALPHABET.length.toLong())
179 | // 遍历字符串,只处理在“猫语字典”中存在的字符
180 | // 乘基加权法。
181 | encodedString.forEach { char ->
182 | val index = STEALTH_CHAR_TO_INDEX_MAP[char]
183 | if (index != null) {
184 | // 核心算法: result = result * base + index
185 | bigInt = bigInt.multiply(base).add(BigInteger.valueOf(index.toLong()))
186 | }
187 | }
188 | // 如果解码结果为0,直接返回空数组
189 | if (bigInt == BigInteger.ZERO) return ByteArray(0)
190 |
191 | // BigInteger.toByteArray() 可能会在开头添加一个0字节来表示正数,我们需要去掉它
192 | val bytes = bigInt.toByteArray()
193 | return if (bytes[0].toInt() == 0) {
194 | bytes.copyOfRange(1, bytes.size)
195 | } else { bytes }
196 | }
197 |
198 | // -- 通过inputStream和outputStream来流式解密 --
199 | /**
200 | * 为 AES/GCM 实现的、真正安全的流式解密方法
201 | * 它会从输入流中读取加密数据,解密后写入输出流。
202 | * @param inputStream 包含加密数据的输入流 (必须是已经跳过GIF头的数据)
203 | * @param outputStream 用于写入解密后数据的输出流
204 | * @param key 用于解密的密钥
205 | */
206 | fun decryptStream(inputStream: InputStream, outputStream: OutputStream, key: String){
207 | val iv = ByteArray(IV_LENGTH_BYTES)
208 | require(inputStream.read(iv) == IV_LENGTH_BYTES) {
209 | "输入流太短,无法读取IV。"
210 | }
211 |
212 | // 2. 初始化 Cipher
213 | val cipher = Cipher.getInstance(TRANSFORMATION).apply {
214 | val spec = GCMParameterSpec(TAG_LENGTH_BITS, iv)
215 | init(Cipher.DECRYPT_MODE, deriveKeyFromString(key), spec)
216 | }
217 |
218 | // 3. 边读边解密边写
219 | val buffer = ByteArray(8 * 1024)
220 | while (true) {
221 | val read = inputStream.read(buffer)
222 | if (read == -1) break
223 | cipher.update(buffer, 0, read)?.let { outputStream.write(it) }
224 | }
225 |
226 | // 4. 关键!在所有数据都处理完后,调用 doFinal 来验证“防伪标签”
227 | try {
228 | cipher.doFinal()?.let { outputStream.write(it) } // 验证通过后,就doFinal做检验,校验不过抛出错误。
229 | } catch (e: AEADBadTagException) {
230 | throw SecurityException("解密失败,数据可能被篡改或密钥错误", e)
231 | }
232 | }
233 |
234 | /**
235 | * ✨ 全新:根据用户设置,为密文应用伪装文本样式。
236 | *
237 | * @return 伪装后的、包含随机语言和真实密文的最终字符串。
238 | */
239 | fun String.applyCiphertextStyle(): String {
240 | // 拿到对应枚举类
241 | val styleType: CiphertextStyleType = CiphertextStyleType.fromName(ciphertextStyleType)
242 | // 3. 获取该风格下的所有可用词组
243 | val content = styleType.content
244 | // 管理最大最小值
245 | val finalMin = minOf(ciphertextStyleLengthMin, ciphertextStyleLengthMax)
246 | val finalMax = maxOf(ciphertextStyleLengthMin, ciphertextStyleLengthMax)
247 | // 如果 finalMin 和 finalMax 相等,直接取这个值,否则在范围内取随机数
248 | val count = if (finalMin == finalMax) finalMin else Random.nextInt(finalMin, finalMax + 1)
249 |
250 | // 5. 随机挑选词组并拼接成伪装文本
251 | val decorativeText = buildString {
252 | repeat(count) {
253 | append(content.random())
254 | }
255 | }
256 |
257 | val middleIndex = decorativeText.length / 2
258 | return decorativeText.substring(0, middleIndex) + this + decorativeText.substring(middleIndex)
259 | }
260 |
261 | }
262 |
263 | enum class CiphertextStyleType(val displayNameResId:Int,val content:List){
264 | NEKO(
265 | displayNameResId = R.string.cipher_style_neko, // 猫娘语
266 | content = listOf("嗷呜!", "咕噜~", "喵~", "喵咕~", "喵喵~", "喵?", "喵喵!", "哈!", "喵呜...", "咪咪喵!", "咕咪?")
267 | ),
268 | BANGBOO(
269 | displayNameResId = R.string.cipher_style_bangboo, // 邦布语
270 | content = listOf("嗯呢...", "哇哒!", "嗯呢!", "嗯呢哒!", "嗯呐呐!", "嗯哒!", "嗯呢呢!")
271 | ),
272 | HILICHURLIAN(
273 | displayNameResId = R.string.cipher_style_Hilichurlian, //丘丘语
274 | content = listOf("Muhe ye!", "Ye dada!", "Ya yika!", "Biat ye!", "Dala si?", "Yaya ika!", "Mi? Dada!",
275 | "ye pupu!", "gusha dada!","Dala?","Mosi mita!","Mani ye!","Biat ye!","Todo yo.","tiga mitono!","Biat, gusha!","Unu dada!","Mimi movo!")
276 | ),
277 | NIER(
278 | displayNameResId = R.string.cipher_style_nier, // 尼尔语
279 | content = listOf(
280 | "Ee ", "ser ", "les ", "hii ", "san ", "mia ", "ni ", "Escalei ", "lu ", "push ", "to ", "lei ",
281 | "Schmosh ", "juna ", "wu ", "ria ", "e ", "je ", "cho ", "no ",
282 | "Nasico ", "whosh ", "pier ", "wa ", "nei ", "Wananba ", "he ", "na ", "qua ", "lei ",
283 | "Sila ", "schmer ", "ya ", "pi ", "pa ", "lu ", "Un ", "schen ", "ta ", "tii ", "pia ", "pa ", "ke ", "lo ")
284 | ),
285 | MANBO(
286 | displayNameResId = R.string.cipher_style_manbo, // 曼波!
287 | content = listOf("曼波~","哈吉米~","哈吉米咩那咩路多~","曼波!","曼波...","欧码叽哩,曼波!","叮咚鸡!","哈压库!","哈压库~","哈吉米!","哦耶~","duang~")
288 | );
289 | companion object{
290 | // 辅助函数
291 | fun fromName(name:String): CiphertextStyleType{
292 | return entries.find { it.name == name.uppercase(getDefault()) } ?:NEKO
293 | }
294 | }
295 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/component/DecryptionPopup.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.component
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.core.Animatable
5 | import androidx.compose.animation.core.LinearEasing
6 | import androidx.compose.animation.core.Spring
7 | import androidx.compose.animation.core.spring
8 | import androidx.compose.animation.core.tween
9 | import androidx.compose.animation.fadeIn
10 | import androidx.compose.animation.fadeOut
11 | import androidx.compose.animation.scaleIn
12 | import androidx.compose.animation.scaleOut
13 | import androidx.compose.foundation.BorderStroke
14 | import androidx.compose.foundation.layout.Box
15 | import androidx.compose.foundation.layout.Row
16 | import androidx.compose.foundation.layout.Spacer
17 | import androidx.compose.foundation.layout.padding
18 | import androidx.compose.foundation.layout.size
19 | import androidx.compose.foundation.layout.width
20 | import androidx.compose.foundation.layout.wrapContentSize
21 | import androidx.compose.foundation.layout.wrapContentWidth
22 | import androidx.compose.foundation.shape.RoundedCornerShape
23 | import androidx.compose.material.icons.Icons
24 | import androidx.compose.material.icons.filled.Close
25 | import androidx.compose.material.icons.filled.FileOpen
26 | import androidx.compose.material.icons.filled.Image
27 | import androidx.compose.material3.ButtonDefaults
28 | import androidx.compose.material3.Card
29 | import androidx.compose.material3.CardDefaults
30 | import androidx.compose.material3.CircularProgressIndicator
31 | import androidx.compose.material3.Icon
32 | import androidx.compose.material3.IconButton
33 | import androidx.compose.material3.MaterialTheme
34 | import androidx.compose.material3.Text
35 | import androidx.compose.material3.TextButton
36 | import androidx.compose.runtime.Composable
37 | import androidx.compose.runtime.LaunchedEffect
38 | import androidx.compose.runtime.getValue
39 | import androidx.compose.runtime.mutableStateOf
40 | import androidx.compose.runtime.remember
41 | import androidx.compose.runtime.setValue
42 | import androidx.compose.ui.Alignment
43 | import androidx.compose.ui.Modifier
44 | import androidx.compose.ui.draw.shadow
45 | import androidx.compose.ui.geometry.Offset
46 | import androidx.compose.ui.graphics.Shadow
47 | import androidx.compose.ui.text.TextStyle
48 | import androidx.compose.ui.unit.dp
49 | import androidx.compose.ui.unit.sp
50 | import kotlinx.coroutines.delay
51 | import me.wjz.nekocrypt.service.handler.LocalFileActionHandler
52 | import me.wjz.nekocrypt.ui.theme.NekoCryptTheme
53 | import me.wjz.nekocrypt.util.NCFileProtocol
54 | import me.wjz.nekocrypt.util.NCFileType
55 |
56 |
57 | /**
58 | * 一个独立的、可复用的解密弹窗 Composable UI
59 | * 它只关心需要显示什么文本 (text),以及被关闭时该做什么 (onDismiss)。
60 | * 它完全不知道什么是无障碍服务,什么是处理器。
61 | */
62 | @Composable
63 | fun DecryptionPopup(
64 | decryptedText: String, durationMills: Long = 3000, onDismiss: () -> Unit,
65 | ) {
66 | // 增加判断,看需要展示纯文本还是图片or文件。
67 | val fileProtocol: NCFileProtocol? = NCFileProtocol.fromString(decryptedText)
68 |
69 | if (fileProtocol != null) {
70 | // --- 情况A:是文件协议,并且成功解析 ---
71 | DecryptedFilePopupContent(
72 | fileInfo = fileProtocol,
73 | onDismiss = onDismiss,
74 | durationMills = durationMills
75 | )
76 | } else {
77 | // --- 情况B:是普通文本,或者协议解析失败 ---
78 | DecryptedTextPopupContent(
79 | text = decryptedText,
80 | onDismiss = onDismiss,
81 | durationMills = durationMills
82 | )
83 | }
84 | }
85 |
86 |
87 | /**
88 | * 负责显示普通文本的弹窗
89 | */
90 | @Composable
91 | private fun DecryptedTextPopupContent(
92 | text: String,
93 | onDismiss: () -> Unit,
94 | durationMills: Long,
95 | ) {
96 | val animationTime = 250
97 | var isVisible by remember { mutableStateOf(false) }
98 | val progress = remember { Animatable(1.0f) }
99 |
100 | LaunchedEffect(Unit) {
101 | isVisible = true // 触发出现
102 | progress.animateTo(
103 | 0.0f,
104 | animationSpec = tween(durationMills.toInt(), easing = LinearEasing)
105 | )
106 | isVisible = false // 倒计时结束后,触发消失
107 | }
108 |
109 | LaunchedEffect(isVisible) {
110 | if (!isVisible) {
111 | delay(animationTime.toLong()) // 等待消失动画播放完毕
112 | onDismiss() // 动画完全结束后,才真正调用 onDismiss
113 | }
114 | }
115 |
116 | NekoCryptTheme(darkTheme = false) {
117 | Box(modifier = Modifier.padding(16.dp)) {
118 | AnimatedVisibility(
119 | visible = isVisible,
120 | enter = scaleIn(
121 | animationSpec = spring(
122 | dampingRatio = Spring.DampingRatioLowBouncy,
123 | stiffness = Spring.StiffnessLow
124 | )
125 | ) + fadeIn(animationSpec = tween(animationTime)),
126 | exit = scaleOut(animationSpec = tween(animationTime)) + fadeOut(
127 | animationSpec = tween(animationTime)
128 | )
129 | ) {
130 | Card(
131 | modifier = Modifier
132 | .wrapContentSize()
133 | .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)),
134 | shape = RoundedCornerShape(12.dp),
135 | colors = CardDefaults.cardColors(
136 | containerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(
137 | alpha = 0.92f
138 | )
139 | ),
140 | border = BorderStroke(
141 | 1.dp,
142 | MaterialTheme.colorScheme.outline.copy(alpha = 0.8f)
143 | )
144 | ) {
145 | Row(
146 | modifier = Modifier.padding(
147 | start = 12.dp,
148 | top = 8.dp,
149 | bottom = 8.dp,
150 | end = 8.dp
151 | ),
152 | verticalAlignment = Alignment.CenterVertically
153 | ) {
154 | Text(
155 | text = text,
156 | fontSize = 18.sp,
157 | color = MaterialTheme.colorScheme.primary,
158 | style = TextStyle(
159 | shadow = Shadow(
160 | color = MaterialTheme.colorScheme.onPrimary.copy(
161 | alpha = 0.5f
162 | ), offset = Offset(2f, 2f), blurRadius = 4f
163 | )
164 | ),
165 | modifier = Modifier.weight(1f, fill = false)
166 | )
167 | Spacer(modifier = Modifier.width(8.dp))
168 | Box(
169 | contentAlignment = Alignment.Center,
170 | modifier = Modifier.size(25.dp)
171 | ) {
172 | CircularProgressIndicator(
173 | progress = { progress.value },
174 | modifier = Modifier.size(25.dp),
175 | color = MaterialTheme.colorScheme.primary,
176 | strokeWidth = 2.dp
177 | )
178 | IconButton(onClick = {
179 | isVisible = false
180 | }) {
181 | Icon(
182 | Icons.Default.Close,
183 | contentDescription = "关闭",
184 | tint = MaterialTheme.colorScheme.onSurfaceVariant
185 | )
186 | }
187 | }
188 | }
189 | }
190 | }
191 | }
192 | }
193 | }
194 |
195 |
196 | /**
197 | * 文件展示,包含图片和普通文件。
198 | */
199 | @Composable
200 | private fun DecryptedFilePopupContent(
201 | fileInfo: NCFileProtocol,
202 | onDismiss: () -> Unit,
203 | durationMills: Long,
204 | ) {
205 | val animationTime = 250
206 | var isVisible by remember { mutableStateOf(false) }
207 | val progress = remember { Animatable(1.0f) }
208 |
209 | LaunchedEffect(Unit) {
210 | isVisible = true // 触发出现
211 | progress.animateTo(
212 | 0.0f,
213 | animationSpec = tween(durationMills.toInt(), easing = LinearEasing)
214 | )
215 | isVisible = false // 倒计时结束后,触发消失
216 | }
217 |
218 | LaunchedEffect(isVisible) {
219 | if (!isVisible) {
220 | delay(animationTime.toLong()) // 等待消失动画播放完毕
221 | onDismiss() // 动画完全结束后,才真正调用 onDismiss
222 | }
223 | }
224 |
225 | NekoCryptTheme(darkTheme = false) {
226 | Box(modifier = Modifier.padding(16.dp)) {
227 | AnimatedVisibility(
228 | visible = isVisible,
229 | enter = scaleIn(
230 | animationSpec = spring(
231 | dampingRatio = Spring.DampingRatioLowBouncy,
232 | stiffness = Spring.StiffnessLow
233 | )
234 | ) + fadeIn(animationSpec = tween(animationTime)),
235 | exit = scaleOut(animationSpec = tween(animationTime)) + fadeOut(
236 | animationSpec = tween(animationTime)
237 | )
238 | ) {
239 | Card(
240 | modifier = Modifier
241 | .wrapContentSize()
242 | .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)),
243 | shape = RoundedCornerShape(12.dp),
244 | colors = CardDefaults.cardColors(
245 | containerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(
246 | alpha = 0.92f
247 | )
248 | ),
249 | border = BorderStroke(
250 | 1.dp,
251 | MaterialTheme.colorScheme.outline.copy(alpha = 0.8f)
252 | )
253 | ) {
254 | Row(
255 | modifier = Modifier.padding(
256 | start = 12.dp,
257 | top = 8.dp,
258 | bottom = 8.dp,
259 | end = 8.dp
260 | ),
261 | verticalAlignment = Alignment.CenterVertically
262 | ) {
263 | // 用来显示文件or图片
264 | FileButton(fileInfo)
265 |
266 | Spacer(modifier = Modifier.width(8.dp))
267 | Box(
268 | contentAlignment = Alignment.Center,
269 | modifier = Modifier.size(25.dp)
270 | ) {
271 | CircularProgressIndicator(
272 | progress = { progress.value },
273 | modifier = Modifier.size(25.dp),
274 | color = MaterialTheme.colorScheme.primary,
275 | strokeWidth = 2.dp
276 | )
277 | IconButton(onClick = {
278 | isVisible = false
279 | }) {
280 | Icon(
281 | Icons.Default.Close,
282 | contentDescription = "关闭",
283 | tint = MaterialTheme.colorScheme.onSurfaceVariant
284 | )
285 | }
286 | }
287 | }
288 | }
289 | }
290 | }
291 | }
292 | }
293 |
294 | @Composable
295 | fun FileButton(
296 | fileInfo: NCFileProtocol,
297 | ) {
298 | val onFileClick = LocalFileActionHandler.current
299 |
300 | TextButton(
301 | onClick = {
302 | onFileClick?.invoke(fileInfo)
303 | },
304 | modifier = Modifier.wrapContentWidth(),
305 | shape = RoundedCornerShape(12.dp),
306 | colors = ButtonDefaults.textButtonColors(
307 | containerColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.95f)
308 | ),
309 | border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.8f))
310 | ) {
311 | when (fileInfo.type) {
312 | NCFileType.IMAGE -> Icon(
313 | Icons.Default.Image,
314 | contentDescription = "click to show image"
315 | )
316 |
317 | NCFileType.FILE -> Icon(
318 | Icons.Default.FileOpen,
319 | contentDescription = "click to show file"
320 | )
321 | }
322 | Spacer(Modifier.width(8.dp))
323 | Text(
324 | text = fileInfo.name,
325 | fontSize = 18.sp,
326 | color = MaterialTheme.colorScheme.primary,
327 | style = TextStyle(
328 | shadow = Shadow(
329 | color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),
330 | offset = Offset(2f, 2f),
331 | blurRadius = 4f
332 | )
333 | ),
334 | modifier = Modifier.weight(1f, fill = false)
335 | )
336 | }
337 | }
338 |
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/screen/SettingsScreen.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.screen
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.util.Log
6 | import android.widget.Toast
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.lazy.LazyColumn
11 | import androidx.compose.material.icons.Icons
12 | import androidx.compose.material.icons.filled.Build
13 | import androidx.compose.material.icons.filled.Info
14 | import androidx.compose.material.icons.filled.Link
15 | import androidx.compose.material.icons.outlined.GraphicEq
16 | import androidx.compose.material.icons.outlined.Timer
17 | import androidx.compose.material3.AlertDialog
18 | import androidx.compose.material3.DividerDefaults
19 | import androidx.compose.material3.HorizontalDivider
20 | import androidx.compose.material3.Icon
21 | import androidx.compose.material3.Text
22 | import androidx.compose.material3.TextButton
23 | import androidx.compose.runtime.Composable
24 | import androidx.compose.runtime.getValue
25 | import androidx.compose.runtime.mutableStateOf
26 | import androidx.compose.runtime.remember
27 | import androidx.compose.runtime.rememberCoroutineScope
28 | import androidx.compose.runtime.setValue
29 | import androidx.compose.ui.Modifier
30 | import androidx.compose.ui.platform.LocalContext
31 | import androidx.compose.ui.res.stringResource
32 | import androidx.compose.ui.unit.dp
33 | import androidx.core.net.toUri
34 | import kotlinx.coroutines.CoroutineScope
35 | import kotlinx.coroutines.Dispatchers
36 | import kotlinx.coroutines.launch
37 | import kotlinx.coroutines.withContext
38 | import kotlinx.serialization.SerialName
39 | import kotlinx.serialization.Serializable
40 | import kotlinx.serialization.json.Json
41 | import me.wjz.nekocrypt.NekoCryptApp
42 | import me.wjz.nekocrypt.R
43 | import me.wjz.nekocrypt.SettingKeys
44 | import me.wjz.nekocrypt.ui.ClickableSettingItem
45 | import me.wjz.nekocrypt.ui.ColorSettingItem
46 | import me.wjz.nekocrypt.ui.RangeSliderSettingItem
47 | import me.wjz.nekocrypt.ui.SettingsHeader
48 | import me.wjz.nekocrypt.ui.SliderSettingItem
49 | import java.io.BufferedReader
50 | import java.net.HttpURLConnection
51 | import java.net.URL
52 |
53 | @Composable
54 | fun SettingsScreen(modifier: Modifier = Modifier) {
55 | var showAboutDialog by remember { mutableStateOf(false) }
56 | val context = LocalContext.current
57 | val scope = rememberCoroutineScope() // 获取协程作用域,用于执行异步任务
58 | LazyColumn(
59 | modifier = modifier.fillMaxSize(),
60 | // verticalArrangement = Arrangement.spacedBy(16.dp), 选项之间不需要间隔
61 | ) {
62 | // 第一个分组:加解密设置
63 | item {
64 | SettingsHeader(stringResource(R.string.crypto_settings))
65 | }
66 | item {
67 | HorizontalDivider(
68 | modifier = Modifier.padding(horizontal = 16.dp),
69 | thickness = DividerDefaults.Thickness,
70 | color = DividerDefaults.color
71 | )
72 | }
73 | item {
74 | SliderSettingItem( // 长按发送密文所需时间
75 | key = SettingKeys.ENCRYPTION_LONG_PRESS_DELAY,
76 | defaultValue = 500L, // 默认 500 毫秒
77 | icon = { Icon(Icons.Outlined.Timer, contentDescription = "Long Press Delay") },
78 | title = stringResource(R.string.decryption_long_press_delay),
79 | subtitle = stringResource(R.string.decryption_long_press_delay_desc),
80 | valueRange = 50L..1000L, // 允许用户在 200ms 到 1500ms 之间选择
81 | step = 50L //每50ms一个挡位
82 | )
83 | }
84 | item {
85 | HorizontalDivider(
86 | modifier = Modifier.padding(horizontal = 16.dp),
87 | thickness = DividerDefaults.Thickness,
88 | color = DividerDefaults.color
89 | )
90 | }
91 | item {
92 | SliderSettingItem( // 点击密文解密的所需时间。
93 | key = SettingKeys.DECRYPTION_WINDOW_SHOW_TIME,
94 | defaultValue = 500L, // 默认 500 毫秒
95 | icon = { Icon(Icons.Outlined.Timer, contentDescription = "Long Press Delay") },
96 | title = stringResource(R.string.decryption_window_show_time),
97 | subtitle = stringResource(R.string.decryption_window_show_time_desc),
98 | valueRange = 500L..3000L,
99 | step = 250L // 单步步长
100 | )
101 | }
102 | item {
103 | HorizontalDivider(
104 | modifier = Modifier.padding(horizontal = 16.dp),
105 | thickness = DividerDefaults.Thickness,
106 | color = DividerDefaults.color
107 | )
108 | }
109 | item {
110 | // 区间选择器
111 | RangeSliderSettingItem(
112 | minKey = SettingKeys.CIPHERTEXT_STYLE_LENGTH_MIN,
113 | maxKey = SettingKeys.CIPHERTEXT_STYLE_LENGTH_MAX,
114 | defaultMin = 3,
115 | defaultMax = 7,
116 | icon = { Icon(Icons.Outlined.GraphicEq, contentDescription = "Ciphertext Length") },
117 | title = stringResource(R.string.ciphertext_length_title),
118 | subtitle = stringResource(R.string.ciphertext_length_subtitle),
119 | valueRange = 1..10,
120 | step = 1
121 | )
122 | }
123 | item {
124 | HorizontalDivider(
125 | modifier = Modifier.padding(horizontal = 16.dp),
126 | thickness = DividerDefaults.Thickness,
127 | color = DividerDefaults.color
128 | )
129 | }
130 | // ————————————————————————————————
131 |
132 | // 第二个分组,界面相关设置
133 | item {
134 | SettingsHeader(stringResource(R.string.crypto_ui_settings))
135 | }
136 | item {
137 | HorizontalDivider(
138 | modifier = Modifier.padding(horizontal = 16.dp),
139 | thickness = DividerDefaults.Thickness,
140 | color = DividerDefaults.color
141 | )
142 | }
143 | item {
144 | SliderSettingItem( // 沉浸式下,密文位置更新间隔
145 | key = SettingKeys.DECRYPTION_WINDOW_POSITION_UPDATE_DELAY,
146 | defaultValue = 250L, // 默认 250
147 | icon = { Icon(Icons.Outlined.Timer, contentDescription = "position update delay") },
148 | title = stringResource(R.string.decryption_window_position_update_delay),
149 | subtitle = stringResource(R.string.decryption_window_position_update_delay_desc),
150 | valueRange = 0L..1000L,
151 | step = 50L // 单步步长
152 | )
153 | }
154 | item {
155 | HorizontalDivider(
156 | modifier = Modifier.padding(horizontal = 16.dp),
157 | thickness = DividerDefaults.Thickness,
158 | color = DividerDefaults.color
159 | )
160 | }
161 | // ————————————————————————————————
162 | item {
163 | ColorSettingItem(
164 | key = SettingKeys.SEND_BTN_OVERLAY_COLOR,
165 | defaultValue = "#5066ccff",
166 | title = stringResource(R.string.send_btn_overlay_color),
167 | subtitle = stringResource(R.string.send_btn_overlay_color_desc)
168 | )
169 | }
170 | item {
171 | HorizontalDivider(
172 | modifier = Modifier.padding(horizontal = 16.dp),
173 | thickness = DividerDefaults.Thickness,
174 | color = DividerDefaults.color
175 | )
176 | }
177 | item {
178 | SliderSettingItem( // 控制拉起附件发送悬浮窗的时间间隔。
179 | key = SettingKeys.SHOW_ATTACHMENT_VIEW_DOUBLE_CLICK_THRESHOLD,
180 | defaultValue = 250L,
181 | icon = { Icon(Icons.Outlined.Timer, contentDescription = "Long Press Delay") },
182 | title = stringResource(R.string.double_click_threshold),
183 | subtitle = stringResource(R.string.double_click_threshold_desc),
184 | valueRange = 250L..1000L, // 允许用户在 200ms 到 1500ms 之间选择
185 | step = 250L //每50ms一个挡位
186 | )
187 | }
188 | item {
189 | HorizontalDivider(
190 | modifier = Modifier.padding(horizontal = 16.dp),
191 | thickness = DividerDefaults.Thickness,
192 | color = DividerDefaults.color
193 | )
194 | }
195 |
196 | // 第二个分组:关于
197 | item {
198 | SettingsHeader("关于")
199 | }
200 | item {
201 | ClickableSettingItem(
202 | icon = { Icon(Icons.Default.Info, contentDescription = "About App") },
203 | title = "关于 NekoCrypt",
204 | onClick = { showAboutDialog=true }
205 | )
206 | }
207 | item {
208 | HorizontalDivider(
209 | modifier = Modifier.padding(horizontal = 16.dp),
210 | thickness = DividerDefaults.Thickness,
211 | color = DividerDefaults.color
212 | )
213 | }
214 | item {
215 | val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
216 | ClickableSettingItem(
217 | icon = { Icon(Icons.Default.Build, contentDescription = "Version") },
218 | // ✨ 在 title 的 Composable 槽位里,自定义我们的布局!
219 | title = stringResource(R.string.version,versionName?:"unknown"),
220 | onClick = { handleCheckUpdate(context, scope, versionName?:"N/A") }
221 | )
222 | }
223 | item {
224 | HorizontalDivider(
225 | modifier = Modifier.padding(horizontal = 16.dp),
226 | thickness = DividerDefaults.Thickness,
227 | color = DividerDefaults.color
228 | )
229 | }
230 | item {
231 | ClickableSettingItem(
232 | icon = { Icon(Icons.Default.Link, contentDescription = "GitHub Link") },
233 | title = stringResource(R.string.github),
234 | onClick = {
235 | val intent = Intent(Intent.ACTION_VIEW, "https://github.com/WJZ-P/NekoCrypt".toUri())
236 | context.startActivity(intent)
237 | }
238 | )
239 | }
240 |
241 | }
242 |
243 | if(showAboutDialog){
244 | AboutDialog(onDismissRequest = {showAboutDialog = false})
245 | }
246 | }
247 |
248 | /**
249 | * 用于显示关于信息的对话框
250 | */
251 | @Composable
252 | private fun AboutDialog(onDismissRequest: () -> Unit) {
253 | AlertDialog(
254 | onDismissRequest = onDismissRequest,
255 | icon = { Icon(Icons.Default.Info, contentDescription = null) },
256 | title = { Text(text = stringResource(R.string.about_dialog_title)) },
257 | text = {
258 | Column {
259 | Text(stringResource(R.string.about_dialog_content))
260 | // 你可以在这里添加更多信息,比如版本号、作者、开源链接等
261 | }
262 | },
263 | confirmButton = {
264 | TextButton(onClick = onDismissRequest) {
265 | Text(stringResource(R.string.accept))
266 | }
267 | }
268 | )
269 | }
270 |
271 | /**
272 | * 一个用于解析 GitHub API /releases/latest 端点返回的 JSON 的数据类。
273 | * @Serializable 注解让它可以被 kotlinx.serialization 库处理。
274 | * @SerialName 注解用于将 JSON 中的 snake_case 字段名映射到我们的 camelCase 属性名。
275 | */
276 | @Serializable
277 | data class GitHubRelease(
278 | @SerialName("tag_name")
279 | val tagName: String, // 版本标签,例如 "v1.1.0"
280 |
281 | @SerialName("html_url")
282 | val htmlUrl: String, // 该发布页面的网址
283 | )
284 |
285 | private fun handleCheckUpdate(context: Context, scope: CoroutineScope, versionName: String) {
286 | scope.launch {
287 | withContext(Dispatchers.Main) {
288 | Toast.makeText(context, context.getString(R.string.checking_for_update), Toast.LENGTH_SHORT).show()
289 | }
290 |
291 | // 切换到 IO 线程执行网络请求
292 | val latestRelease: GitHubRelease? = withContext(Dispatchers.IO) {
293 | try {
294 | val url = URL("https://api.github.com/repos/WJZ-P/NekoCrypt/releases/latest")
295 | val connection = url.openConnection() as HttpURLConnection
296 | val jsonText = connection.inputStream.bufferedReader().use(BufferedReader::readText)
297 | Log.d(NekoCryptApp.TAG,jsonText)
298 |
299 | // 使用 kotlinx.serialization 解析 JSON
300 | Json { ignoreUnknownKeys = true }.decodeFromString(jsonText)
301 |
302 | } catch (e: Exception) {
303 | e.printStackTrace()
304 | null
305 | }
306 | }
307 |
308 | // 回到主线程更新 UI
309 | withContext(Dispatchers.Main) {
310 | if (latestRelease == null) {
311 | Toast.makeText(context, context.getString(R.string.check_for_update_failed), Toast.LENGTH_SHORT).show()
312 | return@withContext
313 | }
314 |
315 | // 比较版本号(简单地移除 'v' 前缀进行比较)
316 | val latestVersionName = latestRelease.tagName.removePrefix("v")
317 |
318 | if (versionName != "N/A" && latestVersionName > versionName) {
319 | Toast.makeText(context, context.getString(R.string.check_for_update_failed,latestRelease.tagName), Toast.LENGTH_SHORT).show()
320 | // 引导用户去发布页面查看
321 | val intent = Intent(Intent.ACTION_VIEW, latestRelease.htmlUrl.toUri())
322 | context.startActivity(intent)
323 | } else {
324 | Toast.makeText(context, context.getString(R.string.is_newest_version), Toast.LENGTH_SHORT).show()
325 | }
326 | }
327 | }
328 | }
--------------------------------------------------------------------------------
/app/src/main/java/me/wjz/nekocrypt/ui/component/CapPawButton.kt:
--------------------------------------------------------------------------------
1 | package me.wjz.nekocrypt.ui.component
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.compose.animation.AnimatedContent
5 | import androidx.compose.animation.SizeTransform
6 | import androidx.compose.animation.animateColorAsState
7 | import androidx.compose.animation.core.animateDpAsState
8 | import androidx.compose.animation.core.animateFloatAsState
9 | import androidx.compose.animation.core.tween
10 | import androidx.compose.animation.fadeIn
11 | import androidx.compose.animation.fadeOut
12 | import androidx.compose.animation.slideInVertically
13 | import androidx.compose.animation.slideOutVertically
14 | import androidx.compose.animation.togetherWith
15 | import androidx.compose.foundation.Canvas
16 | import androidx.compose.foundation.clickable
17 | import androidx.compose.foundation.interaction.MutableInteractionSource
18 | import androidx.compose.foundation.layout.Arrangement
19 | import androidx.compose.foundation.layout.BoxWithConstraints
20 | import androidx.compose.foundation.layout.Column
21 | import androidx.compose.foundation.layout.fillMaxSize
22 | import androidx.compose.foundation.layout.size
23 | import androidx.compose.foundation.shape.CircleShape
24 | import androidx.compose.material3.MaterialTheme
25 | import androidx.compose.material3.Surface
26 | import androidx.compose.material3.Text
27 | import androidx.compose.runtime.Composable
28 | import androidx.compose.runtime.LaunchedEffect
29 | import androidx.compose.runtime.getValue
30 | import androidx.compose.runtime.mutableFloatStateOf
31 | import androidx.compose.runtime.remember
32 | import androidx.compose.runtime.setValue
33 | import androidx.compose.runtime.withFrameNanos
34 | import androidx.compose.ui.Alignment
35 | import androidx.compose.ui.Modifier
36 | import androidx.compose.ui.draw.clip
37 | import androidx.compose.ui.draw.shadow
38 | import androidx.compose.ui.geometry.Offset
39 | import androidx.compose.ui.geometry.Size
40 | import androidx.compose.ui.graphics.Brush
41 | import androidx.compose.ui.graphics.StrokeCap
42 | import androidx.compose.ui.graphics.drawscope.Stroke
43 | import androidx.compose.ui.graphics.drawscope.rotate
44 | import androidx.compose.ui.text.font.FontWeight
45 | import androidx.compose.ui.text.style.TextAlign
46 | import androidx.compose.ui.unit.dp
47 | import androidx.compose.ui.unit.min
48 | import androidx.compose.ui.unit.sp
49 |
50 | private object CatPawDefaults {
51 | const val RING_STROKE_WIDTH = 14f // 外围圆弧段落粗度
52 | const val PAW_STROKE_WIDTH = 15f
53 | const val DESIGN_BASIS_DP = 290f
54 |
55 | // --- 尺寸比例 ---
56 | const val RING_ENABLED_RATIO = 1f // 激活时外圈尺寸比例 (等于基准大小)
57 | const val RING_DISABLED_RATIO = 270f / DESIGN_BASIS_DP // 未激活时外圈的尺寸比例
58 | const val CENTER_BUTTON_RATIO = 260f / DESIGN_BASIS_DP
59 | const val PAW_CANVAS_RATIO = 110f / DESIGN_BASIS_DP
60 |
61 | // --- 字体大小比例 ---
62 | const val FONT_SIZE_RATIO = 20f / DESIGN_BASIS_DP
63 | const val MIN_FONT_SIZE_SP = 12f
64 |
65 | // --- 猫爪内部绘制比例 (相对于猫爪Canvas) ---
66 | const val PALM_WIDTH_RATIO = 0.6f
67 | const val PALM_HEIGHT_RATIO = 0.45f
68 | const val PALM_Y_OFFSET_RATIO = 0.2f
69 | const val TOE_RADIUS_RATIO = 0.1f
70 |
71 | // --- 猫爪脚趾基础位置比例 (相对于猫爪Canvas) ---
72 | const val OUTER_TOE_X_RATIO = 0.35f
73 | const val OUTER_TOE_Y_RATIO = 0.08f
74 | const val INNER_TOE_X_RATIO = 0.15f
75 | const val INNER_TOE_Y_RATIO = 0.25f
76 |
77 | // --- 猫爪脚趾激活状态位移 (基于原始设计尺寸) ---
78 | const val PALM_Y_SHIFT = -10f
79 | const val OUTER_LEFT_TOE_X_SHIFT = -18f
80 | const val OUTER_LEFT_TOE_Y_SHIFT = -15f
81 | const val INNER_LEFT_TOE_X_SHIFT = -10f
82 | const val INNER_LEFT_TOE_Y_SHIFT = -25f
83 | const val INNER_RIGHT_TOE_X_SHIFT = 10f
84 | const val INNER_RIGHT_TOE_Y_SHIFT = -25f
85 | const val OUTER_RIGHT_TOE_X_SHIFT = 18f
86 | const val OUTER_RIGHT_TOE_Y_SHIFT = -15f
87 | }
88 |
89 | /**
90 | * ✨ 响应式猫爪按钮
91 | * 它会根据父组件提供的空间,自动调整自身大小和内部所有元素的比例。
92 | */
93 | @SuppressLint("UnusedBoxWithConstraintsScope")
94 | @Composable
95 | fun CatPawButton(
96 | isEnabled: Boolean,
97 | statusText: String,
98 | onClick: () -> Unit,
99 | modifier: Modifier = Modifier,
100 | ) {
101 | BoxWithConstraints(
102 | modifier = modifier,
103 | contentAlignment = Alignment.Center
104 | ) {
105 | val baseSize = min(maxWidth, maxHeight)
106 | val scaleFactor = baseSize.value / CatPawDefaults.DESIGN_BASIS_DP
107 |
108 | // --- 动画状态 ---
109 | val ringSize by animateDpAsState(
110 | targetValue = if (isEnabled) baseSize * CatPawDefaults.RING_ENABLED_RATIO else baseSize * CatPawDefaults.RING_DISABLED_RATIO,
111 | animationSpec = tween(600),
112 | label = "RingSizeAnimation"
113 | )
114 | val buttonFillColor by animateColorAsState(
115 | targetValue = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
116 | animationSpec = tween(500),
117 | label = "ButtonFillAnimation"
118 | )
119 | val contentColor by animateColorAsState(
120 | targetValue = if (isEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
121 | animationSpec = tween(500),
122 | label = "ContentColorAnimation"
123 | )
124 | val shadowElevation by animateFloatAsState(
125 | targetValue = if (isEnabled) (baseSize.value * 0.055f) else (baseSize.value * 0.027f),
126 | animationSpec = tween(500),
127 | label = "ShadowElevation"
128 | )
129 | val rotationSpeed by animateFloatAsState(
130 | targetValue = if (isEnabled) 15f else 5f,
131 | animationSpec = tween(1500),
132 | label = "RotationSpeedAnimation"
133 | )
134 | var rotationAngle by remember { mutableFloatStateOf(0f) }
135 | val outlineColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
136 | val arcColor1 by animateColorAsState(
137 | targetValue = if (isEnabled) MaterialTheme.colorScheme.primary else outlineColor,
138 | animationSpec = tween(700),
139 | label = "ArcColor1"
140 | )
141 | val arcColor2 by animateColorAsState(
142 | targetValue = if (isEnabled) MaterialTheme.colorScheme.tertiary else outlineColor,
143 | animationSpec = tween(700),
144 | label = "ArcColor2"
145 | )
146 | val arcBrush = Brush.sweepGradient(colors = listOf(arcColor1, arcColor2, arcColor1))
147 |
148 | val palmOffsetY by animateFloatAsState(if (isEnabled) CatPawDefaults.PALM_Y_SHIFT * scaleFactor else 0f, tween(400), label = "PalmOffsetY")
149 | val outerLeftToeX by animateFloatAsState(if (isEnabled) CatPawDefaults.OUTER_LEFT_TOE_X_SHIFT * scaleFactor else 0f, tween(400), label = "OuterLeftToeX")
150 | val outerLeftToeY by animateFloatAsState(if (isEnabled) CatPawDefaults.OUTER_LEFT_TOE_Y_SHIFT * scaleFactor else 0f, tween(400), label = "OuterLeftToeY")
151 | val innerLeftToeX by animateFloatAsState(if (isEnabled) CatPawDefaults.INNER_LEFT_TOE_X_SHIFT * scaleFactor else 0f, tween(400), label = "InnerLeftToeX")
152 | val innerLeftToeY by animateFloatAsState(if (isEnabled) CatPawDefaults.INNER_LEFT_TOE_Y_SHIFT * scaleFactor else 0f, tween(400), label = "InnerLeftToeY")
153 | val innerRightToeX by animateFloatAsState(if (isEnabled) CatPawDefaults.INNER_RIGHT_TOE_X_SHIFT * scaleFactor else 0f, tween(400), label = "InnerRightToeX")
154 | val innerRightToeY by animateFloatAsState(if (isEnabled) CatPawDefaults.INNER_RIGHT_TOE_Y_SHIFT * scaleFactor else 0f, tween(400), label = "InnerRightToeY")
155 | val outerRightToeX by animateFloatAsState(if (isEnabled) CatPawDefaults.OUTER_RIGHT_TOE_X_SHIFT * scaleFactor else 0f, tween(400), label = "OuterRightToeX")
156 | val outerRightToeY by animateFloatAsState(if (isEnabled) CatPawDefaults.OUTER_RIGHT_TOE_Y_SHIFT * scaleFactor else 0f, tween(400), label = "OuterRightToeY")
157 | val gapAngle by animateFloatAsState(
158 | targetValue = if (isEnabled) 8f else 12f,
159 | animationSpec = tween(700),
160 | label = "GapAngleAnimation"
161 | )
162 |
163 | LaunchedEffect(Unit) {
164 | var lastFrameTimeNanos = 0L
165 | while (true) {
166 | withFrameNanos { frameTimeNanos ->
167 | if (lastFrameTimeNanos != 0L) {
168 | val deltaTimeMillis = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000f
169 | val deltaAngle = (rotationSpeed * deltaTimeMillis) / 1000f
170 | rotationAngle = (rotationAngle + deltaAngle) % 360f
171 | }
172 | lastFrameTimeNanos = frameTimeNanos
173 | }
174 | }
175 | }
176 |
177 | // --- 绘制部分 ---
178 | Canvas(modifier = Modifier.size(ringSize)) {
179 | val strokeWidth = CatPawDefaults.RING_STROKE_WIDTH
180 | val dashCount = 12
181 | val totalAnglePerDash = 360f / dashCount
182 | val dashAngle = totalAnglePerDash - gapAngle
183 | rotate(degrees = rotationAngle) {
184 | for (i in 0 until dashCount) {
185 | drawArc(
186 | brush = arcBrush,
187 | startAngle = i * totalAnglePerDash,
188 | sweepAngle = dashAngle,
189 | useCenter = false,
190 | style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
191 | )
192 | }
193 | }
194 | }
195 |
196 | Surface(
197 | modifier = Modifier
198 | .size(baseSize * CatPawDefaults.CENTER_BUTTON_RATIO)
199 | .shadow(elevation = shadowElevation.dp, shape = CircleShape)
200 | .clip(CircleShape)
201 | .clickable(
202 | interactionSource = remember { MutableInteractionSource() },
203 | indication = null,
204 | onClick = onClick
205 | ),
206 | color = buttonFillColor
207 | ) {
208 | Column(
209 | modifier = Modifier.fillMaxSize(),
210 | horizontalAlignment = Alignment.CenterHorizontally,
211 | verticalArrangement = Arrangement.Center
212 | ) {
213 | Canvas(modifier = Modifier.size(baseSize * CatPawDefaults.PAW_CANVAS_RATIO)) {
214 | val strokeWidth = CatPawDefaults.PAW_STROKE_WIDTH
215 |
216 | val palmSize = Size(size.width * CatPawDefaults.PALM_WIDTH_RATIO, size.height * CatPawDefaults.PALM_HEIGHT_RATIO)
217 | val palmBaseCenter = Offset(center.x, center.y + size.height * CatPawDefaults.PALM_Y_OFFSET_RATIO)
218 | val palmAnimatedCenter = palmBaseCenter.copy(y = palmBaseCenter.y + palmOffsetY)
219 | val palmTopLeft = Offset(palmAnimatedCenter.x - palmSize.width / 2f, palmAnimatedCenter.y - palmSize.height / 2f)
220 | drawOval(
221 | color = contentColor,
222 | topLeft = palmTopLeft,
223 | size = palmSize,
224 | style = Stroke(width = strokeWidth)
225 | )
226 |
227 | val toeRadius = size.width * CatPawDefaults.TOE_RADIUS_RATIO
228 | val outerLeftBaseCenter = Offset(center.x - size.width * CatPawDefaults.OUTER_TOE_X_RATIO, center.y - size.height * CatPawDefaults.OUTER_TOE_Y_RATIO)
229 | val innerLeftBaseCenter = Offset(center.x - size.width * CatPawDefaults.INNER_TOE_X_RATIO, center.y - size.height * CatPawDefaults.INNER_TOE_Y_RATIO)
230 | val innerRightBaseCenter = Offset(center.x + size.width * CatPawDefaults.INNER_TOE_X_RATIO, center.y - size.height * CatPawDefaults.INNER_TOE_Y_RATIO)
231 | val outerRightBaseCenter = Offset(center.x + size.width * CatPawDefaults.OUTER_TOE_X_RATIO, center.y - size.height * CatPawDefaults.OUTER_TOE_Y_RATIO)
232 |
233 | drawCircle(
234 | color = contentColor,
235 | center = outerLeftBaseCenter.copy(x = outerLeftBaseCenter.x + outerLeftToeX, y = outerLeftBaseCenter.y + outerLeftToeY),
236 | radius = toeRadius,
237 | style = Stroke(width = strokeWidth)
238 | )
239 | drawCircle(
240 | color = contentColor,
241 | center = innerLeftBaseCenter.copy(x = innerLeftBaseCenter.x + innerLeftToeX, y = innerLeftBaseCenter.y + innerLeftToeY),
242 | radius = toeRadius,
243 | style = Stroke(width = strokeWidth)
244 | )
245 | drawCircle(
246 | color = contentColor,
247 | center = innerRightBaseCenter.copy(x = innerRightBaseCenter.x + innerRightToeX, y = innerRightBaseCenter.y + innerRightToeY),
248 | radius = toeRadius,
249 | style = Stroke(width = strokeWidth)
250 | )
251 | drawCircle(
252 | color = contentColor,
253 | center = outerRightBaseCenter.copy(x = outerRightBaseCenter.x + outerRightToeX, y = outerRightBaseCenter.y + outerRightToeY),
254 | radius = toeRadius,
255 | style = Stroke(width = strokeWidth)
256 | )
257 | }
258 |
259 | AnimatedContent(
260 | targetState = statusText,
261 | transitionSpec = {
262 | (slideInVertically { h -> h } + fadeIn(tween(250)))
263 | .togetherWith(slideOutVertically { h -> -h } + fadeOut(tween(250)))
264 | .using(SizeTransform(clip = false))
265 | },
266 | label = "StatusTextAnimation"
267 | ) { text ->
268 | Text(
269 | text = text,
270 | color = contentColor,
271 | fontSize = (baseSize.value * CatPawDefaults.FONT_SIZE_RATIO).coerceAtLeast(CatPawDefaults.MIN_FONT_SIZE_SP).sp,
272 | fontWeight = FontWeight.Bold,
273 | textAlign = TextAlign.Center
274 | )
275 | }
276 | }
277 | }
278 | }
279 | }
280 |
--------------------------------------------------------------------------------