Ask notifications permission for api 33+

This commit is contained in:
Shinokuni 2024-07-30 15:41:39 +02:00
parent ca9a93d731
commit 9210b8b7fe
11 changed files with 130 additions and 31 deletions

View File

@ -1,6 +1,7 @@
package com.readrops.app
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
@ -50,14 +51,12 @@ val appModule = module {
AccountCredentialsScreenModel(accountType, mode, get())
}
factory { (account: Account) -> NotificationsScreenModel(account, get(), get()) }
factory { (account: Account) -> NotificationsScreenModel(account, get(), get(), get()) }
factory { PreferencesScreenModel(get()) }
single { GetFoldersWithFeeds(get()) }
// repositories
factory<BaseRepository> { (account: Account) ->
when (account.accountType) {
AccountType.LOCAL -> LocalRSSRepository(get(), get(), account)
@ -101,4 +100,6 @@ val appModule = module {
single { DataStorePreferences(get()) }
single { Preferences(get()) }
single { NotificationManagerCompat.from(get()) }
}

View File

@ -1,5 +1,11 @@
package com.readrops.app.notifications
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@ -19,18 +25,25 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.readrops.app.R
import com.readrops.app.more.preferences.PreferencesScreen
import com.readrops.app.more.preferences.components.BasePreference
import com.readrops.app.util.components.AndroidScreen
import com.readrops.app.util.components.ThreeDotsMenu
import com.readrops.app.util.components.dialog.TwoChoicesDialog
@ -41,10 +54,12 @@ import org.koin.core.parameter.parametersOf
class NotificationsScreen(val account: Account) : AndroidScreen() {
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("InlinedApi")
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val screenModel = getScreenModel<NotificationsScreenModel> { parametersOf(account) }
val state by screenModel.state.collectAsStateWithLifecycle()
@ -52,6 +67,17 @@ class NotificationsScreen(val account: Account) : AndroidScreen() {
val topAppBarScrollBehavior =
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val permissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {
screenModel.refreshNotificationManager()
}
LaunchedEffect(permissionState.status) {
if (permissionState.status.isGranted) {
screenModel.refreshNotificationManager()
}
}
if (state.showBackgroundSyncDialog) {
TwoChoicesDialog(
title = stringResource(id = R.string.auto_synchro_disabled),
@ -105,6 +131,29 @@ class NotificationsScreen(val account: Account) : AndroidScreen() {
.padding(paddingValues)
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU
&& !state.areNotificationsEnabled
) {
item {
BasePreference(
title = stringResource(R.string.grant_access_notifications),
subtitle = stringResource(R.string.system_notifications_disabled),
onClick = {
if (!permissionState.status.shouldShowRationale) {
val intent = Intent().apply {
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
}
launcher.launch(intent)
} else {
permissionState.launchPermissionRequest()
}
}
)
}
}
item {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -118,7 +167,7 @@ class NotificationsScreen(val account: Account) : AndroidScreen() {
)
Switch(
checked = state.isNotificationsEnabled,
checked = state.areAccountNotificationsEnabled,
onCheckedChange = {
screenModel.setAccountNotificationsState(it)
@ -147,9 +196,9 @@ class NotificationsScreen(val account: Account) : AndroidScreen() {
iconUrl = feedWithFolder.feed.iconUrl,
folderName = feedWithFolder.folderName,
checked = feedWithFolder.feed.isNotificationEnabled,
enabled = state.isNotificationsEnabled,
enabled = state.areAccountNotificationsEnabled,
onCheckChange = {
if (state.isNotificationsEnabled) {
if (state.areAccountNotificationsEnabled) {
screenModel.setFeedNotificationsState(feedWithFolder.feed.id, it)
}
}

View File

@ -1,5 +1,6 @@
package com.readrops.app.notifications
import androidx.core.app.NotificationManagerCompat
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.app.util.Preferences
@ -15,14 +16,15 @@ class NotificationsScreenModel(
private val account: Account,
private val database: Database,
private val preferences: Preferences,
private val notificationManager: NotificationManagerCompat,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<NotificationsState>(NotificationsState(isNotificationsEnabled = account.isNotificationsEnabled)) {
) : StateScreenModel<NotificationsState>(NotificationsState(areAccountNotificationsEnabled = account.isNotificationsEnabled)) {
init {
screenModelScope.launch(dispatcher) {
database.accountDao().selectAccountNotificationsState(account.id)
.collect { isNotificationsEnabled ->
mutableState.update { it.copy(isNotificationsEnabled = isNotificationsEnabled) }
mutableState.update { it.copy(areAccountNotificationsEnabled = isNotificationsEnabled) }
}
}
@ -69,13 +71,18 @@ class NotificationsScreenModel(
}
}
}
fun refreshNotificationManager() {
mutableState.update { it.copy(areNotificationsEnabled = notificationManager.areNotificationsEnabled()) }
}
}
data class NotificationsState(
val isNotificationsEnabled: Boolean = false,
val areAccountNotificationsEnabled: Boolean = false,
val feedsWithFolder: List<FeedWithFolder> = emptyList(),
val showBackgroundSyncDialog: Boolean = false,
val isBackGroundSyncEnabled: Boolean = false
val isBackGroundSyncEnabled: Boolean = false,
val areNotificationsEnabled: Boolean = false
) {
val allFeedNotificationsEnabled

View File

@ -14,12 +14,12 @@ import org.koin.core.component.inject
class SyncBroadcastReceiver : BroadcastReceiver(), KoinComponent {
private val notificationManager by inject<NotificationManagerCompat>()
private val database by inject<Database>()
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) {
NotificationManagerCompat.from(context)
.cancel(SyncWorker.SYNC_RESULT_NOTIFICATION_ID)
notificationManager.cancel(SyncWorker.SYNC_RESULT_NOTIFICATION_ID)
when (intent.action) {
ACTION_MARK_READ -> {

View File

@ -48,11 +48,9 @@ class SyncWorker(
params: WorkerParameters
) : CoroutineWorker(appContext, params), KoinComponent {
private val notificationManager = NotificationManagerCompat.from(appContext)
private val notificationManager by inject<NotificationManagerCompat>()
private val database by inject<Database>()
// TODO handle notification permission for Android 14+ (or 15?)
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
val isManual = tags.contains(WORK_MANUAL)
@ -106,7 +104,6 @@ class SyncWorker(
}
}
@SuppressLint("MissingPermission")
private suspend fun refreshAccounts(notificationBuilder: Builder): Pair<Result, Map<Account, SyncResult>> {
val sharedPreferences = get<SharedPreferences>()
var workResult = Result.success(workDataOf(END_SYNC_KEY to true))
@ -133,7 +130,10 @@ class SyncWorker(
account.accountName
)
)
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
if (notificationManager.areNotificationsEnabled()) {
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
}
if (account.isLocal) {
val result = refreshLocalAccount(repository, account, notificationBuilder)
@ -209,7 +209,6 @@ class SyncWorker(
return result
}
@SuppressLint("MissingPermission")
private suspend fun fetchFeedColors(
syncResult: SyncResult,
notificationBuilder: Builder
@ -221,7 +220,9 @@ class SyncWorker(
.setStyle(NotificationCompat.BigTextStyle().bigText(feed.name))
.setProgress(feeds.size, index + 1, false)
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
if (notificationManager.areNotificationsEnabled()) {
notificationManager.notify(SYNC_NOTIFICATION_ID, notificationBuilder.build())
}
try {
if (feed.iconUrl != null) {
@ -234,7 +235,6 @@ class SyncWorker(
}
}
@SuppressLint("MissingPermission")
private suspend fun displaySyncResults(syncResults: Map<Account, SyncResult>) {
val notificationContent = SyncAnalyzer(applicationContext, database)
.getNotificationContent(syncResults)
@ -275,7 +275,10 @@ class SyncWorker(
}
notificationContent.largeIcon?.let { notificationBuilder.setLargeIcon(it) }
notificationManager.notify(SYNC_RESULT_NOTIFICATION_ID, notificationBuilder.build())
if (notificationManager.areNotificationsEnabled()) {
notificationManager.notify(SYNC_RESULT_NOTIFICATION_ID, notificationBuilder.build())
}
}
}

View File

@ -127,9 +127,10 @@ class TimelineScreenModel(
screenModelScope.launch(dispatcher) {
combine(
preferences.timelineItemSize.flow,
preferences.scrollRead.flow
) { itemSize, scrollRead -> itemSize to scrollRead }
.collect { (itemSize, scrollRead) ->
preferences.scrollRead.flow,
preferences.displayNotificationsPermission.flow
) { a, b, c -> Triple(a, b, c) }
.collect { (itemSize, scrollRead, notificationPermission) ->
_timelineState.update {
it.copy(
itemSize = when (itemSize) {
@ -137,7 +138,8 @@ class TimelineScreenModel(
"regular" -> TimelineItemSize.REGULAR
else -> TimelineItemSize.LARGE
},
markReadOnScroll = scrollRead
markReadOnScroll = scrollRead,
displayNotificationsPermission = notificationPermission
)
}
}
@ -370,6 +372,12 @@ class TimelineScreenModel(
fun updateLastFirstVisibleItemIndex(index: Int) {
_listIndexState.update { index }
}
fun disableDisplayNotificationsPermission() {
screenModelScope.launch {
preferences.displayNotificationsPermission.write(false)
}
}
}
@Stable
@ -392,7 +400,8 @@ data class TimelineState(
val isAccountLocal: Boolean = false,
val hideReadAllFAB: Boolean = false,
val itemSize: TimelineItemSize = TimelineItemSize.LARGE,
val markReadOnScroll: Boolean = false
val markReadOnScroll: Boolean = false,
val displayNotificationsPermission: Boolean = false
) {
val showSubtitle = filters.subFilter != SubFilter.ALL

View File

@ -1,6 +1,10 @@
package com.readrops.app.timelime
import android.Manifest
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -79,6 +83,7 @@ object TimelineTab : Tab {
title = "Timeline",
)
@SuppressLint("InlinedApi")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
@ -93,8 +98,19 @@ object TimelineTab : Tab {
val pullToRefreshState = rememberPullToRefreshState()
val snackbarHostState = remember { SnackbarHostState() }
val topAppBarState = rememberTopAppBarState()
val topAppBarScrollBehavior =
TopAppBarDefaults.pinnedScrollBehavior(topAppBarState)
val topAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState)
val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) {
screenModel.disableDisplayNotificationsPermission()
}
LaunchedEffect(state.displayNotificationsPermission) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU
&& state.displayNotificationsPermission
) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
LaunchedEffect(state.isRefreshing) {
if (state.isRefreshing) {
@ -382,7 +398,10 @@ object TimelineTab : Tab {
screenModel.updateStarState(itemWithFeed.item)
},
onShare = {
screenModel.shareItem(itemWithFeed.item, context)
screenModel.shareItem(
itemWithFeed.item,
context
)
},
size = state.itemSize
)

View File

@ -60,6 +60,12 @@ class Preferences(
key = stringPreferencesKey("timeline_item_size"),
default = "large"
)
val displayNotificationsPermission = Preference(
dataStore = dataStore,
key = booleanPreferencesKey("display_notification_permission"),
default = true
)
}

View File

@ -190,4 +190,6 @@
<string name="battery_optimization_already_disabled">L\'optimisation de la batterie est déjà optimisée pour cette appli</string>
<string name="opml_export_success">Export OPML réussi</string>
<string name="add_to_favorite">Ajouter aux favoris</string>
<string name="grant_access_notifications">Autoriser l\'accès aux notifications</string>
<string name="system_notifications_disabled">Les notfications système sont désactivées. Cliquez ici pour les activer</string>
</resources>

View File

@ -199,4 +199,6 @@
<string name="battery_optimization_already_disabled">Battery optimization already disabled for this app</string>
<string name="opml_export_success">OPML export success</string>
<string name="add_to_favorite">Add to favorites</string>
<string name="grant_access_notifications">Grant access to Notifications</string>
<string name="system_notifications_disabled">System notifications are currently disabled. Click here to enable them</string>
</resources>

View File

@ -34,6 +34,7 @@ compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview"
# specify material3 version is required for gradle to find the dependency
compose-material3 = { module = "androidx.compose.material3:material3", version = "1.2.1" }
compose-activity = "androidx.activity:activity-compose:1.9.0"
compose-permissions = "com.google.accompanist:accompanist-permissions:0.34.0"
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
@ -106,7 +107,7 @@ expressocore = "androidx.test.espresso:espresso-core:3.6.1"
[bundles]
compose = ["compose-foundation", "compose-runtime", "compose-animation",
"compose-ui", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3", "compose-activity"]
"compose-ui", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3", "compose-activity", "compose-permissions"]
voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-koin", "voyager-transitions"]
lifecycle = ["lifecycle-viewmodel-ktx", "lifecycle-viewmodel-compose", "lifecycle-viewmodel-savedstate",
"lifecyle-runtime-compose"]