├── 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 | 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 | mm_reward_qrcode_1765881183724 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 | --------------------------------------------------------------------------------