diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 465c3143b..093b90086 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,8 +30,7 @@ - - + @@ -469,7 +468,14 @@ android:exported="true"> - + + + + + + diff --git a/app/src/main/java/com/todoroo/andlib/utility/AndroidUtilities.kt b/app/src/main/java/com/todoroo/andlib/utility/AndroidUtilities.kt index 1cb8b7bfe..55cf3da5c 100644 --- a/app/src/main/java/com/todoroo/andlib/utility/AndroidUtilities.kt +++ b/app/src/main/java/com/todoroo/andlib/utility/AndroidUtilities.kt @@ -88,6 +88,7 @@ object AndroidUtilities { return Build.VERSION.SDK_INT >= VERSION_CODES.S } + @JvmStatic fun atLeastTiramisu(): Boolean { return Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU } diff --git a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt index 5dae8441c..162eeceb0 100644 --- a/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt +++ b/app/src/main/java/com/todoroo/astrid/activity/TaskListFragment.kt @@ -5,6 +5,7 @@ */ package com.todoroo.astrid.activity +import android.Manifest import android.app.Activity import android.app.Activity.RESULT_OK import android.content.BroadcastReceiver @@ -27,6 +28,7 @@ import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ShareCompat @@ -45,9 +47,14 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale import com.google.android.material.appbar.AppBarLayout import com.google.android.material.bottomappbar.BottomAppBar import com.google.android.material.snackbar.Snackbar +import com.todoroo.andlib.utility.AndroidUtilities import com.todoroo.andlib.utility.DateUtilities import com.todoroo.astrid.adapter.TaskAdapter import com.todoroo.astrid.adapter.TaskAdapterProvider @@ -85,8 +92,10 @@ import org.tasks.billing.PurchaseActivity import org.tasks.caldav.BaseCaldavCalendarSettingsActivity import org.tasks.compose.FilterSelectionActivity.Companion.launch import org.tasks.compose.FilterSelectionActivity.Companion.registerForListPickerResult +import org.tasks.compose.NotificationsDisabledBanner import org.tasks.compose.SubscriptionNagBanner import org.tasks.compose.collectAsStateLifecycleAware +import org.tasks.compose.rememberReminderPermissionState import org.tasks.data.TaskContainer import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.TagDataDao @@ -103,6 +112,9 @@ import org.tasks.dialogs.DateTimePicker.Companion.newDateTimePicker import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.PriorityPicker.Companion.newPriorityPicker import org.tasks.dialogs.SortSettingsActivity +import org.tasks.extensions.Context.canScheduleExactAlarms +import org.tasks.extensions.Context.openAppNotificationSettings +import org.tasks.extensions.Context.openReminderSettings import org.tasks.extensions.Context.openUri import org.tasks.extensions.Context.toast import org.tasks.extensions.Fragment.safeStartActivityForResult @@ -116,6 +128,7 @@ import org.tasks.filters.PlaceFilter import org.tasks.markdown.MarkdownProvider import org.tasks.preferences.Device import org.tasks.preferences.Preferences +import org.tasks.scheduling.NotificationSchedulerIntentService import org.tasks.sync.SyncAdapters import org.tasks.tags.TagPickerActivity import org.tasks.tasklist.DragAndDropRecyclerAdapter @@ -168,7 +181,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL @Inject lateinit var taskEditEventBus: TaskEditEventBus @Inject lateinit var database: Database @Inject lateinit var markdown: MarkdownProvider - + private val listViewModel: TaskListViewModel by viewModels() private val mainViewModel: MainActivityViewModel by activityViewModels() private lateinit var taskAdapter: TaskAdapter @@ -253,7 +266,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL .launchIn(viewLifecycleOwner.lifecycleScope) } - @OptIn(ExperimentalAnimationApi::class) + @OptIn(ExperimentalAnimationApi::class, ExperimentalPermissionsApi::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { requireActivity().onBackPressedDispatcher.addCallback(owner = viewLifecycleOwner) { @@ -289,7 +302,8 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL (recyclerView.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false recyclerView.layoutManager = LinearLayoutManager(context) lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + listViewModel.updateBannerState() listViewModel.state.collect { if (it.tasks is TaskListViewModel.TasksResults.Results) { submitList(it.tasks.tasks) @@ -343,12 +357,41 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL setupMenu(toolbar) binding.banner.setContent { val context = LocalContext.current - val showBanner = listViewModel.state.collectAsStateLifecycleAware().value.begForSubscription + val state = listViewModel.state.collectAsStateLifecycleAware().value TasksTheme { + val hasRemindersPermission by rememberReminderPermissionState() + val notificationPermissions = if (AndroidUtilities.atLeastTiramisu()) { + rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS, + onPermissionResult = { success -> + if (success) { + NotificationSchedulerIntentService.enqueueWork(context) + listViewModel.dismissNotificationBanner(fix = true) + } + } + ) + } else { + null + } + val showNotificationBanner = state.warnNotificationsDisabled && + (!hasRemindersPermission || notificationPermissions?.status is PermissionStatus.Denied) + NotificationsDisabledBanner( + visible = showNotificationBanner, + settings = { + if (!context.canScheduleExactAlarms()) { + context.openReminderSettings() + } else if (notificationPermissions?.status?.shouldShowRationale == true) { + context.openAppNotificationSettings() + } else { + notificationPermissions?.launchPermissionRequest() + } + }, + dismiss = { listViewModel.dismissNotificationBanner() }, + ) SubscriptionNagBanner( - visible = showBanner, + visible = state.begForSubscription && !showNotificationBanner, subscribe = { - listViewModel.dismissBanner(clickedPurchase = true) + listViewModel.dismissPurchaseBanner(clickedPurchase = true) if (Tasks.IS_GOOGLE_PLAY) { context.startActivity(Intent(context, PurchaseActivity::class.java)) } else { @@ -357,7 +400,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL } }, dismiss = { - listViewModel.dismissBanner(clickedPurchase = false) + listViewModel.dismissPurchaseBanner(clickedPurchase = false) }, ) } diff --git a/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt b/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt index bfa003ba2..9bfd01788 100644 --- a/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt @@ -23,11 +23,13 @@ import org.tasks.R import org.tasks.activities.DateAndTimePickerActivity import org.tasks.compose.collectAsStateLifecycleAware import org.tasks.compose.edit.AlarmRow +import org.tasks.compose.rememberReminderPermissionState import org.tasks.data.entity.Alarm import org.tasks.data.entity.Alarm.Companion.TYPE_DATE_TIME import org.tasks.date.DateTimeUtils import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.MyTimePickerDialog +import org.tasks.extensions.Context.openReminderSettings import org.tasks.scheduling.NotificationSchedulerIntentService import org.tasks.themes.TasksTheme import org.tasks.ui.TaskEditControlFragment @@ -78,6 +80,7 @@ class ReminderControlSet : TaskEditControlFragment() { setContent { TasksTheme { val ringMode by remember { this@ReminderControlSet.ringMode } + val hasReminderPermissions by rememberReminderPermissionState() val notificationPermissions = if (AndroidUtilities.atLeastTiramisu()) { rememberPermissionState( Manifest.permission.POST_NOTIFICATIONS, @@ -103,10 +106,14 @@ class ReminderControlSet : TaskEditControlFragment() { AlarmRow( locale = locale, alarms = viewModel.selectedAlarms.collectAsStateLifecycleAware().value, - permissionStatus = notificationPermissions?.status - ?: PermissionStatus.Granted, - launchPermissionRequest = { - notificationPermissions?.launchPermissionRequest() + hasNotificationPermissions = hasReminderPermissions && + (notificationPermissions == null || notificationPermissions.status == PermissionStatus.Granted), + fixNotificationPermissions = { + if (hasReminderPermissions) { + notificationPermissions?.launchPermissionRequest() + } else { + context.openReminderSettings() + } }, ringMode = ringMode, addAlarm = viewModel::addAlarm, diff --git a/app/src/main/java/org/tasks/compose/Banner.kt b/app/src/main/java/org/tasks/compose/Banner.kt index 076c30cc8..38480c2d5 100644 --- a/app/src/main/java/org/tasks/compose/Banner.kt +++ b/app/src/main/java/org/tasks/compose/Banner.kt @@ -1,61 +1,31 @@ package org.tasks.compose import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import org.tasks.R import org.tasks.Tasks +import org.tasks.compose.components.AnimatedBanner import org.tasks.themes.TasksTheme @ExperimentalAnimationApi @Composable -fun AnimatedBanner( +fun NotificationsDisabledBanner( visible: Boolean, - content: @Composable () -> Unit, - buttons: @Composable () -> Unit, + settings: () -> Unit, + dismiss: () -> Unit, ) { - AnimatedVisibility( + AnimatedBanner( visible = visible, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column( - modifier = Modifier - .fillMaxWidth(), - ) { - Divider() - Spacer(modifier = Modifier.height(16.dp)) - content() - Row( - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - .align(Alignment.End), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - buttons() - } - Divider() - } - } + title = stringResource(id = R.string.enable_reminders), + body = stringResource(id = R.string.enable_reminders_description), + dismissText = stringResource(id = R.string.dismiss), + onDismiss = dismiss, + action = stringResource(id = R.string.TLA_menu_settings), + onAction = settings, + ) } @ExperimentalAnimationApi @@ -67,42 +37,24 @@ fun SubscriptionNagBanner( ) { AnimatedBanner( visible = visible, - content = { - Text( - text = stringResource( - id = if (Tasks.IS_GENERIC) { - R.string.enjoying_tasks - } else { - R.string.tasks_needs_your_support - } - ), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource( - id = if (Tasks.IS_GENERIC) { - R.string.tasks_needs_your_support - } else { - R.string.support_development_subscribe - } - ), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.onSurface, - ) - }, - buttons = { - BannerTextButton(text = R.string.dismiss, dismiss) - val res = if (Tasks.IS_GENERIC) { - R.string.TLA_menu_donate - } else { - R.string.button_subscribe - } - BannerTextButton(text = res, subscribe) - } + title = stringResource(id = if (Tasks.IS_GENERIC) { + R.string.enjoying_tasks + } else { + R.string.tasks_needs_your_support + }), + body = stringResource(id = if (Tasks.IS_GENERIC) { + R.string.tasks_needs_your_support + } else { + R.string.support_development_subscribe + }), + dismissText = stringResource(id = R.string.dismiss), + onDismiss = dismiss, + action = stringResource(id = if (Tasks.IS_GENERIC) { + R.string.TLA_menu_donate + } else { + R.string.button_subscribe + }), + onAction = subscribe ) } @@ -115,38 +67,21 @@ fun BeastModeBanner( ) { AnimatedBanner( visible = visible, - content = { - Text( - text = stringResource(id = R.string.hint_customize_edit_title), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(id = R.string.hint_customize_edit_body), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.onSurface, - ) - }, - buttons = { - BannerTextButton(text = R.string.dismiss, onClick = dismiss) - BannerTextButton(text = R.string.TLA_menu_settings, onClick = showSettings) - } + title = stringResource(id = R.string.hint_customize_edit_title), + body = stringResource(id = R.string.hint_customize_edit_body), + dismissText = stringResource(id = R.string.dismiss), + onDismiss = dismiss, + action = stringResource(id = R.string.TLA_menu_settings), + onAction = showSettings, ) } +@ExperimentalAnimationApi +@Preview(showBackground = true) +@Preview(showBackground = true, backgroundColor = 0x202124, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun BannerTextButton(text: Int, onClick: () -> Unit) { - TextButton(onClick = onClick) { - Text( - text = stringResource(id = text), - style = MaterialTheme.typography.labelLarge.copy( - color = MaterialTheme.colorScheme.onSurface - ), - ) - } +private fun NotificationsDisabledPreview() = TasksTheme { + NotificationsDisabledBanner(visible = true, settings = {}, dismiss = {}) } @ExperimentalAnimationApi @@ -164,4 +99,3 @@ private fun BeastModePreview() = TasksTheme { private fun SubscriptionNagPreview() = TasksTheme { SubscriptionNagBanner(visible = true, subscribe = {}, dismiss = {}) } - diff --git a/app/src/main/java/org/tasks/compose/ReminderPermissionState.kt b/app/src/main/java/org/tasks/compose/ReminderPermissionState.kt new file mode 100644 index 000000000..f9552ed2b --- /dev/null +++ b/app/src/main/java/org/tasks/compose/ReminderPermissionState.kt @@ -0,0 +1,31 @@ +package org.tasks.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import org.tasks.extensions.Context.canScheduleExactAlarms + +@Composable +fun rememberReminderPermissionState(): State { + val context = LocalContext.current + val hasRemindersPermission = remember { mutableStateOf(true) } + val observer = remember { + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasRemindersPermission.value = context.canScheduleExactAlarms() + } + } + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, observer) { + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + return hasRemindersPermission +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt b/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt index 6e154d770..526603bc7 100644 --- a/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt +++ b/app/src/main/java/org/tasks/compose/edit/AlarmRow.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.PermissionStatus import com.todoroo.astrid.ui.ReminderControlSetViewModel import org.tasks.R import org.tasks.compose.AddAlarmDialog @@ -35,12 +34,12 @@ import org.tasks.reminders.AlarmToString import org.tasks.themes.TasksTheme import java.util.Locale -@OptIn(ExperimentalPermissionsApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable fun AlarmRow( vm: ReminderControlSetViewModel = viewModel(), - permissionStatus: PermissionStatus, - launchPermissionRequest: () -> Unit, + hasNotificationPermissions: Boolean, + fixNotificationPermissions: () -> Unit, alarms: List, ringMode: Int, locale: Locale, @@ -53,43 +52,38 @@ fun AlarmRow( iconRes = R.drawable.ic_outline_notifications_24px, content = { val viewState = vm.viewState.collectAsStateLifecycleAware().value - when (permissionStatus) { - PermissionStatus.Granted -> { - Alarms( - alarms = alarms, - ringMode = ringMode, - locale = locale, - replaceAlarm = { - vm.setReplace(it) - vm.showAddAlarm(visible = true) - }, - addAlarm = { - vm.showAddAlarm(visible = true) - }, - deleteAlarm = deleteAlarm, - openRingType = openRingType, + if (hasNotificationPermissions) { + Alarms( + alarms = alarms, + ringMode = ringMode, + locale = locale, + replaceAlarm = { + vm.setReplace(it) + vm.showAddAlarm(visible = true) + }, + addAlarm = { + vm.showAddAlarm(visible = true) + }, + deleteAlarm = deleteAlarm, + openRingType = openRingType, + ) + } else { + Column( + modifier = Modifier + .padding(end = 16.dp) + .clickable { fixNotificationPermissions() } + ) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.enable_reminders), + color = colorResource(id = R.color.red_500), ) - } - is PermissionStatus.Denied -> { - Column( - modifier = Modifier - .padding(end = 16.dp) - .clickable { - launchPermissionRequest() - } - ) { - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = stringResource(id = R.string.enable_reminders), - color = colorResource(id = R.color.red_500), - ) - Text( - text = stringResource(id = R.string.enable_reminders_description), - style = MaterialTheme.typography.bodySmall, - color = colorResource(id = R.color.red_500), - ) - Spacer(modifier = Modifier.height(20.dp)) - } + Text( + text = stringResource(id = R.string.enable_reminders_description), + style = MaterialTheme.typography.bodySmall, + color = colorResource(id = R.color.red_500), + ) + Spacer(modifier = Modifier.height(20.dp)) } } @@ -212,8 +206,8 @@ fun NoAlarms() { addAlarm = {}, deleteAlarm = {}, openRingType = {}, - permissionStatus = PermissionStatus.Granted, - launchPermissionRequest = {}, + hasNotificationPermissions = true, + fixNotificationPermissions = {}, pickDateAndTime = {}, ) } @@ -232,8 +226,8 @@ fun PermissionDenied() { addAlarm = {}, deleteAlarm = {}, openRingType = {}, - permissionStatus = PermissionStatus.Denied(true), - launchPermissionRequest = {}, + hasNotificationPermissions = false, + fixNotificationPermissions = {}, pickDateAndTime = {}, ) } diff --git a/app/src/main/java/org/tasks/extensions/Context.kt b/app/src/main/java/org/tasks/extensions/Context.kt index 7ad895504..a72f4bb53 100644 --- a/app/src/main/java/org/tasks/extensions/Context.kt +++ b/app/src/main/java/org/tasks/extensions/Context.kt @@ -1,6 +1,7 @@ package org.tasks.extensions import android.app.Activity +import android.app.AlarmManager import android.content.ActivityNotFoundException import android.content.ContentResolver import android.content.Context @@ -12,13 +13,17 @@ import android.content.res.Configuration import android.net.ConnectivityManager import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET import android.net.Uri +import android.os.Build +import android.provider.Settings import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.annotation.AnyRes import androidx.browser.customtabs.CustomTabsIntent import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor +import com.todoroo.andlib.utility.AndroidUtilities.atLeastS import org.tasks.R +import org.tasks.notifications.NotificationManager.Companion.NOTIFICATION_CHANNEL_DEFAULT object Context { private const val HTTP = "http" @@ -103,4 +108,35 @@ object Context { } return null } + + fun Context.canScheduleExactAlarms(): Boolean = + !atLeastS() || (getSystemService(Context.ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms() + + fun Context.openReminderSettings() { + if (atLeastS()) { + startActivity( + Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + .apply { data = Uri.parse("package:$packageName") } + ) + } + } + + fun Context.openAppNotificationSettings() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + ) + } + } + + fun Context.openChannelNotificationSettings() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startActivity( + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL_DEFAULT) + ) + } + } } diff --git a/app/src/main/java/org/tasks/notifications/NotificationManager.kt b/app/src/main/java/org/tasks/notifications/NotificationManager.kt index 6430beef3..61cf60752 100644 --- a/app/src/main/java/org/tasks/notifications/NotificationManager.kt +++ b/app/src/main/java/org/tasks/notifications/NotificationManager.kt @@ -156,7 +156,9 @@ class NotificationManager @Inject constructor( fiveTimes: Boolean, useGroupKey: Boolean ) { - if (!permissionChecker.canNotify()) { + if (permissionChecker.canNotify()) { + preferences.warnNotificationsDisabled = true + } else { Timber.w("Notifications disabled") return } diff --git a/app/src/main/java/org/tasks/preferences/PermissionChecker.java b/app/src/main/java/org/tasks/preferences/PermissionChecker.java index d21d386a9..32b43bd9b 100644 --- a/app/src/main/java/org/tasks/preferences/PermissionChecker.java +++ b/app/src/main/java/org/tasks/preferences/PermissionChecker.java @@ -2,7 +2,7 @@ package org.tasks.preferences; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastOreo; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastQ; -import static com.todoroo.andlib.utility.AndroidUtilities.preTiramisu; +import static com.todoroo.andlib.utility.AndroidUtilities.atLeastTiramisu; import static java.util.Arrays.asList; import android.Manifest.permission; @@ -43,7 +43,8 @@ public class PermissionChecker { } public boolean canNotify() { - return preTiramisu() || checkPermissions(permission.POST_NOTIFICATIONS); + return org.tasks.extensions.Context.INSTANCE.canScheduleExactAlarms(context) + && (!atLeastTiramisu() || checkPermissions(permission.POST_NOTIFICATIONS)); } private boolean checkPermissions(String... permissions) { diff --git a/app/src/main/java/org/tasks/preferences/PermissionRequestor.java b/app/src/main/java/org/tasks/preferences/PermissionRequestor.java index 70eda8a54..2f8ba9a27 100644 --- a/app/src/main/java/org/tasks/preferences/PermissionRequestor.java +++ b/app/src/main/java/org/tasks/preferences/PermissionRequestor.java @@ -7,7 +7,6 @@ import android.Manifest.permission; public abstract class PermissionRequestor { - public static final int REQUEST_CALENDAR = 51; public static final int REQUEST_GOOGLE_ACCOUNTS = 53; public static final int REQUEST_BACKGROUND_LOCATION = 54; public static final int REQUEST_FOREGROUND_LOCATION = 55; @@ -18,14 +17,6 @@ public abstract class PermissionRequestor { this.permissionChecker = permissionChecker; } - public boolean requestCalendarPermissions() { - if (permissionChecker.canAccessCalendars()) { - return true; - } - requestPermissions(REQUEST_CALENDAR, permission.READ_CALENDAR, permission.WRITE_CALENDAR); - return false; - } - public boolean requestAccountPermissions() { if (permissionChecker.canAccessAccounts()) { return true; diff --git a/app/src/main/java/org/tasks/preferences/Preferences.kt b/app/src/main/java/org/tasks/preferences/Preferences.kt index d2fd1dc15..69edae40a 100644 --- a/app/src/main/java/org/tasks/preferences/Preferences.kt +++ b/app/src/main/java/org/tasks/preferences/Preferences.kt @@ -17,13 +17,13 @@ import com.todoroo.andlib.utility.AndroidUtilities import com.todoroo.andlib.utility.DateUtilities import com.todoroo.astrid.activity.BeastModePreferences import com.todoroo.astrid.core.SortHelper -import org.tasks.data.entity.Task -import org.tasks.data.entity.Task.Companion.NOTIFY_AFTER_DEADLINE -import org.tasks.data.entity.Task.Companion.NOTIFY_AT_DEADLINE import org.tasks.BuildConfig import org.tasks.R import org.tasks.Strings.isNullOrEmpty import org.tasks.billing.Purchase +import org.tasks.data.entity.Task +import org.tasks.data.entity.Task.Companion.NOTIFY_AFTER_DEADLINE +import org.tasks.data.entity.Task.Companion.NOTIFY_AT_DEADLINE import org.tasks.data.entity.TaskAttachment import org.tasks.extensions.Context.getResourceUri import org.tasks.themes.ColorProvider @@ -570,6 +570,10 @@ class Preferences @JvmOverloads constructor( get() = getLong(R.string.p_last_review_request, 0L) set(value) = setLong(R.string.p_last_review_request, value) + var warnNotificationsDisabled: Boolean + get() = getBoolean(R.string.p_warn_notifications_disabled, true) + set(value) = setBoolean(R.string.p_warn_notifications_disabled, value) + var lastSubscribeRequest: Long get() = getLong(R.string.p_last_subscribe_request, 0L) set(value) = setLong(R.string.p_last_subscribe_request, value) diff --git a/app/src/main/java/org/tasks/preferences/fragments/Notifications.kt b/app/src/main/java/org/tasks/preferences/fragments/Notifications.kt index 29dbf906d..888928639 100644 --- a/app/src/main/java/org/tasks/preferences/fragments/Notifications.kt +++ b/app/src/main/java/org/tasks/preferences/fragments/Notifications.kt @@ -5,7 +5,6 @@ import android.content.Context.POWER_SERVICE import android.content.Intent import android.media.RingtoneManager import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.PowerManager import android.provider.Settings @@ -24,8 +23,8 @@ import org.tasks.compose.FilterSelectionActivity.Companion.launch import org.tasks.compose.FilterSelectionActivity.Companion.registerForListPickerResult import org.tasks.dialogs.MyTimePickerDialog.Companion.newTimePicker import org.tasks.extensions.Context.getResourceUri +import org.tasks.extensions.Context.openChannelNotificationSettings import org.tasks.injection.InjectingPreferenceFragment -import org.tasks.notifications.NotificationManager import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.Preferences import org.tasks.receivers.ShortcutBadger @@ -115,13 +114,7 @@ class Notifications : InjectingPreferenceFragment() { } findPreference(R.string.more_settings).setOnPreferenceClickListener { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startActivity( - Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName) - .putExtra(Settings.EXTRA_CHANNEL_ID, NotificationManager.NOTIFICATION_CHANNEL_DEFAULT) - ) - } + requireContext().openChannelNotificationSettings() true } diff --git a/app/src/main/java/org/tasks/receivers/ScheduleExactAlarmsPermissionReceiver.kt b/app/src/main/java/org/tasks/receivers/ScheduleExactAlarmsPermissionReceiver.kt new file mode 100644 index 000000000..5bbd566ca --- /dev/null +++ b/app/src/main/java/org/tasks/receivers/ScheduleExactAlarmsPermissionReceiver.kt @@ -0,0 +1,27 @@ +package org.tasks.receivers + +import android.app.AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import org.tasks.extensions.Context.canScheduleExactAlarms +import org.tasks.jobs.WorkManager +import org.tasks.scheduling.NotificationSchedulerIntentService +import javax.inject.Inject + +@AndroidEntryPoint +class ScheduleExactAlarmsPermissionReceiver : BroadcastReceiver() { + + @Inject lateinit var workManager: WorkManager + + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action != ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED) { + return + } + + if (context.canScheduleExactAlarms()) { + NotificationSchedulerIntentService.enqueueWork(context) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/ui/TaskListViewModel.kt b/app/src/main/java/org/tasks/ui/TaskListViewModel.kt index ebce31c80..32e4bf810 100644 --- a/app/src/main/java/org/tasks/ui/TaskListViewModel.kt +++ b/app/src/main/java/org/tasks/ui/TaskListViewModel.kt @@ -72,6 +72,7 @@ class TaskListViewModel @Inject constructor( val searchQuery: String? = null, val tasks: TasksResults = TasksResults.Loading, val begForSubscription: Boolean = false, + val warnNotificationsDisabled: Boolean = false, val syncOngoing: Boolean = false, val collapsed: Set = setOf(SectionedDataSource.HEADER_COMPLETED), ) @@ -104,7 +105,16 @@ class TaskListViewModel @Inject constructor( } } - fun dismissBanner(clickedPurchase: Boolean) { + fun dismissNotificationBanner( + fix: Boolean = false, + ) { + _state.update { + it.copy(warnNotificationsDisabled = false) + } + preferences.warnNotificationsDisabled = fix + } + + fun dismissPurchaseBanner(clickedPurchase: Boolean) { _state.update { it.copy(begForSubscription = false) } @@ -170,14 +180,6 @@ class TaskListViewModel @Inject constructor( } .flowOn(Dispatchers.Default) .launchIn(viewModelScope) - - viewModelScope.launch(Dispatchers.Default) { - if (!inventory.hasPro && !firebase.subscribeCooldown) { - _state.update { - it.copy(begForSubscription = true) - } - } - } } override fun onCleared() { @@ -202,6 +204,20 @@ class TaskListViewModel @Inject constructor( } } + fun updateBannerState() { + viewModelScope.launch(Dispatchers.Default) { + _state.update { + it.copy(warnNotificationsDisabled = preferences.warnNotificationsDisabled) + } + + if (!inventory.hasPro && !firebase.subscribeCooldown) { + _state.update { + it.copy(begForSubscription = true) + } + } + } + } + companion object { fun Context.createSearchQuery(query: String): Filter = SearchFilter(getString(R.string.FLA_search_filter, query), query) diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index f3e1fe542..2cfcd30a4 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -414,6 +414,7 @@ install_version install_date default_location + warn_notifications_disabled showed_purchase_dialog billing_flow_result diff --git a/kmp/src/commonMain/kotlin/org/tasks/compose/components/AnimatedBanner.kt b/kmp/src/commonMain/kotlin/org/tasks/compose/components/AnimatedBanner.kt new file mode 100644 index 000000000..839d060ad --- /dev/null +++ b/kmp/src/commonMain/kotlin/org/tasks/compose/components/AnimatedBanner.kt @@ -0,0 +1,98 @@ +package org.tasks.compose.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@ExperimentalAnimationApi +@Composable +fun AnimatedBanner( + visible: Boolean, + title: String, + body: String, + dismissText: String, + onDismiss: () -> Unit, + action: String, + onAction: () -> Unit, +) { + AnimatedBanner( + visible = visible, + content = { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurface, + ) + }, + buttons = { + BannerTextButton(text = dismissText, onDismiss) + BannerTextButton(text = action, onAction) + } + ) +} + +@ExperimentalAnimationApi +@Composable +private fun AnimatedBanner( + visible: Boolean, + content: @Composable () -> Unit, + buttons: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + content() + Row( + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + .align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + buttons() + } + } + } +} + +@Composable +fun BannerTextButton(text: String, onClick: () -> Unit) { + TextButton(onClick = onClick) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + ) + } +}