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
+ ),
+ )
+ }
+}