├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── drawable
│ │ │ │ ├── widget_preview.png
│ │ │ │ ├── widget_background.xml
│ │ │ │ ├── ic_notification.xml
│ │ │ │ ├── ic_empty_package.xml
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_adaptive_back.png
│ │ │ │ └── ic_launcher_adaptive_fore.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_adaptive_back.png
│ │ │ │ └── ic_launcher_adaptive_fore.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_adaptive_back.png
│ │ │ │ └── ic_launcher_adaptive_fore.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_adaptive_back.png
│ │ │ │ └── ic_launcher_adaptive_fore.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ ├── ic_launcher_adaptive_back.png
│ │ │ │ └── ic_launcher_adaptive_fore.png
│ │ │ ├── values-night
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ └── ic_launcher.xml
│ │ │ ├── mipmap-anydpi
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── xml
│ │ │ │ ├── parcel_widget_info.xml
│ │ │ │ ├── parcel_widget_xl_info.xml
│ │ │ │ ├── parcel_widget_large_info.xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ │ └── layout
│ │ │ │ └── widget_layout.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── xxxx
│ │ │ │ └── parcel
│ │ │ │ ├── model
│ │ │ │ ├── SmsData.kt
│ │ │ │ ├── ParcelData.kt
│ │ │ │ └── SmsModel.kt
│ │ │ │ ├── ui
│ │ │ │ ├── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── AboutScreen.kt
│ │ │ │ ├── FailSmsScreen.kt
│ │ │ │ ├── SuccessSmsScreen.kt
│ │ │ │ ├── LogScreen.kt
│ │ │ │ ├── AddRuleScreen.kt
│ │ │ │ ├── RulesScreen.kt
│ │ │ │ └── AddCustomSmsScreen.kt
│ │ │ │ ├── receiver
│ │ │ │ ├── SmsReceiver.kt
│ │ │ │ └── BootReceiver.kt
│ │ │ │ ├── service
│ │ │ │ ├── SmsSyncService.kt
│ │ │ │ ├── NotificationAccessTileService.kt
│ │ │ │ └── ParcelNotificationListenerService.kt
│ │ │ │ ├── widget
│ │ │ │ ├── ParcelWidgetLargeMiui.kt
│ │ │ │ ├── ParcelWidgetLarge.kt
│ │ │ │ ├── ParcelWidgetXL.kt
│ │ │ │ ├── ParcelWidgetMiui.kt
│ │ │ │ └── ParcelWidget.kt
│ │ │ │ ├── util
│ │ │ │ ├── PermissionUtil.kt
│ │ │ │ ├── SmsUtil.kt
│ │ │ │ └── SmsParser.kt
│ │ │ │ ├── viewmodel
│ │ │ │ └── ParcelViewModel.kt
│ │ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── xxxx
│ │ │ └── parcel
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── xxxx
│ │ └── parcel
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── fastlane
└── metadata
│ └── android
│ ├── zh-CN
│ ├── title.txt
│ ├── short_description.txt
│ ├── images
│ │ ├── phoneScreenshots
│ │ │ ├── 1.png
│ │ │ ├── 2.png
│ │ │ ├── 3.png
│ │ │ ├── 4.png
│ │ │ ├── 5.png
│ │ │ └── 6.png
│ │ └── icon.png
│ └── full_description.txt
│ └── en-US
│ ├── title.txt
│ ├── images
│ ├── phoneScreenshots
│ │ ├── 1.png
│ │ ├── 2.png
│ │ ├── 3.png
│ │ ├── 4.png
│ │ ├── 5.png
│ │ └── 6.png
│ └── icon.png
│ ├── short_description.txt
│ └── full_description.txt
├── show1.jpg
├── show2.jpg
├── show3.jpg
├── show4.jpg
├── show5.jpg
├── show6.jpg
├── .idea
├── .gitignore
├── compiler.xml
├── vcs.xml
├── render.experimental.xml
├── AndroidProjectSystem.xml
├── migrations.xml
├── misc.xml
├── deploymentTargetSelector.xml
├── gradle.xml
├── runConfigurations.xml
└── inspectionProfiles
│ └── Project_Default.xml
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── 1build.sh
├── .gitignore
├── .github
└── workflows
│ └── fastlane.yml
├── settings.gradle.kts
├── LICENSE
├── README.md
├── gradle.properties
├── gradlew.bat
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/title.txt:
--------------------------------------------------------------------------------
1 | Parcel - 取件码
2 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/title.txt:
--------------------------------------------------------------------------------
1 | Parcel - Pickup code
2 |
--------------------------------------------------------------------------------
/show1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/show1.jpg
--------------------------------------------------------------------------------
/show2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/show2.jpg
--------------------------------------------------------------------------------
/show3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/show3.jpg
--------------------------------------------------------------------------------
/show4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/show4.jpg
--------------------------------------------------------------------------------
/show5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/show5.jpg
--------------------------------------------------------------------------------
/show6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/show6.jpg
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/short_description.txt:
--------------------------------------------------------------------------------
1 | 自动解析收到的短信,并从中提取出地址和取件码信息
2 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show1.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show2.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show3.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show4.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show5.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show6.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show1.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show2.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show3.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/phoneScreenshots/4.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show4.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/phoneScreenshots/5.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show5.jpg
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/phoneScreenshots/6.png:
--------------------------------------------------------------------------------
1 | ../../../../../../show6.jpg
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | 取件码
3 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | Extract the address and pickup code from SMS automatically
2 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/1build.sh:
--------------------------------------------------------------------------------
1 | ./gradlew assembleRelease
2 |
3 | cp app/build/outputs/apk/release/app-release.apk ~/Desktop/parcel-v1.0.40.apk
--------------------------------------------------------------------------------
/app/src/main/res/drawable/widget_preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/drawable/widget_preview.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/fastlane/metadata/android/en-US/images/icon.png
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/fastlane/metadata/android/zh-CN/images/icon.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shareven/parcel/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/render.experimental.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/model/SmsData.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.model
2 |
3 |
4 | data class SmsData(val address: String, val code: String, val sms: SmsModel,val id:String,var isCompleted:Boolean=false)
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/widget_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/AndroidProjectSystem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/model/ParcelData.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.model
2 |
3 | data class ParcelData(
4 | val address: String,
5 | val smsDataList: MutableList,
6 | var num: Int=0,
7 | // val parcels: MutableList
8 | )
9 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/model/SmsModel.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.model
2 |
3 | import kotlinx.serialization.Serializable
4 | import java.sql.Timestamp
5 |
6 | @Serializable
7 | data class SmsModel(
8 | val id:String,
9 | val body:String,
10 | val timestamp: Long
11 | )
12 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 | key.properties
17 | .kotlin
18 | .vscode
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/fastlane/metadata/android/zh-CN/full_description.txt:
--------------------------------------------------------------------------------
1 | 这是一个免费、开源、无广告、不联网,追求简洁的app,不收集任何个人信息。
2 |
3 | 本app会自动解析收到的短信,并从中提取出地址和取件码信息,可以展示到桌面卡片上(支持暗色模式)。
4 |
5 | 您可以添加自定义规则来改进解析效果。
6 |
7 | 支持淘宝身份码和拼多多身份码
8 |
9 | 还支持监听第三方app通知,自动保存取件码消息,帮微信朋友取快递更方便了
10 |
11 | 打开监听通知权限,还能实现后台进程保活,实时更新桌面卡片。
12 |
13 | 桌面卡片添加:一般是藏在全部卡片-最底部的插件或者安卓小组件里面
14 |
15 | 欢迎下载和使用!有问题或建议请提issue。
16 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
5 | networkTimeout=10000
6 | validateDistributionUrl=true
7 | zipStoreBase=GRADLE_USER_HOME
8 | zipStorePath=wrapper/dists
9 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/test/java/com/xxxx/parcel/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FFFFFFFF
9 | #FF000000
10 | #FF666666
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values-night/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 | #FF999999
11 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/parcel_widget_info.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/parcel_widget_xl_info.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/parcel_widget_large_info.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
--------------------------------------------------------------------------------
/.github/workflows/fastlane.yml:
--------------------------------------------------------------------------------
1 | name: Validate Fastlane metadata
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches: [ "main" ]
7 | paths:
8 | - 'fastlane/**'
9 | pull_request:
10 | branches: [ "main" ]
11 | paths:
12 | - 'fastlane/**'
13 |
14 | jobs:
15 | go:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@v4
19 | - name: Validate Fastlane Supply Metadata
20 | uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2
21 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notification.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
10 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "parcel"
23 | include(":app")
24 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/.idea/deploymentTargetSelector.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/xxxx/parcel/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.xxxx.parcel", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/receiver/SmsReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.receiver
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.os.Build
7 | import com.xxxx.parcel.service.SmsSyncService
8 | import com.xxxx.parcel.util.addLog
9 |
10 | class SmsReceiver : BroadcastReceiver() {
11 | override fun onReceive(context: Context, intent: Intent) {
12 | if (intent.action == "android.provider.Telephony.SMS_RECEIVED") {
13 | try {
14 | val i = Intent(context, SmsSyncService::class.java)
15 | if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(i) else context.startService(i)
16 | addLog(context, "短信接收广播触发,启动前台服务")
17 | } catch (_: Throwable) {}
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | This is a free, open-source, ad-free, offline app that prioritizes simplicity and does not collect any personal information.
2 |
3 | This app automatically parses received SMS messages and extracts the address and pickup code information, which can be displayed on a desktop card (dark mode supported).
4 |
5 | You can add custom rules to improve the parsing effect.
6 |
7 | Supports Taobao and Pinduoduo ID codes.
8 |
9 | It also supports listening to third-party app notifications and automatically saving pickup code messages, making it more convenient for WeChat friends to pick up their packages.
10 |
11 | Enabling notification listening permissions also allows for background process persistence and real-time updates to the desktop card.
12 |
13 | Adding a desktop card: Generally hidden in the "All Cards" section at the bottom, under "Plugins" or "Android Widgets".
14 |
15 | Welcome to download and use! Please submit an issue for any problems or suggestions.
16 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 shareven
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_empty_package.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
14 |
21 |
28 |
29 |
32 |
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Parcel - 取件码
2 |
3 | 这是一个免费、开源、无广告、不联网,追求简洁的app,不收集任何个人信息。
4 |
5 | 本app会自动解析收到的短信,并从中提取出地址和取件码信息,可以展示到桌面卡片上(支持暗色模式)。
6 |
7 | 您可以添加自定义规则来改进解析效果。
8 |
9 | 支持淘宝身份码和拼多多身份码
10 |
11 | 还支持监听第三方app通知,自动保存取件码消息,帮微信朋友取快递更方便了
12 |
13 | 打开监听通知权限,还能实现后台进程保活,实时更新桌面卡片。
14 |
15 | 桌面卡片添加:一般是藏在全部卡片-最底部的插件或者安卓小组件里面
16 |
17 | 欢迎下载和使用!有问题或建议请提issue。
18 |
19 | # 下载 | Download
20 |
21 | 最新版本:v1.0.40
22 |
23 | [Android apk release](https://github.com/shareven/parcel/releases/)
24 |
25 | [](https://f-droid.org/packages/com.xxxx.parcel/)
26 |
27 | 
28 |
29 | # 使用问题
30 |
31 | 1. 桌面卡片添加: 一般藏在全部卡片-最底部的插件或者安卓小组件里面
32 |
33 | 2. 小米手机要打开通知类短信权限【权限管理→其他权限→通知类短信→始终允许】
34 |
35 | 3. 如果发现有快递短信无法识别,并且是没有发送者号码的这种。这种短信目前识别不了,第一种办法设置不接收这种短信。去短信设置里关闭 [服务信息]或者[5G信息] : 通过移动数据或者WLAN接收商家信息。第二种办法开启监听第三方app功能,设置并监听网络短信通知,自动保存通知里的取件码信息到自定义取件短信。
36 |
37 | 4. 如果出现桌面卡片不更新的情况,可能是后台进程被杀了,尝试打开监听通知权限,有助于实现后台进程保活,实时更新桌面卡片。在耗电管理里设置 不限制应用的后台行为,然后重新添加桌面卡片
38 |
39 | 5. 不发短信的话,可以复制取件码或短信,点击+号自动粘贴导入。或者找快递客服,让他把取件码通知方式改成短信通知
40 |
41 | 6. 如果github下载不了,请到微云下载[https://share.weiyun.com/6NipcuLD](https://share.weiyun.com/6NipcuLD)
42 |
43 | # app展示
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | # use cache to speed up build process
25 | org.gradle.configuration-cache=true
26 | org.gradle.caching=true
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.10.0"
3 | kotlin = "2.0.21"
4 | coreKtx = "1.16.0"
5 | junit = "4.13.2"
6 | junitVersion = "1.2.1"
7 | espressoCore = "3.6.1"
8 | lifecycleRuntimeKtx = "2.9.0"
9 | activityCompose = "1.10.1"
10 | composeBom = "2024.09.00"
11 | roomRuntimeAndroid = "2.7.1"
12 |
13 | [libraries]
14 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
15 | junit = { group = "junit", name = "junit", version.ref = "junit" }
16 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
17 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
18 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
19 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
20 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
21 | androidx-ui = { group = "androidx.compose.ui", name = "ui" }
22 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
23 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
24 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
25 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
26 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
27 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
28 | androidx-room-runtime-android = { group = "androidx.room", name = "room-runtime-android", version.ref = "roomRuntimeAndroid" }
29 |
30 | [plugins]
31 | android-application = { id = "com.android.application", version.ref = "agp" }
32 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
33 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/receiver/BootReceiver.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.receiver
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.ComponentName
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.content.pm.PackageManager
8 | import android.appwidget.AppWidgetManager
9 | import androidx.core.app.NotificationManagerCompat
10 | import com.xxxx.parcel.service.ParcelNotificationListenerService
11 | import com.xxxx.parcel.widget.ParcelWidget
12 | import com.xxxx.parcel.widget.ParcelWidgetLarge
13 | import com.xxxx.parcel.widget.ParcelWidgetXL
14 |
15 | class BootReceiver : BroadcastReceiver() {
16 | override fun onReceive(context: Context, intent: Intent?) {
17 | val action = intent?.action
18 | if (action == Intent.ACTION_BOOT_COMPLETED ||
19 | action == Intent.ACTION_USER_UNLOCKED ||
20 | action == Intent.ACTION_MY_PACKAGE_REPLACED) {
21 | val enabled = NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName)
22 | if (!enabled) return
23 | try {
24 | context.startService(Intent(context, ParcelNotificationListenerService::class.java))
25 | } catch (_: Throwable) {}
26 | val pm = context.packageManager
27 | val cn = ComponentName(context, ParcelNotificationListenerService::class.java)
28 | pm.setComponentEnabledSetting(
29 | cn,
30 | PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
31 | PackageManager.DONT_KILL_APP,
32 | )
33 | pm.setComponentEnabledSetting(
34 | cn,
35 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
36 | PackageManager.DONT_KILL_APP,
37 | )
38 |
39 | try {
40 | ParcelWidget.updateAppWidget(
41 | context,
42 | AppWidgetManager.getInstance(context),
43 | null,
44 | null
45 | )
46 | ParcelWidget.updateAllByProvider(context, ParcelWidgetLarge::class.java, null)
47 | ParcelWidget.updateAllByProvider(context, ParcelWidgetXL::class.java, null)
48 | } catch (_: Throwable) {}
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/service/SmsSyncService.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.service
2 |
3 | import android.app.Notification
4 | import android.app.NotificationChannel
5 | import android.app.NotificationManager
6 | import android.app.Service
7 | import android.content.Intent
8 | import android.os.Build
9 | import android.os.IBinder
10 | import androidx.core.app.NotificationCompat
11 | import com.xxxx.parcel.util.addLog
12 |
13 | class SmsSyncService : Service() {
14 | override fun onCreate() {
15 | if (Build.VERSION.SDK_INT >= 26) {
16 | val mgr = getSystemService(NotificationManager::class.java)
17 | val id = "parcel_sync"
18 | if (mgr?.getNotificationChannel(id) == null) {
19 | val ch = NotificationChannel(id, "数据更新", NotificationManager.IMPORTANCE_MIN)
20 | ch.setShowBadge(false)
21 | ch.enableLights(false)
22 | ch.enableVibration(false)
23 | mgr?.createNotificationChannel(ch)
24 | }
25 | val notif = NotificationCompat.Builder(this, id)
26 | .setSmallIcon(com.xxxx.parcel.R.drawable.ic_notification)
27 | .setContentTitle("数据更新")
28 | .setContentText("正在处理短信")
29 | .setOngoing(true)
30 | .setPriority(NotificationCompat.PRIORITY_MIN)
31 | .build()
32 | startForeground(1002, notif)
33 | }
34 | super.onCreate()
35 | }
36 |
37 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
38 | Thread {
39 | try {
40 | Thread.sleep(1500L)
41 | val i = Intent("com.xxxx.parcel.CUSTOM_SMS_ADDED").setPackage(packageName)
42 | sendBroadcast(i)
43 | try {
44 | val mgr = android.appwidget.AppWidgetManager.getInstance(this)
45 | com.xxxx.parcel.widget.ParcelWidget.updateAppWidget(this, mgr, null, null)
46 | com.xxxx.parcel.widget.ParcelWidgetLarge.updateAppWidget(this, mgr, null, null)
47 | com.xxxx.parcel.widget.ParcelWidgetXL.updateAppWidget(this, mgr, null, null)
48 | com.xxxx.parcel.widget.ParcelWidgetMiui.updateAppWidget(this, mgr, null, null)
49 | com.xxxx.parcel.widget.ParcelWidgetLargeMiui.updateAppWidget(this, mgr, null, null)
50 | } catch (_: Exception) {}
51 | addLog(this, "短信前台服务刷新完成并广播更新")
52 | } catch (e: Exception) { addLog(this, "短信服务线程错误: ${e.message}") }
53 | try { stopForeground(true) } catch (_: Exception) {}
54 | stopSelf()
55 | }.start()
56 | return START_STICKY
57 | }
58 |
59 | override fun onBind(intent: Intent?): IBinder? = null
60 | }
61 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui.theme
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.shape.RoundedCornerShape
8 | import androidx.compose.material3.MaterialTheme
9 | import androidx.compose.material3.Shapes
10 | import androidx.compose.material3.darkColorScheme
11 | import androidx.compose.material3.lightColorScheme
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.graphics.Brush
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.unit.dp
17 |
18 | val TextColor = Color(0xFF222222)
19 | val TextColorAAA = Color(0xFFAAAAAA)
20 | val TextColorWhite = Color(0xFFFFFFFF)
21 |
22 | val Shapes = Shapes(
23 | small = RoundedCornerShape(4.dp),
24 | medium = RoundedCornerShape(8.dp),
25 | large = RoundedCornerShape(12.dp)
26 | )
27 |
28 | private val DarkColorScheme = darkColorScheme(
29 | primary = Color.White,
30 | secondary = PurpleGrey80,
31 | tertiary = Pink80,
32 | onSecondary = Color.Gray,
33 | onTertiary = Color.Black,
34 | background = Color.Transparent,
35 | onBackground = TextColorAAA,
36 | surface = Color.Transparent,
37 | onSurface = TextColorWhite
38 | )
39 |
40 | private val LightColorScheme = lightColorScheme(
41 | primary = Color.Black,
42 | secondary = PurpleGrey40,
43 | tertiary = Pink40,
44 | onSecondary = Color.Gray,
45 | onTertiary = Color.Black,
46 | background = Color.Transparent,
47 | onBackground = TextColor,
48 | surface = Color.Transparent,
49 | onSurface = TextColor
50 |
51 | )
52 |
53 | @Composable
54 | fun ParcelTheme(
55 | darkTheme: Boolean = isSystemInDarkTheme(),
56 | // Dynamic color is available on Android 12+
57 | dynamicColor: Boolean = true,
58 | gradientBrush: Brush = Brush.linearGradient(
59 | colors = listOf(Color(0xFFCEB6F6), Color(0xFFF6F3F4), Color(0xFFF6C8D8)) // 定义渐变颜色
60 | ),
61 | gradientBrushDark: Brush = Brush.linearGradient(
62 | colors = listOf(Color(0xFF2C105E), Color(0xFF020202), Color(0xFF590E26)) // 定义渐变颜色
63 | ),
64 | content: @Composable () -> Unit
65 | ) {
66 | val colorScheme = when {
67 | darkTheme -> DarkColorScheme
68 | else -> LightColorScheme
69 | }
70 |
71 | val backgroundGradient = when {
72 | darkTheme -> gradientBrushDark
73 | else -> gradientBrush
74 | }
75 |
76 | MaterialTheme(
77 | colorScheme = colorScheme,
78 | typography = Typography,
79 | shapes = Shapes,
80 | content = {
81 | Box(modifier = Modifier.fillMaxSize().background(brush = backgroundGradient)) {
82 | content()
83 | }
84 | }
85 | )
86 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/widget/ParcelWidgetLargeMiui.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.widget
2 |
3 | import android.app.PendingIntent
4 | import android.appwidget.AppWidgetManager
5 | import android.appwidget.AppWidgetProvider
6 | import android.content.Context
7 | import android.content.Intent
8 | import android.widget.RemoteViews
9 | import androidx.lifecycle.ViewModelProvider
10 | import androidx.lifecycle.ViewModelStoreOwner
11 | import com.xxxx.parcel.MainActivity
12 | import com.xxxx.parcel.R
13 | import com.xxxx.parcel.viewmodel.ParcelViewModel
14 | import com.xxxx.parcel.widget.ParcelWidget.Companion
15 |
16 | class ParcelWidgetLargeMiui : AppWidgetProvider() {
17 | override fun onReceive(context: Context, intent: Intent) {
18 |
19 | if ("miui.appwidget.action.APPWIDGET_UPDATE".equals(intent.getAction()) ||
20 | "com.xxxx.parcel.CUSTOM_SMS_ADDED".equals(intent.getAction())
21 | ) {
22 |
23 | // 获取 ParcelViewModel 实例
24 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
25 | ViewModelProvider(it)[ParcelViewModel::class.java]
26 | }
27 | ParcelWidget.updateAllByProvider(context, ParcelWidgetLargeMiui::class.java, viewModel)
28 |
29 | } else {
30 |
31 | super.onReceive(context, intent);
32 |
33 | }
34 |
35 | }
36 |
37 | override fun onUpdate(
38 | context: Context,
39 | appWidgetManager: AppWidgetManager,
40 | appWidgetIds: IntArray
41 | ) {
42 | // 获取 ParcelViewModel 实例
43 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
44 | ViewModelProvider(it)[ParcelViewModel::class.java]
45 | }
46 |
47 | // 为每个小部件执行更新
48 | for (appWidgetId in appWidgetIds) {
49 | updateAppWidget(context, appWidgetManager, appWidgetId, viewModel)
50 | }
51 | }
52 |
53 | override fun onEnabled(context: Context?) {
54 | super.onEnabled(context)
55 | // 当第一个小部件被添加时调用
56 | // 获取 ParcelViewModel 实例
57 | if (context != null) {
58 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
59 | ViewModelProvider(it)[ParcelViewModel::class.java]
60 | }
61 | ParcelWidget.updateAppWidget(
62 | context,
63 | AppWidgetManager.getInstance(context),
64 | null,
65 | viewModel
66 | )
67 | }
68 | }
69 |
70 | override fun onDisabled(context: Context?) {
71 | super.onDisabled(context)
72 | // 当最后一个小部件被移除时调用
73 | }
74 |
75 | companion object {
76 | internal fun updateAppWidget(
77 | context: Context,
78 | appWidgetManager: AppWidgetManager,
79 | appWidgetId: Int?,
80 | viewModel: ParcelViewModel?
81 | ) {
82 | ParcelWidget.updateAllByProvider(context, ParcelWidgetLargeMiui::class.java, viewModel)
83 | }
84 |
85 | private fun updateSingleAppWidget(
86 | context: Context,
87 | appWidgetManager: AppWidgetManager,
88 | appWidgetId: Int,
89 | viewModel: ParcelViewModel?
90 | ) {
91 | ParcelWidget.updateSingleAppWidget(context, appWidgetManager, appWidgetId, viewModel)
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/widget/ParcelWidgetLarge.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.widget
2 |
3 | import android.app.PendingIntent
4 | import android.appwidget.AppWidgetManager
5 | import android.appwidget.AppWidgetProvider
6 | import android.content.Context
7 | import android.content.Intent
8 | import android.widget.RemoteViews
9 | import androidx.lifecycle.ViewModelProvider
10 | import androidx.lifecycle.ViewModelStoreOwner
11 | import com.xxxx.parcel.MainActivity
12 | import com.xxxx.parcel.R
13 | import com.xxxx.parcel.viewmodel.ParcelViewModel
14 | import com.xxxx.parcel.widget.ParcelWidget.Companion
15 | import com.xxxx.parcel.util.getCustomList
16 | import com.xxxx.parcel.util.getCustomSmsList
17 | import com.xxxx.parcel.util.SmsParser
18 | import com.xxxx.parcel.util.isSameDay
19 |
20 | class ParcelWidgetLarge : AppWidgetProvider() {
21 | override fun onReceive(context: Context, intent: Intent) {
22 |
23 | if ("miui.appwidget.action.APPWIDGET_UPDATE".equals(intent.getAction()) ||
24 | "com.xxxx.parcel.CUSTOM_SMS_ADDED".equals(intent.getAction())) {
25 |
26 | // 获取 ParcelViewModel 实例
27 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
28 | ViewModelProvider(it)[ParcelViewModel::class.java]
29 | }
30 | ParcelWidget.updateAllByProvider(context, ParcelWidgetLarge::class.java, viewModel)
31 |
32 | } else {
33 |
34 | super.onReceive(context, intent);
35 |
36 | }
37 |
38 | }
39 |
40 | override fun onUpdate(
41 | context: Context,
42 | appWidgetManager: AppWidgetManager,
43 | appWidgetIds: IntArray
44 | ) {
45 | // 获取 ParcelViewModel 实例
46 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
47 | ViewModelProvider(it)[ParcelViewModel::class.java]
48 | }
49 |
50 | // 为每个小部件执行更新
51 | for (appWidgetId in appWidgetIds) {
52 | updateAppWidget(context, appWidgetManager, appWidgetId, viewModel)
53 | }
54 | }
55 |
56 | override fun onEnabled(context: Context?) {
57 | super.onEnabled(context)
58 | // 当第一个小部件被添加时调用
59 | // 获取 ParcelViewModel 实例
60 | if(context!=null) {
61 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
62 | ViewModelProvider(it)[ParcelViewModel::class.java]
63 | }
64 | ParcelWidget.updateAppWidget(
65 | context,
66 | AppWidgetManager.getInstance(context),
67 | null,
68 | viewModel
69 | )
70 | }
71 | }
72 |
73 | override fun onDisabled(context: Context?) {
74 | super.onDisabled(context)
75 | // 当最后一个小部件被移除时调用
76 | }
77 |
78 | companion object {
79 | internal fun updateAppWidget(
80 | context: Context,
81 | appWidgetManager: AppWidgetManager,
82 | appWidgetId: Int?,
83 | viewModel: ParcelViewModel?
84 | ) {
85 | ParcelWidget.updateAllByProvider(context, ParcelWidgetLarge::class.java, viewModel)
86 | }
87 |
88 | private fun updateSingleAppWidget(
89 | context: Context,
90 | appWidgetManager: AppWidgetManager,
91 | appWidgetId: Int,
92 | viewModel: ParcelViewModel?
93 | ) {
94 | ParcelWidget.updateSingleAppWidget(context, appWidgetManager, appWidgetId, viewModel)
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import java.util.Properties
2 | import java.io.FileInputStream
3 |
4 | plugins {
5 | alias(libs.plugins.android.application)
6 | alias(libs.plugins.kotlin.android)
7 | alias(libs.plugins.kotlin.compose)
8 | kotlin("plugin.serialization") version "1.9.10"
9 | }
10 |
11 | val keystoreProperties = Properties()
12 | val keystorePropertiesFile = rootProject.file("key.properties")
13 | if (keystorePropertiesFile.exists()) {
14 | keystoreProperties.load(FileInputStream(keystorePropertiesFile))
15 | }
16 | android {
17 | namespace = "com.xxxx.parcel"
18 | compileSdk = 35
19 |
20 | defaultConfig {
21 | applicationId = "com.xxxx.parcel"
22 | minSdk = 29
23 | targetSdk = 35
24 | versionCode = 40
25 | versionName = "1.0.40"
26 |
27 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
28 | }
29 | signingConfigs {
30 | create("release") {
31 | keyAlias = keystoreProperties["keyAlias"] as String
32 | keyPassword = keystoreProperties["keyPassword"] as String
33 | storeFile = keystoreProperties["storeFile"]?.let { file(it) }
34 | storePassword = keystoreProperties["storePassword"] as String
35 | }
36 | }
37 | buildTypes {
38 | release {
39 | signingConfig = signingConfigs.getByName("release")
40 | isMinifyEnabled = false
41 | proguardFiles(
42 | getDefaultProguardFile("proguard-android-optimize.txt"),
43 | "proguard-rules.pro"
44 | )
45 | }
46 | }
47 | compileOptions {
48 | sourceCompatibility = JavaVersion.VERSION_11
49 | targetCompatibility = JavaVersion.VERSION_11
50 | }
51 | kotlinOptions {
52 | jvmTarget = "11"
53 | }
54 | buildFeatures {
55 | compose = true
56 | }
57 | dependenciesInfo {
58 | // Disables dependency metadata when building APKs.
59 | includeInApk = false
60 | // Disables dependency metadata when building Android App Bundles.
61 | includeInBundle = false
62 | }
63 | }
64 |
65 | dependencies {
66 |
67 | implementation(libs.androidx.core.ktx)
68 | implementation(libs.androidx.lifecycle.runtime.ktx)
69 | implementation(libs.androidx.activity.compose)
70 | implementation(platform(libs.androidx.compose.bom))
71 | implementation(libs.androidx.ui)
72 | implementation(libs.androidx.ui.graphics)
73 | implementation(libs.androidx.ui.tooling.preview)
74 | implementation(libs.androidx.material3)
75 | implementation(libs.androidx.room.runtime.android)
76 | testImplementation(libs.junit)
77 | androidTestImplementation(libs.androidx.junit)
78 | androidTestImplementation(libs.androidx.espresso.core)
79 | androidTestImplementation(platform(libs.androidx.compose.bom))
80 | androidTestImplementation(libs.androidx.ui.test.junit4)
81 | debugImplementation(libs.androidx.ui.tooling)
82 | debugImplementation(libs.androidx.ui.test.manifest)
83 |
84 | implementation("androidx.navigation:navigation-compose:2.7.7")
85 | implementation("androidx.compose.material:material:1.5.4")
86 | implementation("androidx.compose.ui:ui-tooling-preview:1.5.4")
87 | debugImplementation("androidx.compose.ui:ui-tooling:1.5.4")
88 | testImplementation("junit:junit:4.13.2")
89 | androidTestImplementation("androidx.test.ext:junit:1.1.5")
90 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
91 | implementation("androidx.core:core-ktx:1.10.1") // 添加 Core KTX
92 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") // 添加序列化支持
93 | }
94 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/widget/ParcelWidgetXL.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.widget
2 |
3 | import android.app.PendingIntent
4 | import android.appwidget.AppWidgetManager
5 | import android.appwidget.AppWidgetProvider
6 | import android.content.Context
7 | import android.content.Intent
8 | import android.widget.RemoteViews
9 | import androidx.lifecycle.ViewModelProvider
10 | import androidx.lifecycle.ViewModelStoreOwner
11 | import com.xxxx.parcel.MainActivity
12 | import com.xxxx.parcel.R
13 | import com.xxxx.parcel.viewmodel.ParcelViewModel
14 |
15 | class ParcelWidgetXL : AppWidgetProvider() {
16 | override fun onReceive(context: Context, intent: Intent) {
17 |
18 | if ("miui.appwidget.action.APPWIDGET_UPDATE".equals(intent.getAction()) ||
19 | "com.xxxx.parcel.CUSTOM_SMS_ADDED".equals(intent.getAction())) {
20 |
21 | // 获取 ParcelViewModel 实例
22 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
23 | ViewModelProvider(it)[ParcelViewModel::class.java]
24 | }
25 | ParcelWidget.updateAllByProvider(context, ParcelWidgetXL::class.java, viewModel)
26 |
27 | } else {
28 |
29 | super.onReceive(context, intent);
30 |
31 | }
32 |
33 | }
34 |
35 | override fun onUpdate(
36 | context: Context,
37 | appWidgetManager: AppWidgetManager,
38 | appWidgetIds: IntArray
39 | ) {
40 | // 获取 ParcelViewModel 实例
41 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
42 | ViewModelProvider(it)[ParcelViewModel::class.java]
43 | }
44 |
45 | // 为每个小部件执行更新
46 | for (appWidgetId in appWidgetIds) {
47 | updateAppWidget(context, appWidgetManager, appWidgetId, viewModel)
48 | }
49 | }
50 |
51 | override fun onEnabled(context: Context?) {
52 | super.onEnabled(context)
53 | // 当第一个小部件被添加时调用
54 | // 获取 ParcelViewModel 实例
55 | if(context!=null) {
56 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
57 | ViewModelProvider(it)[ParcelViewModel::class.java]
58 | }
59 | updateAppWidget(
60 | context,
61 | AppWidgetManager.getInstance(context),
62 | null,
63 | viewModel
64 | )
65 | }
66 | }
67 |
68 | override fun onDisabled(context: Context?) {
69 | super.onDisabled(context)
70 | // 当最后一个小部件被移除时调用
71 | }
72 |
73 | companion object {
74 | internal fun updateAppWidget(
75 | context: Context,
76 | appWidgetManager: AppWidgetManager,
77 | appWidgetId: Int?,
78 | viewModel: ParcelViewModel?
79 | ) {
80 | // 如果没有提供 appWidgetId,则更新所有实例
81 | if (appWidgetId == null) {
82 | val manager = AppWidgetManager.getInstance(context)
83 | val ids = manager.getAppWidgetIds(
84 | android.content.ComponentName(context, ParcelWidgetXL::class.java)
85 | )
86 | for (id in ids) {
87 | updateSingleAppWidget(context, manager, id, viewModel)
88 | }
89 | } else {
90 | updateSingleAppWidget(context, appWidgetManager, appWidgetId, viewModel)
91 | }
92 | }
93 |
94 | private fun updateSingleAppWidget(
95 | context: Context,
96 | appWidgetManager: AppWidgetManager,
97 | appWidgetId: Int,
98 | viewModel: ParcelViewModel?
99 | ) {
100 | ParcelWidget.updateSingleAppWidget(context, appWidgetManager, appWidgetId, viewModel)
101 | }
102 | }
103 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/widget/ParcelWidgetMiui.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.widget
2 |
3 | import android.app.PendingIntent
4 | import android.appwidget.AppWidgetManager
5 | import android.appwidget.AppWidgetProvider
6 | import android.content.Context
7 | import android.content.Intent
8 | import android.widget.RemoteViews
9 | import androidx.lifecycle.ViewModelProvider
10 | import androidx.lifecycle.ViewModelStoreOwner
11 | import com.xxxx.parcel.MainActivity
12 | import com.xxxx.parcel.R
13 | import com.xxxx.parcel.viewmodel.ParcelViewModel
14 |
15 | class ParcelWidgetMiui : AppWidgetProvider() {
16 | override fun onReceive(context: Context, intent: Intent) {
17 |
18 | if ("miui.appwidget.action.APPWIDGET_UPDATE".equals(intent.getAction())) {
19 |
20 | // 获取 ParcelViewModel 实例
21 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
22 | ViewModelProvider(it)[ParcelViewModel::class.java]
23 | }
24 | updateAppWidget(
25 | context,
26 | AppWidgetManager.getInstance(context),
27 | null,
28 | viewModel
29 | )
30 |
31 | } else {
32 |
33 | super.onReceive(context, intent);
34 |
35 | }
36 |
37 | }
38 |
39 | override fun onUpdate(
40 | context: Context,
41 | appWidgetManager: AppWidgetManager,
42 | appWidgetIds: IntArray
43 | ) {
44 | // 获取 ParcelViewModel 实例
45 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
46 | ViewModelProvider(it)[ParcelViewModel::class.java]
47 | }
48 |
49 | // 为每个小部件执行更新
50 | for (appWidgetId in appWidgetIds) {
51 | updateAppWidget(context, appWidgetManager, appWidgetId, viewModel)
52 | }
53 | }
54 |
55 | override fun onEnabled(context: Context?) {
56 | super.onEnabled(context)
57 | // 当第一个小部件被添加时调用
58 | // 获取 ParcelViewModel 实例
59 | if (context != null) {
60 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
61 | ViewModelProvider(it)[ParcelViewModel::class.java]
62 | }
63 | updateAppWidget(
64 | context,
65 | AppWidgetManager.getInstance(context),
66 | null,
67 | viewModel
68 | )
69 | }
70 | }
71 |
72 | override fun onDisabled(context: Context?) {
73 | super.onDisabled(context)
74 | // 当最后一个小部件被移除时调用
75 | }
76 |
77 | companion object {
78 | internal fun updateAppWidget(
79 | context: Context,
80 | appWidgetManager: AppWidgetManager,
81 | appWidgetId: Int?,
82 | viewModel: ParcelViewModel?
83 | ) {
84 | // 如果没有提供 appWidgetId,则更新所有实例
85 | if (appWidgetId == null) {
86 | val manager = AppWidgetManager.getInstance(context)
87 | val ids = manager.getAppWidgetIds(
88 | android.content.ComponentName(context, ParcelWidgetMiui::class.java)
89 | )
90 | for (id in ids) {
91 | updateSingleAppWidget(context, manager, id, viewModel)
92 | }
93 | } else {
94 | updateSingleAppWidget(context, appWidgetManager, appWidgetId, viewModel)
95 | }
96 | }
97 |
98 | private fun updateSingleAppWidget(
99 | context: Context,
100 | appWidgetManager: AppWidgetManager,
101 | appWidgetId: Int,
102 | viewModel: ParcelViewModel?
103 | ) {
104 | ParcelWidget.updateSingleAppWidget(context, appWidgetManager, appWidgetId, viewModel)
105 | }
106 | }
107 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/util/PermissionUtil.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.util
2 |
3 | import android.app.Activity
4 | import android.app.AlertDialog
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.content.pm.PackageManager
8 | import android.net.Uri
9 | import android.os.Build
10 | import android.os.Environment
11 | import android.provider.Settings
12 | import android.provider.Telephony
13 | import android.widget.Toast
14 | import androidx.core.app.ActivityCompat
15 | import androidx.core.content.ContextCompat
16 | import java.io.File
17 | import java.io.FileInputStream
18 | import java.util.Properties
19 |
20 | object PermissionUtil {
21 | fun hasSmsPermissions(context: Context): Boolean {
22 | if (isMIUI()) {
23 | return isMiuiSmsGranted(context)
24 |
25 | } else {
26 | // 标准Android动态申请
27 | return ContextCompat.checkSelfPermission(
28 | context,
29 | android.Manifest.permission.READ_SMS
30 | ) == PackageManager.PERMISSION_GRANTED
31 | }
32 | }
33 |
34 | // 检测是否为MIUI系统
35 | fun isMIUI(): Boolean {
36 | return try {
37 | Build.MANUFACTURER.equals("Xiaomi", ignoreCase = true) &&
38 | File(Environment.getRootDirectory(), "build.prop").exists()
39 | } catch (e: Exception) {
40 | false
41 | }
42 | }
43 |
44 | fun showMiuiPermissionExplanationDialog(context: Context) {
45 | val builder = AlertDialog.Builder(context)
46 | builder.setTitle("权限申请说明")
47 | builder.setMessage("小米手机需要获取读取短信权限和通知类短信权限2个权限,以便能够自动读取件码。请点击确定前往设置页面开启这2个权限。")
48 | builder.setPositiveButton("确定") { dialog, which ->
49 | // 在这里调用跳转 MIUI 权限设置页的方法
50 | requestMiuiSmsPermission(context as android.app.Activity)
51 | dialog.dismiss()
52 | }
53 |
54 | val dialog = builder.create()
55 | dialog.show()
56 | }
57 |
58 | // 跳转MIUI权限设置页
59 | fun requestMiuiSmsPermission(activity: Activity) {
60 | try {
61 | val intent = Intent("miui.intent.action.APP_PERM_EDITOR").apply {
62 | setClassName(
63 | "com.miui.securitycenter",
64 | if (isMiuiV9OrHigher()) "com.miui.permcenter.permissions.PermissionsEditorActivity"
65 | else "com.miui.permcenter.permissions.AppPermissionsEditorActivity"
66 | )
67 | putExtra("extra_pkgname", activity.packageName)
68 | }
69 | activity.startActivityForResult(intent, 1)
70 | } catch (e: Exception) {
71 | // 备用方案:跳转系统设置
72 | val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
73 | data = Uri.parse("package:${activity.packageName}")
74 | }
75 | activity.startActivityForResult(intent, 1)
76 | }
77 | }
78 |
79 | private fun isMiuiV9OrHigher(): Boolean {
80 | return try {
81 | val properties = Properties().apply {
82 | load(FileInputStream(File(Environment.getRootDirectory(), "build.prop")))
83 | }
84 | properties.getProperty("ro.miui.ui.version.code", "0").toInt() >= 9
85 | } catch (e: Exception) {
86 | false
87 | }
88 | }
89 |
90 | }
91 | // 检测MIUI实际权限状态
92 | private fun isMiuiSmsGranted(context: Context): Boolean {
93 | return try {
94 | // 尝试读取短信验证触发权限检查
95 | val cursor = context.contentResolver.query(
96 | Telephony.Sms.CONTENT_URI,
97 | arrayOf(Telephony.Sms.BODY),
98 | null,
99 | null,
100 | null
101 | )
102 | cursor?.close()
103 | true
104 | } catch (e: SecurityException) {
105 | false
106 | }
107 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/service/NotificationAccessTileService.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.service
2 |
3 | import android.graphics.drawable.Icon
4 | import android.content.Context
5 | import android.content.ComponentName
6 | import android.provider.Settings
7 | import android.service.quicksettings.Tile
8 | import android.service.quicksettings.TileService
9 | import com.xxxx.parcel.service.ParcelNotificationListenerService
10 | import com.xxxx.parcel.util.getMainSwitch
11 | import com.xxxx.parcel.util.setMainSwitch
12 | import android.content.Intent
13 | import android.service.notification.NotificationListenerService
14 | import com.xxxx.parcel.R
15 |
16 | class NotificationAccessTileService : TileService() {
17 | override fun onStartListening() {
18 | val tile = qsTile ?: return
19 | val mainEnabled = getMainSwitch(this)
20 | val hasAccess = isNotificationAccessGranted(this)
21 | tile.state = if (mainEnabled && hasAccess) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
22 | tile.label = getString(R.string.app_name)
23 | tile.icon = Icon.createWithResource(this, R.drawable.ic_notification)
24 | tile.updateTile()
25 | val t = System.currentTimeMillis()
26 | if (t - lastFixTime > 3000L) {
27 | lastFixTime = t
28 | fixRestartNotificationListener()
29 | }
30 | }
31 |
32 | override fun onClick() {
33 | val hasAccess = isNotificationAccessGranted(this)
34 | val mainEnabled = getMainSwitch(this)
35 | if (!hasAccess) {
36 | val intent = Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
37 | startActivityAndCollapse(intent)
38 | } else {
39 | setMainSwitch(this, !mainEnabled)
40 | if (!mainEnabled) {
41 | try {
42 | NotificationListenerService.requestRebind(ComponentName(this, ParcelNotificationListenerService::class.java))
43 | } catch (_: Exception) { }
44 | fixRestartNotificationListener()
45 | }
46 | }
47 | onStartListening()
48 | }
49 |
50 | private fun isNotificationAccessGranted(context: Context): Boolean {
51 | val flat = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
52 | if (flat.isNullOrBlank()) return false
53 | val full = ComponentName(context, ParcelNotificationListenerService::class.java).flattenToString()
54 | val short = "${context.packageName}/.service.ParcelNotificationListenerService"
55 | return flat.split(":").any { it == full || it == short }
56 | }
57 |
58 | private fun fixRestartNotificationListener() {
59 | val ctx = this
60 | Thread {
61 | try {
62 | val full = ComponentName(ctx, ParcelNotificationListenerService::class.java).flattenToString()
63 | val cr = ctx.contentResolver
64 | val flat0 = Settings.Secure.getString(cr, "enabled_notification_listeners") ?: ""
65 | val list0 = flat0.split(":").filter { it.isNotBlank() }.toMutableList()
66 | if (list0.contains(full)) {
67 | list0.remove(full)
68 | Settings.Secure.putString(cr, "enabled_notification_listeners", list0.joinToString(":"))
69 | Thread.sleep(1000L)
70 | }
71 | val flat1 = Settings.Secure.getString(cr, "enabled_notification_listeners") ?: ""
72 | val list1 = flat1.split(":").filter { it.isNotBlank() }.toMutableList()
73 | if (!list1.contains(full)) {
74 | list1.add(full)
75 | Settings.Secure.putString(cr, "enabled_notification_listeners", list1.joinToString(":"))
76 | }
77 | Thread.sleep(2000L)
78 | try {
79 | NotificationListenerService.requestRebind(ComponentName(ctx, ParcelNotificationListenerService::class.java))
80 | } catch (_: Exception) { }
81 | } catch (_: Exception) { }
82 | }.start()
83 | }
84 |
85 | companion object {
86 | private var lastFixTime = 0L
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/util/SmsUtil.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.util
2 |
3 |
4 | import android.content.ContentResolver
5 | import android.content.Context
6 | import android.net.Uri
7 | import android.util.Log
8 | import android.widget.Toast
9 | import androidx.core.net.toUri
10 | import com.xxxx.parcel.model.SmsModel
11 | import java.text.SimpleDateFormat
12 | import java.util.Date
13 | import java.util.Locale
14 | import java.util.Calendar
15 |
16 | class SmsUtil {
17 | companion object {
18 | fun readAllSms(context: Context): List {
19 | return readSmsByTimeFilter(context, 0)
20 | }
21 |
22 | fun readSmsByTimeFilter(context: Context, daysFilter: Int): List {
23 | val smsList = mutableListOf()
24 | val contentResolver: ContentResolver = context.contentResolver
25 | val uri: Uri = "content://sms/inbox".toUri()
26 |
27 | var selection: String? = null
28 | var selectionArgs: Array? = null
29 |
30 | if (daysFilter > 0) {
31 | // 计算从00:00:00开始的时间范围
32 | val calendar = java.util.Calendar.getInstance()
33 | calendar.set(java.util.Calendar.HOUR_OF_DAY, 0)
34 | calendar.set(java.util.Calendar.MINUTE, 0)
35 | calendar.set(java.util.Calendar.SECOND, 0)
36 | calendar.set(java.util.Calendar.MILLISECOND, 0)
37 |
38 | // 减去天数
39 | calendar.add(java.util.Calendar.DAY_OF_YEAR, -(daysFilter - 1))
40 | val startTime = calendar.timeInMillis
41 | selection = "date >= ?"
42 | selectionArgs = arrayOf(startTime.toString())
43 | }
44 |
45 | try {
46 | val cursor = contentResolver.query(
47 | uri,
48 | arrayOf("_id", "body", "date"),
49 | selection,
50 | selectionArgs,
51 | "date DESC"
52 | )
53 | if (cursor != null) {
54 | if (cursor.moveToFirst()) {
55 | do {
56 | val id = cursor.getLong(cursor.getColumnIndexOrThrow("_id"))
57 | val messageBody = cursor.getString(cursor.getColumnIndexOrThrow("body"))
58 | val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow("date"))
59 |
60 | smsList.add(SmsModel(id.toString(), messageBody, timestamp))
61 | } while (cursor.moveToNext())
62 | }
63 | cursor.close()
64 | }
65 | } catch (e: Exception) {
66 | Toast.makeText(context, "读取短信失败: ${e.message}", Toast.LENGTH_LONG).show()
67 | Log.e("SmsUtil", "读取短信失败: ${e.message}")
68 | addLog(context, "读取短信失败: ${e.message}")
69 | }
70 |
71 | return smsList
72 | }
73 |
74 | fun inboxContainsBodyRecent(context: Context, body: String, windowMs: Long = 5 * 60 * 1000L): Boolean {
75 | return try {
76 | val resolver = context.contentResolver
77 | val uri: Uri = "content://sms/inbox".toUri()
78 | val now = System.currentTimeMillis()
79 | val selection = "date >= ? AND body = ?"
80 | val args = arrayOf((now - windowMs).toString(), body)
81 | resolver.query(uri, arrayOf("_id"), selection, args, null)?.use { c ->
82 | c.moveToFirst()
83 | } ?: false
84 | } catch (e: Exception) {
85 | false
86 | }
87 | }
88 | }
89 | }
90 |
91 | fun dateToString(timestamp: Long): String {
92 | // 时间戳转可读格式
93 | return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
94 | .format(Date(timestamp))
95 |
96 | }
97 |
98 | // 判断是否为自定义短信
99 | fun isCustomSms(sms: SmsModel): Boolean {
100 | return sms.body.startsWith("【自定义取件短信】")
101 | }
102 |
103 |
104 | fun isSameDay(ts1: Long, ts2: Long): Boolean {
105 | val c1 = Calendar.getInstance()
106 | c1.timeInMillis = ts1
107 | val c2 = Calendar.getInstance()
108 | c2.timeInMillis = ts2
109 | return c1.get(Calendar.YEAR) == c2.get(Calendar.YEAR) &&
110 | c1.get(Calendar.DAY_OF_YEAR) == c2.get(Calendar.DAY_OF_YEAR)
111 | }
112 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/util/SmsParser.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.util
2 |
3 | import android.util.Log
4 | import java.util.regex.Matcher
5 | import java.util.regex.Pattern
6 |
7 | class SmsParser {
8 | // 使用正则表达式来匹配地址和取件码(1个或多个取件码)优先匹配快递柜
9 | private val lockerPattern: Pattern =
10 | Pattern.compile("""(?i)([0-9]+)号(?:柜|快递柜|丰巢柜|蜂巢柜|熊猫柜|兔喜快递柜|)""")
11 | private val addressPattern: Pattern =
12 | Pattern.compile("""(?i)(地址|收货地址|送货地址|位于|放至|已到达|到达|已到|送达|到|已放入|已存放至|已存放|放入)[\s\S]*?([\w\s-]+?(?:门牌|驿站|,|,|。|$)\d*)""")
13 | private val codePattern: Pattern = Pattern.compile(
14 | """(?i)(取件码为|提货号为|取货码为|提货码为|取件码(|提货号(|取货码(|提货码(|取件码『|提货号『|取货码『|提货码『|取件码【|提货号【|取货码【|提货码【|取件码\(|提货号\(|取货码\(|提货码\(|取件码\[|提货号\[|取货码\[|提货码\[|取件码|提货号|取货码|提货码|凭|快递|京东|天猫|中通|顺丰|韵达|德邦|菜鸟|拼多多|EMS|闪送|美团|饿了么|盒马|叮咚买菜|UU跑腿|签收码|签收编号|操作码|提货编码|收货编码|签收编码|取件編號|提貨號碼|運單碼|快遞碼|快件碼|包裹碼|貨品碼)\s*[A-Za-z0-9\s-]{2,}(?:[,,、][A-Za-z0-9\s-]{2,})*"""
15 | )
16 |
17 | // 动态规则存储
18 | private val customAddressPatterns = mutableListOf()
19 | private val customCodePatterns = mutableListOf()
20 | private val ignoreKeywords = mutableListOf()
21 |
22 |
23 | data class ParseResult(val address: String, val code: String, val success: Boolean)
24 |
25 | fun parseSms(sms: String): ParseResult {
26 | var foundAddress = ""
27 | var foundCode = ""
28 |
29 | // 检查是否包含忽略关键词
30 | for (ignoreKeyword in ignoreKeywords) {
31 | if (ignoreKeyword.isNotBlank() && sms.contains(ignoreKeyword, ignoreCase = true)) {
32 | return ParseResult("", "", false)
33 | }
34 | }
35 |
36 | // 使用字符串匹配查找地址
37 | for (pattern in customAddressPatterns) {
38 | if (sms.contains(pattern, ignoreCase = true)) {
39 | foundAddress = pattern
40 | break
41 | }
42 | }
43 | for (pattern in customCodePatterns) {
44 | val matcher = pattern.matcher(sms)
45 | if (matcher.find()) {
46 | foundCode = matcher.group(1)?.toString() ?: ""
47 | break
48 | }
49 | }
50 |
51 | // 如果自定义规则没有找到,优先匹配柜号地址,其次默认规则
52 | if (foundAddress.isEmpty()) {
53 | val lockerMatcher: Matcher = lockerPattern.matcher(sms)
54 | foundAddress = if (lockerMatcher.find()) lockerMatcher.group(0)?.toString() ?: "" else ""
55 | if (foundAddress.isEmpty()) {
56 | val addressMatcher: Matcher = addressPattern.matcher(sms)
57 | foundAddress = if (addressMatcher.find()) addressMatcher.group(2)?.toString() ?: "" else ""
58 | }
59 | }
60 |
61 | if (foundCode.isEmpty()) {
62 | val codeMatcher: Matcher = codePattern.matcher(sms)
63 |
64 | while (codeMatcher.find()) {
65 | val match = codeMatcher.group(0)
66 | // 进一步将匹配到的内容按分隔符拆分成单个取件码
67 | val codes = match?.split(Regex("[,,、]"))
68 | foundCode = codes?.joinToString(", ") { it.trim() }?:""
69 | foundCode = foundCode.replace(Regex("[^A-Za-z0-9-, ]"), "")
70 | }
71 |
72 | }
73 | foundAddress = foundAddress.replace(Regex("[,,。]"), "") // 移除所有标点和符号
74 | foundAddress = foundAddress.replace("取件", "") // 移除"取件"
75 | return ParseResult(
76 | foundAddress,
77 | foundCode,
78 | foundAddress.isNotEmpty() && foundCode.isNotEmpty()
79 | )
80 | }
81 |
82 | // 添加自定义解析规则
83 |
84 | fun addCustomAddressPattern(pattern: String) {
85 | customAddressPatterns.add(pattern)
86 | }
87 |
88 | fun addCustomCodePattern(pattern: String) {
89 | customCodePatterns.add(Pattern.compile(pattern))
90 | }
91 |
92 | fun clearAllCustomPatterns() {
93 | customAddressPatterns.clear()
94 | customCodePatterns.clear()
95 | ignoreKeywords.clear()
96 | }
97 |
98 | fun addIgnoreKeyword(keyword: String) {
99 | if (keyword.isNotBlank() && !ignoreKeywords.contains(keyword)) {
100 | ignoreKeywords.add(keyword)
101 | }
102 | }
103 |
104 | fun removeIgnoreKeyword(keyword: String) {
105 | ignoreKeywords.remove(keyword)
106 | }
107 |
108 | fun getIgnoreKeywords(): List = ignoreKeywords.toList()
109 |
110 | fun clearIgnoreKeywords() {
111 | ignoreKeywords.clear()
112 | }
113 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/AboutScreen.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.content.pm.PackageManager
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material.icons.Icons
15 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
16 | import androidx.compose.material3.ExperimentalMaterial3Api
17 | import androidx.compose.material3.Icon
18 | import androidx.compose.material3.IconButton
19 | import androidx.compose.material3.MaterialTheme
20 | import androidx.compose.material3.Scaffold
21 | import androidx.compose.material3.Text
22 | import androidx.compose.material3.TextButton
23 | import androidx.compose.material3.TopAppBar
24 | import androidx.compose.runtime.Composable
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.graphics.Color
28 | import androidx.compose.ui.platform.LocalContext
29 | import androidx.compose.ui.unit.dp
30 | import androidx.core.net.toUri
31 | import androidx.navigation.NavController
32 |
33 | fun getAppVersionName(context: Context): String {
34 | try {
35 | // 获取 PackageManager 实例
36 | val packageManager = context.packageManager
37 | // 获取当前应用的包名
38 | val packageName = context.packageName
39 | // 获取应用信息,包含版本号等
40 | val packageInfo = packageManager.getPackageInfo(packageName, 0)
41 | // 返回版本名称
42 | return ("版本:" + packageInfo.versionName)
43 | } catch (e: PackageManager.NameNotFoundException) {
44 | e.printStackTrace()
45 | return "未知版本"
46 | }
47 | }
48 |
49 | @OptIn(ExperimentalMaterial3Api::class)
50 | @Composable
51 | fun AboutScreen(navController: NavController) {
52 | val context=LocalContext.current
53 | val url= "https://github.com/shareven/parcel"
54 |
55 |
56 | Scaffold(
57 | topBar = {
58 | TopAppBar(
59 | title = { Text("关于") },
60 | navigationIcon = {
61 | IconButton(
62 | onClick = { navController.navigateUp() },
63 | ) {
64 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
65 | }
66 | }
67 | )
68 |
69 | },
70 |
71 | ) {
72 | innerPadding ->
73 | Column(
74 | modifier = Modifier
75 | .fillMaxSize()
76 | .padding(innerPadding)
77 | .verticalScroll(rememberScrollState())
78 | .padding(16.dp),
79 | verticalArrangement = Arrangement.Center,
80 | horizontalAlignment = Alignment.CenterHorizontally
81 | ) {
82 | Text(
83 | text = "开源地址",
84 | style = MaterialTheme.typography.headlineLarge,
85 | modifier = Modifier.padding(bottom = 8.dp)
86 | )
87 |
88 | TextButton (
89 | onClick = {
90 | val intent = Intent(Intent.ACTION_VIEW, url.toUri())
91 | context.startActivity(intent)
92 | }
93 | ){
94 | Text(url, color = Color(0XFF6200EE) )
95 | }
96 | Spacer(modifier = Modifier.height(16.dp))
97 | Text(getAppVersionName(context),
98 | style = MaterialTheme.typography.bodyLarge,
99 | modifier = Modifier.padding(bottom = 16.dp))
100 |
101 | Spacer(modifier = Modifier.height(16.dp))
102 | Text(
103 | text = "这是一个免费、开源、无广告、不联网,追求简洁的app,不收集任何个人信息。\n\n本app会自动解析收到的短信,并从中提取出地址和取件码信息,可以展示到桌面卡片上(支持暗色模式)。\n\n您可以添加自定义规则来改进解析效果。\n\n还支持监听第三方app通知,自动保存取件码消息,帮微信朋友取快递更方便了。\n\n打开监听通知权限,还能实现后台进程保活,实时更新桌面卡片。\n\n桌面卡片添加:一般是藏在全部卡片-最底部的插件或者安卓小组件里面\n\n欢迎下载和使用!有问题或建议请提issue!",
104 | style = MaterialTheme.typography.bodyLarge,
105 | modifier = Modifier.padding(bottom = 16.dp)
106 | )
107 |
108 | Spacer(modifier = Modifier.height(16.dp))
109 |
110 |
111 |
112 |
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/widget_layout.xml:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
22 |
23 |
24 |
33 |
34 |
42 |
43 |
44 |
48 |
49 |
56 |
57 |
64 |
65 |
72 |
73 |
80 |
81 |
88 |
89 |
96 |
97 |
104 |
105 |
112 |
113 |
120 |
121 |
128 |
129 |
136 |
137 |
144 |
145 |
146 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/FailSmsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.lazy.LazyColumn
13 | import androidx.compose.foundation.lazy.items
14 | import androidx.compose.foundation.text.selection.SelectionContainer
15 | import androidx.compose.material.icons.Icons
16 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
17 | import androidx.compose.material.icons.filled.Delete
18 | import androidx.compose.material3.Button
19 | import androidx.compose.material3.Card
20 | import androidx.compose.material3.ExperimentalMaterial3Api
21 | import androidx.compose.material3.Icon
22 | import androidx.compose.material3.IconButton
23 | import androidx.compose.material3.MaterialTheme
24 | import androidx.compose.material3.OutlinedButton
25 | import androidx.compose.material3.Scaffold
26 | import androidx.compose.material3.Text
27 | import androidx.compose.material3.TopAppBar
28 | import androidx.compose.runtime.Composable
29 | import androidx.compose.runtime.collectAsState
30 | import androidx.compose.runtime.getValue
31 | import androidx.compose.ui.Alignment
32 | import androidx.compose.ui.Modifier
33 | import androidx.compose.ui.graphics.Color
34 | import androidx.compose.ui.platform.LocalContext
35 | import androidx.compose.ui.unit.dp
36 | import androidx.compose.ui.text.style.TextOverflow
37 | import androidx.navigation.NavController
38 | import com.xxxx.parcel.util.removeCustomSms
39 | import com.xxxx.parcel.viewmodel.ParcelViewModel
40 | import java.net.URLEncoder
41 | import com.xxxx.parcel.util.dateToString
42 | import com.xxxx.parcel.util.isCustomSms
43 |
44 | @SuppressLint("StateFlowValueCalledInComposition")
45 | @OptIn(ExperimentalMaterial3Api::class)
46 | @Composable
47 | fun FailSmsScreen(viewModel: ParcelViewModel, navController: NavController,readAndParseSms: () -> Unit = {}) {
48 |
49 | val context = LocalContext.current
50 | val failSmsData by viewModel.failedMessages.collectAsState()
51 |
52 | Scaffold(
53 | topBar = {
54 | TopAppBar(
55 | title = { Text("解析失败的短信(${failSmsData.size})") },
56 | navigationIcon = {
57 | IconButton(
58 | onClick = { navController.navigateUp() },
59 | ) {
60 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
61 | }
62 | }
63 | )
64 | }
65 | ) { innerPadding ->
66 | LazyColumn(
67 | modifier = Modifier
68 | .fillMaxSize()
69 | .padding(innerPadding)
70 | .padding(horizontal = 16.dp),
71 | verticalArrangement = Arrangement.Top,
72 | horizontalAlignment = Alignment.CenterHorizontally
73 | ) {
74 |
75 | items(failSmsData) { message ->
76 | Card(
77 | modifier = Modifier
78 | .padding(8.dp)
79 | .fillMaxSize()
80 | ) {
81 | Column(
82 | modifier = Modifier
83 | .padding(16.dp)
84 | .fillMaxSize(),
85 | horizontalAlignment = Alignment.Start
86 | ) {
87 | SelectionContainer {
88 | Text(
89 | text = message.body,
90 | modifier = Modifier
91 | .fillMaxWidth(),
92 | maxLines = 2,
93 | overflow = TextOverflow.Ellipsis,
94 | style = MaterialTheme.typography.bodyMedium
95 | )
96 | }
97 |
98 | Spacer(modifier = Modifier.height(8.dp))
99 | Text(
100 | text = "${dateToString(message.timestamp)}",
101 | style = MaterialTheme.typography.bodySmall,
102 | color = Color.Gray
103 | )
104 | Spacer(modifier = Modifier.height(8.dp))
105 | Row(
106 | modifier = Modifier.fillMaxWidth(),
107 | horizontalArrangement = Arrangement.spacedBy(8.dp)
108 | ) {
109 | Button(
110 | onClick = {
111 | val encodedMsg = URLEncoder.encode(message.body, "UTF-8")
112 | navController.navigate("add_rule?message=${encodedMsg}")
113 | },
114 | modifier = Modifier.weight(1f)
115 | ) {
116 | Text(text = "添加解析规则")
117 | }
118 |
119 | // 只有自定义短信才显示删除按钮
120 | if (isCustomSms(message)) {
121 | OutlinedButton(
122 | onClick = {
123 | removeCustomSms(context, message.id)
124 | // 重新读取所有数据
125 | readAndParseSms()
126 | },
127 | modifier = Modifier.weight(1f)
128 | ) {
129 | Icon(
130 | imageVector = Icons.Default.Delete,
131 | contentDescription = "删除",
132 | modifier = Modifier.padding(end = 4.dp)
133 | )
134 | Text(text = "删除")
135 | }
136 | }
137 | }
138 | }
139 | }
140 | }
141 | }
142 | }
143 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/viewmodel/ParcelViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.viewmodel
2 |
3 | import android.util.Log
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import com.xxxx.parcel.model.ParcelData
7 | import com.xxxx.parcel.model.SmsData
8 | import com.xxxx.parcel.model.SmsModel
9 | import com.xxxx.parcel.util.SmsParser
10 | import com.xxxx.parcel.util.isSameDay
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.StateFlow
13 | import kotlinx.coroutines.flow.asStateFlow
14 | import kotlinx.coroutines.launch
15 |
16 | class ParcelViewModel(private val smsParser: SmsParser = SmsParser()) : ViewModel() {
17 | // 所有短信列表
18 | private val _allMessages = MutableStateFlow>(emptyList())
19 |
20 | // 所有已取件id列表
21 | private val _allCompletedIds = MutableStateFlow>(emptyList())
22 |
23 | // 解析成功的短信
24 | private val _successSmsData = MutableStateFlow>(emptyList())
25 | val successSmsData: StateFlow> = _successSmsData
26 |
27 | // 解析失败的短信
28 | private val _failedMessages = MutableStateFlow>(emptyList())
29 | val failedMessages: StateFlow> = _failedMessages
30 |
31 | // 同一地址的取件码列表
32 | private val _parcelsData = MutableStateFlow>(emptyList())
33 | val parcelsData: StateFlow> = _parcelsData.asStateFlow()
34 |
35 | // 时间过滤器
36 | private val _timeFilterIndex = MutableStateFlow(0)
37 | val timeFilterIndex: StateFlow = _timeFilterIndex.asStateFlow()
38 |
39 | fun setTimeFilterIndex(i: Int) {
40 | _timeFilterIndex.value = i
41 | }
42 |
43 | fun setAllCompletedIds(list: List) {
44 | _allCompletedIds.value = list
45 | }
46 |
47 | fun addCompletedIds(list: List) {
48 | val data = _allCompletedIds.value.toMutableList()
49 | data.addAll(list)
50 | _allCompletedIds.value = data
51 | calculateNumAndIsCompleted()
52 | }
53 |
54 | fun removeCompletedId(id: String) {
55 | val data = _allCompletedIds.value.toMutableList()
56 | data.remove(id)
57 | _allCompletedIds.value = data
58 | calculateNumAndIsCompleted()
59 | }
60 |
61 | fun clearData() {
62 | _successSmsData.value = emptyList()
63 | _failedMessages.value = emptyList()
64 | _parcelsData.value = emptyList()
65 | }
66 |
67 | fun getAllMessage(list: List) {
68 | _allMessages.value = list
69 | handleReceivedSms()
70 | }
71 |
72 | fun getAllMessageWithCustom(list: List, customSmsList: List) {
73 | val combinedList = list + customSmsList
74 | _allMessages.value = combinedList
75 | handleReceivedSms()
76 | }
77 |
78 | // 处理接收到的短信
79 | fun handleReceivedSms() {
80 | clearData()
81 | viewModelScope.launch {
82 | _allMessages.value.forEach { sms ->
83 | val currentSuccessful = _successSmsData.value.toMutableList()
84 | val currentParcels = _parcelsData.value.toMutableList()
85 | val currentFailed = _failedMessages.value.toMutableList()
86 |
87 |
88 | val result: SmsParser.ParseResult = smsParser.parseSms(sms.body)
89 |
90 | if (result.success) {
91 | Log.d("成功短信", sms.body)
92 | Log.d("解析", "addr:${result.address} code:${result.code} ")
93 | currentSuccessful.add(SmsData(result.address, result.code, sms, sms.id))
94 | // 把同一地址的取件码添加到 parcels 列表中
95 | currentParcels.find { it.address == result.address }?.let { parcel ->
96 | val newItem = SmsData(result.address, result.code, sms, sms.id)
97 | val existsSameDaySameAddrCode = parcel.smsDataList.any { existing ->
98 | existing.address == newItem.address &&
99 | existing.code == newItem.code &&
100 | isSameDay(existing.sms.timestamp, newItem.sms.timestamp)
101 | }
102 | if (!existsSameDaySameAddrCode) {
103 | parcel.smsDataList.add(newItem)
104 | parcel.smsDataList.sortBy { x -> x.code }
105 | }
106 | } ?: run {
107 | currentParcels.add(
108 | ParcelData(
109 | result.address,
110 | mutableListOf(SmsData(result.address, result.code, sms, sms.id))
111 | )
112 | )
113 | }
114 | } else {
115 | Log.e("失败短信", sms.body)
116 | Log.e("解析", "addr:${result.address} code:${result.code} ")
117 | currentFailed.add(sms)
118 | }
119 | // 按时间降序排序
120 | currentSuccessful.sortByDescending { it.sms.timestamp }
121 | currentFailed.sortByDescending { it.timestamp }
122 | _successSmsData.emit(currentSuccessful)
123 | _parcelsData.emit(currentParcels)
124 | _failedMessages.emit(currentFailed)
125 |
126 | calculateNumAndIsCompleted()
127 | }
128 | }
129 | }
130 |
131 |
132 | //计算包裹数量, 判断是否已取件
133 | private fun calculateNumAndIsCompleted() {
134 |
135 | _parcelsData.value.let { currentList ->
136 | val newList = currentList.map { parcels ->
137 | parcels.copy().apply {
138 | num = smsDataList.sumOf { smsData ->
139 | val isCompleted = _allCompletedIds.value.contains(smsData.id) ?: false
140 | smsData.isCompleted = isCompleted
141 | if (!isCompleted) smsData.code.split(", ").size else 0
142 | }
143 | }
144 | }.sortedByDescending { it.num }
145 |
146 | _parcelsData.value = newList
147 | }
148 | }
149 |
150 | // 将自定义规则添加到 SmsParser
151 | fun addCustomAddressPattern(pattern: String) {
152 | smsParser.addCustomAddressPattern(pattern)
153 | }
154 |
155 | fun addCustomCodePattern(pattern: String) {
156 | smsParser.addCustomCodePattern(pattern)
157 | }
158 |
159 | fun addIgnoreKeyword(keyword: String) {
160 | smsParser.addIgnoreKeyword(keyword)
161 | }
162 |
163 | fun clearAllCustomPatterns() {
164 | smsParser.clearAllCustomPatterns()
165 | }
166 |
167 | }
168 |
169 |
170 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/SuccessSmsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.Context
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.lazy.LazyColumn
14 | import androidx.compose.foundation.lazy.items
15 | import androidx.compose.foundation.text.selection.SelectionContainer
16 | import androidx.compose.material.icons.Icons
17 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
18 | import androidx.compose.material.icons.filled.Delete
19 | import androidx.compose.material3.Button
20 | import androidx.compose.material3.Card
21 | import androidx.compose.material3.CardDefaults
22 | import androidx.compose.material3.ExperimentalMaterial3Api
23 | import androidx.compose.material3.Icon
24 | import androidx.compose.material3.IconButton
25 | import androidx.compose.material3.MaterialTheme
26 | import androidx.compose.material3.OutlinedButton
27 | import androidx.compose.material3.Scaffold
28 | import androidx.compose.material3.Text
29 | import androidx.compose.material3.TopAppBar
30 | import androidx.compose.runtime.Composable
31 | import androidx.compose.runtime.collectAsState
32 | import androidx.compose.runtime.getValue
33 | import androidx.compose.ui.Alignment
34 | import androidx.compose.ui.Modifier
35 | import androidx.compose.ui.graphics.Color
36 | import androidx.compose.ui.platform.LocalContext
37 | import androidx.compose.ui.unit.dp
38 | import androidx.navigation.NavController
39 | import com.xxxx.parcel.util.removeCustomSms
40 | import com.xxxx.parcel.viewmodel.ParcelViewModel
41 | import java.net.URLEncoder
42 | import com.xxxx.parcel.util.dateToString
43 | import com.xxxx.parcel.util.isCustomSms
44 |
45 | @SuppressLint("StateFlowValueCalledInComposition")
46 | @OptIn(ExperimentalMaterial3Api::class)
47 | @Composable
48 | fun SuccessSmsScreen(
49 | viewModel: ParcelViewModel,
50 | navController: NavController,
51 | readAndParseSms: () -> Unit = {}
52 | ) {
53 | val context = LocalContext.current
54 | val successSmsData by viewModel.successSmsData.collectAsState()
55 |
56 | Scaffold(
57 | topBar = {
58 | TopAppBar(
59 | title = { Text("已解析的短信(${successSmsData.size})") },
60 | navigationIcon = {
61 | IconButton(
62 | onClick = { navController.navigateUp() },
63 | ) {
64 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
65 | }
66 | }
67 | )
68 | }
69 | ) { innerPadding ->
70 | LazyColumn(
71 | modifier = Modifier
72 | .fillMaxSize()
73 | .padding(innerPadding)
74 | .padding(horizontal = 16.dp),
75 | verticalArrangement = Arrangement.Top,
76 | horizontalAlignment = Alignment.CenterHorizontally
77 | ) {
78 |
79 |
80 | items(successSmsData) { data ->
81 | Card(
82 | modifier = Modifier
83 | .fillMaxWidth()
84 | .padding(8.dp),
85 | elevation = CardDefaults.cardElevation(defaultElevation = 3.dp)
86 | ) {
87 | Column(
88 | modifier = Modifier
89 | .fillMaxWidth()
90 | .padding(16.dp),
91 | verticalArrangement = Arrangement.Center,
92 | horizontalAlignment = Alignment.Start
93 | ) {
94 | SelectionContainer {
95 | Text(
96 | text = data.sms.body,
97 | style = MaterialTheme.typography.bodyMedium
98 | )
99 | }
100 | Spacer(modifier = Modifier.height(8.dp))
101 | Text(
102 | text = "${dateToString(data.sms.timestamp)}",
103 | style = MaterialTheme.typography.bodySmall,
104 | color = Color.Gray
105 | )
106 | Spacer(modifier = Modifier.height(4.dp))
107 | SelectionContainer {
108 | Text(
109 | text = "地址: ${data.address}\n取件码: ${data.code}",
110 | color = Color(0xFF25AF22),
111 | style = MaterialTheme.typography.bodyLarge
112 |
113 | )
114 | }
115 | Spacer(modifier = Modifier.height(16.dp))
116 |
117 | Row(
118 | modifier = Modifier.fillMaxWidth(),
119 | horizontalArrangement = Arrangement.spacedBy(8.dp)
120 | ) {
121 | Button(
122 | onClick = {
123 | val encodedMsg = URLEncoder.encode(data.sms.body, "UTF-8")
124 | navController.navigate("add_rule?message=${encodedMsg}")
125 | },
126 | modifier = Modifier.weight(1f)
127 | ) {
128 | Text(text = "添加解析规则")
129 | }
130 |
131 | // 只有自定义短信才显示删除按钮
132 | if (isCustomSms(data.sms)) {
133 | OutlinedButton(
134 | onClick = {
135 | removeCustomSms(context, data.sms.id)
136 | // 重新读取所有数据
137 | readAndParseSms()
138 | },
139 | modifier = Modifier.weight(1f)
140 | ) {
141 | Icon(
142 | imageVector = Icons.Default.Delete,
143 | contentDescription = "删除",
144 | modifier = Modifier.padding(end = 4.dp)
145 | )
146 | Text(text = "删除")
147 | }
148 | }
149 | }
150 | }
151 | }
152 | }
153 | }
154 |
155 |
156 | }
157 | }
158 |
159 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/LogScreen.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui
2 |
3 | import android.app.DatePickerDialog
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.lazy.LazyColumn
13 | import androidx.compose.foundation.lazy.items
14 | import androidx.compose.material.icons.Icons
15 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
16 | import androidx.compose.foundation.text.selection.SelectionContainer
17 | import androidx.compose.ui.platform.LocalClipboardManager
18 | import androidx.compose.ui.text.AnnotatedString
19 | import androidx.compose.material3.ExperimentalMaterial3Api
20 | import androidx.compose.material3.Icon
21 | import androidx.compose.material3.IconButton
22 | import androidx.compose.material3.MaterialTheme
23 | import androidx.compose.material3.Text
24 | import androidx.compose.material3.TextButton
25 | import androidx.compose.material3.TopAppBar
26 | import androidx.compose.material3.Scaffold
27 | import androidx.compose.material3.Card
28 | import androidx.compose.material3.CardDefaults
29 | import androidx.compose.foundation.shape.RoundedCornerShape
30 | import androidx.compose.foundation.rememberScrollState
31 | import androidx.compose.foundation.verticalScroll
32 | import android.widget.Toast
33 | import androidx.compose.runtime.Composable
34 | import androidx.compose.runtime.mutableStateOf
35 | import androidx.compose.runtime.remember
36 | import androidx.compose.runtime.getValue
37 | import androidx.compose.runtime.setValue
38 | import androidx.compose.ui.Modifier
39 | import androidx.compose.ui.platform.LocalContext
40 | import androidx.compose.ui.unit.dp
41 | import androidx.navigation.NavController
42 | import com.xxxx.parcel.util.getLogs
43 | import com.xxxx.parcel.util.clearAllLogs
44 | import java.text.SimpleDateFormat
45 | import java.util.Calendar
46 | import java.util.Date
47 | import java.util.Locale
48 |
49 | @OptIn(ExperimentalMaterial3Api::class)
50 | @Composable
51 | fun LogScreen(navController: NavController) {
52 | val context = LocalContext.current
53 | val selectedDay = remember { mutableStateOf(null) }
54 | val sdfDay = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
55 | val sdfTime = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
56 | var logs by remember { mutableStateOf(getLogs(context, selectedDay.value)) }
57 | val clipboard = LocalClipboardManager.current
58 |
59 | Scaffold(
60 | topBar = {
61 | TopAppBar(
62 | title = { Text("日志") },
63 | navigationIcon = {
64 | IconButton(onClick = { navController.navigateUp() }) {
65 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
66 | }
67 | },
68 | actions = {
69 | TextButton(onClick = { clearAllLogs(context); logs = emptyList() }) { Text("清除全部") }
70 | }
71 | )
72 | }
73 | ) { inner ->
74 | Column(
75 | modifier = Modifier
76 | .fillMaxSize()
77 | .padding(inner)
78 | .padding(horizontal = 16.dp),
79 | ) {
80 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
81 | TextButton(onClick = {
82 | val cal = Calendar.getInstance()
83 | val dialog = DatePickerDialog(
84 | context,
85 | { _, year, month, dayOfMonth ->
86 | val c = Calendar.getInstance()
87 | c.set(year, month, dayOfMonth, 0, 0, 0)
88 | c.set(Calendar.MILLISECOND, 0)
89 | selectedDay.value = c.timeInMillis
90 | },
91 | cal.get(Calendar.YEAR),
92 | cal.get(Calendar.MONTH),
93 | cal.get(Calendar.DAY_OF_MONTH)
94 | )
95 | dialog.show()
96 | }) { Text("选择日期") }
97 | TextButton(onClick = { selectedDay.value = null }) { Text("显示全部") }
98 | TextButton(onClick = {
99 | val content = logs.joinToString(separator = "\n\n") { entry ->
100 | val dayStr = sdfDay.format(Date(entry.timestamp))
101 | val timeStr = sdfTime.format(Date(entry.timestamp))
102 | val ver = entry.version.ifBlank { "" }
103 |
104 | "${if (ver.isNotBlank()) "[v$ver] " else ""}$dayStr $timeStr\n${entry.text}"
105 | }
106 | clipboard.setText(AnnotatedString(content))
107 | Toast.makeText(context, "已复制", Toast.LENGTH_SHORT).show()
108 | }) { Text("复制") }
109 |
110 |
111 | }
112 | Spacer(modifier = Modifier.height(8.dp))
113 |
114 | Card(
115 | modifier = Modifier.fillMaxSize(),
116 | elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
117 | shape = RoundedCornerShape(12.dp)
118 | ) {
119 | Column(modifier = Modifier.padding(12.dp).verticalScroll(rememberScrollState())) {
120 | if (logs.isEmpty()) {
121 | Text(text = "暂无日志", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
122 | } else {
123 | SelectionContainer {
124 | Column(modifier = Modifier.fillMaxWidth()) {
125 | logs.forEach { entry ->
126 | val dayStr = sdfDay.format(Date(entry.timestamp))
127 | val timeStr = sdfTime.format(Date(entry.timestamp))
128 | val ver = entry.version.ifBlank { "" }
129 | Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
130 | Text(text = (if (ver.isNotBlank()) "[v$ver] " else "") + "$dayStr $timeStr", style = MaterialTheme.typography.bodyMedium)
131 | Spacer(modifier = Modifier.height(4.dp))
132 | Text(text = entry.text, style = MaterialTheme.typography.bodyLarge)
133 | }
134 | }
135 | }
136 | }
137 | }
138 | }
139 | }
140 | }
141 | }
142 | androidx.compose.runtime.LaunchedEffect(selectedDay.value) {
143 | logs = getLogs(context, selectedDay.value)
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/AddRuleScreen.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui
2 |
3 | import android.content.Context
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.text.selection.SelectionContainer
14 | import androidx.compose.foundation.verticalScroll
15 | import androidx.compose.material.icons.Icons
16 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
17 | import androidx.compose.material3.Button
18 | import androidx.compose.material3.Card
19 | import androidx.compose.material3.ExperimentalMaterial3Api
20 | import androidx.compose.material3.Icon
21 | import androidx.compose.material3.IconButton
22 | import androidx.compose.material3.MaterialTheme
23 | import androidx.compose.material3.OutlinedTextField
24 | import androidx.compose.material3.Scaffold
25 | import androidx.compose.material3.Text
26 | import androidx.compose.material3.TextButton
27 | import androidx.compose.material3.TopAppBar
28 | import androidx.compose.runtime.Composable
29 | import androidx.compose.runtime.getValue
30 | import androidx.compose.runtime.mutableStateOf
31 | import androidx.compose.runtime.remember
32 | import androidx.compose.runtime.setValue
33 | import androidx.compose.ui.Alignment
34 | import androidx.compose.ui.Modifier
35 | import androidx.compose.ui.unit.dp
36 | import androidx.navigation.NavController
37 | import com.xxxx.parcel.util.addCustomList
38 | import com.xxxx.parcel.viewmodel.ParcelViewModel
39 |
40 | @OptIn(ExperimentalMaterial3Api::class)
41 | @Composable
42 | fun AddRuleScreen(
43 | context: Context,
44 | viewModel: ParcelViewModel,
45 | navController: NavController,
46 | message: String,
47 | onCallback: () -> Unit
48 | ) {
49 |
50 | var addressPattern by remember { mutableStateOf("") }
51 | var codePattern by remember { mutableStateOf("") }
52 | var ignoreKeyword by remember { mutableStateOf("") }
53 |
54 | Scaffold(
55 | topBar = {
56 | TopAppBar(
57 | title = { Text("新增规则") },
58 | navigationIcon = {
59 | IconButton(
60 | onClick = { navController.navigateUp() },
61 | ) {
62 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
63 | }
64 | },
65 | actions = {
66 | TextButton(
67 | onClick = {
68 | navController.navigate("rules")
69 | }
70 | ) {
71 | Text("规则列表")
72 | }
73 | }
74 | )
75 | }
76 | ) { innerPadding ->
77 | Column(
78 | modifier = Modifier
79 | .fillMaxSize()
80 | .padding(innerPadding)
81 | .verticalScroll(rememberScrollState())
82 | .padding(16.dp),
83 | verticalArrangement = Arrangement.Top,
84 | horizontalAlignment = Alignment.CenterHorizontally
85 | ) {
86 |
87 |
88 | Card(
89 | modifier = Modifier
90 | .padding(8.dp)
91 | ) {
92 | Column(
93 | modifier = Modifier
94 | .padding(16.dp),
95 |
96 | ) {
97 | SelectionContainer {
98 | Text(
99 | text = message,
100 | modifier = Modifier
101 | .fillMaxWidth(),
102 | style = MaterialTheme.typography.bodyMedium
103 | )
104 | }
105 |
106 | Spacer(modifier = Modifier.height(16.dp))
107 |
108 | Column {
109 | Text(
110 | text = "复制短信中的 取件码 填入",
111 | style = MaterialTheme.typography.bodyMedium
112 | )
113 | Spacer(modifier = Modifier.height(8.dp))
114 | OutlinedTextField(
115 | value = codePattern,
116 | placeholder = {Text("选填")},
117 | onValueChange = { codePattern = it },
118 | modifier = Modifier.fillMaxWidth()
119 | )
120 | }
121 | Spacer(modifier = Modifier.height(16.dp))
122 | Column {
123 | Text(
124 | text = "复制短信中的 地址 填入",
125 | style = MaterialTheme.typography.bodyMedium
126 | )
127 | Spacer(modifier = Modifier.height(8.dp))
128 | OutlinedTextField(
129 | value = addressPattern,
130 | placeholder = {Text("选填")},
131 | onValueChange = { addressPattern = it },
132 | modifier = Modifier.fillMaxWidth()
133 | )
134 | }
135 |
136 | Spacer(modifier = Modifier.height(16.dp))
137 | Column {
138 | Text(
139 | text = "填入关键词,不解析短信",
140 | style = MaterialTheme.typography.bodyMedium
141 | )
142 | Spacer(modifier = Modifier.height(8.dp))
143 | OutlinedTextField(
144 | value = ignoreKeyword,
145 | placeholder = {Text("选填")},
146 | onValueChange = { ignoreKeyword = it },
147 | modifier = Modifier.fillMaxWidth(),
148 | )
149 | Spacer(modifier = Modifier.height(8.dp))
150 | }
151 |
152 | Spacer(modifier = Modifier.height(16.dp))
153 |
154 | Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
155 |
156 |
157 | Button(
158 | enabled = addressPattern.isNotEmpty() && message.contains(addressPattern) || codePattern.isNotEmpty() && message.contains(
159 | codePattern
160 | ) || ignoreKeyword.isNotEmpty(),
161 | onClick = {
162 | if (addressPattern.isNotBlank()) {
163 | addCustomList(context, "address", addressPattern)
164 | viewModel.addCustomAddressPattern(addressPattern)
165 | addressPattern = ""
166 | }
167 | if (codePattern.isNotBlank()) {
168 | // 转义正则表达式中的特殊字符,但保留捕获组
169 | val escapedCodePattern = java.util.regex.Pattern.quote(codePattern)
170 |
171 | // 分别转义取件码前后的部分为字面字符
172 | val parts = message.split(codePattern, limit = 2)
173 | val regexPattern = if (parts.size == 2) {
174 | java.util.regex.Pattern.quote(parts[0]) + """([\s\S]{2,})""" + java.util.regex.Pattern.quote(parts[1])
175 | } else {
176 | // 如果分割失败,使用原来的方法
177 | java.util.regex.Pattern.quote(message).replace(escapedCodePattern, """([\s\S]{2,})""")
178 | }
179 |
180 | addCustomList(
181 | context,
182 | "code",
183 | regexPattern
184 | )
185 | viewModel.addCustomCodePattern(regexPattern)
186 | codePattern = ""
187 | }
188 | if (ignoreKeyword.isNotBlank()) {
189 | addCustomList(context, "ignoreKeywords", ignoreKeyword)
190 | viewModel.addIgnoreKeyword(ignoreKeyword)
191 | ignoreKeyword = ""
192 | }
193 | onCallback()
194 | navController.navigate("rules")
195 | }
196 | ) {
197 | Text(text = "点击自动添加规则")
198 | }
199 | }
200 |
201 | }
202 |
203 | }
204 | }
205 | }
206 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
90 | ' "$PWD" ) || exit
91 |
92 | # Use the maximum available, or set MAX_FD != -1 to use that value.
93 | MAX_FD=maximum
94 |
95 | warn () {
96 | echo "$*"
97 | } >&2
98 |
99 | die () {
100 | echo
101 | echo "$*"
102 | echo
103 | exit 1
104 | } >&2
105 |
106 | # OS specific support (must be 'true' or 'false').
107 | cygwin=false
108 | msys=false
109 | darwin=false
110 | nonstop=false
111 | case "$( uname )" in #(
112 | CYGWIN* ) cygwin=true ;; #(
113 | Darwin* ) darwin=true ;; #(
114 | MSYS* | MINGW* ) msys=true ;; #(
115 | NONSTOP* ) nonstop=true ;;
116 | esac
117 |
118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
119 |
120 |
121 | # Determine the Java command to use to start the JVM.
122 | if [ -n "$JAVA_HOME" ] ; then
123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
124 | # IBM's JDK on AIX uses strange locations for the executables
125 | JAVACMD=$JAVA_HOME/jre/sh/java
126 | else
127 | JAVACMD=$JAVA_HOME/bin/java
128 | fi
129 | if [ ! -x "$JAVACMD" ] ; then
130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
131 |
132 | Please set the JAVA_HOME variable in your environment to match the
133 | location of your Java installation."
134 | fi
135 | else
136 | JAVACMD=java
137 | if ! command -v java >/dev/null 2>&1
138 | then
139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
140 |
141 | Please set the JAVA_HOME variable in your environment to match the
142 | location of your Java installation."
143 | fi
144 | fi
145 |
146 | # Increase the maximum file descriptors if we can.
147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
148 | case $MAX_FD in #(
149 | max*)
150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
151 | # shellcheck disable=SC2039,SC3045
152 | MAX_FD=$( ulimit -H -n ) ||
153 | warn "Could not query maximum file descriptor limit"
154 | esac
155 | case $MAX_FD in #(
156 | '' | soft) :;; #(
157 | *)
158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
159 | # shellcheck disable=SC2039,SC3045
160 | ulimit -n "$MAX_FD" ||
161 | warn "Could not set maximum file descriptor limit to $MAX_FD"
162 | esac
163 | fi
164 |
165 | # Collect all arguments for the java command, stacking in reverse order:
166 | # * args from the command line
167 | # * the main class name
168 | # * -classpath
169 | # * -D...appname settings
170 | # * --module-path (only if needed)
171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
172 |
173 | # For Cygwin or MSYS, switch paths to Windows format before running java
174 | if "$cygwin" || "$msys" ; then
175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
177 |
178 | JAVACMD=$( cygpath --unix "$JAVACMD" )
179 |
180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
181 | for arg do
182 | if
183 | case $arg in #(
184 | -*) false ;; # don't mess with options #(
185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
186 | [ -e "$t" ] ;; #(
187 | *) false ;;
188 | esac
189 | then
190 | arg=$( cygpath --path --ignore --mixed "$arg" )
191 | fi
192 | # Roll the args list around exactly as many times as the number of
193 | # args, so each arg winds up back in the position where it started, but
194 | # possibly modified.
195 | #
196 | # NB: a `for` loop captures its iteration list before it begins, so
197 | # changing the positional parameters here affects neither the number of
198 | # iterations, nor the values presented in `arg`.
199 | shift # remove old arg
200 | set -- "$@" "$arg" # push replacement arg
201 | done
202 | fi
203 |
204 |
205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
207 |
208 | # Collect all arguments for the java command:
209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
210 | # and any embedded shellness will be escaped.
211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
212 | # treated as '${Hostname}' itself on the command line.
213 |
214 | set -- \
215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
216 | -classpath "$CLASSPATH" \
217 | org.gradle.wrapper.GradleWrapperMain \
218 | "$@"
219 |
220 | # Stop when "xargs" is not available.
221 | if ! command -v xargs >/dev/null 2>&1
222 | then
223 | die "xargs is not available"
224 | fi
225 |
226 | # Use "xargs" to parse quoted args.
227 | #
228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
229 | #
230 | # In Bash we could simply go:
231 | #
232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
233 | # set -- "${ARGS[@]}" "$@"
234 | #
235 | # but POSIX shell has neither arrays nor command substitution, so instead we
236 | # post-process each arg (as a line of input to sed) to backslash-escape any
237 | # character that might be a shell metacharacter, then use eval to reverse
238 | # that process (while maintaining the separation between arguments), and wrap
239 | # the whole thing up as a single "set" statement.
240 | #
241 | # This will of course break if any of these variables contains a newline or
242 | # an unmatched quote.
243 | #
244 |
245 | eval "set -- $(
246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
247 | xargs -n1 |
248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
249 | tr '\n' ' '
250 | )" '"$@"'
251 |
252 | exec "$JAVACMD" "$@"
253 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
43 |
46 |
47 |
59 |
62 |
63 |
64 |
65 |
66 |
69 |
72 |
73 |
74 |
75 |
76 |
77 |
80 |
81 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
93 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
108 |
111 |
112 |
115 |
116 |
119 |
120 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
137 |
138 |
141 |
142 |
145 |
146 |
149 |
150 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
181 |
182 |
183 |
184 |
185 |
186 |
190 |
191 |
192 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/RulesScreen.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.Context
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.layout.size
13 | import androidx.compose.foundation.layout.width
14 | import androidx.compose.foundation.lazy.LazyColumn
15 | import androidx.compose.foundation.lazy.items
16 | import androidx.compose.foundation.text.selection.SelectionContainer
17 | import androidx.compose.material.icons.Icons
18 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
19 | import androidx.compose.material.icons.outlined.Delete
20 | import androidx.compose.material3.Card
21 | import androidx.compose.material3.ExperimentalMaterial3Api
22 | import androidx.compose.material3.Icon
23 | import androidx.compose.material3.IconButton
24 | import androidx.compose.material3.MaterialTheme
25 | import androidx.compose.material3.Scaffold
26 | import androidx.compose.material3.Text
27 | import androidx.compose.material3.TextButton
28 | import androidx.compose.material3.TopAppBar
29 | import androidx.compose.runtime.Composable
30 | import androidx.compose.runtime.LaunchedEffect
31 | import androidx.compose.runtime.getValue
32 | import androidx.compose.runtime.mutableStateListOf
33 | import androidx.compose.runtime.mutableStateOf
34 | import androidx.compose.runtime.remember
35 | import androidx.compose.runtime.setValue
36 | import androidx.compose.ui.Alignment
37 | import androidx.compose.ui.Modifier
38 | import androidx.compose.ui.unit.dp
39 | import androidx.navigation.NavController
40 | import com.xxxx.parcel.util.clearAllCustomPatterns
41 | import com.xxxx.parcel.util.clearCustomPattern
42 | import com.xxxx.parcel.util.getCustomList
43 | import com.xxxx.parcel.viewmodel.ParcelViewModel
44 | import kotlinx.coroutines.delay
45 | import kotlin.time.Duration
46 |
47 | @SuppressLint("StateFlowValueCalledInComposition")
48 | @OptIn(ExperimentalMaterial3Api::class)
49 | @Composable
50 | fun RulesScreen(
51 | context: Context,
52 | viewModel: ParcelViewModel,
53 | navController: NavController,
54 | onCallback: () -> Unit
55 | ) {
56 | var listAddr by remember { mutableStateOf(mutableListOf()) }
57 | var listCode by remember { mutableStateOf(mutableListOf()) }
58 | var listIgnoreKeywords by remember { mutableStateOf(mutableListOf()) }
59 |
60 | fun getDate() {
61 | listAddr = getCustomList(context, "address").toMutableList()
62 | listCode = getCustomList(context, "code").toMutableList()
63 | listIgnoreKeywords = getCustomList(context, "ignoreKeywords").toMutableList()
64 | }
65 |
66 | fun onDelete(key: String, pattern: String) {
67 | clearCustomPattern(context, key, pattern, viewModel)
68 |
69 | getDate()
70 | onCallback()
71 | }
72 |
73 | LaunchedEffect(Unit) {
74 | getDate()
75 | }
76 |
77 | Scaffold(
78 | topBar = {
79 | TopAppBar(
80 | title = { Text("规则列表") },
81 | navigationIcon = {
82 | IconButton(
83 | onClick = { navController.navigateUp() },
84 | ) {
85 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
86 | }
87 | },
88 | actions = {
89 | TextButton(
90 | onClick = {
91 | clearAllCustomPatterns(context, viewModel)
92 | onCallback()
93 | navController.navigate("home")
94 | }
95 | ) {
96 | Text("清除所有自定规则")
97 | }
98 | }
99 | )
100 | }
101 | ) { innerPadding ->
102 | LazyColumn(
103 | modifier = Modifier
104 | .fillMaxSize()
105 | .padding(innerPadding)
106 | .padding(16.dp),
107 | verticalArrangement = Arrangement.Top,
108 | horizontalAlignment = Alignment.CenterHorizontally
109 | ) {
110 | item {
111 | Text(
112 | text = "自定义地址规则",
113 | style = MaterialTheme.typography.titleSmall
114 | )
115 | }
116 | items(listAddr) { address ->
117 | Card(
118 | modifier = Modifier
119 | .padding(8.dp)
120 | .fillMaxWidth()
121 |
122 | ) {
123 | Row(
124 | verticalAlignment = Alignment.CenterVertically,
125 | horizontalArrangement = Arrangement.SpaceBetween,
126 | modifier = Modifier
127 | .fillMaxWidth()
128 | .padding(
129 | horizontal = 16.dp,
130 | vertical = 8.dp
131 | )
132 | ) {
133 | SelectionContainer {
134 | Text(
135 | text = address,
136 | style = MaterialTheme.typography.bodyMedium,
137 | modifier = Modifier.weight(1f)
138 | )
139 | }
140 |
141 | IconButton(
142 | modifier = Modifier.size(36.dp),
143 | onClick = { onDelete("address", address) }
144 | ) {
145 | Icon(
146 | imageVector = Icons.Outlined.Delete,
147 | contentDescription = "删除规则",
148 | tint = MaterialTheme.colorScheme.error
149 | )
150 | }
151 | }
152 |
153 | }
154 | }
155 | item {
156 | Spacer(Modifier.height(16.dp))
157 | }
158 | item {
159 | Text(
160 | text = "自定义取件码规则",
161 | style = MaterialTheme.typography.titleSmall
162 | )
163 | }
164 | items(listCode) { code ->
165 | Card(
166 | modifier = Modifier
167 | .padding(8.dp)
168 | .fillMaxWidth()
169 | ) {
170 |
171 | Row(
172 | verticalAlignment = Alignment.CenterVertically,
173 | horizontalArrangement = Arrangement.SpaceBetween,
174 | modifier = Modifier
175 | .fillMaxWidth()
176 | .padding(
177 | horizontal = 16.dp,
178 | vertical = 8.dp
179 | )
180 | ) {
181 |
182 | Text(
183 | text = code,
184 | style = MaterialTheme.typography.bodyMedium,
185 | modifier = Modifier.weight(1f)
186 | )
187 |
188 |
189 | IconButton(
190 | modifier = Modifier.size(36.dp),
191 | onClick = { onDelete("code", code) }
192 | ) {
193 | Icon(
194 | imageVector = Icons.Outlined.Delete,
195 | contentDescription = "删除规则",
196 | tint = MaterialTheme.colorScheme.error
197 | )
198 | }
199 | }
200 | }
201 | }
202 | item {
203 | Spacer(Modifier.height(16.dp))
204 | }
205 | item {
206 | Text(
207 | text = "不解析关键词",
208 | style = MaterialTheme.typography.titleSmall
209 | )
210 | }
211 | items(listIgnoreKeywords) { keyword ->
212 | Card(
213 | modifier = Modifier
214 | .padding(8.dp)
215 | .fillMaxWidth()
216 | ) {
217 | Row(
218 | verticalAlignment = Alignment.CenterVertically,
219 | horizontalArrangement = Arrangement.SpaceBetween,
220 | modifier = Modifier
221 | .fillMaxWidth()
222 | .padding(
223 | horizontal = 16.dp,
224 | vertical = 8.dp
225 | )
226 | ) {
227 | Text(
228 | text = keyword,
229 | style = MaterialTheme.typography.bodyMedium,
230 | modifier = Modifier.weight(1f)
231 | )
232 |
233 | IconButton(
234 | modifier = Modifier.size(36.dp),
235 | onClick = { onDelete("ignoreKeywords", keyword) }
236 | ) {
237 | Icon(
238 | imageVector = Icons.Outlined.Delete,
239 | contentDescription = "删除关键词",
240 | tint = MaterialTheme.colorScheme.error
241 | )
242 | }
243 | }
244 | }
245 | }
246 | }
247 | }
248 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/widget/ParcelWidget.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.widget
2 |
3 | import android.app.PendingIntent
4 | import android.appwidget.AppWidgetManager
5 | import android.appwidget.AppWidgetProvider
6 | import android.content.Context
7 | import android.content.Intent
8 | import android.widget.RemoteViews
9 | import androidx.lifecycle.ViewModelProvider
10 | import androidx.lifecycle.ViewModelStoreOwner
11 | import com.xxxx.parcel.util.getCustomList
12 | import com.xxxx.parcel.util.getCustomSmsList
13 | import com.xxxx.parcel.util.SmsParser
14 | import com.xxxx.parcel.util.isSameDay
15 | import com.xxxx.parcel.util.SmsUtil
16 | import com.xxxx.parcel.util.getIndex
17 | import com.xxxx.parcel.MainActivity
18 | import com.xxxx.parcel.R
19 | import com.xxxx.parcel.util.getIndex
20 | import com.xxxx.parcel.viewmodel.ParcelViewModel
21 |
22 | class ParcelWidget : AppWidgetProvider() {
23 | override fun onReceive(context: Context, intent: Intent) {
24 |
25 | if ("miui.appwidget.action.APPWIDGET_UPDATE".equals(intent.getAction()) ||
26 | "com.xxxx.parcel.CUSTOM_SMS_ADDED".equals(intent.getAction())) {
27 |
28 | // 获取 ParcelViewModel 实例
29 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
30 | ViewModelProvider(it)[ParcelViewModel::class.java]
31 | }
32 | updateAppWidget(
33 | context,
34 | AppWidgetManager.getInstance(context),
35 | null,
36 | viewModel
37 | )
38 |
39 | } else {
40 |
41 | super.onReceive(context, intent);
42 |
43 | }
44 |
45 | }
46 |
47 | override fun onUpdate(
48 | context: Context,
49 | appWidgetManager: AppWidgetManager,
50 | appWidgetIds: IntArray
51 | ) {
52 | // 获取 ParcelViewModel 实例
53 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
54 | ViewModelProvider(it)[ParcelViewModel::class.java]
55 | }
56 |
57 | // 为每个小部件执行更新
58 | for (appWidgetId in appWidgetIds) {
59 | updateAppWidget(context, appWidgetManager, appWidgetId, viewModel)
60 | }
61 | }
62 |
63 | override fun onEnabled(context: Context?) {
64 | super.onEnabled(context)
65 | // 当第一个小部件被添加时调用
66 | // 获取 ParcelViewModel 实例
67 | if(context!=null) {
68 | val viewModel = (context.applicationContext as? ViewModelStoreOwner)?.let {
69 | ViewModelProvider(it)[ParcelViewModel::class.java]
70 | }
71 | updateAppWidget(
72 | context,
73 | AppWidgetManager.getInstance(context),
74 | null,
75 | viewModel
76 | )
77 | }
78 | }
79 |
80 | override fun onDisabled(context: Context?) {
81 | super.onDisabled(context)
82 | // 当最后一个小部件被移除时调用
83 | }
84 |
85 | companion object {
86 | internal fun updateAppWidget(
87 | context: Context,
88 | appWidgetManager: AppWidgetManager,
89 | appWidgetId: Int?,
90 | viewModel: ParcelViewModel?
91 | ) {
92 | // 如果没有提供 appWidgetId,则更新所有实例
93 | if (appWidgetId == null) {
94 | val manager = AppWidgetManager.getInstance(context)
95 | val ids = manager.getAppWidgetIds(
96 | android.content.ComponentName(context, ParcelWidget::class.java)
97 | )
98 | for (id in ids) {
99 | updateSingleAppWidget(context, manager, id, viewModel)
100 | }
101 | } else {
102 | updateSingleAppWidget(context, appWidgetManager, appWidgetId, viewModel)
103 | }
104 | }
105 |
106 | fun updateAllByProvider(
107 | context: Context,
108 | providerClass: Class,
109 | viewModel: ParcelViewModel?
110 | ) {
111 | val manager = AppWidgetManager.getInstance(context)
112 | val ids = manager.getAppWidgetIds(
113 | android.content.ComponentName(context, providerClass)
114 | )
115 | for (id in ids) {
116 | updateSingleAppWidget(context, manager, id, viewModel)
117 | }
118 | }
119 |
120 | fun updateSingleAppWidget(
121 | context: Context,
122 | appWidgetManager: AppWidgetManager,
123 | appWidgetId: Int,
124 | viewModel: ParcelViewModel?
125 | ) {
126 | var total = 0
127 | var address1 = ""
128 | var codeList1 = ""
129 | var address2 = ""
130 | var codeList2 = ""
131 | var address3 = ""
132 | var codeList3 = ""
133 | var address4 = ""
134 | var codeList4 = ""
135 | var address5 = ""
136 | var codeList5 = ""
137 | var address6 = ""
138 | var codeList6 = ""
139 |
140 | val parcels = viewModel?.parcelsData?.value
141 | if (parcels != null && parcels.isNotEmpty()) {
142 | total = parcels.sumOf { it.num }
143 | fun fill(idx: Int, setAddr: (String)->Unit, setCodes: (String)->Unit) {
144 | val item = parcels.getOrNull(idx)
145 | if (item != null && item.num > 0) {
146 | val codes = item.smsDataList.filter { !it.isCompleted }.map { it.code }.joinToString("\n")
147 | setAddr(item.address + "(${item.num})")
148 | setCodes(codes)
149 | } else {
150 | setAddr("")
151 | setCodes("")
152 | }
153 | }
154 | fill(0, { address1 = it }, { codeList1 = it })
155 | fill(1, { address2 = it }, { codeList2 = it })
156 | fill(2, { address3 = it }, { codeList3 = it })
157 | fill(3, { address4 = it }, { codeList4 = it })
158 | fill(4, { address5 = it }, { codeList5 = it })
159 | fill(5, { address6 = it }, { codeList6 = it })
160 | } else {
161 | val parser = SmsParser()
162 | getCustomList(context, "address").forEach { if (it.isNotBlank()) parser.addCustomAddressPattern(it) }
163 | getCustomList(context, "code").forEach { if (it.isNotBlank()) parser.addCustomCodePattern(it) }
164 | getCustomList(context, "ignoreKeywords").forEach { if (it.isNotBlank()) parser.addIgnoreKeyword(it) }
165 | val completedIds = getCustomList(context, "completedIds")
166 | val daysFilter = getIndex(context)
167 | val mergedList = (SmsUtil.readSmsByTimeFilter(context, daysFilter) + getCustomSmsList(context))
168 | val grouped = mutableMapOf>>()
169 | mergedList.forEach { sms ->
170 | val r = parser.parseSms(sms.body)
171 | if (r.success) {
172 | val addr = r.address
173 | val code = r.code
174 | val list = grouped.getOrPut(addr) { mutableListOf() }
175 | val sameDaySame = list.any { it.first == code && isSameDay(it.second, sms.timestamp) }
176 | if (!sameDaySame) list.add(Triple(code, sms.timestamp, sms.id))
177 | }
178 | }
179 | val ordered = grouped.map { (addr, codes) ->
180 | val effectiveCodes = codes.filterNot { triple -> completedIds.contains(triple.third) }
181 | addr to effectiveCodes
182 | }.sortedByDescending { it.second.size }
183 |
184 | total = ordered.sumOf { it.second.size }
185 | fun fill2(idx: Int, setAddr: (String)->Unit, setCodes: (String)->Unit) {
186 | val item = ordered.getOrNull(idx)
187 | if (item != null && item.second.isNotEmpty()) {
188 | setAddr(item.first + "(${item.second.size})")
189 | setCodes(item.second.joinToString("\n") { it.first })
190 | } else {
191 | setAddr("")
192 | setCodes("")
193 | }
194 | }
195 | fill2(0, { address1 = it }, { codeList1 = it })
196 | fill2(1, { address2 = it }, { codeList2 = it })
197 | fill2(2, { address3 = it }, { codeList3 = it })
198 | fill2(3, { address4 = it }, { codeList4 = it })
199 | fill2(4, { address5 = it }, { codeList5 = it })
200 | fill2(5, { address6 = it }, { codeList6 = it })
201 | }
202 |
203 |
204 | // 构建 RemoteViews 对象
205 | val views = RemoteViews(context.packageName, R.layout.widget_layout).apply {
206 | setTextViewText(R.id.parcel_num, total.toString())
207 | setTextViewText(R.id.widget_address1, address1)
208 | setTextViewText(R.id.widget_codes1, codeList1)
209 |
210 | setTextViewText(R.id.widget_address2, address2)
211 | setTextViewText(R.id.widget_codes2, codeList2)
212 | setTextViewText(R.id.widget_address3, address3)
213 | setTextViewText(R.id.widget_codes3, codeList3)
214 |
215 | setTextViewText(R.id.widget_address4, address4)
216 | setTextViewText(R.id.widget_codes4, codeList4)
217 | setTextViewText(R.id.widget_address5, address5)
218 | setTextViewText(R.id.widget_codes5, codeList5)
219 | setTextViewText(R.id.widget_address6, address6)
220 | setTextViewText(R.id.widget_codes6, codeList6)
221 |
222 | // 设置点击意图
223 | val intent = Intent(context, MainActivity::class.java).apply {
224 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
225 | }
226 | setOnClickPendingIntent(
227 | R.id.widget_container,
228 | PendingIntent.getActivity(
229 | context,
230 | 0,
231 | intent,
232 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
233 | )
234 | )
235 | }
236 |
237 | // 更新 App Widget
238 | appWidgetManager.updateAppWidget(appWidgetId, views)
239 | }
240 | }
241 | }
242 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/ui/AddCustomSmsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.ui
2 |
3 | import android.content.ClipboardManager
4 | import android.content.Context
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.layout.width
14 | import androidx.compose.foundation.rememberScrollState
15 | import androidx.compose.foundation.text.selection.SelectionContainer
16 | import androidx.compose.foundation.verticalScroll
17 | import androidx.compose.material.icons.Icons
18 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
19 | import androidx.compose.material3.Button
20 | import androidx.compose.material3.Card
21 | import androidx.compose.material3.ExperimentalMaterial3Api
22 | import androidx.compose.material3.Icon
23 | import androidx.compose.material3.IconButton
24 | import androidx.compose.material3.MaterialTheme
25 | import androidx.compose.material3.OutlinedTextField
26 | import androidx.compose.material3.Scaffold
27 | import androidx.compose.material3.Text
28 | import androidx.compose.material3.TextButton
29 | import androidx.compose.material3.TopAppBar
30 | import androidx.compose.runtime.Composable
31 | import androidx.compose.runtime.LaunchedEffect
32 | import androidx.compose.runtime.getValue
33 | import androidx.compose.runtime.mutableStateOf
34 | import androidx.compose.runtime.remember
35 | import androidx.compose.runtime.setValue
36 | import androidx.compose.ui.Alignment
37 | import androidx.compose.ui.Modifier
38 | import androidx.compose.ui.graphics.Color
39 | import androidx.compose.ui.platform.LocalContext
40 | import androidx.compose.ui.text.font.FontWeight
41 | import androidx.compose.ui.unit.dp
42 | import androidx.navigation.NavController
43 | import com.xxxx.parcel.model.SmsModel
44 | import com.xxxx.parcel.util.addCustomSms
45 | import com.xxxx.parcel.viewmodel.ParcelViewModel
46 | import java.util.regex.Pattern
47 |
48 | @OptIn(ExperimentalMaterial3Api::class)
49 | @Composable
50 | fun AddCustomSmsScreen(
51 | context: Context,
52 | viewModel: ParcelViewModel,
53 | navController: NavController,
54 | address: String,
55 | onCallback: () -> Unit
56 | ) {
57 | var pickupCode by remember { mutableStateOf("") }
58 | var addressSet by remember { mutableStateOf(address) }
59 | var generatedSmsContent by remember { mutableStateOf("") }
60 | var isPickupCodeValid by remember { mutableStateOf(true) }
61 | var isSms by remember { mutableStateOf(false) }
62 | var sms by remember { mutableStateOf("") }
63 | var validationMessage by remember { mutableStateOf("") }
64 |
65 | // 取件码校验正则表达式
66 | val pickupCodePattern = Pattern.compile("[A-Za-z0-9\\s-]{2,}(?:[,,、][A-Za-z0-9\\s-]{2,})*")
67 |
68 | // 校验取件码函数
69 | fun validatePickupCode(code: String): Boolean {
70 | return if (code.isEmpty()) {
71 | validationMessage = "取件码不能为空"
72 | false
73 | } else if (code.length < 2) {
74 | validationMessage = "最小长度为2"
75 | false
76 | } else if (!pickupCodePattern.matcher(code).matches()) {
77 | validationMessage = "取件码格式不正确,应包含字母、数字、空格或短横线,长度至少2位"
78 |
79 | false
80 | } else {
81 | validationMessage = ""
82 | true
83 | }
84 | }
85 |
86 | // 校验是否为短信
87 | fun validateIsSms(code: String): Boolean {
88 | return if (code.isEmpty()) {
89 | false
90 | } else if (code.length < 2) {
91 | false
92 | } else if (pickupCodePattern.matcher(code).matches()) {
93 | false
94 | } else {
95 | true
96 | }
97 | }
98 |
99 |
100 | // 自动粘贴剪贴板内容到取件码输入框
101 | LaunchedEffect(Unit) {
102 | val clipboardManager =
103 | context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
104 | val clipData = clipboardManager.primaryClip
105 | if (clipData != null && clipData.itemCount > 0) {
106 | val clipText = clipData.getItemAt(0).text?.toString() ?: ""
107 |
108 | isSms = validateIsSms(clipText)
109 | if (isSms) {
110 | sms = clipText
111 | } else {
112 | pickupCode = clipText
113 | }
114 | }
115 | }
116 |
117 | // 根据取件码或短信自动生成自定义短信内容
118 | LaunchedEffect(pickupCode, addressSet, sms, isSms) {
119 | if (isSms) {
120 | if (sms.isNotEmpty()) {
121 | generatedSmsContent = "【自定义取件短信】${sms}"
122 | } else {
123 | generatedSmsContent = ""
124 | }
125 | } else if (addressSet.isNotEmpty() && pickupCode.isNotEmpty()) {
126 | generatedSmsContent = "【自定义取件短信】取件码${pickupCode},包裹已到${addressSet}"
127 | } else {
128 | generatedSmsContent = ""
129 | }
130 | }
131 |
132 | Scaffold(
133 | topBar = {
134 | TopAppBar(
135 | title = { Text("新增自定义取件短信") },
136 | navigationIcon = {
137 | IconButton(
138 | onClick = { navController.navigateUp() },
139 | ) {
140 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
141 | }
142 | }
143 | )
144 | }
145 | ) { innerPadding ->
146 | Column(
147 | modifier = Modifier
148 | .fillMaxSize()
149 | .padding(innerPadding)
150 | .verticalScroll(rememberScrollState())
151 | .padding(16.dp),
152 | verticalArrangement = Arrangement.Top,
153 | horizontalAlignment = Alignment.CenterHorizontally
154 | ) {
155 | Text(
156 | text = "自动识别复制内容是取件码还是短信",
157 | style = MaterialTheme.typography.bodyMedium
158 | )
159 | Card(
160 | modifier = Modifier
161 | .padding(8.dp)
162 | ) {
163 | Column(
164 | modifier = Modifier
165 | .padding(16.dp),
166 | ) {
167 |
168 | if (isSms) {
169 |
170 | Column(
171 | modifier = Modifier
172 | .padding(16.dp),
173 | ) {
174 |
175 |
176 | // 取件码输入框
177 | Column {
178 | Text(
179 | text = "短信(可自动粘贴短信)",
180 | style = MaterialTheme.typography.bodyMedium
181 | )
182 | OutlinedTextField(
183 | value = sms,
184 | onValueChange = {
185 | sms = it
186 | },
187 | modifier = Modifier.fillMaxWidth(),
188 | placeholder = { Text("请输入短信") },
189 |
190 | )
191 | }
192 |
193 | Spacer(modifier = Modifier.height(16.dp))
194 |
195 |
196 | }
197 | } else {
198 |
199 | Column(
200 | modifier = Modifier
201 | .padding(16.dp),
202 | ) {
203 | // 显示地址信息
204 | Column {
205 | Text(
206 | text = "取件地址:",
207 | style = MaterialTheme.typography.bodyMedium
208 | )
209 | OutlinedTextField(
210 | value = addressSet,
211 | onValueChange = { addressSet = it },
212 | modifier = Modifier.fillMaxWidth()
213 | )
214 | }
215 |
216 | Spacer(modifier = Modifier.height(16.dp))
217 |
218 | // 取件码输入框
219 | Column {
220 | Text(
221 | text = "取件码(可自动粘贴取件码)",
222 | style = MaterialTheme.typography.bodyMedium
223 | )
224 | OutlinedTextField(
225 | value = pickupCode,
226 | onValueChange = {
227 | pickupCode = it
228 | isPickupCodeValid = validatePickupCode(it)
229 | },
230 | modifier = Modifier.fillMaxWidth(),
231 | placeholder = { Text("请输入取件码") },
232 | isError = !isPickupCodeValid,
233 | supportingText = {
234 | if (!isPickupCodeValid && validationMessage.isNotEmpty()) {
235 | Text(
236 | text = validationMessage,
237 | color = MaterialTheme.colorScheme.error
238 | )
239 | }
240 | }
241 | )
242 | }
243 |
244 | Spacer(modifier = Modifier.height(16.dp))
245 |
246 |
247 | }
248 | }
249 | // 显示自动生成的短信内容
250 | if (generatedSmsContent.isNotEmpty()) {
251 | Column {
252 | Text(
253 | text = "生成的短信内容:",
254 | style = MaterialTheme.typography.bodyMedium
255 | )
256 | SelectionContainer {
257 | Text(
258 | text = generatedSmsContent,
259 | modifier = Modifier
260 | .fillMaxWidth()
261 | .padding(vertical = 8.dp),
262 | color = Color(0xFF25AF22),
263 | style = MaterialTheme.typography.bodyMedium
264 | )
265 | }
266 | }
267 |
268 | Spacer(modifier = Modifier.height(16.dp))
269 | }
270 | }
271 | }
272 |
273 | Row(
274 | modifier = Modifier.fillMaxWidth(),
275 | horizontalArrangement = Arrangement.Center
276 | ) {
277 | TextButton(
278 | onClick = {
279 | isSms = !isSms
280 | }
281 | ) {
282 | if (isSms) Text(text = "输入取件码") else Text(text = "输入短信")
283 | }
284 | Spacer(modifier = Modifier.width(8.dp))
285 | Button(
286 | enabled = generatedSmsContent.isNotEmpty(),
287 | onClick = {
288 | val currentTime = System.currentTimeMillis()
289 | val smsModel = SmsModel(
290 | id = currentTime.toString(),
291 | body = generatedSmsContent,
292 | timestamp = currentTime
293 | )
294 | addCustomSms(context, smsModel)
295 | onCallback()
296 | navController.navigate("home")
297 |
298 | }
299 | ) {
300 | Text(text = "点击保存")
301 | }
302 | }
303 | }
304 | }
305 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/service/ParcelNotificationListenerService.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel.service
2 |
3 | import android.service.notification.NotificationListenerService
4 | import android.service.notification.StatusBarNotification
5 | import android.app.Notification
6 | import android.content.ComponentName
7 | import android.os.Bundle
8 | import android.content.Context
9 | import android.content.Intent
10 | import android.util.Log
11 | import androidx.core.app.NotificationManagerCompat
12 | import androidx.core.app.NotificationCompat
13 | import android.app.NotificationChannel
14 | import android.app.NotificationManager
15 | import android.os.Build
16 | import com.xxxx.parcel.model.SmsModel
17 | import com.xxxx.parcel.util.addCustomSms
18 | import com.xxxx.parcel.util.SmsParser
19 | import com.xxxx.parcel.util.getCustomList
20 | import com.xxxx.parcel.util.isMainSwitchEnabled
21 | import com.xxxx.parcel.util.isAppSwitchEnabled
22 | import com.xxxx.parcel.util.getTitleForPackage
23 | import com.xxxx.parcel.util.getTitlesForPackage
24 | import com.xxxx.parcel.util.ThirdPartyDefaults
25 | import com.xxxx.parcel.util.addLog
26 | import com.xxxx.parcel.util.SmsUtil
27 | import com.xxxx.parcel.util.getSystemSmsPackages
28 | import com.xxxx.parcel.util.getSystemSmsNotifySwitch
29 | import com.xxxx.parcel.util.getSystemSmsPackages
30 | import com.xxxx.parcel.util.getSystemSmsNotifySwitch
31 |
32 | class ParcelNotificationListenerService : NotificationListenerService() {
33 |
34 | companion object {
35 | @Volatile private var lastContent: String? = null
36 | @Volatile private var lastTs: Long = 0L
37 | }
38 |
39 | private val pddPackage = ThirdPartyDefaults.PDD_PACKAGE
40 | private val douyinPackage = ThirdPartyDefaults.DOUYIN_PACKAGE
41 | private val xhsPackage = ThirdPartyDefaults.XHS_PACKAGE
42 | private val wechatPackage = ThirdPartyDefaults.WECHAT_PACKAGE
43 |
44 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
45 | super.onStartCommand(intent, flags, startId)
46 | return START_STICKY
47 | }
48 |
49 | override fun onListenerDisconnected() {
50 | super.onListenerDisconnected()
51 | val enabled = NotificationManagerCompat.getEnabledListenerPackages(applicationContext).contains(applicationContext.packageName)
52 | if (enabled) {
53 | val componentName = ComponentName(this, ParcelNotificationListenerService::class.java)
54 | requestRebind(componentName)
55 | }
56 | }
57 |
58 | override fun onListenerConnected() {
59 | super.onListenerConnected()
60 | try {
61 | val channelId = "parcel_notify_channel"
62 | if (Build.VERSION.SDK_INT >= 26) {
63 | val mgr = getSystemService(NotificationManager::class.java)
64 | if (mgr?.getNotificationChannel(channelId) == null) {
65 | val ch = NotificationChannel(
66 | channelId,
67 | "监听状态",
68 | NotificationManager.IMPORTANCE_MIN
69 | )
70 | ch.setShowBadge(false)
71 | ch.enableLights(false)
72 | ch.enableVibration(false)
73 | mgr?.createNotificationChannel(ch)
74 | }
75 | }
76 | val notif = NotificationCompat.Builder(this, channelId)
77 | .setOngoing(true)
78 | .setSmallIcon(com.xxxx.parcel.R.drawable.ic_notification)
79 | .setContentTitle("取件通知监听")
80 | .setContentText("监听已开启")
81 | .setPriority(NotificationCompat.PRIORITY_MIN)
82 | .build()
83 | startForeground(1001, notif)
84 | } catch (_: Exception) { }
85 | }
86 |
87 | override fun onNotificationPosted(sbn: StatusBarNotification) {
88 | val context = applicationContext
89 | try {
90 | if (!isMainSwitchEnabled(context)) return
91 |
92 | val pkg = sbn.packageName ?: return
93 | val extras = sbn.notification.extras
94 | val title = extras.getString(Notification.EXTRA_TITLE) ?: ""
95 | val conversationTitle = extras.getString(Notification.EXTRA_CONVERSATION_TITLE) ?: ""
96 | val subText = extras.getString(Notification.EXTRA_SUB_TEXT) ?: ""
97 | // 更稳健地提取通知正文,避免因为空文本而提前返回
98 | val text = extractNotificationText(extras)
99 | when (pkg) {
100 | pddPackage -> {
101 | if (isAppSwitchEnabled(context, pddPackage) && title == getTitleForPackage(context, pddPackage, defaultTitle = ThirdPartyDefaults.defaultTitleFor(pddPackage))) {
102 | addLog(context, "PDD通知: ${text}")
103 | addNotificationAsCustomSmsIfNotInInboxDelayed(context, text)
104 | }
105 | }
106 | douyinPackage -> {
107 | if (isAppSwitchEnabled(context, douyinPackage) && title == getTitleForPackage(context, douyinPackage, defaultTitle = ThirdPartyDefaults.defaultTitleFor(douyinPackage))) {
108 | addLog(context, "抖音通知: ${text}")
109 | addNotificationAsCustomSmsIfNotInInboxDelayed(context, text)
110 | }
111 | }
112 | xhsPackage -> {
113 | if (isAppSwitchEnabled(context, xhsPackage) && title == getTitleForPackage(context, xhsPackage, defaultTitle = ThirdPartyDefaults.defaultTitleFor(xhsPackage))) {
114 | addLog(context, "小红书通知: ${text}")
115 | addNotificationAsCustomSmsIfNotInInboxDelayed(context, text)
116 | }
117 | }
118 | wechatPackage -> {
119 | // 微信通知标题通常为会话名;
120 | if (isAppSwitchEnabled(context, wechatPackage)) {
121 | val titles = getTitlesForPackage(context, wechatPackage, count = 5, defaultFirst = ThirdPartyDefaults.WECHAT_DEFAULT_FIRST)
122 | val normalizedSaved = titles.filter { it.isNotBlank() }.map { it.trim() }
123 | val candidates = listOf(title, conversationTitle, subText, sbn.notification.tickerText?.toString() ?: "")
124 | .map { it.trim() }
125 | .filter { it.isNotEmpty() }
126 |
127 | if (normalizedSaved.isNotEmpty() && candidates.any { cand -> normalizedSaved.any { it == cand } }) {
128 | // 文本可能在 MessagingStyle 或 textLines 中,已在 extractNotificationText 处理
129 | if (text.isNotBlank()) {
130 | // 仅当解析成功时才保存
131 | if (shouldSaveBasedOnParse(context, text)) {
132 | addLog(context, "微信通知: ${text}")
133 | addNotificationAsCustomSmsIfNotInInboxDelayed(context, text)
134 | } else {
135 | Log.d(
136 | "ParcelNotifyService",
137 | "WeChat matched titles but parse failed; content not saved"
138 | )
139 |
140 | }
141 | } else {
142 | Log.d(
143 | "ParcelNotifyService",
144 | "WeChat matched titles but text empty; extras may be MessagingStyle"
145 | )
146 | addLog(context, "微信通知文本为空")
147 | }
148 | }
149 | // 调试日志(帮助定位为何未匹配)
150 | else {
151 | Log.d(
152 | "ParcelNotifyService",
153 | "WeChat unmatched. candidates=${candidates} saved=${normalizedSaved}"
154 | )
155 | }
156 | }
157 | }
158 | else -> {
159 | val systemPkgs = getSystemSmsPackages(context)
160 | val systemEnabled = getSystemSmsNotifySwitch(context)
161 | if (systemEnabled && systemPkgs.contains(pkg)) {
162 | if (text.isNotBlank()) {
163 | addLog(context, "短信通知: ${text}")
164 | addNotificationAsCustomSmsIfNotInInboxDelayed(context, text)
165 |
166 | }
167 | }
168 | }
169 | }
170 | } catch (e: Exception) {
171 | Log.e("ParcelNotifyService", "通知处理出错: ${e.message}")
172 | addLog(context,"ParcelNotifyService, 通知处理出错: ${e.message}")
173 | }
174 | }
175 |
176 | private fun addNotificationAsCustomSms(context: Context, content: String) {
177 | val now = System.currentTimeMillis()
178 | val sms = SmsModel(
179 | id = now.toString(),
180 | body = "【自定义取件短信】" + content,
181 | timestamp = now
182 | )
183 | addCustomSms(context, sms)
184 | // 广播通知 UI:已添加自定义短信
185 | try {
186 | val intent = Intent("com.xxxx.parcel.CUSTOM_SMS_ADDED")
187 | intent.setPackage(context.packageName)
188 | context.sendBroadcast(intent)
189 | } catch (e: Exception) {
190 | Log.e("ParcelNotifyService", "广播失败: ${e.message}")
191 | addLog(context, "ParcelNotifyService, 广播失败: ${e.message}")
192 | }
193 | }
194 |
195 | private fun addNotificationAsCustomSmsIfNotInInboxDelayed(context: Context, content: String) {
196 | Thread {
197 | val nowStart = System.currentTimeMillis()
198 | val prev = lastContent
199 | val withinWindow = prev != null && prev == content && (nowStart - lastTs) < 2000L
200 | if (withinWindow) return@Thread
201 | try {
202 | Thread.sleep(1000L)
203 | } catch (_: Exception) {}
204 | val exists = try {
205 | SmsUtil.inboxContainsBodyRecent(context, content, 5 * 60 * 1000L)
206 | } catch (_: Exception) { false }
207 | if (!exists) {
208 | addNotificationAsCustomSms(context, content)
209 | addLog(context, "通知保存: ${content}")
210 | lastContent = content
211 | lastTs = nowStart
212 | } else {
213 | addLog(context, "通知文本已在短信箱,跳过保存: ${content}")
214 | }
215 | }.start()
216 | }
217 |
218 | // 仅保存解析成功的内容(加载自定义规则)
219 | private fun shouldSaveBasedOnParse(context: Context, content: String): Boolean {
220 | if (content.isBlank()) return false
221 | return try {
222 | val parser = SmsParser()
223 | // 加载自定义地址/取件码/忽略关键词规则
224 | getCustomList(context, "address").forEach { rule ->
225 | if (rule.isNotBlank()) parser.addCustomAddressPattern(rule)
226 | }
227 | getCustomList(context, "code").forEach { pattern ->
228 | if (pattern.isNotBlank()) parser.addCustomCodePattern(pattern)
229 | }
230 | getCustomList(context, "ignoreKeywords").forEach { kw ->
231 | if (kw.isNotBlank()) parser.addIgnoreKeyword(kw)
232 | }
233 | val result = parser.parseSms(content)
234 | result.success
235 | } catch (e: Exception) {
236 | Log.e("ParcelNotifyService", "解析出错: ${e.message}")
237 | addLog(context, "ParcelNotifyService 解析出错: ${e.message}")
238 | false
239 | }
240 | }
241 |
242 |
243 | // 更稳健地从通知 extras 中提取文本,兼容 MessagingStyle 与 textLines
244 | private fun extractNotificationText(extras: Bundle): String {
245 | val main = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
246 | ?: extras.getCharSequence(Notification.EXTRA_TEXT)
247 | ?: extras.getCharSequence("android.text"))
248 | ?.toString()
249 | if (!main.isNullOrBlank()) return main
250 |
251 | // 尝试 textLines(有些应用把多行文本放这里)
252 | val lines = extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)
253 | ?: extras.getCharSequenceArray("android.textLines")
254 | val fromLines = lines?.mapNotNull { it?.toString() }?.lastOrNull { it.isNotBlank() }
255 | if (!fromLines.isNullOrBlank()) return fromLines
256 |
257 | // 尝试 MessagingStyle 的 android.messages(Bundle 中 text 字段)
258 | val messages = extras.getParcelableArray("android.messages")
259 | val lastMsgText = messages?.lastOrNull()?.let { it as? Bundle }?.getCharSequence("text")?.toString()
260 | if (!lastMsgText.isNullOrBlank()) return lastMsgText
261 |
262 | return ""
263 | }
264 |
265 | override fun onDestroy() {
266 | try { stopForeground(true) } catch (_: Exception) { }
267 | super.onDestroy()
268 | }
269 | }
270 |
--------------------------------------------------------------------------------
/app/src/main/java/com/xxxx/parcel/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.xxxx.parcel
2 |
3 | import android.Manifest
4 | import android.appwidget.AppWidgetManager
5 | import android.content.ComponentName
6 | import android.content.Context
7 | import android.content.Intent
8 | import android.content.pm.PackageManager
9 | import android.database.ContentObserver
10 | import android.net.Uri
11 | import android.os.Bundle
12 | import android.os.Handler
13 | import android.os.Looper
14 | import android.provider.Settings
15 | import android.service.notification.NotificationListenerService
16 | import android.util.Log
17 | import androidx.activity.ComponentActivity
18 | import androidx.activity.compose.setContent
19 | import androidx.activity.result.contract.ActivityResultContracts
20 | import androidx.compose.foundation.layout.Box
21 | import androidx.compose.foundation.layout.fillMaxSize
22 | import androidx.compose.material3.ExperimentalMaterial3Api
23 | import androidx.compose.runtime.Composable
24 | import androidx.compose.ui.Modifier
25 | import androidx.core.app.ActivityCompat
26 | import androidx.core.net.toUri
27 | import androidx.navigation.NavType
28 | import androidx.navigation.compose.NavHost
29 | import androidx.navigation.compose.composable
30 | import androidx.navigation.compose.rememberNavController
31 | import androidx.navigation.navArgument
32 | import com.xxxx.parcel.ui.AboutScreen
33 | import com.xxxx.parcel.ui.AddCustomSmsScreen
34 | import com.xxxx.parcel.ui.AddRuleScreen
35 | import com.xxxx.parcel.ui.FailSmsScreen
36 | import com.xxxx.parcel.ui.HomeScreen
37 | import com.xxxx.parcel.ui.RulesScreen
38 | import com.xxxx.parcel.ui.SuccessSmsScreen
39 | import com.xxxx.parcel.ui.UseNotificationScreen
40 | import com.xxxx.parcel.ui.theme.ParcelTheme
41 | import com.xxxx.parcel.util.PermissionUtil
42 | import com.xxxx.parcel.util.PermissionUtil.showMiuiPermissionExplanationDialog
43 | import com.xxxx.parcel.util.SmsParser
44 | import com.xxxx.parcel.util.SmsUtil
45 | import com.xxxx.parcel.util.getAllSaveData
46 | import com.xxxx.parcel.util.getCustomSmsByTimeFilter
47 | import com.xxxx.parcel.util.getMainSwitch
48 | import com.xxxx.parcel.viewmodel.ParcelViewModel
49 | import com.xxxx.parcel.widget.ParcelWidget
50 | import com.xxxx.parcel.widget.ParcelWidgetLarge
51 | import com.xxxx.parcel.widget.ParcelWidgetLargeMiui
52 | import com.xxxx.parcel.widget.ParcelWidgetMiui
53 | import java.net.URLDecoder
54 | import android.content.BroadcastReceiver
55 | import android.content.IntentFilter
56 | import android.os.Build
57 | import com.xxxx.parcel.widget.ParcelWidgetXL
58 | import com.xxxx.parcel.service.ParcelNotificationListenerService
59 | import com.xxxx.parcel.ui.LogScreen
60 |
61 |
62 | class MainActivity : ComponentActivity(), ActivityCompat.OnRequestPermissionsResultCallback {
63 | private lateinit var smsContentObserver: ContentObserver
64 | private lateinit var appDetailsLauncher: androidx.activity.result.ActivityResultLauncher
65 | val context = this
66 | val smsParser = SmsParser()
67 | val viewModel = ParcelViewModel(smsParser)
68 | private var customSmsReceiver: BroadcastReceiver? = null
69 |
70 | override fun onCreate(savedInstanceState: Bundle?) {
71 | super.onCreate(savedInstanceState)
72 |
73 |
74 | // 注册 ActivityResultLauncher
75 | appDetailsLauncher =
76 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
77 | init()
78 |
79 | }
80 |
81 | getAllSaveData(this, viewModel)
82 | init()
83 |
84 | setContent {
85 | App(
86 | context,
87 | viewModel,
88 | guideToSettings = { guideToSettings() },
89 | readAndParseSms = { readAndParseSms() },
90 | updateAllWidget = { updateAllWidget() },
91 | )
92 | }
93 | }
94 |
95 |
96 | private fun startSmsDeletionMonitoring() {
97 | smsContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
98 | override fun onChange(selfChange: Boolean) {
99 | super.onChange(selfChange)
100 | readAndParseSms()
101 | }
102 | }
103 | contentResolver.registerContentObserver(
104 | "content://sms".toUri(),
105 | true,
106 | smsContentObserver
107 | )
108 | }
109 |
110 | fun updateAllWidget() {
111 | // 刷新 AppWidget(不传递 appWidgetId 以更新所有实例)
112 | ParcelWidget.updateAppWidget(
113 | context,
114 | AppWidgetManager.getInstance(context),
115 | null,
116 | viewModel
117 | )
118 | ParcelWidgetLarge.updateAppWidget(
119 | context,
120 | AppWidgetManager.getInstance(context),
121 | null,
122 | viewModel
123 | )
124 | ParcelWidgetXL.updateAppWidget(
125 | context,
126 | AppWidgetManager.getInstance(context),
127 | null,
128 | viewModel
129 | )
130 | ParcelWidgetMiui.updateAppWidget(
131 | context,
132 | AppWidgetManager.getInstance(context),
133 | null,
134 | viewModel
135 | )
136 | ParcelWidgetLargeMiui.updateAppWidget(
137 | context,
138 | AppWidgetManager.getInstance(context),
139 | null,
140 | viewModel
141 | )
142 | }
143 |
144 | fun readAndParseSms() {
145 | val context = applicationContext
146 | val daysFilter = viewModel.timeFilterIndex.value
147 | val smsList = SmsUtil.readSmsByTimeFilter(context, daysFilter)
148 | val customSmsList = getCustomSmsByTimeFilter(context, daysFilter)
149 |
150 | viewModel.getAllMessageWithCustom(smsList, customSmsList)
151 |
152 | updateAllWidget()
153 | }
154 |
155 |
156 | private fun init() {
157 | if (PermissionUtil.isMIUI()) {
158 | //小米手机 MIUI widget启用
159 | val component = ComponentName(context, ParcelWidgetMiui::class.java)
160 | context.packageManager.setComponentEnabledSetting(
161 | component,
162 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
163 | PackageManager.DONT_KILL_APP
164 | )
165 | val componentLarge = ComponentName(context, ParcelWidgetLargeMiui::class.java)
166 | context.packageManager.setComponentEnabledSetting(
167 | componentLarge,
168 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
169 | PackageManager.DONT_KILL_APP
170 | )
171 | }
172 | // 检查并请求短信权限
173 | if (!PermissionUtil.hasSmsPermissions(this)) {
174 | ActivityCompat.requestPermissions(
175 | this,
176 | arrayOf(
177 | Manifest.permission.RECEIVE_SMS,
178 | Manifest.permission.READ_SMS,
179 | ),
180 | 1
181 | )
182 | if (PermissionUtil.isMIUI()) {
183 | //小米手机 显示引导弹窗后调用 requestMiuiSmsPermission()
184 | showMiuiPermissionExplanationDialog(context)
185 | }
186 | if (PermissionUtil.hasSmsPermissions(this)) {
187 | readAndParseSms()
188 | startSmsDeletionMonitoring()
189 | }
190 |
191 | } else {
192 | // 权限已授予,读取短信
193 | readAndParseSms()
194 | startSmsDeletionMonitoring()
195 | }
196 | setContent {
197 | App(
198 | context,
199 | viewModel,
200 | guideToSettings = { guideToSettings() },
201 | readAndParseSms = { readAndParseSms() },
202 | updateAllWidget = { updateAllWidget() },
203 | )
204 | }
205 |
206 | // 注册接收“自定义短信已添加”的广播,统一触发 UI 刷新
207 | registerCustomSmsAddedReceiver()
208 |
209 | // 应用启动后尝试重新绑定通知监听服务,避免重启后不工作
210 | rebindNotificationListenerIfNeeded()
211 | }
212 |
213 | private fun registerCustomSmsAddedReceiver() {
214 | val action = "com.xxxx.parcel.CUSTOM_SMS_ADDED"
215 | customSmsReceiver = object : BroadcastReceiver() {
216 | override fun onReceive(context: Context, intent: Intent) {
217 | if (intent.action == action) {
218 | readAndParseSms()
219 | }
220 | }
221 | }
222 | val filter = IntentFilter(action)
223 | if (Build.VERSION.SDK_INT >= 33) {
224 | registerReceiver(customSmsReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
225 | } else {
226 | @Suppress("DEPRECATION")
227 | registerReceiver(customSmsReceiver, filter)
228 | }
229 | }
230 |
231 | private fun rebindNotificationListenerIfNeeded() {
232 | try {
233 | val hasAccess = hasNotificationAccess(this)
234 | val mainEnabled = getMainSwitch(this)
235 | if (hasAccess && mainEnabled) {
236 | NotificationListenerService.requestRebind(
237 | ComponentName(this, ParcelNotificationListenerService::class.java)
238 | )
239 | Log.d("MainActivity", "Requested rebind on app start")
240 | } else {
241 | Log.d("MainActivity", "Skip rebind: hasAccess=$hasAccess mainEnabled=$mainEnabled")
242 | }
243 | } catch (e: Exception) {
244 | Log.e("MainActivity", "Request rebind failed: ${e.message}")
245 | }
246 | }
247 |
248 | private fun hasNotificationAccess(context: Context): Boolean {
249 | val flat = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
250 | if (flat.isNullOrBlank()) return false
251 | val full = ComponentName(context, ParcelNotificationListenerService::class.java).flattenToString()
252 | val short = "${context.packageName}/.service.ParcelNotificationListenerService"
253 | return flat.split(":").any { it == full || it == short }
254 | }
255 |
256 | override fun onRequestPermissionsResult(
257 | requestCode: Int,
258 | permissions: Array,
259 | grantResults: IntArray
260 | ) {
261 | super.onRequestPermissionsResult(requestCode, permissions, grantResults)
262 |
263 | if (grantResults.isNotEmpty() &&
264 | grantResults[0] == PackageManager.PERMISSION_GRANTED
265 | ) {
266 | init()
267 |
268 | } else {
269 | if (shouldShowRequestPermissionRationale(Manifest.permission.READ_SMS)) {
270 | init()
271 | } else {
272 | guideToSettings()
273 | }
274 | }
275 |
276 | }
277 |
278 | private fun guideToSettings() {
279 | val uri = Uri.fromParts("package", "com.xxxx.parcel", null)
280 | val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
281 | data = uri
282 | }
283 | appDetailsLauncher.launch(intent)
284 | }
285 |
286 | override fun onDestroy() {
287 | super.onDestroy()
288 | // 取消短信删除监控
289 | try {
290 | contentResolver.unregisterContentObserver(smsContentObserver)
291 | } catch (_: Exception) { }
292 | // 取消自定义短信广播接收
293 | customSmsReceiver?.let {
294 | try {
295 | unregisterReceiver(it)
296 | } catch (_: Exception) { }
297 | }
298 | }
299 |
300 | }
301 |
302 | @OptIn(ExperimentalMaterial3Api::class)
303 | @Composable
304 | fun App(
305 | context: Context,
306 | viewModel: ParcelViewModel,
307 | guideToSettings: () -> Unit,
308 | readAndParseSms: () -> Unit,
309 | updateAllWidget: () -> Unit,
310 | ) {
311 | ParcelTheme {
312 | val navController = rememberNavController()
313 | Box(
314 | modifier = Modifier
315 | .fillMaxSize()
316 | ) {
317 | NavHost(
318 | navController = navController,
319 | startDestination = "home"
320 | ) {
321 | composable("home") {
322 | HomeScreen(
323 | context,
324 | viewModel,
325 | navController,
326 | onCallBack = { guideToSettings() },
327 | updateAllWidget,
328 | )
329 | }
330 | composable(
331 | route = "add_custom_sms/{address}",
332 | arguments = listOf(
333 | navArgument("address") {
334 | type = NavType.StringType
335 | defaultValue = ""
336 | }
337 | )
338 | ) { backStackEntry ->
339 | val address = backStackEntry.arguments?.getString("address") ?: ""
340 | AddCustomSmsScreen(
341 | context,
342 | viewModel,
343 | navController,
344 | URLDecoder.decode(address, "UTF-8"),
345 | onCallback = { readAndParseSms() }
346 | )
347 | }
348 | composable(
349 | route = "add_rule?message={message}",
350 | arguments = listOf(
351 | navArgument("message") {
352 | type = NavType.StringType
353 | defaultValue = ""
354 | }
355 | )
356 | ) { backStackEntry ->
357 | val message = backStackEntry.arguments?.getString("message") ?: ""
358 | AddRuleScreen(
359 | context,
360 | viewModel,
361 | navController,
362 | URLDecoder.decode(message, "UTF-8"),
363 | onCallback = { readAndParseSms() })
364 | }
365 | composable("rules") {
366 | RulesScreen(
367 | context,
368 | viewModel,
369 | navController,
370 | onCallback = { readAndParseSms() })
371 | }
372 | composable("fail_sms") {
373 | FailSmsScreen(viewModel, navController, readAndParseSms)
374 | }
375 | composable("success_sms") {
376 | SuccessSmsScreen(viewModel, navController, readAndParseSms)
377 | }
378 | composable("about") {
379 | AboutScreen(navController)
380 | }
381 | composable("use_notification") {
382 | UseNotificationScreen(navController)
383 | }
384 | composable("logs") {
385 | LogScreen(navController)
386 | }
387 | }
388 | }
389 | }
390 | }
391 |
--------------------------------------------------------------------------------