Initial project import
@@ -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
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 611 KiB |
|
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>
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
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>
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=768m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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␍
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 566 KiB |
|
After Width: | Height: | Size: 611 KiB |
|
After Width: | Height: | Size: 766 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |