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 | )
--------------------------------------------------------------------------------