Skip to content

Commit aab8d6b

Browse files
authored
feat: Warn if permissions are missing (#71)
2 parents 8568c8a + e054b9e commit aab8d6b

14 files changed

Lines changed: 316 additions & 180 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ android {
1919
applicationId = "co.adityarajput.notifilter"
2020
minSdk = 29
2121
targetSdk = 36
22-
versionCode = 23
23-
versionName = "4.6.0"
22+
versionCode = 24
23+
versionName = "4.6.1"
2424

2525
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2626
}

app/src/main/java/co/adityarajput/notifilter/Constants.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package co.adityarajput.notifilter
33
object Constants {
44
const val STATE = "state"
55
const val WIDGET_PREVIEW_SET_AT = "widget_preview_set_at"
6+
const val SHOW_MISSING_PERMISSIONS_DIALOG = "show_missing_permissions_dialog"
67

78
const val SETTINGS = "settings"
89
const val RUN_IN_FOREGROUND = "run_in_foreground"
Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,103 @@
11
package co.adityarajput.notifilter.utils
22

3+
import android.Manifest
4+
import android.annotation.SuppressLint
5+
import android.app.Activity
36
import android.app.NotificationManager
47
import android.content.Context
58
import android.content.Context.NOTIFICATION_SERVICE
69
import android.content.Context.POWER_SERVICE
10+
import android.content.Intent
11+
import android.os.Build
12+
import android.os.Build.VERSION_CODES.TIRAMISU
713
import android.os.PowerManager
814
import android.provider.Settings
15+
import androidx.core.app.ActivityCompat.requestPermissions
16+
import androidx.core.net.toUri
17+
import co.adityarajput.notifilter.data.models.Action
18+
import co.adityarajput.notifilter.data.models.Filter
919

10-
fun Context.hasNotificationListenerPermission() =
11-
Settings.Secure.getString(contentResolver, "enabled_notification_listeners")
12-
?.contains(packageName) ?: false
20+
enum class Permission {
21+
NOTIFICATION_LISTENER,
22+
ACCESSIBILITY_SERVICE,
23+
UNRESTRICTED_BACKGROUND_USAGE,
24+
POST_NOTIFICATIONS,
25+
NOTIFICATION_POLICY,
26+
}
1327

14-
fun Context.hasAccessibilityServicePermission() =
15-
Settings.Secure.getString(contentResolver, "enabled_accessibility_services")
16-
?.contains(packageName) ?: false
28+
fun Context.isGranted(permission: Permission) = when (permission) {
29+
Permission.NOTIFICATION_LISTENER ->
30+
Settings.Secure.getString(contentResolver, "enabled_notification_listeners")
31+
?.contains(packageName) ?: false
1732

18-
fun Context.hasUnrestrictedBackgroundUsagePermission() =
19-
(getSystemService(POWER_SERVICE) as PowerManager)
20-
.isIgnoringBatteryOptimizations(packageName)
33+
Permission.ACCESSIBILITY_SERVICE ->
34+
Settings.Secure.getString(contentResolver, "enabled_accessibility_services")
35+
?.contains(packageName) ?: false
2136

22-
fun Context.hasPostNotificationsPermission() =
23-
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
24-
.areNotificationsEnabled()
37+
Permission.UNRESTRICTED_BACKGROUND_USAGE ->
38+
(getSystemService(POWER_SERVICE) as PowerManager)
39+
.isIgnoringBatteryOptimizations(packageName)
2540

26-
fun Context.hasNotificationPolicyPermission() =
27-
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
28-
.isNotificationPolicyAccessGranted()
41+
Permission.POST_NOTIFICATIONS ->
42+
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
43+
.areNotificationsEnabled()
44+
45+
Permission.NOTIFICATION_POLICY ->
46+
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
47+
.isNotificationPolicyAccessGranted()
48+
}
49+
50+
fun Context.isGranted(permissions: Iterable<Permission>) =
51+
permissions.associateWith(::isGranted).withDefault { false }
52+
53+
@SuppressLint("BatteryLife")
54+
fun Context.request(permission: Permission, remove: Boolean = false) = try {
55+
when (permission) {
56+
Permission.NOTIFICATION_LISTENER ->
57+
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
58+
59+
Permission.ACCESSIBILITY_SERVICE ->
60+
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
61+
62+
Permission.UNRESTRICTED_BACKGROUND_USAGE ->
63+
startActivity(
64+
if (remove)
65+
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
66+
else
67+
Intent(
68+
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
69+
"package:${packageName}".toUri(),
70+
),
71+
)
72+
73+
Permission.POST_NOTIFICATIONS ->
74+
if (Build.VERSION.SDK_INT >= TIRAMISU) {
75+
requestPermissions(
76+
this as Activity,
77+
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
78+
0,
79+
)
80+
} else {
81+
startActivity(
82+
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
83+
.apply { putExtra(Settings.EXTRA_APP_PACKAGE, packageName) },
84+
)
85+
}
86+
87+
Permission.NOTIFICATION_POLICY ->
88+
startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS))
89+
}
90+
} catch (e: Exception) {
91+
Logger.e("Permissions", "Error while requesting $permission", e)
92+
}
93+
94+
fun permissionsRequired(filters: List<Filter>) = buildList {
95+
if (filters.any { it.action is Action.TAP_NOTIFICATION })
96+
add(Permission.ACCESSIBILITY_SERVICE)
97+
98+
if (filters.any { it.action is Action.ALERT })
99+
add(Permission.POST_NOTIFICATIONS)
100+
101+
if (filters.any { it.action is Action.DISTURB })
102+
add(Permission.NOTIFICATION_POLICY)
103+
}

app/src/main/java/co/adityarajput/notifilter/views/Navigator.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import androidx.navigation.NavOptions
77
import androidx.navigation.compose.NavHost
88
import androidx.navigation.compose.composable
99
import androidx.navigation.toRoute
10-
import co.adityarajput.notifilter.utils.hasNotificationListenerPermission
10+
import co.adityarajput.notifilter.utils.Permission
11+
import co.adityarajput.notifilter.utils.isGranted
1112
import co.adityarajput.notifilter.views.screens.*
1213
import kotlinx.serialization.Serializable
1314

1415
@Composable
1516
fun Navigator(controller: NavHostController) {
16-
val hasPermission = remember { controller.context.hasNotificationListenerPermission() }
17+
val hasPermission = remember { controller.context.isGranted(Permission.NOTIFICATION_LISTENER) }
1718

1819
NavHost(
1920
controller,

app/src/main/java/co/adityarajput/notifilter/views/Widget.kt

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package co.adityarajput.notifilter.views
22

33
import android.content.Context
4+
import android.widget.Toast
45
import androidx.compose.runtime.Composable
56
import androidx.compose.runtime.collectAsState
7+
import androidx.compose.runtime.remember
68
import androidx.compose.ui.unit.dp
79
import androidx.compose.ui.unit.sp
810
import androidx.glance.*
@@ -23,6 +25,8 @@ import co.adityarajput.notifilter.data.Cache
2325
import co.adityarajput.notifilter.data.models.App
2426
import co.adityarajput.notifilter.data.models.Notification
2527
import co.adityarajput.notifilter.utils.Logger
28+
import co.adityarajput.notifilter.utils.Permission
29+
import co.adityarajput.notifilter.utils.isGranted
2630
import co.adityarajput.notifilter.utils.toReadableTime
2731

2832
class Widget(val isPreview: Boolean = false) : GlanceAppWidget() {
@@ -47,6 +51,7 @@ class Widget(val isPreview: Boolean = false) : GlanceAppWidget() {
4751
@GlanceComposable
4852
private fun Content(notifications: List<Notification>, allPackages: List<App> = emptyList()) {
4953
val context = LocalContext.current
54+
val canLaunchMainIntents = remember { context.isGranted(Permission.ACCESSIBILITY_SERVICE) }
5055

5156
Scaffold(
5257
GlanceModifier.padding(vertical = 16.dp),
@@ -88,16 +93,24 @@ private fun Content(notifications: List<Notification>, allPackages: List<App> =
8893
.background(GlanceTheme.colors.primaryContainer)
8994
.wrapContentHeight()
9095
.clickable {
91-
try {
92-
if (intents?.main != null) intents.launchMain() else {
93-
context.startActivity(
94-
context.packageManager.getLaunchIntentForPackage(
95-
it.origin,
96-
),
97-
)
96+
if (!canLaunchMainIntents) {
97+
Toast.makeText(
98+
context,
99+
context.getString(R.string.accessibility_service_description),
100+
Toast.LENGTH_SHORT,
101+
).show()
102+
} else {
103+
try {
104+
if (intents?.main != null) intents.launchMain() else {
105+
context.startActivity(
106+
context.packageManager.getLaunchIntentForPackage(
107+
it.origin,
108+
),
109+
)
110+
}
111+
} catch (e: Exception) {
112+
Logger.e("Widget", "Error clicking $it", e)
98113
}
99-
} catch (e: Exception) {
100-
Logger.e("Widget", "Error clicking $it", e)
101114
}
102115
},
103116
) {

app/src/main/java/co/adityarajput/notifilter/views/components/ManageFilterDialog.kt

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package co.adityarajput.notifilter.views.components
22

3-
import androidx.compose.foundation.layout.Row
43
import androidx.compose.material3.*
54
import androidx.compose.runtime.Composable
65
import androidx.compose.ui.graphics.Color
@@ -56,29 +55,27 @@ fun ManageFilterDialog(viewModel: FiltersViewModel) {
5655
)
5756
},
5857
confirmButton = {
59-
Row {
60-
TextButton(
61-
{
62-
when (dialogState) {
63-
FilterDialogState.TOGGLE_HISTORY -> viewModel.toggleHistory()
64-
FilterDialogState.TOGGLE_FILTER -> viewModel.toggleFilter()
65-
FilterDialogState.DELETE -> viewModel.deleteFilter()
66-
}
67-
hideDialog()
58+
TextButton(
59+
{
60+
when (dialogState) {
61+
FilterDialogState.TOGGLE_HISTORY -> viewModel.toggleHistory()
62+
FilterDialogState.TOGGLE_FILTER -> viewModel.toggleFilter()
63+
FilterDialogState.DELETE -> viewModel.deleteFilter()
64+
}
65+
hideDialog()
66+
},
67+
colors = ButtonDefaults.textButtonColors(
68+
contentColor = if (dialogState == FilterDialogState.DELETE) MaterialTheme.colorScheme.tertiary
69+
else Color.Unspecified,
70+
),
71+
) {
72+
Text(
73+
when (dialogState) {
74+
FilterDialogState.TOGGLE_HISTORY -> filter.historyEnabled.getToggleString()
75+
FilterDialogState.TOGGLE_FILTER -> filter.enabled.getToggleString()
76+
FilterDialogState.DELETE -> stringResource(R.string.delete)
6877
},
69-
colors = ButtonDefaults.textButtonColors(
70-
contentColor = if (dialogState == FilterDialogState.DELETE) MaterialTheme.colorScheme.tertiary
71-
else Color.Unspecified,
72-
),
73-
) {
74-
Text(
75-
when (dialogState) {
76-
FilterDialogState.TOGGLE_HISTORY -> filter.historyEnabled.getToggleString()
77-
FilterDialogState.TOGGLE_FILTER -> filter.enabled.getToggleString()
78-
FilterDialogState.DELETE -> stringResource(R.string.delete)
79-
},
80-
)
81-
}
78+
)
8279
}
8380
},
8481
dismissButton = {

app/src/main/java/co/adityarajput/notifilter/views/components/ManageHistoryDialog.kt

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package co.adityarajput.notifilter.views.components
22

3-
import androidx.compose.foundation.layout.Row
43
import androidx.compose.material3.*
54
import androidx.compose.runtime.Composable
65
import androidx.compose.ui.res.stringResource
@@ -17,12 +16,10 @@ fun ManageHistoryDialog(viewModel: NotificationsViewModel) {
1716
title = { Text(stringResource(R.string.clear_history)) },
1817
text = { Text(stringResource(R.string.clear_history_confirmation)) },
1918
confirmButton = {
20-
Row {
21-
TextButton(
22-
{ viewModel.clearHistory(); hideDialog() },
23-
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.tertiary),
24-
) { Text(stringResource(R.string.clear_history)) }
25-
}
19+
TextButton(
20+
{ viewModel.clearHistory(); hideDialog() },
21+
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.tertiary),
22+
) { Text(stringResource(R.string.clear_history)) }
2623
},
2724
dismissButton = {
2825
TextButton(hideDialog) {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package co.adityarajput.notifilter.views.components
2+
3+
import android.os.Handler
4+
import android.os.Looper
5+
import androidx.compose.foundation.layout.Arrangement
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Row
8+
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.selection.toggleable
10+
import androidx.compose.material3.*
11+
import androidx.compose.runtime.*
12+
import androidx.compose.ui.Alignment
13+
import androidx.compose.ui.Modifier
14+
import androidx.compose.ui.platform.LocalContext
15+
import androidx.compose.ui.res.dimensionResource
16+
import androidx.compose.ui.res.stringResource
17+
import androidx.compose.ui.text.font.FontWeight
18+
import co.adityarajput.notifilter.R
19+
import co.adityarajput.notifilter.utils.Permission
20+
import co.adityarajput.notifilter.utils.isGranted
21+
import co.adityarajput.notifilter.utils.request
22+
23+
@Composable
24+
fun MissingPermissionsDialog(
25+
permissions: Set<Permission>,
26+
hideDialog: () -> Unit,
27+
hidePermanently: () -> Unit,
28+
) {
29+
val context = LocalContext.current
30+
val handler = remember { Handler(Looper.getMainLooper()) }
31+
32+
var hasPermissions by remember { mutableStateOf(permissions.associateWith { false }) }
33+
val watcher = object : Runnable {
34+
override fun run() {
35+
hasPermissions = context.isGranted(permissions)
36+
37+
if (!hasPermissions.all { it.value })
38+
handler.postDelayed(this, 500)
39+
}
40+
}
41+
DisposableEffect(Unit) {
42+
handler.post(watcher)
43+
onDispose { handler.removeCallbacksAndMessages(null) }
44+
}
45+
46+
AlertDialog(
47+
hideDialog,
48+
title = { Text(stringResource(R.string.missing_permissions)) },
49+
text = {
50+
Column {
51+
Text(
52+
stringResource(R.string.explain_missing_permissions),
53+
style = MaterialTheme.typography.bodyMedium,
54+
)
55+
hasPermissions.forEach { (permission, granted) ->
56+
Row(
57+
Modifier
58+
.toggleable(granted, !granted) { context.request(permission) }
59+
.padding(vertical = dimensionResource(R.dimen.padding_small)),
60+
Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
61+
Alignment.CenterVertically,
62+
) {
63+
Checkbox(granted, null)
64+
Text(
65+
stringResource(
66+
when (permission) {
67+
Permission.ACCESSIBILITY_SERVICE -> R.string.explain_accessibility_service_permission
68+
Permission.POST_NOTIFICATIONS -> R.string.explain_post_notifications_permission
69+
Permission.NOTIFICATION_POLICY -> R.string.explain_notification_policy_permission
70+
else -> 0
71+
},
72+
),
73+
style = MaterialTheme.typography.labelMedium,
74+
fontWeight = FontWeight.Normal,
75+
)
76+
}
77+
}
78+
}
79+
},
80+
confirmButton = {
81+
if (!hasPermissions.all { it.value }) {
82+
TextButton(
83+
hidePermanently,
84+
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.tertiary),
85+
) { Text(stringResource(R.string.hide_permanently)) }
86+
} else {
87+
TextButton(hideDialog) {
88+
Text(stringResource(R.string.done), fontWeight = FontWeight.Normal)
89+
}
90+
}
91+
},
92+
)
93+
}

0 commit comments

Comments
 (0)