diff --git a/app/src/androidTest/java/org/tasks/jobs/NotificationQueueTest.java b/app/src/androidTest/java/org/tasks/jobs/NotificationQueueTest.java index 0d07efcfb..fcb3209e9 100644 --- a/app/src/androidTest/java/org/tasks/jobs/NotificationQueueTest.java +++ b/app/src/androidTest/java/org/tasks/jobs/NotificationQueueTest.java @@ -70,6 +70,26 @@ public class NotificationQueueTest { }); } + @Test + public void alarmAndReminderSameTimeDifferentId() { + long now = currentTimeMillis(); + + queue.add(new AlarmEntry(1, 2, now)); + queue.add(new ReminderEntry(1, now + 1000, TYPE_DUE)); + + verify(workManager).scheduleNotification(now); + + Freeze.freezeAt(now) + .thawAfter( + new Snippet() { + { + assertEquals( + newHashSet(new AlarmEntry(1, 2, now), new ReminderEntry(1, now + 1000, TYPE_DUE)), + newHashSet(queue.getOverdueJobs())); + } + }); + } + @Test public void removeAlarmLeaveReminder() { long now = currentTimeMillis(); diff --git a/app/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java b/app/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java index 77bc46d1d..eb175c035 100644 --- a/app/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java +++ b/app/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java @@ -14,7 +14,7 @@ import org.tasks.Notifier; import org.tasks.data.Location; import org.tasks.data.LocationDao; import org.tasks.injection.InjectingJobIntentService; -import org.tasks.injection.IntentServiceComponent; +import org.tasks.injection.ServiceComponent; import timber.log.Timber; public class GeofenceTransitionsIntentService extends InjectingJobIntentService { @@ -45,7 +45,7 @@ public class GeofenceTransitionsIntentService extends InjectingJobIntentService } @Override - protected void inject(IntentServiceComponent component) { + protected void inject(ServiceComponent component) { component.inject(this); } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 72d3a4118..00c87c46c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + @@ -432,6 +433,10 @@ android:value=".dashclock.DashClockSettings"/> + + doWork(intent)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .doFinally(this::stop) + .subscribe(); + + return Service.START_NOT_STICKY; + } + + private void stop() { + stopForeground(true); + stopSelf(); + } + + protected abstract int getNotificationId(); + + protected abstract Notification getNotification(); + + protected abstract void doWork(@Nonnull Intent intent); + + protected abstract void inject(ServiceComponent component); +} diff --git a/app/src/main/java/org/tasks/injection/JobComponent.java b/app/src/main/java/org/tasks/injection/JobComponent.java index 256df042e..8560521a4 100644 --- a/app/src/main/java/org/tasks/injection/JobComponent.java +++ b/app/src/main/java/org/tasks/injection/JobComponent.java @@ -6,7 +6,6 @@ import org.tasks.jobs.BackupWork; import org.tasks.jobs.CleanupWork; import org.tasks.jobs.DriveUploader; import org.tasks.jobs.MidnightRefreshWork; -import org.tasks.jobs.NotificationWork; import org.tasks.jobs.RefreshWork; import org.tasks.jobs.SyncWork; @@ -15,8 +14,6 @@ public interface JobComponent { void inject(SyncWork syncWork); - void inject(NotificationWork notificationWork); - void inject(BackupWork backupWork); void inject(RefreshWork refreshWork); diff --git a/app/src/main/java/org/tasks/injection/IntentServiceComponent.java b/app/src/main/java/org/tasks/injection/ServiceComponent.java similarity index 82% rename from app/src/main/java/org/tasks/injection/IntentServiceComponent.java rename to app/src/main/java/org/tasks/injection/ServiceComponent.java index 852f42e67..735268b90 100644 --- a/app/src/main/java/org/tasks/injection/IntentServiceComponent.java +++ b/app/src/main/java/org/tasks/injection/ServiceComponent.java @@ -1,6 +1,7 @@ package org.tasks.injection; import dagger.Subcomponent; +import org.tasks.jobs.NotificationService; import org.tasks.locale.receiver.TaskerIntentService; import org.tasks.location.GeofenceTransitionsIntentService; import org.tasks.scheduling.BackgroundScheduler; @@ -8,8 +9,8 @@ import org.tasks.scheduling.CalendarNotificationIntentService; import org.tasks.scheduling.GeofenceSchedulingIntentService; import org.tasks.scheduling.NotificationSchedulerIntentService; -@Subcomponent(modules = IntentServiceModule.class) -public interface IntentServiceComponent { +@Subcomponent(modules = ServiceModule.class) +public interface ServiceComponent { void inject(GeofenceSchedulingIntentService geofenceSchedulingIntentService); @@ -22,4 +23,6 @@ public interface IntentServiceComponent { void inject(BackgroundScheduler backgroundScheduler); void inject(TaskerIntentService taskerIntentService); + + void inject(NotificationService notificationService); } diff --git a/app/src/main/java/org/tasks/injection/IntentServiceModule.java b/app/src/main/java/org/tasks/injection/ServiceModule.java similarity index 62% rename from app/src/main/java/org/tasks/injection/IntentServiceModule.java rename to app/src/main/java/org/tasks/injection/ServiceModule.java index 1c97a2043..abbc6a28a 100644 --- a/app/src/main/java/org/tasks/injection/IntentServiceModule.java +++ b/app/src/main/java/org/tasks/injection/ServiceModule.java @@ -3,4 +3,4 @@ package org.tasks.injection; import dagger.Module; @Module -public class IntentServiceModule {} +public class ServiceModule {} diff --git a/app/src/main/java/org/tasks/jobs/NotificationQueue.java b/app/src/main/java/org/tasks/jobs/NotificationQueue.java index a18a46004..45410b8dc 100644 --- a/app/src/main/java/org/tasks/jobs/NotificationQueue.java +++ b/app/src/main/java/org/tasks/jobs/NotificationQueue.java @@ -11,8 +11,6 @@ import com.google.common.primitives.Ints; import java.util.Collections; import java.util.List; import javax.inject.Inject; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.tasks.injection.ApplicationScope; import org.tasks.preferences.Preferences; import org.tasks.time.DateTime; @@ -36,12 +34,11 @@ public class NotificationQueue { } public synchronized void add(Iterable entries) { - boolean reschedule = false; + long originalFirstTime = firstTime(); for (T entry : filter(entries, notNull())) { - reschedule |= jobs.isEmpty() || entry.getTime() < firstTime(); jobs.put(entry.getTime(), entry); } - if (reschedule) { + if (originalFirstTime != firstTime()) { scheduleNext(true); } } @@ -59,16 +56,12 @@ public class NotificationQueue { cancel(ReminderEntry.class, taskId); } - private synchronized void cancel(Class c, long id) { - boolean reschedule = false; + private void cancel(Class c, long id) { long firstTime = firstTime(); - List existing = - newArrayList(filter(jobs.values(), r -> r.getClass().equals(c) && r.getId() == id)); - for (NotificationQueueEntry entry : existing) { - reschedule |= entry.getTime() == firstTime; - jobs.remove(entry.getTime(), entry); - } - if (reschedule) { + + remove(newArrayList(filter(jobs.values(), r -> r.getClass().equals(c) && r.getId() == id))); + + if (firstTime != firstTime()) { scheduleNext(true); } } diff --git a/app/src/main/java/org/tasks/jobs/NotificationService.java b/app/src/main/java/org/tasks/jobs/NotificationService.java new file mode 100644 index 000000000..020cbc2a2 --- /dev/null +++ b/app/src/main/java/org/tasks/jobs/NotificationService.java @@ -0,0 +1,71 @@ +package org.tasks.jobs; + +import android.app.Notification; +import android.content.Intent; +import android.os.IBinder; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import java.util.List; +import javax.annotation.Nonnull; +import javax.inject.Inject; +import org.tasks.BuildConfig; +import org.tasks.Notifier; +import org.tasks.R; +import org.tasks.analytics.Tracker; +import org.tasks.injection.InjectingService; +import org.tasks.injection.ServiceComponent; +import org.tasks.notifications.NotificationManager; +import org.tasks.preferences.Preferences; + +public class NotificationService extends InjectingService { + + @Inject Preferences preferences; + @Inject Notifier notifier; + @Inject NotificationQueue notificationQueue; + @Inject Tracker tracker; + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + protected int getNotificationId() { + return -1; + } + + @Override + protected Notification getNotification() { + return new NotificationCompat.Builder( + this, NotificationManager.NOTIFICATION_CHANNEL_MISCELLANEOUS) + .setSound(null) + .setSmallIcon(R.drawable.ic_check_white_24dp) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.building_notifications)) + .build(); + } + + @Override + protected void doWork(@Nonnull Intent intent) { + try { + if (!preferences.isCurrentlyQuietHours()) { + List overdueJobs = notificationQueue.getOverdueJobs(); + notifier.triggerTaskNotifications(overdueJobs); + boolean success = notificationQueue.remove(overdueJobs); + if (BuildConfig.DEBUG && !success) { + throw new RuntimeException("Failed to remove jobs from queue"); + } + } + } catch (Exception e) { + tracker.reportException(e); + } finally { + notificationQueue.scheduleNext(); + } + } + + @Override + protected void inject(ServiceComponent component) { + component.inject(this); + } +} diff --git a/app/src/main/java/org/tasks/jobs/NotificationWork.java b/app/src/main/java/org/tasks/jobs/NotificationWork.java deleted file mode 100644 index 18c2c584f..000000000 --- a/app/src/main/java/org/tasks/jobs/NotificationWork.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.tasks.jobs; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.work.WorkerParameters; -import java.util.List; -import javax.inject.Inject; -import org.tasks.BuildConfig; -import org.tasks.Notifier; -import org.tasks.injection.JobComponent; -import org.tasks.preferences.Preferences; - -public class NotificationWork extends RepeatingWorker { - - @Inject Preferences preferences; - @Inject Notifier notifier; - @Inject NotificationQueue notificationQueue; - - public NotificationWork(@NonNull Context context, @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result run() { - if (!preferences.isCurrentlyQuietHours()) { - List overdueJobs = notificationQueue.getOverdueJobs(); - notifier.triggerTaskNotifications(overdueJobs); - boolean success = notificationQueue.remove(overdueJobs); - if (BuildConfig.DEBUG && !success) { - throw new RuntimeException("Failed to remove jobs from queue"); - } - } - return Result.SUCCESS; - } - - @Override - protected void inject(JobComponent component) { - component.inject(this); - } - - @Override - protected void scheduleNext() { - notificationQueue.scheduleNext(); - } -} diff --git a/app/src/main/java/org/tasks/jobs/WorkManager.java b/app/src/main/java/org/tasks/jobs/WorkManager.java index 14e9712df..a015dba39 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManager.java +++ b/app/src/main/java/org/tasks/jobs/WorkManager.java @@ -1,10 +1,18 @@ package org.tasks.jobs; +import static com.todoroo.andlib.utility.AndroidUtilities.atLeastKitKat; +import static com.todoroo.andlib.utility.AndroidUtilities.atLeastMarshmallow; +import static com.todoroo.andlib.utility.AndroidUtilities.atLeastOreo; import static com.todoroo.andlib.utility.DateUtilities.now; import static org.tasks.date.DateTimeUtils.midnight; import static org.tasks.date.DateTimeUtils.newDateTime; +import static org.tasks.time.DateTimeUtils.currentTimeMillis; import static org.tasks.time.DateTimeUtils.printTimestamp; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; import android.net.Uri; import androidx.work.BackoffPolicy; import androidx.work.Constraints; @@ -27,6 +35,7 @@ import org.tasks.R; import org.tasks.data.CaldavDao; import org.tasks.data.GoogleTaskListDao; import org.tasks.injection.ApplicationScope; +import org.tasks.injection.ForApplication; import org.tasks.preferences.Preferences; import timber.log.Timber; @@ -36,21 +45,27 @@ public class WorkManager { private static final String TAG_BACKUP = "tag_backup"; private static final String TAG_REFRESH = "tag_refresh"; private static final String TAG_MIDNIGHT_REFRESH = "tag_midnight_refresh"; - private static final String TAG_NOTIFICATION = "tag_notification"; private static final String TAG_SYNC = "tag_sync"; private static final String TAG_BACKGROUND_SYNC = "tag_background_sync"; + private final Context context; private final Preferences preferences; private final GoogleTaskListDao googleTaskListDao; private final CaldavDao caldavDao; + private final AlarmManager alarmManager; private androidx.work.WorkManager workManager; @Inject public WorkManager( - Preferences preferences, GoogleTaskListDao googleTaskListDao, CaldavDao caldavDao) { + @ForApplication Context context, + Preferences preferences, + GoogleTaskListDao googleTaskListDao, + CaldavDao caldavDao) { + this.context = context; this.preferences = preferences; this.googleTaskListDao = googleTaskListDao; this.caldavDao = caldavDao; + alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); } public void init() { @@ -138,7 +153,23 @@ public class WorkManager { @SuppressWarnings("WeakerAccess") public void scheduleNotification(long time) { - enqueueUnique(TAG_NOTIFICATION, NotificationWork.class, time); + if (time < currentTimeMillis()) { + Intent intent = getNotificationIntent(); + if (atLeastOreo()) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } else { + PendingIntent pendingIntent = getNotificationPendingIntent(); + if (atLeastMarshmallow()) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pendingIntent); + } else if (atLeastKitKat()) { + alarmManager.setExact(AlarmManager.RTC_WAKEUP, time, pendingIntent); + } else { + alarmManager.set(AlarmManager.RTC_WAKEUP, time, pendingIntent); + } + } } void scheduleBackup() { @@ -188,7 +219,7 @@ public class WorkManager { @SuppressWarnings("WeakerAccess") public void cancelNotifications() { Timber.d("cancelNotifications"); - workManager.cancelAllWorkByTag(TAG_NOTIFICATION); + alarmManager.cancel(getNotificationPendingIntent()); } public void onStartup() { @@ -196,4 +227,15 @@ public class WorkManager { scheduleMidnightRefresh(); scheduleBackup(); } + + private Intent getNotificationIntent() { + return new Intent(context, NotificationService.class); + } + + private PendingIntent getNotificationPendingIntent() { + Intent intent = getNotificationIntent(); + return atLeastOreo() + ? PendingIntent.getForegroundService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + : PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } } diff --git a/app/src/main/java/org/tasks/locale/receiver/TaskerIntentService.java b/app/src/main/java/org/tasks/locale/receiver/TaskerIntentService.java index 6e4ff04df..ad14d8d4b 100644 --- a/app/src/main/java/org/tasks/locale/receiver/TaskerIntentService.java +++ b/app/src/main/java/org/tasks/locale/receiver/TaskerIntentService.java @@ -11,7 +11,7 @@ import org.tasks.analytics.Tracker; import org.tasks.analytics.Tracking; import org.tasks.injection.ForApplication; import org.tasks.injection.InjectingJobIntentService; -import org.tasks.injection.IntentServiceComponent; +import org.tasks.injection.ServiceComponent; import org.tasks.locale.bundle.ListNotificationBundle; import org.tasks.locale.bundle.TaskCreationBundle; import org.tasks.preferences.DefaultFilterProvider; @@ -49,7 +49,7 @@ public class TaskerIntentService extends InjectingJobIntentService { } @Override - protected void inject(IntentServiceComponent component) { + protected void inject(ServiceComponent component) { component.inject(this); } } diff --git a/app/src/main/java/org/tasks/notifications/NotificationManager.java b/app/src/main/java/org/tasks/notifications/NotificationManager.java index 054638661..cb0a6fcf3 100644 --- a/app/src/main/java/org/tasks/notifications/NotificationManager.java +++ b/app/src/main/java/org/tasks/notifications/NotificationManager.java @@ -58,6 +58,7 @@ public class NotificationManager { public static final String NOTIFICATION_CHANNEL_DEFAULT = "notifications"; public static final String NOTIFICATION_CHANNEL_TASKER = "notifications_tasker"; public static final String NOTIFICATION_CHANNEL_TIMERS = "notifications_timers"; + public static final String NOTIFICATION_CHANNEL_MISCELLANEOUS = "notifications_miscellaneous"; public static final int MAX_NOTIFICATIONS = 40; static final String EXTRA_NOTIFICATION_ID = "extra_notification_id"; static final int SUMMARY_NOTIFICATION_ID = 0; @@ -92,25 +93,30 @@ public class NotificationManager { android.app.NotificationManager notificationManager = (android.app.NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.createNotificationChannel( - createNotificationChannel(NOTIFICATION_CHANNEL_DEFAULT, R.string.notifications)); + createNotificationChannel(NOTIFICATION_CHANNEL_DEFAULT, R.string.notifications, true)); notificationManager.createNotificationChannel( - createNotificationChannel(NOTIFICATION_CHANNEL_TASKER, R.string.tasker_locale)); + createNotificationChannel(NOTIFICATION_CHANNEL_TASKER, R.string.tasker_locale, true)); notificationManager.createNotificationChannel( - createNotificationChannel(NOTIFICATION_CHANNEL_TIMERS, R.string.TEA_timer_controls)); + createNotificationChannel(NOTIFICATION_CHANNEL_TIMERS, R.string.TEA_timer_controls, true)); + notificationManager.createNotificationChannel( + createNotificationChannel(NOTIFICATION_CHANNEL_MISCELLANEOUS, R.string.miscellaneous, false)); } } @TargetApi(Build.VERSION_CODES.O) - private NotificationChannel createNotificationChannel(String channelId, int nameResId) { + private NotificationChannel createNotificationChannel( + String channelId, int nameResId, boolean alert) { String channelName = context.getString(nameResId); + int importance = + alert + ? android.app.NotificationManager.IMPORTANCE_HIGH + : android.app.NotificationManager.IMPORTANCE_LOW; NotificationChannel notificationChannel = - new NotificationChannel( - channelId, channelName, android.app.NotificationManager.IMPORTANCE_HIGH); - notificationChannel.enableLights(true); - notificationChannel.enableVibration(true); - notificationChannel.setBypassDnd(true); - notificationChannel.setShowBadge(true); - notificationChannel.setImportance(android.app.NotificationManager.IMPORTANCE_HIGH); + new NotificationChannel(channelId, channelName, importance); + notificationChannel.enableLights(alert); + notificationChannel.enableVibration(alert); + notificationChannel.setBypassDnd(alert); + notificationChannel.setShowBadge(alert); return notificationChannel; } diff --git a/app/src/main/java/org/tasks/scheduling/BackgroundScheduler.java b/app/src/main/java/org/tasks/scheduling/BackgroundScheduler.java index aeec6096a..189700607 100644 --- a/app/src/main/java/org/tasks/scheduling/BackgroundScheduler.java +++ b/app/src/main/java/org/tasks/scheduling/BackgroundScheduler.java @@ -9,7 +9,7 @@ import javax.inject.Inject; import org.tasks.files.FileHelper; import org.tasks.injection.ForApplication; import org.tasks.injection.InjectingJobIntentService; -import org.tasks.injection.IntentServiceComponent; +import org.tasks.injection.ServiceComponent; import org.tasks.jobs.WorkManager; import org.tasks.preferences.Preferences; import timber.log.Timber; @@ -46,7 +46,7 @@ public class BackgroundScheduler extends InjectingJobIntentService { } @Override - protected void inject(IntentServiceComponent component) { + protected void inject(ServiceComponent component) { component.inject(this); } } diff --git a/app/src/main/java/org/tasks/scheduling/CalendarNotificationIntentService.java b/app/src/main/java/org/tasks/scheduling/CalendarNotificationIntentService.java index d042b6e72..5427a640e 100644 --- a/app/src/main/java/org/tasks/scheduling/CalendarNotificationIntentService.java +++ b/app/src/main/java/org/tasks/scheduling/CalendarNotificationIntentService.java @@ -15,7 +15,7 @@ import org.tasks.calendars.AndroidCalendarEvent; import org.tasks.calendars.CalendarEventProvider; import org.tasks.injection.ForApplication; import org.tasks.injection.InjectingJobIntentService; -import org.tasks.injection.IntentServiceComponent; +import org.tasks.injection.ServiceComponent; import org.tasks.preferences.Preferences; import timber.log.Timber; @@ -78,7 +78,7 @@ public class CalendarNotificationIntentService extends RecurringIntervalIntentSe } @Override - protected void inject(IntentServiceComponent component) { + protected void inject(ServiceComponent component) { component.inject(this); } diff --git a/app/src/main/java/org/tasks/scheduling/GeofenceSchedulingIntentService.java b/app/src/main/java/org/tasks/scheduling/GeofenceSchedulingIntentService.java index 20e899ff9..e55f9e061 100644 --- a/app/src/main/java/org/tasks/scheduling/GeofenceSchedulingIntentService.java +++ b/app/src/main/java/org/tasks/scheduling/GeofenceSchedulingIntentService.java @@ -5,7 +5,7 @@ import android.content.Intent; import androidx.core.app.JobIntentService; import javax.inject.Inject; import org.tasks.injection.InjectingJobIntentService; -import org.tasks.injection.IntentServiceComponent; +import org.tasks.injection.ServiceComponent; import org.tasks.location.GeofenceService; import timber.log.Timber; @@ -30,7 +30,7 @@ public class GeofenceSchedulingIntentService extends InjectingJobIntentService { } @Override - protected void inject(IntentServiceComponent component) { + protected void inject(ServiceComponent component) { component.inject(this); } } diff --git a/app/src/main/java/org/tasks/scheduling/NotificationSchedulerIntentService.java b/app/src/main/java/org/tasks/scheduling/NotificationSchedulerIntentService.java index 75129b00c..c5ee9b5bd 100644 --- a/app/src/main/java/org/tasks/scheduling/NotificationSchedulerIntentService.java +++ b/app/src/main/java/org/tasks/scheduling/NotificationSchedulerIntentService.java @@ -7,7 +7,7 @@ import com.todoroo.astrid.alarms.AlarmService; import com.todoroo.astrid.reminders.ReminderService; import javax.inject.Inject; import org.tasks.injection.InjectingJobIntentService; -import org.tasks.injection.IntentServiceComponent; +import org.tasks.injection.ServiceComponent; import org.tasks.jobs.NotificationQueue; import org.tasks.notifications.NotificationManager; import timber.log.Timber; @@ -46,7 +46,7 @@ public class NotificationSchedulerIntentService extends InjectingJobIntentServic } @Override - protected void inject(IntentServiceComponent component) { + protected void inject(ServiceComponent component) { component.inject(this); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a32897182..7dc4a0211 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -876,4 +876,5 @@ File %1$s contained %2$s.\n\n Open %s Arrived at %s Departed %s + Generating notifications