diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 75b49dc..dca843a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -17,7 +17,7 @@ android {
defaultConfig {
applicationId = "cc.chenhe.qqnotifyevo"
minSdk = 26
- targetSdk = 31
+ targetSdk = 33
versionCode = vCode
versionName = vName
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a1bbe51..b70f5ad 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/core/InnerNotificationProcessor.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/core/InnerNotificationProcessor.kt
index 6762428..a24c103 100644
--- a/app/src/main/java/cc/chenhe/qqnotifyevo/core/InnerNotificationProcessor.kt
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/core/InnerNotificationProcessor.kt
@@ -1,5 +1,7 @@
package cc.chenhe.qqnotifyevo.core
+import android.Manifest
+import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.service.notification.StatusBarNotification
@@ -9,6 +11,7 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import cc.chenhe.qqnotifyevo.utils.NotifyChannel
import cc.chenhe.qqnotifyevo.utils.Tag
+import cc.chenhe.qqnotifyevo.utils.hasPermission
import kotlinx.coroutines.CoroutineScope
import timber.log.Timber
import java.util.*
@@ -60,8 +63,11 @@ class InnerNotificationProcessor(
}
private fun sendNotification(context: Context, tag: Tag, id: Int, notification: Notification) {
- NotificationManagerCompat.from(context).notify(id, notification)
- addNotifyId(tag, id)
+ @SuppressLint("MissingPermission")
+ if (context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)) {
+ NotificationManagerCompat.from(context).notify(id, notification)
+ addNotifyId(tag, id)
+ }
}
override fun renewQzoneNotification(
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/core/NevoNotificationProcessor.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/core/NevoNotificationProcessor.kt
index 74da3a0..bd4f73a 100644
--- a/app/src/main/java/cc/chenhe/qqnotifyevo/core/NevoNotificationProcessor.kt
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/core/NevoNotificationProcessor.kt
@@ -1,5 +1,7 @@
package cc.chenhe.qqnotifyevo.core
+import android.Manifest
+import android.annotation.SuppressLint
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
@@ -51,7 +53,8 @@ class NevoNotificationProcessor(context: Context, scope: CoroutineScope) :
// 目前关联账号的消息都会合并
return
}
- if (nevoMultiMsgTip(ctx)) {
+ @SuppressLint("MissingPermission")
+ if (nevoMultiMsgTip(ctx) && ctx.hasPermission(Manifest.permission.POST_NOTIFICATIONS)) {
val dontShow = PendingIntent.getBroadcast(
ctx, REQ_MULTI_MSG_DONT_SHOW,
Intent(ctx, StaticReceiver::class.java).also {
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/advanced/AdvancedOptionsScreen.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/advanced/AdvancedOptionsScreen.kt
index d44ad87..6c2ed99 100644
--- a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/advanced/AdvancedOptionsScreen.kt
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/advanced/AdvancedOptionsScreen.kt
@@ -136,7 +136,7 @@ private fun NotificationGroup(
onIntent: (AdvancedOptionsIntent) -> Unit,
) {
PreferenceGroup(groupTitle = stringResource(id = R.string.pref_cate_advanced_notify)) {
- // 显式特别关心前缀
+ // 显示特别关心前缀
PreferenceItem(title = stringResource(id = R.string.pref_advanced_show_special_prefix),
icon = Icons.Rounded.Favorite,
description = stringResource(id = R.string.pref_advanced_show_special_prefix_summary),
@@ -298,7 +298,7 @@ private fun OtherGroup(
}
)
PreferenceDivider()
- // 在最近应用列表显式
+ // 在最近应用列表显示
PreferenceItem(
title = stringResource(id = R.string.pref_show_in_recent),
icon = Icons.Rounded.TableRows,
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/PreferenceComponent.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/PreferenceComponent.kt
index 189851c..f3f2486 100644
--- a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/PreferenceComponent.kt
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/PreferenceComponent.kt
@@ -59,14 +59,16 @@ private fun PreferencePreview() {
}
@Composable
-internal fun PreferenceGroup(groupTitle: String, content: @Composable ColumnScope.() -> Unit) {
+internal fun PreferenceGroup(groupTitle: String?, content: @Composable ColumnScope.() -> Unit) {
Column {
- Text(
- text = groupTitle,
- style = MaterialTheme.typography.titleSmall,
- modifier = Modifier.padding(start = 24.dp, bottom = 8.dp),
- color = MaterialTheme.colorScheme.secondary
- )
+ if (groupTitle != null) {
+ Text(
+ text = groupTitle,
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(start = 24.dp, bottom = 8.dp),
+ color = MaterialTheme.colorScheme.secondary
+ )
+ }
Card(content = content, modifier = Modifier.animateContentSize())
}
}
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/MutablePermissionState.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/MutablePermissionState.kt
new file mode 100644
index 0000000..a8c495e
--- /dev/null
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/MutablePermissionState.kt
@@ -0,0 +1,182 @@
+package cc.chenhe.qqnotifyevo.ui.common.permission
+
+import android.app.Activity
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.SystemClock
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.core.app.ActivityCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import cc.chenhe.qqnotifyevo.utils.getActivity
+
+/**
+ * If the time (ms) between requesting permission and being rejected is less than this threshold,
+ * it may be permanently rejected.
+ */
+private const val ALWAYS_DENY_THRESHOLD = 200
+
+
+/**
+ * Creates a [MutablePermissionState] that is remembered across compositions.
+ *
+ * It's recommended that apps exercise the permissions workflow as described in the
+ * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions).
+ *
+ * @param permission the permission to control and observe.
+ * @param onPermissionResult will be called with whether or not the user granted the permission
+ * after [PermissionState.launchPermissionRequest] is called.
+ * @param onAlwaysDenied will be called if the user denied the permission and
+ * `shouldShowRationale=false` after [PermissionState.launchPermissionRequest] is called.
+ * It doesn't affect the calling of [onPermissionResult].
+ * @param permissionChecker can custom the logic of permission checking.
+ * @param alwaysRefreshPermissionStatus refresh the permission status, even if current status is
+ * [PermissionStatus.Granted]. Normally it is unnecessary because denying a permission triggers a
+ * process restart.
+ */
+@Composable
+internal fun rememberMutablePermissionState(
+ permission: String,
+ onPermissionResult: (Boolean) -> Unit,
+ onAlwaysDenied: () -> Unit,
+ permissionChecker: ((permission: String) -> PermissionStatus)?,
+ alwaysRefreshPermissionStatus: Boolean = false,
+): MutablePermissionState {
+ val ctx = LocalContext.current
+ val inspectMode = LocalInspectionMode.current
+ val permissionState = remember(permission, permissionChecker) {
+ val activity = try {
+ ctx.getActivity()
+ } catch (e: IllegalStateException) {
+ if (inspectMode) {
+ null
+ } else {
+ throw e
+ }
+ }
+ MutablePermissionState(permission, ctx, activity, permissionChecker)
+ }
+
+ // Refresh the permission status when the lifecycle is resumed
+ PermissionLifecycleCheckerEffect(
+ permissionState = permissionState,
+ alwaysRefreshPermissionStatus = alwaysRefreshPermissionStatus
+ )
+
+ val launcher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) {
+ permissionState.refreshPermissionStatus()
+ if (!it && !(permissionState.status as PermissionStatus.Denied).shouldShowRationale
+ && SystemClock.elapsedRealtime() - permissionState.launchTime < ALWAYS_DENY_THRESHOLD
+ ) {
+ onAlwaysDenied()
+ }
+ onPermissionResult(it)
+ }
+ DisposableEffect(permissionState, launcher) {
+ permissionState.launcher = launcher
+ onDispose {
+ permissionState.launcher = null
+ }
+ }
+
+ return permissionState
+}
+
+/**
+ * Effect that updates the `hasPermission` state of a revoked [MutablePermissionState] permission
+ * when the lifecycle gets called with [lifecycleEvent].
+ *
+ * @param alwaysRefreshPermissionStatus refresh the permission status, even if current status is
+ * [PermissionStatus.Granted]. Normally it is unnecessary because denying a permission triggers a
+ * process restart.
+ */
+@Composable
+internal fun PermissionLifecycleCheckerEffect(
+ permissionState: MutablePermissionState,
+ lifecycleEvent: Lifecycle.Event = Lifecycle.Event.ON_RESUME,
+ alwaysRefreshPermissionStatus: Boolean = false,
+) {
+ val observer = remember(permissionState) {
+ LifecycleEventObserver { _, event ->
+ if (event == lifecycleEvent) {
+ // We don't check if the permission was denied as that triggers a process restart.
+ if (alwaysRefreshPermissionStatus || permissionState.status != PermissionStatus.Granted) {
+ permissionState.refreshPermissionStatus()
+ }
+ }
+ }
+ }
+ val lifecycle = LocalLifecycleOwner.current.lifecycle
+ DisposableEffect(key1 = lifecycle, observer) {
+ lifecycle.addObserver(observer)
+ onDispose {
+ lifecycle.removeObserver(observer)
+ }
+ }
+}
+
+/**
+ * A mutable state object that can be used to control and observe permission status changes.
+ *
+ * In most cases, this will be created via [rememberMutablePermissionState].
+ *
+ * @param permission the permission to control and observe.
+ * @param context to check the status of the [permission].
+ * @param activity to check if the user should be presented with a rationale for [permission].
+ * should never be null unless in compose preview.
+ * @param permissionChecker can custom the logic of permission checking.
+ */
+@Stable
+internal class MutablePermissionState(
+ override val permission: String,
+ private val context: Context,
+ private val activity: Activity?,
+ private val permissionChecker: ((permission: String) -> PermissionStatus)?,
+) : PermissionState {
+ override var status: PermissionStatus by mutableStateOf(getPermissionStatus())
+
+ internal var launcher: ActivityResultLauncher? = null
+
+ internal var launchTime: Long = 0
+ override fun launchPermissionRequest() {
+ launchTime = SystemClock.elapsedRealtime()
+ launcher?.launch(permission)
+ ?: throw IllegalStateException("ActivityResultLauncher cannot be null")
+ }
+
+ internal fun refreshPermissionStatus() {
+ status = getPermissionStatus()
+ }
+
+ private fun getPermissionStatus(): PermissionStatus {
+ if (permissionChecker != null) {
+ return permissionChecker.invoke(permission)
+ }
+ val hasPermission =
+ context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
+ return if (hasPermission) {
+ PermissionStatus.Granted
+ } else {
+ if (activity == null) {
+ PermissionStatus.Denied(false)
+ } else {
+ PermissionStatus.Denied(
+ ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/PermissionState.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/PermissionState.kt
new file mode 100644
index 0000000..f40174c
--- /dev/null
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/PermissionState.kt
@@ -0,0 +1,137 @@
+package cc.chenhe.qqnotifyevo.ui.common.permission
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationManagerCompat
+import cc.chenhe.qqnotifyevo.utils.getActivity
+
+/**
+ * Creates a [PermissionState] that is remembered across compositions.
+ *
+ * It's recommended that apps exercise the permissions workflow as described in the
+ * [documentation](https://developer.android.com/training/permissions/requesting#workflow_for_requesting_permissions).
+ *
+ * @param permission the permission to control and observe.
+ * @param onPermissionResult will be called with whether or not the user granted the permission
+ * after [PermissionState.launchPermissionRequest] is called.
+ * @param onAlwaysDenied will be called if the user denied the permission and
+ * `shouldShowRationale=false` after [PermissionState.launchPermissionRequest] is called.
+ * It doesn't affect the calling of [onPermissionResult].
+ * @param permissionChecker can custom the logic of permission checking.
+ */
+@Composable
+fun rememberPermissionState(
+ permission: String,
+ onPermissionResult: (Boolean) -> Unit = {},
+ onAlwaysDenied: () -> Unit = {},
+ permissionChecker: ((permission: String) -> PermissionStatus)? = null
+): PermissionState = rememberMutablePermissionState(
+ permission = permission,
+ onPermissionResult = onPermissionResult,
+ onAlwaysDenied = onAlwaysDenied,
+ permissionChecker = permissionChecker,
+)
+
+/**
+ * Similar to [rememberPermissionState], but supports api level that below 33. If the system doesn't
+ * support [Manifest.permission.POST_NOTIFICATIONS] permission, [NotificationManagerCompat.areNotificationsEnabled]
+ * is used to check the permission status, and [PermissionStatus.Denied.shouldShowRationale] is always `false`.
+ *
+ * **Warning:** do not call [PermissionState.launchPermissionRequest] if api level is lower than 33.
+ * You must implement your own logic instead, typically it should be like:
+ * ```
+ * val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ * putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
+ * }
+ * context.startActivity(intent)
+ * ```
+ * The returned [PermissionState] will be updated automatically, just like the one returned by
+ * [rememberPermissionState], regardless of the api level.
+ */
+@SuppressLint("InlinedApi")
+@Composable
+fun rememberNotificationPermissionState(
+ onPermissionResult: (Boolean) -> Unit = {},
+ onAlwaysDenied: () -> Unit = {},
+): PermissionState {
+ val inspectMode = LocalInspectionMode.current
+ val ctx = LocalContext.current
+ return rememberMutablePermissionState(
+ permission = Manifest.permission.POST_NOTIFICATIONS,
+ onPermissionResult = onPermissionResult,
+ onAlwaysDenied = onAlwaysDenied,
+ alwaysRefreshPermissionStatus = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU,
+ permissionChecker = { p ->
+ val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ctx.checkSelfPermission(p) == PackageManager.PERMISSION_GRANTED
+ } else {
+ NotificationManagerCompat.from(ctx).areNotificationsEnabled()
+ }
+ if (hasPermission) {
+ PermissionStatus.Granted
+ } else {
+ val shouldShowRationale =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ try {
+ ctx.getActivity()
+ } catch (e: IllegalStateException) {
+ if (inspectMode) {
+ null
+ } else {
+ throw e
+ }
+ }?.let { aty ->
+ ActivityCompat.shouldShowRequestPermissionRationale(aty, p)
+ } ?: false
+ } else {
+ false
+ }
+ PermissionStatus.Denied(shouldShowRationale)
+ }
+ },
+ )
+}
+
+@Stable
+interface PermissionState {
+ /**
+ * The permission to control and observe.
+ */
+ val permission: String
+
+
+ /**
+ * [permission]'s status
+ */
+ var status: PermissionStatus
+
+ val isGranted: Boolean get() = this.status == PermissionStatus.Granted
+
+ /**
+ * Request the [permission] to the user.
+ *
+ * This should always be triggered from non-composable scope, for example, from a side-effect
+ * or a non-composable callback. Otherwise, this will result in an IllegalStateException.
+ *
+ * This triggers a system dialog that asks the user to grant or revoke the permission.
+ * Note that this dialog might not appear on the screen if the user doesn't want to be asked
+ * again or has denied the permission multiple times.
+ * This behavior varies depending on the Android level API.
+ */
+ fun launchPermissionRequest()
+}
+
+@Stable
+sealed interface PermissionStatus {
+ data object Granted : PermissionStatus
+ data class Denied(
+ val shouldShowRationale: Boolean
+ ) : PermissionStatus
+}
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/main/MainPreferenceScreen.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/main/MainPreferenceScreen.kt
index e3a17bd..0d604f4 100644
--- a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/main/MainPreferenceScreen.kt
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/main/MainPreferenceScreen.kt
@@ -4,10 +4,12 @@ import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
+import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@@ -41,6 +43,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import cc.chenhe.qqnotifyevo.R
@@ -50,6 +53,7 @@ import cc.chenhe.qqnotifyevo.ui.common.PreferenceGroup
import cc.chenhe.qqnotifyevo.ui.common.PreferenceGroupInterval
import cc.chenhe.qqnotifyevo.ui.common.PreferenceItem
import cc.chenhe.qqnotifyevo.ui.common.SingleSelectionDialog
+import cc.chenhe.qqnotifyevo.ui.common.permission.rememberNotificationPermissionState
import cc.chenhe.qqnotifyevo.ui.theme.AppTheme
import cc.chenhe.qqnotifyevo.utils.GITHUB_URL
import cc.chenhe.qqnotifyevo.utils.IconStyle
@@ -57,6 +61,9 @@ import cc.chenhe.qqnotifyevo.utils.MANUAL_URL
import cc.chenhe.qqnotifyevo.utils.Mode
import cc.chenhe.qqnotifyevo.utils.Mode.*
import cc.chenhe.qqnotifyevo.utils.getVersion
+import timber.log.Timber
+
+private const val TAG = "MainPreferenceScreen"
@Composable
fun MainPreferenceScreen(
@@ -117,18 +124,12 @@ private fun MainPreference(
)
}
- AnimatedVisibility(visible = !uiState.isServiceRunning) {
- Column {
- when (uiState.mode) {
- Nevo -> NevoServiceWarningCard(Modifier.fillMaxWidth(), onIntent)
- Legacy -> LegacyNotificationMonitorServiceWarningCard(
- Modifier.fillMaxWidth(),
- navigateToPermissionScreen,
- )
- }
- PreferenceGroupInterval()
- }
- }
+ WarningCards(
+ isServiceRunning = uiState.isServiceRunning,
+ mode = uiState.mode,
+ onIntent = onIntent,
+ navigateToPermissionScreen = navigateToPermissionScreen,
+ )
BasePreferenceGroup(uiState.mode, onIntent, navigateToPermissionScreen)
PreferenceGroupInterval()
@@ -139,6 +140,64 @@ private fun MainPreference(
}
}
+@Composable
+private fun WarningCards(
+ isServiceRunning: Boolean,
+ mode: Mode,
+ onIntent: (MainPreferenceIntent) -> Unit,
+ navigateToPermissionScreen: () -> Unit,
+) {
+ val ctx = LocalContext.current
+ // 通知权限
+ val notificationPermission = rememberNotificationPermissionState(onAlwaysDenied = {
+ openNotificationSettings(ctx)
+ })
+ val space = PaddingValues(bottom = 12.dp)
+ AnimatedVisibility(visible = !notificationPermission.isGranted) {
+ ErrorCard(
+ modifier = Modifier.padding(space),
+ title = stringResource(id = R.string.permission_notification_card_title),
+ description = stringResource(id = R.string.permission_notification_card_text),
+ button = {
+ TextButton(
+ onClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationPermission.launchPermissionRequest()
+ } else {
+ openNotificationSettings(ctx)
+ }
+ },
+ colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onErrorContainer)
+ ) {
+ Text(text = stringResource(id = R.string.permission_notification_card_allow))
+ }
+ },
+ )
+ }
+
+ // 服务未运行
+ AnimatedVisibility(visible = !isServiceRunning) {
+ when (mode) {
+ Nevo -> NevoServiceWarningCard(Modifier.padding(space), onIntent)
+ Legacy -> LegacyNotificationMonitorServiceWarningCard(
+ Modifier.padding(space),
+ navigateToPermissionScreen,
+ )
+ }
+ }
+}
+
+private fun openNotificationSettings(context: Context) {
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ }
+ try {
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "failed to request notification permission")
+ }
+}
+
@Composable
@Preview
private fun NevoServiceWarningCard(
@@ -147,8 +206,8 @@ private fun NevoServiceWarningCard(
) {
val ctx = LocalContext.current
ErrorCard(
- title = stringResource(id = R.string.warning_nevo_service),
- description = stringResource(id = R.string.warning_nevo_service_summary),
+ title = stringResource(id = R.string.nevo_service_card_title),
+ description = stringResource(id = R.string.nevo_service_card_text),
button = {
TextButton(
onClick = {
@@ -158,7 +217,7 @@ private fun NevoServiceWarningCard(
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onErrorContainer)
) {
- Text(text = stringResource(id = R.string.warning_nevo_service_button))
+ Text(text = stringResource(id = R.string.nevo_service_card_button))
}
},
modifier = modifier,
@@ -172,14 +231,14 @@ private fun LegacyNotificationMonitorServiceWarningCard(
navigateToPermissionScreen: () -> Unit = {},
) {
ErrorCard(
- title = stringResource(id = R.string.warning_monitor_service),
- description = stringResource(id = R.string.warning_monitor_service_summary),
+ title = stringResource(id = R.string.monitor_service_card_title),
+ description = stringResource(id = R.string.monitor_service_card_text),
button = {
TextButton(
onClick = navigateToPermissionScreen,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onErrorContainer)
) {
- Text(text = stringResource(id = R.string.pref_cate_permit))
+ Text(text = stringResource(id = R.string.monitor_service_card_button))
}
},
modifier = modifier,
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionScreen.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionScreen.kt
index 237aeb2..4448c49 100644
--- a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionScreen.kt
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionScreen.kt
@@ -4,10 +4,14 @@ import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
+import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.launch
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -17,9 +21,11 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NavigateBefore
import androidx.compose.material.icons.rounded.AccessibilityNew
import androidx.compose.material.icons.rounded.BatterySaver
-import androidx.compose.material.icons.rounded.CircleNotifications
+import androidx.compose.material.icons.rounded.CheckCircleOutline
import androidx.compose.material.icons.rounded.ErrorOutline
+import androidx.compose.material.icons.rounded.MarkChatRead
import androidx.compose.material.icons.rounded.MotionPhotosPaused
+import androidx.compose.material.icons.rounded.NotificationsActive
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
@@ -53,8 +59,12 @@ import cc.chenhe.qqnotifyevo.ui.common.PreferenceDivider
import cc.chenhe.qqnotifyevo.ui.common.PreferenceGroup
import cc.chenhe.qqnotifyevo.ui.common.PreferenceGroupInterval
import cc.chenhe.qqnotifyevo.ui.common.PreferenceItem
+import cc.chenhe.qqnotifyevo.ui.common.permission.rememberNotificationPermissionState
import cc.chenhe.qqnotifyevo.ui.theme.AppTheme
import cc.chenhe.qqnotifyevo.utils.Mode
+import timber.log.Timber
+
+private const val TAG = "PermissionScreen"
@Composable
fun PermissionScreen(navigateUp: () -> Unit, model: PermissionViewModel = viewModel()) {
@@ -95,6 +105,8 @@ private fun Permission(uiState: PermissionUiState, navigateUp: () -> Unit = {})
.clip(CardDefaults.shape)
.verticalScroll(scrollState)
) {
+ NotificationGroup(uiState.mode)
+ PreferenceGroupInterval()
if (uiState.mode == Mode.Legacy) {
LegacyModeGroup(uiState.notificationAccess, uiState.accessibility)
PreferenceGroupInterval()
@@ -104,13 +116,80 @@ private fun Permission(uiState: PermissionUiState, navigateUp: () -> Unit = {})
}
}
+@Composable
+private fun NotificationGroup(mode: Mode) {
+ var showEnableNotificationGuideDialog by remember { mutableStateOf(false) }
+ if (showEnableNotificationGuideDialog) {
+ EnableNotificationGuideDialog { showEnableNotificationGuideDialog = false }
+ }
+
+ PreferenceGroup(groupTitle = null) {
+ // 通知权限
+ val notificationPermissionState = rememberNotificationPermissionState(
+ onAlwaysDenied = {
+ showEnableNotificationGuideDialog = true
+ }
+ )
+ PreferenceItem(
+ title = stringResource(id = R.string.pref_send_notification_permission),
+ icon = Icons.Rounded.NotificationsActive,
+ description = if (notificationPermissionState.isGranted) {
+ stringResource(id = R.string.pref_send_notification_permission_allow)
+ } else {
+ when (mode) {
+ Mode.Nevo -> stringResource(R.string.pref_send_notification_permission_deny)
+ Mode.Legacy -> stringResource(R.string.pref_send_notification_permission_deny_legacy)
+ }
+ },
+ enabled = notificationPermissionState.isGranted.not(),
+ onClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationPermissionState.launchPermissionRequest()
+ } else {
+ showEnableNotificationGuideDialog = true
+ }
+ },
+ button = {
+ StatusIcon(notificationPermissionState.isGranted)
+ },
+ )
+ }
+}
+
+@Composable
+@Preview
+private fun EnableNotificationGuideDialog(dismiss: () -> Unit = {}) {
+ val ctx = LocalContext.current
+ AlertDialog(
+ icon = { Icon(Icons.Rounded.NotificationsActive, contentDescription = null) },
+ title = { Text(text = stringResource(id = R.string.enable_notification_guide_dialog_title)) },
+ text = { Text(text = stringResource(id = R.string.enable_notification_guide_dialog_text)) },
+ onDismissRequest = dismiss,
+ confirmButton = {
+ TextButton(onClick = {
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
+ }
+ try {
+ ctx.startActivity(intent)
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "failed to request notification permission")
+ }
+ dismiss()
+ }) {
+ Text(text = stringResource(id = R.string.enable_notification_guide_dialog_confirm))
+ }
+ }
+ )
+}
+
@Composable
private fun LegacyModeGroup(notificationAccess: Boolean?, accessibility: Boolean?) {
val ctx = LocalContext.current
PreferenceGroup(groupTitle = stringResource(id = R.string.pref_cate_legacy_permission)) {
PreferenceItem(
title = stringResource(id = R.string.pref_notf_permit),
- icon = Icons.Rounded.CircleNotifications,
+ icon = Icons.Rounded.MarkChatRead,
description = when (notificationAccess) {
true -> stringResource(id = R.string.pref_notf_permit_allow)
false -> stringResource(id = R.string.pref_notf_permit_deny)
@@ -118,13 +197,7 @@ private fun LegacyModeGroup(notificationAccess: Boolean?, accessibility: Boolean
},
onClick = { ctx.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) },
button = {
- if (notificationAccess == false) {
- Icon(
- Icons.Rounded.ErrorOutline,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
- )
- }
+ StatusIcon(notificationAccess)
},
)
PreferenceDivider()
@@ -138,13 +211,7 @@ private fun LegacyModeGroup(notificationAccess: Boolean?, accessibility: Boolean
},
onClick = { ctx.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) },
button = {
- if (accessibility == false) {
- Icon(
- Icons.Rounded.ErrorOutline,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
- )
- }
+ StatusIcon(accessibility)
}
)
}
@@ -176,13 +243,7 @@ private fun BatteryGroup(ignoreBatteryOptimize: Boolean?, appRestrictionsEnabled
enabled = ignoreBatteryOptimize == false,
onClick = { ignoreBatteryOptimezeLauncher.launch() },
button = {
- if (ignoreBatteryOptimize == false) {
- Icon(
- Icons.Rounded.ErrorOutline,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
- )
- }
+ StatusIcon(ignoreBatteryOptimize)
}
)
PreferenceDivider()
@@ -222,13 +283,7 @@ private fun BatteryGroup(ignoreBatteryOptimize: Boolean?, appRestrictionsEnabled
enabled = appRestrictionsEnabled == true,
onClick = { showDisableAppHibernationDialog = true },
button = {
- if (appRestrictionsEnabled == true) {
- Icon(
- Icons.Rounded.ErrorOutline,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
- )
- }
+ StatusIcon(appRestrictionsEnabled.not())
}
)
PreferenceDivider()
@@ -252,4 +307,19 @@ private fun BatteryGroup(ignoreBatteryOptimize: Boolean?, appRestrictionsEnabled
description = stringResource(id = R.string.pref_auto_start_summary),
onClick = { showAutoRunCheckDialog = true })
}
+}
+
+@Composable
+private fun StatusIcon(ok: Boolean?) {
+ AnimatedVisibility(visible = ok != null, enter = fadeIn(), exit = fadeOut()) {
+ Icon(
+ if (ok == true) Icons.Rounded.CheckCircleOutline else Icons.Rounded.ErrorOutline,
+ contentDescription = null,
+ tint = if (ok == true) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.error
+ },
+ )
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionViewModel.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionViewModel.kt
index 4d77ccb..66731c8 100644
--- a/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionViewModel.kt
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/permission/PermissionViewModel.kt
@@ -45,20 +45,7 @@ class PermissionViewModel(application: Application) :
refreshNotificationAccessState()
refreshAccessibilityState()
refreshIgnoreBatteryOptimizationState()
-
- val unusedAppRestrictionsStatus: Boolean? =
- when (PackageManagerCompat.getUnusedAppRestrictionsStatus(getApplication()).await()) {
- UnusedAppRestrictionsConstants.ERROR,
- UnusedAppRestrictionsConstants.FEATURE_NOT_AVAILABLE -> null
-
- UnusedAppRestrictionsConstants.DISABLED -> false
- UnusedAppRestrictionsConstants.API_30_BACKPORT,
- UnusedAppRestrictionsConstants.API_30,
- UnusedAppRestrictionsConstants.API_31 -> true
-
- else -> null
- }
- _uiState.getAndUpdate { it.copy(unusedAppRestrictionsEnabled = unusedAppRestrictionsStatus) }
+ refreshUnusedAppRestrictionStatus()
}
private fun refreshNotificationAccessState() {
@@ -120,4 +107,21 @@ class PermissionViewModel(application: Application) :
?: state.copy(ignoreBatteryOptimize = ignore)
}
}
+
+ private suspend fun refreshUnusedAppRestrictionStatus() {
+ val unusedAppRestrictionsStatus: Boolean? =
+ when (PackageManagerCompat.getUnusedAppRestrictionsStatus(getApplication()).await()) {
+ UnusedAppRestrictionsConstants.ERROR,
+ UnusedAppRestrictionsConstants.FEATURE_NOT_AVAILABLE -> null
+
+ UnusedAppRestrictionsConstants.DISABLED -> false
+ UnusedAppRestrictionsConstants.API_30_BACKPORT,
+ UnusedAppRestrictionsConstants.API_30,
+ UnusedAppRestrictionsConstants.API_31 -> true
+
+ else -> null
+ }
+ _uiState.getAndUpdate { it.copy(unusedAppRestrictionsEnabled = unusedAppRestrictionsStatus) }
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt
new file mode 100644
index 0000000..5b0beec
--- /dev/null
+++ b/app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt
@@ -0,0 +1,24 @@
+package cc.chenhe.qqnotifyevo.utils
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+
+/**
+ * @throws IllegalStateException can not find [Activity]
+ */
+fun Context.getActivity(): Activity {
+ var current = this
+ while (current is ContextWrapper) {
+ if (current is Activity) {
+ return current
+ }
+ current = current.baseContext
+ }
+ throw IllegalStateException("can not find Activity from current context")
+}
+
+fun Context.hasPermission(permission: String): Boolean =
+ ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5ea9978..b75d7b9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -58,11 +58,20 @@
升级中
正在迁移数据,请勿强行停止。
- Nevo 插件服务未运行
- 请尝试在 Nevo 中重新启用本插件,并确认已允许 Nevo 和本应用自启动/后台运行。
- 转到 Nevo
- 通知监听服务未运行
- 请到「必要权限」里授予通知访问权,并确认已允许本应用自启动/后台运行。
+
+
+
+ 无法发送通知
+ 需要发送通知权限来显示优化后的 QQ 通知,否则无法正常工作。
+ 允许发送通知
+ Nevo 插件服务未运行
+ 请尝试在 Nevo 中重新启用本插件,并确认已允许 Nevo 和本应用自启动/后台运行。若不想安装 Nevo 请切换到传统模式。
+ 转到 Nevo
+ 通知监听服务未运行
+ 传统模式依赖此服务来监听 QQ 的通知并优化它们。请到「必要权限」里授予通知访问权,并确认已允许本应用自启动/后台运行。
+ 查看必要权限
必要权限
- 传统模式
- 通知访问权
- 已启用,正在监听 QQ 通知
- 已禁用,无法监听 QQ 通知
- 无障碍服务
- 已启用
- 已禁用,可能重复显式已读消息
- 持续运行
- 停用电池优化
- 已停用
- 未停用,可能丢失通知
- 停用应用休眠
- 未停用,可能丢失通知
- 已停用
- 关闭应用休眠
- 请在即将打开的窗口中关闭「在应用程序未使用时移除权限」或「休眠未使用的应用」等类似选项。
- 前往设置
- 自启动与后台运行
- 需要手动检查
- 根据设备型号的不同,请前往手机管家、智能管理器等位置允许本应用的自动启动与后台运行。
+
通知
通知设置
@@ -104,28 +93,66 @@
自动
QQ
TIM
+
关于
+ 高级选项
使用手册
模式选择 · 双重通知 · 最佳实践
开放源代码
版本号:%1$s
GitHub 发布页
+
+
+ 发送通知
+ 已授权
+ 未授权,程序异常时可能遗漏 QQ 消息
+ 未授权,无法优化 QQ 通知
+ 允许通知
+ 请在即将打开的窗口中手动授予通知权限。
+ 前往设置
+
+
+ 传统模式
+ 通知访问权
+ 已启用,正在监听 QQ 通知
+ 已禁用,无法监听 QQ 通知
+ 无障碍服务
+ 已启用
+ 已禁用,可能重复显示已读消息
+
+
+ 持续运行
+ 停用电池优化
+ 已停用
+ 未停用,可能丢失通知
+
+ 停用应用休眠
+ 未停用,可能丢失通知
+ 已停用
+ 关闭应用休眠
+ 请在即将打开的窗口中关闭「在应用程序未使用时移除权限」或「休眠未使用的应用」等类似选项。
+ 前往设置
+
+ 自启动与后台运行
+ 需要手动检查
+ 根据设备型号的不同,请前往手机管家、智能管理器等位置允许本应用的自动启动与后台运行。
+
+
- 高级选项
通知
显示特别关心前缀
@@ -145,7 +172,7 @@
7天
重置使用提示
已重置
- 无法显式使用提示
+ 无法显示使用提示
是否允许发送使用提示通知?
启用通知
忽略提示