├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── values-zh │ │ │ │ └── strings.xml │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── themes.xml │ │ │ │ └── strings.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── drawable-xxhdpi │ │ │ │ └── ic_intelligence_56.png │ │ │ ├── drawable │ │ │ │ ├── ic_arrow_outward.xml │ │ │ │ ├── ic_check_circle.xml │ │ │ │ ├── baseline_battery_1_bar_24.xml │ │ │ │ ├── ic_remove_circle.xml │ │ │ │ ├── ic_cancel_circle.xml │ │ │ │ ├── baseline_mark_email_read_24.xml │ │ │ │ ├── ic_schedule.xml │ │ │ │ ├── ic_change_circle.xml │ │ │ │ ├── ic_lightbulb.xml │ │ │ │ ├── ic_forward_to_inbox.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ └── xml │ │ │ │ ├── backup_rules.xml │ │ │ │ └── data_extraction_rules.xml │ │ ├── ic_launcher-playstore.png │ │ ├── java │ │ │ └── cn │ │ │ │ └── quickweather │ │ │ │ └── messageforward │ │ │ │ ├── ui │ │ │ │ └── theme │ │ │ │ │ ├── Type.kt │ │ │ │ │ ├── Components.kt │ │ │ │ │ ├── Color.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── api │ │ │ │ ├── WritingReq.kt │ │ │ │ ├── IsImportantResult.kt │ │ │ │ ├── WritingToolsResponse.kt │ │ │ │ └── MessageToolsApi.kt │ │ │ │ ├── history │ │ │ │ ├── HistoryData.kt │ │ │ │ └── ForwardHistoryDataStore.kt │ │ │ │ ├── MessageApplication.kt │ │ │ │ ├── setting │ │ │ │ ├── SettingDataStore.kt │ │ │ │ ├── SettingData.kt │ │ │ │ ├── SettingViewModel.kt │ │ │ │ └── SettingScreen.kt │ │ │ │ ├── sms │ │ │ │ ├── ForwardStatus.kt │ │ │ │ ├── MsgImportanceResolver.kt │ │ │ │ ├── SmsBroadcastReceiver.kt │ │ │ │ ├── MessageData.kt │ │ │ │ └── SmsForwardManager.kt │ │ │ │ ├── service │ │ │ │ ├── StartOnBootReceiver.kt │ │ │ │ ├── LowBatteryHandler.kt │ │ │ │ └── SmsDaemonService.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ └── MessageModules.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── cn │ │ │ └── quickweather │ │ │ └── messageforward │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── cn │ │ └── quickweather │ │ └── messageforward │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro ├── assets │ └── message_forwarder_privacy.html └── build.gradle.kts ├── common ├── .gitignore ├── consumer-rules.pro ├── src │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── cn │ │ │ └── quickweather │ │ │ └── android │ │ │ └── common │ │ │ ├── util │ │ │ ├── ContextUtil.kt │ │ │ ├── PermissionUtil.kt │ │ │ ├── ToastUtil.kt │ │ │ ├── TaskThread.kt │ │ │ ├── CommonUtil.kt │ │ │ ├── ComposeUtil.kt │ │ │ ├── LogUtil.kt │ │ │ └── ViewUtil.kt │ │ │ ├── app │ │ │ └── BaseApplication.kt │ │ │ ├── data │ │ │ └── DataSerializer.kt │ │ │ └── network │ │ │ └── OkHttpInterceptorK.kt │ ├── test │ │ └── java │ │ │ └── cn │ │ │ └── quickweather │ │ │ └── android │ │ │ └── common │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── cn │ │ └── quickweather │ │ └── android │ │ └── common │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle.kts ├── secret └── msg.keystore ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── screenshot ├── Screenshot_20240602_085257.png └── Screenshot_20241004_173453.png ├── .gitignore ├── settings.gradle.kts ├── README.md ├── .github └── workflows │ ├── android.yml │ └── build-realease.yml ├── gradle.properties ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /common/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /common/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /secret/msg.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/secret/msg.keystore -------------------------------------------------------------------------------- /app/src/main/res/values-zh/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 短信转发器 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /screenshot/Screenshot_20240602_085257.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/screenshot/Screenshot_20240602_085257.png -------------------------------------------------------------------------------- /screenshot/Screenshot_20241004_173453.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/screenshot/Screenshot_20241004_173453.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /common/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_intelligence_56.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ma-weihao/MessageForward/HEAD/app/src/main/res/drawable-xxhdpi/ic_intelligence_56.png -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #4C662B 4 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | 5 | 6 | val AppTypography = Typography() -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/api/WritingReq.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.api 2 | 3 | /** 4 | * Created by maweihao on 8/3/24 5 | */ 6 | data class WritingReq( 7 | val text: String, 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/api/IsImportantResult.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.api 2 | 3 | /** 4 | * Created by maweihao on 8/2/24 5 | */ 6 | data class IsImportantResult ( 7 | val res: Boolean, 8 | val model: String?, 9 | ) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu May 16 21:59:34 CST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /.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 | /.idea 17 | /app/release/ 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_outward.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | 16 | rootProject.name = "MessageForward" 17 | include(":app") 18 | include(":common") 19 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/history/HistoryData.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.history 2 | 3 | import cn.quickweather.messageforward.sms.MessageData 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class HistoryData( 8 | val message: MessageData, 9 | // reference to [ForwardStatus] 10 | val status: Int, 11 | ) { 12 | val id: String 13 | get() = message.id 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_check_circle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/baseline_battery_1_bar_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/test/java/cn/quickweather/messageforward/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward 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/drawable/ic_remove_circle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/test/java/cn/quickweather/android/common/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common 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/drawable/ic_cancel_circle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/baseline_mark_email_read_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_schedule.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MessageForwarder: forward received sms to another phone number. 2 | 3 | ## Screenshot 4 | screenshot 5 | 6 | ## Feature List 7 | 8 | ### 1.0 9 | - Forward received sms to another phone number. 10 | - Battery Notification. 11 | - Forward History. 12 | - Only forward sms with priority. (AI powered) 13 | 14 | ## Planning Feature List 15 | - [ ] Clear History. 16 | - [ ] Report misdetected priority SMS. 17 | - [ ] Support phone number with country code. 18 | - [ ] Mark forwarded as Read. 19 | - [ ] Reply **STOP** to disable forwarder. -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: set up JDK 17 17 | uses: actions/setup-java@v4 18 | with: 19 | java-version: '17' 20 | distribution: 'temurin' 21 | cache: gradle 22 | 23 | - name: Grant execute permission for gradlew 24 | run: chmod +x gradlew 25 | - name: Build with Gradle 26 | run: ./gradlew buildDebug 27 | -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/util/ContextUtil.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.util 2 | 3 | import android.content.Context 4 | import cn.quickweather.android.common.app.BaseApplication 5 | import kotlinx.coroutines.CoroutineScope 6 | 7 | /** 8 | * Created by maweihao on 5/24/24 9 | */ 10 | 11 | var appApplicationContext: Context? = null 12 | 13 | val applicationContext: Context by lazyUnsafe { 14 | appApplicationContext ?: BaseApplication.application 15 | } 16 | 17 | val globalMainScope: CoroutineScope 18 | get() { 19 | return applicationContext as BaseApplication 20 | } 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/MessageApplication.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward 2 | 3 | import cn.quickweather.android.common.app.BaseApplication 4 | import org.koin.android.ext.koin.androidContext 5 | import org.koin.android.ext.koin.androidLogger 6 | import org.koin.core.context.startKoin 7 | 8 | /** 9 | * Created by maweihao on 5/20/24 10 | */ 11 | class MessageApplication: BaseApplication() { 12 | 13 | override fun onCreate() { 14 | super.onCreate() 15 | startKoin { 16 | androidLogger() 17 | androidContext(this@MessageApplication) 18 | modules(messageModules) 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/app/BaseApplication.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.app 2 | 3 | import android.app.Application 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.SupervisorJob 7 | 8 | /** 9 | * Created by maweihao on 5/24/24 10 | */ 11 | open class BaseApplication: Application(), CoroutineScope by applicationMainScope() { 12 | 13 | companion object { 14 | lateinit var application: BaseApplication 15 | } 16 | 17 | override fun onCreate() { 18 | application = this 19 | super.onCreate() 20 | } 21 | } 22 | 23 | private fun applicationMainScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_change_circle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/util/PermissionUtil.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.util 2 | 3 | import android.content.Context 4 | 5 | /** 6 | * Created by maweihao on 10/3/24 7 | */ 8 | fun Context.hasPermission(permission: String): Boolean { 9 | return checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED 10 | } 11 | 12 | fun Context.hasPermissions(permissions: List): Boolean { 13 | return permissions.all { hasPermission(it) } 14 | } 15 | 16 | fun Context.hasSmsPermissions(): Boolean { 17 | return hasPermissions( 18 | listOf( 19 | android.Manifest.permission.RECEIVE_SMS, 20 | android.Manifest.permission.SEND_SMS, 21 | android.Manifest.permission.READ_SMS, 22 | ) 23 | ) 24 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/api/WritingToolsResponse.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.api 2 | 3 | /** 4 | * Created by maweihao on 8/2/24 5 | */ 6 | data class WritingToolsResponse ( 7 | val code: Int, 8 | val data: T?, 9 | val errMsg: String?, 10 | ) { 11 | companion object { 12 | 13 | const val CODE_SUCCESS = 0 14 | const val CODE_ERROR = 1 15 | 16 | fun internalError(errMsg: String?): WritingToolsResponse { 17 | return WritingToolsResponse( 18 | code = CODE_ERROR, 19 | data = null, 20 | errMsg = errMsg, 21 | ) 22 | } 23 | } 24 | } 25 | 26 | val WritingToolsResponse<*>.isSuccess: Boolean 27 | get() = code == WritingToolsResponse.CODE_SUCCESS && data != null -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /common/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/androidTest/java/cn/quickweather/messageforward/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward 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("cn.quickweather.messageforward", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /common/src/androidTest/java/cn/quickweather/android/common/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common 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("cn.quickweather.android.common.test", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /.github/workflows/build-realease.yml: -------------------------------------------------------------------------------- 1 | name: Build Release Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up JDK 17 16 | uses: actions/setup-java@v4 17 | with: 18 | java-version: '17' 19 | distribution: 'temurin' 20 | cache: gradle 21 | 22 | - name: Grant execute permission for gradlew 23 | run: chmod +x gradlew 24 | 25 | - name: Build with Gradle 26 | run: ./gradlew assembleRelease -PKEY_PASSWORD=${{ secrets.RELEASE_KEY_SECRET }} -PSTORE_PASSWORD=${{ secrets.RELEASE_KEYSTORE_SECRET }} 27 | 28 | - name: Upload APK 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: release-apk 32 | path: app/build/outputs/apk/release/*.apk -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/setting/SettingDataStore.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.setting 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.dataStore 6 | import cn.quickweather.android.common.data.DataSerializer 7 | import kotlinx.coroutines.flow.Flow 8 | 9 | /** 10 | * Created by maweihao on 5/24/24 11 | */ 12 | class SettingDataStore(context: Context) { 13 | 14 | private val Context.dataStore: DataStore by dataStore( 15 | fileName = "settings", 16 | serializer = DataSerializer(SettingData.serializer(), SettingData()), 17 | ) 18 | 19 | private val dataStore = context.dataStore 20 | 21 | val settingData: Flow = dataStore.data 22 | 23 | suspend fun updateSetting(data: SettingData) { 24 | dataStore.updateData { 25 | data 26 | } 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_lightbulb.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_forward_to_inbox.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/util/ToastUtil.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.util 2 | 3 | import android.os.Looper 4 | import android.widget.Toast 5 | import androidx.annotation.StringRes 6 | import cn.quickweather.android.common.BuildConfig 7 | 8 | /** 9 | * Created by maweihao on 5/24/24 10 | */ 11 | fun showShortToast(@StringRes res: Int) { 12 | showShortToast(res.toResString()) 13 | } 14 | 15 | fun showDebugToast(content: String?) { 16 | if (BuildConfig.DEBUG) { 17 | showShortToast(content) 18 | } 19 | } 20 | 21 | fun showShortToast(content: String?) { 22 | if (content.isNullOrBlank()) return 23 | logI("showShortToast", content) 24 | if (Looper.getMainLooper() == Looper.myLooper()) { 25 | Toast.makeText(applicationContext, content, Toast.LENGTH_SHORT).show() 26 | } else { 27 | TaskThread.postMain { 28 | Toast.makeText(applicationContext, content, Toast.LENGTH_SHORT).show() 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/sms/ForwardStatus.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.sms 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.annotation.StringRes 5 | import cn.quickweather.messageforward.R 6 | 7 | /** 8 | * Created by maweihao on 5/29/24 9 | */ 10 | enum class ForwardStatus( 11 | @StringRes val label: Int, 12 | @DrawableRes val icon: Int, 13 | ) { 14 | 15 | // Do not change the order of this enum 16 | 17 | Pending(R.string.label_status_pending, R.drawable.ic_schedule), 18 | 19 | DetectingPriority(R.string.label_status_detecting_priority, R.drawable.ic_change_circle), 20 | 21 | ForwardSucceed(R.string.label_status_forward_succeed, R.drawable.ic_check_circle), 22 | 23 | ForwardFailedDueToSms(R.string.label_status_forward_failed, R.drawable.ic_cancel_circle), 24 | 25 | NotForwardDueToUnimportant(R.string.label_status_unimportant, R.drawable.ic_remove_circle), 26 | 27 | ; 28 | 29 | companion object { 30 | fun parse(value: Int): ForwardStatus { 31 | return entries.first { it.ordinal == value } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/service/StartOnBootReceiver.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.service 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import cn.quickweather.messageforward.sms.SmsForwardManager 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.SupervisorJob 10 | import kotlinx.coroutines.cancel 11 | import kotlinx.coroutines.launch 12 | import org.koin.core.component.KoinComponent 13 | import org.koin.java.KoinJavaComponent 14 | 15 | /** 16 | * Created by maweihao on 6/1/24 17 | */ 18 | private const val TAG = "StartOnBootReceiver" 19 | class StartOnBootReceiver: BroadcastReceiver(), KoinComponent { 20 | 21 | private val manager: SmsForwardManager by KoinJavaComponent.inject(SmsForwardManager::class.java) 22 | 23 | override fun onReceive(context: Context?, intent: Intent?) { 24 | val pendingResult = goAsync() 25 | 26 | CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { 27 | manager.checkServiceState() 28 | pendingResult.finish() 29 | coroutineContext.cancel() 30 | } 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/data/DataSerializer.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.data 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.Serializer 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.SerializationException 7 | import kotlinx.serialization.json.Json 8 | import java.io.InputStream 9 | import java.io.OutputStream 10 | 11 | /** 12 | * Created by maweihao on 5/24/24 13 | */ 14 | open class DataSerializer( 15 | private val serializer: KSerializer, 16 | override val defaultValue: Data, 17 | ) : Serializer { 18 | 19 | override suspend fun readFrom(input: InputStream): Data { 20 | try { 21 | return Json.decodeFromString( 22 | serializer, input.readBytes().decodeToString() 23 | ) 24 | } catch (serialization: SerializationException) { 25 | throw CorruptionException("Unable to read Prefs", serialization) 26 | } 27 | } 28 | 29 | override suspend fun writeTo(t: Data, output: OutputStream) { 30 | output.write( 31 | Json.encodeToString(serializer, t) 32 | .encodeToByteArray() 33 | ) 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/sms/MsgImportanceResolver.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.sms 2 | 3 | import cn.quickweather.messageforward.api.MessageToolsApi 4 | import cn.quickweather.messageforward.api.WritingReq 5 | import cn.quickweather.messageforward.api.isSuccess 6 | 7 | 8 | /** 9 | * Created by maweihao on 5/29/24 10 | */ 11 | class MsgImportanceResolver( 12 | private val messageToolsApi: MessageToolsApi, 13 | ) { 14 | 15 | private fun containsVerificationCode(content: String?): Boolean { 16 | if (content.isNullOrBlank()) return false 17 | val matched = keywords.any { 18 | content.contains(it, true) 19 | } 20 | return matched 21 | } 22 | 23 | suspend fun isMessageImportant(content: String?): Boolean { 24 | if (content.isNullOrBlank()) return false 25 | if (containsVerificationCode(content)) { 26 | return true 27 | } 28 | val response = messageToolsApi.isMsgImportant(WritingReq(content)) 29 | if (!response.isSuccess) { 30 | return false 31 | } 32 | return response.data?.res ?: false 33 | } 34 | 35 | companion object { 36 | private val keywords = listOf("验证码", "动态密码", "verification", "code", "代码", "인증") 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/setting/SettingData.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.setting 2 | 3 | import kotlinx.serialization.Serializable 4 | import java.util.regex.Pattern 5 | 6 | 7 | @Serializable 8 | data class SettingData( 9 | val enabled: Boolean = false, 10 | val smsToNumber: String? = null, 11 | val onlyVerificationCode: Boolean = false, 12 | val markAsRead: Boolean = false, 13 | val sendBatteryNotification: Boolean = false, 14 | val lastBatteryNotificationTime: Long = 0, 15 | ) 16 | 17 | private val CHINA_PHONE_NUMBER_PATTERN = Pattern.compile("^1[3-9]\\d{9}$") 18 | 19 | val SettingData.phoneNumberValid: Boolean 20 | get() = smsToNumber.phoneNumberValid 21 | 22 | val String?.phoneNumberValid: Boolean 23 | get() { 24 | if (this.isNullOrBlank()) return false 25 | if (this == "10000" || this == "10086" || this == "10010") { 26 | return true 27 | } 28 | var number = this 29 | if (this.startsWith("+")) { 30 | if (!this.startsWith("+86")) { 31 | return false 32 | } 33 | number = number.replaceFirst("+86", "").replace("-", "") 34 | } 35 | val matcher = CHINA_PHONE_NUMBER_PATTERN.matcher(number) 36 | return matcher.matches() 37 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Surface 10 | import androidx.compose.runtime.CompositionLocalProvider 11 | import androidx.compose.ui.Modifier 12 | import androidx.lifecycle.compose.LocalLifecycleOwner 13 | import cn.quickweather.messageforward.setting.SettingScreen 14 | import cn.quickweather.messageforward.ui.theme.MessageForwardTheme 15 | 16 | class MainActivity : ComponentActivity() { 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | enableEdgeToEdge() 20 | setContent { 21 | MessageForwardTheme { 22 | Surface( 23 | modifier = Modifier.fillMaxSize(), 24 | color = MaterialTheme.colorScheme.background, 25 | ) { 26 | CompositionLocalProvider(LocalLifecycleOwner provides this) { 27 | SettingScreen() 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/MessageModules.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward 2 | 3 | import cn.quickweather.android.common.util.globalMainScope 4 | import cn.quickweather.messageforward.api.MessageToolsApi 5 | import cn.quickweather.messageforward.api.MessageToolsApiImpl 6 | import cn.quickweather.messageforward.sms.SmsForwardManager 7 | import cn.quickweather.messageforward.history.ForwardHistoryDataStore 8 | import cn.quickweather.messageforward.sms.MsgImportanceResolver 9 | import cn.quickweather.messageforward.setting.SettingDataStore 10 | import cn.quickweather.messageforward.setting.SettingViewModel 11 | import cn.quickweather.messageforward.service.LowBatteryHandler 12 | import org.koin.androidx.viewmodel.dsl.viewModelOf 13 | import org.koin.core.module.dsl.factoryOf 14 | import org.koin.core.module.dsl.singleOf 15 | import org.koin.dsl.module 16 | 17 | /** 18 | * Created by maweihao on 6/1/24 19 | */ 20 | val messageModules = module { 21 | single { 22 | SmsForwardManager( 23 | get(), 24 | get(), 25 | get(), 26 | globalMainScope, 27 | ) 28 | } 29 | singleOf(::MsgImportanceResolver) 30 | singleOf(::SettingDataStore) 31 | singleOf(::ForwardHistoryDataStore) 32 | viewModelOf(::SettingViewModel) 33 | singleOf(::MessageToolsApiImpl) 34 | factoryOf(::LowBatteryHandler) 35 | } -------------------------------------------------------------------------------- /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. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec: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 -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/util/TaskThread.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.util 2 | 3 | import android.os.Handler 4 | import android.os.HandlerThread 5 | import android.os.Looper 6 | 7 | /** 8 | * Created by maweihao on 3/13/21 9 | */ 10 | object TaskThread { 11 | 12 | private val mainHandler: Handler by lazy { Handler(Looper.getMainLooper()) } 13 | private val backgroundHandler: Handler by lazy { 14 | newHandlerThread("TaskThread-background", HandlerThread.NORM_PRIORITY) 15 | } 16 | 17 | fun postMain(delay: Long = 0, task: Runnable) { 18 | if (delay > 0) { 19 | mainHandler.postDelayed(task, delay) 20 | } else { 21 | mainHandler.post(task) 22 | } 23 | } 24 | 25 | fun removeMain(task: Runnable) { 26 | mainHandler.removeCallbacks(task) 27 | } 28 | 29 | fun postBackground(delay: Long = 0, task: Runnable) { 30 | if (delay > 0) { 31 | backgroundHandler.postDelayed(task, delay) 32 | } else { 33 | backgroundHandler.post(task) 34 | } 35 | } 36 | 37 | fun removeBackground(task: Runnable) { 38 | backgroundHandler.removeCallbacks(task) 39 | } 40 | 41 | private fun newHandlerThread(name: String, priority: Int = HandlerThread.NORM_PRIORITY): Handler { 42 | val ht = HandlerThread(name, priority) 43 | ht.start() 44 | return Handler(ht.looper) 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/sms/SmsBroadcastReceiver.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.sms 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.pm.PackageManager 7 | import android.telephony.SmsMessage 8 | import cn.quickweather.android.common.util.hasSmsPermissions 9 | import cn.quickweather.android.common.util.logE 10 | import cn.quickweather.android.common.util.logI 11 | import org.koin.core.component.KoinComponent 12 | import org.koin.java.KoinJavaComponent.inject 13 | 14 | class SmsBroadcastReceiver : BroadcastReceiver(), KoinComponent { 15 | 16 | private val manager: SmsForwardManager by inject(SmsForwardManager::class.java) 17 | 18 | override fun onReceive(context: Context, intent: Intent) { 19 | if (intent.action != "android.provider.Telephony.SMS_RECEIVED") { 20 | logE(TAG, "Unexpected action: ${intent.action}") 21 | return 22 | } 23 | 24 | val data = intent.extras 25 | 26 | if (context.hasSmsPermissions().not()) { 27 | logI(TAG, "SMS permissions not granted") 28 | return 29 | } 30 | 31 | // creating an object on below line. 32 | val pdus = data!!["pdus"] as? Array ?: return 33 | val format = data.getString("format") 34 | 35 | val messageData = pdus.map { 36 | SmsMessage.createFromPdu(it as ByteArray, format) 37 | }.toMessageData() 38 | logI(TAG, "$messageData") 39 | manager.onNewSmsReceived(messageData) 40 | } 41 | } 42 | private const val TAG = "SmsBroadcastReceiver" -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/api/MessageToolsApi.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.api 2 | 3 | import cn.quickweather.android.common.network.OkHttpInterceptor 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.withContext 6 | import okhttp3.OkHttpClient 7 | import retrofit2.Retrofit 8 | import retrofit2.converter.gson.GsonConverterFactory 9 | import retrofit2.http.Body 10 | import retrofit2.http.POST 11 | import java.lang.Exception 12 | 13 | /** 14 | * Created by maweihao on 8/11/24 15 | */ 16 | interface MessageToolsApi { 17 | 18 | @POST("isimportant") 19 | suspend fun isMsgImportant(@Body req: WritingReq): WritingToolsResponse 20 | } 21 | 22 | private const val baseUrl = "https://writingtools-hk-jgvsuzcgqo.cn-hongkong.fcapp.run/" 23 | 24 | class MessageToolsApiImpl: MessageToolsApi { 25 | 26 | private val client: OkHttpClient by lazy { 27 | OkHttpClient.Builder().apply { 28 | addInterceptor(OkHttpInterceptor()) 29 | }.build() 30 | } 31 | 32 | private val retrofit: Retrofit by lazy { 33 | Retrofit.Builder() 34 | .addConverterFactory(GsonConverterFactory.create()) 35 | .baseUrl(baseUrl) 36 | .client(client) 37 | .build() 38 | } 39 | 40 | private val instance: MessageToolsApi by lazy { 41 | retrofit.create(MessageToolsApi::class.java) 42 | } 43 | 44 | override suspend fun isMsgImportant(req: WritingReq): WritingToolsResponse = withContext(Dispatchers.IO) { 45 | return@withContext try { 46 | instance.isMsgImportant(req) 47 | } catch (e: Exception) { 48 | WritingToolsResponse.internalError(e.message) 49 | } 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/history/ForwardHistoryDataStore.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.history 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.dataStore 6 | import cn.quickweather.android.common.data.DataSerializer 7 | import cn.quickweather.messageforward.sms.ForwardStatus 8 | import cn.quickweather.messageforward.sms.MessageData 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.serialization.builtins.ListSerializer 11 | 12 | /** 13 | * Created by maweihao on 6/25/24 14 | */ 15 | class ForwardHistoryDataStore(context: Context) { 16 | 17 | private val Context.dataStore: DataStore> by dataStore( 18 | fileName = "history", 19 | serializer = DataSerializer(ListSerializer(HistoryData.serializer()), emptyList()), 20 | ) 21 | 22 | private val dataStore = context.dataStore 23 | 24 | val historyData: Flow> = dataStore.data 25 | 26 | suspend fun updateHistory(data: HistoryData) { 27 | dataStore.updateData { list -> 28 | list.toMutableList().apply { 29 | replaceAll { 30 | if (it.id == data.id) { 31 | data 32 | } else { 33 | it 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | suspend fun addHistory(data: MessageData) { 41 | dataStore.updateData { list -> 42 | list.toMutableList().apply { 43 | add( 44 | 0, HistoryData( 45 | data, 46 | ForwardStatus.Pending.ordinal, 47 | ) 48 | ) 49 | } 50 | } 51 | } 52 | 53 | suspend fun updateHistoryList(data: List) { 54 | dataStore.updateData { 55 | data 56 | } 57 | } 58 | 59 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/sms/MessageData.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.sms 2 | 3 | import android.net.Uri 4 | import android.telephony.SmsMessage 5 | import cn.quickweather.android.common.util.applicationContext 6 | import kotlinx.serialization.Serializable 7 | import java.util.UUID 8 | 9 | /** 10 | * Created by maweihao on 6/1/24 11 | */ 12 | @Serializable 13 | data class MessageData( 14 | val originatingAddress: String?, 15 | val msgBody: String?, 16 | val receivedTime: Long = 0, 17 | val splitPartsSize: Int = 0, 18 | val id: String = "", 19 | val idInSmsDB: Long = -1, 20 | val messageOrder: Int = MessageType.SMS.ordinal, 21 | ) { 22 | val isSms: Boolean 23 | get() = messageOrder == MessageType.SMS.ordinal 24 | } 25 | 26 | enum class MessageType { 27 | // don't change the order 28 | SMS, 29 | LOW_BATTERY, 30 | ; 31 | 32 | companion object { 33 | fun fromInt(value: Int) = entries.first { it.ordinal == value } 34 | } 35 | } 36 | 37 | fun List.toMessageData(): MessageData { 38 | return MessageData( 39 | originatingAddress = get(0).originatingAddress, 40 | msgBody = map { 41 | it.messageBody 42 | }.reduce { acc, smsMessage -> 43 | acc + smsMessage 44 | }, 45 | receivedTime = get(0).timestampMillis, 46 | splitPartsSize = size, 47 | id = UUID.randomUUID().toString(), 48 | idInSmsDB = getMessageIdFromSms(get(0)) 49 | ) 50 | } 51 | 52 | private fun getMessageIdFromSms(sms: SmsMessage): Long { 53 | val smsUri = Uri.parse("content://sms/inbox") 54 | 55 | val cursor = applicationContext.contentResolver.query( 56 | smsUri, arrayOf("_id"), "address=?", 57 | arrayOf(sms.originatingAddress), "date DESC" 58 | ) 59 | 60 | val messageId = if (cursor != null && cursor.moveToFirst()) { 61 | cursor.getLong(cursor.getColumnIndexOrThrow("_id")) 62 | } else { 63 | -1 64 | } 65 | 66 | cursor?.close() 67 | return messageId 68 | } -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/util/CommonUtil.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.util 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.JsonSyntaxException 5 | import java.io.Closeable 6 | import java.lang.Exception 7 | import java.text.SimpleDateFormat 8 | import java.util.* 9 | 10 | fun lazyUnsafe(initializer: () -> T): Lazy = lazy(LazyThreadSafetyMode.NONE, initializer) 11 | 12 | fun ifNotNull(vararg obj: Any?, callback: () -> Unit) { 13 | if (!obj.any{ it == null}) { 14 | callback.invoke() 15 | } 16 | } 17 | 18 | fun Closeable?.safeClose() { 19 | this?.let { 20 | close() 21 | } 22 | } 23 | 24 | fun Any?.toJsonStr(): String { 25 | return try { 26 | Gson().toJson(this) 27 | } catch (ignore: Exception) { 28 | "PARSE EXCEPTION" 29 | } 30 | } 31 | 32 | infix fun Int?.moreThan(other: Int): Boolean { 33 | return if (this == null) { 34 | false 35 | } else { 36 | this - other > 0 37 | } 38 | } 39 | 40 | infix fun Long?.moreThan(other: Long): Boolean { 41 | return if (this == null) { 42 | false 43 | } else { 44 | this - other > 0 45 | } 46 | } 47 | 48 | infix fun Int?.lessThan(other: Int): Boolean { 49 | return if (this == null) { 50 | false 51 | } else { 52 | this - other < 0 53 | } 54 | } 55 | 56 | infix fun Long?.lessThan(other: Long): Boolean { 57 | return if (this == null) { 58 | false 59 | } else { 60 | this - other < 0 61 | } 62 | } 63 | 64 | fun deserializeOrNull(text: String?, clazz: Class): T? { 65 | if (text.isNullOrBlank()) { 66 | return null 67 | } 68 | return try { 69 | Gson().fromJson(text, clazz) 70 | } catch (e: JsonSyntaxException) { 71 | logE("parseObjectOrNull", "parse object error", e) 72 | null 73 | } 74 | } 75 | 76 | inline fun deserializeOrNull(text: String?): T? { 77 | return deserializeOrNull(text, T::class.java) 78 | } 79 | 80 | private val hmFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) 81 | fun formatTimeHM(time: Long = System.currentTimeMillis()): String { 82 | return hmFormat.format(time) 83 | } 84 | 85 | fun String.subStringAtMost(maxLen: Int): String { 86 | if (maxLen <= 0) return "" 87 | if (length <= maxLen) { 88 | return this 89 | } 90 | return substring(maxLen) 91 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/service/LowBatteryHandler.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.service 2 | 3 | import cn.quickweather.android.common.util.logI 4 | import cn.quickweather.android.common.util.toResString 5 | import cn.quickweather.messageforward.R 6 | import cn.quickweather.messageforward.setting.SettingDataStore 7 | import cn.quickweather.messageforward.sms.MessageData 8 | import cn.quickweather.messageforward.sms.MessageType 9 | import cn.quickweather.messageforward.sms.SmsForwardManager 10 | import kotlinx.coroutines.flow.first 11 | import java.util.UUID 12 | 13 | /** 14 | * Created by maweihao on 10/5/24 15 | */ 16 | class LowBatteryHandler( 17 | private val smsForwardManager: SmsForwardManager, 18 | private val settingDataStore: SettingDataStore, 19 | ) { 20 | 21 | fun isLowBattery(level: Int): Boolean { 22 | return level <= BATTERY_LOW_LEVEL 23 | } 24 | 25 | suspend fun handleLowBattery(level: Int) { 26 | if (level > BATTERY_LOW_LEVEL) { 27 | return 28 | } 29 | val settingData = smsForwardManager.settingData.first() 30 | if (!settingData.enabled || !settingData.sendBatteryNotification) { 31 | return 32 | } 33 | 34 | val lastSentTime = settingData.lastBatteryNotificationTime 35 | val interval = (System.currentTimeMillis() - lastSentTime) / 1000 36 | if (interval < 60 * 60 * 24) { 37 | logI(TAG, "Battery notification already sent ${interval / 60}min ago") 38 | return 39 | } 40 | 41 | settingDataStore.updateSetting(settingData.copy(lastBatteryNotificationTime = System.currentTimeMillis())) 42 | 43 | val msg = createLowBatteryNotification(level) 44 | logI(TAG, "Sending low battery notification $msg") 45 | smsForwardManager.onNewSmsReceived(msg) 46 | } 47 | 48 | private fun createLowBatteryNotification(level: Int): MessageData { 49 | return MessageData( 50 | originatingAddress = R.string.send_dead_notification_title.toResString(), 51 | msgBody = R.string.send_dead_notification_msg_content.toResString(level.toString()), 52 | receivedTime = System.currentTimeMillis(), 53 | splitPartsSize = 1, 54 | id = UUID.randomUUID().toString(), 55 | messageOrder = MessageType.LOW_BATTERY.ordinal, 56 | ) 57 | } 58 | 59 | } 60 | private const val TAG = "LowBatteryHandler" 61 | private const val BATTERY_LOW_LEVEL = 5 -------------------------------------------------------------------------------- /common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | id("kotlin-kapt") 4 | id("org.jetbrains.kotlin.android") 5 | id("org.jetbrains.kotlin.plugin.serialization") 6 | } 7 | 8 | android { 9 | namespace = "cn.quickweather.android.common" 10 | compileSdk = 34 11 | 12 | defaultConfig { 13 | minSdk = 26 14 | 15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 16 | consumerProguardFiles("consumer-rules.pro") 17 | } 18 | 19 | buildTypes { 20 | release { 21 | isMinifyEnabled = false 22 | proguardFiles( 23 | getDefaultProguardFile("proguard-android-optimize.txt"), 24 | "proguard-rules.pro" 25 | ) 26 | } 27 | } 28 | compileOptions { 29 | sourceCompatibility = JavaVersion.VERSION_1_8 30 | targetCompatibility = JavaVersion.VERSION_1_8 31 | } 32 | buildFeatures { 33 | buildConfig = true 34 | } 35 | kotlinOptions { 36 | jvmTarget = "1.8" 37 | } 38 | } 39 | 40 | dependencies { 41 | api("androidx.lifecycle:lifecycle-runtime-compose") 42 | api("androidx.lifecycle:lifecycle-viewmodel-compose") 43 | api("com.google.accompanist:accompanist-permissions:0.35.1-alpha") 44 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1") 45 | implementation("androidx.activity:activity-compose") 46 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") 47 | implementation("androidx.datastore:datastore-preferences:1.1.1") 48 | implementation(platform("androidx.compose:compose-bom:2024.05.00")) 49 | implementation("androidx.compose.ui:ui") 50 | implementation("androidx.compose.ui:ui-graphics") 51 | testImplementation("androidx.compose.ui:ui-tooling-preview") 52 | implementation("androidx.compose.material3:material3") 53 | implementation("androidx.core:core-ktx:1.13.1") 54 | implementation("androidx.appcompat:appcompat:1.7.0") 55 | implementation("com.google.android.material:material:1.12.0") 56 | testImplementation("junit:junit:4.13.2") 57 | androidTestImplementation("androidx.test.ext:junit:1.1.5") 58 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") 59 | 60 | implementation("com.squareup.okhttp3:okhttp:4.12.0") 61 | implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") 62 | implementation("com.squareup.retrofit2:retrofit:2.11.0") 63 | implementation("com.squareup.retrofit2:converter-gson:2.11.0") 64 | implementation("com.google.code.gson:gson:2.11.0") 65 | } -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/util/ComposeUtil.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.util 2 | 3 | import android.text.Spanned 4 | import android.text.style.AbsoluteSizeSpan 5 | import android.text.style.ForegroundColorSpan 6 | import android.text.style.StrikethroughSpan 7 | import android.text.style.StyleSpan 8 | import android.text.style.UnderlineSpan 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.text.AnnotatedString 11 | import androidx.compose.ui.text.SpanStyle 12 | import androidx.compose.ui.text.buildAnnotatedString 13 | import androidx.compose.ui.text.font.FontStyle 14 | import androidx.compose.ui.text.font.FontWeight 15 | import androidx.compose.ui.text.style.TextDecoration 16 | import androidx.compose.ui.unit.sp 17 | 18 | /** 19 | * Created by maweihao on 8/11/24 20 | */ 21 | 22 | fun Spanned.toAnnotatedString(): AnnotatedString { 23 | return buildAnnotatedString { 24 | val text = this@toAnnotatedString.toString() 25 | append(text) 26 | 27 | getSpans(0, length, Any::class.java).forEach { span -> 28 | val start = getSpanStart(span) 29 | val end = getSpanEnd(span) 30 | 31 | when (span) { 32 | is StyleSpan -> { 33 | when (span.style) { 34 | android.graphics.Typeface.BOLD -> { 35 | addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) 36 | } 37 | android.graphics.Typeface.ITALIC -> { 38 | addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) 39 | } 40 | } 41 | } 42 | is UnderlineSpan -> { 43 | addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) 44 | } 45 | is StrikethroughSpan -> { 46 | addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end) 47 | } 48 | is ForegroundColorSpan -> { 49 | addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) 50 | } 51 | is AbsoluteSizeSpan -> { 52 | addStyle(SpanStyle(fontSize = span.size.sp), start, end) 53 | } 54 | // Add more span types as needed 55 | } 56 | } 57 | } 58 | } 59 | 60 | fun String.toAnnotatedString(): AnnotatedString { 61 | return buildAnnotatedString { 62 | append(this@toAnnotatedString) 63 | } 64 | } -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/network/OkHttpInterceptorK.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.network 2 | 3 | import android.os.SystemClock 4 | import cn.quickweather.android.common.BuildConfig 5 | import okhttp3.Interceptor 6 | import okhttp3.MediaType 7 | import okhttp3.Request 8 | import okhttp3.RequestBody 9 | import okhttp3.Response 10 | import okhttp3.ResponseBody 11 | import okio.Buffer 12 | import okio.BufferedSource 13 | import java.io.IOException 14 | import java.nio.charset.Charset 15 | import java.nio.charset.StandardCharsets 16 | import java.nio.charset.UnsupportedCharsetException 17 | 18 | import cn.quickweather.android.common.util.logV 19 | 20 | class OkHttpInterceptor : Interceptor { 21 | 22 | @Throws(IOException::class) 23 | override fun intercept(chain: Interceptor.Chain): Response { 24 | val request: Request = chain.request() 25 | val requestBody: RequestBody? = request.body 26 | var body: String? = null 27 | if (requestBody != null) { 28 | val buffer = Buffer() 29 | requestBody.writeTo(buffer) 30 | var charset: Charset = UTF8 31 | val contentType: MediaType? = requestBody.contentType() 32 | if (contentType != null) { 33 | charset = contentType.charset(UTF8) ?: UTF8 34 | } 35 | body = buffer.readString(charset) 36 | } 37 | if (BuildConfig.DEBUG) logV(TAG, 38 | "HTTP REQUEST: method: ${request.method}, url: ${request.url}, head: ${request.headers}, param : $body" 39 | ) 40 | 41 | val startMs = SystemClock.elapsedRealtime() 42 | val response: Response = chain.proceed(request) 43 | val tookMs = SystemClock.elapsedRealtime() - startMs 44 | 45 | val responseBody: ResponseBody? = response.body 46 | val rBody: String 47 | 48 | val source: BufferedSource = responseBody!!.source() 49 | source.request(Long.MAX_VALUE) 50 | val buffer = source.buffer 51 | 52 | var charset: Charset = UTF8 53 | val contentType: MediaType? = responseBody.contentType() 54 | if (contentType != null) { 55 | try { 56 | charset = contentType.charset(UTF8) ?: UTF8 57 | } catch (e: UnsupportedCharsetException) { 58 | e.printStackTrace() 59 | } 60 | } 61 | rBody = buffer.clone().readString(charset) 62 | 63 | if (BuildConfig.DEBUG) logV(TAG, 64 | "HTTP RESPONSE: code: ${response.code}, cost: ${tookMs}ms, url: ${response.request.url}, body: $body, Response: $rBody" 65 | ) 66 | 67 | return response 68 | } 69 | } 70 | 71 | private val TAG = OkHttpInterceptor::class.java.simpleName 72 | private val UTF8: Charset = StandardCharsets.UTF_8 -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 56 | 57 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | MessageForwarder 3 | 4 | Message Forwarder 5 | 6 | Enable Forward SMS 7 | If enabled, all sms you received will be forwarded to another phone. 8 | 9 | Phone number of recipient 10 | Click to set number 11 | Phone number valid 12 | Phone number invalid 13 | 14 | Only forward priority messages 15 | Priority messages will be detected to reduce interruptions. 16 | 17 | Privacy Consent 18 | Message content will be upload to determine priority. Your message will not be stored in server. 19 | I understand 20 | Cancel 21 | 22 | MessageForwarder 23 | Forward service is running as long as this notification exists 24 | 25 | Forwarded 26 | Forward Failed 27 | Determined as unimportant 28 | Detecting 29 | Pending 30 | 31 | Forward service will not be available until a valid recipient number is provided 32 | Forward service will not be available until sms permission is granted 33 | Lack notification permission, forward service will not be available while app in the background. 34 | 35 | Battery notification 36 | Send a message when battery is nearly dead. 37 | Your phone battery is nearly dead (%1$s\%%). Message Forwarder will not be working soon. 38 | 39 | Mark forwarded as read 40 | 41 | Forward History 42 | No Forwarded Messages 43 | 44 | Forward Settings 45 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /app/assets/message_forwarder_privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Privacy Agreement - Message Forwarder 7 | 17 | 18 | 19 |

Message Forwarder - Privacy Policy

20 | 21 |

Introduction

22 |

Welcome to Message Forwarder. This privacy agreement outlines how we collect, use, and protect your information when you use our app.

23 | 24 |

Information Collection

25 |

Personal Information: We collect your phone number to forward messages to the specified recipient.

26 |

Message Content: When using the "Only forward priority messages" function, the content of your messages will be sent to our server to determine their priority.

27 | 28 |

Use of Information

29 |

Forwarding Messages: Your phone number and message content are used solely for the purpose of forwarding messages to the recipient you specify.

30 |

Priority Detection: Message content sent to our server is analyzed to detect priority messages and reduce interruptions.

31 | 32 |

Permissions

33 |

Our app requires the following permissions to function properly:

34 |
    35 |
  • FOREGROUND_SERVICE: To run services in the foreground.
  • 36 |
  • RECEIVE_SMS: To receive SMS messages.
  • 37 |
  • SEND_SMS: To send SMS messages.
  • 38 |
  • POST_NOTIFICATIONS: To use foreground service.
  • 39 |
  • RECEIVE_BOOT_COMPLETED: To start the forward service when the device boots up.
  • 40 |
  • INTERNET: To access the internet for message forwarding and priority detection.
  • 41 |
42 | 43 |

Data Security

44 |

We implement industry-standard security measures to protect your information. However, no method of transmission over the internet or electronic storage is 100% secure.

45 | 46 |

Data Sharing

47 |

We do not share your personal information or message content with third parties, except as required by law.

48 | 49 |

User Rights

50 |

You have the right to:

51 |
    52 |
  • Access the personal information we hold about you.
  • 53 |
  • Request correction of any inaccurate information.
  • 54 |
  • Request deletion of your personal information.
  • 55 |
56 | 57 |

Changes to This Agreement

58 |

We may update this privacy agreement from time to time. We will notify you of any changes by posting the new privacy agreement on this page.

59 |

Last Updated: October 2, 2024

60 | 61 |

Contact Us

62 |

If you have any questions about this privacy agreement, please contact us at support@messageforwarder.com.

63 | 64 |

By using Message Forwarder, you agree to the terms outlined in this privacy agreement.

65 | 66 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/sms/SmsForwardManager.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.sms 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.telephony.SmsManager 6 | import android.util.Log 7 | import cn.quickweather.android.common.util.applicationContext 8 | import cn.quickweather.android.common.util.logI 9 | import cn.quickweather.messageforward.history.ForwardHistoryDataStore 10 | import cn.quickweather.messageforward.service.SmsDaemonService 11 | import cn.quickweather.messageforward.setting.SettingDataStore 12 | import cn.quickweather.messageforward.setting.phoneNumberValid 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.channels.Channel 16 | import kotlinx.coroutines.flow.first 17 | import kotlinx.coroutines.launch 18 | 19 | 20 | /** 21 | * Created by maweihao on 5/21/24 22 | */ 23 | class SmsForwardManager( 24 | settingDataStore: SettingDataStore, 25 | private val verificationCodeResolver: MsgImportanceResolver, 26 | private val historyDataStore: ForwardHistoryDataStore, 27 | private val scope: CoroutineScope, 28 | ) { 29 | 30 | val settingData = settingDataStore.settingData 31 | private val msgChannel = Channel(Channel.BUFFERED) 32 | 33 | private val smsManager: SmsManager by lazy { 34 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 35 | applicationContext.getSystemService(SmsManager::class.java).createForSubscriptionId(SmsManager.getDefaultSmsSubscriptionId()) 36 | } else { 37 | SmsManager.getDefault() 38 | } 39 | } 40 | 41 | init { 42 | scope.launch(Dispatchers.IO) { 43 | checkServiceState() 44 | while (true) { 45 | val element = msgChannel.receive() 46 | forwardMessage(element) 47 | } 48 | } 49 | } 50 | 51 | private suspend fun forwardMessage(sms: MessageData) { 52 | val history = historyDataStore.historyData.first().first { it.id == sms.id } 53 | val data = settingData.first() 54 | if (!data.enabled || !data.phoneNumberValid) { 55 | return 56 | } 57 | if (data.onlyVerificationCode && sms.isSms) { 58 | historyDataStore.updateHistory(history.copy(status = ForwardStatus.DetectingPriority.ordinal)) 59 | val important = verificationCodeResolver.isMessageImportant(sms.msgBody) 60 | if (!important) { 61 | historyDataStore.updateHistory(history.copy(status = ForwardStatus.NotForwardDueToUnimportant.ordinal)) 62 | return 63 | } 64 | } 65 | 66 | Log.i(TAG, "forwardMessage: send to ${data.smsToNumber}") 67 | smsManager.sendMultipartTextMessage( 68 | data.smsToNumber, 69 | null, 70 | smsManager.divideMessage(sms.msgBody), 71 | null, null, 72 | ) 73 | historyDataStore.updateHistory(history.copy(status = ForwardStatus.ForwardSucceed.ordinal)) 74 | } 75 | 76 | suspend fun checkServiceState() { 77 | val data = settingData.first() 78 | logI(TAG, "checkServiceState $data") 79 | enableForwardService(applicationContext, data.enabled) 80 | } 81 | 82 | fun onNewSmsReceived(sms: MessageData) { 83 | scope.launch { 84 | historyDataStore.addHistory(sms) 85 | msgChannel.trySend(sms) 86 | } 87 | } 88 | 89 | fun enableForwardService(context: Context, enable: Boolean) { 90 | SmsDaemonService.enableService(context, enable) 91 | } 92 | 93 | } 94 | private const val TAG = "SmsForwardManager" -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("kotlin-kapt") 4 | id("org.jetbrains.kotlin.plugin.serialization") 5 | id("org.jetbrains.kotlin.android") 6 | } 7 | 8 | android { 9 | namespace = "cn.quickweather.messageforward" 10 | compileSdk = 34 11 | 12 | defaultConfig { 13 | applicationId = "cn.quickweather.messageforward" 14 | minSdk = 26 15 | targetSdk = 34 16 | versionCode = 1 17 | versionName = "1.0.0" 18 | 19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 20 | vectorDrawables { 21 | useSupportLibrary = true 22 | } 23 | } 24 | 25 | signingConfigs { 26 | create("release") { 27 | keyAlias = "Msg" 28 | keyPassword = project.findProperty("KEY_PASSWORD") as String? ?: "" 29 | storeFile = file("../secret/msg.keystore") 30 | storePassword = project.findProperty("STORE_PASSWORD") as String? ?: "" 31 | } 32 | } 33 | 34 | buildTypes { 35 | release { 36 | isMinifyEnabled = false 37 | proguardFiles( 38 | getDefaultProguardFile("proguard-android-optimize.txt"), 39 | "proguard-rules.pro" 40 | ) 41 | signingConfig = signingConfigs.getByName("release") 42 | } 43 | } 44 | 45 | compileOptions { 46 | sourceCompatibility = JavaVersion.VERSION_1_8 47 | targetCompatibility = JavaVersion.VERSION_1_8 48 | } 49 | 50 | kotlinOptions { 51 | jvmTarget = "1.8" 52 | } 53 | 54 | buildFeatures { 55 | compose = true 56 | } 57 | 58 | composeOptions { 59 | kotlinCompilerExtensionVersion = "1.5.1" 60 | } 61 | 62 | packaging { 63 | resources { 64 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 65 | } 66 | } 67 | 68 | lint { 69 | disable.add("MissingTranslation") 70 | } 71 | 72 | applicationVariants.all { 73 | outputs.all { 74 | val outPutImpl = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl 75 | val outputFileName = "app-${outPutImpl.name}-${outPutImpl.versionCode}.apk" 76 | outPutImpl.outputFileName = outputFileName 77 | } 78 | } 79 | } 80 | 81 | val koinAndroidVersion = "3.5.6" 82 | 83 | dependencies { 84 | api(project(":common")) 85 | implementation("androidx.core:core-ktx:1.13.1") 86 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1") 87 | implementation("androidx.activity:activity-compose:1.9.0") 88 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") 89 | implementation("androidx.datastore:datastore-preferences:1.1.1") 90 | implementation(platform("androidx.compose:compose-bom:2024.05.00")) 91 | implementation("androidx.compose.ui:ui") 92 | implementation("androidx.compose.ui:ui-graphics") 93 | implementation("androidx.compose.ui:ui-tooling-preview") 94 | implementation("androidx.compose.material3:material3") 95 | implementation(platform("io.insert-koin:koin-bom:$koinAndroidVersion")) 96 | implementation("io.insert-koin:koin-core") 97 | implementation("io.insert-koin:koin-android:$koinAndroidVersion") 98 | implementation("com.google.accompanist:accompanist-permissions:0.34.0") 99 | testImplementation("junit:junit:4.13.2") 100 | // Koin Test features 101 | testImplementation("io.insert-koin:koin-test:$koinAndroidVersion") 102 | // Koin for JUnit 4 103 | testImplementation("io.insert-koin:koin-test-junit4:$koinAndroidVersion") 104 | // Koin for JUnit 5 105 | testImplementation("io.insert-koin:koin-test-junit5:$koinAndroidVersion") 106 | // Java Compatibility 107 | // implementation("io.insert-koin:koin-android-compat:$koinAndroidVersion") 108 | // Jetpack WorkManager 109 | // implementation("io.insert-koin:koin-androidx-workmanager:$koinAndroidVersion") 110 | // Navigation Graph 111 | implementation("io.insert-koin:koin-androidx-navigation:$koinAndroidVersion") 112 | implementation("io.insert-koin:koin-androidx-compose:$koinAndroidVersion") 113 | androidTestImplementation("androidx.test.ext:junit:1.1.5") 114 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") 115 | debugImplementation("androidx.compose.ui:ui-tooling") 116 | debugImplementation("androidx.compose.ui:ui-test-manifest") 117 | 118 | implementation("com.squareup.okhttp3:okhttp:4.12.0") 119 | implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") 120 | implementation("com.squareup.retrofit2:retrofit:2.11.0") 121 | implementation("com.squareup.retrofit2:converter-gson:2.11.0") 122 | implementation("com.google.code.gson:gson:2.11.0") 123 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/ui/theme/Components.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.ui.theme 2 | 3 | import androidx.appcompat.widget.DialogTitle 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.ColumnScope 7 | import androidx.compose.foundation.layout.PaddingValues 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.layout.wrapContentHeight 13 | import androidx.compose.foundation.shape.CornerSize 14 | import androidx.compose.foundation.shape.RoundedCornerShape 15 | import androidx.compose.material3.Card 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.LocalContentColor 18 | import androidx.compose.material3.MaterialTheme 19 | import androidx.compose.material3.OutlinedTextField 20 | import androidx.compose.material3.Surface 21 | import androidx.compose.material3.Text 22 | import androidx.compose.material3.TextButton 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.CompositionLocalProvider 25 | import androidx.compose.ui.Alignment 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.res.painterResource 29 | import androidx.compose.ui.res.stringResource 30 | import androidx.compose.ui.tooling.preview.Preview 31 | import androidx.compose.ui.unit.Dp 32 | import androidx.compose.ui.unit.dp 33 | import cn.quickweather.messageforward.R 34 | 35 | /** 36 | * Created by maweihao on 5/25/24 37 | */ 38 | 39 | @Composable 40 | internal fun ContentCard( 41 | modifier: Modifier = Modifier, 42 | backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer, 43 | contentColor: Color = MaterialTheme.colorScheme.onSurface, 44 | topCornerSize: Dp = 24.dp, 45 | bottomCornerSize: Dp = 24.dp, 46 | outerPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp), 47 | innerPadding: PaddingValues = PaddingValues(12.dp), 48 | content: @Composable ColumnScope.() -> Unit, 49 | ) { 50 | Surface( 51 | modifier = modifier 52 | .fillMaxWidth() 53 | .padding(outerPadding) 54 | .wrapContentHeight(), 55 | shape = RoundedCornerShape( 56 | topStart = topCornerSize, 57 | topEnd = topCornerSize, 58 | bottomStart = bottomCornerSize, 59 | bottomEnd = bottomCornerSize 60 | ), 61 | color = backgroundColor, 62 | contentColor = contentColor, 63 | ) { 64 | Column(modifier.padding(innerPadding)) { 65 | content() 66 | } 67 | } 68 | } 69 | 70 | @Composable 71 | internal fun ErrorCard( 72 | message: String, 73 | modifier: Modifier = Modifier, 74 | ) { 75 | ContentCard( 76 | modifier = modifier, 77 | backgroundColor = MaterialTheme.colorScheme.errorContainer, 78 | contentColor = MaterialTheme.colorScheme.error, 79 | ) { 80 | Row( 81 | verticalAlignment = Alignment.CenterVertically, 82 | ) { 83 | Icon( 84 | painter = painterResource(id = R.drawable.ic_lightbulb), 85 | modifier = Modifier 86 | .padding(end = 12.dp) 87 | .size(22.dp), 88 | tint = MaterialTheme.colorScheme.error, 89 | contentDescription = null, 90 | ) 91 | Text(text = message, style = MaterialTheme.typography.titleMedium) 92 | } 93 | } 94 | } 95 | 96 | @Composable 97 | internal fun Dialog( 98 | title: String, 99 | content: @Composable ColumnScope.() -> Unit, 100 | actions: @Composable () -> Unit, 101 | dismissDialog: () -> Unit, 102 | ) { 103 | androidx.compose.ui.window.Dialog( 104 | onDismissRequest = dismissDialog, 105 | ) { 106 | Card( 107 | modifier = Modifier.fillMaxWidth(), 108 | shape = RoundedCornerShape(16.dp), 109 | ) { 110 | Text( 111 | text = title, 112 | style = MaterialTheme.typography.titleLarge, 113 | color = MaterialTheme.colorScheme.onSurface, 114 | modifier = Modifier.padding(16.dp) 115 | ) 116 | 117 | content() 118 | 119 | Box( 120 | modifier = Modifier 121 | .fillMaxWidth() 122 | .padding(horizontal = 16.dp, vertical = 8.dp), 123 | contentAlignment = Alignment.CenterEnd, 124 | ) { 125 | actions() 126 | } 127 | } 128 | } 129 | } 130 | 131 | @Preview(showSystemUi = true) 132 | @Composable 133 | fun ErrorCardPreview() { 134 | MessageForwardTheme { 135 | ErrorCard("error") 136 | } 137 | } -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/util/LogUtil.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.util 2 | 3 | import android.util.Log 4 | import cn.quickweather.android.common.BuildConfig 5 | 6 | 7 | /** 8 | * Priority constant for the println method; use Log.v. 9 | */ 10 | private const val VERBOSE = 2 11 | 12 | /** 13 | * Priority constant for the println method; use Log.d. 14 | */ 15 | private const val DEBUG = 3 16 | 17 | /** 18 | * Priority constant for the println method; use Log.i. 19 | */ 20 | private const val INFO = 4 21 | 22 | /** 23 | * Priority constant for the println method; use Log.w. 24 | */ 25 | private const val WARN = 5 26 | 27 | /** 28 | * Priority constant for the println method; use Log.e. 29 | */ 30 | private const val ERROR = 6 31 | 32 | /** 33 | * Priority constant for the println method. 34 | */ 35 | private const val ASSERT = 7 36 | 37 | /** 38 | * Send a [.VERBOSE] log message. 39 | * @param tag Used to identify the source of a log message. It usually identifies 40 | * the class or activity where the log call occurs. 41 | * @param msg The message you would like logged. 42 | */ 43 | fun logV(tag: String, msg: String): Int { 44 | if (!BuildConfig.DEBUG) { 45 | return 0 46 | } 47 | return Log.v(tag, msg) 48 | } 49 | 50 | /** 51 | * Send a [.VERBOSE] log message and log the exception. 52 | * @param tag Used to identify the source of a log message. It usually identifies 53 | * the class or activity where the log call occurs. 54 | * @param msg The message you would like logged. 55 | * @param tr An exception to log 56 | */ 57 | fun logV(tag: String, msg: String, tr: Throwable?): Int { 58 | if (!BuildConfig.DEBUG) { 59 | return 0 60 | } 61 | return Log.v(tag, msg, tr) 62 | } 63 | 64 | /** 65 | * Send a [.DEBUG] log message. 66 | * @param tag Used to identify the source of a log message. It usually identifies 67 | * the class or activity where the log call occurs. 68 | * @param msg The message you would like logged. 69 | */ 70 | fun logD(tag: String?, msg: String?): Int { 71 | if (!BuildConfig.DEBUG) { 72 | return 0 73 | } 74 | return Log.d(tag, msg!!) 75 | } 76 | 77 | /** 78 | * Send a [.DEBUG] log message and log the exception. 79 | * @param tag Used to identify the source of a log message. It usually identifies 80 | * the class or activity where the log call occurs. 81 | * @param msg The message you would like logged. 82 | * @param tr An exception to log 83 | */ 84 | fun logD(tag: String?, msg: String?, tr: Throwable?): Int { 85 | if (!BuildConfig.DEBUG) { 86 | return 0 87 | } 88 | return Log.d(tag, msg, tr) 89 | } 90 | 91 | /** 92 | * Send an [.INFO] log message. 93 | * @param tag Used to identify the source of a log message. It usually identifies 94 | * the class or activity where the log call occurs. 95 | * @param msg The message you would like logged. 96 | */ 97 | fun logI(tag: String?, msg: String?): Int { 98 | return Log.i(tag, msg!!) 99 | } 100 | 101 | /** 102 | * Send a [.INFO] log message and log the exception. 103 | * @param tag Used to identify the source of a log message. It usually identifies 104 | * the class or activity where the log call occurs. 105 | * @param msg The message you would like logged. 106 | * @param tr An exception to log 107 | */ 108 | fun logI(tag: String?, msg: String?, tr: Throwable?): Int { 109 | return Log.i(tag, msg, tr) 110 | } 111 | 112 | /** 113 | * Send a [.WARN] log message. 114 | * @param tag Used to identify the source of a log message. It usually identifies 115 | * the class or activity where the log call occurs. 116 | * @param msg The message you would like logged. 117 | */ 118 | fun logW(tag: String?, msg: String?): Int { 119 | return Log.w(tag, msg!!) 120 | } 121 | 122 | /** 123 | * Send a [.WARN] log message and log the exception. 124 | * @param tag Used to identify the source of a log message. It usually identifies 125 | * the class or activity where the log call occurs. 126 | * @param msg The message you would like logged. 127 | * @param tr An exception to log 128 | */ 129 | fun logW(tag: String?, msg: String?, tr: Throwable?): Int { 130 | return Log.w(tag, msg, tr) 131 | } 132 | 133 | /* 134 | * Send a {@link #WARN} log message and log the exception. 135 | * @param tag Used to identify the source of a log message. It usually identifies 136 | * the class or activity where the log call occurs. 137 | * @param tr An exception to log 138 | */ 139 | fun logW(tag: String?, tr: Throwable?): Int { 140 | return Log.w(tag, tr) 141 | } 142 | 143 | /** 144 | * Send an [.ERROR] log message. 145 | * @param tag Used to identify the source of a log message. It usually identifies 146 | * the class or activity where the log call occurs. 147 | * @param msg The message you would like logged. 148 | */ 149 | fun logE(tag: String?, msg: String): Int { 150 | return Log.e(tag, msg) 151 | } 152 | 153 | /** 154 | * Send a [.ERROR] log message and log the exception. 155 | * @param tag Used to identify the source of a log message. It usually identifies 156 | * the class or activity where the log call occurs. 157 | * @param msg The message you would like logged. 158 | * @param tr An exception to log 159 | */ 160 | fun logE(tag: String?, msg: String?, tr: Throwable?): Int { 161 | return Log.e(tag, msg, tr) 162 | } 163 | 164 | fun logE(tag: String?, tr: Throwable?): Int { 165 | return Log.e(tag, "", tr) 166 | } 167 | 168 | fun safeAssert(tag: String?, msg: String, tr: Throwable? = null) { 169 | Log.e(tag, msg, tr) 170 | if (BuildConfig.DEBUG) { 171 | throw RuntimeException("$tag $msg") 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/service/SmsDaemonService.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.service 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.app.PendingIntent 7 | import android.app.Service 8 | import android.content.BroadcastReceiver 9 | import android.content.Context 10 | import android.content.Intent 11 | import android.content.IntentFilter 12 | import android.os.BatteryManager 13 | import android.os.IBinder 14 | import android.util.Log 15 | import androidx.core.app.NotificationCompat 16 | import androidx.core.app.NotificationManagerCompat 17 | import cn.quickweather.android.common.util.logD 18 | import cn.quickweather.android.common.util.logI 19 | import cn.quickweather.messageforward.MainActivity 20 | import cn.quickweather.messageforward.R 21 | import cn.quickweather.messageforward.sms.SmsForwardManager 22 | import kotlinx.coroutines.CoroutineScope 23 | import kotlinx.coroutines.Dispatchers 24 | import kotlinx.coroutines.SupervisorJob 25 | import kotlinx.coroutines.cancel 26 | import kotlinx.coroutines.launch 27 | import org.koin.android.ext.android.inject 28 | 29 | class SmsDaemonService : Service() { 30 | 31 | private val smsForwardManager: SmsForwardManager by inject() 32 | private val lowBatteryHandler: LowBatteryHandler by inject() 33 | private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 34 | 35 | override fun onBind(intent: Intent): IBinder? { 36 | return null 37 | } 38 | 39 | override fun onCreate() { 40 | logI(TAG, "onCreate") 41 | running = true 42 | finishing = false 43 | super.onCreate() 44 | createNotificationChannel() 45 | startForeground(ID_SERVICE, createNotification()) 46 | scope.launch { 47 | smsForwardManager.settingData.collect { 48 | if (!it.enabled) { 49 | finishing = true 50 | stopSelf() 51 | } 52 | } 53 | } 54 | registerLowBattery() 55 | } 56 | 57 | private val batteryStatusReceiver = object : BroadcastReceiver() { 58 | override fun onReceive(context: Context, intent: Intent) { 59 | val level = intent.getIntExtra("level", -1) 60 | val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) 61 | val isCharging = 62 | status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL 63 | if (!isCharging && lowBatteryHandler.isLowBattery(level)) { 64 | handleLowBattery(level) 65 | } 66 | } 67 | } 68 | 69 | private fun handleLowBattery(level: Int) { 70 | scope.launch { 71 | lowBatteryHandler.handleLowBattery(level) 72 | } 73 | } 74 | 75 | private fun registerLowBattery() { 76 | val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) 77 | registerReceiver(batteryStatusReceiver, filter) 78 | } 79 | 80 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 81 | running = true 82 | finishing = false 83 | logI(TAG, "onStartCommand") 84 | return START_STICKY 85 | } 86 | 87 | override fun onDestroy() { 88 | logI(TAG, "onDestroy") 89 | running = false 90 | finishing = false 91 | cancelNotification() 92 | scope.coroutineContext.cancel() 93 | unregisterReceiver(batteryStatusReceiver) 94 | super.onDestroy() 95 | } 96 | 97 | private fun createNotificationChannel() { 98 | // Create the NotificationChannel. 99 | val name = "Background Task" 100 | val descriptionText = "Forward service is on as long as this notification exists" 101 | val importance = NotificationManager.IMPORTANCE_DEFAULT 102 | val mChannel = NotificationChannel(CHANNEL_ID, name, importance) 103 | mChannel.description = descriptionText 104 | // Register the channel with the system. You can't change the importance 105 | // or other notification behaviors after this. 106 | val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager 107 | notificationManager.createNotificationChannel(mChannel) 108 | } 109 | 110 | private fun createNotification(): Notification { 111 | val intent = Intent(this, MainActivity::class.java).apply { 112 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 113 | } 114 | val pendingIntent = PendingIntent.getActivity(this, OPEN_SETTING_ACTIVITY_ID, intent, PendingIntent.FLAG_IMMUTABLE) 115 | 116 | val builder = NotificationCompat.Builder(this, CHANNEL_ID) 117 | .setSmallIcon(R.drawable.ic_forward_to_inbox) 118 | .setContentTitle(getString(R.string.title_daemon_service)) 119 | .setContentText(getString(R.string.content_daemon_service)) 120 | .setContentIntent(pendingIntent) 121 | .setPriority(NotificationCompat.PRIORITY_LOW) 122 | 123 | return builder.build() 124 | } 125 | 126 | private fun cancelNotification() { 127 | NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID) 128 | } 129 | 130 | companion object { 131 | private const val TAG = "SmsDaemonService" 132 | 133 | private const val CHANNEL_ID = "BackgroundTask" 134 | 135 | private const val ID_SERVICE = 100 136 | 137 | private const val OPEN_SETTING_ACTIVITY_ID = 200 138 | 139 | private const val NOTIFICATION_ID = 300 140 | 141 | 142 | private var running = false 143 | private var finishing = false 144 | 145 | fun enableService(context: Context, enable: Boolean) { 146 | Log.i(TAG, "enableService: enable:$enable running:$running finishing:$finishing") 147 | if (enable && !running && !finishing) { 148 | val intent = Intent(context, SmsDaemonService::class.java) 149 | context.startForegroundService(intent) 150 | } 151 | if (!enable && !finishing && running) { 152 | val intent = Intent(context, SmsDaemonService::class.java) 153 | context.stopService(intent) 154 | } 155 | } 156 | } 157 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/setting/SettingViewModel.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.setting 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import cn.quickweather.messageforward.R 8 | import cn.quickweather.messageforward.history.ForwardHistoryDataStore 9 | import cn.quickweather.messageforward.history.HistoryData 10 | import cn.quickweather.messageforward.sms.SmsForwardManager 11 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 12 | import com.google.accompanist.permissions.PermissionStatus 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.MutableStateFlow 15 | import kotlinx.coroutines.flow.SharingStarted 16 | import kotlinx.coroutines.flow.combine 17 | import kotlinx.coroutines.flow.first 18 | import kotlinx.coroutines.flow.stateIn 19 | import kotlinx.coroutines.launch 20 | 21 | /** 22 | * Created by maweihao on 5/21/24 23 | */ 24 | internal class SettingViewModel( 25 | private val smsForwardManager: SmsForwardManager, 26 | private val settingDataStore: SettingDataStore, 27 | private val historyDataStore: ForwardHistoryDataStore, 28 | ): ViewModel() { 29 | 30 | private val showConsentDialog = MutableStateFlow(false) 31 | private val settingDataFlow: Flow = settingDataStore.settingData 32 | private val permissionFlow = MutableStateFlow(PermissionState( 33 | smsPermissionEnabled = true, 34 | notificationPermissionEnabled = true 35 | )) 36 | 37 | private val _shownSettingDataFlow: Flow = combine( 38 | settingDataFlow, 39 | permissionFlow, 40 | historyDataStore.historyData, 41 | showConsentDialog, 42 | ) { settingData, permission, historyList, consent -> 43 | if (!settingData.enabled) { 44 | ShownSettingData(settingData) 45 | } else if (!settingData.phoneNumberValid) { 46 | ShownSettingData( 47 | settingData, 48 | ShownError.InvalidPhoneNumber, 49 | historyList, 50 | showConsentDialog = consent 51 | ) 52 | } else if (!permission.smsPermissionEnabled) { 53 | ShownSettingData( 54 | settingData, 55 | ShownError.LackSmsPermission, 56 | historyList, 57 | showConsentDialog = consent 58 | ) 59 | } else if (!permission.notificationPermissionEnabled) { 60 | ShownSettingData( 61 | settingData, 62 | ShownError.LackNotificationPermission, 63 | historyList, 64 | showConsentDialog = consent 65 | ) 66 | } else { 67 | ShownSettingData(settingData, history = historyList, showConsentDialog = consent) 68 | } 69 | } 70 | 71 | val shownSettingDataFlow = _shownSettingDataFlow.stateIn( 72 | viewModelScope, 73 | SharingStarted.WhileSubscribed(5000L), 74 | ShownSettingData(SettingData()), 75 | ) 76 | 77 | fun refreshSmsPermissionState(enabled: Boolean) { 78 | permissionFlow.value = permissionFlow.value.copy(smsPermissionEnabled = enabled) 79 | } 80 | 81 | fun refreshNotificationPermissionState(enabled: Boolean) { 82 | permissionFlow.value = permissionFlow.value.copy(notificationPermissionEnabled = enabled) 83 | } 84 | 85 | fun changeSetting(context: Context, enabled: Boolean) { 86 | viewModelScope.launch { 87 | settingDataStore.updateSetting( 88 | settingDataFlow.first().copy( 89 | enabled = enabled, 90 | ) 91 | ) 92 | smsForwardManager.enableForwardService(context, enabled) 93 | } 94 | } 95 | 96 | fun changePhoneNumber(s: String?) { 97 | viewModelScope.launch { 98 | settingDataStore.updateSetting( 99 | settingDataFlow.first().copy( 100 | smsToNumber = s, 101 | ) 102 | ) 103 | } 104 | } 105 | 106 | fun changeOnlyForwardVerificationCode(enabled: Boolean) { 107 | viewModelScope.launch { 108 | if (enabled) { 109 | if (hasAgreedConsent) { 110 | settingDataStore.updateSetting( 111 | settingDataFlow.first().copy( 112 | onlyVerificationCode = true, 113 | ) 114 | ) 115 | } else { 116 | showConsentDialog.value = true 117 | } 118 | } else { 119 | settingDataStore.updateSetting( 120 | settingDataFlow.first().copy( 121 | onlyVerificationCode = false, 122 | ) 123 | ) 124 | } 125 | } 126 | } 127 | 128 | fun changeBatteryNotification(enabled: Boolean) { 129 | viewModelScope.launch { 130 | settingDataStore.updateSetting( 131 | settingDataFlow.first().copy( 132 | sendBatteryNotification = enabled, 133 | lastBatteryNotificationTime = 0L, 134 | ) 135 | ) 136 | } 137 | } 138 | 139 | fun onAgreeConsent() { 140 | showConsentDialog.value = false 141 | hasAgreedConsent = true 142 | viewModelScope.launch { 143 | settingDataStore.updateSetting( 144 | settingDataFlow.first().copy( 145 | onlyVerificationCode = true, 146 | ) 147 | ) 148 | } 149 | } 150 | 151 | fun onDisagreeConsent() { 152 | showConsentDialog.value = false 153 | } 154 | } 155 | 156 | private var hasAgreedConsent = false 157 | private const val TAG = "SettingViewModel" 158 | 159 | internal data class ShownSettingData( 160 | val settingData: SettingData, 161 | val shownError: ShownError? = null, 162 | val history: List = emptyList(), 163 | val showConsentDialog: Boolean = false, 164 | ) 165 | 166 | private data class PermissionState( 167 | val smsPermissionEnabled: Boolean, 168 | val notificationPermissionEnabled: Boolean, 169 | ) 170 | 171 | internal enum class ShownError( 172 | @StringRes val errString: Int, 173 | ) { 174 | InvalidPhoneNumber(R.string.error_invalid_phone_number), 175 | LackSmsPermission(R.string.error_LackSmsPermission), 176 | LackNotificationPermission(R.string.error_LackNotificationPermission), 177 | ; 178 | } 179 | 180 | @OptIn(ExperimentalPermissionsApi::class) 181 | internal object GrantedPermissionState : com.google.accompanist.permissions.PermissionState { 182 | override val permission: String = "" 183 | override val status: PermissionStatus 184 | get() = PermissionStatus.Granted 185 | 186 | override fun launchPermissionRequest() {} 187 | 188 | } 189 | -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val primaryLight = Color(0xFF4C662B) 6 | val onPrimaryLight = Color(0xFFFFFFFF) 7 | val primaryContainerLight = Color(0xFFCDEDA3) 8 | val onPrimaryContainerLight = Color(0xFF102000) 9 | val secondaryLight = Color(0xFF586249) 10 | val onSecondaryLight = Color(0xFFFFFFFF) 11 | val secondaryContainerLight = Color(0xFFDCE7C8) 12 | val onSecondaryContainerLight = Color(0xFF151E0B) 13 | val tertiaryLight = Color(0xFF386663) 14 | val onTertiaryLight = Color(0xFFFFFFFF) 15 | val tertiaryContainerLight = Color(0xFFBCECE7) 16 | val onTertiaryContainerLight = Color(0xFF00201E) 17 | val errorLight = Color(0xFFBA1A1A) 18 | val onErrorLight = Color(0xFFFFFFFF) 19 | val errorContainerLight = Color(0xFFFFDAD6) 20 | val onErrorContainerLight = Color(0xFF410002) 21 | val backgroundLight = Color(0xFFF9FAEF) 22 | val onBackgroundLight = Color(0xFF1A1C16) 23 | val surfaceLight = Color(0xFFF9FAEF) 24 | val onSurfaceLight = Color(0xFF1A1C16) 25 | val surfaceVariantLight = Color(0xFFE1E4D5) 26 | val onSurfaceVariantLight = Color(0xFF44483D) 27 | val outlineLight = Color(0xFF75796C) 28 | val outlineVariantLight = Color(0xFFC5C8BA) 29 | val scrimLight = Color(0xFF000000) 30 | val inverseSurfaceLight = Color(0xFF2F312A) 31 | val inverseOnSurfaceLight = Color(0xFFF1F2E6) 32 | val inversePrimaryLight = Color(0xFFB1D18A) 33 | val surfaceDimLight = Color(0xFFDADBD0) 34 | val surfaceBrightLight = Color(0xFFF9FAEF) 35 | val surfaceContainerLowestLight = Color(0xFFFFFFFF) 36 | val surfaceContainerLowLight = Color(0xFFF3F4E9) 37 | val surfaceContainerLight = Color(0xFFEEEFE3) 38 | val surfaceContainerHighLight = Color(0xFFE8E9DE) 39 | val surfaceContainerHighestLight = Color(0xFFE2E3D8) 40 | 41 | val primaryLightMediumContrast = Color(0xFF314A12) 42 | val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) 43 | val primaryContainerLightMediumContrast = Color(0xFF617D3F) 44 | val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) 45 | val secondaryLightMediumContrast = Color(0xFF3C462F) 46 | val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) 47 | val secondaryContainerLightMediumContrast = Color(0xFF6E785E) 48 | val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) 49 | val tertiaryLightMediumContrast = Color(0xFF1A4A47) 50 | val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) 51 | val tertiaryContainerLightMediumContrast = Color(0xFF4F7D79) 52 | val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) 53 | val errorLightMediumContrast = Color(0xFF8C0009) 54 | val onErrorLightMediumContrast = Color(0xFFFFFFFF) 55 | val errorContainerLightMediumContrast = Color(0xFFDA342E) 56 | val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) 57 | val backgroundLightMediumContrast = Color(0xFFF9FAEF) 58 | val onBackgroundLightMediumContrast = Color(0xFF1A1C16) 59 | val surfaceLightMediumContrast = Color(0xFFF9FAEF) 60 | val onSurfaceLightMediumContrast = Color(0xFF1A1C16) 61 | val surfaceVariantLightMediumContrast = Color(0xFFE1E4D5) 62 | val onSurfaceVariantLightMediumContrast = Color(0xFF404439) 63 | val outlineLightMediumContrast = Color(0xFF5D6155) 64 | val outlineVariantLightMediumContrast = Color(0xFF787C70) 65 | val scrimLightMediumContrast = Color(0xFF000000) 66 | val inverseSurfaceLightMediumContrast = Color(0xFF2F312A) 67 | val inverseOnSurfaceLightMediumContrast = Color(0xFFF1F2E6) 68 | val inversePrimaryLightMediumContrast = Color(0xFFB1D18A) 69 | val surfaceDimLightMediumContrast = Color(0xFFDADBD0) 70 | val surfaceBrightLightMediumContrast = Color(0xFFF9FAEF) 71 | val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) 72 | val surfaceContainerLowLightMediumContrast = Color(0xFFF3F4E9) 73 | val surfaceContainerLightMediumContrast = Color(0xFFEEEFE3) 74 | val surfaceContainerHighLightMediumContrast = Color(0xFFE8E9DE) 75 | val surfaceContainerHighestLightMediumContrast = Color(0xFFE2E3D8) 76 | 77 | val primaryLightHighContrast = Color(0xFF142700) 78 | val onPrimaryLightHighContrast = Color(0xFFFFFFFF) 79 | val primaryContainerLightHighContrast = Color(0xFF314A12) 80 | val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) 81 | val secondaryLightHighContrast = Color(0xFF1C2511) 82 | val onSecondaryLightHighContrast = Color(0xFFFFFFFF) 83 | val secondaryContainerLightHighContrast = Color(0xFF3C462F) 84 | val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) 85 | val tertiaryLightHighContrast = Color(0xFF002725) 86 | val onTertiaryLightHighContrast = Color(0xFFFFFFFF) 87 | val tertiaryContainerLightHighContrast = Color(0xFF1A4A47) 88 | val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) 89 | val errorLightHighContrast = Color(0xFF4E0002) 90 | val onErrorLightHighContrast = Color(0xFFFFFFFF) 91 | val errorContainerLightHighContrast = Color(0xFF8C0009) 92 | val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) 93 | val backgroundLightHighContrast = Color(0xFFF9FAEF) 94 | val onBackgroundLightHighContrast = Color(0xFF1A1C16) 95 | val surfaceLightHighContrast = Color(0xFFF9FAEF) 96 | val onSurfaceLightHighContrast = Color(0xFF000000) 97 | val surfaceVariantLightHighContrast = Color(0xFFE1E4D5) 98 | val onSurfaceVariantLightHighContrast = Color(0xFF21251C) 99 | val outlineLightHighContrast = Color(0xFF404439) 100 | val outlineVariantLightHighContrast = Color(0xFF404439) 101 | val scrimLightHighContrast = Color(0xFF000000) 102 | val inverseSurfaceLightHighContrast = Color(0xFF2F312A) 103 | val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) 104 | val inversePrimaryLightHighContrast = Color(0xFFD6F7AC) 105 | val surfaceDimLightHighContrast = Color(0xFFDADBD0) 106 | val surfaceBrightLightHighContrast = Color(0xFFF9FAEF) 107 | val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) 108 | val surfaceContainerLowLightHighContrast = Color(0xFFF3F4E9) 109 | val surfaceContainerLightHighContrast = Color(0xFFEEEFE3) 110 | val surfaceContainerHighLightHighContrast = Color(0xFFE8E9DE) 111 | val surfaceContainerHighestLightHighContrast = Color(0xFFE2E3D8) 112 | 113 | val primaryDark = Color(0xFFB1D18A) 114 | val onPrimaryDark = Color(0xFF1F3701) 115 | val primaryContainerDark = Color(0xFF354E16) 116 | val onPrimaryContainerDark = Color(0xFFCDEDA3) 117 | val secondaryDark = Color(0xFFBFCBAD) 118 | val onSecondaryDark = Color(0xFF2A331E) 119 | val secondaryContainerDark = Color(0xFF404A33) 120 | val onSecondaryContainerDark = Color(0xFFDCE7C8) 121 | val tertiaryDark = Color(0xFFA0D0CB) 122 | val onTertiaryDark = Color(0xFF003735) 123 | val tertiaryContainerDark = Color(0xFF1F4E4B) 124 | val onTertiaryContainerDark = Color(0xFFBCECE7) 125 | val errorDark = Color(0xFFFFB4AB) 126 | val onErrorDark = Color(0xFF690005) 127 | val errorContainerDark = Color(0xFF93000A) 128 | val onErrorContainerDark = Color(0xFFFFDAD6) 129 | val backgroundDark = Color(0xFF12140E) 130 | val onBackgroundDark = Color(0xFFE2E3D8) 131 | val surfaceDark = Color(0xFF12140E) 132 | val onSurfaceDark = Color(0xFFE2E3D8) 133 | val surfaceVariantDark = Color(0xFF44483D) 134 | val onSurfaceVariantDark = Color(0xFFC5C8BA) 135 | val outlineDark = Color(0xFF8F9285) 136 | val outlineVariantDark = Color(0xFF44483D) 137 | val scrimDark = Color(0xFF000000) 138 | val inverseSurfaceDark = Color(0xFFE2E3D8) 139 | val inverseOnSurfaceDark = Color(0xFF2F312A) 140 | val inversePrimaryDark = Color(0xFF4C662B) 141 | val surfaceDimDark = Color(0xFF12140E) 142 | val surfaceBrightDark = Color(0xFF383A32) 143 | val surfaceContainerLowestDark = Color(0xFF0C0F09) 144 | val surfaceContainerLowDark = Color(0xFF1A1C16) 145 | val surfaceContainerDark = Color(0xFF1E201A) 146 | val surfaceContainerHighDark = Color(0xFF282B24) 147 | val surfaceContainerHighestDark = Color(0xFF33362E) 148 | 149 | val primaryDarkMediumContrast = Color(0xFFB5D58E) 150 | val onPrimaryDarkMediumContrast = Color(0xFF0C1A00) 151 | val primaryContainerDarkMediumContrast = Color(0xFF7D9A59) 152 | val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) 153 | val secondaryDarkMediumContrast = Color(0xFFC4CFB1) 154 | val onSecondaryDarkMediumContrast = Color(0xFF101907) 155 | val secondaryContainerDarkMediumContrast = Color(0xFF8A9579) 156 | val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) 157 | val tertiaryDarkMediumContrast = Color(0xFFA4D4D0) 158 | val onTertiaryDarkMediumContrast = Color(0xFF001A19) 159 | val tertiaryContainerDarkMediumContrast = Color(0xFF6B9995) 160 | val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) 161 | val errorDarkMediumContrast = Color(0xFFFFBAB1) 162 | val onErrorDarkMediumContrast = Color(0xFF370001) 163 | val errorContainerDarkMediumContrast = Color(0xFFFF5449) 164 | val onErrorContainerDarkMediumContrast = Color(0xFF000000) 165 | val backgroundDarkMediumContrast = Color(0xFF12140E) 166 | val onBackgroundDarkMediumContrast = Color(0xFFE2E3D8) 167 | val surfaceDarkMediumContrast = Color(0xFF12140E) 168 | val onSurfaceDarkMediumContrast = Color(0xFFFBFCF0) 169 | val surfaceVariantDarkMediumContrast = Color(0xFF44483D) 170 | val onSurfaceVariantDarkMediumContrast = Color(0xFFC9CCBE) 171 | val outlineDarkMediumContrast = Color(0xFFA1A497) 172 | val outlineVariantDarkMediumContrast = Color(0xFF818578) 173 | val scrimDarkMediumContrast = Color(0xFF000000) 174 | val inverseSurfaceDarkMediumContrast = Color(0xFFE2E3D8) 175 | val inverseOnSurfaceDarkMediumContrast = Color(0xFF282B24) 176 | val inversePrimaryDarkMediumContrast = Color(0xFF364F17) 177 | val surfaceDimDarkMediumContrast = Color(0xFF12140E) 178 | val surfaceBrightDarkMediumContrast = Color(0xFF383A32) 179 | val surfaceContainerLowestDarkMediumContrast = Color(0xFF0C0F09) 180 | val surfaceContainerLowDarkMediumContrast = Color(0xFF1A1C16) 181 | val surfaceContainerDarkMediumContrast = Color(0xFF1E201A) 182 | val surfaceContainerHighDarkMediumContrast = Color(0xFF282B24) 183 | val surfaceContainerHighestDarkMediumContrast = Color(0xFF33362E) 184 | 185 | val primaryDarkHighContrast = Color(0xFFF4FFDF) 186 | val onPrimaryDarkHighContrast = Color(0xFF000000) 187 | val primaryContainerDarkHighContrast = Color(0xFFB5D58E) 188 | val onPrimaryContainerDarkHighContrast = Color(0xFF000000) 189 | val secondaryDarkHighContrast = Color(0xFFF4FFDF) 190 | val onSecondaryDarkHighContrast = Color(0xFF000000) 191 | val secondaryContainerDarkHighContrast = Color(0xFFC4CFB1) 192 | val onSecondaryContainerDarkHighContrast = Color(0xFF000000) 193 | val tertiaryDarkHighContrast = Color(0xFFEAFFFC) 194 | val onTertiaryDarkHighContrast = Color(0xFF000000) 195 | val tertiaryContainerDarkHighContrast = Color(0xFFA4D4D0) 196 | val onTertiaryContainerDarkHighContrast = Color(0xFF000000) 197 | val errorDarkHighContrast = Color(0xFFFFF9F9) 198 | val onErrorDarkHighContrast = Color(0xFF000000) 199 | val errorContainerDarkHighContrast = Color(0xFFFFBAB1) 200 | val onErrorContainerDarkHighContrast = Color(0xFF000000) 201 | val backgroundDarkHighContrast = Color(0xFF12140E) 202 | val onBackgroundDarkHighContrast = Color(0xFFE2E3D8) 203 | val surfaceDarkHighContrast = Color(0xFF12140E) 204 | val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) 205 | val surfaceVariantDarkHighContrast = Color(0xFF44483D) 206 | val onSurfaceVariantDarkHighContrast = Color(0xFFF9FCED) 207 | val outlineDarkHighContrast = Color(0xFFC9CCBE) 208 | val outlineVariantDarkHighContrast = Color(0xFFC9CCBE) 209 | val scrimDarkHighContrast = Color(0xFF000000) 210 | val inverseSurfaceDarkHighContrast = Color(0xFFE2E3D8) 211 | val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) 212 | val inversePrimaryDarkHighContrast = Color(0xFF1A3000) 213 | val surfaceDimDarkHighContrast = Color(0xFF12140E) 214 | val surfaceBrightDarkHighContrast = Color(0xFF383A32) 215 | val surfaceContainerLowestDarkHighContrast = Color(0xFF0C0F09) 216 | val surfaceContainerLowDarkHighContrast = Color(0xFF1A1C16) 217 | val surfaceContainerDarkHighContrast = Color(0xFF1E201A) 218 | val surfaceContainerHighDarkHighContrast = Color(0xFF282B24) 219 | val surfaceContainerHighestDarkHighContrast = Color(0xFF33362E) 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /common/src/main/java/cn/quickweather/android/common/util/ViewUtil.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.android.common.util 2 | 3 | import android.app.Activity 4 | import android.content.ClipData 5 | import android.content.ClipboardManager 6 | import android.content.Context 7 | import android.content.ContextWrapper 8 | import android.content.pm.ActivityInfo 9 | import android.content.res.Configuration 10 | import android.content.res.Resources 11 | import android.graphics.Color 12 | import android.graphics.Rect 13 | import android.graphics.drawable.Drawable 14 | import android.os.Build 15 | import android.view.LayoutInflater 16 | import android.view.View 17 | import android.view.ViewGroup 18 | import android.view.Window 19 | import android.view.WindowInsetsController 20 | import android.view.WindowManager 21 | import android.view.inputmethod.InputMethodManager 22 | import android.widget.EditText 23 | import android.widget.TextView 24 | import androidx.activity.ComponentActivity 25 | import androidx.annotation.ColorInt 26 | import androidx.annotation.LayoutRes 27 | import androidx.annotation.StringRes 28 | import androidx.core.content.res.ResourcesCompat 29 | import androidx.core.hardware.display.DisplayManagerCompat 30 | import androidx.fragment.app.FragmentActivity 31 | import androidx.fragment.app.FragmentManager 32 | import androidx.recyclerview.widget.RecyclerView 33 | import com.google.android.material.appbar.AppBarLayout 34 | import kotlin.math.abs 35 | 36 | /** 37 | * Created by maweihao on 5/24/24 38 | */ 39 | private val density: Float by lazyUnsafe { 40 | applicationContext.resources.displayMetrics.density 41 | } 42 | 43 | val screenWidth: Int by lazyUnsafe { 44 | val service = applicationContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager 45 | service.defaultDisplay.width 46 | } 47 | 48 | fun Int.px(context: Context): Float { 49 | return context.resources.displayMetrics.density * this.toFloat() 50 | } 51 | 52 | fun Int.px(): Float { 53 | return Resources.getSystem().displayMetrics.density * this.toFloat() 54 | } 55 | 56 | val Int.px: Float 57 | get() { 58 | return Resources.getSystem().displayMetrics.density * this.toFloat() 59 | } 60 | 61 | fun Float.px(): Float { 62 | return density * this 63 | } 64 | 65 | val GlobalRes: Resources 66 | get() { 67 | return applicationContext.resources 68 | } 69 | 70 | fun Int?.toDrawable(): Drawable? { 71 | if (this == null) return null 72 | return ResourcesCompat.getDrawable(GlobalRes, this, null) 73 | } 74 | 75 | fun Int?.toResString(vararg formatArgs: Any?): String { 76 | if (this == null) return "" 77 | return GlobalRes.getString(this, *formatArgs) 78 | } 79 | 80 | fun Int.toResColor(): Int { 81 | return GlobalRes.getColor(this) 82 | } 83 | 84 | fun Boolean?.toVisibility(): Int { 85 | return if (this == true) View.VISIBLE else View.GONE 86 | } 87 | 88 | fun parseColor(s: String?, defaultValue: Int = 0): Int { 89 | return s?.let { 90 | try { 91 | Color.parseColor(s) 92 | } catch (ignored: Exception) { 93 | defaultValue 94 | } 95 | } ?: defaultValue 96 | } 97 | 98 | @ColorInt 99 | fun Int.withAlpha(alpha: Float): Int { 100 | if (alpha < 0 || alpha > 1) return this 101 | return Color.argb((alpha * 255.0f + 0.5f).toInt(), Color.red(this), Color.green(this), Color.blue(this)) 102 | } 103 | 104 | fun Int.toHexColor(): String { 105 | return String.format("#%06X", 0xFFFFFF and this) 106 | } 107 | 108 | fun RecyclerView.canScrollUp(): Boolean { 109 | return canScrollVertically(-1) 110 | } 111 | 112 | fun View.setMarginStart(value: Int) { 113 | val params = layoutParams as? ViewGroup.MarginLayoutParams ?: return 114 | if (params.marginStart == value) return 115 | params.marginStart = value 116 | layoutParams = params 117 | } 118 | 119 | fun View.setMarginEnd(value: Int) { 120 | val params = layoutParams as? ViewGroup.MarginLayoutParams ?: return 121 | if (params.marginEnd == value) return 122 | params.marginEnd = value 123 | layoutParams = params 124 | } 125 | 126 | fun View.applyMarginTop(@StringRes tag: Int, value: Int) { 127 | val last = getTag(tag) as? Int ?: 0 128 | val params = layoutParams as? ViewGroup.MarginLayoutParams ?: return 129 | val marginTop = params.topMargin + value - last 130 | params.topMargin = marginTop 131 | setTag(tag, value) 132 | layoutParams = params 133 | } 134 | 135 | fun View.applyMarginBottom(@StringRes tag: Int, value: Int) { 136 | val last = getTag(tag) as? Int ?: 0 137 | val params = layoutParams as? ViewGroup.MarginLayoutParams ?: return 138 | val marginBottom = params.bottomMargin + value - last 139 | params.bottomMargin = marginBottom 140 | setTag(tag, value) 141 | layoutParams = params 142 | } 143 | 144 | fun View.applyPaddingTop(@StringRes tag: Int, value: Int) { 145 | val last = getTag(tag) as? Int ?: 0 146 | val top = this.paddingTop + value - last 147 | setPadding(paddingLeft, top, paddingRight, paddingBottom) 148 | setTag(tag, value) 149 | } 150 | 151 | fun View.applyPaddingBottom(@StringRes tag: Int, value: Int) { 152 | val last = getTag(tag) as? Int ?: 0 153 | val bottom = this.paddingBottom + value - last 154 | setPadding(paddingLeft, paddingTop, paddingRight, bottom) 155 | setTag(tag, value) 156 | } 157 | 158 | fun View.applyHeight(value: Int) { 159 | if (layoutParams.height == value) return 160 | layoutParams.height = value 161 | layoutParams = layoutParams 162 | } 163 | 164 | fun View.applyWidth(value: Int) { 165 | if (layoutParams.width == value) return 166 | layoutParams.width = value 167 | layoutParams = layoutParams 168 | } 169 | 170 | fun View.gone() { 171 | visibility = View.GONE 172 | } 173 | 174 | fun View.invisible() { 175 | visibility = View.INVISIBLE 176 | } 177 | 178 | fun View.visible() { 179 | visibility = View.VISIBLE 180 | } 181 | 182 | fun Activity?.hideKeyboard() { 183 | this ?: return 184 | val imm: InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 185 | // 隐藏软键盘 186 | imm.hideSoftInputFromWindow(window.decorView.windowToken, 0) 187 | } 188 | 189 | fun EditText.showKeyboard() { 190 | requestFocus() 191 | val imm: InputMethodManager = 192 | context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 193 | imm.showSoftInput(findFocus(), 0) 194 | } 195 | 196 | fun TextView.loadOrGone(content: String?) { 197 | text = content 198 | visibility = (content?.isNotBlank() == true).toVisibility() 199 | } 200 | 201 | fun copyContentToClipBoard(context: Context, content: String?) { 202 | val cm: ClipboardManager? = 203 | context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager? 204 | val mClipData = ClipData.newPlainText("Label", content) 205 | cm?.setPrimaryClip(mClipData) 206 | } 207 | 208 | fun isDarkTheme(): Boolean { 209 | val flag = GlobalRes.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK 210 | return flag == Configuration.UI_MODE_NIGHT_YES 211 | } 212 | 213 | fun Window.setStatusBarTextDark(dark: Boolean, fitDarkTheme: Boolean = true) { 214 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 215 | val controller = decorView.windowInsetsController 216 | val finalDark = if (fitDarkTheme) { 217 | if (isDarkTheme()) !dark else dark 218 | } else { 219 | dark 220 | } 221 | if (finalDark) { 222 | controller?.setSystemBarsAppearance( 223 | WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, 224 | WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS 225 | ) 226 | } else { 227 | controller?.setSystemBarsAppearance(0, WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS) 228 | } 229 | } else { 230 | addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) 231 | clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) 232 | val decorView = decorView 233 | decorView.systemUiVisibility = if (dark) { 234 | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 235 | } else { 236 | View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 237 | } 238 | } 239 | } 240 | 241 | internal typealias SimpleCallback = () -> Unit 242 | fun AppBarLayout.addStateListener(onExpand: SimpleCallback, onCollapse: SimpleCallback, onIndeterminate: SimpleCallback) { 243 | addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener { 244 | private var state: CollapsingToolbarLayoutState? = null 245 | 246 | override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { 247 | if (verticalOffset == 0) { 248 | if (state != CollapsingToolbarLayoutState.EXPANDED) { 249 | onExpand.invoke() 250 | } 251 | } else if (abs(verticalOffset) >= appBarLayout.totalScrollRange) { 252 | if (state != CollapsingToolbarLayoutState.COLLAPSED) { 253 | state = CollapsingToolbarLayoutState.COLLAPSED 254 | onCollapse.invoke() 255 | } 256 | } else if (state != CollapsingToolbarLayoutState.INDETERMINATE) { 257 | if (state == CollapsingToolbarLayoutState.COLLAPSED) { 258 | onIndeterminate.invoke() 259 | } 260 | state = CollapsingToolbarLayoutState.INDETERMINATE 261 | } 262 | } 263 | }) 264 | } 265 | 266 | fun Context.enableWideColorGamut(): Boolean { 267 | if (!DisplayManagerCompat.getInstance(this).displays[0].isWideColorGamut) { 268 | return false 269 | } 270 | requireActivity().let { activity -> 271 | activity.window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT 272 | val wideColorGamut = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { 273 | activity.window.isWideColorGamut 274 | } else { 275 | true 276 | } 277 | logI("enableWideColorGamut", "$wideColorGamut") 278 | return wideColorGamut 279 | } 280 | } 281 | 282 | val Context.activityFragmentManager: FragmentManager 283 | get() { 284 | return (this.requireActivity() as FragmentActivity).supportFragmentManager 285 | } 286 | 287 | fun Context.isWideColorGamut(): Boolean { 288 | if (!DisplayManagerCompat.getInstance(this).displays[0].isWideColorGamut) { 289 | return false 290 | } 291 | requireActivity().let { activity -> 292 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { 293 | activity.window.isWideColorGamut 294 | } else { 295 | true 296 | } 297 | } 298 | } 299 | 300 | private enum class CollapsingToolbarLayoutState { 301 | EXPANDED, COLLAPSED, INDETERMINATE 302 | } 303 | 304 | open class SimpleViewHolder(parent: ViewGroup, @LayoutRes layout: Int) : 305 | RecyclerView.ViewHolder(LayoutInflater.from(parent.context).inflate(layout, parent, false)) { 306 | open fun bind(data: Data) { } 307 | } 308 | 309 | class IntervalDecoration(private val bottom: Int): RecyclerView.ItemDecoration() { 310 | 311 | override fun getItemOffsets( 312 | outRect: Rect, 313 | view: View, 314 | parent: RecyclerView, 315 | state: RecyclerView.State 316 | ) { 317 | super.getItemOffsets(outRect, view, parent, state) 318 | outRect.bottom = bottom 319 | } 320 | 321 | } 322 | 323 | fun Context.requireActivity(): ComponentActivity { 324 | return findActivity() ?: error( 325 | "${this.javaClass.simpleName} is not an activity" 326 | ) 327 | } 328 | 329 | 330 | fun Context.findActivity(): ComponentActivity? { 331 | return when (this) { 332 | is ComponentActivity -> { 333 | this 334 | } 335 | is ContextWrapper -> { 336 | baseContext.findActivity() 337 | } 338 | else -> { 339 | null 340 | } 341 | } 342 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package cn.quickweather.messageforward.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.Immutable 13 | import androidx.compose.runtime.SideEffect 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.graphics.toArgb 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.platform.LocalView 18 | import androidx.core.view.WindowCompat 19 | 20 | private val lightScheme = lightColorScheme( 21 | primary = primaryLight, 22 | onPrimary = onPrimaryLight, 23 | primaryContainer = primaryContainerLight, 24 | onPrimaryContainer = onPrimaryContainerLight, 25 | secondary = secondaryLight, 26 | onSecondary = onSecondaryLight, 27 | secondaryContainer = secondaryContainerLight, 28 | onSecondaryContainer = onSecondaryContainerLight, 29 | tertiary = tertiaryLight, 30 | onTertiary = onTertiaryLight, 31 | tertiaryContainer = tertiaryContainerLight, 32 | onTertiaryContainer = onTertiaryContainerLight, 33 | error = errorLight, 34 | onError = onErrorLight, 35 | errorContainer = errorContainerLight, 36 | onErrorContainer = onErrorContainerLight, 37 | background = backgroundLight, 38 | onBackground = onBackgroundLight, 39 | surface = surfaceLight, 40 | onSurface = onSurfaceLight, 41 | surfaceVariant = surfaceVariantLight, 42 | onSurfaceVariant = onSurfaceVariantLight, 43 | outline = outlineLight, 44 | outlineVariant = outlineVariantLight, 45 | scrim = scrimLight, 46 | inverseSurface = inverseSurfaceLight, 47 | inverseOnSurface = inverseOnSurfaceLight, 48 | inversePrimary = inversePrimaryLight, 49 | // surfaceDim = surfaceDimLight, 50 | // surfaceBright = surfaceBrightLight, 51 | // surfaceContainerLowest = surfaceContainerLowestLight, 52 | // surfaceContainerLow = surfaceContainerLowLight, 53 | // surfaceContainer = surfaceContainerLight, 54 | // surfaceContainerHigh = surfaceContainerHighLight, 55 | // surfaceContainerHighest = surfaceContainerHighestLight, 56 | ) 57 | 58 | private val darkScheme = darkColorScheme( 59 | primary = primaryDark, 60 | onPrimary = onPrimaryDark, 61 | primaryContainer = primaryContainerDark, 62 | onPrimaryContainer = onPrimaryContainerDark, 63 | secondary = secondaryDark, 64 | onSecondary = onSecondaryDark, 65 | secondaryContainer = secondaryContainerDark, 66 | onSecondaryContainer = onSecondaryContainerDark, 67 | tertiary = tertiaryDark, 68 | onTertiary = onTertiaryDark, 69 | tertiaryContainer = tertiaryContainerDark, 70 | onTertiaryContainer = onTertiaryContainerDark, 71 | error = errorDark, 72 | onError = onErrorDark, 73 | errorContainer = errorContainerDark, 74 | onErrorContainer = onErrorContainerDark, 75 | background = backgroundDark, 76 | onBackground = onBackgroundDark, 77 | surface = surfaceDark, 78 | onSurface = onSurfaceDark, 79 | surfaceVariant = surfaceVariantDark, 80 | onSurfaceVariant = onSurfaceVariantDark, 81 | outline = outlineDark, 82 | outlineVariant = outlineVariantDark, 83 | scrim = scrimDark, 84 | inverseSurface = inverseSurfaceDark, 85 | inverseOnSurface = inverseOnSurfaceDark, 86 | inversePrimary = inversePrimaryDark, 87 | // surfaceDim = surfaceDimDark, 88 | // surfaceBright = surfaceBrightDark, 89 | // surfaceContainerLowest = surfaceContainerLowestDark, 90 | // surfaceContainerLow = surfaceContainerLowDark, 91 | // surfaceContainer = surfaceContainerDark, 92 | // surfaceContainerHigh = surfaceContainerHighDark, 93 | // surfaceContainerHighest = surfaceContainerHighestDark, 94 | ) 95 | 96 | private val mediumContrastLightColorScheme = lightColorScheme( 97 | primary = primaryLightMediumContrast, 98 | onPrimary = onPrimaryLightMediumContrast, 99 | primaryContainer = primaryContainerLightMediumContrast, 100 | onPrimaryContainer = onPrimaryContainerLightMediumContrast, 101 | secondary = secondaryLightMediumContrast, 102 | onSecondary = onSecondaryLightMediumContrast, 103 | secondaryContainer = secondaryContainerLightMediumContrast, 104 | onSecondaryContainer = onSecondaryContainerLightMediumContrast, 105 | tertiary = tertiaryLightMediumContrast, 106 | onTertiary = onTertiaryLightMediumContrast, 107 | tertiaryContainer = tertiaryContainerLightMediumContrast, 108 | onTertiaryContainer = onTertiaryContainerLightMediumContrast, 109 | error = errorLightMediumContrast, 110 | onError = onErrorLightMediumContrast, 111 | errorContainer = errorContainerLightMediumContrast, 112 | onErrorContainer = onErrorContainerLightMediumContrast, 113 | background = backgroundLightMediumContrast, 114 | onBackground = onBackgroundLightMediumContrast, 115 | surface = surfaceLightMediumContrast, 116 | onSurface = onSurfaceLightMediumContrast, 117 | surfaceVariant = surfaceVariantLightMediumContrast, 118 | onSurfaceVariant = onSurfaceVariantLightMediumContrast, 119 | outline = outlineLightMediumContrast, 120 | outlineVariant = outlineVariantLightMediumContrast, 121 | scrim = scrimLightMediumContrast, 122 | inverseSurface = inverseSurfaceLightMediumContrast, 123 | inverseOnSurface = inverseOnSurfaceLightMediumContrast, 124 | inversePrimary = inversePrimaryLightMediumContrast, 125 | // surfaceDim = surfaceDimLightMediumContrast, 126 | // surfaceBright = surfaceBrightLightMediumContrast, 127 | // surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, 128 | // surfaceContainerLow = surfaceContainerLowLightMediumContrast, 129 | // surfaceContainer = surfaceContainerLightMediumContrast, 130 | // surfaceContainerHigh = surfaceContainerHighLightMediumContrast, 131 | // surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, 132 | ) 133 | 134 | private val highContrastLightColorScheme = lightColorScheme( 135 | primary = primaryLightHighContrast, 136 | onPrimary = onPrimaryLightHighContrast, 137 | primaryContainer = primaryContainerLightHighContrast, 138 | onPrimaryContainer = onPrimaryContainerLightHighContrast, 139 | secondary = secondaryLightHighContrast, 140 | onSecondary = onSecondaryLightHighContrast, 141 | secondaryContainer = secondaryContainerLightHighContrast, 142 | onSecondaryContainer = onSecondaryContainerLightHighContrast, 143 | tertiary = tertiaryLightHighContrast, 144 | onTertiary = onTertiaryLightHighContrast, 145 | tertiaryContainer = tertiaryContainerLightHighContrast, 146 | onTertiaryContainer = onTertiaryContainerLightHighContrast, 147 | error = errorLightHighContrast, 148 | onError = onErrorLightHighContrast, 149 | errorContainer = errorContainerLightHighContrast, 150 | onErrorContainer = onErrorContainerLightHighContrast, 151 | background = backgroundLightHighContrast, 152 | onBackground = onBackgroundLightHighContrast, 153 | surface = surfaceLightHighContrast, 154 | onSurface = onSurfaceLightHighContrast, 155 | surfaceVariant = surfaceVariantLightHighContrast, 156 | onSurfaceVariant = onSurfaceVariantLightHighContrast, 157 | outline = outlineLightHighContrast, 158 | outlineVariant = outlineVariantLightHighContrast, 159 | scrim = scrimLightHighContrast, 160 | inverseSurface = inverseSurfaceLightHighContrast, 161 | inverseOnSurface = inverseOnSurfaceLightHighContrast, 162 | inversePrimary = inversePrimaryLightHighContrast, 163 | // surfaceDim = surfaceDimLightHighContrast, 164 | // surfaceBright = surfaceBrightLightHighContrast, 165 | // surfaceContainerLowest = surfaceContainerLowestLightHighContrast, 166 | // surfaceContainerLow = surfaceContainerLowLightHighContrast, 167 | // surfaceContainer = surfaceContainerLightHighContrast, 168 | // surfaceContainerHigh = surfaceContainerHighLightHighContrast, 169 | // surfaceContainerHighest = surfaceContainerHighestLightHighContrast, 170 | ) 171 | 172 | private val mediumContrastDarkColorScheme = darkColorScheme( 173 | primary = primaryDarkMediumContrast, 174 | onPrimary = onPrimaryDarkMediumContrast, 175 | primaryContainer = primaryContainerDarkMediumContrast, 176 | onPrimaryContainer = onPrimaryContainerDarkMediumContrast, 177 | secondary = secondaryDarkMediumContrast, 178 | onSecondary = onSecondaryDarkMediumContrast, 179 | secondaryContainer = secondaryContainerDarkMediumContrast, 180 | onSecondaryContainer = onSecondaryContainerDarkMediumContrast, 181 | tertiary = tertiaryDarkMediumContrast, 182 | onTertiary = onTertiaryDarkMediumContrast, 183 | tertiaryContainer = tertiaryContainerDarkMediumContrast, 184 | onTertiaryContainer = onTertiaryContainerDarkMediumContrast, 185 | error = errorDarkMediumContrast, 186 | onError = onErrorDarkMediumContrast, 187 | errorContainer = errorContainerDarkMediumContrast, 188 | onErrorContainer = onErrorContainerDarkMediumContrast, 189 | background = backgroundDarkMediumContrast, 190 | onBackground = onBackgroundDarkMediumContrast, 191 | surface = surfaceDarkMediumContrast, 192 | onSurface = onSurfaceDarkMediumContrast, 193 | surfaceVariant = surfaceVariantDarkMediumContrast, 194 | onSurfaceVariant = onSurfaceVariantDarkMediumContrast, 195 | outline = outlineDarkMediumContrast, 196 | outlineVariant = outlineVariantDarkMediumContrast, 197 | scrim = scrimDarkMediumContrast, 198 | inverseSurface = inverseSurfaceDarkMediumContrast, 199 | inverseOnSurface = inverseOnSurfaceDarkMediumContrast, 200 | inversePrimary = inversePrimaryDarkMediumContrast, 201 | // surfaceDim = surfaceDimDarkMediumContrast, 202 | // surfaceBright = surfaceBrightDarkMediumContrast, 203 | // surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, 204 | // surfaceContainerLow = surfaceContainerLowDarkMediumContrast, 205 | // surfaceContainer = surfaceContainerDarkMediumContrast, 206 | // surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, 207 | // surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, 208 | ) 209 | 210 | private val highContrastDarkColorScheme = darkColorScheme( 211 | primary = primaryDarkHighContrast, 212 | onPrimary = onPrimaryDarkHighContrast, 213 | primaryContainer = primaryContainerDarkHighContrast, 214 | onPrimaryContainer = onPrimaryContainerDarkHighContrast, 215 | secondary = secondaryDarkHighContrast, 216 | onSecondary = onSecondaryDarkHighContrast, 217 | secondaryContainer = secondaryContainerDarkHighContrast, 218 | onSecondaryContainer = onSecondaryContainerDarkHighContrast, 219 | tertiary = tertiaryDarkHighContrast, 220 | onTertiary = onTertiaryDarkHighContrast, 221 | tertiaryContainer = tertiaryContainerDarkHighContrast, 222 | onTertiaryContainer = onTertiaryContainerDarkHighContrast, 223 | error = errorDarkHighContrast, 224 | onError = onErrorDarkHighContrast, 225 | errorContainer = errorContainerDarkHighContrast, 226 | onErrorContainer = onErrorContainerDarkHighContrast, 227 | background = backgroundDarkHighContrast, 228 | onBackground = onBackgroundDarkHighContrast, 229 | surface = surfaceDarkHighContrast, 230 | onSurface = onSurfaceDarkHighContrast, 231 | surfaceVariant = surfaceVariantDarkHighContrast, 232 | onSurfaceVariant = onSurfaceVariantDarkHighContrast, 233 | outline = outlineDarkHighContrast, 234 | outlineVariant = outlineVariantDarkHighContrast, 235 | scrim = scrimDarkHighContrast, 236 | inverseSurface = inverseSurfaceDarkHighContrast, 237 | inverseOnSurface = inverseOnSurfaceDarkHighContrast, 238 | inversePrimary = inversePrimaryDarkHighContrast, 239 | // surfaceDim = surfaceDimDarkHighContrast, 240 | // surfaceBright = surfaceBrightDarkHighContrast, 241 | // surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, 242 | // surfaceContainerLow = surfaceContainerLowDarkHighContrast, 243 | // surfaceContainer = surfaceContainerDarkHighContrast, 244 | // surfaceContainerHigh = surfaceContainerHighDarkHighContrast, 245 | // surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, 246 | ) 247 | 248 | @Immutable 249 | data class ColorFamily( 250 | val color: Color, 251 | val onColor: Color, 252 | val colorContainer: Color, 253 | val onColorContainer: Color 254 | ) 255 | 256 | val unspecified_scheme = ColorFamily( 257 | Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified 258 | ) 259 | 260 | @Composable 261 | fun MessageForwardTheme( 262 | darkTheme: Boolean = isSystemInDarkTheme(), 263 | // Dynamic color is available on Android 12+ 264 | dynamicColor: Boolean = false, 265 | content: @Composable () -> Unit 266 | ) { 267 | val colorScheme = when { 268 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 269 | val context = LocalContext.current 270 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 271 | } 272 | 273 | darkTheme -> darkScheme 274 | else -> lightScheme 275 | } 276 | val view = LocalView.current 277 | if (!view.isInEditMode) { 278 | SideEffect { 279 | val window = (view.context as Activity).window 280 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme 281 | } 282 | } 283 | 284 | MaterialTheme( 285 | colorScheme = colorScheme, 286 | typography = AppTypography, 287 | content = content 288 | ) 289 | } -------------------------------------------------------------------------------- /app/src/main/java/cn/quickweather/messageforward/setting/SettingScreen.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) 2 | 3 | package cn.quickweather.messageforward.setting 4 | 5 | import android.Manifest 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.layout.Box 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.PaddingValues 12 | import androidx.compose.foundation.layout.Row 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.foundation.layout.fillMaxWidth 15 | import androidx.compose.foundation.layout.padding 16 | import androidx.compose.foundation.layout.size 17 | import androidx.compose.foundation.layout.wrapContentHeight 18 | import androidx.compose.foundation.lazy.LazyColumn 19 | import androidx.compose.foundation.lazy.LazyListScope 20 | import androidx.compose.foundation.shape.RoundedCornerShape 21 | import androidx.compose.material3.Card 22 | import androidx.compose.material3.CenterAlignedTopAppBar 23 | import androidx.compose.material3.ExperimentalMaterial3Api 24 | import androidx.compose.material3.HorizontalDivider 25 | import androidx.compose.material3.Icon 26 | import androidx.compose.material3.MaterialTheme 27 | import androidx.compose.material3.OutlinedTextField 28 | import androidx.compose.material3.Scaffold 29 | import androidx.compose.material3.Switch 30 | import androidx.compose.material3.Text 31 | import androidx.compose.material3.TextButton 32 | import androidx.compose.runtime.Composable 33 | import androidx.compose.runtime.LaunchedEffect 34 | import androidx.compose.runtime.getValue 35 | import androidx.compose.runtime.mutableStateOf 36 | import androidx.compose.runtime.remember 37 | import androidx.compose.runtime.setValue 38 | import androidx.compose.ui.Alignment 39 | import androidx.compose.ui.Modifier 40 | import androidx.compose.ui.graphics.Color 41 | import androidx.compose.ui.platform.LocalContext 42 | import androidx.compose.ui.res.painterResource 43 | import androidx.compose.ui.res.stringResource 44 | import androidx.compose.ui.text.style.TextOverflow 45 | import androidx.compose.ui.tooling.preview.Preview 46 | import androidx.compose.ui.unit.Dp 47 | import androidx.compose.ui.unit.dp 48 | import androidx.compose.ui.window.Dialog 49 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 50 | import cn.quickweather.android.common.util.showShortToast 51 | import cn.quickweather.messageforward.R 52 | import cn.quickweather.messageforward.history.HistoryData 53 | import cn.quickweather.messageforward.sms.ForwardStatus 54 | import cn.quickweather.messageforward.sms.MessageData 55 | import cn.quickweather.messageforward.ui.theme.ContentCard 56 | import cn.quickweather.messageforward.ui.theme.ErrorCard 57 | import cn.quickweather.messageforward.ui.theme.MessageForwardTheme 58 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 59 | import com.google.accompanist.permissions.MultiplePermissionsState 60 | import com.google.accompanist.permissions.PermissionState 61 | import com.google.accompanist.permissions.PermissionStatus 62 | import com.google.accompanist.permissions.rememberMultiplePermissionsState 63 | import com.google.accompanist.permissions.rememberPermissionState 64 | import org.koin.androidx.compose.koinViewModel 65 | import java.text.SimpleDateFormat 66 | import java.util.Date 67 | import java.util.Locale 68 | 69 | /** 70 | * Created by maweihao on 5/20/24 71 | */ 72 | @OptIn(ExperimentalPermissionsApi::class) 73 | @Composable 74 | fun SettingScreen( 75 | modifier: Modifier = Modifier, 76 | ) { 77 | val viewModel: SettingViewModel = koinViewModel() 78 | Scaffold( 79 | topBar = { 80 | CenterAlignedTopAppBar( 81 | title = { 82 | Row( 83 | verticalAlignment = Alignment.CenterVertically 84 | ) { 85 | Icon( 86 | painter = painterResource(id = R.drawable.ic_forward_to_inbox), 87 | contentDescription = null, 88 | tint = MaterialTheme.colorScheme.onSurface, 89 | modifier = Modifier.padding(end = 16.dp) 90 | ) 91 | Text(text = stringResource(id = R.string.display_app_name), color = MaterialTheme.colorScheme.onSurface) 92 | } 93 | } 94 | ) 95 | }, 96 | modifier = modifier.fillMaxSize(), 97 | ) { padding -> 98 | Box(modifier = Modifier.padding(top = padding.calculateTopPadding())) { 99 | val shownSettingData = viewModel.shownSettingDataFlow.collectAsStateWithLifecycle().value 100 | val smsPermissionState = rememberMultiplePermissionsState( 101 | listOf( 102 | Manifest.permission.SEND_SMS, 103 | Manifest.permission.RECEIVE_SMS, 104 | Manifest.permission.READ_SMS, 105 | ) 106 | ) 107 | LaunchedEffect(smsPermissionState.allPermissionsGranted) { 108 | viewModel.refreshSmsPermissionState(smsPermissionState.allPermissionsGranted) 109 | } 110 | val notificationPermissionState = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { 111 | rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) 112 | } else { 113 | GrantedPermissionState 114 | } 115 | LaunchedEffect(notificationPermissionState.status) { 116 | viewModel.refreshNotificationPermissionState(notificationPermissionState.status == PermissionStatus.Granted) 117 | } 118 | val context = LocalContext.current 119 | SettingContent( 120 | shownSettingData = shownSettingData, 121 | history = shownSettingData.history, 122 | onSwitchChanged = { on -> 123 | if (on) { 124 | requestPermission(smsPermissionState, notificationPermissionState) 125 | viewModel.changeSetting(context, true) 126 | } else { 127 | viewModel.changeSetting(context, false) 128 | } 129 | }, 130 | onPhoneNumberChanged = { 131 | viewModel.changePhoneNumber(it) 132 | }, 133 | onFilterSwitchChanged = { 134 | viewModel.changeOnlyForwardVerificationCode(it) 135 | }, 136 | onBatteryNotificationChanged = { 137 | viewModel.changeBatteryNotification(it) 138 | }, 139 | bottomPadding = padding.calculateBottomPadding(), 140 | ) 141 | if (shownSettingData.showConsentDialog) { 142 | ConsentDialog( 143 | onConfirm = { 144 | viewModel.onAgreeConsent() 145 | }, 146 | onDismiss = { 147 | viewModel.onDisagreeConsent() 148 | } 149 | ) 150 | } 151 | } 152 | } 153 | } 154 | 155 | @Composable 156 | private fun SettingContent( 157 | shownSettingData: ShownSettingData, 158 | history: List, 159 | onSwitchChanged: (Boolean) -> Unit, 160 | onPhoneNumberChanged: (String?) -> Unit, 161 | onFilterSwitchChanged: (Boolean) -> Unit, 162 | onBatteryNotificationChanged: (Boolean) -> Unit, 163 | bottomPadding: Dp, 164 | modifier: Modifier = Modifier, 165 | ) { 166 | val settingData = shownSettingData.settingData 167 | val shownError = shownSettingData.shownError 168 | LazyColumn( 169 | contentPadding = PaddingValues(bottom = bottomPadding), 170 | modifier = modifier 171 | .fillMaxSize() 172 | .padding(top = 16.dp), 173 | ) { 174 | if (shownError != null) { 175 | item { 176 | ErrorCard( 177 | message = stringResource(id = shownError.errString) 178 | ) 179 | } 180 | } 181 | 182 | item { 183 | MainSwitch( 184 | checked = settingData.enabled, 185 | onCheckedChange = onSwitchChanged, 186 | ) 187 | } 188 | 189 | if (settingData.enabled) { 190 | item { 191 | MainSettingItems( 192 | settingData = settingData, 193 | onPhoneNumberChanged = onPhoneNumberChanged, 194 | onFilterSwitchChanged = onFilterSwitchChanged, 195 | onBatteryNotificationChanged = onBatteryNotificationChanged, 196 | ) 197 | } 198 | } 199 | 200 | 201 | if (settingData.enabled) { 202 | forwardHistoryList( 203 | history = history 204 | ) 205 | } 206 | } 207 | 208 | } 209 | 210 | private fun requestPermission( 211 | smsPermissionState: MultiplePermissionsState, 212 | notificationPermissionState: PermissionState 213 | ) { 214 | if (!smsPermissionState.allPermissionsGranted) { 215 | smsPermissionState.launchMultiplePermissionRequest() 216 | } 217 | if (notificationPermissionState.status != PermissionStatus.Granted) { 218 | notificationPermissionState.launchPermissionRequest() 219 | } 220 | } 221 | 222 | @Composable 223 | private fun MainSwitch( 224 | checked: Boolean, 225 | onCheckedChange: (Boolean) -> Unit, 226 | ) { 227 | ContentCard( 228 | outerPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), 229 | ) { 230 | Row( 231 | verticalAlignment = Alignment.CenterVertically, 232 | ) { 233 | Column( 234 | Modifier 235 | .weight(1f) 236 | .padding(end = 4.dp, top = 8.dp, bottom = 8.dp)) { 237 | Text( 238 | text = stringResource(id = R.string.title_enable_forward), 239 | style = MaterialTheme.typography.titleLarge, 240 | ) 241 | Text( 242 | text = stringResource(id = R.string.description_enable_forward), 243 | style = MaterialTheme.typography.bodySmall, 244 | modifier = Modifier.padding(top = 4.dp), 245 | ) 246 | } 247 | Switch( 248 | checked = checked, 249 | onCheckedChange = onCheckedChange, 250 | ) 251 | } 252 | } 253 | } 254 | 255 | @Composable 256 | private fun MainSettingItems( 257 | settingData: SettingData, 258 | onPhoneNumberChanged: (String?) -> Unit, 259 | onFilterSwitchChanged: (Boolean) -> Unit, 260 | onBatteryNotificationChanged: (Boolean) -> Unit, 261 | ) { 262 | ContentCard( 263 | outerPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), 264 | ) { 265 | Text( 266 | text = stringResource( 267 | id = R.string.title_setting 268 | ), 269 | style = MaterialTheme.typography.titleLarge, 270 | ) 271 | ForwardToNumberContent( 272 | number = settingData.smsToNumber, 273 | onPhoneNumberChanged = onPhoneNumberChanged, 274 | modifier = Modifier.padding(vertical = 8.dp) 275 | ) 276 | BatteryNotificationContent( 277 | checked = settingData.sendBatteryNotification, 278 | onCheckedChange = onBatteryNotificationChanged, 279 | modifier = Modifier.padding(vertical = 8.dp), 280 | ) 281 | OnlyForwardPriorityContent( 282 | checked = settingData.onlyVerificationCode, 283 | onCheckedChange = onFilterSwitchChanged, 284 | modifier = Modifier.padding(vertical = 8.dp), 285 | ) 286 | } 287 | } 288 | 289 | @Composable 290 | private fun ForwardToNumberContent( 291 | number: String?, 292 | onPhoneNumberChanged: (String?) -> Unit, 293 | modifier: Modifier = Modifier, 294 | ) { 295 | var showDialog by remember { 296 | mutableStateOf(false) 297 | } 298 | val textColor = if (number.isNullOrBlank() || number.phoneNumberValid) { 299 | MaterialTheme.colorScheme.tertiary 300 | } else { 301 | MaterialTheme.colorScheme.error 302 | } 303 | Row( 304 | modifier = modifier 305 | .clickable { 306 | showDialog = true 307 | } 308 | .padding(top = 4.dp, bottom = 4.dp), 309 | verticalAlignment = Alignment.CenterVertically, 310 | ) { 311 | Icon( 312 | painter = painterResource(id = R.drawable.ic_arrow_outward), 313 | modifier = Modifier 314 | .size(32.dp) 315 | .padding(end = 8.dp), 316 | tint = MaterialTheme.colorScheme.primary, 317 | contentDescription = null, 318 | ) 319 | Text( 320 | text = stringResource(id = R.string.title_forward_to_number), 321 | style = MaterialTheme.typography.titleMedium, 322 | modifier = Modifier.weight(1f), 323 | ) 324 | Text( 325 | text = if (number.isNullOrBlank()) { 326 | stringResource(id = R.string.title_forward_unset) 327 | } else { 328 | number 329 | }, 330 | style = MaterialTheme.typography.bodyMedium.copy(color = textColor), 331 | ) 332 | } 333 | if (showDialog) { 334 | NumberInputDialog( 335 | number = number, 336 | onPhoneNumberChanged = onPhoneNumberChanged, 337 | dismissDialog = { 338 | showDialog = false 339 | } 340 | ) 341 | } 342 | } 343 | 344 | @Composable 345 | private fun MarkAsReadContent( 346 | checked: Boolean, 347 | onCheckedChange: (Boolean) -> Unit, 348 | modifier: Modifier = Modifier, 349 | ) { 350 | Row( 351 | modifier = modifier.wrapContentHeight(), 352 | verticalAlignment = Alignment.CenterVertically, 353 | ) { 354 | Row( 355 | modifier = Modifier 356 | .weight(1f) 357 | .wrapContentHeight() 358 | .padding(end = 4.dp, top = 8.dp), 359 | verticalAlignment = Alignment.CenterVertically, 360 | ) { 361 | Icon( 362 | painter = painterResource(id = R.drawable.baseline_mark_email_read_24), 363 | modifier = Modifier 364 | .size(32.dp) 365 | .padding(end = 8.dp), 366 | tint = MaterialTheme.colorScheme.primary, 367 | contentDescription = null, 368 | ) 369 | Text( 370 | text = stringResource(id = R.string.title_mark_as_read_title), 371 | style = MaterialTheme.typography.titleMedium, 372 | ) 373 | } 374 | Switch( 375 | checked = checked, 376 | onCheckedChange = onCheckedChange, 377 | ) 378 | } 379 | } 380 | 381 | @Composable 382 | private fun OnlyForwardPriorityContent( 383 | checked: Boolean, 384 | onCheckedChange: (Boolean) -> Unit, 385 | modifier: Modifier = Modifier, 386 | ) { 387 | Row( 388 | modifier = modifier.wrapContentHeight(), 389 | verticalAlignment = Alignment.CenterVertically, 390 | ) { 391 | Column( 392 | Modifier 393 | .weight(1f) 394 | .wrapContentHeight() 395 | .padding(end = 4.dp, top = 8.dp) 396 | ) { 397 | Row( 398 | verticalAlignment = Alignment.CenterVertically, 399 | ) { 400 | Image( 401 | painter = painterResource(id = R.drawable.ic_intelligence_56), 402 | modifier = Modifier 403 | .size(32.dp) 404 | .padding(end = 8.dp), 405 | contentDescription = null, 406 | ) 407 | Text( 408 | text = stringResource(id = R.string.title_only_forward_priority_messages_title), 409 | style = MaterialTheme.typography.titleMedium, 410 | ) 411 | } 412 | Text( 413 | text = stringResource(id = R.string.title_only_forward_priority_messages_desc), 414 | style = MaterialTheme.typography.bodySmall, 415 | modifier = Modifier.padding(top = 4.dp), 416 | ) 417 | } 418 | Switch( 419 | checked = checked, 420 | onCheckedChange = onCheckedChange, 421 | ) 422 | } 423 | } 424 | 425 | @Composable 426 | private fun BatteryNotificationContent( 427 | checked: Boolean, 428 | onCheckedChange: (Boolean) -> Unit, 429 | modifier: Modifier = Modifier, 430 | ) { 431 | Row( 432 | modifier = modifier.wrapContentHeight(), 433 | verticalAlignment = Alignment.CenterVertically, 434 | ) { 435 | Column( 436 | Modifier 437 | .weight(1f) 438 | .wrapContentHeight() 439 | .padding(end = 4.dp, top = 8.dp) 440 | ) { 441 | Row( 442 | verticalAlignment = Alignment.CenterVertically, 443 | ) { 444 | Icon( 445 | painter = painterResource(id = R.drawable.baseline_battery_1_bar_24), 446 | modifier = Modifier 447 | .size(32.dp) 448 | .padding(end = 8.dp), 449 | tint = MaterialTheme.colorScheme.primary, 450 | contentDescription = null, 451 | ) 452 | Text( 453 | text = stringResource(id = R.string.send_dead_notification_title), 454 | style = MaterialTheme.typography.titleMedium, 455 | ) 456 | } 457 | Text( 458 | text = stringResource(id = R.string.send_dead_notification_desc), 459 | style = MaterialTheme.typography.bodySmall, 460 | modifier = Modifier.padding(top = 4.dp), 461 | ) 462 | } 463 | Switch( 464 | checked = checked, 465 | onCheckedChange = onCheckedChange, 466 | ) 467 | } 468 | } 469 | 470 | private fun LazyListScope.forwardHistoryList( 471 | history: List, 472 | ) { 473 | item { 474 | if (history.isNotEmpty()) { 475 | ContentCard( 476 | bottomCornerSize = 0.dp, 477 | outerPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 4.dp), 478 | ) { 479 | Text( 480 | text = stringResource( 481 | id = R.string.title_forward_history 482 | ), 483 | style = MaterialTheme.typography.titleLarge, 484 | ) 485 | } 486 | } else { 487 | ContentCard( 488 | outerPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), 489 | innerPadding = PaddingValues(vertical = 16.dp, horizontal = 12.dp) 490 | ) { 491 | Text( 492 | text = stringResource(id = R.string.title_no_forward_history), 493 | style = MaterialTheme.typography.titleLarge, 494 | ) 495 | } 496 | } 497 | } 498 | 499 | if (history.isNotEmpty()) { 500 | items(history.size, key = { 501 | history[it].id 502 | }) { index -> 503 | ForwardHistoryItem( 504 | time = history[index].message.receivedTime, 505 | status = ForwardStatus.parse(history[index].status), 506 | from = history[index].message.originatingAddress ?: "", 507 | content = history[index].message.msgBody ?: "", 508 | withBottomDivider = index < history.size - 1, 509 | modifier = Modifier 510 | .padding(horizontal = 16.dp) 511 | .background(MaterialTheme.colorScheme.surfaceContainer) 512 | .padding(8.dp) 513 | ) 514 | } 515 | } 516 | 517 | item { 518 | ContentCard( 519 | topCornerSize = 0.dp, 520 | innerPadding = PaddingValues(bottom = 16.dp), 521 | outerPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 4.dp), 522 | ) { 523 | } 524 | } 525 | } 526 | 527 | @Composable 528 | private fun ForwardHistoryItem( 529 | time: Long, 530 | status: ForwardStatus, 531 | from: String, 532 | content: String, 533 | modifier: Modifier = Modifier, 534 | withBottomDivider: Boolean = true, 535 | ) { 536 | val shownTime = remember(key1 = time) { 537 | val date = Date(time) 538 | val today = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) 539 | val dateStr = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(date) 540 | if (today == dateStr) { 541 | "Today " + SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) 542 | } else { 543 | SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(date) 544 | } 545 | } 546 | Column( 547 | modifier = modifier, 548 | ) { 549 | Row( 550 | verticalAlignment = Alignment.CenterVertically, 551 | ) { 552 | Row( 553 | Modifier.weight(1f), 554 | verticalAlignment = Alignment.CenterVertically, 555 | ) { 556 | Text( 557 | text = from, 558 | style = MaterialTheme.typography.titleMedium, 559 | modifier = Modifier.padding(end = 12.dp, start = 4.dp), 560 | ) 561 | Text( 562 | text = shownTime, 563 | style = MaterialTheme.typography.bodySmall, 564 | ) 565 | } 566 | Icon( 567 | painter = painterResource(id = status.icon), 568 | contentDescription = null, 569 | tint = Color.Unspecified, 570 | modifier = Modifier 571 | .padding(end = 12.dp) 572 | .size(24.dp) 573 | .clickable { 574 | showShortToast(status.label) 575 | }, 576 | ) 577 | } 578 | Text( 579 | text = content, 580 | style = MaterialTheme.typography.bodySmall, 581 | modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp), 582 | maxLines = 2, 583 | overflow = TextOverflow.Ellipsis, 584 | ) 585 | if (withBottomDivider) { 586 | HorizontalDivider( 587 | modifier = Modifier.padding(horizontal = 8.dp), 588 | color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) 589 | ) 590 | } 591 | } 592 | } 593 | 594 | @Composable 595 | private fun NumberInputDialog( 596 | number: String?, 597 | onPhoneNumberChanged: (String?) -> Unit, 598 | dismissDialog: () -> Unit, 599 | ) { 600 | var shownNumber by remember { 601 | mutableStateOf(number ?: "") 602 | } 603 | Dialog( 604 | onDismissRequest = dismissDialog, 605 | ) { 606 | Card( 607 | modifier = Modifier.fillMaxWidth(), 608 | shape = RoundedCornerShape(16.dp), 609 | ) { 610 | Text( 611 | text = stringResource(id = R.string.title_forward_to_number), 612 | style = MaterialTheme.typography.titleLarge, 613 | color = MaterialTheme.colorScheme.onSurface, 614 | modifier = Modifier.padding(16.dp) 615 | ) 616 | 617 | OutlinedTextField( 618 | value = shownNumber, 619 | onValueChange = { 620 | shownNumber = it 621 | }, 622 | modifier = Modifier 623 | .fillMaxWidth() 624 | .padding(horizontal = 16.dp, vertical = 16.dp), 625 | ) 626 | 627 | Box( 628 | modifier = Modifier 629 | .fillMaxWidth() 630 | .padding(horizontal = 16.dp, vertical = 8.dp), 631 | contentAlignment = Alignment.CenterEnd, 632 | ) { 633 | Row { 634 | TextButton( 635 | onClick = dismissDialog, 636 | modifier = Modifier.padding(horizontal = 12.dp) 637 | ) { 638 | Text("Cancel") 639 | } 640 | TextButton( 641 | onClick = { 642 | onPhoneNumberChanged(shownNumber) 643 | dismissDialog() 644 | }, 645 | ) { 646 | Text("Confirm") 647 | } 648 | } 649 | } 650 | } 651 | } 652 | } 653 | 654 | @Composable 655 | private fun ConsentDialog( 656 | onConfirm: () -> Unit, 657 | onDismiss: () -> Unit, 658 | ) { 659 | cn.quickweather.messageforward.ui.theme.Dialog( 660 | title = stringResource(id = R.string.warning_title_only_forward_priority_messages), 661 | content = { 662 | Text( 663 | text = stringResource(id = R.string.warning_content_only_forward_priority_messages), 664 | style = MaterialTheme.typography.bodyMedium, 665 | modifier = Modifier.padding(16.dp) 666 | ) 667 | }, 668 | actions = { 669 | Row { 670 | TextButton( 671 | onClick = onDismiss, 672 | ) { 673 | Text(stringResource(id = R.string.warning_negative_button_only_forward_priority_messages)) 674 | } 675 | TextButton( 676 | onClick = onConfirm, 677 | ) { 678 | Text(stringResource(id = R.string.warning_positive_button_only_forward_priority_messages)) 679 | } 680 | } 681 | }, 682 | dismissDialog = onDismiss, 683 | ) 684 | } 685 | 686 | @Preview(showSystemUi = true) 687 | @Composable 688 | private fun SettingScreenPreview() { 689 | MessageForwardTheme { 690 | Column { 691 | ErrorCard(message = stringResource(id = R.string.phone_number_invalid)) 692 | MainSwitch(true) { 693 | 694 | } 695 | ContentCard { 696 | ForwardToNumberContent( 697 | "15952033659", 698 | {}, 699 | modifier = Modifier.padding(vertical = 8.dp) 700 | ) 701 | MarkAsReadContent(false, {}, modifier = Modifier.padding(vertical = 8.dp)) 702 | BatteryNotificationContent(false, {}, modifier = Modifier.padding(vertical = 8.dp)) 703 | OnlyForwardPriorityContent(false, {}, modifier = Modifier.padding(vertical = 8.dp)) 704 | } 705 | LazyColumn { 706 | forwardHistoryList( 707 | history = previewHistoryList 708 | ) 709 | } 710 | } 711 | } 712 | } 713 | 714 | @Preview 715 | @Composable 716 | private fun NumberInputDialogPreview() { 717 | MessageForwardTheme { 718 | NumberInputDialog("123", {}) { 719 | 720 | } 721 | } 722 | } 723 | 724 | @Preview(showBackground = true) 725 | @Composable 726 | private fun ForwardHistoryItemPreview() { 727 | MessageForwardTheme { 728 | ForwardHistoryItem( 729 | 1632192000000, 730 | ForwardStatus.ForwardSucceed, 731 | "15952032659", 732 | "亲爱的居民朋友:2024年9月21日是我国第24个全民国防教育日,也是上海市第17个全市防空警报试鸣日。您可通过高德、百度地图搜索“民防工程”,查询身边的民防工程;打开微信小程序“民防在我身边”,了解浦东新区范围内的民防教育基地、应急避难场所和民防工程。【浦东新区国动办】", 733 | ) 734 | } 735 | } 736 | 737 | @Preview(showBackground = true) 738 | @Composable 739 | private fun ConsentDialogPreview() { 740 | MessageForwardTheme { 741 | ConsentDialog({}, {}) 742 | } 743 | } 744 | 745 | private val previewHistoryList = listOf( 746 | HistoryData( 747 | MessageData( 748 | "15952033659", 749 | "亲爱的居民朋友:2024年9月21日是我国第24个全民国防教育日,也是上海市第17个全市防空警报试鸣日。您可通过高德、百度地图搜索“民防工程”,查询身边的民防工程;打开微信小程序“民防在我身边”,了解浦东新区范围内的民防教育基地、应急避难场所和民防工程。【浦东新区国动办】", 750 | 1632192000000L, 751 | id = "1", 752 | ), 753 | ForwardStatus.ForwardSucceed.ordinal 754 | ), 755 | // HistoryData( 756 | // MessageData( 757 | // "15952033659", 758 | // "【充值提醒】尊敬的客户,您已成功充值30.00元,查询余额请登录中国电信APP http://a.189.cn/JJLkBW 或关注“吉林电信”微信公众号查询 。邀您领取1-100元随机话费福利,限量福利先到先得,点击 http://a.189.cn/JJLkBW。【好服务 更随心】中国电信", 759 | // 1632192000000L, 760 | // id = "2", 761 | // ), 762 | // ForwardStatus.ForwardFailedDueToSms.ordinal 763 | // ), 764 | ) --------------------------------------------------------------------------------