├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ ├── data_extraction_rules.xml
│ │ │ │ ├── file_paths.xml
│ │ │ │ ├── live_notification_config.xml
│ │ │ │ ├── widget_info.xml
│ │ │ │ ├── accessibility_service_config.xml
│ │ │ │ └── shortcuts.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_round.png
│ │ │ │ └── ic_launcher_foreground.png
│ │ │ ├── values
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── strings.xml
│ │ │ ├── color
│ │ │ │ ├── live_time.xml
│ │ │ │ ├── live_bg_color.xml
│ │ │ │ ├── live_low_text.xml
│ │ │ │ └── live_title_color.xml
│ │ │ ├── color-night
│ │ │ │ ├── live_time.xml
│ │ │ │ ├── live_bg_color.xml
│ │ │ │ ├── live_low_text.xml
│ │ │ │ └── live_title_color.xml
│ │ │ ├── drawable
│ │ │ │ ├── ticket_tear_background.xml
│ │ │ │ ├── view_button_background.xml
│ │ │ │ ├── ic_stat_pin.xml
│ │ │ │ ├── ic_qs_pin.xml
│ │ │ │ ├── ic_capsule_ring.xml
│ │ │ │ ├── location_background.xml
│ │ │ │ └── ticket_perforation.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ └── layout
│ │ │ │ ├── widget_initial_layout.xml
│ │ │ │ ├── live_notification_card.xml
│ │ │ │ └── live_notification_qrcode_card.xml
│ │ ├── ic_launcher-playstore.png
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── brycewg
│ │ │ │ └── pinme
│ │ │ │ ├── ui
│ │ │ │ ├── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ └── components
│ │ │ │ │ └── TutorialDialog.kt
│ │ │ │ ├── widget
│ │ │ │ ├── WidgetAutoUpdateReceiver.kt
│ │ │ │ └── PinMeWidget.kt
│ │ │ │ ├── capture
│ │ │ │ ├── NotificationDismissReceiver.kt
│ │ │ │ ├── QuickCaptureTileService.kt
│ │ │ │ ├── CaptureActivity.kt
│ │ │ │ ├── AccessibilityCaptureService.kt
│ │ │ │ ├── RootCaptureService.kt
│ │ │ │ └── ScreenCaptureService.kt
│ │ │ │ ├── service
│ │ │ │ └── LiveNotification.kt
│ │ │ │ ├── vllm
│ │ │ │ ├── LlmPreferences.kt
│ │ │ │ └── VllmClient.kt
│ │ │ │ ├── db
│ │ │ │ ├── Entity.kt
│ │ │ │ ├── AppDatabase.kt
│ │ │ │ ├── DatabaseProvider.kt
│ │ │ │ └── PinMeDao.kt
│ │ │ │ ├── extract
│ │ │ │ └── ExtractParsing.kt
│ │ │ │ ├── notification
│ │ │ │ └── OpenSourceAppReceiver.kt
│ │ │ │ ├── Constants.kt
│ │ │ │ ├── ShareReceiverActivity.kt
│ │ │ │ ├── qrcode
│ │ │ │ └── QrCodeDetector.kt
│ │ │ │ ├── usage
│ │ │ │ └── SourceAppTracker.kt
│ │ │ │ └── share
│ │ │ │ └── ShareProcessorService.kt
│ │ └── AndroidManifest.xml
│ └── debug
│ │ └── res
│ │ └── values
│ │ └── strings.xml
├── proguard-rules.pro
└── build.gradle.kts
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── .gitignore
├── settings.gradle.kts
├── README.md
├── gradle.properties
├── AGENTS.md
├── CLAUDE.md
├── gradlew.bat
├── .github
└── workflows
│ └── release.yml
├── GEMINI.md
├── gradlew
└── LICENSE
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/debug/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | PinMe Debug
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BryceWG/Pinme/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #434343
4 |
--------------------------------------------------------------------------------
/app/src/main/res/color/live_time.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/color-night/live_time.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/color/live_bg_color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/color/live_low_text.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/color/live_title_color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/file_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/color-night/live_bg_color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/color-night/live_low_text.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/color-night/live_title_color.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ticket_tear_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/live_notification_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | PinMe
4 | 截屏识别
5 | @mipmap/ic_launcher
6 | false
7 |
8 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Sep 19 19:05:52 CST 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/view_button_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/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/com/brycewg/pinme/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.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)
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_stat_pin.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_qs_pin.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/widget_info.xml:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_capsule_ring.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .DS_Store
3 |
4 | # Gradle
5 | .gradle/
6 | build/
7 |
8 | # Android Studio / IntelliJ
9 | .idea/
10 | .kotlin/
11 |
12 | # Local configuration
13 | local.properties
14 |
15 | # Keystore files
16 | *.keystore
17 | *.jks
18 |
19 | # Native build
20 | .externalNativeBuild/
21 | .cxx/
22 |
23 | # Captures
24 | captures/
25 |
26 | # Module outputs
27 | app/build/
28 | app/release/
29 |
30 | # Tests (currently excluded)
31 | app/src/androidTest/
32 | app/src/test/
33 |
34 | # Agent/local tooling
35 | .claude/
36 |
37 | dev_docs/
--------------------------------------------------------------------------------
/app/src/main/res/drawable/location_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/widget_initial_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/accessibility_service_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/shortcuts.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/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 | plugins {
15 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
16 | }
17 | dependencyResolutionManagement {
18 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
19 | repositories {
20 | google()
21 | mavenCentral()
22 | }
23 | }
24 |
25 | rootProject.name = "pinme-flyme"
26 | include(":app")
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/widget/WidgetAutoUpdateReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.widget
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.launch
9 |
10 | class WidgetAutoUpdateReceiver : BroadcastReceiver() {
11 | override fun onReceive(context: Context, intent: Intent) {
12 | val pendingResult = goAsync()
13 | CoroutineScope(Dispatchers.Default).launch {
14 | try {
15 | PinMeWidget.updateWidgetContent(context.applicationContext)
16 | } finally {
17 | pendingResult.finish()
18 | }
19 | }
20 | }
21 | }
22 |
23 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
16 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ticket_perforation.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
25 |
26 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # =====================================================
2 | # PinMe ProGuard/R8 Rules - 精简版
3 | # =====================================================
4 |
5 | # Kotlin Serialization - 只保留序列化相关
6 | -keepattributes *Annotation*, InnerClasses
7 | -dontnote kotlinx.serialization.**
8 |
9 | -keepclassmembers @kotlinx.serialization.Serializable class ** {
10 | *** Companion;
11 | }
12 | -keepclasseswithmembers class **$$serializer {
13 | *** INSTANCE;
14 | }
15 |
16 | # Room - 只保留实体注解
17 | -keep @androidx.room.Entity class *
18 | -keep @androidx.room.Dao class *
19 |
20 | # OkHttp - 最小化规则
21 | -dontwarn okhttp3.internal.platform.**
22 | -dontwarn org.conscrypt.**
23 | -dontwarn org.bouncycastle.**
24 | -dontwarn org.openjsse.**
25 |
26 | # ML Kit - 只保留必要的
27 | -dontwarn com.google.mlkit.**
28 |
29 | # 移除 release 版本的日志
30 | -assumenosideeffects class android.util.Log {
31 | public static int v(...);
32 | public static int d(...);
33 | public static int i(...);
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | PinMe
3 | 查看
4 | 内容
5 | 取餐码 123456
6 | 识别结果
7 |
8 |
9 | 关闭
10 |
11 |
12 | 二维码图片
13 |
14 |
15 | PinMe 使用此无障碍服务来静默截取屏幕内容,无需每次授权。开启后可通过快捷设置磁贴一键截图识别。
16 | 无障碍截图模式
17 | 开启后使用无障碍服务进行截图,无需每次授权
18 | 无障碍服务未开启
19 | 前往设置
20 |
21 |
22 | 截图识别
23 | PinMe 截图识别
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/capture/NotificationDismissReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.capture
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.util.Log
7 | import com.brycewg.pinme.notification.UnifiedNotificationManager
8 |
9 | /**
10 | * 接收取消通知的广播(用于定时取消或用户点击关闭按钮)
11 | */
12 | class NotificationDismissReceiver : BroadcastReceiver() {
13 |
14 | companion object {
15 | private const val TAG = "NotificationDismiss"
16 | const val EXTRA_EXTRACT_ID = "extra_extract_id"
17 | }
18 |
19 | override fun onReceive(context: Context, intent: Intent?) {
20 | val extractId = intent?.getLongExtra(EXTRA_EXTRACT_ID, -1L) ?: -1L
21 | if (extractId != -1L) {
22 | Log.d(TAG, "Dismissing notification for extractId: $extractId")
23 | UnifiedNotificationManager(context).cancelExtractNotification(extractId)
24 | } else {
25 | Log.w(TAG, "Received dismiss intent without valid extractId")
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/service/LiveNotification.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.service
2 |
3 | import android.app.Service
4 | import android.content.Intent
5 | import android.os.IBinder
6 | import android.util.Log
7 | import com.brycewg.pinme.BuildConfig
8 |
9 | private const val TAG = "LiveNotification"
10 |
11 | class LiveNotification : Service() {
12 | override fun onCreate() {
13 | super.onCreate()
14 | if (BuildConfig.DEBUG) {
15 | Log.d(TAG, "Service created")
16 | }
17 | }
18 |
19 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
20 | if (BuildConfig.DEBUG) {
21 | Log.d(TAG, "Service started")
22 | }
23 | return START_STICKY
24 | }
25 |
26 | override fun onBind(intent: Intent?): IBinder? {
27 | if (BuildConfig.DEBUG) {
28 | Log.d(TAG, "Service bound")
29 | }
30 | return null
31 | }
32 |
33 | override fun onDestroy() {
34 | super.onDestroy()
35 | if (BuildConfig.DEBUG) {
36 | Log.d(TAG, "Service destroyed")
37 | }
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
PinMe:磁贴截屏识别 → Flyme 实况通知 / 桌面插件
2 |
3 | 包名(`applicationId`):`com.brycewg.pinme`
4 |
5 | ## 功能
6 |
7 | - 控制中心磁贴一键触发:请求一次截屏权限并抓取当前屏幕。
8 | - 调用 vLLM(OpenAI 兼容接口)进行识别与关键信息抽取(例如取餐码、乘车信息等)。
9 | - 结果同步到 Flyme 实况通知(胶囊)与普通通知。
10 | - 桌面插件(Glance AppWidget)展示最近识别内容。
11 | - 自定义识别类型与模型提供商(智谱 AI / 硅基流动 / 自定义)。
12 | - 可选静默截图:无障碍服务 / Root(两者互斥)。
13 |
14 | ## 使用
15 |
16 | 1. 打开应用 → `设置`:配置 `Model` / `API Key`(默认使用智谱渠道,可以免费调用 glm-4v-flash)。
17 | 2. 系统里编辑控制中心,把 `PinMe` 磁贴拖进去。
18 | 3. 点击磁贴:首次会弹出系统截屏授权;识别完成后会推送通知并刷新桌面插件。
19 | 4. 无障碍服务 / Root:可选,开启后静默截图,无需每次申请录屏权限(Root 模式需要设备已 root,并授予 PinMe 超级用户权限)。
20 |
21 | ## 配置
22 |
23 | 1. 启动软件,允许通知权限
24 | 2. 进入设置页配置模型 API,推荐使用智谱的免费模型 glm-4v-flash,速度快,效果稳.注册并申请:https://bigmodel.cn/usercenter/proj-mgmt/apikeys
25 | 3. 可以将快捷方式发送到桌面,使用快捷小窗触发截图
26 | 4. 可以固定磁贴到控制中心,触发截图
27 |
28 | ## 致谢
29 |
30 | - 感谢 [StarSchedule](https://github.com/lightStarrr/starSchedule) 提供的 Flyme 实况通知调用参考。
31 | - 感谢群友 Ruyue 提供的实况通知代码支持。
32 |
33 | ## 赞赏
34 |
35 | 开源项目开发不易,喜欢我的项目可以请我喝一杯咖啡~
36 |
37 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.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 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
35 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/capture/QuickCaptureTileService.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.capture
2 |
3 | import android.app.PendingIntent
4 | import android.content.Intent
5 | import android.os.Build
6 | import android.service.quicksettings.TileService
7 |
8 | class QuickCaptureTileService : TileService() {
9 | override fun onClick() {
10 | super.onClick()
11 | // 始终通过 startActivityAndCollapse 启动 CaptureActivity
12 | // 这样可以确保控制中心被收起
13 | // CaptureActivity 会根据设置决定使用哪种截图方式
14 | val intent = Intent(this, CaptureActivity::class.java).apply {
15 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
16 | addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
17 | addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
18 | }
19 | if (Build.VERSION.SDK_INT >= 34) {
20 | val pendingIntent = PendingIntent.getActivity(
21 | this,
22 | 0,
23 | intent,
24 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
25 | )
26 | startActivityAndCollapse(pendingIntent)
27 | } else {
28 | @Suppress("DEPRECATION")
29 | startActivityAndCollapse(intent)
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/vllm/LlmPreferences.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.vllm
2 |
3 | import com.brycewg.pinme.Constants.LlmProvider
4 | import com.brycewg.pinme.db.PinMeDao
5 |
6 | private fun llmScopedKey(baseKey: String, provider: LlmProvider): String {
7 | return "${baseKey}_${provider.name.lowercase()}"
8 | }
9 |
10 | suspend fun PinMeDao.getLlmScopedPreference(baseKey: String, provider: LlmProvider): String? {
11 | return getPreference(llmScopedKey(baseKey, provider))
12 | }
13 |
14 | suspend fun PinMeDao.setLlmScopedPreference(baseKey: String, provider: LlmProvider, value: String) {
15 | setPreference(llmScopedKey(baseKey, provider), value)
16 | }
17 |
18 | suspend fun PinMeDao.getLlmScopedPreferenceWithLegacyFallback(
19 | baseKey: String,
20 | provider: LlmProvider
21 | ): String? {
22 | return getPreference(llmScopedKey(baseKey, provider)) ?: getPreference(baseKey)
23 | }
24 |
25 | suspend fun PinMeDao.migrateLegacyLlmPreferencesToScoped(provider: LlmProvider, baseKeys: List) {
26 | for (baseKey in baseKeys) {
27 | val scopedKey = llmScopedKey(baseKey, provider)
28 | val existingScoped = getPreference(scopedKey)
29 | if (existingScoped != null) continue
30 |
31 | val legacy = getPreference(baseKey) ?: continue
32 | setPreference(scopedKey, legacy)
33 | }
34 | }
35 |
36 | fun LlmProvider.toStoredValue(): String = name.lowercase()
37 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/db/Entity.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.db
2 |
3 | import androidx.room.Entity
4 | import androidx.room.PrimaryKey
5 |
6 | @Entity(tableName = "preference")
7 | data class PreferenceEntity(
8 | @PrimaryKey val prefKey: String,
9 | val value: String
10 | )
11 |
12 | @Entity(tableName = "extract")
13 | data class ExtractEntity(
14 | @PrimaryKey(autoGenerate = true) val id: Long = 0,
15 | val title: String,
16 | val content: String,
17 | val emoji: String? = null, // LLM 生成的 emoji,更精准地表达内容
18 | val qrCodeBase64: String? = null, // 二维码图片的 Base64 编码(JPEG 格式)
19 | val source: String = "screen",
20 | val sourcePackage: String? = null,
21 | val rawModelOutput: String = "",
22 | val createdAtMillis: Long
23 | )
24 |
25 | @Entity(tableName = "market_item")
26 | data class MarketItemEntity(
27 | @PrimaryKey(autoGenerate = true) val id: Long = 0,
28 | val title: String, // 标题,如"取件码"
29 | val contentDesc: String, // 内容描述,如"取件码号"
30 | val outputExample: String = "", // 输出示例(可多行)
31 | val emoji: String, // 显示的emoji,如"📦"
32 | val capsuleColor: String, // 胶囊颜色,如"#FFC107"
33 | val durationMinutes: Int, // 显示时长(分钟)
34 | val isEnabled: Boolean = true,
35 | val isPreset: Boolean = false, // 是否为预置类型(预置类型不可删除)
36 | val presetKey: String? = null, // 预置类型的唯一标识,用于避免重复插入
37 | val createdAtMillis: Long = System.currentTimeMillis()
38 | )
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/ui/components/TutorialDialog.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.ui.components
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.material3.AlertDialog
6 | import androidx.compose.material3.Text
7 | import androidx.compose.material3.TextButton
8 | import androidx.compose.ui.platform.LocalContext
9 | import android.content.Intent
10 | import android.net.Uri
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.unit.dp
13 |
14 | @Composable
15 | fun TutorialDialog(onDismiss: () -> Unit) {
16 | val context = LocalContext.current
17 | AlertDialog(
18 | onDismissRequest = onDismiss,
19 | title = { Text("使用教程") },
20 | text = {
21 | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
22 | Text("1. 去智谱官网注册账号,申请自己的大模型 API Key。")
23 | Text("2. 在设置中填入智谱 AI 的 API Key,点击测试链接,应显示连接成功。")
24 | Text("3. 可以选择使用无障碍或者 Root 权限(adb 模板即可)实现静默截图处理;若不启用,每次截图会申请录制屏幕权限,无需其他特殊权限。")
25 | Text("4. 可将快捷方式发送到桌面或添加控制中心磁贴来截图;也可通过系统分享图片/文本到 PinMe 提取信息;还可在记录页右下角手动导入图片或文字内容。")
26 | Text("5. 更多功能请自行探索。")
27 | }
28 | },
29 | confirmButton = {
30 | TextButton(onClick = onDismiss) {
31 | Text("关闭")
32 | }
33 | },
34 | dismissButton = {
35 | TextButton(
36 | onClick = {
37 | val intent = Intent(
38 | Intent.ACTION_VIEW,
39 | Uri.parse("https://bigmodel.cn/usercenter/proj-mgmt/apikeys")
40 | )
41 | context.startActivity(intent)
42 | }
43 | ) {
44 | Text("智谱官网")
45 | }
46 | }
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.ui.theme
2 |
3 | import android.app.Activity
4 | import android.os.Build
5 | import androidx.compose.foundation.isSystemInDarkTheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.dynamicDarkColorScheme
9 | import androidx.compose.material3.dynamicLightColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.platform.LocalContext
13 |
14 | private val DarkColorScheme = darkColorScheme(
15 | primary = Purple80,
16 | secondary = PurpleGrey80,
17 | tertiary = Pink80
18 | )
19 |
20 | private val LightColorScheme = lightColorScheme(
21 | primary = Purple40,
22 | secondary = PurpleGrey40,
23 | tertiary = Pink40
24 |
25 | /* Other default colors to override
26 | background = Color(0xFFFFFBFE),
27 | surface = Color(0xFFFFFBFE),
28 | onPrimary = Color.White,
29 | onSecondary = Color.White,
30 | onTertiary = Color.White,
31 | onBackground = Color(0xFF1C1B1F),
32 | onSurface = Color(0xFF1C1B1F),
33 | */
34 | )
35 |
36 | @Composable
37 | fun StarScheduleTheme(
38 | darkTheme: Boolean = isSystemInDarkTheme(),
39 | // Dynamic color is available on Android 12+
40 | dynamicColor: Boolean = true,
41 | content: @Composable () -> Unit
42 | ) {
43 | val colorScheme = when {
44 | dynamicColor -> {
45 | val context = LocalContext.current
46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
47 | }
48 |
49 | darkTheme -> DarkColorScheme
50 | else -> LightColorScheme
51 | }
52 |
53 | MaterialTheme(
54 | colorScheme = colorScheme,
55 | typography = Typography,
56 | content = content
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # Repository Guidelines
2 |
3 | Communicate in Chinese.
4 |
5 | ## Project Structure & Module Organization
6 |
7 | - `app/` is the Android application module (Kotlin + Jetpack Compose).
8 | - Kotlin sources live in `app/src/main/java/com/brycewg/pinme/` with feature packages like `capture/`, `extract/`, `vllm/`, `notification/`, `widget/`, `db/`, `service/`, and `ui/`.
9 | - Android resources live in `app/src/main/res/` (e.g., `drawable/`, `values/`, `xml/`, `raw/`).
10 | - Repo-level docs/artifacts: `README.md` and `LICENSE`.
11 |
12 | ## Build, Test, and Development Commands
13 |
14 | Never try to use `./gradlew build`.
15 |
16 | ## Coding Style & Naming Conventions
17 |
18 | - Kotlin follows the official style (`kotlin.code.style=official`): 4-space indentation, no tabs.
19 | - Names: packages `lowercase`, classes/objects `PascalCase`, functions/variables `camelCase`.
20 | - Compose: `@Composable` functions are typically `PascalCase` for screens/components (for example, `ExtractHome`).
21 | - Prefer small, focused changes; avoid formatting-only diffs unless required.
22 |
23 | ## Testing Guidelines
24 |
25 | - Unit tests: `app/src/test/...` (JUnit4).
26 | - Instrumented/UI tests: `app/src/androidTest/...` (AndroidX JUnit, Espresso, Compose UI test).
27 | - Name tests `*Test` and focus coverage on parsing/workflow boundaries first (for example, `extract/` and `vllm/`).
28 |
29 | Note: `.gitignore` currently excludes `app/src/test` and `app/src/androidTest`; update it if you intend to add tests to the repo.
30 |
31 | ## Commit & Pull Request Guidelines
32 |
33 | - Git history uses short, imperative, sentence-case summaries (for example, “Add initial project setup …”); keep commits scoped and readable.
34 | - PRs should describe behavior changes, include screenshots for UI/widget/notification updates, and list what you tested (API level and Flyme/Meizu device if relevant).
35 |
36 | ## Security & Configuration Tips
37 |
38 | - Never commit `local.properties`, API keys, or private endpoints. Configure Base URL/Model/API Key via in-app Settings; avoid hardcoding secrets in `Constants.kt`.
39 | - Keep Flyme-specific behavior behind capability checks and maintain non-Flyme fallbacks (see `notification/UnifiedNotificationManager.kt`).
40 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/extract/ExtractParsing.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.extract
2 |
3 | import org.json.JSONObject
4 |
5 | data class ExtractParsed(
6 | val title: String,
7 | val content: String,
8 | val emoji: String? = null
9 | )
10 |
11 | data class ExtractParseResult(
12 | val parsed: ExtractParsed,
13 | val parsedFromJson: Boolean
14 | )
15 |
16 | object ExtractParsing {
17 | fun parseModelOutput(modelOutput: String): ExtractParsed {
18 | return parseModelOutputWithStatus(modelOutput).parsed
19 | }
20 |
21 | fun parseModelOutputWithStatus(modelOutput: String): ExtractParseResult {
22 | val trimmed = modelOutput.trim()
23 | val json = tryParseJsonObject(trimmed) ?: tryParseJsonObject(extractFirstJsonObject(trimmed))
24 | if (json != null) {
25 | val title = json.optString("title", "").trim()
26 | val content = json.optString("content", "").trim()
27 | val emoji = json.optString("emoji", "").trim().takeIf { it.isNotBlank() }
28 | if (title.isNotBlank() && content.isNotBlank()) {
29 | return ExtractParseResult(
30 | parsed = ExtractParsed(title = title, content = content, emoji = emoji),
31 | parsedFromJson = true
32 | )
33 | }
34 | }
35 |
36 | return ExtractParseResult(
37 | parsed = ExtractParsed(
38 | title = "识别结果",
39 | content = trimmed
40 | ),
41 | parsedFromJson = false
42 | )
43 | }
44 |
45 | private fun tryParseJsonObject(input: String?): JSONObject? {
46 | if (input.isNullOrBlank()) return null
47 | return try {
48 | JSONObject(input)
49 | } catch (_: Exception) {
50 | null
51 | }
52 | }
53 |
54 | private fun extractFirstJsonObject(text: String): String? {
55 | val start = text.indexOf('{')
56 | if (start < 0) return null
57 | var depth = 0
58 | for (i in start until text.length) {
59 | when (text[i]) {
60 | '{' -> depth++
61 | '}' -> {
62 | depth--
63 | if (depth == 0) return text.substring(start, i + 1)
64 | }
65 | }
66 | }
67 | return null
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/notification/OpenSourceAppReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.notification
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.ComponentName
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.content.pm.PackageManager
8 | import android.net.Uri
9 | import android.provider.Settings
10 | import android.widget.Toast
11 |
12 | class OpenSourceAppReceiver : BroadcastReceiver() {
13 | companion object {
14 | const val EXTRA_PACKAGE_NAME = "extra_package_name"
15 | }
16 |
17 | override fun onReceive(context: Context, intent: Intent?) {
18 | val packageName = intent?.getStringExtra(EXTRA_PACKAGE_NAME)?.trim()
19 | if (packageName.isNullOrEmpty()) {
20 | Toast.makeText(context, "无法打开来源应用", Toast.LENGTH_SHORT).show()
21 | return
22 | }
23 | val packageManager = context.packageManager
24 | val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
25 | if (launchIntent != null) {
26 | launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
27 | context.startActivity(launchIntent)
28 | return
29 | }
30 |
31 | val launcherQuery = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
32 | .setPackage(packageName)
33 | val resolvedActivities = packageManager.queryIntentActivities(
34 | launcherQuery,
35 | PackageManager.MATCH_DEFAULT_ONLY
36 | )
37 | if (resolvedActivities.isNotEmpty()) {
38 | val activityInfo = resolvedActivities.first().activityInfo
39 | val component = ComponentName(activityInfo.packageName, activityInfo.name)
40 | val explicitIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
41 | .setComponent(component)
42 | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
43 | context.startActivity(explicitIntent)
44 | return
45 | }
46 |
47 | val isInstalled = runCatching { packageManager.getPackageInfo(packageName, 0) }.isSuccess
48 | if (isInstalled) {
49 | val settingsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
50 | data = Uri.fromParts("package", packageName, null)
51 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
52 | }
53 | context.startActivity(settingsIntent)
54 | Toast.makeText(context, "应用无启动入口,已打开应用信息", Toast.LENGTH_SHORT).show()
55 | } else {
56 | Toast.makeText(context, "目标应用已卸载或包名无效", Toast.LENGTH_SHORT).show()
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/Constants.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme
2 |
3 | object Constants {
4 | // 偏好设置键
5 | const val PREF_LIVE_CAPSULE_BG_COLOR = "live_capsule_bg_color"
6 | const val PREF_TUTORIAL_SEEN = "tutorial_seen"
7 |
8 | // 历史记录配置
9 | const val PREF_MAX_HISTORY_COUNT = "max_history_count" // 最大历史记录数量 (1-20)
10 | const val DEFAULT_MAX_HISTORY_COUNT = 20 // 默认最大历史记录数量
11 |
12 | // 截图压缩配置
13 | const val SCREENSHOT_MAX_WIDTH = 1080 // 截图最大宽度(按比例缩放)
14 |
15 | // 截图模式配置
16 | const val PREF_USE_ACCESSIBILITY_CAPTURE = "use_accessibility_capture" // 是否使用无障碍截图模式
17 | const val PREF_USE_ROOT_CAPTURE = "use_root_capture" // 是否使用 Root 截图模式(与无障碍互斥)
18 |
19 | // 隐私配置
20 | const val PREF_EXCLUDE_FROM_RECENTS = "exclude_from_recents" // 是否从多任务管理中隐藏
21 | const val PREF_CAPTURE_TOAST_ENABLED = "capture_toast_enabled" // 截图触发时 Toast 提醒
22 | const val PREF_SOURCE_APP_JUMP_ENABLED = "source_app_jump_enabled" // 实况通知标题跳转来源应用
23 |
24 | // LLM 配置
25 | const val PREF_LLM_PROVIDER = "llm_provider" // 供应商类型: zhipu / siliconflow / custom
26 | const val PREF_LLM_API_KEY = "llm_api_key" // API Key
27 | const val PREF_LLM_MODEL = "llm_model" // 模型 ID
28 | const val PREF_LLM_TEMPERATURE = "llm_temperature" // 温度 (0.0 - 2.0)
29 | const val PREF_LLM_CUSTOM_BASE_URL = "llm_custom_base_url" // 自定义 Base URL (到 /v1 即可)
30 | const val PREF_CUSTOM_SYSTEM_INSTRUCTION = "custom_system_instruction" // 自定义系统指令(角色描述)
31 |
32 | // 默认系统指令
33 | const val DEFAULT_SYSTEM_INSTRUCTION = "你是手机截图信息提取助手。从截图中识别用户最可能需要反复查看或复制的关键信息。"
34 |
35 | // 解析异常通知样式
36 | const val PARSE_ERROR_TITLE = "解析异常"
37 | const val PARSE_ERROR_CONTENT = "无法解析模型输出"
38 | const val PARSE_ERROR_EMOJI = "❗"
39 | const val PARSE_ERROR_CAPSULE_COLOR = "#D32F2F"
40 | const val MODEL_ERROR_TITLE = "模型出错"
41 | const val MODEL_ERROR_CONTENT = "模型调用失败"
42 | const val MODEL_ERROR_EMOJI = "❗"
43 | const val MODEL_ERROR_CAPSULE_COLOR = "#D32F2F"
44 |
45 | // 预置供应商
46 | enum class LlmProvider(val displayName: String, val baseUrl: String, val defaultModel: String) {
47 | ZHIPU("智谱 AI", "https://open.bigmodel.cn/api/paas/v4", "glm-4v-flash"),
48 | SILICONFLOW("硅基流动", "https://api.siliconflow.cn/v1", "Qwen/Qwen2.5-VL-72B-Instruct"),
49 | CUSTOM("自定义", "", "");
50 |
51 | companion object {
52 | fun fromStoredValue(value: String?): LlmProvider {
53 | val normalized = value?.trim()?.lowercase() ?: return ZHIPU
54 | return entries.firstOrNull { it.name.lowercase() == normalized } ?: ZHIPU
55 | }
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 | Communicate in Chinese.
5 |
6 | ## Project Overview
7 |
8 | PinMe is an Android app (Kotlin, Jetpack Compose) that captures screenshots via a Quick Settings tile, uses vision LLMs to extract key information (pickup codes, train tickets, verification codes, etc.), and displays results through Flyme Live Notifications (Meizu-specific) or standard notifications plus a home screen widget.
9 |
10 | **Package:** `com.brycewg.pinme`
11 | **Min SDK:** 33 | **Target SDK:** 36
12 |
13 | ## Build Commands
14 |
15 | Never use `./gradlew build` in this project.
16 |
17 | ## Architecture
18 |
19 | ### Core Flow
20 |
21 | 1. **QuickCaptureTileService** - Quick Settings tile triggers `CaptureActivity`
22 | 2. **CaptureActivity** - Requests MediaProjection permission, captures screen as Bitmap
23 | 3. **ExtractWorkflow** - Sends screenshot to vision LLM, parses JSON response
24 | 4. **UnifiedNotificationManager** - Shows result via Flyme Live Notification (if available) or standard notification
25 | 5. **PinMeWidget** - Glance AppWidget displays recent extractions
26 |
27 | ### Key Components
28 |
29 | | Package | Purpose |
30 | | --------------- | ---------------------------------------------------------- |
31 | | `capture/` | Screen capture via MediaProjection API |
32 | | `extract/` | LLM workflow and JSON parsing |
33 | | `vllm/` | OpenAI-compatible API client (OkHttp) |
34 | | `notification/` | Notification handling with Flyme Live Notification support |
35 | | `widget/` | Glance AppWidget implementation |
36 | | `db/` | Room database for preferences and extraction history |
37 | | `service/` | LiveNotification service for Flyme integration |
38 |
39 | ### LLM Configuration
40 |
41 | Supports multiple providers configured in `Constants.kt`:
42 |
43 | - **智谱 AI (ZHIPU)** - Default, uses `glm-4v-flash`
44 | - **硅基流动 (SiliconFlow)** - Uses `Qwen/Qwen2.5-VL-72B-Instruct`
45 | - **Custom** - User-specified OpenAI-compatible endpoint
46 |
47 | Settings stored in Room database via `PreferenceEntity`.
48 |
49 | ### Database Schema
50 |
51 | - `PreferenceEntity` - Key-value settings storage
52 | - `ExtractEntity` - Extraction history (title, content, raw output, timestamp)
53 |
54 | ### Flyme-Specific Features
55 |
56 | `UnifiedNotificationManager.isLiveCapsuleCustomizationAvailable()` checks:
57 |
58 | - Meizu device manufacturer
59 | - Flyme version >= 11
60 | - Live notification permission granted
61 |
62 | Uses custom notification extras for capsule styling (background color, icon, content color).
63 |
64 | 任何你认为应该调用 SubAgent:Explore 的情况,都应该调用 MCP 工具:auggie-mcp,这是一种效率更高的代码检索工具。
65 |
66 | 构建环境没有配置 Java,请不要建议任何需要 Java 环境的操作。
67 |
--------------------------------------------------------------------------------
/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/com/brycewg/pinme/ShareReceiverActivity.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme
2 |
3 | import android.content.Intent
4 | import android.net.Uri
5 | import android.os.Bundle
6 | import android.widget.Toast
7 | import androidx.activity.ComponentActivity
8 | import androidx.core.content.IntentCompat
9 | import com.brycewg.pinme.share.ShareProcessorService
10 | import com.brycewg.pinme.usage.SourceAppTracker
11 | import kotlinx.coroutines.runBlocking
12 |
13 | /**
14 | * 透明 Activity,用于接收系统分享
15 | * 立即启动 Service 处理,然后关闭自身
16 | */
17 | class ShareReceiverActivity : ComponentActivity() {
18 |
19 | override fun onCreate(savedInstanceState: Bundle?) {
20 | super.onCreate(savedInstanceState)
21 |
22 | if (intent?.action == Intent.ACTION_SEND) {
23 | handleShareIntent(intent)
24 | }
25 | finish()
26 | }
27 |
28 | private fun handleShareIntent(intent: Intent) {
29 | val type = intent.type ?: return
30 | val sourcePackage = resolveShareSourcePackage(intent)
31 | val resolvedSourcePackage = runBlocking {
32 | if (SourceAppTracker.isEnabled(this@ShareReceiverActivity)) sourcePackage else null
33 | }
34 |
35 | when {
36 | type.startsWith("image/") -> {
37 | val sharedUri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
38 | ?: intent.clipData?.getItemAt(0)?.uri
39 | if (sharedUri != null) {
40 | Toast.makeText(this, "正在后台识别图片...", Toast.LENGTH_SHORT).show()
41 | ShareProcessorService.startWithImage(this, sharedUri, resolvedSourcePackage)
42 | } else {
43 | Toast.makeText(this, "未检测到可分享的图片", Toast.LENGTH_SHORT).show()
44 | }
45 | }
46 | type.startsWith("text/") -> {
47 | val sharedText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)
48 | ?.toString()
49 | ?.trim()
50 | if (!sharedText.isNullOrBlank()) {
51 | Toast.makeText(this, "正在后台识别文本...", Toast.LENGTH_SHORT).show()
52 | ShareProcessorService.startWithText(this, sharedText, resolvedSourcePackage)
53 | } else {
54 | Toast.makeText(this, "未检测到可分享的文本", Toast.LENGTH_SHORT).show()
55 | }
56 | }
57 | }
58 | }
59 |
60 | private fun resolveShareSourcePackage(intent: Intent?): String? {
61 | val referrerUri = intent?.let {
62 | IntentCompat.getParcelableExtra(it, Intent.EXTRA_REFERRER, Uri::class.java)
63 | }
64 | ?: intent?.getStringExtra(Intent.EXTRA_REFERRER_NAME)?.let { Uri.parse(it) }
65 | ?: referrer
66 | return parseAndroidAppReferrer(referrerUri)
67 | }
68 |
69 | private fun parseAndroidAppReferrer(referrer: Uri?): String? {
70 | if (referrer == null || referrer.scheme != "android-app") return null
71 | val host = referrer.host
72 | if (!host.isNullOrBlank()) {
73 | return host
74 | }
75 | val schemeSpecific = referrer.schemeSpecificPart?.removePrefix("//")
76 | return schemeSpecific?.substringBefore("/")?.takeIf { it.isNotBlank() }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/capture/CaptureActivity.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.capture
2 |
3 | import android.content.Context
4 | import android.media.projection.MediaProjectionManager
5 | import android.os.Bundle
6 | import android.widget.Toast
7 | import androidx.activity.ComponentActivity
8 | import androidx.activity.result.contract.ActivityResultContracts
9 | import com.brycewg.pinme.Constants
10 | import com.brycewg.pinme.db.DatabaseProvider
11 | import kotlinx.coroutines.runBlocking
12 |
13 | class CaptureActivity : ComponentActivity() {
14 |
15 | private val mediaProjectionManager: MediaProjectionManager by lazy {
16 | getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
17 | }
18 |
19 | private var captureToastEnabled = true
20 |
21 | private val permissionLauncher =
22 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
23 | val data = result.data
24 | if (result.resultCode != RESULT_OK || data == null) {
25 | if (captureToastEnabled) {
26 | Toast.makeText(this, "未授予截屏权限", Toast.LENGTH_SHORT).show()
27 | }
28 | finishAndRemoveTask()
29 | return@registerForActivityResult
30 | }
31 |
32 | // 启动前台服务执行截屏 (Android 14+ 要求)
33 | ScreenCaptureService.start(this, result.resultCode, data)
34 | finishAndRemoveTask()
35 | @Suppress("DEPRECATION")
36 | overridePendingTransition(0, 0) // 禁用退出动画
37 | }
38 |
39 | override fun onCreate(savedInstanceState: Bundle?) {
40 | super.onCreate(savedInstanceState)
41 |
42 | // 检查截图模式偏好
43 | val (useRootCapture, useAccessibilityCapture) = runBlocking {
44 | if (!DatabaseProvider.isInitialized()) {
45 | DatabaseProvider.init(this@CaptureActivity)
46 | }
47 | val dao = DatabaseProvider.dao()
48 | val rootEnabled = dao.getPreference(Constants.PREF_USE_ROOT_CAPTURE) == "true"
49 | val accessibility = dao.getPreference(Constants.PREF_USE_ACCESSIBILITY_CAPTURE) == "true"
50 | captureToastEnabled = dao.getPreference(Constants.PREF_CAPTURE_TOAST_ENABLED) != "false"
51 | rootEnabled to accessibility
52 | }
53 |
54 | // Root 截图(与无障碍互斥,优先级更高)
55 | if (useRootCapture) {
56 | if (RootCaptureService.isSuAvailable()) {
57 | RootCaptureService.start(this)
58 | finishAndRemoveTask()
59 | @Suppress("DEPRECATION")
60 | overridePendingTransition(0, 0)
61 | return
62 | } else {
63 | if (captureToastEnabled) {
64 | Toast.makeText(this, "未检测到 Root,将使用传统截图方式", Toast.LENGTH_SHORT).show()
65 | }
66 | }
67 | }
68 |
69 | // 无障碍截图
70 | if (!useRootCapture && useAccessibilityCapture && AccessibilityCaptureService.isServiceEnabled(this)) {
71 | AccessibilityCaptureService.requestCapture()
72 | finishAndRemoveTask()
73 | @Suppress("DEPRECATION")
74 | overridePendingTransition(0, 0)
75 | return
76 | }
77 |
78 | // 否则使用 MediaProjection 方式
79 | permissionLauncher.launch(mediaProjectionManager.createScreenCaptureIntent())
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/db/AppDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.db
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import androidx.room.migration.Migration
6 | import androidx.sqlite.db.SupportSQLiteDatabase
7 |
8 | @Database(
9 | entities = [
10 | PreferenceEntity::class,
11 | ExtractEntity::class,
12 | MarketItemEntity::class
13 | ],
14 | version = 7,
15 | exportSchema = false
16 | )
17 | abstract class AppDatabase : RoomDatabase() {
18 | abstract fun pinMeDao(): PinMeDao
19 |
20 | companion object {
21 | val MIGRATION_1_2 = object : Migration(1, 2) {
22 | override fun migrate(db: SupportSQLiteDatabase) {
23 | db.execSQL("""
24 | CREATE TABLE IF NOT EXISTS `market_item` (
25 | `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
26 | `title` TEXT NOT NULL,
27 | `contentDesc` TEXT NOT NULL,
28 | `emoji` TEXT NOT NULL,
29 | `capsuleColor` TEXT NOT NULL,
30 | `durationMinutes` INTEGER NOT NULL,
31 | `isEnabled` INTEGER NOT NULL DEFAULT 1,
32 | `createdAtMillis` INTEGER NOT NULL
33 | )
34 | """.trimIndent())
35 | }
36 | }
37 |
38 | val MIGRATION_2_3 = object : Migration(2, 3) {
39 | override fun migrate(db: SupportSQLiteDatabase) {
40 | // 添加 isPreset 和 presetKey 字段
41 | db.execSQL("ALTER TABLE `market_item` ADD COLUMN `isPreset` INTEGER NOT NULL DEFAULT 0")
42 | db.execSQL("ALTER TABLE `market_item` ADD COLUMN `presetKey` TEXT DEFAULT NULL")
43 | }
44 | }
45 |
46 | val MIGRATION_3_4 = object : Migration(3, 4) {
47 | override fun migrate(db: SupportSQLiteDatabase) {
48 | // 给 extract 表添加 emoji 字段,存储 LLM 生成的 emoji
49 | db.execSQL("ALTER TABLE `extract` ADD COLUMN `emoji` TEXT DEFAULT NULL")
50 | }
51 | }
52 |
53 | val MIGRATION_4_5 = object : Migration(4, 5) {
54 | override fun migrate(db: SupportSQLiteDatabase) {
55 | // 给 extract 表添加 qrCodeBase64 字段,存储检测到的二维码图片
56 | db.execSQL("ALTER TABLE `extract` ADD COLUMN `qrCodeBase64` TEXT DEFAULT NULL")
57 | }
58 | }
59 | val MIGRATION_5_6 = object : Migration(5, 6) {
60 | override fun migrate(db: SupportSQLiteDatabase) {
61 | // 给 market_item 表添加输出示例字段,并填充预置示例
62 | db.execSQL("ALTER TABLE `market_item` ADD COLUMN `outputExample` TEXT NOT NULL DEFAULT ''")
63 | db.execSQL("UPDATE `market_item` SET `outputExample` = '5-8-2-1\n菜鸟驿站' WHERE `presetKey` = 'pickup_code'")
64 | db.execSQL("UPDATE `market_item` SET `outputExample` = 'A128\nB032' WHERE `presetKey` = 'meal_code'")
65 | db.execSQL("UPDATE `market_item` SET `outputExample` = '14:30 G1234 07车12F B2检票口' WHERE `presetKey` = 'train_ticket'")
66 | db.execSQL("UPDATE `market_item` SET `outputExample` = '847291' WHERE `presetKey` = 'verification_code'")
67 | db.execSQL("UPDATE `market_item` SET `outputExample` = '微信支付成功 ¥128.00\n航班CA1234 准点\n无有效信息' WHERE `presetKey` = 'no_match'")
68 | }
69 | }
70 |
71 | val MIGRATION_6_7 = object : Migration(6, 7) {
72 | override fun migrate(db: SupportSQLiteDatabase) {
73 | // 给 extract 表添加来源应用包名字段
74 | db.execSQL("ALTER TABLE `extract` ADD COLUMN `sourcePackage` TEXT DEFAULT NULL")
75 | }
76 | }
77 | }
78 | }
79 |
80 |
81 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.13.0"
3 | kotlin = "2.2.20"
4 | coreKtx = "1.17.0"
5 | junit = "4.13.2"
6 | junitVersion = "1.3.0"
7 | espressoCore = "3.7.0"
8 | kotlinxSerializationJson = "1.8.0"
9 | lifecycleRuntimeKtx = "2.9.4"
10 | composeBom = "2025.10.00"
11 | roomRuntime = "2.8.2"
12 | roomCompiler = "2.8.2"
13 | roomKtx = "2.8.2"
14 | materialIconsExtended = "1.7.8"
15 | okhttp = "5.2.1"
16 | okhttpBom = "5.2.1"
17 | glanceAppwidget = "1.2.0-beta01"
18 | glanceMaterial3 = "1.2.0-beta01"
19 | glance = "1.2.0-beta01"
20 | datastorePreferences = "1.1.1"
21 | mlkitBarcodeScanning = "17.3.0"
22 |
23 | [libraries]
24 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
25 | junit = { group = "junit", name = "junit", version.ref = "junit" }
26 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
27 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
28 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
29 | androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
30 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version = "1.11.0" }
31 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
32 | androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
33 | androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
34 | androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
35 | androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
36 | androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.9.3" }
37 | androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
38 | androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha06" }
39 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
40 | androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomRuntime" }
41 | androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomCompiler" }
42 | androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomKtx" }
43 | androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" }
44 | okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
45 | okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttpBom" }
46 | androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glanceAppwidget" }
47 | androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glanceMaterial3" }
48 | androidx-glance = { group = "androidx.glance", name = "glance", version.ref = "glance" }
49 | androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
50 | mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcodeScanning" }
51 |
52 | [plugins]
53 | android-application = { id = "com.android.application", version.ref = "agp" }
54 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
55 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
56 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
57 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/qrcode/QrCodeDetector.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.qrcode
2 |
3 | import android.graphics.Bitmap
4 | import android.graphics.Rect
5 | import android.util.Log
6 | import com.google.mlkit.vision.barcode.BarcodeScanning
7 | import com.google.mlkit.vision.barcode.common.Barcode
8 | import com.google.mlkit.vision.common.InputImage
9 | import kotlinx.coroutines.Dispatchers
10 | import kotlinx.coroutines.suspendCancellableCoroutine
11 | import kotlinx.coroutines.withContext
12 | import kotlin.coroutines.resume
13 | import kotlin.coroutines.resumeWithException
14 |
15 | /**
16 | * 二维码检测结果
17 | * @param boundingBox 二维码在原图中的边界框
18 | * @param croppedBitmap 裁剪后的二维码图片
19 | */
20 | data class QrCodeResult(
21 | val boundingBox: Rect,
22 | val croppedBitmap: Bitmap
23 | )
24 |
25 | object QrCodeDetector {
26 |
27 | private const val TAG = "QrCodeDetector"
28 |
29 | /** 裁剪时的边距比例(避免裁剪太紧) */
30 | private const val CROP_PADDING_RATIO = 0.1f
31 |
32 | /** 最大裁剪尺寸(用于 RemoteViews 的 Bitmap 限制) */
33 | private const val MAX_CROP_SIZE = 400
34 |
35 | /**
36 | * 检测截图中的二维码
37 | * @param bitmap 原始截图
38 | * @return 检测到的第一个二维码结果,未检测到返回 null
39 | */
40 | suspend fun detect(bitmap: Bitmap): QrCodeResult? = withContext(Dispatchers.Default) {
41 | val scanner = BarcodeScanning.getClient()
42 | val image = InputImage.fromBitmap(bitmap, 0)
43 |
44 | try {
45 | val barcodes = suspendCancellableCoroutine { cont ->
46 | scanner.process(image)
47 | .addOnSuccessListener { cont.resume(it) }
48 | .addOnFailureListener { cont.resumeWithException(it) }
49 | .addOnCanceledListener { cont.cancel() }
50 | }
51 |
52 | // 仅处理二维码类型(排除条形码等)
53 | val qrCode = barcodes.firstOrNull {
54 | it.format == Barcode.FORMAT_QR_CODE ||
55 | it.format == Barcode.FORMAT_DATA_MATRIX ||
56 | it.format == Barcode.FORMAT_AZTEC
57 | }
58 |
59 | if (qrCode == null || qrCode.boundingBox == null) {
60 | return@withContext null
61 | }
62 |
63 | val boundingBox = qrCode.boundingBox!!
64 | val croppedBitmap = cropQrCode(bitmap, boundingBox)
65 |
66 | QrCodeResult(
67 | boundingBox = boundingBox,
68 | croppedBitmap = croppedBitmap
69 | )
70 | } catch (e: Exception) {
71 | Log.e(TAG, "QR code detection failed", e)
72 | null
73 | } finally {
74 | scanner.close()
75 | }
76 | }
77 |
78 | /**
79 | * 裁剪二维码区域,带边距
80 | */
81 | private fun cropQrCode(bitmap: Bitmap, boundingBox: Rect): Bitmap {
82 | // 计算带边距的裁剪区域
83 | val padding = (minOf(boundingBox.width(), boundingBox.height()) * CROP_PADDING_RATIO).toInt()
84 | val left = maxOf(0, boundingBox.left - padding)
85 | val top = maxOf(0, boundingBox.top - padding)
86 | val right = minOf(bitmap.width, boundingBox.right + padding)
87 | val bottom = minOf(bitmap.height, boundingBox.bottom + padding)
88 |
89 | val cropped = Bitmap.createBitmap(
90 | bitmap,
91 | left,
92 | top,
93 | right - left,
94 | bottom - top
95 | )
96 |
97 | // 如果尺寸过大,缩放以满足 RemoteViews 限制
98 | return if (cropped.width > MAX_CROP_SIZE || cropped.height > MAX_CROP_SIZE) {
99 | val scale = MAX_CROP_SIZE.toFloat() / maxOf(cropped.width, cropped.height)
100 | val scaledWidth = (cropped.width * scale).toInt()
101 | val scaledHeight = (cropped.height * scale).toInt()
102 | Bitmap.createScaledBitmap(cropped, scaledWidth, scaledHeight, true).also {
103 | if (it !== cropped) cropped.recycle()
104 | }
105 | } else {
106 | cropped
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/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 | alias(libs.plugins.kotlin.serialization)
6 | id("kotlin-kapt")
7 | }
8 |
9 | android {
10 | namespace = "com.brycewg.pinme"
11 | compileSdk = 36
12 |
13 | defaultConfig {
14 | applicationId = "com.brycewg.pinme"
15 | minSdk = 33
16 | targetSdk = 36
17 | versionCode = 9
18 | versionName = "1.3.1"
19 |
20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
21 | }
22 |
23 | androidResources {
24 | // 只保留中英文资源,减少 APK 体积
25 | localeFilters += listOf("zh", "en")
26 | }
27 |
28 | signingConfigs {
29 | create("release") {
30 | val keystoreFile = file("release.keystore")
31 | if (keystoreFile.exists()) {
32 | storeFile = keystoreFile
33 | storePassword = System.getenv("KEYSTORE_PASSWORD")
34 | keyAlias = System.getenv("KEY_ALIAS")
35 | keyPassword = System.getenv("KEY_PASSWORD")
36 | }
37 | }
38 | }
39 |
40 | buildTypes {
41 | debug {
42 | applicationIdSuffix = ".debug"
43 | versionNameSuffix = "-debug"
44 | }
45 | release {
46 | isMinifyEnabled = true
47 | isShrinkResources = true
48 | proguardFiles(
49 | getDefaultProguardFile("proguard-android-optimize.txt"),
50 | "proguard-rules.pro"
51 | )
52 | val releaseConfig = signingConfigs.findByName("release")
53 | if (releaseConfig?.storeFile?.exists() == true) {
54 | signingConfig = releaseConfig
55 | }
56 | }
57 | }
58 | compileOptions {
59 | sourceCompatibility = JavaVersion.VERSION_11
60 | targetCompatibility = JavaVersion.VERSION_11
61 | }
62 | buildFeatures {
63 | compose = true
64 | buildConfig = true
65 | }
66 | }
67 |
68 | kotlin {
69 | compilerOptions {
70 | jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
71 | }
72 | }
73 |
74 | dependencies {
75 | implementation(libs.androidx.core.ktx)
76 | implementation(libs.androidx.lifecycle.runtime.ktx)
77 | implementation(libs.androidx.lifecycle.runtime.compose)
78 | implementation(libs.androidx.activity.compose)
79 | implementation(platform(libs.androidx.compose.bom))
80 | implementation(libs.androidx.compose.ui)
81 | implementation(libs.androidx.compose.ui.graphics)
82 | implementation(libs.androidx.compose.ui.tooling.preview)
83 | implementation(libs.androidx.compose.material3)
84 | implementation(libs.androidx.room.runtime) {
85 | exclude(group = "com.intellij", module = "annotations")
86 | }
87 | implementation(libs.androidx.compose.material.icons.extended)
88 | implementation(libs.okhttp)
89 | implementation(platform(libs.okhttp.bom))
90 | implementation(libs.androidx.datastore.preferences)
91 | implementation(libs.androidx.glance)
92 | implementation(libs.androidx.glance.appwidget)
93 | implementation(libs.androidx.glance.material3)
94 | kapt(libs.androidx.room.compiler) {
95 | exclude(group = "com.intellij", module = "annotations")
96 | }
97 | implementation(libs.androidx.room.ktx) {
98 | exclude(group = "com.intellij", module = "annotations")
99 | }
100 | implementation(libs.kotlinx.serialization.json)
101 | implementation(libs.mlkit.barcode.scanning)
102 | testImplementation(libs.junit)
103 | androidTestImplementation(libs.androidx.junit)
104 | androidTestImplementation(libs.androidx.espresso.core)
105 | androidTestImplementation(platform(libs.androidx.compose.bom))
106 | androidTestImplementation(libs.androidx.compose.ui.test.junit4)
107 | debugImplementation(libs.androidx.compose.ui.tooling)
108 | debugImplementation(libs.androidx.compose.ui.test.manifest)
109 | }
110 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/usage/SourceAppTracker.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.usage
2 |
3 | import android.content.Context
4 | import com.brycewg.pinme.Constants
5 | import com.brycewg.pinme.capture.AccessibilityCaptureService
6 | import com.brycewg.pinme.db.DatabaseProvider
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.async
9 | import kotlinx.coroutines.coroutineScope
10 | import kotlinx.coroutines.withContext
11 | import java.util.concurrent.TimeUnit
12 |
13 | object SourceAppTracker {
14 | suspend fun isEnabled(context: Context): Boolean = withContext(Dispatchers.IO) {
15 | if (!DatabaseProvider.isInitialized()) {
16 | DatabaseProvider.init(context)
17 | }
18 | DatabaseProvider.dao().getPreference(Constants.PREF_SOURCE_APP_JUMP_ENABLED) == "true"
19 | }
20 |
21 | fun resolveForegroundPackage(context: Context): String? {
22 | return AccessibilityCaptureService.getActiveWindowPackageName(context)
23 | }
24 |
25 | suspend fun resolveForegroundPackageWithRootFallback(context: Context): String? = withContext(Dispatchers.IO) {
26 | val fromAccessibility = resolveForegroundPackage(context)
27 | if (!fromAccessibility.isNullOrBlank()) {
28 | return@withContext fromAccessibility
29 | }
30 | resolveForegroundPackageViaRoot(context)
31 | }
32 |
33 | private suspend fun resolveForegroundPackageViaRoot(context: Context): String? {
34 | val activityOutput = runSuCommand("dumpsys activity activities")
35 | val activityPackage = activityOutput?.let { extractPackageFromOutput(it, context) }
36 | if (!activityPackage.isNullOrBlank()) {
37 | return activityPackage
38 | }
39 | val windowOutput = runSuCommand("dumpsys window windows")
40 | return windowOutput?.let { extractPackageFromOutput(it, context) }
41 | }
42 |
43 | private fun extractPackageFromOutput(output: String, context: Context): String? {
44 | val patterns = listOf(
45 | Regex("mResumedActivity:.*?\\s([\\w.]+)/([\\w.]+|\\.[\\w.]+)"),
46 | Regex("ResumedActivity:.*?\\s([\\w.]+)/([\\w.]+|\\.[\\w.]+)"),
47 | Regex("mFocusedApp=.*?\\s([\\w.]+)/([\\w.]+|\\.[\\w.]+)"),
48 | Regex("mCurrentFocus=.*?\\s([\\w.]+)/([\\w.]+|\\.[\\w.]+)")
49 | )
50 | for (line in output.lineSequence()) {
51 | for (pattern in patterns) {
52 | val match = pattern.find(line) ?: continue
53 | val packageName = match.groupValues.getOrNull(1)?.trim().orEmpty()
54 | if (packageName.isNotBlank() && packageName != context.packageName) {
55 | return packageName
56 | }
57 | }
58 | }
59 | return null
60 | }
61 |
62 | private suspend fun runSuCommand(command: String, timeoutSeconds: Long = 2): String? = coroutineScope {
63 | val process = try {
64 | ProcessBuilder("su", "-c", command).start()
65 | } catch (_: Exception) {
66 | return@coroutineScope null
67 | }
68 |
69 | try {
70 | process.outputStream.close()
71 | val stdoutDeferred = async { process.inputStream.bufferedReader().use { it.readText() } }
72 | val stderrDeferred = async { process.errorStream.bufferedReader().use { it.readText() } }
73 | val exited = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
74 | if (!exited) {
75 | process.destroy()
76 | process.waitFor(200, TimeUnit.MILLISECONDS)
77 | if (process.isAlive) {
78 | process.destroyForcibly()
79 | }
80 | return@coroutineScope null
81 | }
82 | val stdout = runCatching { stdoutDeferred.await() }.getOrDefault("")
83 | runCatching { stderrDeferred.await() }
84 | if (process.exitValue() != 0) {
85 | return@coroutineScope null
86 | }
87 | if (stdout.isBlank()) {
88 | return@coroutineScope null
89 | }
90 | stdout
91 | } finally {
92 | if (process.isAlive) {
93 | process.destroy()
94 | }
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/live_notification_card.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
19 |
20 |
21 |
26 |
27 |
37 |
38 |
55 |
56 |
64 |
65 |
66 |
67 |
79 |
80 |
81 |
82 |
89 |
90 |
91 |
100 |
101 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release APK
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 | paths:
9 | - 'app/build.gradle.kts'
10 |
11 | jobs:
12 | check-version:
13 | runs-on: ubuntu-latest
14 | outputs:
15 | should_release: ${{ steps.check.outputs.should_release }}
16 | version: ${{ steps.check.outputs.version }}
17 | steps:
18 | - name: Checkout code
19 | uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 0
22 |
23 | - name: Check version change
24 | id: check
25 | run: |
26 | # 获取当前 versionName
27 | CURRENT_VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
28 | echo "Current version: $CURRENT_VERSION"
29 |
30 | # 检查该版本的 tag 是否已存在
31 | if git rev-parse "v$CURRENT_VERSION" >/dev/null 2>&1; then
32 | echo "Tag v$CURRENT_VERSION already exists, skipping release"
33 | echo "should_release=false" >> $GITHUB_OUTPUT
34 | else
35 | echo "New version detected: $CURRENT_VERSION"
36 | echo "should_release=true" >> $GITHUB_OUTPUT
37 | echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
38 | fi
39 |
40 | build-and-release:
41 | needs: check-version
42 | if: needs.check-version.outputs.should_release == 'true'
43 | runs-on: ubuntu-latest
44 | permissions:
45 | contents: write
46 |
47 | steps:
48 | - name: Checkout code
49 | uses: actions/checkout@v4
50 | with:
51 | fetch-depth: 0
52 |
53 | - name: Set up JDK 17
54 | uses: actions/setup-java@v4
55 | with:
56 | java-version: '17'
57 | distribution: 'temurin'
58 |
59 | - name: Setup Gradle
60 | uses: gradle/actions/setup-gradle@v4
61 |
62 | - name: Grant execute permission for gradlew
63 | run: chmod +x gradlew
64 |
65 | - name: Decode Keystore
66 | env:
67 | KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
68 | run: |
69 | if [ -n "$KEYSTORE_BASE64" ]; then
70 | echo "$KEYSTORE_BASE64" | tr -d '\n\r ' | base64 -d > app/release.keystore
71 | echo "Keystore decoded successfully"
72 | else
73 | echo "KEYSTORE_BASE64 not set, skipping"
74 | fi
75 |
76 | - name: Build Release APK
77 | env:
78 | KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
79 | KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
80 | KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
81 | run: ./gradlew assembleRelease
82 |
83 | - name: Find and rename APK
84 | id: apk
85 | run: |
86 | APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
87 | NEW_NAME="pinme-${{ needs.check-version.outputs.version }}.apk"
88 | mv "$APK_PATH" "app/build/outputs/apk/release/$NEW_NAME"
89 | echo "APK_NAME=$NEW_NAME" >> $GITHUB_OUTPUT
90 |
91 | - name: Get previous tag
92 | id: prev_tag
93 | run: |
94 | PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
95 | echo "PREV_TAG=$PREV_TAG" >> $GITHUB_OUTPUT
96 |
97 | - name: Generate changelog
98 | id: changelog
99 | run: |
100 | if [ -n "${{ steps.prev_tag.outputs.PREV_TAG }}" ]; then
101 | CHANGELOG=$(git log ${{ steps.prev_tag.outputs.PREV_TAG }}..HEAD --pretty=format:"- %s (%h)" --no-merges)
102 | else
103 | CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges -20)
104 | fi
105 | echo "CHANGELOG<> $GITHUB_OUTPUT
106 | echo "$CHANGELOG" >> $GITHUB_OUTPUT
107 | echo "EOF" >> $GITHUB_OUTPUT
108 |
109 | - name: Create Tag
110 | run: |
111 | git config user.name "github-actions[bot]"
112 | git config user.email "github-actions[bot]@users.noreply.github.com"
113 | git tag -a "v${{ needs.check-version.outputs.version }}" -m "Release v${{ needs.check-version.outputs.version }}"
114 | git push origin "v${{ needs.check-version.outputs.version }}"
115 |
116 | - name: Create Release
117 | uses: softprops/action-gh-release@v2
118 | with:
119 | tag_name: v${{ needs.check-version.outputs.version }}
120 | name: PinMe ${{ needs.check-version.outputs.version }}
121 | body: |
122 | ## What's Changed
123 | ${{ steps.changelog.outputs.CHANGELOG }}
124 | files: app/build/outputs/apk/release/${{ steps.apk.outputs.APK_NAME }}
125 | draft: false
126 | prerelease: false
127 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/live_notification_qrcode_card.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
19 |
20 |
25 |
26 |
35 |
36 |
53 |
54 |
63 |
64 |
65 |
72 |
73 |
74 |
85 |
86 |
87 |
88 |
95 |
96 |
97 |
106 |
107 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/db/DatabaseProvider.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.db
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import androidx.room.RoomDatabase
6 | import androidx.sqlite.db.SupportSQLiteDatabase
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.launch
10 | import kotlinx.coroutines.sync.Mutex
11 | import kotlinx.coroutines.sync.withLock
12 |
13 | object DatabaseProvider {
14 | private val lock = Any()
15 | private val insertMutex = Mutex()
16 | private var presetItemsInserted = false
17 | lateinit var db: AppDatabase
18 | private set
19 |
20 | fun isInitialized(): Boolean = ::db.isInitialized
21 |
22 | fun init(context: Context) {
23 | synchronized(lock) {
24 | if (!::db.isInitialized) {
25 | db = Room.databaseBuilder(
26 | context.applicationContext,
27 | AppDatabase::class.java,
28 | "pinme.db"
29 | )
30 | .addMigrations(
31 | AppDatabase.MIGRATION_1_2,
32 | AppDatabase.MIGRATION_2_3,
33 | AppDatabase.MIGRATION_3_4,
34 | AppDatabase.MIGRATION_4_5,
35 | AppDatabase.MIGRATION_5_6,
36 | AppDatabase.MIGRATION_6_7
37 | )
38 | .addCallback(object : RoomDatabase.Callback() {
39 | override fun onOpen(db: SupportSQLiteDatabase) {
40 | super.onOpen(db)
41 | // 每次打开数据库时检查并插入缺失的预置类型
42 | // 注意:onCreate 后必定会调用 onOpen,所以只需在 onOpen 中处理
43 | CoroutineScope(Dispatchers.IO).launch {
44 | insertPresetMarketItems()
45 | }
46 | }
47 | })
48 | .build()
49 | }
50 | }
51 | }
52 |
53 | fun dao(): PinMeDao = db.pinMeDao()
54 |
55 | private suspend fun insertPresetMarketItems() {
56 | // 使用 Mutex 确保只执行一次,避免并发问题
57 | insertMutex.withLock {
58 | if (presetItemsInserted) return
59 | val dao = db.pinMeDao()
60 | // 清理已废弃的二维码预设(二维码检测由独立管线处理,不再作为 AI 识别类型)
61 | dao.deleteMarketItemByPresetKey("qr_code")
62 | PresetMarketTypes.ALL.forEach { preset ->
63 | // 使用带事务的方法确保检查和插入的原子性
64 | dao.insertPresetMarketItemIfNotExists(preset)
65 | }
66 | presetItemsInserted = true
67 | }
68 | }
69 | }
70 |
71 | /**
72 | * 预置市场类型定义
73 | */
74 | object PresetMarketTypes {
75 | val PICKUP_CODE = MarketItemEntity(
76 | title = "取件码",
77 | contentDesc = "取件码+驿站/快递柜名称(如:5-8-2-1 菜鸟驿站)",
78 | outputExample = "5-8-2-1\n菜鸟驿站",
79 | emoji = "📦",
80 | capsuleColor = "#FFC107",
81 | durationMinutes = 30,
82 | isEnabled = true,
83 | isPreset = true,
84 | presetKey = "pickup_code"
85 | )
86 |
87 | val MEAL_CODE = MarketItemEntity(
88 | title = "取餐码",
89 | contentDesc = "餐饮取餐号/排队号",
90 | outputExample = "A128\nB032",
91 | emoji = "🍔",
92 | capsuleColor = "#FF5722",
93 | durationMinutes = 15,
94 | isEnabled = true,
95 | isPreset = true,
96 | presetKey = "meal_code"
97 | )
98 |
99 | val TRAIN_TICKET = MarketItemEntity(
100 | title = "火车票",
101 | contentDesc = "出发时间+车次+座位+检票口(如:14:30 G1234 07车12F B2检票口)",
102 | outputExample = "14:30 G1234 07车12F B2检票口",
103 | emoji = "🚄",
104 | capsuleColor = "#2196F3",
105 | durationMinutes = 120,
106 | isEnabled = true,
107 | isPreset = true,
108 | presetKey = "train_ticket"
109 | )
110 |
111 | val VERIFICATION_CODE = MarketItemEntity(
112 | title = "验证码",
113 | contentDesc = "短信/邮件验证码",
114 | outputExample = "847291",
115 | emoji = "🔐",
116 | capsuleColor = "#4CAF50",
117 | durationMinutes = 5,
118 | isEnabled = true,
119 | isPreset = true,
120 | presetKey = "verification_code"
121 | )
122 |
123 | val NO_MATCH = MarketItemEntity(
124 | title = "无匹配",
125 | contentDesc = "屏幕内容摘要(无特定类型匹配时)",
126 | outputExample = "微信支付成功 ¥128.00\n航班CA1234 准点\n无有效信息",
127 | emoji = "📋",
128 | capsuleColor = "#607D8B",
129 | durationMinutes = 10,
130 | isEnabled = true,
131 | isPreset = true,
132 | presetKey = "no_match"
133 | )
134 |
135 | val ALL = listOf(
136 | PICKUP_CODE,
137 | MEAL_CODE,
138 | TRAIN_TICKET,
139 | VERIFICATION_CODE,
140 | NO_MATCH
141 | )
142 | }
143 |
144 |
145 |
146 |
--------------------------------------------------------------------------------
/GEMINI.md:
--------------------------------------------------------------------------------
1 | # PinMe - Intelligent Screen Capture & Extraction
2 |
3 | ## Project Overview
4 |
5 | **PinMe** (`com.brycewg.pinme`) is an Android application designed to intelligently capture screen content and extract key information (e.g., pickup codes, train tickets, verification codes) using Vision Large Language Models (LLMs). The extracted information is then presented via Meizu Flyme's Live Notifications (Capsule) or a standard Android AppWidget for quick access.
6 |
7 | ## Technology Stack
8 |
9 | - **Language:** Kotlin
10 | - **UI Framework:** Jetpack Compose (Material3)
11 | - **Architecture:** Modern Android Architecture (Compose + Room + Coroutines)
12 | - **Database:** Room (SQLite abstraction)
13 | - **Networking:** OkHttp (for LLM API communication)
14 | - **AI/LLM Integration:** OpenAI-compatible API client (supports Zhipu AI, SiliconFlow, Custom)
15 | - **Key Android APIs:**
16 | - `MediaProjection`: For screen capture.
17 | - `AccessibilityService`: For silent/background capture.
18 | - `Glance`: For building AppWidgets with Compose-like syntax.
19 | - `NotificationManager`: For system and Flyme-specific notifications.
20 |
21 | ## Project Structure
22 |
23 | ```text
24 | app/src/main/java/com/brycewg/pinme/
25 | ├── Constants.kt # App-wide constants (Prefs keys, LLM defaults)
26 | ├── MainActivity.kt # Main entry point / Configuration UI
27 | ├── capture/ # Screen capture logic
28 | │ ├── CaptureActivity.kt # Foreground capture (MediaProjection)
29 | │ ├── Accessibility... # Background/Silent capture service
30 | │ └── QuickCaptureTile... # Quick Settings Tile implementation
31 | ├── db/ # Room Database definitions
32 | │ ├── AppDatabase.kt # Database holder
33 | │ ├── Entity.kt # Data models (ExtractEntity, PreferenceEntity)
34 | │ └── PinMeDao.kt # Data Access Objects
35 | ├── extract/ # Core Business Logic
36 | │ ├── ExtractWorkflow.kt # Orchestrates Capture -> LLM -> DB flow
37 | │ └── ExtractParsing.kt # Parses LLM JSON response
38 | ├── notification/ # Notification handling
39 | │ └── UnifiedNotification... # Handles Flyme Live Capsules & Standard Notifs
40 | ├── ui/ # Jetpack Compose UI
41 | │ ├── components/ # Reusable UI elements
42 | │ ├── layouts/ # Screen layouts (Settings, Home, etc.)
43 | │ └── theme/ # Theme definitions
44 | ├── vllm/ # LLM Client Network Layer
45 | │ └── VllmClient.kt # OkHttp client for OpenAI-compatible APIs
46 | └── widget/ # Home Screen Widget
47 | └── PinMeWidget.kt # Glance AppWidget implementation
48 | ```
49 |
50 | ## Core Workflows
51 |
52 | ### 1. Screen Capture
53 |
54 | Triggered via Quick Settings Tile, Widget, or Shortcut.
55 |
56 | - **Foreground:** `CaptureActivity` requests `MediaProjection` permission.
57 | - **Background:** `AccessibilityCaptureService` or Root (if enabled) captures silently.
58 | - **Output:** Generates a `Bitmap` of the current screen.
59 |
60 | ### 2. Information Extraction (`ExtractWorkflow.kt`)
61 |
62 | 1. **Preprocessing:** Bitmap is compressed to JPEG and encoded to Base64 (max width 1080px).
63 | 2. **Prompting:** Constructs a system prompt based on user-configured "Market Items" (types of info to extract) and a user prompt.
64 | 3. **Inference:** Sends request to the configured LLM provider (Zhipu, SiliconFlow, or Custom).
65 | 4. **Parsing:** `ExtractParsing` validates and parses the JSON response from the LLM.
66 | 5. **Storage:** Saves the result (`ExtractEntity`) to the Room database.
67 |
68 | ### 3. Display & Notification
69 |
70 | - **Flyme Live Notification:** If supported (Meizu devices), displays a dynamic "capsule" in the status bar.
71 | - **Standard Notification:** Fallback for other devices.
72 | - **Widget:** `PinMeWidget` updates to show the most recent extraction.
73 |
74 | ## Build & Run
75 |
76 | ### Prerequisites
77 |
78 | - JDK 11+
79 | - Android SDK (Min SDK 33, Target SDK 36)
80 |
81 | ### Common Commands
82 |
83 | Never use `./gradlew build` in this project.
84 |
85 | ## Configuration
86 |
87 | ### Constants & Preferences
88 |
89 | Defined in `Constants.kt`. Key preferences include:
90 |
91 | - `PREF_LLM_PROVIDER`: Selected LLM provider (`zhipu`, `siliconflow`, `custom`).
92 | - `PREF_LLM_API_KEY`: API Key for the provider.
93 | - `PREF_LLM_MODEL`: Model ID (e.g., `glm-4v-flash`, `Qwen/Qwen2.5-VL-72B-Instruct`).
94 |
95 | ### Database Models (`db/Entity.kt`)
96 |
97 | - `PreferenceEntity`: Key-value storage for app settings.
98 | - `ExtractEntity`: Stores extraction history (Title, Content, Emoji, Raw Output).
99 | - `MarketItemEntity`: Configurable extraction types (e.g., "Pickup Code", "Train Ticket") that guide the LLM's focus.
100 |
101 | ## Development Guidelines
102 |
103 | - **Communication:** In Chinese.
104 | - **Style:** Follow Official Kotlin Coding Conventions.
105 | - **UI:** Use Jetpack Compose for all new UI.
106 | - **Privacy:** Never log sensitive user screen content.
107 | - **Threading:** Use Coroutines for all I/O and heavy computation (LLM calls, DB access).
108 | - **Flyme Integration:** Check for feature availability before calling Flyme-specific APIs (`UnifiedNotificationManager.isLiveCapsuleCustomizationAvailable()`).
109 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/db/PinMeDao.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.db
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Delete
5 | import androidx.room.Insert
6 | import androidx.room.OnConflictStrategy
7 | import androidx.room.Query
8 | import androidx.room.Transaction
9 | import androidx.room.Update
10 | import kotlinx.coroutines.flow.Flow
11 |
12 | @Dao
13 | abstract class PinMeDao {
14 | @Query("SELECT value FROM preference WHERE prefKey = :prefKey LIMIT 1")
15 | abstract fun getPreferenceFlow(prefKey: String): Flow
16 |
17 | @Query("SELECT value FROM preference WHERE prefKey = :prefKey LIMIT 1")
18 | abstract suspend fun getPreference(prefKey: String): String?
19 |
20 | @Insert(onConflict = OnConflictStrategy.REPLACE)
21 | abstract suspend fun insertPreference(preference: PreferenceEntity)
22 |
23 | @Transaction
24 | open suspend fun setPreference(key: String, value: String) {
25 | insertPreference(PreferenceEntity(key, value))
26 | }
27 |
28 | @Insert
29 | abstract suspend fun insertExtract(extract: ExtractEntity): Long
30 |
31 | @Query("UPDATE extract SET qrCodeBase64 = :qrCodeBase64 WHERE id = :id")
32 | abstract suspend fun updateExtractQrCode(id: Long, qrCodeBase64: String)
33 |
34 | @Query("UPDATE extract SET title = :title, content = :content, emoji = :emoji WHERE id = :id")
35 | abstract suspend fun updateExtract(id: Long, title: String, content: String, emoji: String?)
36 |
37 | @Query("SELECT * FROM extract ORDER BY createdAtMillis DESC LIMIT :limit")
38 | abstract fun getLatestExtractsFlow(limit: Int): Flow>
39 |
40 | @Query("SELECT * FROM extract ORDER BY createdAtMillis DESC LIMIT :limit")
41 | abstract suspend fun getLatestExtractsOnce(limit: Int): List
42 |
43 | @Query("DELETE FROM extract")
44 | abstract suspend fun deleteAllExtracts()
45 |
46 | @Query("DELETE FROM extract WHERE id = :id")
47 | abstract suspend fun deleteExtractById(id: Long)
48 |
49 | @Query("SELECT COUNT(*) FROM extract")
50 | abstract suspend fun getExtractCount(): Int
51 |
52 | @Query("SELECT * FROM extract ORDER BY createdAtMillis DESC LIMIT :limit OFFSET :offset")
53 | abstract suspend fun getExtractsWithOffset(limit: Int, offset: Int): List
54 |
55 | @Query("DELETE FROM extract WHERE id IN (SELECT id FROM extract ORDER BY createdAtMillis ASC LIMIT :deleteCount)")
56 | abstract suspend fun deleteOldestExtracts(deleteCount: Int)
57 |
58 | @Transaction
59 | open suspend fun trimExtractsToLimit(maxCount: Int) {
60 | val currentCount = getExtractCount()
61 | if (currentCount > maxCount) {
62 | deleteOldestExtracts(currentCount - maxCount)
63 | }
64 | }
65 |
66 | // Market Item operations
67 | @Insert
68 | abstract suspend fun insertMarketItem(item: MarketItemEntity): Long
69 |
70 | @Update
71 | abstract suspend fun updateMarketItem(item: MarketItemEntity)
72 |
73 | @Delete
74 | abstract suspend fun deleteMarketItem(item: MarketItemEntity)
75 |
76 | @Query("SELECT * FROM market_item ORDER BY createdAtMillis DESC")
77 | abstract fun getAllMarketItemsFlow(): Flow>
78 |
79 | @Query("SELECT * FROM market_item WHERE isEnabled = 1 ORDER BY createdAtMillis DESC")
80 | abstract suspend fun getEnabledMarketItems(): List
81 |
82 | @Query("SELECT * FROM market_item WHERE id = :id LIMIT 1")
83 | abstract suspend fun getMarketItemById(id: Long): MarketItemEntity?
84 |
85 | @Query("DELETE FROM market_item WHERE id = :id")
86 | abstract suspend fun deleteMarketItemById(id: Long)
87 |
88 | @Query("SELECT * FROM market_item WHERE presetKey = :presetKey LIMIT 1")
89 | abstract suspend fun getMarketItemByPresetKey(presetKey: String): MarketItemEntity?
90 |
91 | @Query("DELETE FROM market_item WHERE presetKey = :presetKey")
92 | abstract suspend fun deleteMarketItemByPresetKey(presetKey: String)
93 |
94 | @Query("SELECT * FROM market_item WHERE isPreset = 1 ORDER BY createdAtMillis ASC")
95 | abstract fun getPresetMarketItemsFlow(): Flow>
96 |
97 | @Query("SELECT * FROM market_item WHERE isPreset = 0 ORDER BY createdAtMillis DESC")
98 | abstract fun getCustomMarketItemsFlow(): Flow>
99 |
100 | /**
101 | * 在事务中插入预置市场项(如果不存在)
102 | * 使用事务确保检查和插入的原子性,避免重复插入
103 | */
104 | @Transaction
105 | open suspend fun insertPresetMarketItemIfNotExists(item: MarketItemEntity) {
106 | val existing = getMarketItemByPresetKey(item.presetKey!!)
107 | if (existing == null) {
108 | insertMarketItem(item)
109 | }
110 | }
111 |
112 | /**
113 | * 重置所有预置市场项为默认配置
114 | * - 已存在的预置项:覆盖标题、描述、图标、颜色、时长、启用状态
115 | * - 不存在的预置项:插入默认记录
116 | */
117 | @Transaction
118 | open suspend fun resetPresetMarketItems(defaultItems: List) {
119 | defaultItems.forEach { preset ->
120 | val key = preset.presetKey ?: return@forEach
121 | val existing = getMarketItemByPresetKey(key)
122 | if (existing == null) {
123 | insertMarketItem(preset)
124 | } else {
125 | val updated = existing.copy(
126 | title = preset.title,
127 | contentDesc = preset.contentDesc,
128 | outputExample = preset.outputExample,
129 | emoji = preset.emoji,
130 | capsuleColor = preset.capsuleColor,
131 | durationMinutes = preset.durationMinutes,
132 | isEnabled = preset.isEnabled
133 | )
134 | updateMarketItem(updated)
135 | }
136 | }
137 | }
138 | }
139 |
140 |
141 |
--------------------------------------------------------------------------------
/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/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
42 |
43 |
48 |
49 |
50 |
51 |
52 |
55 |
56 |
57 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
92 |
93 |
94 |
95 |
98 |
99 |
100 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
113 |
114 |
115 |
116 |
117 |
120 |
121 |
122 |
128 |
129 |
130 |
131 |
132 |
133 |
137 |
138 |
142 |
143 |
147 |
148 |
153 |
154 |
155 |
156 |
159 |
160 |
161 |
164 |
165 |
168 |
169 |
170 |
175 |
178 |
179 |
180 |
181 |
182 |
183 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/vllm/VllmClient.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.vllm
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.withContext
5 | import okhttp3.MediaType.Companion.toMediaType
6 | import okhttp3.OkHttpClient
7 | import okhttp3.Request
8 | import okhttp3.RequestBody.Companion.toRequestBody
9 | import org.json.JSONArray
10 | import org.json.JSONObject
11 | import java.util.concurrent.TimeUnit
12 |
13 | class VllmClient(
14 | private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
15 | .connectTimeout(30, TimeUnit.SECONDS)
16 | .readTimeout(90, TimeUnit.SECONDS)
17 | .writeTimeout(90, TimeUnit.SECONDS)
18 | .build()
19 | ) {
20 | suspend fun chatCompletionWithImage(
21 | baseUrl: String,
22 | apiKey: String?,
23 | model: String,
24 | systemPrompt: String,
25 | userPrompt: String,
26 | imageBase64: String,
27 | temperature: Double = 0.1,
28 | maxTokens: Int = 256
29 | ): String = withContext(Dispatchers.IO) {
30 | val url = buildChatCompletionsUrl(baseUrl)
31 | val bodyJson = JSONObject().apply {
32 | put("model", model)
33 | put(
34 | "messages",
35 | JSONArray()
36 | .put(
37 | JSONObject().apply {
38 | put("role", "system")
39 | put("content", systemPrompt)
40 | }
41 | )
42 | .put(
43 | JSONObject().apply {
44 | put("role", "user")
45 | put(
46 | "content",
47 | JSONArray()
48 | .put(
49 | JSONObject().apply {
50 | put("type", "text")
51 | put("text", userPrompt)
52 | }
53 | )
54 | .put(
55 | JSONObject().apply {
56 | put("type", "image_url")
57 | put(
58 | "image_url",
59 | JSONObject().apply {
60 | put("url", "data:image/png;base64,$imageBase64")
61 | }
62 | )
63 | }
64 | )
65 | )
66 | }
67 | )
68 | )
69 | put("temperature", temperature)
70 | put("max_tokens", maxTokens)
71 | }
72 |
73 | val requestBody = bodyJson.toString().toRequestBody("application/json; charset=utf-8".toMediaType())
74 | val requestBuilder = Request.Builder()
75 | .url(url)
76 | .post(requestBody)
77 | .header("Content-Type", "application/json")
78 |
79 | if (!apiKey.isNullOrBlank()) {
80 | requestBuilder.header("Authorization", "Bearer ${apiKey.trim()}")
81 | }
82 |
83 | val response = okHttpClient.newCall(requestBuilder.build()).execute()
84 | response.use { resp ->
85 | val responseBody = resp.body?.string().orEmpty()
86 | if (!resp.isSuccessful) {
87 | throw IllegalStateException("vLLM请求失败: HTTP ${resp.code} ${resp.message}\n$responseBody")
88 | }
89 |
90 | val json = JSONObject(responseBody)
91 | val content = json
92 | .getJSONArray("choices")
93 | .getJSONObject(0)
94 | .getJSONObject("message")
95 | .optString("content", "")
96 | if (content.isBlank()) {
97 | throw IllegalStateException("vLLM返回空内容")
98 | }
99 | content
100 | }
101 | }
102 |
103 | suspend fun chatCompletion(
104 | baseUrl: String,
105 | apiKey: String?,
106 | model: String,
107 | systemPrompt: String,
108 | userPrompt: String,
109 | temperature: Double = 0.1,
110 | maxTokens: Int = 256
111 | ): String = withContext(Dispatchers.IO) {
112 | val url = buildChatCompletionsUrl(baseUrl)
113 | val bodyJson = JSONObject().apply {
114 | put("model", model)
115 | put(
116 | "messages",
117 | JSONArray()
118 | .put(
119 | JSONObject().apply {
120 | put("role", "system")
121 | put("content", systemPrompt)
122 | }
123 | )
124 | .put(
125 | JSONObject().apply {
126 | put("role", "user")
127 | put("content", userPrompt)
128 | }
129 | )
130 | )
131 | put("temperature", temperature)
132 | put("max_tokens", maxTokens)
133 | }
134 |
135 | val requestBody = bodyJson.toString().toRequestBody("application/json; charset=utf-8".toMediaType())
136 | val requestBuilder = Request.Builder()
137 | .url(url)
138 | .post(requestBody)
139 | .header("Content-Type", "application/json")
140 |
141 | if (!apiKey.isNullOrBlank()) {
142 | requestBuilder.header("Authorization", "Bearer ${apiKey.trim()}")
143 | }
144 |
145 | val response = okHttpClient.newCall(requestBuilder.build()).execute()
146 | response.use { resp ->
147 | val responseBody = resp.body?.string().orEmpty()
148 | if (!resp.isSuccessful) {
149 | throw IllegalStateException("vLLM请求失败: HTTP ${resp.code} ${resp.message}\n$responseBody")
150 | }
151 |
152 | val json = JSONObject(responseBody)
153 | val content = json
154 | .getJSONArray("choices")
155 | .getJSONObject(0)
156 | .getJSONObject("message")
157 | .optString("content", "")
158 | if (content.isBlank()) {
159 | throw IllegalStateException("vLLM返回空内容")
160 | }
161 | content
162 | }
163 | }
164 |
165 | suspend fun testConnection(
166 | baseUrl: String,
167 | apiKey: String?,
168 | model: String,
169 | imageBase64: String
170 | ): String = withContext(Dispatchers.IO) {
171 | val url = buildChatCompletionsUrl(baseUrl)
172 | val bodyJson = JSONObject().apply {
173 | put("model", model)
174 | put(
175 | "messages",
176 | JSONArray()
177 | .put(
178 | JSONObject().apply {
179 | put("role", "user")
180 | put(
181 | "content",
182 | JSONArray()
183 | .put(
184 | JSONObject().apply {
185 | put("type", "text")
186 | put("text", "Describe this image in one short sentence.")
187 | }
188 | )
189 | .put(
190 | JSONObject().apply {
191 | put("type", "image_url")
192 | put(
193 | "image_url",
194 | JSONObject().apply {
195 | put(
196 | "url",
197 | "data:image/png;base64,${imageBase64.trim()}"
198 | )
199 | }
200 | )
201 | }
202 | )
203 | )
204 | }
205 | )
206 | )
207 | put("max_tokens", 32)
208 | }
209 |
210 | val requestBody = bodyJson.toString().toRequestBody("application/json; charset=utf-8".toMediaType())
211 | val requestBuilder = Request.Builder()
212 | .url(url)
213 | .post(requestBody)
214 | .header("Content-Type", "application/json")
215 |
216 | if (!apiKey.isNullOrBlank()) {
217 | requestBuilder.header("Authorization", "Bearer ${apiKey.trim()}")
218 | }
219 |
220 | val response = okHttpClient.newCall(requestBuilder.build()).execute()
221 | response.use { resp ->
222 | val responseBody = resp.body?.string().orEmpty()
223 | if (!resp.isSuccessful) {
224 | throw IllegalStateException("HTTP ${resp.code}: $responseBody")
225 | }
226 |
227 | val json = JSONObject(responseBody)
228 | json.getJSONArray("choices")
229 | .getJSONObject(0)
230 | .getJSONObject("message")
231 | .optString("content", "")
232 | }
233 | }
234 |
235 | private fun buildChatCompletionsUrl(baseUrl: String): String {
236 | val trimmed = baseUrl.trim().trimEnd('/')
237 | // 检查是否已包含版本号 (如 /v1, /v4 等)
238 | return if (Regex("/v\\d+$").containsMatchIn(trimmed)) {
239 | "$trimmed/chat/completions"
240 | } else {
241 | "$trimmed/v1/chat/completions"
242 | }
243 | }
244 | }
245 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/share/ShareProcessorService.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.share
2 |
3 | import android.app.Notification
4 | import android.app.NotificationChannel
5 | import android.app.NotificationManager
6 | import android.app.Service
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.content.pm.ServiceInfo
10 | import android.graphics.Bitmap
11 | import android.graphics.BitmapFactory
12 | import android.net.Uri
13 | import android.os.Handler
14 | import android.os.IBinder
15 | import android.os.Looper
16 | import android.util.Base64
17 | import android.util.Log
18 | import android.widget.Toast
19 | import com.brycewg.pinme.R
20 | import com.brycewg.pinme.db.DatabaseProvider
21 | import com.brycewg.pinme.db.ExtractEntity
22 | import com.brycewg.pinme.extract.ExtractWorkflow
23 | import com.brycewg.pinme.notification.UnifiedNotificationManager
24 | import com.brycewg.pinme.qrcode.QrCodeDetector
25 | import com.brycewg.pinme.widget.PinMeWidget
26 | import kotlinx.coroutines.CoroutineScope
27 | import kotlinx.coroutines.Dispatchers
28 | import kotlinx.coroutines.SupervisorJob
29 | import kotlinx.coroutines.async
30 | import kotlinx.coroutines.cancel
31 | import kotlinx.coroutines.coroutineScope
32 | import kotlinx.coroutines.launch
33 | import kotlinx.coroutines.withContext
34 | import java.io.ByteArrayOutputStream
35 |
36 | class ShareProcessorService : Service() {
37 |
38 | companion object {
39 | private const val TAG = "ShareProcessorService"
40 | private const val CHANNEL_ID = "share_processor_channel"
41 | private const val NOTIFICATION_ID = 1003
42 |
43 | private const val EXTRA_TYPE = "type"
44 | private const val EXTRA_URI = "uri"
45 | private const val EXTRA_TEXT = "text"
46 | private const val EXTRA_SOURCE_PACKAGE = "source_package"
47 |
48 | private const val TYPE_IMAGE = "image"
49 | private const val TYPE_TEXT = "text"
50 |
51 | fun startWithImage(context: Context, uri: Uri, sourcePackage: String?) {
52 | val intent = Intent(context, ShareProcessorService::class.java).apply {
53 | putExtra(EXTRA_TYPE, TYPE_IMAGE)
54 | putExtra(EXTRA_URI, uri.toString())
55 | putExtra(EXTRA_SOURCE_PACKAGE, sourcePackage)
56 | }
57 | context.startForegroundService(intent)
58 | }
59 |
60 | fun startWithText(context: Context, text: String, sourcePackage: String?) {
61 | val intent = Intent(context, ShareProcessorService::class.java).apply {
62 | putExtra(EXTRA_TYPE, TYPE_TEXT)
63 | putExtra(EXTRA_TEXT, text)
64 | putExtra(EXTRA_SOURCE_PACKAGE, sourcePackage)
65 | }
66 | context.startForegroundService(intent)
67 | }
68 | }
69 |
70 | private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
71 | private val mainHandler = Handler(Looper.getMainLooper())
72 |
73 | override fun onBind(intent: Intent?): IBinder? = null
74 |
75 | override fun onCreate() {
76 | super.onCreate()
77 | createNotificationChannel()
78 | }
79 |
80 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
81 | val notification = createNotification()
82 | startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
83 |
84 | val type = intent?.getStringExtra(EXTRA_TYPE)
85 | val sourcePackage = intent?.getStringExtra(EXTRA_SOURCE_PACKAGE)
86 |
87 | serviceScope.launch {
88 | when (type) {
89 | TYPE_IMAGE -> {
90 | val uriString = intent.getStringExtra(EXTRA_URI)
91 | if (uriString != null) {
92 | processImage(Uri.parse(uriString), sourcePackage)
93 | }
94 | }
95 | TYPE_TEXT -> {
96 | val text = intent.getStringExtra(EXTRA_TEXT)
97 | if (text != null) {
98 | processText(text, sourcePackage)
99 | }
100 | }
101 | }
102 | stopSelf()
103 | }
104 |
105 | return START_NOT_STICKY
106 | }
107 |
108 | private fun createNotificationChannel() {
109 | val channel = NotificationChannel(
110 | CHANNEL_ID,
111 | "分享处理",
112 | NotificationManager.IMPORTANCE_LOW
113 | ).apply {
114 | description = "用于处理分享内容的前台服务"
115 | setShowBadge(false)
116 | }
117 | val notificationManager = getSystemService(NotificationManager::class.java)
118 | notificationManager.createNotificationChannel(channel)
119 | }
120 |
121 | private fun createNotification(): Notification {
122 | return Notification.Builder(this, CHANNEL_ID)
123 | .setContentTitle("PinMe")
124 | .setContentText("正在处理分享内容…")
125 | .setSmallIcon(R.drawable.ic_stat_pin)
126 | .setOngoing(true)
127 | .build()
128 | }
129 |
130 | private suspend fun processImage(uri: Uri, sourcePackage: String?) {
131 | try {
132 | if (!DatabaseProvider.isInitialized()) {
133 | DatabaseProvider.init(this)
134 | }
135 |
136 | val bitmap = withContext(Dispatchers.IO) {
137 | contentResolver.openInputStream(uri)?.use { stream ->
138 | BitmapFactory.decodeStream(stream)
139 | } ?: throw IllegalStateException("无法读取图片")
140 | }
141 |
142 | val (qrResult, extract) = coroutineScope {
143 | val qrDeferred = async { QrCodeDetector.detect(bitmap) }
144 | val extractDeferred = async {
145 | ExtractWorkflow(this@ShareProcessorService).processScreenshot(bitmap, sourcePackage)
146 | }
147 | qrDeferred.await() to extractDeferred.await()
148 | }
149 |
150 | if (qrResult != null) {
151 | val qrBase64 = withContext(Dispatchers.IO) {
152 | qrResult.croppedBitmap.toJpegBase64()
153 | }
154 | DatabaseProvider.dao().updateExtractQrCode(extract.id, qrBase64)
155 | }
156 |
157 | val marketItem = withContext(Dispatchers.IO) {
158 | DatabaseProvider.dao().getEnabledMarketItems()
159 | .find { it.title == extract.title }
160 | }
161 |
162 | val timeText = android.text.format.DateFormat.format("HH:mm", extract.createdAtMillis).toString()
163 |
164 | val notificationManager = UnifiedNotificationManager(this)
165 | notificationManager.showExtractNotification(
166 | title = extract.title,
167 | content = extract.content,
168 | timeText = timeText,
169 | capsuleColor = marketItem?.capsuleColor,
170 | emoji = extract.emoji ?: marketItem?.emoji,
171 | qrBitmap = qrResult?.croppedBitmap,
172 | extractId = extract.id,
173 | sourcePackage = extract.sourcePackage
174 | )
175 |
176 | PinMeWidget.updateWidgetContent(this)
177 |
178 | val qrInfo = if (qrResult != null) " [含二维码]" else ""
179 | showToast("${extract.title}: ${extract.content}$qrInfo")
180 |
181 | bitmap.recycle()
182 | } catch (e: Exception) {
183 | Log.e(TAG, "processImage failed", e)
184 | showToast("识别失败:${e.message}")
185 | }
186 | }
187 |
188 | private suspend fun processText(text: String, sourcePackage: String?) {
189 | try {
190 | if (!DatabaseProvider.isInitialized()) {
191 | DatabaseProvider.init(this)
192 | }
193 |
194 | val parsed = withContext(Dispatchers.IO) {
195 | ExtractWorkflow(this@ShareProcessorService).extractFromText(text)
196 | }
197 | val createdAt = System.currentTimeMillis()
198 | val entity = ExtractEntity(
199 | title = parsed.title,
200 | content = parsed.content,
201 | emoji = parsed.emoji,
202 | source = "share_text",
203 | sourcePackage = sourcePackage,
204 | rawModelOutput = "",
205 | createdAtMillis = createdAt
206 | )
207 | val id = withContext(Dispatchers.IO) {
208 | DatabaseProvider.dao().insertExtract(entity)
209 | }
210 |
211 | val marketItem = withContext(Dispatchers.IO) {
212 | DatabaseProvider.dao().getEnabledMarketItems()
213 | .find { it.title == entity.title }
214 | }
215 |
216 | val notificationManager = UnifiedNotificationManager(this)
217 | val timeText = android.text.format.DateFormat.format("HH:mm", createdAt).toString()
218 | notificationManager.showExtractNotification(
219 | title = entity.title,
220 | content = entity.content,
221 | timeText = timeText,
222 | capsuleColor = marketItem?.capsuleColor,
223 | emoji = entity.emoji ?: marketItem?.emoji,
224 | extractId = id,
225 | sourcePackage = entity.sourcePackage
226 | )
227 |
228 | PinMeWidget.updateWidgetContent(this)
229 |
230 | showToast("${entity.title}: ${entity.content}")
231 | } catch (e: Exception) {
232 | Log.e(TAG, "processText failed", e)
233 | showToast("识别失败:${e.message}")
234 | }
235 | }
236 |
237 | private fun showToast(message: String) {
238 | mainHandler.post {
239 | Toast.makeText(this@ShareProcessorService, message, Toast.LENGTH_SHORT).show()
240 | }
241 | }
242 |
243 | override fun onDestroy() {
244 | super.onDestroy()
245 | serviceScope.cancel()
246 | }
247 |
248 | private fun Bitmap.toJpegBase64(): String {
249 | val stream = ByteArrayOutputStream()
250 | compress(Bitmap.CompressFormat.JPEG, 85, stream)
251 | val bytes = stream.toByteArray()
252 | return Base64.encodeToString(bytes, Base64.NO_WRAP)
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/capture/AccessibilityCaptureService.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.capture
2 |
3 | import android.accessibilityservice.AccessibilityService
4 | import android.app.AlarmManager
5 | import android.app.PendingIntent
6 | import android.content.ComponentName
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.graphics.Bitmap
10 | import android.os.Handler
11 | import android.os.Looper
12 | import android.provider.Settings
13 | import android.text.TextUtils
14 | import android.util.Log
15 | import android.view.Display
16 | import android.view.accessibility.AccessibilityEvent
17 | import android.widget.Toast
18 | import com.brycewg.pinme.Constants
19 | import com.brycewg.pinme.db.DatabaseProvider
20 | import com.brycewg.pinme.extract.ExtractWorkflow
21 | import com.brycewg.pinme.notification.UnifiedNotificationManager
22 | import com.brycewg.pinme.qrcode.QrCodeDetector
23 | import com.brycewg.pinme.widget.PinMeWidget
24 | import com.brycewg.pinme.usage.SourceAppTracker
25 | import kotlinx.coroutines.CoroutineScope
26 | import kotlinx.coroutines.Dispatchers
27 | import kotlinx.coroutines.SupervisorJob
28 | import kotlinx.coroutines.async
29 | import kotlinx.coroutines.cancel
30 | import kotlinx.coroutines.coroutineScope
31 | import kotlinx.coroutines.delay
32 | import kotlinx.coroutines.launch
33 | import java.util.concurrent.Executor
34 |
35 | class AccessibilityCaptureService : AccessibilityService() {
36 |
37 | companion object {
38 | private const val TAG = "AccessibilityCaptureService"
39 |
40 | @Volatile
41 | private var instance: AccessibilityCaptureService? = null
42 |
43 | /**
44 | * 检查无障碍服务是否已启用
45 | */
46 | fun isServiceEnabled(context: Context): Boolean {
47 | val expectedComponentName = ComponentName(context, AccessibilityCaptureService::class.java)
48 | val enabledServices = Settings.Secure.getString(
49 | context.contentResolver,
50 | Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
51 | ) ?: return false
52 |
53 | val colonSplitter = TextUtils.SimpleStringSplitter(':')
54 | colonSplitter.setString(enabledServices)
55 |
56 | while (colonSplitter.hasNext()) {
57 | val componentNameString = colonSplitter.next()
58 | val enabledComponent = ComponentName.unflattenFromString(componentNameString)
59 | if (enabledComponent != null && enabledComponent == expectedComponentName) {
60 | return true
61 | }
62 | }
63 | return false
64 | }
65 |
66 | /**
67 | * 请求截图
68 | * @return 是否成功发起截图请求
69 | */
70 | fun requestCapture(): Boolean {
71 | val service = instance
72 | if (service == null) {
73 | Log.w(TAG, "Service instance is null, cannot capture")
74 | return false
75 | }
76 | service.performCapture()
77 | return true
78 | }
79 |
80 | fun getActiveWindowPackageName(context: Context): String? {
81 | val service = instance ?: return null
82 | val packageName = service.rootInActiveWindow?.packageName?.toString()?.trim()
83 | if (packageName.isNullOrBlank() || packageName == context.packageName) {
84 | return null
85 | }
86 | return packageName
87 | }
88 |
89 | /**
90 | * 打开无障碍设置页面
91 | */
92 | fun openAccessibilitySettings(context: Context) {
93 | val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
94 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
95 | }
96 | context.startActivity(intent)
97 | }
98 | }
99 |
100 | private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
101 | private val mainHandler = Handler(Looper.getMainLooper())
102 | private val mainExecutor = Executor { command -> mainHandler.post(command) }
103 |
104 | override fun onServiceConnected() {
105 | super.onServiceConnected()
106 | instance = this
107 | Log.d(TAG, "Accessibility service connected")
108 | }
109 |
110 | override fun onAccessibilityEvent(event: AccessibilityEvent?) {
111 | // no-op: 仅在截图时读取前台应用
112 | }
113 |
114 | override fun onInterrupt() {
115 | // 服务被中断时调用
116 | Log.d(TAG, "Accessibility service interrupted")
117 | }
118 |
119 | override fun onDestroy() {
120 | super.onDestroy()
121 | instance = null
122 | serviceScope.cancel()
123 | Log.d(TAG, "Accessibility service destroyed")
124 | }
125 |
126 | /**
127 | * 执行截图
128 | */
129 | fun performCapture() {
130 | Log.d(TAG, "Starting screenshot capture")
131 |
132 | // 延迟截图,等待控制中心/通知栏收起动画完成
133 | // 不同系统的动画时长可能不同,使用较长的延迟确保兼容性
134 | serviceScope.launch {
135 | delay(800) // 等待 800ms 让控制中心收起
136 | doTakeScreenshot()
137 | }
138 | }
139 |
140 | /**
141 | * 实际执行截图操作
142 | */
143 | private fun doTakeScreenshot() {
144 | showToast("正在截图...")
145 |
146 | takeScreenshot(
147 | Display.DEFAULT_DISPLAY,
148 | mainExecutor,
149 | object : TakeScreenshotCallback {
150 | override fun onSuccess(screenshot: ScreenshotResult) {
151 | Log.d(TAG, "Screenshot captured successfully")
152 | val bitmap = Bitmap.wrapHardwareBuffer(
153 | screenshot.hardwareBuffer,
154 | screenshot.colorSpace
155 | )
156 | screenshot.hardwareBuffer.close()
157 |
158 | if (bitmap != null) {
159 | // 转换为软件位图以便后续处理
160 | val softwareBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, false)
161 | bitmap.recycle()
162 | processScreenshot(softwareBitmap)
163 | } else {
164 | showToast("截图失败:无法创建位图")
165 | }
166 | }
167 |
168 | override fun onFailure(errorCode: Int) {
169 | Log.e(TAG, "Screenshot failed with error code: $errorCode")
170 | // Error codes: 1=INTERNAL_ERROR, 2=NO_ACCESSIBILITY_ACCESS, 3=REQUEST_CANCELLED, 4=TIMED_OUT
171 | val errorMessage = when (errorCode) {
172 | 1 -> "内部错误"
173 | 2 -> "无障碍权限不足"
174 | 3 -> "请求被取消"
175 | 4 -> "截图超时"
176 | else -> "未知错误 ($errorCode)"
177 | }
178 | showToast("截图失败:$errorMessage")
179 | }
180 | }
181 | )
182 | }
183 |
184 | /**
185 | * 处理截图
186 | */
187 | private fun processScreenshot(bitmap: Bitmap) {
188 | serviceScope.launch {
189 | try {
190 | showToast("截图成功,正在处理")
191 |
192 | val sourcePackage = resolveSourcePackage()
193 | // 并行执行二维码检测和 LLM 识别
194 | val (qrResult, extract) = coroutineScope {
195 | val qrDeferred = async { QrCodeDetector.detect(bitmap) }
196 | val extractDeferred = async {
197 | ExtractWorkflow(this@AccessibilityCaptureService).processScreenshot(bitmap, sourcePackage)
198 | }
199 | qrDeferred.await() to extractDeferred.await()
200 | }
201 |
202 | val timeText = android.text.format.DateFormat.format("HH:mm", extract.createdAtMillis).toString()
203 |
204 | // 根据提取结果的 title 匹配市场类型
205 | val matchedItem = findMatchedMarketItem(extract.title)
206 | val capsuleColor = matchedItem?.capsuleColor
207 | val durationMinutes = matchedItem?.durationMinutes
208 |
209 | UnifiedNotificationManager(this@AccessibilityCaptureService)
210 | .showExtractNotification(
211 | title = extract.title,
212 | content = extract.content,
213 | timeText = timeText,
214 | capsuleColor = capsuleColor,
215 | emoji = matchedItem?.emoji,
216 | qrBitmap = qrResult?.croppedBitmap,
217 | extractId = extract.id,
218 | sourcePackage = extract.sourcePackage
219 | )
220 |
221 | // 设置定时取消通知
222 | if (durationMinutes != null && durationMinutes > 0) {
223 | scheduleNotificationDismiss(durationMinutes, extract.id)
224 | }
225 |
226 | PinMeWidget.updateWidgetContent(this@AccessibilityCaptureService)
227 |
228 | val qrInfo = if (qrResult != null) " [含二维码]" else ""
229 | showToast("${extract.title}: ${extract.content}$qrInfo")
230 | } catch (e: Exception) {
231 | Log.e(TAG, "processScreenshot failed", e)
232 | showToast("模型处理失败:${e.message}")
233 | } finally {
234 | bitmap.recycle()
235 | }
236 | }
237 | }
238 |
239 | /**
240 | * 根据提取结果的 title 匹配市场类型
241 | */
242 | private suspend fun findMatchedMarketItem(title: String): com.brycewg.pinme.db.MarketItemEntity? {
243 | if (!DatabaseProvider.isInitialized()) {
244 | DatabaseProvider.init(this)
245 | }
246 | val dao = DatabaseProvider.dao()
247 | val marketItems = dao.getEnabledMarketItems()
248 |
249 | // 精确匹配
250 | val exactMatch = marketItems.find { it.title == title }
251 | if (exactMatch != null) {
252 | return exactMatch
253 | }
254 |
255 | // 模糊匹配
256 | return marketItems.find {
257 | title.contains(it.title) || it.title.contains(title)
258 | }
259 | }
260 |
261 | /**
262 | * 设置定时取消通知
263 | */
264 | private fun scheduleNotificationDismiss(durationMinutes: Int, extractId: Long) {
265 | val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
266 | val intent = Intent(this, NotificationDismissReceiver::class.java).apply {
267 | putExtra(NotificationDismissReceiver.EXTRA_EXTRACT_ID, extractId)
268 | }
269 | // 使用 extractId 的 hashCode 作为 requestCode,确保每个通知有唯一的定时器
270 | val requestCode = extractId.hashCode()
271 | val pendingIntent = PendingIntent.getBroadcast(
272 | this,
273 | requestCode,
274 | intent,
275 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
276 | )
277 |
278 | val triggerTime = System.currentTimeMillis() + durationMinutes * 60 * 1000L
279 |
280 | try {
281 | if (alarmManager.canScheduleExactAlarms()) {
282 | alarmManager.setExactAndAllowWhileIdle(
283 | AlarmManager.RTC_WAKEUP,
284 | triggerTime,
285 | pendingIntent
286 | )
287 | } else {
288 | alarmManager.setAndAllowWhileIdle(
289 | AlarmManager.RTC_WAKEUP,
290 | triggerTime,
291 | pendingIntent
292 | )
293 | }
294 | Log.d(TAG, "Scheduled notification dismiss for extractId $extractId in $durationMinutes minutes")
295 | } catch (e: Exception) {
296 | Log.e(TAG, "Failed to schedule notification dismiss", e)
297 | }
298 | }
299 |
300 | private fun showToast(message: String) {
301 | serviceScope.launch(Dispatchers.IO) {
302 | if (!isCaptureToastEnabled()) return@launch
303 | mainHandler.post {
304 | Toast.makeText(this@AccessibilityCaptureService, message, Toast.LENGTH_LONG).show()
305 | }
306 | }
307 | }
308 |
309 | private suspend fun resolveSourcePackage(): String? {
310 | if (!SourceAppTracker.isEnabled(this)) return null
311 | return SourceAppTracker.resolveForegroundPackage(this)
312 | }
313 |
314 | private suspend fun isCaptureToastEnabled(): Boolean {
315 | if (!DatabaseProvider.isInitialized()) {
316 | DatabaseProvider.init(this)
317 | }
318 | return DatabaseProvider.dao()
319 | .getPreference(Constants.PREF_CAPTURE_TOAST_ENABLED) != "false"
320 | }
321 | }
322 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/capture/RootCaptureService.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.capture
2 |
3 | import android.app.AlarmManager
4 | import android.app.Notification
5 | import android.app.NotificationChannel
6 | import android.app.NotificationManager
7 | import android.app.PendingIntent
8 | import android.app.Service
9 | import android.content.Context
10 | import android.content.Intent
11 | import android.content.pm.ServiceInfo
12 | import android.graphics.Bitmap
13 | import android.graphics.BitmapFactory
14 | import android.os.Handler
15 | import android.os.IBinder
16 | import android.os.Looper
17 | import android.util.Base64
18 | import android.util.Log
19 | import android.widget.Toast
20 | import com.brycewg.pinme.Constants
21 | import com.brycewg.pinme.R
22 | import com.brycewg.pinme.db.DatabaseProvider
23 | import com.brycewg.pinme.extract.ExtractWorkflow
24 | import com.brycewg.pinme.notification.UnifiedNotificationManager
25 | import com.brycewg.pinme.qrcode.QrCodeDetector
26 | import com.brycewg.pinme.widget.PinMeWidget
27 | import com.brycewg.pinme.usage.SourceAppTracker
28 | import java.io.ByteArrayOutputStream
29 | import java.io.File
30 | import java.io.InputStream
31 | import java.util.concurrent.TimeUnit
32 | import kotlinx.coroutines.CoroutineScope
33 | import kotlinx.coroutines.Dispatchers
34 | import kotlinx.coroutines.SupervisorJob
35 | import kotlinx.coroutines.async
36 | import kotlinx.coroutines.cancel
37 | import kotlinx.coroutines.coroutineScope
38 | import kotlinx.coroutines.delay
39 | import kotlinx.coroutines.launch
40 | import kotlinx.coroutines.withContext
41 |
42 | class RootCaptureService : Service() {
43 |
44 | companion object {
45 | private const val TAG = "RootCaptureService"
46 | private const val CHANNEL_ID = "screen_capture_channel"
47 | private const val NOTIFICATION_ID = 1002
48 |
49 | @Volatile
50 | private var cachedSuAvailable: Boolean? = null
51 |
52 | fun isSuAvailable(): Boolean {
53 | cachedSuAvailable?.let { return it }
54 |
55 | val candidates = listOf(
56 | "/system/bin/su",
57 | "/system/xbin/su",
58 | "/sbin/su",
59 | "/vendor/bin/su",
60 | "/su/bin/su",
61 | "/data/local/bin/su",
62 | "/data/local/xbin/su",
63 | "/data/local/su"
64 | )
65 | if (candidates.any { File(it).exists() }) {
66 | cachedSuAvailable = true
67 | return true
68 | }
69 |
70 | val available = try {
71 | val process = ProcessBuilder("sh", "-c", "command -v su").start()
72 | process.outputStream.close()
73 | val output = process.inputStream.bufferedReader().use { it.readText() }.trim()
74 | val ok = process.waitFor(1, TimeUnit.SECONDS) && process.exitValue() == 0
75 | ok && output.isNotBlank()
76 | } catch (_: Exception) {
77 | false
78 | }
79 | cachedSuAvailable = available
80 | return available
81 | }
82 |
83 | fun start(context: Context) {
84 | val intent = Intent(context, RootCaptureService::class.java)
85 | context.startForegroundService(intent)
86 | }
87 | }
88 |
89 | private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
90 | private val mainHandler = Handler(Looper.getMainLooper())
91 |
92 | override fun onBind(intent: Intent?): IBinder? = null
93 |
94 | override fun onCreate() {
95 | super.onCreate()
96 | createNotificationChannel()
97 | }
98 |
99 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
100 | val notification = createNotification()
101 | startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
102 |
103 | serviceScope.launch {
104 | delay(500)
105 | performCapture()
106 | stopSelf()
107 | }
108 |
109 | return START_NOT_STICKY
110 | }
111 |
112 | private fun createNotificationChannel() {
113 | val channel = NotificationChannel(
114 | CHANNEL_ID,
115 | "截屏服务",
116 | NotificationManager.IMPORTANCE_LOW
117 | ).apply {
118 | description = "用于截屏识别的前台服务"
119 | setShowBadge(false)
120 | }
121 | val notificationManager = getSystemService(NotificationManager::class.java)
122 | notificationManager.createNotificationChannel(channel)
123 | }
124 |
125 | private fun createNotification(): Notification {
126 | return Notification.Builder(this, CHANNEL_ID)
127 | .setContentTitle("PinMe")
128 | .setContentText("正在截屏识别…")
129 | .setSmallIcon(R.drawable.ic_stat_pin)
130 | .setOngoing(true)
131 | .build()
132 | }
133 |
134 | private suspend fun performCapture() {
135 | if (!isSuAvailable()) {
136 | showToast("未检测到 su,无法使用 Root 截图")
137 | return
138 | }
139 |
140 | showToast("正在截图...")
141 | val bitmap = captureScreenViaRoot()
142 | if (bitmap == null) {
143 | showToast("Root 截屏失败(请检查 root 授权)")
144 | return
145 | }
146 |
147 | showToast("截图成功,正在处理")
148 |
149 | try {
150 | val sourcePackage = resolveSourcePackage()
151 | val (qrResult, extract) = coroutineScope {
152 | val qrDeferred = async { QrCodeDetector.detect(bitmap) }
153 | val extractDeferred = async {
154 | ExtractWorkflow(this@RootCaptureService).processScreenshot(bitmap, sourcePackage)
155 | }
156 | qrDeferred.await() to extractDeferred.await()
157 | }
158 |
159 | if (qrResult != null) {
160 | val qrBase64 = qrResult.croppedBitmap.toJpegBase64()
161 | if (!DatabaseProvider.isInitialized()) {
162 | DatabaseProvider.init(this)
163 | }
164 | DatabaseProvider.dao().updateExtractQrCode(extract.id, qrBase64)
165 | }
166 |
167 | val timeText = android.text.format.DateFormat.format("HH:mm", extract.createdAtMillis).toString()
168 |
169 | val matchedItem = findMatchedMarketItem(extract.title)
170 | val capsuleColor = matchedItem?.capsuleColor
171 | val durationMinutes = matchedItem?.durationMinutes
172 | val emoji = extract.emoji ?: matchedItem?.emoji
173 |
174 | UnifiedNotificationManager(this)
175 | .showExtractNotification(
176 | title = extract.title,
177 | content = extract.content,
178 | timeText = timeText,
179 | capsuleColor = capsuleColor,
180 | emoji = emoji,
181 | qrBitmap = qrResult?.croppedBitmap,
182 | extractId = extract.id,
183 | sourcePackage = extract.sourcePackage
184 | )
185 |
186 | if (durationMinutes != null && durationMinutes > 0) {
187 | scheduleNotificationDismiss(durationMinutes, extract.id)
188 | }
189 |
190 | PinMeWidget.updateWidgetContent(this)
191 |
192 | val qrInfo = if (qrResult != null) " [含二维码]" else ""
193 | showToast("${extract.title}: ${extract.content}$qrInfo")
194 | } catch (e: Exception) {
195 | Log.e(TAG, "processScreenshot failed", e)
196 | showToast("模型处理失败")
197 | } finally {
198 | bitmap.recycle()
199 | }
200 | }
201 |
202 | private suspend fun captureScreenViaRoot(): Bitmap? = withContext(Dispatchers.IO) {
203 | val bytes = try {
204 | coroutineScope {
205 | val process = ProcessBuilder("su", "-c", "screencap -p").start()
206 | try {
207 | val stdoutDeferred = async { process.inputStream.use { it.readAllToBytes() } }
208 | val stderrDeferred = async { process.errorStream.use { it.readAllToBytes() } }
209 |
210 | val exited = process.waitFor(30, TimeUnit.SECONDS)
211 | val stdout = runCatching { stdoutDeferred.await() }.getOrDefault(ByteArray(0))
212 | val stderr = runCatching { stderrDeferred.await() }.getOrDefault(ByteArray(0))
213 |
214 | if (!exited) {
215 | Log.e(TAG, "su screencap timed out")
216 | return@coroutineScope null
217 | }
218 |
219 | val exitCode = process.exitValue()
220 | if (exitCode != 0 || stdout.isEmpty()) {
221 | val errText = stderr.decodeToString().trim()
222 | Log.e(TAG, "su screencap failed: exitCode=$exitCode stderr=$errText")
223 | return@coroutineScope null
224 | }
225 |
226 | fixScreencapPng(stdout)
227 | } finally {
228 | try {
229 | process.destroy()
230 | } catch (_: Exception) {
231 | }
232 | }
233 | }
234 | } catch (e: Exception) {
235 | Log.e(TAG, "capture via root failed", e)
236 | null
237 | } ?: return@withContext null
238 |
239 | return@withContext BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
240 | }
241 |
242 | private fun InputStream.readAllToBytes(): ByteArray {
243 | val buffer = ByteArray(16 * 1024)
244 | val output = ByteArrayOutputStream()
245 | while (true) {
246 | val read = read(buffer)
247 | if (read <= 0) break
248 | output.write(buffer, 0, read)
249 | }
250 | return output.toByteArray()
251 | }
252 |
253 | private fun fixScreencapPng(input: ByteArray): ByteArray {
254 | var i = 0
255 | val output = ByteArrayOutputStream(input.size)
256 | while (i < input.size) {
257 | if (i + 2 < input.size &&
258 | input[i] == 0x0D.toByte() &&
259 | input[i + 1] == 0x0D.toByte() &&
260 | input[i + 2] == 0x0A.toByte()
261 | ) {
262 | output.write(0x0D)
263 | output.write(0x0A)
264 | i += 3
265 | continue
266 | }
267 | output.write(input[i].toInt())
268 | i++
269 | }
270 | return output.toByteArray()
271 | }
272 |
273 | private suspend fun findMatchedMarketItem(title: String): com.brycewg.pinme.db.MarketItemEntity? {
274 | if (!DatabaseProvider.isInitialized()) {
275 | DatabaseProvider.init(this)
276 | }
277 | val dao = DatabaseProvider.dao()
278 | val marketItems = dao.getEnabledMarketItems()
279 |
280 | val exactMatch = marketItems.find { it.title == title }
281 | if (exactMatch != null) {
282 | return exactMatch
283 | }
284 |
285 | return marketItems.find {
286 | title.contains(it.title) || it.title.contains(title)
287 | }
288 | }
289 |
290 | private fun scheduleNotificationDismiss(durationMinutes: Int, extractId: Long) {
291 | val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
292 | val intent = Intent(this, NotificationDismissReceiver::class.java).apply {
293 | putExtra(NotificationDismissReceiver.EXTRA_EXTRACT_ID, extractId)
294 | }
295 | val requestCode = extractId.hashCode()
296 | val pendingIntent = PendingIntent.getBroadcast(
297 | this,
298 | requestCode,
299 | intent,
300 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
301 | )
302 |
303 | val triggerTime = System.currentTimeMillis() + durationMinutes * 60 * 1000L
304 |
305 | try {
306 | if (alarmManager.canScheduleExactAlarms()) {
307 | alarmManager.setExactAndAllowWhileIdle(
308 | AlarmManager.RTC_WAKEUP,
309 | triggerTime,
310 | pendingIntent
311 | )
312 | } else {
313 | alarmManager.setAndAllowWhileIdle(
314 | AlarmManager.RTC_WAKEUP,
315 | triggerTime,
316 | pendingIntent
317 | )
318 | }
319 | Log.d(TAG, "Scheduled notification dismiss for extractId $extractId in $durationMinutes minutes")
320 | } catch (e: Exception) {
321 | Log.e(TAG, "Failed to schedule notification dismiss", e)
322 | }
323 | }
324 |
325 | private fun showToast(message: String) {
326 | serviceScope.launch(Dispatchers.IO) {
327 | if (!isCaptureToastEnabled()) return@launch
328 | mainHandler.post {
329 | Toast.makeText(this@RootCaptureService, message, Toast.LENGTH_LONG).show()
330 | }
331 | }
332 | }
333 |
334 | private suspend fun resolveSourcePackage(): String? {
335 | if (!SourceAppTracker.isEnabled(this)) return null
336 | return SourceAppTracker.resolveForegroundPackageWithRootFallback(this)
337 | }
338 |
339 | private suspend fun isCaptureToastEnabled(): Boolean {
340 | if (!DatabaseProvider.isInitialized()) {
341 | DatabaseProvider.init(this)
342 | }
343 | return DatabaseProvider.dao()
344 | .getPreference(Constants.PREF_CAPTURE_TOAST_ENABLED) != "false"
345 | }
346 |
347 | override fun onDestroy() {
348 | super.onDestroy()
349 | serviceScope.cancel()
350 | }
351 |
352 | private fun Bitmap.toJpegBase64(): String {
353 | val stream = ByteArrayOutputStream()
354 | compress(Bitmap.CompressFormat.JPEG, 85, stream)
355 | val bytes = stream.toByteArray()
356 | return Base64.encodeToString(bytes, Base64.NO_WRAP)
357 | }
358 | }
359 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/capture/ScreenCaptureService.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.capture
2 |
3 | import android.app.AlarmManager
4 | import android.app.Notification
5 | import android.app.NotificationChannel
6 | import android.app.NotificationManager
7 | import android.app.PendingIntent
8 | import android.app.Service
9 | import android.content.Context
10 | import android.content.Intent
11 | import android.content.pm.ServiceInfo
12 | import android.graphics.Bitmap
13 | import android.graphics.PixelFormat
14 | import android.hardware.display.DisplayManager
15 | import android.hardware.display.VirtualDisplay
16 | import android.media.ImageReader
17 | import android.media.projection.MediaProjection
18 | import android.media.projection.MediaProjectionManager
19 | import android.os.Handler
20 | import android.os.IBinder
21 | import android.os.Looper
22 | import android.util.Base64
23 | import android.util.Log
24 | import android.widget.Toast
25 | import com.brycewg.pinme.Constants
26 | import com.brycewg.pinme.R
27 | import com.brycewg.pinme.db.DatabaseProvider
28 | import com.brycewg.pinme.extract.ExtractWorkflow
29 | import com.brycewg.pinme.notification.UnifiedNotificationManager
30 | import com.brycewg.pinme.widget.PinMeWidget
31 | import com.brycewg.pinme.qrcode.QrCodeDetector
32 | import com.brycewg.pinme.usage.SourceAppTracker
33 | import java.io.ByteArrayOutputStream
34 | import kotlinx.coroutines.CoroutineScope
35 | import kotlinx.coroutines.Dispatchers
36 | import kotlinx.coroutines.SupervisorJob
37 | import kotlinx.coroutines.async
38 | import kotlinx.coroutines.cancel
39 | import kotlinx.coroutines.coroutineScope
40 | import kotlinx.coroutines.delay
41 | import kotlinx.coroutines.launch
42 | import kotlinx.coroutines.suspendCancellableCoroutine
43 | import kotlinx.coroutines.withContext
44 | import kotlinx.coroutines.withTimeoutOrNull
45 | import java.nio.ByteBuffer
46 | import kotlin.coroutines.resume
47 |
48 | class ScreenCaptureService : Service() {
49 |
50 | companion object {
51 | private const val TAG = "ScreenCaptureService"
52 | private const val CHANNEL_ID = "screen_capture_channel"
53 | private const val NOTIFICATION_ID = 1001
54 | const val EXTRA_RESULT_CODE = "result_code"
55 | const val EXTRA_RESULT_DATA = "result_data"
56 |
57 | fun start(context: Context, resultCode: Int, resultData: Intent) {
58 | val intent = Intent(context, ScreenCaptureService::class.java).apply {
59 | putExtra(EXTRA_RESULT_CODE, resultCode)
60 | putExtra(EXTRA_RESULT_DATA, resultData)
61 | }
62 | context.startForegroundService(intent)
63 | }
64 | }
65 |
66 | private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
67 | private val mediaProjectionManager: MediaProjectionManager by lazy {
68 | getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
69 | }
70 | private val mainHandler = Handler(Looper.getMainLooper())
71 |
72 | override fun onBind(intent: Intent?): IBinder? = null
73 |
74 | override fun onCreate() {
75 | super.onCreate()
76 | createNotificationChannel()
77 | }
78 |
79 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
80 | val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, 0) ?: 0
81 | val resultData = intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
82 |
83 | if (resultCode != android.app.Activity.RESULT_OK || resultData == null) {
84 | Log.e(TAG, "Invalid result: resultCode=$resultCode, data=${resultData != null}")
85 | stopSelf()
86 | return START_NOT_STICKY
87 | }
88 |
89 | val notification = createNotification()
90 | startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
91 |
92 | serviceScope.launch {
93 | // 等待CaptureActivity完全消失和系统UI恢复
94 | delay(500)
95 | performCapture(resultCode, resultData)
96 | stopSelf()
97 | }
98 |
99 | return START_NOT_STICKY
100 | }
101 |
102 | private fun createNotificationChannel() {
103 | val channel = NotificationChannel(
104 | CHANNEL_ID,
105 | "截屏服务",
106 | NotificationManager.IMPORTANCE_LOW
107 | ).apply {
108 | description = "用于截屏识别的前台服务"
109 | setShowBadge(false)
110 | }
111 | val notificationManager = getSystemService(NotificationManager::class.java)
112 | notificationManager.createNotificationChannel(channel)
113 | }
114 |
115 | private fun createNotification(): Notification {
116 | return Notification.Builder(this, CHANNEL_ID)
117 | .setContentTitle("PinMe")
118 | .setContentText("正在截屏识别…")
119 | .setSmallIcon(R.drawable.ic_stat_pin)
120 | .setOngoing(true)
121 | .build()
122 | }
123 |
124 | private suspend fun performCapture(resultCode: Int, resultData: Intent) {
125 | try {
126 | val bitmap = captureScreen(resultCode, resultData)
127 | if (bitmap == null) {
128 | showToast("截屏失败")
129 | return
130 | }
131 |
132 | showToast("截图成功,正在处理")
133 |
134 | try {
135 | val sourcePackage = resolveSourcePackage()
136 | // 并行执行二维码检测和 LLM 识别
137 | val (qrResult, extract) = coroutineScope {
138 | val qrDeferred = async { QrCodeDetector.detect(bitmap) }
139 | val extractDeferred = async {
140 | ExtractWorkflow(this@ScreenCaptureService).processScreenshot(bitmap, sourcePackage)
141 | }
142 | qrDeferred.await() to extractDeferred.await()
143 | }
144 |
145 | // 如果检测到二维码,保存到数据库
146 | if (qrResult != null) {
147 | val qrBase64 = qrResult.croppedBitmap.toJpegBase64()
148 | if (!DatabaseProvider.isInitialized()) {
149 | DatabaseProvider.init(this)
150 | }
151 | DatabaseProvider.dao().updateExtractQrCode(extract.id, qrBase64)
152 | }
153 |
154 | val timeText = android.text.format.DateFormat.format("HH:mm", extract.createdAtMillis).toString()
155 |
156 | // 根据提取结果的 title 匹配市场类型(获取颜色和时长)
157 | val matchedItem = findMatchedMarketItem(extract.title)
158 | val capsuleColor = matchedItem?.capsuleColor
159 | val durationMinutes = matchedItem?.durationMinutes
160 |
161 | // 优先使用 LLM 生成的 emoji,回退到类型预设的 emoji
162 | val emoji = extract.emoji ?: matchedItem?.emoji
163 |
164 | UnifiedNotificationManager(this)
165 | .showExtractNotification(
166 | title = extract.title,
167 | content = extract.content,
168 | timeText = timeText,
169 | capsuleColor = capsuleColor,
170 | emoji = emoji,
171 | qrBitmap = qrResult?.croppedBitmap,
172 | extractId = extract.id,
173 | sourcePackage = extract.sourcePackage
174 | )
175 |
176 | // 设置定时取消通知
177 | if (durationMinutes != null && durationMinutes > 0) {
178 | scheduleNotificationDismiss(durationMinutes, extract.id)
179 | }
180 |
181 | PinMeWidget.updateWidgetContent(this)
182 |
183 | val qrInfo = if (qrResult != null) " [含二维码]" else ""
184 | showToast("${extract.title}: ${extract.content}$qrInfo")
185 | } catch (e: Exception) {
186 | Log.e(TAG, "processScreenshot failed", e)
187 | showToast("模型处理失败")
188 | }
189 | } catch (e: Exception) {
190 | Log.e(TAG, "performCapture failed", e)
191 | showToast("截屏失败: ${e.message}")
192 | }
193 | }
194 |
195 | private suspend fun captureScreen(resultCode: Int, resultData: Intent): Bitmap? = withContext(Dispatchers.Default) {
196 | val windowManager = getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
197 | val bounds = windowManager.currentWindowMetrics.bounds
198 | val width = bounds.width()
199 | val height = bounds.height()
200 | val densityDpi = resources.displayMetrics.densityDpi
201 |
202 | if (width <= 0 || height <= 0) return@withContext null
203 |
204 | var projection: MediaProjection? = null
205 | var imageReader: ImageReader? = null
206 | var virtualDisplay: VirtualDisplay? = null
207 |
208 | try {
209 | projection = withContext(Dispatchers.Main) {
210 | mediaProjectionManager.getMediaProjection(resultCode, resultData)
211 | } ?: return@withContext null
212 |
213 | // 注册 callback (Android 14+ 要求)
214 | projection.registerCallback(object : MediaProjection.Callback() {}, mainHandler)
215 |
216 | imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
217 | virtualDisplay = projection.createVirtualDisplay(
218 | "pinme_screen_capture",
219 | width,
220 | height,
221 | densityDpi,
222 | DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
223 | imageReader.surface,
224 | null,
225 | null
226 | )
227 |
228 | val image = withTimeoutOrNull(5_000) {
229 | suspendCancellableCoroutine { cont ->
230 | val listener = ImageReader.OnImageAvailableListener { reader ->
231 | try {
232 | val img = reader.acquireLatestImage()
233 | if (img != null && cont.isActive) {
234 | try {
235 | reader.setOnImageAvailableListener(null, mainHandler)
236 | } catch (_: Exception) {
237 | }
238 | cont.resume(img)
239 | }
240 | } catch (e: Exception) {
241 | if (cont.isActive) cont.resume(null)
242 | }
243 | }
244 | imageReader.setOnImageAvailableListener(listener, mainHandler)
245 | cont.invokeOnCancellation {
246 | try {
247 | imageReader.setOnImageAvailableListener(null, mainHandler)
248 | } catch (_: Exception) {
249 | }
250 | }
251 | }
252 | } ?: return@withContext null
253 |
254 | try {
255 | val planes = image.planes
256 | if (planes.isEmpty()) return@withContext null
257 | val buffer: ByteBuffer = planes[0].buffer
258 | buffer.rewind()
259 | val pixelStride = planes[0].pixelStride
260 | val rowStride = planes[0].rowStride
261 | val rowPadding = rowStride - pixelStride * width
262 | val bitmapWidth = width + (rowPadding / pixelStride)
263 | val bitmap = Bitmap.createBitmap(bitmapWidth, height, Bitmap.Config.ARGB_8888)
264 | bitmap.copyPixelsFromBuffer(buffer)
265 | return@withContext Bitmap.createBitmap(bitmap, 0, 0, width, height)
266 | } finally {
267 | try { image.close() } catch (_: Exception) {}
268 | }
269 | } catch (e: Exception) {
270 | Log.e(TAG, "captureScreen failed", e)
271 | return@withContext null
272 | } finally {
273 | try { imageReader?.setOnImageAvailableListener(null, mainHandler) } catch (_: Exception) {}
274 | try { virtualDisplay?.release() } catch (_: Exception) {}
275 | try { imageReader?.close() } catch (_: Exception) {}
276 | try { projection?.stop() } catch (_: Exception) {}
277 | }
278 | }
279 |
280 | /**
281 | * 根据提取结果的 title 匹配市场类型
282 | * @return 匹配到的市场条目,未匹配返回 null
283 | */
284 | private suspend fun findMatchedMarketItem(title: String): com.brycewg.pinme.db.MarketItemEntity? {
285 | if (!DatabaseProvider.isInitialized()) {
286 | DatabaseProvider.init(this)
287 | }
288 | val dao = DatabaseProvider.dao()
289 | val marketItems = dao.getEnabledMarketItems()
290 |
291 | // 精确匹配
292 | val exactMatch = marketItems.find { it.title == title }
293 | if (exactMatch != null) {
294 | return exactMatch
295 | }
296 |
297 | // 模糊匹配(title 包含市场类型名称,或市场类型名称包含 title)
298 | return marketItems.find {
299 | title.contains(it.title) || it.title.contains(title)
300 | }
301 | }
302 |
303 | /**
304 | * 设置定时取消通知
305 | */
306 | private fun scheduleNotificationDismiss(durationMinutes: Int, extractId: Long) {
307 | val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
308 | val intent = Intent(this, NotificationDismissReceiver::class.java).apply {
309 | putExtra(NotificationDismissReceiver.EXTRA_EXTRACT_ID, extractId)
310 | }
311 | // 使用 extractId 的 hashCode 作为 requestCode,确保每个通知有唯一的定时器
312 | val requestCode = extractId.hashCode()
313 | val pendingIntent = PendingIntent.getBroadcast(
314 | this,
315 | requestCode,
316 | intent,
317 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
318 | )
319 |
320 | val triggerTime = System.currentTimeMillis() + durationMinutes * 60 * 1000L
321 |
322 | try {
323 | // 尝试使用精确闹钟(需要 SCHEDULE_EXACT_ALARM 权限)
324 | if (alarmManager.canScheduleExactAlarms()) {
325 | alarmManager.setExactAndAllowWhileIdle(
326 | AlarmManager.RTC_WAKEUP,
327 | triggerTime,
328 | pendingIntent
329 | )
330 | } else {
331 | // 回退到非精确闹钟
332 | alarmManager.setAndAllowWhileIdle(
333 | AlarmManager.RTC_WAKEUP,
334 | triggerTime,
335 | pendingIntent
336 | )
337 | }
338 | Log.d(TAG, "Scheduled notification dismiss for extractId $extractId in $durationMinutes minutes")
339 | } catch (e: Exception) {
340 | Log.e(TAG, "Failed to schedule notification dismiss", e)
341 | }
342 | }
343 |
344 | private fun showToast(message: String) {
345 | serviceScope.launch(Dispatchers.IO) {
346 | if (!isCaptureToastEnabled()) return@launch
347 | mainHandler.post {
348 | Toast.makeText(this@ScreenCaptureService, message, Toast.LENGTH_LONG).show()
349 | }
350 | }
351 | }
352 |
353 | private suspend fun resolveSourcePackage(): String? {
354 | if (!SourceAppTracker.isEnabled(this)) return null
355 | return SourceAppTracker.resolveForegroundPackage(this)
356 | }
357 |
358 | private suspend fun isCaptureToastEnabled(): Boolean {
359 | if (!DatabaseProvider.isInitialized()) {
360 | DatabaseProvider.init(this)
361 | }
362 | return DatabaseProvider.dao()
363 | .getPreference(Constants.PREF_CAPTURE_TOAST_ENABLED) != "false"
364 | }
365 |
366 | override fun onDestroy() {
367 | super.onDestroy()
368 | serviceScope.cancel()
369 | }
370 |
371 | /**
372 | * 将 Bitmap 转换为 JPEG 格式的 Base64 字符串
373 | */
374 | private fun Bitmap.toJpegBase64(): String {
375 | val stream = ByteArrayOutputStream()
376 | compress(Bitmap.CompressFormat.JPEG, 85, stream)
377 | val bytes = stream.toByteArray()
378 | return Base64.encodeToString(bytes, Base64.NO_WRAP)
379 | }
380 | }
381 |
--------------------------------------------------------------------------------
/app/src/main/java/com/brycewg/pinme/widget/PinMeWidget.kt:
--------------------------------------------------------------------------------
1 | package com.brycewg.pinme.widget
2 |
3 | import android.appwidget.AppWidgetManager
4 | import android.content.ComponentName
5 | import android.content.Context
6 | import android.graphics.BitmapFactory
7 | import android.util.Base64
8 | import android.util.Log
9 | import android.widget.Toast
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.unit.dp
12 | import androidx.compose.ui.unit.sp
13 | import androidx.core.graphics.toColorInt
14 | import androidx.datastore.preferences.core.Preferences
15 | import androidx.datastore.preferences.core.mutablePreferencesOf
16 | import androidx.datastore.preferences.core.stringPreferencesKey
17 | import androidx.glance.GlanceId
18 | import androidx.glance.GlanceModifier
19 | import androidx.glance.GlanceTheme
20 | import androidx.glance.LocalContext
21 | import androidx.glance.action.ActionParameters
22 | import androidx.glance.action.actionParametersOf
23 | import androidx.glance.action.clickable
24 | import androidx.glance.appwidget.GlanceAppWidget
25 | import androidx.glance.appwidget.GlanceAppWidgetManager
26 | import androidx.glance.appwidget.GlanceAppWidgetReceiver
27 | import androidx.glance.appwidget.action.ActionCallback
28 | import androidx.glance.appwidget.action.actionRunCallback
29 | import androidx.glance.appwidget.cornerRadius
30 | import androidx.glance.appwidget.lazy.LazyColumn
31 | import androidx.glance.appwidget.lazy.items
32 | import androidx.glance.appwidget.provideContent
33 | import androidx.glance.appwidget.state.updateAppWidgetState
34 | import androidx.glance.appwidget.updateAll
35 | import androidx.glance.background
36 | import androidx.glance.currentState
37 | import androidx.glance.layout.Alignment
38 | import androidx.glance.layout.Box
39 | import androidx.glance.layout.Column
40 | import androidx.glance.layout.Row
41 | import androidx.glance.layout.Spacer
42 | import androidx.glance.layout.fillMaxSize
43 | import androidx.glance.layout.fillMaxWidth
44 | import androidx.glance.layout.height
45 | import androidx.glance.layout.padding
46 | import androidx.glance.layout.size
47 | import androidx.glance.layout.width
48 | import androidx.glance.state.PreferencesGlanceStateDefinition
49 | import androidx.glance.text.FontWeight
50 | import androidx.glance.text.Text
51 | import androidx.glance.text.TextAlign
52 | import androidx.glance.text.TextStyle
53 | import androidx.glance.unit.ColorProvider
54 | import com.brycewg.pinme.db.DatabaseProvider
55 | import com.brycewg.pinme.db.MarketItemEntity
56 | import com.brycewg.pinme.notification.UnifiedNotificationManager
57 | import kotlinx.coroutines.CoroutineScope
58 | import kotlinx.coroutines.Dispatchers
59 | import kotlinx.coroutines.launch
60 | import kotlinx.serialization.Serializable
61 | import kotlinx.serialization.json.Json
62 | import java.text.SimpleDateFormat
63 | import java.util.Date
64 | import java.util.Locale
65 | import androidx.compose.ui.graphics.Color as ComposeColor
66 |
67 | private const val TAG = "PinMeWidget"
68 | private const val DEFAULT_CAPSULE_COLOR = "#FF9800"
69 |
70 | private val KEY_EXTRACTS_JSON = stringPreferencesKey("extracts_json")
71 | private val KEY_UPDATE_TIME = stringPreferencesKey("update_time")
72 |
73 | private val jsonParser = Json {
74 | ignoreUnknownKeys = true
75 | coerceInputValues = true
76 | encodeDefaults = true
77 | }
78 |
79 | @Serializable
80 | data class WidgetExtractData(
81 | val items: List,
82 | val updateTime: String
83 | )
84 |
85 | @Serializable
86 | data class WidgetExtractItem(
87 | val id: Long,
88 | val title: String,
89 | val content: String,
90 | val emoji: String? = null,
91 | val qrCodeBase64: String? = null,
92 | val sourcePackage: String? = null,
93 | val capsuleColor: String? = null,
94 | val createdAtMillis: Long
95 | )
96 |
97 | // ActionParameters keys for pin action
98 | private val PARAM_EXTRACT_ID = ActionParameters.Key("extract_id")
99 | private val PARAM_TITLE = ActionParameters.Key("title")
100 | private val PARAM_CONTENT = ActionParameters.Key("content")
101 | private val PARAM_EMOJI = ActionParameters.Key("emoji")
102 | private val PARAM_QR_CODE_BASE64 = ActionParameters.Key("qr_code_base64")
103 | private val PARAM_CAPSULE_COLOR = ActionParameters.Key("capsule_color")
104 | private val PARAM_CREATED_AT = ActionParameters.Key("created_at")
105 | private val PARAM_SOURCE_PACKAGE = ActionParameters.Key("source_package")
106 |
107 | /**
108 | * Build ActionParameters for pin action
109 | */
110 | private fun buildPinActionParameters(item: WidgetExtractItem): ActionParameters {
111 | val params = mutableListOf>(
112 | PARAM_EXTRACT_ID to item.id,
113 | PARAM_TITLE to item.title,
114 | PARAM_CONTENT to item.content,
115 | PARAM_CREATED_AT to item.createdAtMillis
116 | )
117 |
118 | item.emoji?.let { params.add(PARAM_EMOJI to it) }
119 | item.qrCodeBase64?.let { params.add(PARAM_QR_CODE_BASE64 to it) }
120 | item.capsuleColor?.let { params.add(PARAM_CAPSULE_COLOR to it) }
121 | item.sourcePackage?.let { params.add(PARAM_SOURCE_PACKAGE to it) }
122 |
123 | return actionParametersOf(*params.toTypedArray())
124 | }
125 |
126 | /**
127 | * ActionCallback to handle pin-to-notification button click
128 | */
129 | class PinToNotificationAction : ActionCallback {
130 | override suspend fun onAction(
131 | context: Context,
132 | glanceId: GlanceId,
133 | parameters: ActionParameters
134 | ) {
135 | val extractId = parameters[PARAM_EXTRACT_ID] ?: return
136 | val title = parameters[PARAM_TITLE] ?: return
137 | val content = parameters[PARAM_CONTENT] ?: return
138 | val emoji = parameters[PARAM_EMOJI]
139 | val qrCodeBase64 = parameters[PARAM_QR_CODE_BASE64]
140 | val capsuleColor = parameters[PARAM_CAPSULE_COLOR]
141 | val createdAt = parameters[PARAM_CREATED_AT] ?: System.currentTimeMillis()
142 | val sourcePackage = parameters[PARAM_SOURCE_PACKAGE]
143 |
144 | val timeText = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(createdAt))
145 |
146 | val qrBitmap = qrCodeBase64?.let { base64 ->
147 | try {
148 | val bytes = Base64.decode(base64, Base64.NO_WRAP)
149 | BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
150 | } catch (e: Exception) {
151 | Log.e(TAG, "Failed to decode QR code", e)
152 | null
153 | }
154 | }
155 |
156 | val notificationManager = UnifiedNotificationManager(context)
157 | val isLive = notificationManager.isLiveCapsuleCustomizationAvailable()
158 |
159 | notificationManager.showExtractNotification(
160 | title = title,
161 | content = content,
162 | timeText = timeText,
163 | capsuleColor = capsuleColor,
164 | emoji = emoji,
165 | qrBitmap = qrBitmap,
166 | extractId = extractId,
167 | sourcePackage = sourcePackage
168 | )
169 |
170 | CoroutineScope(Dispatchers.Main).launch {
171 | val toastText = if (isLive) "已挂到实况通知" else "已发送通知"
172 | Toast.makeText(context, toastText, Toast.LENGTH_SHORT).show()
173 | }
174 | }
175 | }
176 |
177 | class PinMeWidget : GlanceAppWidget() {
178 | override val stateDefinition = PreferencesGlanceStateDefinition
179 |
180 | override suspend fun provideGlance(context: Context, id: GlanceId) {
181 | // 在 provideContent 之前先确保数据已加载
182 | val data = loadDataDirectly(context)
183 |
184 | provideContent {
185 | GlanceTheme {
186 | Content(data)
187 | }
188 | }
189 | }
190 |
191 | /**
192 | * 直接从数据库加载数据,不依赖 preferences state
193 | */
194 | private suspend fun loadDataDirectly(context: Context): WidgetExtractData {
195 | return try {
196 | if (!DatabaseProvider.isInitialized()) {
197 | DatabaseProvider.init(context.applicationContext)
198 | }
199 |
200 | val dao = DatabaseProvider.dao()
201 | val extracts = dao.getLatestExtractsOnce(10)
202 | val marketItems = dao.getEnabledMarketItems()
203 |
204 | val items = extracts.map { extract ->
205 | val matchedItem = findMatchedMarketItem(extract.title, marketItems)
206 | WidgetExtractItem(
207 | id = extract.id,
208 | title = extract.title,
209 | content = extract.content,
210 | emoji = extract.emoji ?: matchedItem?.emoji,
211 | qrCodeBase64 = extract.qrCodeBase64,
212 | sourcePackage = extract.sourcePackage,
213 | capsuleColor = matchedItem?.capsuleColor,
214 | createdAtMillis = extract.createdAtMillis
215 | )
216 | }
217 | val updateTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
218 | WidgetExtractData(items = items, updateTime = updateTime)
219 | } catch (e: Exception) {
220 | Log.e(TAG, "loadDataDirectly failed", e)
221 | WidgetExtractData(items = emptyList(), updateTime = "")
222 | }
223 | }
224 |
225 | @Composable
226 | private fun Content(data: WidgetExtractData) {
227 | Column(
228 | modifier = GlanceModifier
229 | .fillMaxSize()
230 | .background(ComposeColor(0xFFF5F5F5))
231 | .cornerRadius(16.dp)
232 | .padding(12.dp),
233 | horizontalAlignment = Alignment.Horizontal.Start,
234 | verticalAlignment = Alignment.Vertical.Top
235 | ) {
236 | // Header row
237 | Row(
238 | modifier = GlanceModifier.fillMaxWidth(),
239 | verticalAlignment = Alignment.Vertical.CenterVertically
240 | ) {
241 | Text(
242 | text = "📌 PinMe",
243 | style = TextStyle(
244 | fontSize = 15.sp,
245 | fontWeight = FontWeight.Bold,
246 | color = ColorProvider(ComposeColor(0xFF333333))
247 | )
248 | )
249 | Spacer(modifier = GlanceModifier.defaultWeight())
250 | if (data.updateTime.isNotBlank()) {
251 | Text(
252 | text = data.updateTime,
253 | style = TextStyle(
254 | fontSize = 11.sp,
255 | color = ColorProvider(ComposeColor(0xFF888888))
256 | )
257 | )
258 | }
259 | }
260 |
261 | Spacer(modifier = GlanceModifier.height(8.dp))
262 |
263 | if (data.items.isEmpty()) {
264 | Box(
265 | modifier = GlanceModifier.fillMaxSize(),
266 | contentAlignment = Alignment.Center
267 | ) {
268 | Column(horizontalAlignment = Alignment.Horizontal.CenterHorizontally) {
269 | Text(
270 | text = "暂无识别内容",
271 | style = TextStyle(
272 | fontSize = 14.sp,
273 | color = ColorProvider(ComposeColor(0xFF666666))
274 | )
275 | )
276 | Spacer(modifier = GlanceModifier.height(4.dp))
277 | Text(
278 | text = "点击控制中心磁贴开始",
279 | style = TextStyle(
280 | fontSize = 12.sp,
281 | color = ColorProvider(ComposeColor(0xFF999999))
282 | )
283 | )
284 | }
285 | }
286 | } else {
287 | LazyColumn(
288 | modifier = GlanceModifier.fillMaxSize()
289 | ) {
290 | items(data.items, itemId = { it.id }) { item ->
291 | Column {
292 | ExtractItemRow(item)
293 | Spacer(modifier = GlanceModifier.height(6.dp))
294 | }
295 | }
296 | }
297 | }
298 | }
299 | }
300 |
301 | @Composable
302 | private fun ExtractItemRow(item: WidgetExtractItem) {
303 | val buttonColor = try {
304 | val baseColor = ComposeColor((item.capsuleColor ?: DEFAULT_CAPSULE_COLOR).toColorInt())
305 | ComposeColor(
306 | red = baseColor.red * 0.4f + 0.6f,
307 | green = baseColor.green * 0.4f + 0.6f,
308 | blue = baseColor.blue * 0.4f + 0.6f,
309 | alpha = 1f
310 | )
311 | } catch (e: Exception) {
312 | ComposeColor(0xFFFFE0B2)
313 | }
314 |
315 | Row(
316 | modifier = GlanceModifier
317 | .fillMaxWidth()
318 | .background(ComposeColor.White)
319 | .cornerRadius(10.dp)
320 | .padding(8.dp),
321 | verticalAlignment = Alignment.Vertical.CenterVertically
322 | ) {
323 | if (item.emoji != null) {
324 | Text(
325 | text = item.emoji,
326 | style = TextStyle(fontSize = 20.sp)
327 | )
328 | Spacer(modifier = GlanceModifier.width(8.dp))
329 | }
330 |
331 | Column(
332 | modifier = GlanceModifier.defaultWeight()
333 | ) {
334 | Text(
335 | text = item.title,
336 | style = TextStyle(
337 | fontSize = 12.sp,
338 | fontWeight = FontWeight.Medium,
339 | color = ColorProvider(ComposeColor(0xFF666666))
340 | ),
341 | maxLines = 1
342 | )
343 | Text(
344 | text = item.content,
345 | style = TextStyle(
346 | fontSize = 14.sp,
347 | fontWeight = FontWeight.Bold,
348 | color = ColorProvider(ComposeColor(0xFF333333))
349 | ),
350 | maxLines = 1
351 | )
352 | }
353 |
354 | Spacer(modifier = GlanceModifier.width(6.dp))
355 |
356 | Box(
357 | modifier = GlanceModifier
358 | .size(32.dp)
359 | .background(buttonColor)
360 | .cornerRadius(8.dp)
361 | .clickable(
362 | onClick = actionRunCallback(
363 | parameters = buildPinActionParameters(item)
364 | )
365 | ),
366 | contentAlignment = Alignment.Center
367 | ) {
368 | Text(
369 | text = "📌",
370 | style = TextStyle(
371 | fontSize = 16.sp,
372 | textAlign = TextAlign.Center
373 | )
374 | )
375 | }
376 | }
377 | }
378 |
379 | companion object {
380 | suspend fun updateWidgetContent(context: Context) {
381 | try {
382 | val appContext = context.applicationContext
383 | val appWidgetIds = AppWidgetManager.getInstance(appContext).getAppWidgetIds(
384 | ComponentName(appContext, PinMeWidgetReceiver::class.java)
385 | )
386 | if (appWidgetIds.isEmpty()) return
387 |
388 | // 直接触发所有小组件更新,让 provideGlance 重新加载数据
389 | PinMeWidget().updateAll(appContext)
390 | } catch (e: Exception) {
391 | Log.e(TAG, "updateWidgetContent failed", e)
392 | }
393 | }
394 |
395 | private fun findMatchedMarketItem(title: String, marketItems: List): MarketItemEntity? {
396 | val exactMatch = marketItems.find { it.title == title }
397 | if (exactMatch != null) return exactMatch
398 | return marketItems.find {
399 | title.contains(it.title) || it.title.contains(title)
400 | }
401 | }
402 | }
403 | }
404 |
405 | class PinMeWidgetReceiver : GlanceAppWidgetReceiver() {
406 | override val glanceAppWidget: GlanceAppWidget = PinMeWidget()
407 |
408 | override fun onEnabled(context: Context) {
409 | super.onEnabled(context)
410 | }
411 |
412 | override fun onDisabled(context: Context) {
413 | super.onDisabled(context)
414 | }
415 | }
416 |
--------------------------------------------------------------------------------