Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
**/*.p12
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: android
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: ios
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+11
View File
@@ -0,0 +1,11 @@
/.gradle
/captures/
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+167
View File
@@ -0,0 +1,167 @@
import java.io.FileInputStream
import java.util.Base64
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
load(FileInputStream(keystorePropertiesFile))
}
}
val releaseSigningKeys = listOf(
"storePassword",
"keyPassword",
"keyAlias",
"storeFile",
)
val releaseSigningConfigured = keystorePropertiesFile.exists() &&
releaseSigningKeys.all { !keystoreProperties.getProperty(it).isNullOrBlank() }
android {
namespace = "com.nx.miaoji"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.nx.miaoji"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
if (releaseSigningConfigured) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
storeType = keystoreProperties.getProperty("storeType", "PKCS12")
}
}
}
flavorDimensions += "environment"
productFlavors {
create("internal") {
dimension = "environment"
applicationIdSuffix = ".internal"
versionNameSuffix = "-internal"
signingConfig = signingConfigs.getByName("debug")
}
create("production") {
dimension = "environment"
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
}
}
buildTypes {
release {}
}
}
val dartDefines = (project.findProperty("dart-defines") as? String)
.orEmpty()
.split(',')
.mapNotNull { encoded ->
runCatching {
String(Base64.getDecoder().decode(encoded), Charsets.UTF_8)
}.getOrNull()
}
.mapNotNull { define ->
val separator = define.indexOf('=')
if (separator <= 0) null
else define.substring(0, separator) to define.substring(separator + 1)
}
.toMap()
val internalReleaseRequested = gradle.startParameter.taskNames.any { requestedTask ->
val taskName = requestedTask.substringAfterLast(':')
taskName.contains("InternalRelease", ignoreCase = true)
}
if (internalReleaseRequested) {
val expectedApiUrl = "https://lt.frp-say.com:38012"
if (dartDefines["INTERNAL_BUILD"] != "true") {
throw GradleException(
"Internal release requires --dart-define=INTERNAL_BUILD=true",
)
}
if (dartDefines["API_BASE_URL"] != expectedApiUrl) {
throw GradleException(
"Internal release requires --dart-define=API_BASE_URL=$expectedApiUrl",
)
}
if (dartDefines["APP_VERSION"].isNullOrBlank()) {
throw GradleException(
"Internal release requires --dart-define=APP_VERSION=<build-id>",
)
}
}
val productionReleaseRequested = gradle.startParameter.taskNames.any { requestedTask ->
val taskName = requestedTask.substringAfterLast(':')
taskName.contains("ProductionRelease", ignoreCase = true) ||
taskName.equals("assembleRelease", ignoreCase = true) ||
taskName.equals("bundleRelease", ignoreCase = true)
}
if (productionReleaseRequested && !releaseSigningConfigured) {
throw GradleException(
"Production release signing is not configured. " +
"Create frontend/android/key.properties before building productionRelease.",
)
}
if (releaseSigningConfigured) {
val releaseStore = file(keystoreProperties["storeFile"] as String)
if (!releaseStore.isFile) {
throw GradleException("Configured production release keystore does not exist: $releaseStore")
}
}
val stripIntegrationTestFromReleaseRegistrant = tasks.register(
"stripIntegrationTestFromReleaseRegistrant",
) {
doLast {
val registrant = file(
"src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java",
)
if (!registrant.isFile) return@doLast
val integrationTestRegistration = Regex(
"""(?ms)^\s*try \{\r?\n\s*flutterEngine\.getPlugins\(\)\.add\(new dev\.flutter\.plugins\.integration_test\.IntegrationTestPlugin\(\)\);\r?\n\s*\} catch \(Exception e\) \{\r?\n\s*Log\.e\(TAG, "Error registering plugin integration_test,[^\r\n]*\);\r?\n\s*\}\r?\n?""",
)
val source = registrant.readText()
val sanitized = source.replace(integrationTestRegistration, "")
if (sanitized != source) registrant.writeText(sanitized)
}
}
tasks.matching {
it.name.matches(Regex("compile.*ReleaseJavaWithJavac"))
}.configureEach {
dependsOn(stripIntegrationTestFromReleaseRegistrant)
}
flutter {
source = "../.."
}
dependencies {
implementation("com.google.mlkit:text-recognition-chinese:16.0.1")
testImplementation("junit:junit:4.13.2")
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config_internal"
tools:replace="android:usesCleartextTraffic,android:networkSecurityConfig">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.update.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/update_file_paths"/>
</provider>
</application>
</manifest>
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDOzCCAiOgAwIBAgIELZVPvzANBgkqhkiG9w0BAQsFADBQMQswCQYDVQQGEwJD
TjEtMCsGA1UEAxMkU2FrdXJhRnJwIEF1dG9tYXRpYyBUTFMgc24uNzY0NzU5OTk5
MRIwEAYDVQQFEwk3NjQ3NTk5OTkwHhcNMjYwNzIwMDQ0NDM4WhcNMjcwNzIwMDQ0
NDM4WjBQMQswCQYDVQQGEwJDTjEtMCsGA1UEAxMkU2FrdXJhRnJwIEF1dG9tYXRp
YyBUTFMgc24uNzY0NzU5OTk5MRIwEAYDVQQFEwk3NjQ3NTk5OTkwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0poNmZC+UWeDWGRjHot86lnA3J5Ueujml
GvapcAWUoQBNs1p+tlVedz1Dwo/Hq1u7tHdp3WBDax5naKLFKIz0kQIbCWxDrDTH
YNVQ9O7MHcf8dcDeayvo6q9z7PzhVXH/CJTlWx2634RGYbaU5jBjWX4fPFHyXJe0
57zQqSIYrxZFozEd9NewELavkjCydI8atSFQNEDtlHziiXXLKlvUk7Sk6Drc2AxU
ZYk0/mz6GFbBIeIKLUeDlvoocHQvzC3kK+pn1Ggq+ky2DViGPkzekh8HYiOo+/Wv
VRXP9LHp17AsmvsQQM4y2NzSkX/la4R1pgq5OwHw6pHHCeWwvBWTAgMBAAGjHTAb
MBkGA1UdEQQSMBCCDmx0LmZycC1zYXkuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBg
I4grSvEqI2RRXlwbRjKlBnlBWgiw51sEuM7Sjq6P8t2IoaGJ5/F3PeT0XWwyTopg
hV5hNPU+wOKtVilyNqepljrPQ5XAm3uWp68aIHBuCxh3XOfjetPBPXoisY67AUHH
9gilTg24GjZ7koJGfiS0iHmfLtf1rEDUgCl27pX9e2NzMRr9aVAsRkdp6D3esXZL
e2aUTgBWRg45PU+26dd/JN738F85nqwdRc16MeTMDDbqyIVmcUnZZVPeGXAmTotk
b9pHfmaT3h3YqPDv/rWa9w+AMdc2mBIckPQ3ZV4H95xHEOR56uFQwkfiPQABlBKO
2oBDbJB1yhK++6XkhmLj
-----END CERTIFICATE-----
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">记之·内测</string>
</resources>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="false">lt.frp-say.com</domain>
<trust-anchors>
<certificates src="system"/>
<certificates src="@raw/sakura_frp_test_ca"/>
</trust-anchors>
</domain-config>
</network-security-config>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="updates" path="updates/"/>
</paths>
@@ -0,0 +1,111 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>
<activity
android:name=".ProjectionConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/ProjectionConsentTheme"/>
<service
android:name=".OneShotProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection"
android:process=":recognition"
android:stopWithTask="false"/>
<service
android:name=".ScreenshotTileService"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/screenshot_tile_label"
android:process=":recognition"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE"/>
</intent-filter>
</service>
<service
android:name=".PaymentNotificationListenerService"
android:exported="true"
android:label="@string/notification_listener_label"
android:process=":recognition"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService"/>
</intent-filter>
</service>
<service
android:name=".ScreenshotAccessibilityService"
android:exported="true"
android:process=":recognition"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config"/>
</service>
<provider
android:name=".RecognitionBridgeProvider"
android:authorities="${applicationId}.recognition.bridge"
android:exported="false"
android:grantUriPermissions="false"
android:process=":recognition"/>
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
</application>
<queries>
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,190 @@
package com.nx.miaoji
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import java.util.Locale
object AiProgressNotifier {
private const val CHANNEL_ID = "ai_screenshot_progress"
private const val NOTIFICATION_ID = 2101
fun start(context: Context) {
show(
context = context,
title = "截图已获取",
text = "AI 正在分析账单内容",
progress = 20,
indeterminate = true,
ongoing = true,
requestPromotion = true,
)
}
fun update(context: Context, count: Int) {
val text = if (count > 0) {
"识别到 ${count} 笔,等待确认"
} else {
"未识别到明确账单,可手动补充"
}
show(
context = context,
title = "AI 分析完成",
text = text,
progress = 75,
indeterminate = false,
ongoing = true,
requestPromotion = true,
)
}
fun finish(context: Context, count: Int, total: Double) {
val totalText = String.format(Locale.CHINA, "%.2f", total)
show(
context = context,
title = "入账完成",
text = "已保存 ${count} 笔,共 ¥$totalText",
progress = 100,
indeterminate = false,
ongoing = false,
requestPromotion = false,
)
}
fun fail(context: Context, message: String) {
show(
context = context,
title = "截屏记账未完成",
text = message.take(80),
progress = 0,
indeterminate = false,
ongoing = false,
requestPromotion = false,
)
}
private fun show(
context: Context,
title: String,
text: String,
progress: Int,
indeterminate: Boolean,
ongoing: Boolean,
requestPromotion: Boolean,
) {
if (!canNotify(context)) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
ensureChannel(manager)
val openIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pendingIntent = PendingIntent.getActivity(
context,
NOTIFICATION_ID,
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
@Suppress("DEPRECATION")
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(context, CHANNEL_ID)
} else {
Notification.Builder(context)
}
builder.setSmallIcon(android.R.drawable.ic_menu_camera)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_PROGRESS)
.setOnlyAlertOnce(true)
.setOngoing(ongoing)
.setAutoCancel(!ongoing)
.setShowWhen(false)
val liveStyleApplied =
Build.VERSION.SDK_INT >= 36 &&
ongoing &&
applyAndroid16ProgressStyle(
builder,
progress,
indeterminate,
requestPromotion,
)
if (!liveStyleApplied) {
builder.setProgress(100, progress, indeterminate)
}
manager.notify(NOTIFICATION_ID, builder.build())
}
private fun applyAndroid16ProgressStyle(
builder: Notification.Builder,
progress: Int,
indeterminate: Boolean,
requestPromotion: Boolean,
): Boolean {
return try {
val styleClass = Class.forName("android.app.Notification\$ProgressStyle")
val style = styleClass.getDeclaredConstructor().newInstance() as Notification.Style
styleClass.getMethod(
"setProgress",
Int::class.javaPrimitiveType,
).invoke(style, progress)
styleClass.getMethod(
"setProgressIndeterminate",
Boolean::class.javaPrimitiveType,
).invoke(style, indeterminate)
builder.setStyle(style)
if (requestPromotion) {
Notification.Builder::class.java.getMethod(
"setRequestPromotedOngoing",
Boolean::class.javaPrimitiveType,
).invoke(builder, true)
}
true
} catch (_: ReflectiveOperationException) {
false
}
}
private fun ensureChannel(manager: NotificationManager) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"AI 截屏识别进度",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "显示用户主动发起的截屏记账识别和入账进度"
setSound(null, null)
enableVibration(false)
}
manager.createNotificationChannel(channel)
}
private fun canNotify(context: Context): Boolean {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return false
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
return Build.VERSION.SDK_INT < Build.VERSION_CODES.N ||
manager.areNotificationsEnabled()
}
}
@@ -0,0 +1,112 @@
package com.nx.miaoji
import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.time.Instant
import java.util.UUID
import java.util.concurrent.Executors
object BackgroundAiRecognizer {
private val executor = Executors.newSingleThreadExecutor()
fun analyze(
context: Context,
packageName: String,
image: ByteArray,
flowSessionId: String? = null,
) {
val appContext = context.applicationContext
executor.execute {
try {
val settings = RecognitionSettings.snapshot(appContext)
val token = RecognitionSettings.runtimeToken(appContext)
val baseUrl = settings.baseUrl?.trimEnd('/')
if (!settings.aiScreenshot || !settings.aiAllowed || !settings.hasAccount ||
token.isNullOrBlank() || baseUrl.isNullOrBlank()
) {
return@execute
}
val response = upload("$baseUrl/api/parse/image?source=screenshot", token, image)
if (response.first == HttpURLConnection.HTTP_FORBIDDEN &&
response.second.contains("AI_PERMISSION_DENIED")
) {
RecognitionSettings.disableRuntimeAi(appContext)
return@execute
}
if (response.first !in 200..299) {
Log.w(TAG, "Background AI parse failed status=${response.first}")
return@execute
}
val root = JSONObject(response.second)
val items = root.optJSONArray("items") ?: return@execute
for (index in 0 until minOf(items.length(), 10)) {
val item = items.optJSONObject(index) ?: continue
val type = item.optString("type").lowercase()
val amount = item.optDouble("amount", 0.0)
if (type !in setOf("income", "expense") || amount <= 0) continue
val occurredAt = runCatching {
Instant.parse(item.optString("occurredAt")).toEpochMilli()
}.getOrElse { System.currentTimeMillis() }
val note = item.optString("note").takeIf { it.isNotBlank() }
RecognitionCoordinator.get(appContext).submit(
PaymentSignal(
packageName = packageName,
channel = "recognition_ai",
amountCents = kotlin.math.round(amount * 100).toLong(),
type = type,
merchant = note,
orderId = null,
occurredAtEpochMs = occurredAt,
knownTemplate = false,
sourceEventId = "ai:" + (flowSessionId ?: UUID.randomUUID().toString()),
sourceText = "AI 截图补全 · " + PaymentParser.appName(packageName),
flowSessionId = flowSessionId,
evidenceConfidence = "confirm",
),
)
break
}
} catch (error: Exception) {
Log.w(TAG, "Background AI parse unavailable", error)
} finally {
image.fill(0)
}
}
}
private fun upload(
endpoint: String,
token: String,
image: ByteArray,
): Pair<Int, String> {
val boundary = "----Jizhi${UUID.randomUUID()}"
val connection = URL(endpoint).openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 120_000
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Authorization", "Bearer $token")
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
connection.outputStream.use { output ->
output.write("--$boundary\r\n".toByteArray())
output.write(
"Content-Disposition: form-data; name=\"file\"; filename=\"recognition.png\"\r\n"
.toByteArray(),
)
output.write("Content-Type: image/png\r\n\r\n".toByteArray())
output.write(image)
output.write("\r\n--$boundary--\r\n".toByteArray())
}
val code = connection.responseCode
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
connection.disconnect()
return code to body
}
private const val TAG = "JizhiRecognition"
}
@@ -0,0 +1,483 @@
package com.nx.miaoji
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Rect
import android.util.Log
import com.google.mlkit.common.MlKitException
import com.google.mlkit.common.sdkinternal.MlKitContext
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.Text
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.TextRecognizer
import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlin.math.roundToLong
data class LocalOcrOutcome(
val signal: PaymentSignal?,
val sawSuccess: Boolean,
val amountCandidateCount: Int,
val reason: String,
val latencyMs: Long,
val statusStrength: String = "none",
val expectedAmountMatched: Boolean? = null,
val resultTransitionObserved: Boolean = false,
val redactedPreview: List<String> = emptyList(),
)
object OcrEvidenceEvaluator {
fun qualifiesWeakAuto(
trustedFlow: Boolean,
freshScreenshot: Boolean,
uniqueAmount: Boolean,
expectedAmountMatched: Boolean,
resultTransitionObserved: Boolean,
directionKnown: Boolean,
): Boolean = trustedFlow &&
freshScreenshot &&
uniqueAmount &&
expectedAmountMatched &&
resultTransitionObserved &&
directionKnown
}
object OcrDiagnosticRedactor {
private val relevantLine = Regex(
"""支付|付款|转账|收款|红包|领取|退回|退款|存入|到账|完成|成功|失败|等待|处理|金额|实付|交易|返回|继续|[¥¥]|[0-9]+[.][0-9]{1,2}""",
)
private val labelledIdentity = Regex(
"""(转账给|付款给|收款方|付款方|交易对象|对方|商户)[: ]*[^,]{1,40}""",
)
private val orderValue = Regex(
"""(订单号|交易单号|转账单号|商户单号|交易号)[: ]*[A-Za-z0-9_-]{6,64}""",
RegexOption.IGNORE_CASE,
)
private val phone = Regex("""(?<![0-9])1[3-9][0-9]{9}(?![0-9])""")
private val longNumber = Regex("""(?<![.0-9])[0-9]{6,19}(?![.0-9])""")
private val url = Regex("""https?://[^ ]+""", RegexOption.IGNORE_CASE)
fun redact(lines: List<String>): List<String> = lines
.asSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.map(::redactSensitive)
.map { line -> if (relevantLine.containsMatchIn(line)) line.take(MAX_LINE_CHARS) else HIDDEN }
.distinct()
.take(MAX_LINES)
.toList()
private fun redactSensitive(value: String): String {
var redacted = url.replace(value, "[链接已隐藏]")
redacted = orderValue.replace(redacted) { match ->
match.groupValues[1] + "******"
}
redacted = labelledIdentity.replace(redacted) { match ->
match.groupValues[1] + ":已隐藏"
}
redacted = phone.replace(redacted, "1**********")
return longNumber.replace(redacted) { match ->
"******" + match.value.takeLast(2)
}
}
private const val MAX_LINES = 12
private const val MAX_LINE_CHARS = 60
private const val HIDDEN = "[其他文字已隐藏]"
}
object LocalPaymentOcr {
private val callbackExecutor = Executors.newSingleThreadExecutor()
private val closeScheduler = Executors.newSingleThreadScheduledExecutor()
private var recognizer: TextRecognizer? = null
private var closeFuture: ScheduledFuture<*>? = null
fun analyze(
context: Context,
bitmap: Bitmap,
packageName: String,
flowSessionId: String,
trustedFlow: Boolean,
capturedAt: Long,
expectedAmountCents: Long?,
expectedType: String?,
resultTransitionObserved: Boolean,
submittedFlow: Boolean,
flowKind: String,
diagnosticPreviewEnabled: Boolean,
callback: (LocalOcrOutcome) -> Unit,
) {
val startedAt = System.currentTimeMillis()
val client = getRecognizer(context)
client.process(InputImage.fromBitmap(bitmap, 0))
.addOnSuccessListener(callbackExecutor) { result ->
val outcome = runCatching {
parse(
result = result,
packageName = packageName,
flowSessionId = flowSessionId,
trustedFlow = trustedFlow,
capturedAt = capturedAt,
expectedAmountCents = expectedAmountCents,
expectedType = expectedType,
resultTransitionObserved = resultTransitionObserved,
submittedFlow = submittedFlow,
flowKind = flowKind,
diagnosticPreviewEnabled = diagnosticPreviewEnabled,
startedAt = startedAt,
)
}.getOrElse {
LocalOcrOutcome(
signal = null,
sawSuccess = false,
amountCandidateCount = 0,
reason = "ocr_parse_failed",
latencyMs = System.currentTimeMillis() - startedAt,
)
}
runCatching { callback(outcome) }
scheduleClose()
}
.addOnFailureListener(callbackExecutor) { error ->
Log.e(
TAG,
"Local OCR task failed: " +
(error as? MlKitException)?.errorCode.orEmptyCode(),
error,
)
runCatching {
callback(
LocalOcrOutcome(
signal = null,
sawSuccess = false,
amountCandidateCount = 0,
reason = taskFailureReason(error),
latencyMs = System.currentTimeMillis() - startedAt,
),
)
}
scheduleClose()
}
}
@Synchronized
fun initialize(context: Context) {
MlKitContext.initializeIfNeeded(context.applicationContext)
}
@Synchronized
private fun getRecognizer(context: Context): TextRecognizer {
initialize(context)
closeFuture?.cancel(false)
closeFuture = null
return recognizer ?: TextRecognition.getClient(
ChineseTextRecognizerOptions.Builder().build(),
).also { recognizer = it }
}
@Synchronized
private fun scheduleClose() {
closeFuture?.cancel(false)
closeFuture = closeScheduler.schedule(
{
synchronized(this) {
recognizer?.close()
recognizer = null
closeFuture = null
}
},
OCR_IDLE_CLOSE_SECONDS,
TimeUnit.SECONDS,
)
}
private fun parse(
result: Text,
packageName: String,
flowSessionId: String,
trustedFlow: Boolean,
capturedAt: Long,
expectedAmountCents: Long?,
expectedType: String?,
resultTransitionObserved: Boolean,
submittedFlow: Boolean,
flowKind: String,
diagnosticPreviewEnabled: Boolean,
startedAt: Long,
): LocalOcrOutcome {
val lines = result.textBlocks
.flatMap { block -> block.lines }
.map { VisualLine(it.text.trim(), it.boundingBox) }
.filter { it.text.isNotBlank() }
.sortedWith(compareBy<VisualLine>({ it.bounds?.top ?: Int.MAX_VALUE }, { it.bounds?.left ?: 0 }))
val allText = lines.joinToString(System.lineSeparator(), transform = VisualLine::text)
val preview = if (diagnosticPreviewEnabled) {
OcrDiagnosticRedactor.redact(lines.map(VisualLine::text))
} else {
emptyList()
}
if (lines.isEmpty()) {
return outcome(
signal = null,
sawSuccess = false,
amountCount = 0,
reason = "no_text",
startedAt = startedAt,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
}
if (PaymentParser.isHistoryPageText(allText)) {
return outcome(
null,
false,
0,
"history_page",
startedAt,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
}
if (PaymentParser.containsBlockedStatus(allText)) {
return outcome(
null,
false,
0,
"blocked_status",
startedAt,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
}
if (flowKind != "red_packet_send" && PaymentParser.isPendingRedPacketSurface(allText)) {
return outcome(
null,
false,
0,
"red_packet_not_settled",
startedAt,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
}
if (PaymentParser.isPaymentInputPage(allText)) {
return outcome(
null,
false,
0,
"payment_input_page",
startedAt,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
}
val detectedStatus = PaymentParser.detectStatus(allText, expectedType)
val redPacketSentSurface = flowKind == "red_packet_send" &&
PaymentParser.hasRedPacketSentSurface(allText)
val status = if (detectedStatus.strength == PaymentStatusStrength.NONE &&
redPacketSentSurface
) {
PaymentStatusEvidence(PaymentStatusStrength.WEAK, "expense")
} else {
detectedStatus
}
if (status.strength == PaymentStatusStrength.NONE) {
return outcome(
null,
false,
0,
"no_success_status",
startedAt,
statusStrength = status.strength.wireValue,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
}
val direction = status.direction
?: return outcome(
null,
true,
0,
"direction_unknown",
startedAt,
statusStrength = status.strength.wireValue,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
val amountCandidates = collectAmounts(lines)
.filter { it.amount.isFinite() && it.amount > 0 && it.amount <= MAX_AMOUNT }
val statusIndex = lines.indexOfFirst {
PaymentParser.detectStatus(it.text, expectedType).strength != PaymentStatusStrength.NONE
}
val statusBounds = lines.getOrNull(statusIndex)?.bounds
val ranked = amountCandidates.sortedWith(
compareBy<AmountCandidate>(
{ candidate -> verticalDistance(statusBounds, candidate.bounds) },
{ candidate -> -(candidate.bounds?.height() ?: 0) },
),
)
val distinctCents = ranked.map { (it.amount * 100).roundToLong() }.distinct()
val expectedCandidate = expectedAmountCents?.let { expected ->
ranked.firstOrNull { (it.amount * 100).roundToLong() == expected }
}
val resultSelectedCents = (expectedCandidate ?: ranked.firstOrNull())
?.let { (it.amount * 100).roundToLong() }
val canUseExpectedAmount = resultSelectedCents == null &&
expectedAmountCents != null &&
submittedFlow &&
resultTransitionObserved &&
expectedType == direction
if (resultSelectedCents == null && !canUseExpectedAmount) {
return outcome(
null,
true,
0,
if (expectedAmountCents == null) "expected_amount_missing" else "missing_amount",
startedAt,
statusStrength = status.strength.wireValue,
expectedAmountMatched = expectedAmountCents?.let { false },
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
}
val selectedCents = resultSelectedCents ?: expectedAmountCents!!
val amountSource = if (resultSelectedCents == null) "expected" else "result"
val expectedMatched = expectedAmountCents?.let { it == selectedCents }
val fresh = System.currentTimeMillis() - capturedAt <= MAX_SCREENSHOT_AGE_MS
val resultAmountSafe = distinctCents.size == 1 &&
(expectedAmountCents == null || expectedMatched == true)
val highConfidence = when {
amountSource == "expected" -> trustedFlow && fresh && submittedFlow &&
resultTransitionObserved && expectedType == direction
status.strength == PaymentStatusStrength.STRONG -> trustedFlow && fresh &&
submittedFlow && resultTransitionObserved && resultAmountSafe
status.strength == PaymentStatusStrength.WEAK -> OcrEvidenceEvaluator.qualifiesWeakAuto(
trustedFlow = trustedFlow && submittedFlow,
freshScreenshot = fresh,
uniqueAmount = distinctCents.size == 1,
expectedAmountMatched = expectedMatched == true,
resultTransitionObserved = resultTransitionObserved,
directionKnown = expectedType != null && direction == expectedType,
)
else -> false
}
val merchant = PaymentParser.extractMerchant(allText)
val orderId = PaymentParser.extractOrderId(allText)
val kind = if (flowKind.isNotBlank()) flowKind else PaymentParser.recognitionKind(allText, direction)
val signal = PaymentSignal(
packageName = packageName,
channel = "local_ocr",
amountCents = selectedCents,
type = direction,
merchant = merchant,
orderId = orderId,
occurredAtEpochMs = capturedAt,
knownTemplate = true,
sourceEventId = "ocr:" + packageName + ":" + flowSessionId,
sourceText = "本地 OCR · " + PaymentParser.appName(packageName),
flowSessionId = flowSessionId,
evidenceConfidence = if (highConfidence) "high" else "confirm",
recognitionKind = kind,
categoryHint = PaymentParser.categoryHint(kind, direction),
amountSource = amountSource,
resultFingerprint = PaymentParser.resultFingerprint(
packageName,
kind,
direction,
selectedCents,
merchant,
orderId,
PaymentParser.sha256(allText),
),
)
val reason = when {
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
highConfidence && status.strength == PaymentStatusStrength.WEAK ->
"combined_high_confidence"
highConfidence -> "high_confidence"
status.strength == PaymentStatusStrength.WEAK -> "weak_status_confirm"
else -> "ambiguous_or_unarmed"
}
return outcome(
signal = signal,
sawSuccess = true,
amountCount = distinctCents.size,
reason = reason,
startedAt = startedAt,
statusStrength = status.strength.wireValue,
expectedAmountMatched = expectedMatched,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = preview,
)
}
private fun collectAmounts(lines: List<VisualLine>): List<AmountCandidate> {
val candidates = mutableListOf<AmountCandidate>()
lines.forEachIndexed { index, line ->
PaymentParser.parseAmountCandidate(line.text)?.let {
candidates += AmountCandidate(it, line.bounds)
}
if (line.text.trim() in setOf("¥", "") && index + 1 < lines.size) {
val next = lines[index + 1]
val amount = next.text.trim().toDoubleOrNull()
if (amount != null) candidates += AmountCandidate(amount, next.bounds)
}
}
return candidates.distinctBy { (it.amount * 100).roundToLong() to it.bounds }
}
private fun verticalDistance(status: Rect?, amount: Rect?): Int {
if (status == null || amount == null) return Int.MAX_VALUE / 2
return abs(status.centerY() - amount.centerY())
}
private fun outcome(
signal: PaymentSignal?,
sawSuccess: Boolean,
amountCount: Int,
reason: String,
startedAt: Long,
statusStrength: String = "none",
expectedAmountMatched: Boolean? = null,
resultTransitionObserved: Boolean = false,
redactedPreview: List<String> = emptyList(),
) = LocalOcrOutcome(
signal = signal,
sawSuccess = sawSuccess,
amountCandidateCount = amountCount,
reason = reason,
latencyMs = System.currentTimeMillis() - startedAt,
statusStrength = statusStrength,
expectedAmountMatched = expectedAmountMatched,
resultTransitionObserved = resultTransitionObserved,
redactedPreview = redactedPreview,
)
private fun taskFailureReason(error: Exception): String {
val code = (error as? MlKitException)?.errorCode ?: return "ocr_failed"
return when (code) {
MlKitException.NOT_FOUND,
MlKitException.FAILED_PRECONDITION,
MlKitException.MODEL_INCOMPATIBLE_WITH_TFLITE,
MlKitException.MODEL_HASH_MISMATCH,
-> "ocr_model_unavailable"
MlKitException.UNAVAILABLE -> "ocr_unavailable"
else -> "ocr_failed"
}
}
private fun Int?.orEmptyCode(): String = this?.let { "code=$it" } ?: "non_mlkit_error"
private data class VisualLine(val text: String, val bounds: Rect?)
private data class AmountCandidate(val amount: Double, val bounds: Rect?)
private const val TAG = "JizhiRecognition"
private const val OCR_IDLE_CLOSE_SECONDS = 60L
private const val MAX_SCREENSHOT_AGE_MS = 3_000L
private const val MAX_AMOUNT = 100_000_000.0
}
@@ -0,0 +1,864 @@
package com.nx.miaoji
import android.Manifest
import android.app.StatusBarManager
import android.app.UiModeManager
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.provider.Settings
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.util.UUID
class MainActivity : FlutterActivity() {
companion object {
const val CHANNEL = "com.miaoji/screenshot"
const val AUDIO_PERMISSION_REQUEST = 1002
const val NOTIFICATION_PERMISSION_REQUEST = 1003
const val EXTRA_ACTION = "action"
const val ACTION_SCREENSHOT_SHORTCUT = "screenshot"
const val ACTION_SCREENSHOT_RESULT = "screenshot_result"
const val ACTION_SCREENSHOT_ERROR = "screenshot_error"
const val ACTION_RECOGNITION_CONFIRM = "recognition_confirm"
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
const val EXTRA_SCREENSHOT_PATH = "screenshotPath"
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
const val EXTRA_TRANSACTION_ID = "transactionId"
}
private val handler by lazy { Handler(mainLooper) }
private var channel: MethodChannel? = null
private var pendingCaptureResult: MethodChannel.Result? = null
private var shortcutQueued = false
private var shortcutScheduled = false
private var pendingUiScreenshotPath: String? = null
private var pendingUiScreenshotError: String? = null
private var lastScreenshotSessionId: String? = null
private var pendingNotificationStart = false
private var pendingNotificationPermissionResult: MethodChannel.Result? = null
private var pendingProgressCount: Int? = null
private var pendingRecognitionAction: Map<String, Any?>? = null
private var recognitionReceiverRegistered = false
private var updateInstallBridge: UpdateInstallBridge? = null
private var pendingSpeechResult: MethodChannel.Result? = null
private var speechRecognizer: SpeechRecognizer? = null
private var streamingSpeech = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
updateInstallBridge = UpdateInstallBridge(this).also { it.register(flutterEngine) }
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
channel?.setMethodCallHandler { call, result ->
when (call.method) {
"captureScreenshot" -> captureFromFlutter(result)
"cleanupStaleScreenshots" -> cleanupStaleScreenshots(result)
"startAiProgress" -> startAiProgress(result)
"updateAiProgress" -> {
val count = call.intArgument("count")
pendingProgressCount = count
AiProgressNotifier.update(this, count)
result.success(true)
}
"finishAiProgress" -> {
pendingProgressCount = null
AiProgressNotifier.finish(
this,
call.intArgument("count"),
call.doubleArgument("total"),
)
result.success(true)
}
"failAiProgress" -> {
pendingProgressCount = null
AiProgressNotifier.fail(
this,
call.argument<String>("message") ?: "识别失败,请重试",
)
result.success(true)
}
"recognizeSpeech" -> startSpeechRecognition(result)
"startSpeechRecognition" -> startStreamingSpeechRecognition(result)
"stopSpeechRecognition" -> {
speechRecognizer?.stopListening()
result.success(true)
}
"cancelSpeechRecognition" -> {
cancelSpeechRecognition()
result.success(true)
}
"isAccessibilityEnabled" -> result.success(isAccessibilityServiceEnabled())
"getRecognitionStatus" -> {
result.success(recognitionStatus())
}
"setRecognitionToggle" -> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_SET_TOGGLE,
extras = Bundle().apply {
putString("key", call.argument<String>("key"))
putBoolean("enabled", call.argument<Boolean>("enabled") == true)
},
)
result.success(response?.getBoolean("success") == true)
}
"clearRecognitionDiagnostic" -> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_CLEAR_DIAGNOSTIC,
)
result.success(response?.getBoolean("success") == true)
}
"configureRecognitionContext" -> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_SET_RUNTIME,
extras = Bundle().apply {
putBoolean("aiAllowed", call.argument<Boolean>("aiAllowed") == true)
putBoolean("hasAccount", call.argument<Boolean>("hasAccount") == true)
putString("baseUrl", call.argument<String>("baseUrl"))
putString("token", call.argument<String>("token"))
},
)
result.success(response?.getBoolean("success") == true)
}
"drainRecognitionCandidates" -> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_DRAIN,
)
result.success(response?.getStringArrayList("candidates") ?: arrayListOf<String>())
}
"ackRecognitionCandidate" -> acknowledgeRecognition(call, result)
"openAccessibilitySettings" -> {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
result.success(true)
}
"openNotificationAccessSettings" -> {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
result.success(true)
}
"openBatteryOptimizationSettings" -> {
openBatteryOptimizationSettings()
result.success(true)
}
"openBackgroundStartupSettings" -> {
openBackgroundStartupSettings()
result.success(true)
}
"requestNotificationPermission" -> requestNotificationPermission(result)
"setThemeMode" -> {
setNativeThemeMode(call.argument<String>("mode"))
result.success(true)
}
"openQuickSettings" -> openQuickSettings(result)
else -> result.notImplemented()
}
}
registerRecognitionReceiver()
handleIncomingIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
}
override fun onResume() {
super.onResume()
updateInstallBridge?.onResume()
scheduleShortcutIfNeeded()
dispatchPendingScreenshot()
dispatchPendingRecognitionAction()
}
private fun handleIncomingIntent(incoming: Intent?) {
when (incoming?.getStringExtra(EXTRA_ACTION)) {
ACTION_SCREENSHOT_SHORTCUT -> {
shortcutQueued = true
scheduleShortcutIfNeeded()
}
ACTION_SCREENSHOT_RESULT -> {
val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH)
val sessionId =
incoming.getStringExtra(EXTRA_SCREENSHOT_SESSION_ID) ?: path
if (!path.isNullOrBlank()) {
if (sessionId == lastScreenshotSessionId) {
incoming.removeExtra(EXTRA_ACTION)
return
}
if (!ScreenshotResultDispatcher.isValidImage(path)) {
File(path).delete()
finishScreenshotError("截屏图片无效,请重新截取")
incoming.removeExtra(EXTRA_ACTION)
return
}
lastScreenshotSessionId = sessionId
val callback = pendingCaptureResult
pendingCaptureResult = null
if (callback != null) {
callback.success(path)
} else {
pendingUiScreenshotPath = path
pendingUiScreenshotError = null
dispatchPendingScreenshot()
}
}
}
ACTION_SCREENSHOT_ERROR -> {
val message =
incoming.getStringExtra(EXTRA_SCREENSHOT_ERROR) ?: "截屏失败,请重试"
if (pendingCaptureResult != null) {
finishScreenshotError(message)
} else {
pendingUiScreenshotError = message
pendingUiScreenshotPath = null
dispatchPendingScreenshot()
}
}
ACTION_RECOGNITION_CONFIRM,
ACTION_RECOGNITION_UNDO,
ACTION_RECOGNITION_EDIT -> {
pendingRecognitionAction = mapOf(
"action" to incoming.getStringExtra(EXTRA_ACTION),
"candidateId" to incoming.getStringExtra(
RecognitionCoordinator.EXTRA_CANDIDATE_ID,
),
"transactionId" to incoming.getLongExtra(
EXTRA_TRANSACTION_ID,
Long.MIN_VALUE,
).takeIf { it != Long.MIN_VALUE },
)
dispatchPendingRecognitionAction()
}
}
incoming?.removeExtra(EXTRA_ACTION)
incoming?.removeExtra(EXTRA_SCREENSHOT_SESSION_ID)
}
private fun scheduleShortcutIfNeeded() {
if (!shortcutQueued || shortcutScheduled) return
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return
shortcutScheduled = true
handler.postDelayed({
shortcutScheduled = false
if (!shortcutQueued) return@postDelayed
shortcutQueued = false
if (
Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
!isAccessibilityServiceEnabled()
) {
startActivity(
Intent(this, ProjectionConsentActivity::class.java),
)
return@postDelayed
}
moveTaskToBack(true)
val requestId = UUID.randomUUID().toString()
val accepted = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_REQUEST_SCREENSHOT,
extras = Bundle().apply {
putString("requestId", requestId)
putBoolean("showResult", true)
putLong("delayMs", 500L)
},
)
?.getBoolean("accepted") == true
if (!accepted) {
startActivity(
Intent(this, ProjectionConsentActivity::class.java),
)
}
}, 180L)
}
private fun captureFromFlutter(result: MethodChannel.Result) {
if (pendingCaptureResult != null) {
result.error("BUSY", "截屏正在处理中", null)
return
}
pendingCaptureResult = result
val requestId = UUID.randomUUID().toString()
val accepted = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_REQUEST_SCREENSHOT,
extras = Bundle().apply {
putString("requestId", requestId)
putBoolean("showResult", false)
},
)?.getBoolean("accepted") == true
if (!accepted) {
try {
startActivity(
Intent(
this,
ProjectionConsentActivity::class.java,
).putExtra(
ProjectionConsentActivity.EXTRA_RETURN_ERROR_TO_APP,
true,
),
)
} catch (e: Exception) {
finishScreenshotError(
e.message ?: "无法申请系统截屏授权,请稍后重试",
)
}
} else {
pollScreenshotResult(requestId, System.currentTimeMillis())
}
}
private fun pollScreenshotResult(requestId: String, startedAt: Long) {
if (pendingCaptureResult == null) return
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_SCREENSHOT_RESULT,
arg = requestId,
)
if (response?.getBoolean("ready") == true) {
val path = response.getString("path")
val error = response.getString("error")
when {
!path.isNullOrBlank() && ScreenshotResultDispatcher.isValidImage(path) -> {
pendingCaptureResult?.success(path)
pendingCaptureResult = null
}
!path.isNullOrBlank() -> {
File(path).delete()
finishScreenshotError("截屏图片无效,请重新截取")
}
else -> finishScreenshotError(error ?: "截屏失败,请重试")
}
return
}
if (System.currentTimeMillis() - startedAt > 12_000L) {
finishScreenshotError("截屏服务响应超时,请重试")
return
}
handler.postDelayed({ pollScreenshotResult(requestId, startedAt) }, 120L)
}
private fun dispatchPendingScreenshot() {
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return
handler.removeCallbacks(dispatchScreenshotRunnable)
handler.postDelayed(dispatchScreenshotRunnable, 700L)
}
private val dispatchScreenshotRunnable = Runnable {
val path = pendingUiScreenshotPath
val error = pendingUiScreenshotError
when {
path != null -> {
pendingUiScreenshotPath = null
channel?.invokeMethod("onScreenshotReady", path)
}
error != null -> {
pendingUiScreenshotError = null
channel?.invokeMethod("onScreenshotError", error)
}
}
}
private fun finishScreenshotError(message: String) {
val callback = pendingCaptureResult
pendingCaptureResult = null
if (callback != null) {
callback.error("SCREENSHOT_ERROR", message, null)
} else {
pendingUiScreenshotError = message
dispatchPendingScreenshot()
}
}
private fun openBatteryOptimizationSettings() {
runCatching {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}.onFailure {
startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
),
)
}
}
private fun openBackgroundStartupSettings() {
val candidates = if (Build.MANUFACTURER.equals("vivo", ignoreCase = true)) {
listOf(
Intent().setComponent(
ComponentName(
"com.vivo.permissionmanager",
"com.vivo.permissionmanager.activity.BgStartUpManagerActivity",
),
),
Intent().setComponent(
ComponentName(
"com.iqoo.secure",
"com.iqoo.secure.ui.phoneoptimize.BgStartUpManager",
),
),
)
} else {
emptyList()
}
val target = candidates.firstOrNull {
it.resolveActivity(packageManager) != null
} ?: Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName"),
)
startActivity(target)
}
private fun requestNotificationPermission(result: MethodChannel.Result) {
if (
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
) {
result.success(true)
return
}
pendingNotificationPermissionResult = result
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST,
)
}
private fun startAiProgress(result: MethodChannel.Result) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
pendingNotificationStart = true
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST,
)
result.success(false)
return
}
AiProgressNotifier.start(this)
result.success(true)
}
private fun MethodCall.intArgument(name: String): Int {
return (argument<Number>(name))?.toInt() ?: 0
}
private fun MethodCall.doubleArgument(name: String): Double {
return (argument<Number>(name))?.toDouble() ?: 0.0
}
private fun startSpeechRecognition(result: MethodChannel.Result) {
if (pendingSpeechResult != null || speechRecognizer != null) {
result.error("BUSY", "语音识别正在进行中", null)
return
}
pendingSpeechResult = result
streamingSpeech = false
requestSpeechPermissionOrBegin()
}
private fun startStreamingSpeechRecognition(result: MethodChannel.Result) {
if (pendingSpeechResult != null || speechRecognizer != null) {
result.error("BUSY", "语音识别正在进行中", null)
return
}
streamingSpeech = true
result.success(true)
requestSpeechPermissionOrBegin()
}
private fun requestSpeechPermissionOrBegin() {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.RECORD_AUDIO,
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.RECORD_AUDIO),
AUDIO_PERMISSION_REQUEST,
)
return
}
beginSpeechRecognition()
}
private fun beginSpeechRecognition() {
try {
val onDeviceAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
SpeechRecognizer.isOnDeviceRecognitionAvailable(this)
if (!onDeviceAvailable && !SpeechRecognizer.isRecognitionAvailable(this)) {
finishSpeechError("UNAVAILABLE", "手机没有可用的语音识别服务")
return
}
speechRecognizer?.destroy()
speechRecognizer = if (onDeviceAvailable) {
SpeechRecognizer.createOnDeviceSpeechRecognizer(this)
} else {
SpeechRecognizer.createSpeechRecognizer(this)
}
speechRecognizer?.setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
emitSpeechEvent("ready")
}
override fun onBeginningOfSpeech() {
emitSpeechEvent("speaking")
}
override fun onRmsChanged(rmsdB: Float) {
emitSpeechEvent("rms", rms = rmsdB.coerceIn(-2f, 12f))
}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
emitSpeechEvent("processing")
}
override fun onPartialResults(partialResults: Bundle?) {
val text = partialResults
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull()
?.trim()
if (!text.isNullOrEmpty()) emitSpeechEvent("partial", text)
}
override fun onEvent(eventType: Int, params: Bundle?) {}
override fun onError(error: Int) {
if (
error == SpeechRecognizer.ERROR_NO_MATCH ||
error == SpeechRecognizer.ERROR_SPEECH_TIMEOUT
) {
finishSpeechSuccess(null)
} else {
finishSpeechError("SPEECH_$error", speechErrorMessage(error))
}
}
override fun onResults(results: Bundle?) {
val text = results
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull()
?.trim()
finishSpeechSuccess(text)
}
})
val recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, "zh-CN")
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, packageName)
}
speechRecognizer?.startListening(recognizerIntent)
} catch (e: Exception) {
finishSpeechError("ERROR", e.message ?: "语音识别启动失败")
}
}
private fun speechErrorMessage(error: Int): String = when (error) {
SpeechRecognizer.ERROR_AUDIO -> "录音失败,请重试"
SpeechRecognizer.ERROR_CLIENT -> "语音识别客户端异常"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "没有录音权限"
SpeechRecognizer.ERROR_NETWORK,
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "语音识别网络不可用"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "语音识别服务忙,请稍后重试"
SpeechRecognizer.ERROR_SERVER -> "语音识别服务异常"
else -> "语音识别失败($error"
}
private fun finishSpeechSuccess(text: String?) {
val callback = pendingSpeechResult
pendingSpeechResult = null
if (streamingSpeech) emitSpeechEvent("final", text)
streamingSpeech = false
speechRecognizer?.destroy()
speechRecognizer = null
callback?.success(text)
}
private fun finishSpeechError(code: String, message: String) {
val callback = pendingSpeechResult
pendingSpeechResult = null
if (streamingSpeech) emitSpeechEvent("error", message)
streamingSpeech = false
speechRecognizer?.destroy()
speechRecognizer = null
callback?.error(code, message, null)
}
private fun cancelSpeechRecognition() {
speechRecognizer?.cancel()
speechRecognizer?.destroy()
speechRecognizer = null
pendingSpeechResult?.success(null)
pendingSpeechResult = null
if (streamingSpeech) emitSpeechEvent("cancelled")
streamingSpeech = false
}
private fun emitSpeechEvent(type: String, text: String? = null, rms: Float? = null) {
if (!streamingSpeech) return
val payload = mutableMapOf<String, Any>("type" to type)
if (text != null) payload["text"] = text
if (rms != null) payload["rms"] = rms
channel?.invokeMethod("onSpeechEvent", payload)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
AUDIO_PERMISSION_REQUEST -> {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
beginSpeechRecognition()
} else {
finishSpeechError(
"PERMISSION_DENIED",
"需要录音权限才能使用语音记账",
)
}
}
NOTIFICATION_PERMISSION_REQUEST -> {
val granted =
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED
pendingNotificationPermissionResult?.success(granted)
pendingNotificationPermissionResult = null
if (pendingNotificationStart && granted) {
AiProgressNotifier.start(this)
pendingProgressCount?.let { AiProgressNotifier.update(this, it) }
}
pendingNotificationStart = false
}
}
}
private fun openQuickSettings(result: MethodChannel.Result) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val manager = getSystemService(StatusBarManager::class.java)
manager.requestAddTileService(
ComponentName(this, ScreenshotTileService::class.java),
"截屏记账",
Icon.createWithResource(this, android.R.drawable.ic_menu_camera),
mainExecutor,
) { resultCode ->
result.success(resultCode == 1 || resultCode == 2)
}
} else {
startActivity(Intent("android.settings.QUICK_SETTINGS"))
result.success(true)
}
} catch (e: Exception) {
result.error("ERROR", e.message, null)
}
}
private fun cleanupStaleScreenshots(result: MethodChannel.Result) {
try {
val cutoff = System.currentTimeMillis() - 24L * 60L * 60L * 1000L
var deleted = 0
val directories = listOfNotNull(
getExternalFilesDir(Environment.DIRECTORY_PICTURES),
filesDir,
).distinctBy(File::getAbsolutePath)
directories.forEach { directory ->
directory.listFiles()?.forEach { file ->
val isMiaojiScreenshot =
file.name.startsWith("miaoji_screenshot_") ||
file.name.startsWith("miaoji_projection_")
if (isMiaojiScreenshot && file.lastModified() < cutoff && file.delete()) {
deleted += 1
}
}
}
result.success(deleted)
} catch (error: Exception) {
result.error("CLEANUP_ERROR", error.message, null)
}
}
private fun setNativeThemeMode(mode: String?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val manager = getSystemService(UiModeManager::class.java)
manager.setApplicationNightMode(
when (mode) {
"dark" -> UiModeManager.MODE_NIGHT_YES
"light" -> UiModeManager.MODE_NIGHT_NO
else -> UiModeManager.MODE_NIGHT_AUTO
},
)
}
private fun recognitionStatus(): Map<String, Any?> {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_STATUS,
)
val enabledListeners = Settings.Secure.getString(
contentResolver,
"enabled_notification_listeners",
).orEmpty()
val notificationAuthorized = enabledListeners
.split(':')
.mapNotNull(ComponentName::unflattenFromString)
.any { it == ComponentName(this, PaymentNotificationListenerService::class.java) }
return mapOf(
"accessibilityAuthorized" to isAccessibilityEnabledInSystem(),
"accessibilityConnected" to (response?.getBoolean("accessibilityConnected") == true),
"notificationAuthorized" to notificationAuthorized,
"notificationConnected" to (response?.getBoolean("notificationConnected") == true),
"postNotificationsGranted" to (
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
),
"batteryOptimizationIgnored" to (
Build.VERSION.SDK_INT < Build.VERSION_CODES.M ||
getSystemService(PowerManager::class.java)
.isIgnoringBatteryOptimizations(packageName)
),
"manufacturer" to Build.MANUFACTURER.orEmpty(),
"settings" to response?.getString("settings"),
"latestStatus" to response?.getString("latestStatus"),
"latestDiagnostic" to response?.getString("latestDiagnostic"),
)
}
private fun acknowledgeRecognition(call: MethodCall, result: MethodChannel.Result) {
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_ACK,
extras = Bundle().apply {
putString("id", call.argument<String>("id"))
putString("state", call.argument<String>("state"))
call.argument<Number>("transactionId")?.let {
putLong("transactionId", it.toLong())
}
},
)
result.success(response?.getBoolean("success") == true)
}
private fun registerRecognitionReceiver() {
if (recognitionReceiverRegistered) return
val filter = IntentFilter(RecognitionCoordinator.ACTION_READY)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(recognitionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(recognitionReceiver, filter)
}
recognitionReceiverRegistered = true
}
private val recognitionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
pendingRecognitionAction = mapOf(
"action" to "ready",
"candidateId" to intent?.getStringExtra(
RecognitionCoordinator.EXTRA_CANDIDATE_ID,
),
)
dispatchPendingRecognitionAction()
}
}
private fun dispatchPendingRecognitionAction() {
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return
val action = pendingRecognitionAction ?: return
pendingRecognitionAction = null
channel?.invokeMethod("onRecognitionAction", action)
}
private fun isAccessibilityServiceEnabled(): Boolean {
if (!isAccessibilityEnabledInSystem()) return false
val response = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_STATUS,
)
return response?.getBoolean("accessibilityConnected") == true
}
private fun isAccessibilityEnabledInSystem(): Boolean {
val expected = ComponentName(this, ScreenshotAccessibilityService::class.java)
val enabled = Settings.Secure.getString(
contentResolver,
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
) ?: return false
val enabledInSystem = enabled
.split(':')
.mapNotNull { ComponentName.unflattenFromString(it) }
.any { it == expected }
return enabledInSystem
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
if (recognitionReceiverRegistered) {
unregisterReceiver(recognitionReceiver)
recognitionReceiverRegistered = false
}
speechRecognizer?.destroy()
speechRecognizer = null
pendingSpeechResult = null
streamingSpeech = false
updateInstallBridge?.dispose()
updateInstallBridge = null
super.onDestroy()
}
}
@@ -0,0 +1,54 @@
package com.nx.miaoji
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
object NativeCrypto {
private const val ALIAS = "jizhi_recognition_queue"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
fun encrypt(value: ByteArray): String? = runCatching {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key())
val encrypted = cipher.doFinal(value)
val payload = ByteArray(1 + cipher.iv.size + encrypted.size)
payload[0] = cipher.iv.size.toByte()
cipher.iv.copyInto(payload, 1)
encrypted.copyInto(payload, 1 + cipher.iv.size)
Base64.encodeToString(payload, Base64.NO_WRAP)
}.getOrNull()
fun decrypt(encoded: String): ByteArray? = runCatching {
val payload = Base64.decode(encoded, Base64.NO_WRAP)
val ivSize = payload[0].toInt()
require(ivSize in 12..16 && payload.size > ivSize + 1)
val iv = payload.copyOfRange(1, 1 + ivSize)
val encrypted = payload.copyOfRange(1 + ivSize, payload.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv))
cipher.doFinal(encrypted)
}.getOrNull()
private fun key(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(
ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return generator.generateKey()
}
}
@@ -0,0 +1,289 @@
package com.nx.miaoji
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import java.io.File
import java.io.FileOutputStream
class OneShotProjectionService : Service() {
private val handler = Handler(Looper.getMainLooper())
private var mediaProjection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var completed = false
private var sessionId = ""
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (completed || mediaProjection != null) return START_NOT_STICKY
sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)
?: ScreenshotResultDispatcher.newSessionId()
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Int.MIN_VALUE)
?: Int.MIN_VALUE
@Suppress("DEPRECATION")
val resultData = intent?.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
if (resultCode != android.app.Activity.RESULT_OK || resultData == null) {
fail("截屏授权数据无效,请重新授权")
return START_NOT_STICKY
}
handler.postDelayed(
{ startCapture(resultCode, resultData) },
SOURCE_APP_RESTORE_DELAY_MS,
)
handler.postDelayed(
{ fail("截屏等待超时,请重试") },
CAPTURE_TIMEOUT_MS,
)
return START_NOT_STICKY
}
private fun startCapture(resultCode: Int, resultData: Intent) {
if (completed) return
try {
val manager = getSystemService(MediaProjectionManager::class.java)
mediaProjection = manager.getMediaProjection(resultCode, resultData)
mediaProjection?.registerCallback(
object : MediaProjection.Callback() {
override fun onStop() {
if (!completed) fail("系统已停止截屏会话,请重试")
}
},
handler,
)
val metrics = resources.displayMetrics
val width = metrics.widthPixels
val height = metrics.heightPixels
imageReader = ImageReader.newInstance(
width,
height,
PixelFormat.RGBA_8888,
2,
).also { reader ->
reader.setOnImageAvailableListener(
{ available -> consumeImage(available, width, height) },
handler,
)
}
virtualDisplay = mediaProjection?.createVirtualDisplay(
"MiaoJiOneShotCapture",
width,
height,
metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
null,
handler,
)
if (virtualDisplay == null) {
fail("系统无法创建截屏画面,请重试")
}
} catch (e: SecurityException) {
Log.e(TAG, "Projection permission rejected", e)
fail("截屏授权已失效,请重新授权")
} catch (e: Exception) {
Log.e(TAG, "Unable to start projection capture", e)
fail("系统截屏失败,请重试")
}
}
private fun consumeImage(reader: ImageReader, width: Int, height: Int) {
if (completed) return
val image = reader.acquireLatestImage() ?: return
var paddedBitmap: Bitmap? = null
var bitmap: Bitmap? = null
try {
val plane = image.planes.first()
val rowPadding = plane.rowStride - plane.pixelStride * width
paddedBitmap = Bitmap.createBitmap(
width + rowPadding / plane.pixelStride,
height,
Bitmap.Config.ARGB_8888,
)
paddedBitmap.copyPixelsFromBuffer(plane.buffer)
bitmap = Bitmap.createBitmap(paddedBitmap, 0, 0, width, height)
if (isLikelySecureFrame(bitmap)) {
fail("当前页面禁止截屏,无法识别")
return
}
val path = saveBitmap(bitmap)
complete(path)
} catch (e: Exception) {
Log.e(TAG, "Unable to consume projection image", e)
fail("截屏图片生成失败,请重试")
} finally {
bitmap?.recycle()
paddedBitmap?.recycle()
image.close()
}
}
private fun isLikelySecureFrame(bitmap: Bitmap): Boolean {
val startX = bitmap.width / 10
val endX = bitmap.width - startX
val startY = bitmap.height / 10
val endY = bitmap.height - startY
val stepX = maxOf(1, (endX - startX) / 24)
val stepY = maxOf(1, (endY - startY) / 24)
var samples = 0
var blankPixels = 0
for (y in startY until endY step stepY) {
for (x in startX until endX step stepX) {
val color = bitmap.getPixel(x, y)
val alpha = color ushr 24 and 0xff
val red = color ushr 16 and 0xff
val green = color ushr 8 and 0xff
val blue = color and 0xff
if (alpha == 0 || (red <= 4 && green <= 4 && blue <= 4)) {
blankPixels++
}
samples++
}
}
return samples > 0 && blankPixels * 100 / samples >= 98
}
private fun saveBitmap(bitmap: Bitmap): String {
val directory = getExternalFilesDir(Environment.DIRECTORY_PICTURES)
?: filesDir
if (!directory.exists() && !directory.mkdirs()) {
throw IllegalStateException("无法创建截屏目录")
}
val file = File(directory, "miaoji_projection_${sessionId}.png")
FileOutputStream(file).use { output ->
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)) {
throw IllegalStateException("无法写入截屏文件")
}
}
return file.absolutePath
}
private fun complete(path: String) {
if (!markCompleted()) return
Log.i(TAG, "One-shot projection screenshot saved")
if (!ScreenshotResultDispatcher.openResult(this, path, sessionId)) {
File(path).delete()
}
releaseResources()
}
private fun fail(message: String) {
if (!markCompleted()) return
Log.w(TAG, message)
ScreenshotResultDispatcher.notifyFailure(this, message)
releaseResources()
}
private fun markCompleted(): Boolean {
if (completed) return false
completed = true
handler.removeCallbacksAndMessages(null)
return true
}
private fun releaseResources() {
virtualDisplay?.release()
virtualDisplay = null
imageReader?.close()
imageReader = null
try {
mediaProjection?.stop()
} catch (_: Exception) {
}
mediaProjection = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
if (!completed) {
completed = true
handler.removeCallbacksAndMessages(null)
virtualDisplay?.release()
imageReader?.close()
try {
mediaProjection?.stop()
} catch (_: Exception) {
}
}
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"一次性截屏",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "仅在用户授权后截取一帧,完成后立即关闭"
setShowBadge(false)
},
)
}
private fun buildNotification(): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("正在获取截图")
.setContentText("完成后会立即停止系统投屏")
.setOngoing(true)
.setCategory(Notification.CATEGORY_PROGRESS)
.build()
}
companion object {
const val EXTRA_RESULT_CODE = "resultCode"
const val EXTRA_RESULT_DATA = "resultData"
const val EXTRA_SESSION_ID = "sessionId"
private const val TAG = "MiaoJiProjection"
private const val CHANNEL_ID = "miaoji_one_shot_projection"
private const val NOTIFICATION_ID = 4312
private const val SOURCE_APP_RESTORE_DELAY_MS = 900L
private const val CAPTURE_TIMEOUT_MS = 7_000L
}
}
@@ -0,0 +1,40 @@
package com.nx.miaoji
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
class PaymentNotificationListenerService : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
isConnected = true
Log.i(TAG, "Notification recognition listener connected")
}
override fun onListenerDisconnected() {
isConnected = false
requestRebind(
android.content.ComponentName(this, PaymentNotificationListenerService::class.java),
)
super.onListenerDisconnected()
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val notification = sbn ?: return
if (!RecognitionSettings.snapshot(this).notificationEvents) return
if (notification.packageName !in PaymentParser.supportedPackages) return
PaymentParser.fromNotification(notification)?.let {
RecognitionCoordinator.get(this).submit(it)
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification?) = Unit
companion object {
private const val TAG = "JizhiRecognition"
@Volatile
var isConnected: Boolean = false
private set
}
}
@@ -0,0 +1,541 @@
package com.nx.miaoji
import android.app.Notification
import android.service.notification.StatusBarNotification
import java.security.MessageDigest
import kotlin.math.roundToLong
data class PaymentSignal(
val packageName: String,
val channel: String,
val amountCents: Long,
val type: String,
val merchant: String?,
val orderId: String?,
val occurredAtEpochMs: Long,
val knownTemplate: Boolean,
val sourceEventId: String,
val sourceText: String?,
val flowSessionId: String? = null,
val evidenceConfidence: String = "confirm",
val recognitionKind: String = "payment",
val categoryHint: String? = null,
val amountSource: String = "result",
val resultFingerprint: String? = null,
)
enum class PaymentStatusStrength(val wireValue: String) {
STRONG("strong"),
WEAK("weak"),
NONE("none"),
}
data class PaymentStatusEvidence(
val strength: PaymentStatusStrength,
val direction: String?,
)
object PaymentParser {
const val WECHAT = "com.tencent.mm"
const val ALIPAY = "com.eg.android.AlipayGphone"
val supportedPackages = setOf(WECHAT, ALIPAY)
private val amountPatterns = listOf(
Regex("""(?:实付|付款金额|支付金额|收款金额|到账金额|交易金额|金额)[:\s]*[¥¥]?\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
Regex("""[¥¥]\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
Regex("""([0-9]+(?:\.[0-9]{1,2})?)\s*元"""),
)
private val orderPattern = Regex(
"""(?:交易单号|订单号|商户单号|转账单号|交易号)[:\s]*([A-Za-z0-9_-]{6,64})""",
RegexOption.IGNORE_CASE,
)
private val merchantPattern = Regex(
"""(?:商户|收款方|付款给|交易对象|对方)[:\s]*([^\n,]{2,40})""",
)
private val refundWords = listOf("退款成功", "退款已到账", "退款到账成功")
private val incomeWords = listOf(
"收款成功", "成功收款", "收款到账", "收款已到账", "收款完成", "已收款",
"收钱成功", "转账到账", "转账已到账", "到账成功", "已到账",
)
private val expenseWords = listOf(
"支付成功", "成功支付", "付款成功", "成功付款", "支付已完成", "付款已完成",
"支付完成", "付款完成", "消费成功", "扫码支付成功", "转账成功", "成功转账",
"转账已成功", "转账完成", "转账已完成", "已转账",
)
private val blockedWords = listOf(
"支付失败", "付款失败", "转账失败", "交易失败", "处理中", "支付处理中",
"等待收款", "等待对方收款", "待收款", "交易关闭", "已关闭", "已撤销",
"已取消", "未完成",
)
private val historyTitles = setOf(
"账单", "账单详情", "交易详情", "交易记录", "全部账单", "收支明细",
"订单详情", "支付详情", "付款详情", "收款详情", "转账详情",
)
private val paymentContextWords = listOf(
"确认支付", "立即支付", "确认付款", "立即付款", "确认转账", "继续付款",
"收款方", "付款方", "付款给", "转账给", "支付金额", "付款金额", "实付",
"收银台", "支付方式", "输入支付密码",
)
private val paymentActionWords = listOf(
"确认支付", "立即支付", "确认付款", "立即付款", "确认转账", "继续付款",
"支付", "付款", "转账",
)
private val weakCompletionWords = setOf(
"完成", "已完成", "操作完成", "交易完成", "转账完成", "支付完成", "付款完成", "收款完成",
)
private val expenseContextWords = listOf(
"确认支付", "立即支付", "确认付款", "立即付款", "确认转账", "继续付款",
"付款给", "转账给", "收款方", "付款金额", "支付金额", "实付", "输入支付密码",
)
private val incomeContextWords = listOf(
"收款金额", "收钱", "收款到账", "退款到账",
)
private val paymentInputWords = listOf(
"输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款",
"立即支付", "立即付款", "继续付款",
)
private val redPacketSendContextWords = listOf(
"发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包",
"红包个数", "确认发送", "立即发送",
)
private val redPacketSendActionWords = listOf(
"塞钱进红包", "发红包", "发送红包", "确认发送", "立即发送",
)
private val redPacketSendSuccessWords = listOf(
"红包发送成功", "红包已发送", "已发出红包", "红包已发出",
)
private val redPacketReceiveSuccessWords = listOf(
"红包领取成功", "领取红包成功", "红包已到账", "已存入零钱", "已存入余额",
"红包已存入零钱", "红包已存入余额",
)
private val redPacketRefundSuccessWords = listOf(
"红包退款到账", "红包退回到账", "红包退还到账", "红包已退回到账",
)
private val redPacketPendingWords = listOf(
"领取红包", "查看红包", "红包已被领完", "红包已过期", "已领取", "待领取",
)
private val redPacketSentSurfaceWords = listOf(
"红包已发送", "红包发送成功", "恭喜发财,大吉大利", "恭喜发财大吉大利",
)
fun fromAccessibility(
packageName: String,
text: String,
eventTime: Long,
windowId: Int,
flowSessionId: String? = null,
trustedFlow: Boolean = false,
expectedAmountCents: Long? = null,
expectedType: String? = null,
resultTransitionObserved: Boolean = false,
submittedFlow: Boolean = false,
flowKind: String? = null,
): PaymentSignal? {
if (packageName !in supportedPackages ||
containsBlockedStatus(text) ||
isHistoryPageText(text) ||
isPaymentInputPage(text)
) return null
val eventId = flowSessionId?.let { "a:" + packageName + ":" + it }
?: "a:" + windowId + ":" + sha256(normalize(text)) + ":" + (eventTime / 10_000L)
val status = detectStatus(text, expectedType)
val parsed = if (status.strength != PaymentStatusStrength.WEAK) {
parse(
packageName = packageName,
channel = "accessibility",
text = text,
occurredAt = System.currentTimeMillis(),
sourceEventId = eventId,
flowSessionId = flowSessionId,
evidenceConfidence = "confirm",
)
} else {
null
}
if (parsed != null) {
val uniqueResultAmount = uniqueAmountCents(text)
val standaloneRedPacketIncome = parsed.recognitionKind in setOf(
"red_packet_receive",
"red_packet_refund",
)
val expectedMatches = expectedAmountCents == null ||
uniqueResultAmount == expectedAmountCents
val evidenceHigh = when {
standaloneRedPacketIncome -> uniqueResultAmount == parsed.amountCents
submittedFlow -> trustedFlow && resultTransitionObserved &&
uniqueResultAmount == parsed.amountCents && expectedMatches
else -> false
}
return parsed.copy(
evidenceConfidence = if (evidenceHigh) "high" else "confirm",
)
}
val redPacketSentSurface = flowKind == "red_packet_send" &&
hasRedPacketSentSurface(text)
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) return null
val direction = status.direction ?: if (redPacketSentSurface) "expense" else return null
val resultAmount = uniqueAmountCents(text)
val canUseExpectedAmount = resultAmount == null &&
expectedAmountCents != null &&
submittedFlow &&
resultTransitionObserved &&
expectedType == direction
val amountCents = resultAmount ?: expectedAmountCents?.takeIf { canUseExpectedAmount }
?: return null
val expectedMatched = expectedAmountCents != null && amountCents == expectedAmountCents
val evidenceHigh = trustedFlow && submittedFlow && resultTransitionObserved &&
expectedMatched && expectedType == direction
val kind = flowKind ?: recognitionKind(text, direction)
val merchant = extractMerchant(text)
val orderId = extractOrderId(text)
return PaymentSignal(
packageName = packageName,
channel = "accessibility",
amountCents = amountCents,
type = direction,
merchant = merchant,
orderId = orderId,
occurredAtEpochMs = System.currentTimeMillis(),
knownTemplate = true,
sourceEventId = eventId,
sourceText = buildSummary(packageName, merchant, orderId, kind),
flowSessionId = flowSessionId,
evidenceConfidence = if (evidenceHigh) "high" else "confirm",
recognitionKind = kind,
categoryHint = categoryHint(kind, direction),
amountSource = if (resultAmount == null) "expected" else "result",
resultFingerprint = resultFingerprint(
packageName,
kind,
direction,
amountCents,
merchant,
orderId,
sha256(normalize(text)),
),
)
}
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
val extras = notification.notification.extras
val parts = listOfNotNull(
extras.getCharSequence(Notification.EXTRA_TITLE)?.toString(),
extras.getCharSequence(Notification.EXTRA_TEXT)?.toString(),
extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString(),
extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString(),
).filter { it.isNotBlank() }
if (parts.isEmpty()) return null
return parse(
packageName = notification.packageName,
channel = "notification",
text = parts.distinct().joinToString("\n"),
occurredAt = validEpoch(notification.postTime),
sourceEventId = "n:" + notification.key,
flowSessionId = null,
evidenceConfidence = "confirm",
)
}
fun mayBePaymentPage(text: String): Boolean {
val normalized = normalize(text)
return !isHistoryPage(normalized) &&
!containsBlockedStatus(normalized) &&
detectDirection(normalized) != null
}
fun detectDirection(value: String): String? = detectStatus(value).direction
fun detectStatus(
value: String,
fallbackDirection: String? = null,
): PaymentStatusEvidence {
paymentLines(normalize(value)).firstNotNullOfOrNull { line ->
val compact = compactStatusLine(line)
when {
compact.length > MAX_SUCCESS_HEADING_CHARS -> null
redPacketRefundSuccessWords.any { compactStatusLine(it) in compact } -> "income"
redPacketReceiveSuccessWords.any { compactStatusLine(it) in compact } -> "income"
redPacketSendSuccessWords.any { compactStatusLine(it) in compact } -> "expense"
refundWords.any(compact::contains) -> "income"
incomeWords.any(compact::contains) -> "income"
expenseWords.any(compact::contains) -> "expense"
else -> null
}
}?.let { return PaymentStatusEvidence(PaymentStatusStrength.STRONG, it) }
val weak = paymentLines(normalize(value))
.map(::compactStatusLine)
.any(weakCompletionWords::contains)
return if (weak) {
PaymentStatusEvidence(PaymentStatusStrength.WEAK, fallbackDirection)
} else {
PaymentStatusEvidence(PaymentStatusStrength.NONE, null)
}
}
fun containsBlockedStatus(value: String): Boolean {
val normalized = normalize(value)
return blockedWords.any(normalized::contains)
}
fun isHistoryPageText(value: String): Boolean = isHistoryPage(normalize(value))
fun hasPaymentContext(value: String): Boolean {
val normalized = normalize(value)
return paymentContextWords.any(normalized::contains) ||
redPacketSendContextWords.any(normalized::contains) ||
amountPatterns.any { it.containsMatchIn(normalized) }
}
fun hasPaymentAction(value: String): Boolean {
val normalized = normalize(value)
return paymentActionWords.any(normalized::contains) ||
redPacketSendActionWords.any(normalized::contains)
}
fun inferContextDirection(value: String): String? {
val normalized = normalize(value)
if (redPacketSendContextWords.any(normalized::contains)) return "expense"
val expense = expenseContextWords.any(normalized::contains)
val income = incomeContextWords.any(normalized::contains)
return when {
expense && !income -> "expense"
income && !expense -> "income"
else -> null
}
}
fun uniqueAmountCents(value: String): Long? {
val normalized = normalize(value)
val cents = buildList {
amountPatterns.forEach { pattern ->
pattern.findAll(normalized).forEach { match ->
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let { amount ->
if (amount.isFinite() && amount > 0 && amount <= 100_000_000) {
add((amount * 100).roundToLong())
}
}
}
}
paymentLines(normalized).forEach { line ->
parseAmountCandidate(line)?.let { amount ->
if (amount.isFinite() && amount > 0 && amount <= 100_000_000) {
add((amount * 100).roundToLong())
}
}
}
}.distinct()
return cents.singleOrNull()
}
fun isPaymentInputPage(value: String): Boolean {
val normalized = normalize(value)
return paymentInputWords.any(normalized::contains) ||
redPacketSendActionWords.any(normalized::contains)
}
fun detectFlowKind(value: String): String? {
val normalized = normalize(value)
return when {
redPacketSendContextWords.any(normalized::contains) -> "red_packet_send"
"转账" in normalized -> "transfer"
paymentContextWords.any(normalized::contains) -> "payment"
else -> null
}
}
fun recognitionKind(value: String, type: String): String {
val normalized = normalize(value)
return when {
redPacketRefundSuccessWords.any(normalized::contains) -> "red_packet_refund"
redPacketReceiveSuccessWords.any(normalized::contains) -> "red_packet_receive"
redPacketSendSuccessWords.any(normalized::contains) -> "red_packet_send"
"转账" in normalized -> "transfer"
else -> "payment"
}
}
fun categoryHint(kind: String, type: String): String? = when {
kind == "red_packet_send" && type == "expense" -> "人情"
kind in setOf("red_packet_receive", "red_packet_refund") && type == "income" -> "红包"
else -> null
}
fun hasRedPacketSentSurface(value: String): Boolean {
val normalized = compactStatusLine(normalize(value))
return redPacketSentSurfaceWords.any { compactStatusLine(it) in normalized }
}
fun isPendingRedPacketSurface(value: String): Boolean {
val normalized = normalize(value)
return redPacketPendingWords.any(normalized::contains) &&
redPacketReceiveSuccessWords.none(normalized::contains) &&
redPacketRefundSuccessWords.none(normalized::contains)
}
fun isResultSurface(value: String, flowKind: String? = null): Boolean =
detectStatus(value).strength != PaymentStatusStrength.NONE ||
(flowKind == "red_packet_send" && hasRedPacketSentSurface(value))
fun resultFingerprint(
packageName: String,
kind: String,
type: String,
amountCents: Long,
merchant: String?,
orderId: String?,
pageSignature: String? = null,
): String = sha256(
listOf(
packageName,
kind,
type,
amountCents.toString(),
merchant?.trim()?.lowercase().orEmpty(),
orderId.orEmpty(),
pageSignature.orEmpty(),
).joinToString("|"),
)
fun parseAmountCandidate(value: String): Double? {
val normalized = normalize(value)
amountPatterns.firstNotNullOfOrNull { pattern ->
pattern.find(normalized)?.groupValues?.getOrNull(1)?.toDoubleOrNull()
}?.let { return it }
val match = STANDALONE_AMOUNT.matchEntire(normalized) ?: return null
val number = match.groupValues[2]
val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() ||
match.groupValues[3].isNotEmpty()
return if (!hasCurrencyOrUnit && !number.contains('.')) null else number.toDoubleOrNull()
}
fun extractOrderId(value: String): String? =
orderPattern.find(normalize(value))?.groupValues?.getOrNull(1)
fun extractMerchant(value: String): String? =
merchantPattern.find(normalize(value))?.groupValues?.getOrNull(1)?.trim()
fun paymentFingerprint(signal: PaymentSignal): String = sha256(
listOf(
signal.packageName,
signal.type,
signal.amountCents.toString(),
signal.merchant?.trim()?.lowercase().orEmpty(),
signal.orderId.orEmpty(),
signal.flowSessionId.orEmpty(),
).joinToString("|"),
)
fun appName(packageName: String): String = when (packageName) {
WECHAT -> "微信"
ALIPAY -> "支付宝"
else -> "支付应用"
}
private fun parse(
packageName: String,
channel: String,
text: String,
occurredAt: Long,
sourceEventId: String,
flowSessionId: String?,
evidenceConfidence: String,
): PaymentSignal? {
if (packageName !in supportedPackages) return null
val normalized = normalize(text)
if (containsBlockedStatus(normalized) || isHistoryPage(normalized)) return null
val type = detectDirection(normalized) ?: return null
val amount = extractAmount(normalized) ?: return null
if (!amount.isFinite() || amount <= 0 || amount > 100_000_000) return null
val orderId = extractOrderId(normalized)
val merchant = extractMerchant(normalized)
val kind = recognitionKind(normalized, type)
return PaymentSignal(
packageName = packageName,
channel = channel,
amountCents = (amount * 100).roundToLong(),
type = type,
merchant = merchant,
orderId = orderId,
occurredAtEpochMs = occurredAt,
knownTemplate = true,
sourceEventId = sourceEventId,
sourceText = buildSummary(packageName, merchant, orderId, kind),
flowSessionId = flowSessionId,
evidenceConfidence = evidenceConfidence,
recognitionKind = kind,
categoryHint = categoryHint(kind, type),
amountSource = "result",
resultFingerprint = resultFingerprint(
packageName,
kind,
type,
(amount * 100).roundToLong(),
merchant,
orderId,
sha256(normalized),
),
)
}
private fun buildSummary(
packageName: String,
merchant: String?,
orderId: String?,
kind: String,
): String = buildString {
append(appName(packageName))
when (kind) {
"red_packet_send" -> append(" · 发红包")
"red_packet_receive" -> append(" · 红包到账")
"red_packet_refund" -> append(" · 红包退回")
}
merchant?.let { append(" · ").append(it.take(32)) }
orderId?.let { append(" · 单号尾号 ").append(it.takeLast(6)) }
}
fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
private fun extractAmount(value: String): Double? {
amountPatterns.firstNotNullOfOrNull { pattern ->
pattern.find(value)?.groupValues?.getOrNull(1)?.toDoubleOrNull()
}?.let { return it }
return paymentLines(value).firstNotNullOfOrNull(::parseAmountCandidate)
}
private fun paymentLines(value: String): List<String> = value
.lineSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.take(MAX_PAYMENT_SCAN_LINES)
.toList()
private fun isHistoryPage(value: String): Boolean = paymentLines(value)
.take(MAX_HISTORY_TITLE_LINES)
.any(historyTitles::contains)
private fun validEpoch(value: Long): Long {
val now = System.currentTimeMillis()
return value.takeIf { it in (now - MAX_EVENT_AGE_MS)..(now + MAX_FUTURE_MS) } ?: now
}
private fun normalize(value: String): String = value
.replace(' ', ' ')
.replace(Regex("""[ ]+"""), " ")
.replace(Regex("""\n{2,}"""), "\n")
.trim()
private fun compactStatusLine(value: String): String = value.replace(STATUS_DECORATION, "")
private const val MAX_EVENT_AGE_MS = 24L * 60L * 60L * 1000L
private const val MAX_FUTURE_MS = 5L * 60L * 1000L
private val STATUS_DECORATION = Regex("""[\s:,。!!·]""")
private val STANDALONE_AMOUNT = Regex(
"""^\s*([¥¥]?)\s*([0-9]{1,8}(?:\.[0-9]{1,2})?)\s*(元?)\s*$""",
)
private const val MAX_SUCCESS_HEADING_CHARS = 48
private const val MAX_PAYMENT_SCAN_LINES = 48
private const val MAX_HISTORY_TITLE_LINES = 3
}
@@ -0,0 +1,108 @@
package com.nx.miaoji
import android.app.Activity
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import androidx.core.content.ContextCompat
import java.util.UUID
class ProjectionConsentActivity : Activity() {
private var permissionRequested = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.getBooleanExtra(EXTRA_ACCESSIBILITY_CAPTURE, false)) {
val accepted = RecognitionBridge.call(
this,
RecognitionBridgeProvider.METHOD_REQUEST_SCREENSHOT,
extras = Bundle().apply {
putString("requestId", UUID.randomUUID().toString())
putBoolean("showResult", true)
putBoolean("preferExternalSource", true)
putLong("delayMs", SOURCE_APP_RESTORE_DELAY_MS)
},
)
?.getBoolean("accepted") == true
finish()
if (!accepted) {
reportError("无障碍服务未连接,本次请重新点击磁贴并授权截屏")
}
return
}
permissionRequested = savedInstanceState?.getBoolean(STATE_PERMISSION_REQUESTED)
?: false
if (!permissionRequested) {
permissionRequested = true
requestProjectionPermission()
}
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(STATE_PERMISSION_REQUESTED, permissionRequested)
super.onSaveInstanceState(outState)
}
@Suppress("DEPRECATION")
private fun requestProjectionPermission() {
try {
val manager = getSystemService(MediaProjectionManager::class.java)
startActivityForResult(
manager.createScreenCaptureIntent(),
REQUEST_MEDIA_PROJECTION,
)
} catch (e: Exception) {
Log.e(TAG, "Unable to request projection permission", e)
finish()
reportError("无法申请系统截屏授权,请稍后重试")
}
}
@Deprecated("Deprecated in Android, retained for broad device compatibility")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != REQUEST_MEDIA_PROJECTION) return
if (resultCode != RESULT_OK || data == null) {
finish()
reportError("已取消截屏授权,本次没有截取任何内容")
return
}
val sessionId = ScreenshotResultDispatcher.newSessionId()
val serviceIntent = Intent(this, OneShotProjectionService::class.java).apply {
putExtra(OneShotProjectionService.EXTRA_RESULT_CODE, resultCode)
putExtra(OneShotProjectionService.EXTRA_RESULT_DATA, data)
putExtra(OneShotProjectionService.EXTRA_SESSION_ID, sessionId)
}
try {
ContextCompat.startForegroundService(this, serviceIntent)
moveTaskToBack(true)
finish()
} catch (e: Exception) {
Log.e(TAG, "Unable to start one-shot projection", e)
finish()
reportError("系统未能启动一次性截屏,请重试")
}
}
private fun reportError(message: String) {
if (intent.getBooleanExtra(EXTRA_RETURN_ERROR_TO_APP, false)) {
startActivity(ScreenshotResultDispatcher.errorIntent(this, message))
} else {
ScreenshotResultDispatcher.notifyFailure(this, message)
}
}
companion object {
const val EXTRA_ACCESSIBILITY_CAPTURE = "accessibilityCapture"
const val EXTRA_RETURN_ERROR_TO_APP = "returnErrorToApp"
private const val TAG = "MiaoJiProjection"
private const val REQUEST_MEDIA_PROJECTION = 2001
private const val STATE_PERMISSION_REQUESTED = "permissionRequested"
private const val SOURCE_APP_RESTORE_DELAY_MS = 500L
}
}
@@ -0,0 +1,146 @@
package com.nx.miaoji
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.os.ParcelFileDescriptor
import java.util.concurrent.ConcurrentHashMap
class RecognitionBridgeProvider : ContentProvider() {
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
override fun onCreate(): Boolean {
context?.let(RecognitionCoordinator::get)
return true
}
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
val appContext = requireNotNull(context).applicationContext
return when (method) {
METHOD_STATUS -> Bundle().apply {
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
putString("settings", RecognitionSettings.statusJson(appContext))
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext))
}
METHOD_SET_TOGGLE -> Bundle().apply {
putBoolean(
"success",
RecognitionSettings.setToggle(
appContext,
extras?.getString("key").orEmpty(),
extras?.getBoolean("enabled") ?: false,
),
)
}
METHOD_CLEAR_DIAGNOSTIC -> Bundle().apply {
RecognitionDiagnostics.clear(appContext)
putBoolean("success", true)
}
METHOD_SET_RUNTIME -> Bundle().apply {
RecognitionSettings.setRuntime(
appContext,
aiAllowed = extras?.getBoolean("aiAllowed") ?: false,
hasAccount = extras?.getBoolean("hasAccount") ?: false,
baseUrl = extras?.getString("baseUrl"),
token = extras?.getString("token"),
)
putBoolean("success", true)
}
METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras)
METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg)
METHOD_DRAIN -> Bundle().apply {
putStringArrayList(
"candidates",
ArrayList(
RecognitionCoordinator.get(appContext)
.drainReady()
.map(StoredCandidate::json),
),
)
}
METHOD_ACK -> Bundle().apply {
val candidate = RecognitionCoordinator.get(appContext).acknowledge(
extras?.getString("id").orEmpty(),
extras?.getString("state").orEmpty(),
extras?.getLong("transactionId")?.takeIf {
extras.containsKey("transactionId")
},
)
putBoolean("success", candidate != null)
}
else -> super.call(method, arg, extras) ?: Bundle()
}
}
private fun requestScreenshot(extras: Bundle?): Bundle {
val requestId = extras?.getString("requestId").orEmpty()
if (requestId.isBlank()) return Bundle().apply { putBoolean("accepted", false) }
val showResult = extras?.getBoolean("showResult") ?: false
val accepted = ScreenshotAccessibilityService.requestScreenshot(
showResult = showResult,
delayMs = extras?.getLong("delayMs") ?: 0L,
preferExternalSource = extras?.getBoolean("preferExternalSource") ?: false,
) { result ->
captureResults[requestId] = result.fold(
onSuccess = { CaptureResult(path = it, error = null) },
onFailure = { CaptureResult(path = null, error = it.message ?: "截屏失败,请重试") },
)
}
return Bundle().apply { putBoolean("accepted", accepted) }
}
private fun takeScreenshotResult(requestId: String?): Bundle {
val result = requestId?.let(captureResults::remove)
return Bundle().apply {
putBoolean("ready", result != null)
result?.path?.let { putString("path", it) }
result?.error?.let { putString("error", it) }
}
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
private data class CaptureResult(val path: String?, val error: String?)
companion object {
const val METHOD_STATUS = "status"
const val METHOD_SET_TOGGLE = "setToggle"
const val METHOD_SET_RUNTIME = "setRuntime"
const val METHOD_CLEAR_DIAGNOSTIC = "clearDiagnostic"
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
const val METHOD_DRAIN = "drain"
const val METHOD_ACK = "ack"
}
}
object RecognitionBridge {
fun call(context: Context, method: String, arg: String? = null, extras: Bundle? = null): Bundle? =
context.contentResolver.call(
Uri.parse("content://${context.packageName}.recognition.bridge"),
method,
arg,
extras,
)
}
@@ -0,0 +1,60 @@
package com.nx.miaoji
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
class RecognitionCoordinator private constructor(private val context: Context) {
private val store = RecognitionStore(context)
private val thread = HandlerThread("jizhi-recognition").apply { start() }
private val handler = Handler(thread.looper)
fun submit(signal: PaymentSignal) {
handler.post {
runCatching {
val id = store.upsert(signal)
handler.postDelayed({ finalize(id) }, 1_650L)
}.onFailure { Log.e(TAG, "Unable to store recognition signal", it) }
}
}
fun drainReady(): List<StoredCandidate> = store.ready()
fun acknowledge(id: String, state: String, transactionId: Long?): StoredCandidate? {
val candidate = store.acknowledge(id, state, transactionId)
if (candidate != null && state == "imported") {
RecognitionNotifier.showImported(context, candidate)
}
return candidate
}
fun latestStatus(): String? = store.latestStatus()
private fun finalize(id: String) {
val candidate = store.finalizeCandidate(id) ?: return
context.sendBroadcast(
Intent(ACTION_READY)
.setPackage(context.packageName)
.putExtra(EXTRA_CANDIDATE_ID, id),
)
RecognitionNotifier.showReady(context, candidate)
}
companion object {
const val ACTION_READY = "com.nx.miaoji.RECOGNITION_READY"
const val EXTRA_CANDIDATE_ID = "candidateId"
private const val TAG = "JizhiRecognition"
@Volatile
private var instance: RecognitionCoordinator? = null
fun get(context: Context): RecognitionCoordinator =
instance ?: synchronized(this) {
instance ?: RecognitionCoordinator(context.applicationContext).also {
instance = it
}
}
}
}
@@ -0,0 +1,109 @@
package com.nx.miaoji
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
object RecognitionDiagnostics {
private const val PREFS = "recognition_diagnostics"
private const val KEY_LATEST = "latest"
private const val STALE_OPERATION_MS = 20_000L
@Synchronized
fun record(
context: Context,
packageName: String?,
stage: String,
result: String,
nodeCount: Int = 0,
ocrMs: Long? = null,
amountCandidates: Int? = null,
reason: String? = null,
statusStrength: String? = null,
expectedAmountMatched: Boolean? = null,
resultTransitionObserved: Boolean? = null,
recognitionKind: String? = null,
amountSource: String? = null,
resultFingerprint: String? = null,
ocrPreview: List<String> = emptyList(),
previewExpiresAt: Long? = null,
) {
val payload = JSONObject()
.put("at", System.currentTimeMillis())
.put("appName", packageName?.let(PaymentParser::appName) ?: "支付应用")
.put("stage", stage)
.put("result", result)
.put("nodeCount", nodeCount)
ocrMs?.let { payload.put("ocrMs", it) }
amountCandidates?.let { payload.put("amountCandidates", it) }
reason?.take(80)?.let { payload.put("reason", it) }
statusStrength?.let { payload.put("statusStrength", it) }
expectedAmountMatched?.let { payload.put("expectedAmountMatched", it) }
resultTransitionObserved?.let { payload.put("resultTransitionObserved", it) }
recognitionKind?.let { payload.put("recognitionKind", it) }
amountSource?.let { payload.put("amountSource", it) }
resultFingerprint?.let { payload.put("resultFingerprint", it.take(16)) }
if (ocrPreview.isNotEmpty() && previewExpiresAt != null &&
previewExpiresAt > System.currentTimeMillis()
) {
payload.put("ocrPreview", JSONArray(ocrPreview.take(12)))
payload.put("previewExpiresAt", previewExpiresAt)
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY_LATEST, payload.toString())
.apply()
}
@Synchronized
fun latest(context: Context): String? {
val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = preferences.getString(KEY_LATEST, null) ?: return null
val normalized = runCatching {
val payload = JSONObject(raw)
val now = System.currentTimeMillis()
var changed = false
val startedAt = payload.optLong("at")
if (payload.optString("result") == "started" &&
now - startedAt > STALE_OPERATION_MS
) {
payload
.put("result", "failed")
.put("reason", "operation_interrupted")
changed = true
}
val previewExpiresAt = payload.optLong("previewExpiresAt")
if (previewExpiresAt > 0L && previewExpiresAt <= now) {
payload.remove("ocrPreview")
payload.remove("previewExpiresAt")
changed = true
}
if (changed) payload.toString() else raw
}.getOrDefault(raw)
if (normalized != raw) {
preferences.edit().putString(KEY_LATEST, normalized).apply()
}
return normalized
}
@Synchronized
fun clearPreview(context: Context) {
val preferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = preferences.getString(KEY_LATEST, null) ?: return
val updated = runCatching {
JSONObject(raw)
.apply {
remove("ocrPreview")
remove("previewExpiresAt")
}
.toString()
}.getOrNull() ?: return
preferences.edit().putString(KEY_LATEST, updated).apply()
}
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.remove(KEY_LATEST)
.apply()
}
}
@@ -0,0 +1,108 @@
package com.nx.miaoji
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import org.json.JSONObject
import kotlin.math.absoluteValue
object RecognitionNotifier {
private const val CHANNEL_ID = "smart_recognition"
fun showReady(context: Context, candidate: StoredCandidate) {
val payload = JSONObject(candidate.json)
val auto = payload.optString("confidence") == "auto"
val title = if (auto) "识别到一笔账单" else "发现可能的账单"
val text = "${payload.optString("appName")} · ¥${String.format("%.2f", payload.optDouble("amount"))}"
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(MainActivity.EXTRA_ACTION, MainActivity.ACTION_RECOGNITION_CONFIRM)
putExtra(RecognitionCoordinator.EXTRA_CANDIDATE_ID, candidate.id)
}
notify(
context,
candidate.id.hashCode(),
Notification.Builder(context, ensureChannel(context))
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(pendingActivity(context, candidate.id.hashCode(), intent))
.setCategory(Notification.CATEGORY_STATUS)
.build(),
)
}
fun showImported(context: Context, candidate: StoredCandidate) {
val payload = JSONObject(candidate.json)
val text = "已记录${if (payload.optString("type") == "income") "收入" else "支出"} ¥${
String.format("%.2f", payload.optDouble("amount"))
}"
val openIntent = actionIntent(context, candidate, MainActivity.ACTION_RECOGNITION_EDIT)
val undoIntent = actionIntent(context, candidate, MainActivity.ACTION_RECOGNITION_UNDO)
val notification = Notification.Builder(context, ensureChannel(context))
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("智能识别已入账")
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(pendingActivity(context, candidate.id.hashCode(), openIntent))
.addAction(
Notification.Action.Builder(
null,
"撤销",
pendingActivity(context, candidate.id.hashCode() xor 0x3301, undoIntent),
).build(),
)
.addAction(
Notification.Action.Builder(
null,
"编辑",
pendingActivity(context, candidate.id.hashCode() xor 0x3302, openIntent),
).build(),
)
.build()
notify(context, candidate.id.hashCode(), notification)
}
private fun actionIntent(context: Context, candidate: StoredCandidate, action: String) =
Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(MainActivity.EXTRA_ACTION, action)
putExtra(RecognitionCoordinator.EXTRA_CANDIDATE_ID, candidate.id)
candidate.transactionId?.let { putExtra(MainActivity.EXTRA_TRANSACTION_ID, it) }
}
private fun pendingActivity(context: Context, requestCode: Int, intent: Intent): PendingIntent =
PendingIntent.getActivity(
context,
requestCode.absoluteValue,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun ensureChannel(context: Context): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"智能识别记账",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "微信、支付宝账单识别结果与确认提醒"
},
)
}
return CHANNEL_ID
}
private fun notify(context: Context, id: Int, notification: Notification) {
runCatching {
context.getSystemService(NotificationManager::class.java)
.notify(id.absoluteValue, notification)
}
}
}
@@ -0,0 +1,115 @@
package com.nx.miaoji
import android.content.Context
import org.json.JSONObject
object RecognitionSettings {
const val KEY_ACCESSIBILITY_EVENTS = "accessibility_events"
const val KEY_NOTIFICATION_EVENTS = "notification_events"
const val KEY_AI_SCREENSHOT = "ai_screenshot"
const val KEY_OCR_DIAGNOSTIC_PREVIEW = "ocr_diagnostic_preview"
data class Snapshot(
val accessibilityEvents: Boolean,
val notificationEvents: Boolean,
val aiScreenshot: Boolean,
val ocrDiagnosticPreview: Boolean,
val ocrDiagnosticPreviewExpiresAt: Long?,
val aiAllowed: Boolean,
val hasAccount: Boolean,
val baseUrl: String?,
)
private const val PREFS = "recognition_settings"
private const val KEY_AI_ALLOWED = "runtime_ai_allowed"
private const val KEY_HAS_ACCOUNT = "runtime_has_account"
private const val KEY_BASE_URL = "runtime_base_url"
private const val KEY_TOKEN = "runtime_token"
private const val KEY_OCR_DIAGNOSTIC_PREVIEW_UNTIL = "ocr_diagnostic_preview_until"
private const val OCR_DIAGNOSTIC_PREVIEW_MS = 10L * 60L * 1000L
fun snapshot(context: Context): Snapshot {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val previewExpiresAt = prefs.getLong(KEY_OCR_DIAGNOSTIC_PREVIEW_UNTIL, 0L)
.takeIf { it > System.currentTimeMillis() }
return Snapshot(
accessibilityEvents = prefs.getBoolean(KEY_ACCESSIBILITY_EVENTS, false),
notificationEvents = prefs.getBoolean(KEY_NOTIFICATION_EVENTS, false),
aiScreenshot = prefs.getBoolean(KEY_AI_SCREENSHOT, false),
ocrDiagnosticPreview = previewExpiresAt != null,
ocrDiagnosticPreviewExpiresAt = previewExpiresAt,
aiAllowed = prefs.getBoolean(KEY_AI_ALLOWED, false),
hasAccount = prefs.getBoolean(KEY_HAS_ACCOUNT, false),
baseUrl = prefs.getString(KEY_BASE_URL, null),
)
}
fun setToggle(context: Context, key: String, enabled: Boolean): Boolean {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (key == KEY_OCR_DIAGNOSTIC_PREVIEW) {
val editor = prefs.edit()
if (enabled) {
editor.putLong(
KEY_OCR_DIAGNOSTIC_PREVIEW_UNTIL,
System.currentTimeMillis() + OCR_DIAGNOSTIC_PREVIEW_MS,
)
} else {
editor.remove(KEY_OCR_DIAGNOSTIC_PREVIEW_UNTIL)
}
editor.apply()
if (!enabled) RecognitionDiagnostics.clearPreview(context)
return true
}
if (key !in setOf(KEY_ACCESSIBILITY_EVENTS, KEY_NOTIFICATION_EVENTS, KEY_AI_SCREENSHOT)) {
return false
}
prefs.edit().putBoolean(key, enabled).apply()
return true
}
fun setRuntime(
context: Context,
aiAllowed: Boolean,
hasAccount: Boolean,
baseUrl: String?,
token: String?,
) {
val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_AI_ALLOWED, aiAllowed)
.putBoolean(KEY_HAS_ACCOUNT, hasAccount)
if (baseUrl.isNullOrBlank()) editor.remove(KEY_BASE_URL) else editor.putString(KEY_BASE_URL, baseUrl)
if (token.isNullOrBlank()) {
editor.remove(KEY_TOKEN)
} else {
val encrypted = NativeCrypto.encrypt(token.toByteArray(Charsets.UTF_8))
if (encrypted != null) editor.putString(KEY_TOKEN, encrypted)
}
editor.apply()
}
fun disableRuntimeAi(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_AI_ALLOWED, false)
.apply()
}
fun runtimeToken(context: Context): String? {
val encoded = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_TOKEN, null) ?: return null
return NativeCrypto.decrypt(encoded)?.toString(Charsets.UTF_8)
}
fun statusJson(context: Context): String {
val snapshot = snapshot(context)
return JSONObject()
.put("accessibilityEvents", snapshot.accessibilityEvents)
.put("notificationEvents", snapshot.notificationEvents)
.put("aiScreenshot", snapshot.aiScreenshot)
.put("ocrDiagnosticPreview", snapshot.ocrDiagnosticPreview)
.put("ocrDiagnosticPreviewExpiresAt", snapshot.ocrDiagnosticPreviewExpiresAt)
.put("aiAllowed", snapshot.aiAllowed)
.put("hasAccount", snapshot.hasAccount)
.toString()
}
}
@@ -0,0 +1,428 @@
package com.nx.miaoji
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import org.json.JSONObject
import java.util.UUID
import kotlin.math.abs
data class StoredCandidate(
val id: String,
val clientRequestId: String,
val state: String,
val transactionId: Long?,
val json: String,
)
class RecognitionStore(context: Context) :
SQLiteOpenHelper(context, "recognition_queue.db", null, 2) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE candidates (
id TEXT PRIMARY KEY,
client_request_id TEXT NOT NULL UNIQUE,
package_name TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
direction TEXT NOT NULL,
merchant_hash TEXT NOT NULL,
strong_key TEXT,
channel_mask INTEGER NOT NULL,
known_template INTEGER NOT NULL,
occurred_at INTEGER NOT NULL,
payload_encrypted TEXT NOT NULL,
state TEXT NOT NULL,
high_confidence INTEGER NOT NULL,
available_at INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
transaction_id INTEGER
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE evidence (
source_hash TEXT PRIMARY KEY,
candidate_id TEXT NOT NULL,
channel INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"CREATE INDEX ix_recognition_merge ON candidates " +
"(package_name, amount_cents, direction, merchant_hash, occurred_at)",
)
db.execSQL(
"CREATE UNIQUE INDEX ux_recognition_strong ON candidates(strong_key) " +
"WHERE strong_key IS NOT NULL",
)
db.execSQL(
"CREATE INDEX ix_recognition_state ON candidates(state, available_at)",
)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) {
db.execSQL(
"CREATE INDEX IF NOT EXISTS ix_recognition_state " +
"ON candidates(state, available_at)",
)
}
}
@Synchronized
fun upsert(signal: PaymentSignal): String {
val now = System.currentTimeMillis()
val channelBit = channelBit(signal.channel)
val sourceHash = PaymentParser.sha256(signal.sourceEventId)
writableDatabase.beginTransaction()
try {
writableDatabase.rawQuery(
"SELECT candidate_id FROM evidence WHERE source_hash = ?",
arrayOf(sourceHash),
).use { cursor ->
if (cursor.moveToFirst()) {
writableDatabase.setTransactionSuccessful()
return cursor.getString(0)
}
}
val merchantHash = PaymentParser.sha256(
signal.merchant?.trim()?.lowercase().orEmpty(),
)
val orderStrongKey = signal.orderId?.takeIf { it.isNotBlank() }?.let {
PaymentParser.sha256("${signal.packageName}|$it")
}
val flowStrongKey = signal.flowSessionId?.takeIf { it.isNotBlank() }?.let {
PaymentParser.sha256("${signal.packageName}|flow|$it")
}
val strongKey = orderStrongKey ?: flowStrongKey
val signalHigh = signal.channel in setOf("accessibility", "local_ocr") &&
signal.evidenceConfidence == "high"
val existing = findMergeCandidate(signal, channelBit, merchantHash, strongKey, now)
val id: String
if (existing != null) {
id = existing.id
val mergedMask = existing.channelMask or channelBit
val hasNonAiEvidence = mergedMask and channelBit("recognition_ai").inv() != 0
val high = hasNonAiEvidence &&
(signalHigh ||
(mergedMask and ACCESSIBILITY_NOTIFICATION_MASK) ==
ACCESSIBILITY_NOTIFICATION_MASK ||
existing.highConfidence)
val mergedPayload = mergePayload(existing.payload, signal)
writableDatabase.update(
"candidates",
ContentValues().apply {
put("channel_mask", mergedMask)
put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0)
put("high_confidence", if (high) 1 else 0)
put("payload_encrypted", encryptPayload(mergedPayload))
put("updated_at", now)
put("available_at", now + MERGE_DELAY_MS)
},
"id = ?",
arrayOf(id),
)
} else {
id = UUID.randomUUID().toString()
val payload = signalToJson(signal)
val high = signalHigh
writableDatabase.insertOrThrow(
"candidates",
null,
ContentValues().apply {
put("id", id)
put("client_request_id", "recognition-$id")
put("package_name", signal.packageName)
put("amount_cents", signal.amountCents)
put("direction", signal.type)
put("merchant_hash", merchantHash)
put("strong_key", strongKey)
put("channel_mask", channelBit)
put("known_template", if (signal.knownTemplate) 1 else 0)
put("occurred_at", signal.occurredAtEpochMs)
put("payload_encrypted", encryptPayload(payload))
put("state", "pending_merge")
put("high_confidence", if (high) 1 else 0)
put("available_at", now + MERGE_DELAY_MS)
put("first_seen", now)
put("updated_at", now)
},
)
}
writableDatabase.insertOrThrow(
"evidence",
null,
ContentValues().apply {
put("source_hash", sourceHash)
put("candidate_id", id)
put("channel", channelBit)
put("created_at", now)
},
)
writableDatabase.setTransactionSuccessful()
return id
} finally {
writableDatabase.endTransaction()
}
}
@Synchronized
fun finalizeCandidate(id: String): StoredCandidate? {
val now = System.currentTimeMillis()
writableDatabase.beginTransaction()
try {
writableDatabase.execSQL(
"""
UPDATE candidates
SET state = CASE WHEN high_confidence = 1
THEN 'auto_ready' ELSE 'pending_confirm' END,
updated_at = ?
WHERE id = ? AND state = 'pending_merge' AND available_at <= ?
""".trimIndent(),
arrayOf<Any>(now, id, now),
)
val result = loadById(id)
writableDatabase.setTransactionSuccessful()
return result?.takeIf { it.state == "auto_ready" || it.state == "pending_confirm" }
} finally {
writableDatabase.endTransaction()
}
}
@Synchronized
fun ready(): List<StoredCandidate> {
val now = System.currentTimeMillis()
writableDatabase.execSQL(
"""
UPDATE candidates
SET state = CASE WHEN high_confidence = 1
THEN 'auto_ready' ELSE 'pending_confirm' END,
updated_at = ?
WHERE state = 'pending_merge' AND available_at <= ?
""".trimIndent(),
arrayOf(now, now),
)
expireOld(now)
return readableDatabase.rawQuery(
"""
SELECT * FROM candidates
WHERE state IN ('auto_ready', 'pending_confirm')
ORDER BY occurred_at, first_seen
""".trimIndent(),
null,
).use { cursor ->
buildList {
while (cursor.moveToNext()) decode(cursor)?.let(::add)
}
}
}
@Synchronized
fun acknowledge(id: String, state: String, transactionId: Long?): StoredCandidate? {
if (state !in setOf("imported", "undone", "expired")) return null
writableDatabase.update(
"candidates",
ContentValues().apply {
put("state", state)
put("updated_at", System.currentTimeMillis())
if (transactionId == null) putNull("transaction_id") else put("transaction_id", transactionId)
},
"id = ?",
arrayOf(id),
)
return loadById(id)
}
@Synchronized
fun latestStatus(): String? = readableDatabase.rawQuery(
"SELECT state, updated_at FROM candidates ORDER BY updated_at DESC LIMIT 1",
null,
).use { cursor ->
if (!cursor.moveToFirst()) null
else JSONObject()
.put("state", cursor.getString(0))
.put("updatedAt", cursor.getLong(1))
.toString()
}
private fun findMergeCandidate(
signal: PaymentSignal,
channelBit: Int,
merchantHash: String,
strongKey: String?,
now: Long,
): CandidateRow? {
if (strongKey != null) {
readableDatabase.rawQuery(
"SELECT * FROM candidates WHERE strong_key = ? AND state NOT IN ('undone','expired') LIMIT 1",
arrayOf(strongKey),
).use { cursor ->
if (cursor.moveToFirst()) return row(cursor)
}
}
if (signal.channel == "local_ocr" && signal.flowSessionId != null) return null
val since = signal.occurredAtEpochMs - NO_ORDER_WINDOW_MS
val until = signal.occurredAtEpochMs + NO_ORDER_WINDOW_MS
readableDatabase.rawQuery(
"""
SELECT * FROM candidates
WHERE package_name = ? AND amount_cents = ? AND direction = ?
AND merchant_hash = ? AND occurred_at BETWEEN ? AND ?
AND state IN ('pending_merge','auto_ready','pending_confirm','imported')
ORDER BY ABS(occurred_at - ?) LIMIT 4
""".trimIndent(),
arrayOf(
signal.packageName,
signal.amountCents.toString(),
signal.type,
merchantHash,
since.toString(),
until.toString(),
signal.occurredAtEpochMs.toString(),
),
).use { cursor ->
while (cursor.moveToNext()) {
val candidate = row(cursor)
val hasSameChannel = candidate.channelMask and channelBit != 0
if (!hasSameChannel || now - candidate.updatedAt <= SAME_CHANNEL_DEBOUNCE_MS) {
return candidate
}
}
}
return null
}
private fun loadById(id: String): StoredCandidate? = readableDatabase.rawQuery(
"SELECT * FROM candidates WHERE id = ? LIMIT 1",
arrayOf(id),
).use { cursor -> if (cursor.moveToFirst()) decode(cursor) else null }
private fun decode(cursor: Cursor): StoredCandidate? {
val payload = decryptPayload(cursor.getString(cursor.getColumnIndexOrThrow("payload_encrypted")))
?: return null
payload
.put("id", cursor.getString(cursor.getColumnIndexOrThrow("id")))
.put("clientRequestId", cursor.getString(cursor.getColumnIndexOrThrow("client_request_id")))
.put("state", cursor.getString(cursor.getColumnIndexOrThrow("state")))
.put("confidence", if (cursor.getInt(cursor.getColumnIndexOrThrow("high_confidence")) == 1) "auto" else "confirm")
.put("source", sourceFromMask(cursor.getInt(cursor.getColumnIndexOrThrow("channel_mask"))))
val txIndex = cursor.getColumnIndexOrThrow("transaction_id")
val txId = if (cursor.isNull(txIndex)) null else cursor.getLong(txIndex)
if (txId != null) payload.put("transactionId", txId)
return StoredCandidate(
id = payload.getString("id"),
clientRequestId = payload.getString("clientRequestId"),
state = payload.getString("state"),
transactionId = txId,
json = payload.toString(),
)
}
private fun row(cursor: Cursor): CandidateRow {
val payload = decryptPayload(cursor.getString(cursor.getColumnIndexOrThrow("payload_encrypted")))
?: JSONObject()
return CandidateRow(
id = cursor.getString(cursor.getColumnIndexOrThrow("id")),
channelMask = cursor.getInt(cursor.getColumnIndexOrThrow("channel_mask")),
knownTemplate = cursor.getInt(cursor.getColumnIndexOrThrow("known_template")) == 1,
highConfidence = cursor.getInt(cursor.getColumnIndexOrThrow("high_confidence")) == 1,
updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")),
payload = payload,
)
}
private fun signalToJson(signal: PaymentSignal): JSONObject = JSONObject()
.put("packageName", signal.packageName)
.put("appName", PaymentParser.appName(signal.packageName))
.put("type", signal.type)
.put("amount", signal.amountCents / 100.0)
.put("merchant", signal.merchant)
.put("orderId", signal.orderId)
.put("occurredAtEpochMs", signal.occurredAtEpochMs)
.put("sourceText", signal.sourceText)
.put("flowSessionId", signal.flowSessionId)
.put("evidenceConfidence", signal.evidenceConfidence)
.put("recognitionKind", signal.recognitionKind)
.put("categoryHint", signal.categoryHint)
.put("amountSource", signal.amountSource)
.put("resultFingerprint", signal.resultFingerprint)
.put(
"note",
signal.merchant?.take(40) ?: when (signal.recognitionKind) {
"red_packet_send" -> "发红包"
"red_packet_receive" -> "红包到账"
"red_packet_refund" -> "红包退回"
else -> PaymentParser.appName(signal.packageName)
},
)
private fun mergePayload(existing: JSONObject, signal: PaymentSignal): JSONObject {
if (existing.isNull("merchant") && signal.merchant != null) existing.put("merchant", signal.merchant)
if (existing.isNull("orderId") && signal.orderId != null) existing.put("orderId", signal.orderId)
if (existing.isNull("sourceText") && signal.sourceText != null) existing.put("sourceText", signal.sourceText)
if (existing.optString("recognitionKind").isBlank()) existing.put("recognitionKind", signal.recognitionKind)
if (existing.isNull("categoryHint") && signal.categoryHint != null) existing.put("categoryHint", signal.categoryHint)
if (existing.optString("amountSource").isBlank()) existing.put("amountSource", signal.amountSource)
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
existing.put("resultFingerprint", signal.resultFingerprint)
}
if (existing.optString("note").isBlank() && signal.merchant != null) existing.put("note", signal.merchant.take(40))
existing.put("occurredAtEpochMs", minOf(existing.optLong("occurredAtEpochMs"), signal.occurredAtEpochMs))
return existing
}
private fun encryptPayload(payload: JSONObject): String =
NativeCrypto.encrypt(payload.toString().toByteArray(Charsets.UTF_8))
?: throw IllegalStateException("无法加密识别候选")
private fun decryptPayload(value: String): JSONObject? =
NativeCrypto.decrypt(value)?.toString(Charsets.UTF_8)?.let(::JSONObject)
private fun expireOld(now: Long) {
writableDatabase.execSQL(
"UPDATE candidates SET state = 'expired', updated_at = ? " +
"WHERE state NOT IN ('imported','undone','expired') AND first_seen < ?",
arrayOf(now, now - EXPIRE_MS),
)
writableDatabase.delete("evidence", "created_at < ?", arrayOf((now - EXPIRE_MS).toString()))
}
private fun channelBit(channel: String): Int = when (channel) {
"accessibility" -> 1
"notification" -> 2
"recognition_ai" -> 4
"local_ocr" -> 8
else -> 0
}
private fun sourceFromMask(mask: Int): String = when {
mask and 8 != 0 -> "local_ocr"
mask and 4 != 0 -> "recognition_ai"
mask and 1 != 0 -> "accessibility"
else -> "notification"
}
private data class CandidateRow(
val id: String,
val channelMask: Int,
val knownTemplate: Boolean,
val highConfidence: Boolean,
val updatedAt: Long,
val payload: JSONObject,
)
companion object {
private const val ACCESSIBILITY_NOTIFICATION_MASK = 3
private const val MERGE_DELAY_MS = 1_500L
private const val NO_ORDER_WINDOW_MS = 90_000L
private const val SAME_CHANNEL_DEBOUNCE_MS = 10_000L
private const val EXPIRE_MS = 7L * 24L * 60L * 60L * 1000L
}
}
@@ -0,0 +1,73 @@
package com.nx.miaoji
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import java.io.File
import java.util.UUID
object ScreenshotResultDispatcher {
fun newSessionId(): String = UUID.randomUUID().toString()
fun isValidImage(path: String): Boolean {
val file = File(path)
if (!file.isFile || file.length() < MIN_IMAGE_BYTES) return false
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(path, options)
return options.outWidth > 0 && options.outHeight > 0
}
fun openResult(
context: Context,
path: String,
sessionId: String,
): Boolean {
if (!isValidImage(path)) {
File(path).delete()
notifyFailure(context, "截屏图片无效,请重新截取")
return false
}
return try {
context.startActivity(Intent(context, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
putExtra(MainActivity.EXTRA_ACTION, MainActivity.ACTION_SCREENSHOT_RESULT)
putExtra(MainActivity.EXTRA_SCREENSHOT_PATH, path)
putExtra(MainActivity.EXTRA_SCREENSHOT_SESSION_ID, sessionId)
})
true
} catch (_: Exception) {
File(path).delete()
notifyFailure(context, "\u622A\u56FE\u5DF2\u5B8C\u6210\uFF0C\u4F46\u7CFB\u7EDF\u963B\u6B62\u6253\u5F00\u8BC6\u522B\u9875\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5")
false
}
}
fun notifyFailure(context: Context, message: String) {
Handler(Looper.getMainLooper()).post {
Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show()
}
}
fun errorIntent(context: Context, message: String): Intent {
return Intent(context, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP,
)
putExtra(MainActivity.EXTRA_ACTION, MainActivity.ACTION_SCREENSHOT_ERROR)
putExtra(MainActivity.EXTRA_SCREENSHOT_ERROR, message)
}
}
private const val MIN_IMAGE_BYTES = 1024L
}
@@ -0,0 +1,49 @@
package com.nx.miaoji
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.service.quicksettings.TileService
import android.util.Log
class ScreenshotTileService : TileService() {
override fun onClick() {
super.onClick()
val accessibilityCapture = ScreenshotAccessibilityService.isConnected
Log.i(TAG, "Tile clicked accessibilityConnected=$accessibilityCapture")
val intent = Intent(this, ProjectionConsentActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(
ProjectionConsentActivity.EXTRA_ACCESSIBILITY_CAPTURE,
accessibilityCapture,
)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pendingIntent = PendingIntent.getActivity(
this,
1001,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
} catch (e: Exception) {
Log.e(TAG, "Unable to launch screenshot flow", e)
try {
startActivity(intent)
} catch (fallback: Exception) {
Log.e(TAG, "Unable to launch screenshot fallback", fallback)
}
}
}
companion object {
private const val TAG = "MiaoJiScreenshot"
}
}
@@ -0,0 +1,234 @@
package com.nx.miaoji
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.security.MessageDigest
class UpdateInstallBridge(private val activity: MainActivity) : MethodChannel.MethodCallHandler {
companion object {
private const val CHANNEL = "com.nx.miaoji/update"
private const val APK_MIME = "application/vnd.android.package-archive"
private const val CLEANUP_DELAY_MS = 10L * 60L * 1000L
}
private data class PendingInstall(val path: String, val expectedBuild: Long)
private data class Inspection(val valid: Boolean, val message: String)
private var pendingInstall: PendingInstall? = null
private var returningFromPermissionSettings = false
fun register(engine: FlutterEngine) {
MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"inspectApk" -> {
val path = call.argument<String>("path").orEmpty()
val expectedBuild = call.numberArgument("expectedBuild")
val inspection = inspect(path, expectedBuild)
result.success(
mapOf(
"valid" to inspection.valid,
"message" to inspection.message,
),
)
}
"installApk" -> install(call, result)
else -> result.notImplemented()
}
}
fun onResume() {
if (!returningFromPermissionSettings) return
returningFromPermissionSettings = false
val pending = pendingInstall ?: return
if (canInstallPackages()) {
pendingInstall = null
launchInstaller(File(pending.path))
}
}
fun dispose() {
pendingInstall = null
}
private fun install(call: MethodCall, result: MethodChannel.Result) {
if (!isInternalPackage()) {
result.success(mapOf("status" to "unsupported"))
return
}
val path = call.argument<String>("path").orEmpty()
val expectedBuild = call.numberArgument("expectedBuild")
val inspection = inspect(path, expectedBuild)
if (!inspection.valid) {
result.error("INVALID_APK", inspection.message, null)
return
}
if (!canInstallPackages()) {
pendingInstall = PendingInstall(path, expectedBuild)
returningFromPermissionSettings = true
try {
activity.startActivity(
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${activity.packageName}"),
),
)
result.success(mapOf("status" to "permission_requested"))
} catch (error: Exception) {
pendingInstall = null
returningFromPermissionSettings = false
result.error("PERMISSION_ERROR", error.message, null)
}
return
}
if (launchInstaller(File(path))) {
result.success(mapOf("status" to "launched"))
} else {
result.success(mapOf("status" to "unsupported"))
}
}
private fun inspect(path: String, expectedBuild: Long): Inspection {
if (!isInternalPackage()) {
return Inspection(false, "正式版不支持应用内安装")
}
val current = currentPackageInfo()
?: return Inspection(false, "无法读取当前应用信息")
if (expectedBuild <= versionCode(current)) {
return Inspection(false, "目标版本号必须高于当前版本")
}
val file = File(path)
if (!file.isFile || file.length() <= 0L) {
return Inspection(false, "更新包文件不存在或为空")
}
val updateRoot = File(activity.cacheDir, "updates").canonicalFile
val canonical = file.canonicalFile
if (!canonical.path.startsWith(updateRoot.path + File.separator)) {
return Inspection(false, "更新包不在应用私有目录中")
}
val archive = packageArchiveInfo(canonical.path)
?: return Inspection(false, "无法读取更新包信息")
if (archive.packageName != activity.packageName) {
return Inspection(false, "更新包包名与当前内测版不一致")
}
if (versionCode(archive) != expectedBuild) {
return Inspection(false, "更新包版本号与发布记录不一致")
}
val currentSigners = signerDigests(current)
val archiveSigners = signerDigests(archive)
if (currentSigners.isEmpty() || currentSigners != archiveSigners) {
return Inspection(false, "更新包签名与当前应用不一致")
}
return Inspection(true, "校验通过")
}
private fun packageArchiveInfo(path: String): PackageInfo? {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
@Suppress("DEPRECATION")
PackageManager.GET_SIGNATURES
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.packageManager.getPackageArchiveInfo(
path,
PackageManager.PackageInfoFlags.of(flags.toLong()),
)
} else {
@Suppress("DEPRECATION")
activity.packageManager.getPackageArchiveInfo(path, flags)
}
}
private fun currentPackageInfo(): PackageInfo? {
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
PackageManager.GET_SIGNING_CERTIFICATES
} else {
@Suppress("DEPRECATION")
PackageManager.GET_SIGNATURES
}
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.packageManager.getPackageInfo(
activity.packageName,
PackageManager.PackageInfoFlags.of(flags.toLong()),
)
} else {
@Suppress("DEPRECATION")
activity.packageManager.getPackageInfo(activity.packageName, flags)
}
} catch (_: PackageManager.NameNotFoundException) {
null
}
}
private fun signerDigests(info: PackageInfo): Set<String> {
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val signingInfo = info.signingInfo ?: return emptySet()
if (signingInfo.hasMultipleSigners()) {
signingInfo.apkContentsSigners
} else {
signingInfo.signingCertificateHistory
}
} else {
@Suppress("DEPRECATION")
info.signatures ?: emptyArray()
}
return signatures
.map { signature ->
MessageDigest.getInstance("SHA-256")
.digest(signature.toByteArray())
.joinToString("") { byte -> "%02x".format(byte) }
}
.toSet()
}
private fun versionCode(info: PackageInfo): Long =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.longVersionCode
} else {
@Suppress("DEPRECATION")
info.versionCode.toLong()
}
private fun isInternalPackage(): Boolean =
activity.packageName == "com.nx.miaoji.internal"
private fun canInstallPackages(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
activity.packageManager.canRequestPackageInstalls()
private fun launchInstaller(file: File): Boolean {
return try {
val uri = FileProvider.getUriForFile(
activity,
"${activity.packageName}.update.files",
file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, APK_MIME)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity.startActivity(intent)
activity.window.decorView.postDelayed({ file.delete() }, CLEANUP_DELAY_MS)
true
} catch (_: Exception) {
false
}
}
private fun MethodCall.numberArgument(name: String): Long =
(argument<Number>(name))?.toLong() ?: -1L
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#111615"/>
</shape>
</item>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/jizhi_launch_icon"/>
</item>
</layer-list>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#111615"/>
</shape>
</item>
<item>
<bitmap
android:gravity="center"
android:src="@drawable/jizhi_launch_icon"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/jizhi_launch_icon" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/jizhi_app_foreground"
android:gravity="fill" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/jizhi_app_monochrome"
android:gravity="fill" />
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/jizhi_launch_icon" />
</item>
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowSplashScreenBackground">#111615</item>
<item name="android:windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowSplashScreenBackground">@android:color/white</item>
<item name="android:windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="ProjectionConsentTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowDisablePreview">true</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">记之</string>
<string name="screenshot_tile_label">截屏记账</string>
<string name="notification_listener_label">记之通知识别</string>
<string name="accessibility_service_desc">用于用户主动开启的微信、支付宝账单识别,以及截屏记账磁贴的免授权截图。仅在支持的支付应用中读取当前页面可见文字,不记录完整控件树,不监听或拦截按键。</string>
</resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="ProjectionConsentTheme" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowDisablePreview">true</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_service_desc"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowsChanged|typeWindowContentChanged|typeViewTextChanged|typeViewClicked"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="150"
android:accessibilityFlags="flagDefault|flagReportViewIds|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:canTakeScreenshot="true"/>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,257 @@
package com.nx.miaoji
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PaymentParserTest {
@Test
fun explicitSuccessStatusesKeepDirection() {
assertEquals("expense", PaymentParser.detectDirection("扫码支付成功"))
assertEquals("expense", PaymentParser.detectDirection("转账已完成"))
assertEquals("income", PaymentParser.detectDirection("收款已到账"))
assertEquals("income", PaymentParser.detectDirection("退款成功"))
}
@Test
fun blockedAndHistoryPagesNeverBecomePaymentSuccess() {
assertNull(PaymentParser.detectDirection("支付处理中"))
assertTrue(PaymentParser.containsBlockedStatus("等待对方收款"))
assertTrue(PaymentParser.isHistoryPageText("账单详情\n支付成功\n¥88.00"))
assertFalse(PaymentParser.isHistoryPageText("支付成功\n¥88.00"))
}
@Test
fun amountsRequireCurrencyUnitOrDecimalForBareValues() {
assertEquals(88.0, PaymentParser.parseAmountCandidate("¥88.00")!!, 0.001)
assertEquals(12.5, PaymentParser.parseAmountCandidate("实付:12.50")!!, 0.001)
assertEquals(9.9, PaymentParser.parseAmountCandidate("9.90")!!, 0.001)
assertNull(PaymentParser.parseAmountCandidate("100"))
}
@Test
fun paymentFlowContextIsSeparateFromSuccess() {
assertTrue(PaymentParser.hasPaymentAction("确认转账"))
assertTrue(PaymentParser.hasPaymentContext("收款方:张三\n付款金额 ¥20"))
assertNull(PaymentParser.detectDirection("确认转账\n付款金额 ¥20"))
}
@Test
fun spacedAndWeakCompletionStatusesAreClassified() {
val strong = PaymentParser.detectStatus("转 账 成 功")
assertEquals(PaymentStatusStrength.STRONG, strong.strength)
assertEquals("expense", strong.direction)
val weak = PaymentParser.detectStatus("完成", fallbackDirection = "expense")
assertEquals(PaymentStatusStrength.WEAK, weak.strength)
assertEquals("expense", weak.direction)
}
@Test
fun weakCompletionNeedsFullCombinedEvidenceForAutomaticImport() {
assertTrue(
OcrEvidenceEvaluator.qualifiesWeakAuto(
trustedFlow = true,
freshScreenshot = true,
uniqueAmount = true,
expectedAmountMatched = true,
resultTransitionObserved = true,
directionKnown = true,
),
)
assertFalse(
OcrEvidenceEvaluator.qualifiesWeakAuto(
trustedFlow = true,
freshScreenshot = true,
uniqueAmount = true,
expectedAmountMatched = false,
resultTransitionObserved = true,
directionKnown = true,
),
)
assertFalse(
OcrEvidenceEvaluator.qualifiesWeakAuto(
trustedFlow = true,
freshScreenshot = true,
uniqueAmount = true,
expectedAmountMatched = true,
resultTransitionObserved = false,
directionKnown = true,
),
)
}
@Test
fun weakUiTreeSignalFallsBackToConfirmationWhenEvidenceIsIncomplete() {
val automatic = PaymentParser.fromAccessibility(
packageName = PaymentParser.WECHAT,
text = "完成\n¥20.00",
eventTime = 1L,
windowId = 7,
flowSessionId = "flow-a",
trustedFlow = true,
expectedAmountCents = 2_000L,
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
)
assertEquals("high", automatic?.evidenceConfidence)
val confirmation = PaymentParser.fromAccessibility(
packageName = PaymentParser.WECHAT,
text = "完成\n¥20.00",
eventTime = 1L,
windowId = 7,
flowSessionId = "flow-b",
trustedFlow = true,
expectedAmountCents = 3_000L,
expectedType = "expense",
resultTransitionObserved = true,
)
assertEquals("confirm", confirmation?.evidenceConfidence)
}
@Test
fun paymentInputAndAmbiguousAmountsAreRejected() {
assertTrue(PaymentParser.isPaymentInputPage("请输入支付密码\n确认转账"))
assertEquals(2_000L, PaymentParser.uniqueAmountCents("付款金额 ¥20.00\n¥20.00"))
assertNull(PaymentParser.uniqueAmountCents("¥20.00\n优惠 ¥2.00"))
}
@Test
fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() {
val preview = OcrDiagnosticRedactor.redact(
listOf(
"转账成功",
"转账给:丁伊文",
"付款金额 ¥20.00",
"手机号 13607268374",
"订单号:202607221234567890",
) + List(20) { "其他文字 $it" },
)
val joined = preview.joinToString("\n")
assertTrue(preview.size <= 12)
assertTrue(joined.contains("转账成功"))
assertTrue(joined.contains("¥20.00"))
assertFalse(joined.contains("丁伊文"))
assertFalse(joined.contains("13607268374"))
assertFalse(joined.contains("202607221234567890"))
}
@Test
fun paymentResultWithoutAmountUsesSubmittedFlowAmount() {
val signal = PaymentParser.fromAccessibility(
packageName = PaymentParser.WECHAT,
text = "支付成功",
eventTime = 1L,
windowId = 8,
flowSessionId = "payment-flow",
trustedFlow = true,
expectedAmountCents = 1_880L,
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
flowKind = "payment",
)
assertEquals(1_880L, signal?.amountCents)
assertEquals("expense", signal?.type)
assertEquals("expected", signal?.amountSource)
assertEquals("high", signal?.evidenceConfidence)
}
@Test
fun paymentAmountFallbackRequiresSubmissionAndTransition() {
val signal = PaymentParser.fromAccessibility(
packageName = PaymentParser.WECHAT,
text = "支付成功",
eventTime = 1L,
windowId = 8,
flowSessionId = "payment-flow",
trustedFlow = true,
expectedAmountCents = 1_880L,
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = false,
flowKind = "payment",
)
assertNull(signal)
}
@Test
fun redPacketLifecycleUsesCorrectDirectionAndCategory() {
val sent = PaymentParser.fromAccessibility(
packageName = PaymentParser.WECHAT,
text = "红包已发送",
eventTime = 1L,
windowId = 9,
flowSessionId = "red-packet-flow",
trustedFlow = true,
expectedAmountCents = 2_000L,
expectedType = "expense",
resultTransitionObserved = true,
submittedFlow = true,
flowKind = "red_packet_send",
)
val received = PaymentParser.fromAccessibility(
packageName = PaymentParser.WECHAT,
text = "红包已存入零钱\n¥8.88",
eventTime = 2L,
windowId = 10,
)
val refunded = PaymentParser.fromAccessibility(
packageName = PaymentParser.ALIPAY,
text = "红包退回到账\n到账金额 6.66",
eventTime = 3L,
windowId = 11,
)
assertEquals("red_packet_send", sent?.recognitionKind)
assertEquals("expense", sent?.type)
assertEquals("人情", sent?.categoryHint)
assertEquals("expected", sent?.amountSource)
assertEquals("high", sent?.evidenceConfidence)
assertEquals("red_packet_receive", received?.recognitionKind)
assertEquals("income", received?.type)
assertEquals("红包", received?.categoryHint)
assertEquals("high", received?.evidenceConfidence)
assertEquals("red_packet_refund", refunded?.recognitionKind)
assertEquals("income", refunded?.type)
assertEquals("红包", refunded?.categoryHint)
}
@Test
fun ordinaryAndUnsettledRedPacketSurfacesAreIgnored() {
assertNull(
PaymentParser.fromAccessibility(
packageName = PaymentParser.WECHAT,
text = "领取红包\n¥8.88",
eventTime = 1L,
windowId = 12,
),
)
assertNull(PaymentParser.detectDirection("恭喜发财,点击领取红包"))
assertTrue(PaymentParser.isPendingRedPacketSurface("红包已被领完"))
}
@Test
fun diagnosticPreviewKeepsRedPacketStateButMasksIdentity() {
val preview = OcrDiagnosticRedactor.redact(
listOf(
"红包已存入零钱",
"红包金额 ¥18.88",
"收款方:丁伊文",
"订单号:202607221234567890",
),
).joinToString("\n")
assertTrue(preview.contains("红包已存入零钱"))
assertTrue(preview.contains("¥18.88"))
assertFalse(preview.contains("丁伊文"))
assertFalse(preview.contains("202607221234567890"))
}
}
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=768m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
Vendored Executable
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+90
View File
@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
storePassword=replace-me
keyPassword=replace-me
keyAlias=miaoji-release
storeFile=C:/Users/your-name/.android/keystores/miaoji-release.p12
storeType=PKCS12␍
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13" r="7"/><path d="M9 5c.4-2.2 3.6-2.6 4.7-.8.7 1.2-.1 2.8-1.5 2.8M9.5 12h.01m5 0h.01M9.5 15.5c1.5 1.2 3.5 1.2 5 0"/></g></svg>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3c1.1 3.2 3.1 5.2 6.5 6.3-3.4 1.1-5.4 3.1-6.5 6.4-1.1-3.3-3.1-5.3-6.5-6.4C8.9 8.2 10.9 6.2 12 3Z"/><path d="M18 15.5c.5 1.5 1.5 2.5 3 3-1.5.5-2.5 1.5-3 3-.5-1.5-1.5-2.5-3-3 1.5-.5 2.5-1.5 3-3Z"/></g></svg>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M6 10a6 6 0 1 1 12 0c0 4 1.5 5.5 1.5 5.5h-15S6 14 6 10Z'/><path d='M10 19a2.2 2.2 0 0 0 4 0'/></g></svg>

After

Width:  |  Height:  |  Size: 300 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M5 5.5A2.5 2.5 0 0 1 7.5 3H19v15.5H7.5A2.5 2.5 0 0 0 5 21V5.5Z'/><path d='M5 18.5A2.5 2.5 0 0 1 7.5 16H19'/></g></svg>

After

Width:  |  Height:  |  Size: 314 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x='4' y='8' width='16' height='11' rx='2'/><path d='M9 8V6a1.5 1.5 0 0 1 1.5-1.5h3A1.5 1.5 0 0 1 15 6v2M4 13h16'/></g></svg>

After

Width:  |  Height:  |  Size: 317 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M4 8h3l1.5-2h7L17 8h3v11H4V8Z'/><circle cx='12' cy='13' r='3.2'/></g></svg>

After

Width:  |  Height:  |  Size: 271 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 16h14l-1.2-6.2A2 2 0 0 0 15.8 8H8.2a2 2 0 0 0-2 1.8L5 16Z"/><path d="M4 13h16v5H4zM7 18v2m10-2v2"/><circle cx="7.5" cy="15.5" r=".7"/><circle cx="16.5" cy="15.5" r=".7"/></g></svg>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x='3.5' y='6' width='17' height='12.5' rx='2.5'/><path d='M3.5 10h17M7 15h4'/></g></svg>

After

Width:  |  Height:  |  Size: 281 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M4 5h2.5l1.8 9.5h9.4L20 8H7'/><circle cx='9.5' cy='19' r='1.4'/><circle cx='16.5' cy='19' r='1.4'/></g></svg>

After

Width:  |  Height:  |  Size: 305 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M5.5 9.5 4 4.5l4.2 2.1a8 8 0 0 1 7.6 0L20 4.5l-1.5 5A8 8 0 0 1 20 13c0 4.4-3.6 7.5-8 7.5S4 17.4 4 13c0-1.3.6-2.5 1.5-3.5Z'/><circle cx='9' cy='12.5' r='1' fill='currentColor' stroke='none'/><circle cx='15' cy='12.5' r='1' fill='currentColor' stroke='none'/><path d='M10.5 15.8c.9.9 2.1.9 3 0'/></g></svg>

After

Width:  |  Height:  |  Size: 500 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M4 20h16'/><path d='M7 16v-4M12 16V7M17 16v-6'/></g></svg>

After

Width:  |  Height:  |  Size: 254 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M20 12a8 8 0 0 1-11.6 7.1L4 20l1-4.1A8 8 0 1 1 20 12Z'/><path d='M8.5 12h.01M12 12h.01M15.5 12h.01'/></g></svg>

After

Width:  |  Height:  |  Size: 307 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='m5 12.5 4.5 4.5L19 7.5'/></g></svg>

After

Width:  |  Height:  |  Size: 231 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='m7 10 5 5 5-5'/></g></svg>

After

Width:  |  Height:  |  Size: 222 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='m10 7 5 5-5 5'/></g></svg>

After

Width:  |  Height:  |  Size: 222 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='m6 6l12 12M18 6 6 18'/></g></svg>

After

Width:  |  Height:  |  Size: 229 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M7 18a4 4 0 0 1-.5-8A5.5 5.5 0 0 1 17 8.5 4.2 4.2 0 0 1 17.5 17'/><path d='M12 12v7M9.5 14.5 12 12l2.5 2.5'/></g></svg>

After

Width:  |  Height:  |  Size: 315 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M7 6h10l-1.2 13H8.2L7 6Z'/><path d='M7.5 11h9M11 3l1.5 3'/></g></svg>

After

Width:  |  Height:  |  Size: 265 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M7 6.5C5 7.5 4 9.5 4 12c0 4.4 3.6 7.5 8 7.5s8-3.1 8-7.5c0-2.5-1-4.5-3-5.5'/><path d='M7 6.5C6.5 4 7.5 2.5 9.5 3.5L11 5M17 6.5c.5-2.5-.5-4-2.5-3l-1.5 1.5'/><circle cx='9.5' cy='11.5' r='1' fill='currentColor' stroke='none'/><circle cx='14.5' cy='11.5' r='1' fill='currentColor' stroke='none'/><path d='M12 14v1.5M12 15.5c-.8.8-1.7.8-2.5.2M12 15.5c.8.8 1.7.8 2.5.2'/></g></svg>

After

Width:  |  Height:  |  Size: 571 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M5 9h14M5 15h14'/></g></svg>

After

Width:  |  Height:  |  Size: 224 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M14.5 5.5 4 16v4h4L18.5 9.5'/><path d='m13 7 4 4M15.5 4.5l2-2 4 4-2 2'/></g></svg>

After

Width:  |  Height:  |  Size: 278 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M12 15V4M8 8l4-4 4 4'/><path d='M5 14v6h14v-6'/></g></svg>

After

Width:  |  Height:  |  Size: 254 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M3 12s3.5-6 9-6 9 6 9 6-3.5 6-9 6-9-6-9-6Z'/><circle cx='12' cy='12' r='2.5'/></g></svg>

After

Width:  |  Height:  |  Size: 284 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M12 21c-3.9 0-6.5-2.4-6.5-6 0-3 2.3-5.4 3.5-7.5.6 1.3 1.3 2 2.3 2.7C11.5 8 12 5.5 14 3c.6 3 4.5 5.6 4.5 11 .1 4-2.6 7-6.5 7Z'/><path d='M12 21c-1.8 0-3-1.2-3-3 0-1.5 1.2-2.6 2-4 1.5 1.3 4 2.2 4 4.3 0 1.6-1.2 2.7-3 2.7Z'/></g></svg>

After

Width:  |  Height:  |  Size: 427 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d='M4 11h16a8 8 0 0 1-16 0Z'/><path d='M7 8c2-1.5 8-1.5 10 0M12 6V4'/></g></svg>

After

Width:  |  Height:  |  Size: 273 B

Some files were not shown because too many files have changed in this diff Show More