From 580d21fb1395b9e576f55f30975bd9b5aba145ce Mon Sep 17 00:00:00 2001 From: Chenhe Date: Sat, 11 Nov 2023 19:52:14 +0000 Subject: [PATCH 1/3] request post notification runtime permission --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 1 + .../ui/common/PreferenceComponent.kt | 16 +- .../permission/MutablePermissionState.kt | 182 ++++++++++++++++++ .../ui/common/permission/PermissionState.kt | 137 +++++++++++++ .../ui/main/MainPreferenceScreen.kt | 83 ++++++-- .../ui/permission/PermissionScreen.kt | 130 ++++++++++--- .../ui/permission/PermissionViewModel.kt | 32 +-- .../chenhe/qqnotifyevo/utils/ContextUtils.kt | 19 ++ app/src/main/res/values/strings.xml | 15 ++ 10 files changed, 553 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/MutablePermissionState.kt create mode 100644 app/src/main/java/cc/chenhe/qqnotifyevo/ui/common/permission/PermissionState.kt create mode 100644 app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt 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/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..9c87a79 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( 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..02314f2 --- /dev/null +++ b/app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt @@ -0,0 +1,19 @@ +package cc.chenhe.qqnotifyevo.utils + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +/** + * @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") +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ea9978..3d0cee1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,6 +64,14 @@ 通知监听服务未运行 请到「必要权限」里授予通知访问权,并确认已允许本应用自启动/后台运行。 + + + 无法发送通知 + 需要发送通知权限来显示优化后的 QQ 通知,否则无法正常工作。 + 允许发送通知 + @@ -75,6 +83,13 @@ 传统 必要权限 + 发送通知 + 已授权 + 未授权,程序异常时可能遗漏 QQ 消息 + 未授权,无法优化 QQ 通知 + 允许通知 + 请在即将打开的窗口中手动授予通知权限。 + 前往设置 传统模式 通知访问权 已启用,正在监听 QQ 通知 From 3856624915a64ea6ee7951d9af5f9b22fbfdc0cc Mon Sep 17 00:00:00 2001 From: Chenhe Date: Sat, 11 Nov 2023 21:00:41 +0000 Subject: [PATCH 2/3] improve texts --- .../ui/advanced/AdvancedOptionsScreen.kt | 4 +- .../ui/main/MainPreferenceScreen.kt | 12 +-- app/src/main/res/values/strings.xml | 80 +++++++++++-------- 3 files changed, 54 insertions(+), 42 deletions(-) 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/main/MainPreferenceScreen.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/ui/main/MainPreferenceScreen.kt index 9c87a79..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 @@ -206,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 = { @@ -217,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, @@ -231,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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d0cee1..b75d7b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,11 +58,6 @@ 升级中 正在迁移数据,请勿强行停止。 - Nevo 插件服务未运行 - 请尝试在 Nevo 中重新启用本插件,并确认已允许 Nevo 和本应用自启动/后台运行。 - 转到 Nevo - 通知监听服务未运行 - 请到「必要权限」里授予通知访问权,并确认已允许本应用自启动/后台运行。 必要权限 + + + 通知 + 通知设置 + 前往系统原生页面设置不同渠道的声音与振动 + 前往 Nevo 页面设置不同渠道的声音与振动(内源模式自行前往 QQ 页面设置)\n由于 Nevo 限制需要触发一次通知对应渠道才会显示。 + 图标样式 + 自动 + QQ + TIM + + + 关于 + 高级选项 + 使用手册 + 模式选择 · 双重通知 · 最佳实践 + 开放源代码 + 版本号:%1$s + + + + GitHub 发布页 + + + 发送通知 已授权 未授权,程序异常时可能遗漏 QQ 消息 @@ -90,57 +121,38 @@ 允许通知 请在即将打开的窗口中手动授予通知权限。 前往设置 + + 传统模式 通知访问权 已启用,正在监听 QQ 通知 已禁用,无法监听 QQ 通知 无障碍服务 已启用 - 已禁用,可能重复显式已读消息 + 已禁用,可能重复显示已读消息 + + 持续运行 停用电池优化 已停用 未停用,可能丢失通知 + 停用应用休眠 未停用,可能丢失通知 已停用 关闭应用休眠 请在即将打开的窗口中关闭「在应用程序未使用时移除权限」或「休眠未使用的应用」等类似选项。 前往设置 + 自启动与后台运行 需要手动检查 根据设备型号的不同,请前往手机管家、智能管理器等位置允许本应用的自动启动与后台运行。 - - 通知 - 通知设置 - 前往系统原生页面设置不同渠道的声音与振动 - 前往 Nevo 页面设置不同渠道的声音与振动(内源模式自行前往 QQ 页面设置)\n由于 Nevo 限制需要触发一次通知对应渠道才会显示。 - 图标样式 - 自动 - QQ - TIM - - 关于 - 使用手册 - 模式选择 · 双重通知 · 最佳实践 - 开放源代码 - 版本号:%1$s - - - - GitHub 发布页 + - 高级选项 通知 显示特别关心前缀 @@ -160,7 +172,7 @@ 7天 重置使用提示 已重置 - 无法显式使用提示 + 无法显示使用提示 是否允许发送使用提示通知? 启用通知 忽略提示 From ab6418157d84800e8c275a09668b651ee87ba571 Mon Sep 17 00:00:00 2001 From: Chenhe Date: Sat, 11 Nov 2023 21:20:41 +0000 Subject: [PATCH 3/3] check permission before post notification --- .../qqnotifyevo/core/InnerNotificationProcessor.kt | 10 ++++++++-- .../qqnotifyevo/core/NevoNotificationProcessor.kt | 5 ++++- .../java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt | 5 +++++ 3 files changed, 17 insertions(+), 3 deletions(-) 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/utils/ContextUtils.kt b/app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt index 02314f2..5b0beec 100644 --- a/app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt +++ b/app/src/main/java/cc/chenhe/qqnotifyevo/utils/ContextUtils.kt @@ -3,6 +3,8 @@ 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] @@ -17,3 +19,6 @@ fun Context.getActivity(): Activity { } 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