Use SCHEDULE_EXACT_ALARM

pull/2928/head
Alex Baker 3 months ago
parent a4cd0829b0
commit d8186e5fe4

@ -30,8 +30,7 @@
<!-- ************* --> <!-- ************* -->
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- *************************** --> <!-- *************************** -->
<!-- google calendar integration --> <!-- google calendar integration -->
@ -469,7 +468,14 @@
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <action android:name="android.intent.action.BOOT_COMPLETED"/>
<category android:name="android.intent.category.DEFAULT"/> </intent-filter>
</receiver>
<receiver
android:name=".receivers.ScheduleExactAlarmsPermissionReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
</intent-filter> </intent-filter>
</receiver> </receiver>

@ -88,6 +88,7 @@ object AndroidUtilities {
return Build.VERSION.SDK_INT >= VERSION_CODES.S return Build.VERSION.SDK_INT >= VERSION_CODES.S
} }
@JvmStatic
fun atLeastTiramisu(): Boolean { fun atLeastTiramisu(): Boolean {
return Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU return Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU
} }

@ -5,6 +5,7 @@
*/ */
package com.todoroo.astrid.activity package com.todoroo.astrid.activity
import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@ -27,6 +28,7 @@ import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
@ -45,9 +47,14 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener 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.appbar.AppBarLayout
import com.google.android.material.bottomappbar.BottomAppBar import com.google.android.material.bottomappbar.BottomAppBar
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.todoroo.andlib.utility.AndroidUtilities
import com.todoroo.andlib.utility.DateUtilities import com.todoroo.andlib.utility.DateUtilities
import com.todoroo.astrid.adapter.TaskAdapter import com.todoroo.astrid.adapter.TaskAdapter
import com.todoroo.astrid.adapter.TaskAdapterProvider import com.todoroo.astrid.adapter.TaskAdapterProvider
@ -85,8 +92,10 @@ import org.tasks.billing.PurchaseActivity
import org.tasks.caldav.BaseCaldavCalendarSettingsActivity import org.tasks.caldav.BaseCaldavCalendarSettingsActivity
import org.tasks.compose.FilterSelectionActivity.Companion.launch import org.tasks.compose.FilterSelectionActivity.Companion.launch
import org.tasks.compose.FilterSelectionActivity.Companion.registerForListPickerResult import org.tasks.compose.FilterSelectionActivity.Companion.registerForListPickerResult
import org.tasks.compose.NotificationsDisabledBanner
import org.tasks.compose.SubscriptionNagBanner import org.tasks.compose.SubscriptionNagBanner
import org.tasks.compose.collectAsStateLifecycleAware import org.tasks.compose.collectAsStateLifecycleAware
import org.tasks.compose.rememberReminderPermissionState
import org.tasks.data.TaskContainer import org.tasks.data.TaskContainer
import org.tasks.data.dao.CaldavDao import org.tasks.data.dao.CaldavDao
import org.tasks.data.dao.TagDataDao 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.DialogBuilder
import org.tasks.dialogs.PriorityPicker.Companion.newPriorityPicker import org.tasks.dialogs.PriorityPicker.Companion.newPriorityPicker
import org.tasks.dialogs.SortSettingsActivity 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.openUri
import org.tasks.extensions.Context.toast import org.tasks.extensions.Context.toast
import org.tasks.extensions.Fragment.safeStartActivityForResult import org.tasks.extensions.Fragment.safeStartActivityForResult
@ -116,6 +128,7 @@ import org.tasks.filters.PlaceFilter
import org.tasks.markdown.MarkdownProvider import org.tasks.markdown.MarkdownProvider
import org.tasks.preferences.Device import org.tasks.preferences.Device
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.scheduling.NotificationSchedulerIntentService
import org.tasks.sync.SyncAdapters import org.tasks.sync.SyncAdapters
import org.tasks.tags.TagPickerActivity import org.tasks.tags.TagPickerActivity
import org.tasks.tasklist.DragAndDropRecyclerAdapter import org.tasks.tasklist.DragAndDropRecyclerAdapter
@ -168,7 +181,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
@Inject lateinit var taskEditEventBus: TaskEditEventBus @Inject lateinit var taskEditEventBus: TaskEditEventBus
@Inject lateinit var database: Database @Inject lateinit var database: Database
@Inject lateinit var markdown: MarkdownProvider @Inject lateinit var markdown: MarkdownProvider
private val listViewModel: TaskListViewModel by viewModels() private val listViewModel: TaskListViewModel by viewModels()
private val mainViewModel: MainActivityViewModel by activityViewModels() private val mainViewModel: MainActivityViewModel by activityViewModels()
private lateinit var taskAdapter: TaskAdapter private lateinit var taskAdapter: TaskAdapter
@ -253,7 +266,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
.launchIn(viewLifecycleOwner.lifecycleScope) .launchIn(viewLifecycleOwner.lifecycleScope)
} }
@OptIn(ExperimentalAnimationApi::class) @OptIn(ExperimentalAnimationApi::class, ExperimentalPermissionsApi::class)
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
requireActivity().onBackPressedDispatcher.addCallback(owner = viewLifecycleOwner) { requireActivity().onBackPressedDispatcher.addCallback(owner = viewLifecycleOwner) {
@ -289,7 +302,8 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
(recyclerView.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false (recyclerView.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false
recyclerView.layoutManager = LinearLayoutManager(context) recyclerView.layoutManager = LinearLayoutManager(context)
lifecycleScope.launch { lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
listViewModel.updateBannerState()
listViewModel.state.collect { listViewModel.state.collect {
if (it.tasks is TaskListViewModel.TasksResults.Results) { if (it.tasks is TaskListViewModel.TasksResults.Results) {
submitList(it.tasks.tasks) submitList(it.tasks.tasks)
@ -343,12 +357,41 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
setupMenu(toolbar) setupMenu(toolbar)
binding.banner.setContent { binding.banner.setContent {
val context = LocalContext.current val context = LocalContext.current
val showBanner = listViewModel.state.collectAsStateLifecycleAware().value.begForSubscription val state = listViewModel.state.collectAsStateLifecycleAware().value
TasksTheme { 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( SubscriptionNagBanner(
visible = showBanner, visible = state.begForSubscription && !showNotificationBanner,
subscribe = { subscribe = {
listViewModel.dismissBanner(clickedPurchase = true) listViewModel.dismissPurchaseBanner(clickedPurchase = true)
if (Tasks.IS_GOOGLE_PLAY) { if (Tasks.IS_GOOGLE_PLAY) {
context.startActivity(Intent(context, PurchaseActivity::class.java)) context.startActivity(Intent(context, PurchaseActivity::class.java))
} else { } else {
@ -357,7 +400,7 @@ class TaskListFragment : Fragment(), OnRefreshListener, Toolbar.OnMenuItemClickL
} }
}, },
dismiss = { dismiss = {
listViewModel.dismissBanner(clickedPurchase = false) listViewModel.dismissPurchaseBanner(clickedPurchase = false)
}, },
) )
} }

@ -23,11 +23,13 @@ import org.tasks.R
import org.tasks.activities.DateAndTimePickerActivity import org.tasks.activities.DateAndTimePickerActivity
import org.tasks.compose.collectAsStateLifecycleAware import org.tasks.compose.collectAsStateLifecycleAware
import org.tasks.compose.edit.AlarmRow import org.tasks.compose.edit.AlarmRow
import org.tasks.compose.rememberReminderPermissionState
import org.tasks.data.entity.Alarm import org.tasks.data.entity.Alarm
import org.tasks.data.entity.Alarm.Companion.TYPE_DATE_TIME import org.tasks.data.entity.Alarm.Companion.TYPE_DATE_TIME
import org.tasks.date.DateTimeUtils import org.tasks.date.DateTimeUtils
import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.DialogBuilder
import org.tasks.dialogs.MyTimePickerDialog import org.tasks.dialogs.MyTimePickerDialog
import org.tasks.extensions.Context.openReminderSettings
import org.tasks.scheduling.NotificationSchedulerIntentService import org.tasks.scheduling.NotificationSchedulerIntentService
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
import org.tasks.ui.TaskEditControlFragment import org.tasks.ui.TaskEditControlFragment
@ -78,6 +80,7 @@ class ReminderControlSet : TaskEditControlFragment() {
setContent { setContent {
TasksTheme { TasksTheme {
val ringMode by remember { this@ReminderControlSet.ringMode } val ringMode by remember { this@ReminderControlSet.ringMode }
val hasReminderPermissions by rememberReminderPermissionState()
val notificationPermissions = if (AndroidUtilities.atLeastTiramisu()) { val notificationPermissions = if (AndroidUtilities.atLeastTiramisu()) {
rememberPermissionState( rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS,
@ -103,10 +106,14 @@ class ReminderControlSet : TaskEditControlFragment() {
AlarmRow( AlarmRow(
locale = locale, locale = locale,
alarms = viewModel.selectedAlarms.collectAsStateLifecycleAware().value, alarms = viewModel.selectedAlarms.collectAsStateLifecycleAware().value,
permissionStatus = notificationPermissions?.status hasNotificationPermissions = hasReminderPermissions &&
?: PermissionStatus.Granted, (notificationPermissions == null || notificationPermissions.status == PermissionStatus.Granted),
launchPermissionRequest = { fixNotificationPermissions = {
notificationPermissions?.launchPermissionRequest() if (hasReminderPermissions) {
notificationPermissions?.launchPermissionRequest()
} else {
context.openReminderSettings()
}
}, },
ringMode = ringMode, ringMode = ringMode,
addAlarm = viewModel::addAlarm, addAlarm = viewModel::addAlarm,

@ -1,61 +1,31 @@
package org.tasks.compose package org.tasks.compose
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi 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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.tasks.R import org.tasks.R
import org.tasks.Tasks import org.tasks.Tasks
import org.tasks.compose.components.AnimatedBanner
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
@ExperimentalAnimationApi @ExperimentalAnimationApi
@Composable @Composable
fun AnimatedBanner( fun NotificationsDisabledBanner(
visible: Boolean, visible: Boolean,
content: @Composable () -> Unit, settings: () -> Unit,
buttons: @Composable () -> Unit, dismiss: () -> Unit,
) { ) {
AnimatedVisibility( AnimatedBanner(
visible = visible, visible = visible,
enter = expandVertically(), title = stringResource(id = R.string.enable_reminders),
exit = shrinkVertically(), body = stringResource(id = R.string.enable_reminders_description),
) { dismissText = stringResource(id = R.string.dismiss),
Column( onDismiss = dismiss,
modifier = Modifier action = stringResource(id = R.string.TLA_menu_settings),
.fillMaxWidth(), onAction = settings,
) { )
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()
}
}
} }
@ExperimentalAnimationApi @ExperimentalAnimationApi
@ -67,42 +37,24 @@ fun SubscriptionNagBanner(
) { ) {
AnimatedBanner( AnimatedBanner(
visible = visible, visible = visible,
content = { title = stringResource(id = if (Tasks.IS_GENERIC) {
Text( R.string.enjoying_tasks
text = stringResource( } else {
id = if (Tasks.IS_GENERIC) { R.string.tasks_needs_your_support
R.string.enjoying_tasks }),
} else { body = stringResource(id = if (Tasks.IS_GENERIC) {
R.string.tasks_needs_your_support R.string.tasks_needs_your_support
} } else {
), R.string.support_development_subscribe
style = MaterialTheme.typography.bodyLarge, }),
modifier = Modifier.padding(horizontal = 16.dp), dismissText = stringResource(id = R.string.dismiss),
color = MaterialTheme.colorScheme.onSurface, onDismiss = dismiss,
) action = stringResource(id = if (Tasks.IS_GENERIC) {
Spacer(modifier = Modifier.height(4.dp)) R.string.TLA_menu_donate
Text( } else {
text = stringResource( R.string.button_subscribe
id = if (Tasks.IS_GENERIC) { }),
R.string.tasks_needs_your_support onAction = subscribe
} 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)
}
) )
} }
@ -115,38 +67,21 @@ fun BeastModeBanner(
) { ) {
AnimatedBanner( AnimatedBanner(
visible = visible, visible = visible,
content = { title = stringResource(id = R.string.hint_customize_edit_title),
Text( body = stringResource(id = R.string.hint_customize_edit_body),
text = stringResource(id = R.string.hint_customize_edit_title), dismissText = stringResource(id = R.string.dismiss),
style = MaterialTheme.typography.bodyLarge, onDismiss = dismiss,
modifier = Modifier.padding(horizontal = 16.dp), action = stringResource(id = R.string.TLA_menu_settings),
color = MaterialTheme.colorScheme.onSurface, onAction = showSettings,
)
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)
}
) )
} }
@ExperimentalAnimationApi
@Preview(showBackground = true)
@Preview(showBackground = true, backgroundColor = 0x202124, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
fun BannerTextButton(text: Int, onClick: () -> Unit) { private fun NotificationsDisabledPreview() = TasksTheme {
TextButton(onClick = onClick) { NotificationsDisabledBanner(visible = true, settings = {}, dismiss = {})
Text(
text = stringResource(id = text),
style = MaterialTheme.typography.labelLarge.copy(
color = MaterialTheme.colorScheme.onSurface
),
)
}
} }
@ExperimentalAnimationApi @ExperimentalAnimationApi
@ -164,4 +99,3 @@ private fun BeastModePreview() = TasksTheme {
private fun SubscriptionNagPreview() = TasksTheme { private fun SubscriptionNagPreview() = TasksTheme {
SubscriptionNagBanner(visible = true, subscribe = {}, dismiss = {}) SubscriptionNagBanner(visible = true, subscribe = {}, dismiss = {})
} }

@ -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<Boolean> {
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
}

@ -21,7 +21,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.todoroo.astrid.ui.ReminderControlSetViewModel import com.todoroo.astrid.ui.ReminderControlSetViewModel
import org.tasks.R import org.tasks.R
import org.tasks.compose.AddAlarmDialog import org.tasks.compose.AddAlarmDialog
@ -35,12 +34,12 @@ import org.tasks.reminders.AlarmToString
import org.tasks.themes.TasksTheme import org.tasks.themes.TasksTheme
import java.util.Locale import java.util.Locale
@OptIn(ExperimentalPermissionsApi::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun AlarmRow( fun AlarmRow(
vm: ReminderControlSetViewModel = viewModel(), vm: ReminderControlSetViewModel = viewModel(),
permissionStatus: PermissionStatus, hasNotificationPermissions: Boolean,
launchPermissionRequest: () -> Unit, fixNotificationPermissions: () -> Unit,
alarms: List<Alarm>, alarms: List<Alarm>,
ringMode: Int, ringMode: Int,
locale: Locale, locale: Locale,
@ -53,43 +52,38 @@ fun AlarmRow(
iconRes = R.drawable.ic_outline_notifications_24px, iconRes = R.drawable.ic_outline_notifications_24px,
content = { content = {
val viewState = vm.viewState.collectAsStateLifecycleAware().value val viewState = vm.viewState.collectAsStateLifecycleAware().value
when (permissionStatus) { if (hasNotificationPermissions) {
PermissionStatus.Granted -> { Alarms(
Alarms( alarms = alarms,
alarms = alarms, ringMode = ringMode,
ringMode = ringMode, locale = locale,
locale = locale, replaceAlarm = {
replaceAlarm = { vm.setReplace(it)
vm.setReplace(it) vm.showAddAlarm(visible = true)
vm.showAddAlarm(visible = true) },
}, addAlarm = {
addAlarm = { vm.showAddAlarm(visible = true)
vm.showAddAlarm(visible = true) },
}, deleteAlarm = deleteAlarm,
deleteAlarm = deleteAlarm, openRingType = openRingType,
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),
) )
} Text(
is PermissionStatus.Denied -> { text = stringResource(id = R.string.enable_reminders_description),
Column( style = MaterialTheme.typography.bodySmall,
modifier = Modifier color = colorResource(id = R.color.red_500),
.padding(end = 16.dp) )
.clickable { Spacer(modifier = Modifier.height(20.dp))
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))
}
} }
} }
@ -212,8 +206,8 @@ fun NoAlarms() {
addAlarm = {}, addAlarm = {},
deleteAlarm = {}, deleteAlarm = {},
openRingType = {}, openRingType = {},
permissionStatus = PermissionStatus.Granted, hasNotificationPermissions = true,
launchPermissionRequest = {}, fixNotificationPermissions = {},
pickDateAndTime = {}, pickDateAndTime = {},
) )
} }
@ -232,8 +226,8 @@ fun PermissionDenied() {
addAlarm = {}, addAlarm = {},
deleteAlarm = {}, deleteAlarm = {},
openRingType = {}, openRingType = {},
permissionStatus = PermissionStatus.Denied(true), hasNotificationPermissions = false,
launchPermissionRequest = {}, fixNotificationPermissions = {},
pickDateAndTime = {}, pickDateAndTime = {},
) )
} }

@ -1,6 +1,7 @@
package org.tasks.extensions package org.tasks.extensions
import android.app.Activity import android.app.Activity
import android.app.AlarmManager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
@ -12,13 +13,17 @@ import android.content.res.Configuration
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.view.View import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.annotation.AnyRes import androidx.annotation.AnyRes
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor
import com.todoroo.andlib.utility.AndroidUtilities.atLeastS
import org.tasks.R import org.tasks.R
import org.tasks.notifications.NotificationManager.Companion.NOTIFICATION_CHANNEL_DEFAULT
object Context { object Context {
private const val HTTP = "http" private const val HTTP = "http"
@ -103,4 +108,35 @@ object Context {
} }
return null 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)
)
}
}
} }

@ -156,7 +156,9 @@ class NotificationManager @Inject constructor(
fiveTimes: Boolean, fiveTimes: Boolean,
useGroupKey: Boolean useGroupKey: Boolean
) { ) {
if (!permissionChecker.canNotify()) { if (permissionChecker.canNotify()) {
preferences.warnNotificationsDisabled = true
} else {
Timber.w("Notifications disabled") Timber.w("Notifications disabled")
return return
} }

@ -2,7 +2,7 @@ package org.tasks.preferences;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastOreo; import static com.todoroo.andlib.utility.AndroidUtilities.atLeastOreo;
import static com.todoroo.andlib.utility.AndroidUtilities.atLeastQ; 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 static java.util.Arrays.asList;
import android.Manifest.permission; import android.Manifest.permission;
@ -43,7 +43,8 @@ public class PermissionChecker {
} }
public boolean canNotify() { 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) { private boolean checkPermissions(String... permissions) {

@ -7,7 +7,6 @@ import android.Manifest.permission;
public abstract class PermissionRequestor { public abstract class PermissionRequestor {
public static final int REQUEST_CALENDAR = 51;
public static final int REQUEST_GOOGLE_ACCOUNTS = 53; public static final int REQUEST_GOOGLE_ACCOUNTS = 53;
public static final int REQUEST_BACKGROUND_LOCATION = 54; public static final int REQUEST_BACKGROUND_LOCATION = 54;
public static final int REQUEST_FOREGROUND_LOCATION = 55; public static final int REQUEST_FOREGROUND_LOCATION = 55;
@ -18,14 +17,6 @@ public abstract class PermissionRequestor {
this.permissionChecker = permissionChecker; 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() { public boolean requestAccountPermissions() {
if (permissionChecker.canAccessAccounts()) { if (permissionChecker.canAccessAccounts()) {
return true; return true;

@ -17,13 +17,13 @@ import com.todoroo.andlib.utility.AndroidUtilities
import com.todoroo.andlib.utility.DateUtilities import com.todoroo.andlib.utility.DateUtilities
import com.todoroo.astrid.activity.BeastModePreferences import com.todoroo.astrid.activity.BeastModePreferences
import com.todoroo.astrid.core.SortHelper 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.BuildConfig
import org.tasks.R import org.tasks.R
import org.tasks.Strings.isNullOrEmpty import org.tasks.Strings.isNullOrEmpty
import org.tasks.billing.Purchase 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.data.entity.TaskAttachment
import org.tasks.extensions.Context.getResourceUri import org.tasks.extensions.Context.getResourceUri
import org.tasks.themes.ColorProvider import org.tasks.themes.ColorProvider
@ -570,6 +570,10 @@ class Preferences @JvmOverloads constructor(
get() = getLong(R.string.p_last_review_request, 0L) get() = getLong(R.string.p_last_review_request, 0L)
set(value) = setLong(R.string.p_last_review_request, value) 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 var lastSubscribeRequest: Long
get() = getLong(R.string.p_last_subscribe_request, 0L) get() = getLong(R.string.p_last_subscribe_request, 0L)
set(value) = setLong(R.string.p_last_subscribe_request, value) set(value) = setLong(R.string.p_last_subscribe_request, value)

@ -5,7 +5,6 @@ import android.content.Context.POWER_SERVICE
import android.content.Intent import android.content.Intent
import android.media.RingtoneManager import android.media.RingtoneManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings 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.compose.FilterSelectionActivity.Companion.registerForListPickerResult
import org.tasks.dialogs.MyTimePickerDialog.Companion.newTimePicker import org.tasks.dialogs.MyTimePickerDialog.Companion.newTimePicker
import org.tasks.extensions.Context.getResourceUri import org.tasks.extensions.Context.getResourceUri
import org.tasks.extensions.Context.openChannelNotificationSettings
import org.tasks.injection.InjectingPreferenceFragment import org.tasks.injection.InjectingPreferenceFragment
import org.tasks.notifications.NotificationManager
import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences import org.tasks.preferences.Preferences
import org.tasks.receivers.ShortcutBadger import org.tasks.receivers.ShortcutBadger
@ -115,13 +114,7 @@ class Notifications : InjectingPreferenceFragment() {
} }
findPreference(R.string.more_settings).setOnPreferenceClickListener { findPreference(R.string.more_settings).setOnPreferenceClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { requireContext().openChannelNotificationSettings()
startActivity(
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationManager.NOTIFICATION_CHANNEL_DEFAULT)
)
}
true true
} }

@ -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)
}
}
}

@ -72,6 +72,7 @@ class TaskListViewModel @Inject constructor(
val searchQuery: String? = null, val searchQuery: String? = null,
val tasks: TasksResults = TasksResults.Loading, val tasks: TasksResults = TasksResults.Loading,
val begForSubscription: Boolean = false, val begForSubscription: Boolean = false,
val warnNotificationsDisabled: Boolean = false,
val syncOngoing: Boolean = false, val syncOngoing: Boolean = false,
val collapsed: Set<Long> = setOf(SectionedDataSource.HEADER_COMPLETED), val collapsed: Set<Long> = 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 { _state.update {
it.copy(begForSubscription = false) it.copy(begForSubscription = false)
} }
@ -170,14 +180,6 @@ class TaskListViewModel @Inject constructor(
} }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.launchIn(viewModelScope) .launchIn(viewModelScope)
viewModelScope.launch(Dispatchers.Default) {
if (!inventory.hasPro && !firebase.subscribeCooldown) {
_state.update {
it.copy(begForSubscription = true)
}
}
}
} }
override fun onCleared() { 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 { companion object {
fun Context.createSearchQuery(query: String): Filter = fun Context.createSearchQuery(query: String): Filter =
SearchFilter(getString(R.string.FLA_search_filter, query), query) SearchFilter(getString(R.string.FLA_search_filter, query), query)

@ -414,6 +414,7 @@
<string name="p_install_version">install_version</string> <string name="p_install_version">install_version</string>
<string name="p_install_date">install_date</string> <string name="p_install_date">install_date</string>
<string name="p_default_location">default_location</string> <string name="p_default_location">default_location</string>
<string name="p_warn_notifications_disabled">warn_notifications_disabled</string>
<string name="event_showed_purchase_dialog">showed_purchase_dialog</string> <string name="event_showed_purchase_dialog">showed_purchase_dialog</string>
<string name="event_purchase_result">billing_flow_result</string> <string name="event_purchase_result">billing_flow_result</string>

@ -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
),
)
}
}
Loading…
Cancel
Save