Notify user if permission to schedule exact alarms is missing

This commit is contained in:
cketti 2024-03-20 16:37:15 +01:00
parent 16e98ed745
commit 73ae228f70
11 changed files with 199 additions and 11 deletions

View file

@ -44,9 +44,13 @@ class K9CoreResourceProvider(private val context: Context) : CoreResourceProvide
PushNotificationState.LISTENING -> R.string.push_notification_state_listening
PushNotificationState.WAIT_BACKGROUND_SYNC -> R.string.push_notification_state_wait_background_sync
PushNotificationState.WAIT_NETWORK -> R.string.push_notification_state_wait_network
PushNotificationState.ALARM_PERMISSION_MISSING -> R.string.push_notification_state_alarm_permission_missing
}
return context.getString(resId)
}
override fun pushNotificationInfoText(): String = context.getString(R.string.push_notification_info)
override fun pushNotificationGrantAlarmPermissionText(): String =
context.getString(R.string.push_notification_grant_alarm_permission)
}

View file

@ -32,4 +32,5 @@ interface CoreResourceProvider {
val iconPushNotification: Int
fun pushNotificationText(notificationState: PushNotificationState): String
fun pushNotificationInfoText(): String
fun pushNotificationGrantAlarmPermissionText(): String
}

View file

@ -0,0 +1,50 @@
package com.fsck.k9.controller.push
import android.content.Context
import android.os.Build
import android.provider.Settings
import com.fsck.k9.helper.AlarmManagerCompat
/**
* Checks whether the app can schedule exact alarms.
*/
internal interface AlarmPermissionManager {
/**
* Checks whether the app can schedule exact alarms.
*
* If this method returns `false`, the app has to request the permission to schedule exact alarms. See
* [Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM].
*/
fun canScheduleExactAlarms(): Boolean
/**
* Register a listener to be notified when the app was granted the permission to schedule exact alarms.
*/
fun registerListener(listener: AlarmPermissionListener)
/**
* Unregister the listener registered via [registerListener].
*/
fun unregisterListener()
}
/**
* Factory method to create an Android API-specific instance of [AlarmPermissionManager].
*/
internal fun AlarmPermissionManager(context: Context, alarmManagerCompat: AlarmManagerCompat): AlarmPermissionManager {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
AlarmPermissionManagerApi31(context, alarmManagerCompat)
} else {
AlarmPermissionManagerApi21()
}
}
/**
* Listener that can be notified when the app was granted the permission to schedule exact alarms.
*
* Note: Currently Android stops (and potentially restarts) the app when the permission is revoked. So there's no
* callback mechanism for the permission revocation case.
*/
internal fun interface AlarmPermissionListener {
fun onAlarmPermissionGranted()
}

View file

@ -0,0 +1,14 @@
package com.fsck.k9.controller.push
/**
* On Android versions prior to 12 there's no permission to limit an app's ability to schedule exact alarms.
*/
internal class AlarmPermissionManagerApi21 : AlarmPermissionManager {
override fun canScheduleExactAlarms(): Boolean {
return true
}
override fun registerListener(listener: AlarmPermissionListener) = Unit
override fun unregisterListener() = Unit
}

View file

@ -0,0 +1,59 @@
package com.fsck.k9.controller.push
import android.app.AlarmManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import com.fsck.k9.helper.AlarmManagerCompat
import timber.log.Timber
/**
* Starting with Android 12 we have to check whether the app can schedule exact alarms.
*/
@RequiresApi(Build.VERSION_CODES.S)
internal class AlarmPermissionManagerApi31(
private val context: Context,
private val alarmManagerCompat: AlarmManagerCompat,
) : AlarmPermissionManager {
private var isRegistered = false
private var listener: AlarmPermissionListener? = null
private val intentFilter = IntentFilter().apply {
addAction(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)
}
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val listener = synchronized(this@AlarmPermissionManagerApi31) { listener }
listener?.onAlarmPermissionGranted()
}
}
override fun canScheduleExactAlarms(): Boolean {
return alarmManagerCompat.canScheduleExactAlarms()
}
@Synchronized
override fun registerListener(listener: AlarmPermissionListener) {
if (!isRegistered) {
Timber.v("Registering alarm permission listener")
isRegistered = true
this.listener = listener
ContextCompat.registerReceiver(context, receiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
}
}
@Synchronized
override fun unregisterListener() {
if (isRegistered) {
Timber.v("Unregistering alarm permission listener")
isRegistered = false
listener = null
context.unregisterReceiver(receiver)
}
}
}

View file

@ -22,9 +22,12 @@ internal val controllerPushModule = module {
pushServiceManager = get(),
bootCompleteManager = get(),
autoSyncManager = get(),
alarmPermissionManager = get(),
pushNotificationManager = get(),
connectivityManager = get(),
accountPushControllerFactory = get(),
)
}
single<AlarmPermissionManager> { AlarmPermissionManager(context = get(), alarmManagerCompat = get()) }
}

View file

@ -7,6 +7,7 @@ import com.fsck.k9.network.ConnectivityChangeListener
import com.fsck.k9.network.ConnectivityManager
import com.fsck.k9.notification.PushNotificationManager
import com.fsck.k9.notification.PushNotificationState
import com.fsck.k9.notification.PushNotificationState.ALARM_PERMISSION_MISSING
import com.fsck.k9.notification.PushNotificationState.LISTENING
import com.fsck.k9.notification.PushNotificationState.WAIT_BACKGROUND_SYNC
import com.fsck.k9.notification.PushNotificationState.WAIT_NETWORK
@ -28,6 +29,7 @@ import timber.log.Timber
/**
* Starts and stops [AccountPushController]s as necessary. Manages the Push foreground service.
*/
@Suppress("LongParameterList")
class PushController internal constructor(
private val accountManager: AccountManager,
private val generalSettingsManager: GeneralSettingsManager,
@ -35,6 +37,7 @@ class PushController internal constructor(
private val pushServiceManager: PushServiceManager,
private val bootCompleteManager: BootCompleteManager,
private val autoSyncManager: AutoSyncManager,
private val alarmPermissionManager: AlarmPermissionManager,
private val pushNotificationManager: PushNotificationManager,
private val connectivityManager: ConnectivityManager,
private val accountPushControllerFactory: AccountPushControllerFactory,
@ -50,6 +53,7 @@ class PushController internal constructor(
override fun onConnectivityChanged() = this@PushController.onConnectivityChanged()
override fun onConnectivityLost() = this@PushController.onConnectivityLost()
}
private val alarmPermissionListener = AlarmPermissionListener(::onAlarmPermissionGranted)
/**
* Initialize [PushController].
@ -109,6 +113,10 @@ class PushController internal constructor(
launchUpdatePushers()
}
private fun onAlarmPermissionGranted() {
launchUpdatePushers()
}
private fun onConnectivityChanged() {
coroutineScope.launch(coroutineDispatcher) {
synchronized(lock) {
@ -142,17 +150,24 @@ class PushController internal constructor(
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun updatePushers() {
Timber.v("PushController.updatePushers()")
val generalSettings = generalSettingsManager.getSettings()
val alarmPermissionMissing = !alarmPermissionManager.canScheduleExactAlarms()
val backgroundSyncDisabledViaSystem = autoSyncManager.isAutoSyncDisabled
val backgroundSyncDisabledInApp = generalSettings.backgroundSync == BackgroundSync.NEVER
val networkNotAvailable = !connectivityManager.isNetworkAvailable()
val realPushAccounts = getPushAccounts()
val pushAccounts = if (backgroundSyncDisabledViaSystem || backgroundSyncDisabledInApp || networkNotAvailable) {
val shouldDisablePushAccounts = backgroundSyncDisabledViaSystem ||
backgroundSyncDisabledInApp ||
networkNotAvailable ||
alarmPermissionMissing
val pushAccounts = if (shouldDisablePushAccounts) {
emptyList()
} else {
realPushAccounts
@ -202,6 +217,11 @@ class PushController internal constructor(
startServices()
}
alarmPermissionMissing -> {
setPushNotificationState(ALARM_PERMISSION_MISSING)
startServices()
}
arePushersActive -> {
setPushNotificationState(LISTENING)
startServices()
@ -228,6 +248,7 @@ class PushController internal constructor(
bootCompleteManager.enableReceiver()
registerAutoSyncListener()
registerConnectivityChangeListener()
registerAlarmPermissionListener()
connectivityManager.start()
}
@ -236,6 +257,7 @@ class PushController internal constructor(
bootCompleteManager.disableReceiver()
autoSyncManager.unregisterListener()
unregisterConnectivityChangeListener()
alarmPermissionManager.unregisterListener()
connectivityManager.stop()
}
@ -254,4 +276,10 @@ class PushController internal constructor(
private fun unregisterConnectivityChangeListener() {
connectivityManager.removeListener(connectivityChangeListener)
}
private fun registerAlarmPermissionListener() {
if (!alarmPermissionManager.canScheduleExactAlarms()) {
alarmPermissionManager.registerListener(alarmPermissionListener)
}
}
}

View file

@ -4,6 +4,9 @@ import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.fsck.k9.CoreResourceProvider
@ -49,26 +52,47 @@ internal class PushNotificationManager(
}
private fun createNotification(): Notification {
val intent = Intent(PUSH_INFO_ACTION).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
setPackage(context.packageName)
}
val contentIntent = PendingIntent.getActivity(context, 1, intent, FLAG_IMMUTABLE)
return NotificationCompat.Builder(context, notificationChannelManager.pushChannelId)
.setSmallIcon(resourceProvider.iconPushNotification)
.setContentTitle(resourceProvider.pushNotificationText(notificationState))
.setContentText(resourceProvider.pushNotificationInfoText())
.setContentIntent(contentIntent)
.setContentText(getContentText())
.setContentIntent(getContentIntent())
.setOngoing(true)
.setNotificationSilent()
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
.setLocalOnly(true)
.setShowWhen(false)
.build()
}
private fun getContentIntent(): PendingIntent {
val intent = if (notificationState == PushNotificationState.ALARM_PERMISSION_MISSING) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
error("ACTION_REQUEST_SCHEDULE_EXACT_ALARM is only available on API 31+")
}
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = Uri.parse("package:${context.packageName}")
}
} else {
Intent(PUSH_INFO_ACTION).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
setPackage(context.packageName)
}
}
return PendingIntent.getActivity(context, 1, intent, FLAG_IMMUTABLE)
}
private fun getContentText(): String {
return if (notificationState == PushNotificationState.ALARM_PERMISSION_MISSING) {
resourceProvider.pushNotificationGrantAlarmPermissionText()
} else {
resourceProvider.pushNotificationInfoText()
}
}
}
enum class PushNotificationState {
@ -76,4 +100,5 @@ enum class PushNotificationState {
LISTENING,
WAIT_BACKGROUND_SYNC,
WAIT_NETWORK,
ALARM_PERMISSION_MISSING,
}

View file

@ -39,4 +39,5 @@ class TestCoreResourceProvider : CoreResourceProvider {
}
override fun pushNotificationInfoText(): String = throw UnsupportedOperationException("not implemented")
override fun pushNotificationGrantAlarmPermissionText() = throw UnsupportedOperationException("not implemented")
}

View file

@ -1098,7 +1098,9 @@ You can keep this message and use it as a backup for your secret key. If you wan
<string name="push_notification_state_listening">Waiting for new emails</string>
<string name="push_notification_state_wait_background_sync">Sleeping until background sync is allowed</string>
<string name="push_notification_state_wait_network">Sleeping until network is available</string>
<string name="push_notification_state_alarm_permission_missing">Missing permission to schedule alarms</string>
<string name="push_notification_info">Tap to learn more.</string>
<string name="push_notification_grant_alarm_permission">Tap to grant permission.</string>
<string name="push_info_title">Push Info</string>
<string name="push_info_notification_explanation_text">When using Push, K-9 Mail maintains a connection to the mail server. Android requires displaying an ongoing notification while the app is active in the background. %s</string>

View file

@ -39,4 +39,5 @@ class TestCoreResourceProvider : CoreResourceProvider {
}
override fun pushNotificationInfoText(): String = throw UnsupportedOperationException("not implemented")
override fun pushNotificationGrantAlarmPermissionText() = throw UnsupportedOperationException("not implemented")
}