From 29226dd93e9dfcf72ec80e476331d1bcc43c6e05 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miko=C5=82aj=20Pich?= <m.pich@outlook.com>
Date: Wed, 20 May 2020 15:11:01 +0200
Subject: [PATCH] Add notification about upcoming lesson (#578)

---
 .../timetable/TimetableRepositoryTest.kt      |  20 ++-
 app/src/main/AndroidManifest.xml              |   2 +
 .../github/wulkanowy/data/RepositoryModule.kt |   2 +
 .../preferences/PreferencesRepository.kt      |   4 +
 .../timetable/TimetableRepository.kt          |  10 +-
 .../io/github/wulkanowy/di/BindingModule.kt   |   4 +
 .../wulkanowy/services/ServicesModule.kt      |  11 ++
 .../alarm/TimetableNotificationReceiver.kt    | 117 ++++++++++++++++++
 .../TimetableNotificationSchedulerHelper.kt   | 109 ++++++++++++++++
 .../sync/channels/UpcomingLessonsChannel.kt   |  31 +++++
 .../ui/modules/settings/SettingsPresenter.kt  |   6 +-
 .../github/wulkanowy/utils/TimeExtension.kt   |   8 ++
 .../res/drawable-hdpi/ic_stat_timetable.png   | Bin 0 -> 312 bytes
 .../res/drawable-mdpi/ic_stat_timetable.png   | Bin 0 -> 275 bytes
 .../res/drawable-xhdpi/ic_stat_timetable.png  | Bin 0 -> 358 bytes
 .../res/drawable-xxhdpi/ic_stat_timetable.png | Bin 0 -> 459 bytes
 .../drawable-xxxhdpi/ic_stat_timetable.png    | Bin 0 -> 659 bytes
 app/src/main/res/values-pl/strings.xml        |   5 +
 .../main/res/values/preferences_defaults.xml  |   1 +
 app/src/main/res/values/preferences_keys.xml  |   1 +
 app/src/main/res/values/strings.xml           |   5 +
 app/src/main/res/xml/scheme_preferences.xml   |   5 +
 22 files changed, 333 insertions(+), 8 deletions(-)
 create mode 100644 app/src/main/java/io/github/wulkanowy/services/alarm/TimetableNotificationReceiver.kt
 create mode 100644 app/src/main/java/io/github/wulkanowy/services/alarm/TimetableNotificationSchedulerHelper.kt
 create mode 100644 app/src/main/java/io/github/wulkanowy/services/sync/channels/UpcomingLessonsChannel.kt
 create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_timetable.png
 create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_timetable.png
 create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_timetable.png
 create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_timetable.png
 create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_stat_timetable.png

diff --git a/app/src/androidTest/java/io/github/wulkanowy/data/repositories/timetable/TimetableRepositoryTest.kt b/app/src/androidTest/java/io/github/wulkanowy/data/repositories/timetable/TimetableRepositoryTest.kt
index fdf193a26..75f2f0b83 100644
--- a/app/src/androidTest/java/io/github/wulkanowy/data/repositories/timetable/TimetableRepositoryTest.kt
+++ b/app/src/androidTest/java/io/github/wulkanowy/data/repositories/timetable/TimetableRepositoryTest.kt
@@ -8,12 +8,15 @@ import androidx.test.filters.SdkSuppress
 import com.github.pwittchen.reactivenetwork.library.rx2.internet.observing.InternetObservingSettings
 import io.github.wulkanowy.data.db.AppDatabase
 import io.github.wulkanowy.data.db.entities.Semester
+import io.github.wulkanowy.data.db.entities.Student
 import io.github.wulkanowy.data.repositories.TestInternetObservingStrategy
 import io.github.wulkanowy.data.repositories.getStudent
+import io.github.wulkanowy.services.alarm.TimetableNotificationSchedulerHelper
 import io.github.wulkanowy.sdk.Sdk
 import io.mockk.MockKAnnotations
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
 import io.reactivex.Single
 import org.junit.After
 import org.junit.Before
@@ -34,11 +37,17 @@ class TimetableRepositoryTest {
         .strategy(TestInternetObservingStrategy())
         .build()
 
+    @MockK
+    private lateinit var studentMock: Student
+
     private val student = getStudent()
 
     @MockK
     private lateinit var semesterMock: Semester
 
+    @MockK
+    private lateinit var timetableNotificationSchedulerHelper: TimetableNotificationSchedulerHelper
+
     private lateinit var timetableRemote: TimetableRemote
 
     private lateinit var timetableLocal: TimetableLocal
@@ -52,10 +61,17 @@ class TimetableRepositoryTest {
         timetableLocal = TimetableLocal(testDb.timetableDao)
         timetableRemote = TimetableRemote(mockSdk)
 
+        every { timetableNotificationSchedulerHelper.scheduleNotifications(any(), any()) } returns mockk()
+        every { timetableNotificationSchedulerHelper.cancelScheduled(any(), any()) } returns mockk()
+
+        every { studentMock.studentId } returns 1
+        every { studentMock.studentName } returns "Jan Kowalski"
+
         every { semesterMock.studentId } returns 1
         every { semesterMock.diaryId } returns 2
         every { semesterMock.schoolYear } returns 2019
         every { semesterMock.semesterId } returns 1
+
         every { mockSdk.switchDiary(any(), any()) } returns mockSdk
     }
 
@@ -80,7 +96,7 @@ class TimetableRepositoryTest {
             createTimetableRemote(of(2019, 3, 5, 10, 30), 4, "", "W-F")
         ))
 
-        val lessons = TimetableRepository(settings, timetableLocal, timetableRemote)
+        val lessons = TimetableRepository(settings, timetableLocal, timetableRemote, timetableNotificationSchedulerHelper)
             .getTimetable(student, semesterMock, LocalDate.of(2019, 3, 5), LocalDate.of(2019, 3, 5), true)
             .blockingGet()
 
@@ -126,7 +142,7 @@ class TimetableRepositoryTest {
             createTimetableRemote(of(2019, 12, 25, 10, 40), 4, "126", "Matematyka", "Paweł Czwartkowski", true)
         ))
 
-        val lessons = TimetableRepository(settings, timetableLocal, timetableRemote)
+        val lessons = TimetableRepository(settings, timetableLocal, timetableRemote, timetableNotificationSchedulerHelper)
             .getTimetable(student, semesterMock, LocalDate.of(2019, 12, 23), LocalDate.of(2019, 12, 25), true)
             .blockingGet()
 
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4dd70721e..4ec2f7816 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -92,6 +92,8 @@
                 android:resource="@xml/provider_widget_lucky_number" />
         </receiver>
 
+        <receiver android:name=".services.alarm.TimetableNotificationReceiver" />
+
         <provider
             android:name="androidx.work.impl.WorkManagerInitializer"
             android:authorities="${applicationId}.workmanager-init"
diff --git a/app/src/main/java/io/github/wulkanowy/data/RepositoryModule.kt b/app/src/main/java/io/github/wulkanowy/data/RepositoryModule.kt
index 43c27c529..9540372fc 100644
--- a/app/src/main/java/io/github/wulkanowy/data/RepositoryModule.kt
+++ b/app/src/main/java/io/github/wulkanowy/data/RepositoryModule.kt
@@ -1,9 +1,11 @@
 package io.github.wulkanowy.data
 
+import android.app.AlarmManager
 import android.content.Context
 import android.content.SharedPreferences
 import android.content.res.AssetManager
 import android.content.res.Resources
+import androidx.core.content.getSystemService
 import androidx.preference.PreferenceManager
 import com.github.pwittchen.reactivenetwork.library.rx2.internet.observing.InternetObservingSettings
 import com.github.pwittchen.reactivenetwork.library.rx2.internet.observing.strategy.WalledGardenInternetObservingStrategy
diff --git a/app/src/main/java/io/github/wulkanowy/data/repositories/preferences/PreferencesRepository.kt b/app/src/main/java/io/github/wulkanowy/data/repositories/preferences/PreferencesRepository.kt
index b7a539925..b916bf96f 100644
--- a/app/src/main/java/io/github/wulkanowy/data/repositories/preferences/PreferencesRepository.kt
+++ b/app/src/main/java/io/github/wulkanowy/data/repositories/preferences/PreferencesRepository.kt
@@ -55,6 +55,10 @@ class PreferencesRepository @Inject constructor(
     val isNotificationsEnable: Boolean
         get() = getBoolean(R.string.pref_key_notifications_enable, R.bool.pref_default_notifications_enable)
 
+    val isUpcomingLessonsNotificationsEnableKey = context.getString(R.string.pref_key_notifications_upcoming_lessons_enable)
+    val isUpcomingLessonsNotificationsEnable: Boolean
+        get() = getBoolean(isUpcomingLessonsNotificationsEnableKey, R.bool.pref_default_notification_upcoming_lessons_enable)
+
     val isDebugNotificationEnableKey = context.getString(R.string.pref_key_notification_debug)
     val isDebugNotificationEnable: Boolean
         get() = getBoolean(isDebugNotificationEnableKey, R.bool.pref_default_notification_debug)
diff --git a/app/src/main/java/io/github/wulkanowy/data/repositories/timetable/TimetableRepository.kt b/app/src/main/java/io/github/wulkanowy/data/repositories/timetable/TimetableRepository.kt
index 42812b30f..082b03cf8 100644
--- a/app/src/main/java/io/github/wulkanowy/data/repositories/timetable/TimetableRepository.kt
+++ b/app/src/main/java/io/github/wulkanowy/data/repositories/timetable/TimetableRepository.kt
@@ -5,6 +5,7 @@ import com.github.pwittchen.reactivenetwork.library.rx2.internet.observing.Inter
 import io.github.wulkanowy.data.db.entities.Semester
 import io.github.wulkanowy.data.db.entities.Student
 import io.github.wulkanowy.data.db.entities.Timetable
+import io.github.wulkanowy.services.alarm.TimetableNotificationSchedulerHelper
 import io.github.wulkanowy.utils.friday
 import io.github.wulkanowy.utils.monday
 import io.github.wulkanowy.utils.uniqueSubtract
@@ -18,7 +19,8 @@ import javax.inject.Singleton
 class TimetableRepository @Inject constructor(
     private val settings: InternetObservingSettings,
     private val local: TimetableLocal,
-    private val remote: TimetableRemote
+    private val remote: TimetableRemote,
+    private val schedulerHelper: TimetableNotificationSchedulerHelper
 ) {
 
     fun getTimetable(student: Student, semester: Semester, start: LocalDate, end: LocalDate, forceRefresh: Boolean = false): Single<List<Timetable>> {
@@ -31,8 +33,8 @@ class TimetableRepository @Inject constructor(
                     local.getTimetable(semester, monday, friday)
                         .toSingle(emptyList())
                         .doOnSuccess { old ->
-                            local.deleteTimetable(old.uniqueSubtract(new))
-                            local.saveTimetable(new.uniqueSubtract(old).map { item ->
+                            local.deleteTimetable(old.uniqueSubtract(new).also { schedulerHelper.cancelScheduled(it) })
+                            local.saveTimetable(new.uniqueSubtract(old).also { schedulerHelper.scheduleNotifications(it, student) }.map { item ->
                                 item.also { new ->
                                     old.singleOrNull { new.start == it.start }?.let { old ->
                                         return@map new.copy(
@@ -45,7 +47,7 @@ class TimetableRepository @Inject constructor(
                         }
                 }.flatMap {
                     local.getTimetable(semester, monday, friday).toSingle(emptyList())
-                }).map { list -> list.filter { it.date in start..end } }
+                }).map { list -> list.filter { it.date in start..end }.also { schedulerHelper.scheduleNotifications(it, student) } }
         }
     }
 }
diff --git a/app/src/main/java/io/github/wulkanowy/di/BindingModule.kt b/app/src/main/java/io/github/wulkanowy/di/BindingModule.kt
index ba8c78d3f..1b462964d 100644
--- a/app/src/main/java/io/github/wulkanowy/di/BindingModule.kt
+++ b/app/src/main/java/io/github/wulkanowy/di/BindingModule.kt
@@ -4,6 +4,7 @@ import dagger.Module
 import dagger.android.ContributesAndroidInjector
 import io.github.wulkanowy.di.scopes.PerActivity
 import io.github.wulkanowy.ui.base.ErrorDialog
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver
 import io.github.wulkanowy.ui.modules.login.LoginActivity
 import io.github.wulkanowy.ui.modules.login.LoginModule
 import io.github.wulkanowy.ui.modules.luckynumberwidget.LuckyNumberWidgetConfigureActivity
@@ -48,4 +49,7 @@ internal abstract class BindingModule {
 
     @ContributesAndroidInjector
     abstract fun bindLuckyNumberWidgetProvider(): LuckyNumberWidgetProvider
+
+    @ContributesAndroidInjector
+    abstract fun bindTimetableNotificationReceiver(): TimetableNotificationReceiver
 }
diff --git a/app/src/main/java/io/github/wulkanowy/services/ServicesModule.kt b/app/src/main/java/io/github/wulkanowy/services/ServicesModule.kt
index c7c573e27..b87f0e683 100644
--- a/app/src/main/java/io/github/wulkanowy/services/ServicesModule.kt
+++ b/app/src/main/java/io/github/wulkanowy/services/ServicesModule.kt
@@ -1,7 +1,9 @@
 package io.github.wulkanowy.services
 
+import android.app.AlarmManager
 import android.content.Context
 import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.getSystemService
 import androidx.work.WorkManager
 import com.squareup.inject.assisted.dagger2.AssistedModule
 import dagger.Binds
@@ -15,6 +17,7 @@ import io.github.wulkanowy.services.sync.channels.LuckyNumberChannel
 import io.github.wulkanowy.services.sync.channels.NewGradesChannel
 import io.github.wulkanowy.services.sync.channels.NewMessagesChannel
 import io.github.wulkanowy.services.sync.channels.NewNotesChannel
+import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel
 import io.github.wulkanowy.services.sync.channels.PushChannel
 import io.github.wulkanowy.services.sync.works.AttendanceSummaryWork
 import io.github.wulkanowy.services.sync.works.AttendanceWork
@@ -46,6 +49,10 @@ abstract class ServicesModule {
         @Singleton
         @Provides
         fun provideNotificationManager(context: Context) = NotificationManagerCompat.from(context)
+
+        @Singleton
+        @Provides
+        fun provideAlarmManager(context: Context): AlarmManager = context.getSystemService()!!
     }
 
     @ContributesAndroidInjector
@@ -126,4 +133,8 @@ abstract class ServicesModule {
     @Binds
     @IntoSet
     abstract fun providePushChannel(channel: PushChannel): Channel
+
+    @Binds
+    @IntoSet
+    abstract fun provideUpcomingLessonsChannel(channel: UpcomingLessonsChannel): Channel
 }
diff --git a/app/src/main/java/io/github/wulkanowy/services/alarm/TimetableNotificationReceiver.kt b/app/src/main/java/io/github/wulkanowy/services/alarm/TimetableNotificationReceiver.kt
new file mode 100644
index 000000000..0130f4673
--- /dev/null
+++ b/app/src/main/java/io/github/wulkanowy/services/alarm/TimetableNotificationReceiver.kt
@@ -0,0 +1,117 @@
+package io.github.wulkanowy.services.alarm
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_UPDATE_CURRENT
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Build.VERSION_CODES.N
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import dagger.android.AndroidInjection
+import io.github.wulkanowy.R
+import io.github.wulkanowy.data.repositories.student.StudentRepository
+import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel.Companion.CHANNEL_ID
+import io.github.wulkanowy.ui.modules.main.MainActivity
+import io.github.wulkanowy.ui.modules.main.MainView
+import io.github.wulkanowy.utils.SchedulersProvider
+import io.github.wulkanowy.utils.getCompatColor
+import io.github.wulkanowy.utils.toLocalDateTime
+import timber.log.Timber
+import javax.inject.Inject
+
+class TimetableNotificationReceiver : BroadcastReceiver() {
+
+    @Inject
+    lateinit var studentRepository: StudentRepository
+
+    @Inject
+    lateinit var schedulers: SchedulersProvider
+
+    companion object {
+        const val NOTIFICATION_TYPE_CURRENT = 1
+        const val NOTIFICATION_TYPE_UPCOMING = 2
+        const val NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION = 3
+
+        const val NOTIFICATION_ID = "id"
+
+        const val STUDENT_NAME = "student_name"
+        const val STUDENT_ID = "student_id"
+        const val LESSON_TYPE = "type"
+        const val LESSON_TITLE = "title"
+        const val LESSON_ROOM = "room"
+        const val LESSON_NEXT_TITLE = "next_title"
+        const val LESSON_NEXT_ROOM = "next_room"
+        const val LESSON_START = "start_timestamp"
+        const val LESSON_END = "end_timestamp"
+    }
+
+    @SuppressLint("CheckResult")
+    override fun onReceive(context: Context, intent: Intent) {
+        Timber.d("Receiving intent... ${intent.toUri(0)}")
+        AndroidInjection.inject(this, context)
+
+        studentRepository.getCurrentStudent(false)
+            .subscribeOn(schedulers.backgroundThread)
+            .observeOn(schedulers.mainThread)
+            .subscribe({
+                val studentId = intent.getIntExtra(STUDENT_ID, 0)
+                if (it.studentId == studentId) prepareNotification(context, intent)
+                else Timber.d("Notification studentId($studentId) differs from current(${it.studentId})")
+            }, { Timber.e(it) })
+    }
+
+    private fun prepareNotification(context: Context, intent: Intent) {
+        val type = intent.getIntExtra(LESSON_TYPE, 0)
+        val notificationId = intent.getIntExtra(NOTIFICATION_ID, MainView.Section.TIMETABLE.id)
+
+        if (type == NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION) {
+            return NotificationManagerCompat.from(context).cancel(notificationId)
+        }
+
+        val studentId = intent.getIntExtra(STUDENT_ID, 0)
+        val studentName = intent.getStringExtra(STUDENT_NAME)
+
+        val subject = intent.getStringExtra(LESSON_TITLE)
+        val room = intent.getStringExtra(LESSON_ROOM)
+
+        val start = intent.getLongExtra(LESSON_START, 0)
+        val end = intent.getLongExtra(LESSON_END, 0)
+
+        val nextSubject = intent.getStringExtra(LESSON_NEXT_TITLE)
+        val nextRoom = intent.getStringExtra(LESSON_NEXT_ROOM)
+
+        Timber.d("TimetableNotification receive: type: $type, subject: $subject, start: ${start.toLocalDateTime()}, student: $studentId")
+
+        showNotification(context, notificationId, studentName,
+            if (type == NOTIFICATION_TYPE_CURRENT) end else start, end - start,
+            context.getString(if (type == NOTIFICATION_TYPE_CURRENT) R.string.timetable_now else R.string.timetable_next, "($room) $subject".removePrefix("()")),
+            nextSubject?.let { context.getString(R.string.timetable_later, "($nextRoom) $nextSubject".removePrefix("()")) }
+        )
+    }
+
+    private fun showNotification(context: Context, notificationId: Int, studentName: String?, countDown: Long, timeout: Long, title: String, next: String?) {
+        NotificationManagerCompat.from(context).notify(notificationId, NotificationCompat.Builder(context, CHANNEL_ID)
+            .setContentTitle(title)
+            .setContentText(next)
+            .setAutoCancel(false)
+            .setOngoing(true)
+            .setWhen(countDown)
+            .apply {
+                if (Build.VERSION.SDK_INT >= N) setUsesChronometer(true)
+            }
+            .setTimeoutAfter(timeout)
+            .setSmallIcon(R.drawable.ic_stat_timetable)
+            .setColor(context.getCompatColor(R.color.colorPrimary))
+            .setStyle(NotificationCompat.InboxStyle().also {
+                it.setSummaryText(studentName)
+                it.addLine(next)
+            })
+            .setContentIntent(PendingIntent.getActivity(context, MainView.Section.TIMETABLE.id,
+                MainActivity.getStartIntent(context, MainView.Section.TIMETABLE, true), FLAG_UPDATE_CURRENT))
+            .build()
+        )
+    }
+}
diff --git a/app/src/main/java/io/github/wulkanowy/services/alarm/TimetableNotificationSchedulerHelper.kt b/app/src/main/java/io/github/wulkanowy/services/alarm/TimetableNotificationSchedulerHelper.kt
new file mode 100644
index 000000000..5374c4767
--- /dev/null
+++ b/app/src/main/java/io/github/wulkanowy/services/alarm/TimetableNotificationSchedulerHelper.kt
@@ -0,0 +1,109 @@
+package io.github.wulkanowy.services.alarm
+
+import android.app.AlarmManager
+import android.app.AlarmManager.RTC_WAKEUP
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_CANCEL_CURRENT
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.AlarmManagerCompat
+import androidx.core.app.NotificationManagerCompat
+import io.github.wulkanowy.data.db.entities.Student
+import io.github.wulkanowy.data.db.entities.Timetable
+import io.github.wulkanowy.data.repositories.preferences.PreferencesRepository
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.LESSON_END
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.LESSON_NEXT_ROOM
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.LESSON_NEXT_TITLE
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.LESSON_ROOM
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.LESSON_START
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.LESSON_TITLE
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.LESSON_TYPE
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.NOTIFICATION_ID
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.NOTIFICATION_TYPE_CURRENT
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.NOTIFICATION_TYPE_UPCOMING
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.STUDENT_ID
+import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.STUDENT_NAME
+import io.github.wulkanowy.ui.modules.main.MainView
+import io.github.wulkanowy.utils.toTimestamp
+import org.threeten.bp.LocalDateTime
+import org.threeten.bp.LocalDateTime.now
+import timber.log.Timber
+import javax.inject.Inject
+
+class TimetableNotificationSchedulerHelper @Inject constructor(
+    private val context: Context,
+    private val alarmManager: AlarmManager,
+    private val preferencesRepository: PreferencesRepository
+) {
+
+    private fun getRequestCode(time: LocalDateTime, studentId: Int) = (time.toTimestamp() * studentId).toInt()
+
+    private fun getUpcomingLessonTime(index: Int, day: List<Timetable>, lesson: Timetable): LocalDateTime {
+        return day.getOrNull(index - 1)?.end ?: lesson.start.minusMinutes(30)
+    }
+
+    fun cancelScheduled(lessons: List<Timetable>, studentId: Int = 1) {
+        lessons.sortedBy { it.start }.forEachIndexed { index, lesson ->
+            val upcomingTime = getUpcomingLessonTime(index, lessons, lesson)
+            cancelScheduledTo(upcomingTime..lesson.start, getRequestCode(upcomingTime, studentId))
+            cancelScheduledTo(lesson.start..lesson.end, getRequestCode(lesson.start, studentId))
+
+            Timber.d("TimetableNotification canceled: type 1 & 2, subject: ${lesson.subject}, start: ${lesson.start}, student: $studentId")
+        }
+    }
+
+    private fun cancelScheduledTo(range: ClosedRange<LocalDateTime>, requestCode: Int) {
+        if (now() in range) cancelNotification()
+        alarmManager.cancel(PendingIntent.getBroadcast(context, requestCode, Intent(), FLAG_CANCEL_CURRENT))
+    }
+
+    fun cancelNotification() = NotificationManagerCompat.from(context).cancel(MainView.Section.TIMETABLE.id)
+
+    fun scheduleNotifications(lessons: List<Timetable>, student: Student) {
+        if (!preferencesRepository.isUpcomingLessonsNotificationsEnable) return cancelScheduled(lessons, student.studentId)
+
+        lessons.groupBy { it.date }
+            .map { it.value.sortedBy { lesson -> lesson.start } }
+            .map { it.filter { lesson -> !lesson.canceled && lesson.isStudentPlan } }
+            .map { day ->
+                day.forEachIndexed { index, lesson ->
+                    val intent = createIntent(student, lesson, day.getOrNull(index + 1))
+
+                    if (lesson.start > now()) {
+                        scheduleBroadcast(intent, student.studentId, NOTIFICATION_TYPE_UPCOMING, getUpcomingLessonTime(index, day, lesson))
+                    }
+
+                    if (lesson.end > now()) {
+                        scheduleBroadcast(intent, student.studentId, NOTIFICATION_TYPE_CURRENT, lesson.start)
+                        if (day.lastIndex == index) {
+                            scheduleBroadcast(intent, student.studentId, NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION, lesson.end)
+                        }
+                    }
+                }
+            }
+    }
+
+    private fun createIntent(student: Student, lesson: Timetable, nextLesson: Timetable?): Intent {
+        return Intent(context, TimetableNotificationReceiver::class.java).apply {
+            putExtra(STUDENT_ID, student.studentId)
+            putExtra(STUDENT_NAME, student.studentName)
+            putExtra(LESSON_ROOM, lesson.room)
+            putExtra(LESSON_START, lesson.start.toTimestamp())
+            putExtra(LESSON_END, lesson.end.toTimestamp())
+            putExtra(LESSON_TITLE, lesson.subject)
+            putExtra(LESSON_NEXT_TITLE, nextLesson?.subject)
+            putExtra(LESSON_NEXT_ROOM, nextLesson?.room)
+        }
+    }
+
+    private fun scheduleBroadcast(intent: Intent, studentId: Int, notificationType: Int, time: LocalDateTime) {
+        AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, RTC_WAKEUP, time.toTimestamp(),
+            PendingIntent.getBroadcast(context, getRequestCode(time, studentId), intent.also {
+                it.putExtra(NOTIFICATION_ID, MainView.Section.TIMETABLE.id)
+                it.putExtra(LESSON_TYPE, notificationType)
+            }, FLAG_CANCEL_CURRENT)
+        )
+        Timber.d("TimetableNotification scheduled: type: $notificationType, subject: ${intent.getStringExtra(LESSON_TITLE)}, start: $time, student: $studentId")
+    }
+}
diff --git a/app/src/main/java/io/github/wulkanowy/services/sync/channels/UpcomingLessonsChannel.kt b/app/src/main/java/io/github/wulkanowy/services/sync/channels/UpcomingLessonsChannel.kt
new file mode 100644
index 000000000..a292c8b53
--- /dev/null
+++ b/app/src/main/java/io/github/wulkanowy/services/sync/channels/UpcomingLessonsChannel.kt
@@ -0,0 +1,31 @@
+package io.github.wulkanowy.services.sync.channels
+
+import android.annotation.TargetApi
+import android.app.Notification.VISIBILITY_PUBLIC
+import android.app.NotificationChannel
+import android.app.NotificationManager.IMPORTANCE_DEFAULT
+import android.content.Context
+import androidx.core.app.NotificationManagerCompat
+import io.github.wulkanowy.R
+import javax.inject.Inject
+
+@TargetApi(26)
+class UpcomingLessonsChannel @Inject constructor(
+    private val notificationManager: NotificationManagerCompat,
+    private val context: Context
+) : Channel {
+
+    companion object {
+        const val CHANNEL_ID = "lesson_channel"
+    }
+
+    override fun create() {
+        notificationManager.createNotificationChannel(
+            NotificationChannel(CHANNEL_ID, context.getString(R.string.channel_upcoming_lessons), IMPORTANCE_DEFAULT).apply {
+                lockscreenVisibility = VISIBILITY_PUBLIC
+                setShowBadge(false)
+                enableVibration(false)
+            }
+        )
+    }
+}
diff --git a/app/src/main/java/io/github/wulkanowy/ui/modules/settings/SettingsPresenter.kt b/app/src/main/java/io/github/wulkanowy/ui/modules/settings/SettingsPresenter.kt
index c8545ac0e..09fc2d707 100644
--- a/app/src/main/java/io/github/wulkanowy/ui/modules/settings/SettingsPresenter.kt
+++ b/app/src/main/java/io/github/wulkanowy/ui/modules/settings/SettingsPresenter.kt
@@ -4,6 +4,7 @@ import androidx.work.WorkInfo
 import com.chuckerteam.chucker.api.ChuckerCollector
 import io.github.wulkanowy.data.repositories.preferences.PreferencesRepository
 import io.github.wulkanowy.data.repositories.student.StudentRepository
+import io.github.wulkanowy.services.alarm.TimetableNotificationSchedulerHelper
 import io.github.wulkanowy.services.sync.SyncManager
 import io.github.wulkanowy.ui.base.BasePresenter
 import io.github.wulkanowy.ui.base.ErrorHandler
@@ -20,6 +21,7 @@ class SettingsPresenter @Inject constructor(
     errorHandler: ErrorHandler,
     studentRepository: StudentRepository,
     private val preferencesRepository: PreferencesRepository,
+    private val timetableNotificationHelper: TimetableNotificationSchedulerHelper,
     private val analytics: FirebaseAnalyticsHelper,
     private val syncManager: SyncManager,
     private val chuckerCollector: ChuckerCollector,
@@ -36,17 +38,17 @@ class SettingsPresenter @Inject constructor(
     fun onSharedPreferenceChanged(key: String) {
         Timber.i("Change settings $key")
 
-        with(preferencesRepository) {
+        preferencesRepository.apply {
             when (key) {
                 serviceEnableKey -> with(syncManager) { if (isServiceEnabled) startPeriodicSyncWorker() else stopSyncWorker() }
                 servicesIntervalKey, servicesOnlyWifiKey -> syncManager.startPeriodicSyncWorker(true)
                 isDebugNotificationEnableKey -> chuckerCollector.showNotification = isDebugNotificationEnable
                 appThemeKey -> view?.recreateView()
+                isUpcomingLessonsNotificationsEnableKey -> if (!isUpcomingLessonsNotificationsEnable) timetableNotificationHelper.cancelNotification()
                 appLanguageKey -> view?.run {
                     updateLanguage(if (appLanguage == "system") appInfo.systemLanguage else appLanguage)
                     recreateView()
                 }
-                else -> Unit
             }
         }
         analytics.logEvent("setting_changed", "name" to key)
diff --git a/app/src/main/java/io/github/wulkanowy/utils/TimeExtension.kt b/app/src/main/java/io/github/wulkanowy/utils/TimeExtension.kt
index a91f823fa..8d022fc5d 100644
--- a/app/src/main/java/io/github/wulkanowy/utils/TimeExtension.kt
+++ b/app/src/main/java/io/github/wulkanowy/utils/TimeExtension.kt
@@ -4,9 +4,13 @@ import org.threeten.bp.DayOfWeek.FRIDAY
 import org.threeten.bp.DayOfWeek.MONDAY
 import org.threeten.bp.DayOfWeek.SATURDAY
 import org.threeten.bp.DayOfWeek.SUNDAY
+import org.threeten.bp.Instant.ofEpochMilli
 import org.threeten.bp.LocalDate
 import org.threeten.bp.LocalDateTime
+import org.threeten.bp.LocalDateTime.ofInstant
 import org.threeten.bp.Month
+import org.threeten.bp.ZoneId
+import org.threeten.bp.ZoneOffset
 import org.threeten.bp.format.DateTimeFormatter.ofPattern
 import org.threeten.bp.format.TextStyle.FULL_STANDALONE
 import org.threeten.bp.temporal.TemporalAdjusters.firstInMonth
@@ -18,6 +22,10 @@ private const val DATE_PATTERN = "dd.MM.yyyy"
 
 fun String.toLocalDate(format: String = DATE_PATTERN): LocalDate = LocalDate.parse(this, ofPattern(format))
 
+fun LocalDateTime.toTimestamp() = atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneOffset.UTC).toInstant().toEpochMilli()
+
+fun Long.toLocalDateTime() = ofInstant(ofEpochMilli(this), ZoneId.systemDefault())
+
 fun LocalDate.toFormattedString(format: String = DATE_PATTERN): String = format(ofPattern(format))
 
 fun LocalDateTime.toFormattedString(format: String = DATE_PATTERN): String = format(ofPattern(format))
diff --git a/app/src/main/res/drawable-hdpi/ic_stat_timetable.png b/app/src/main/res/drawable-hdpi/ic_stat_timetable.png
new file mode 100644
index 0000000000000000000000000000000000000000..201419d5d48501c657d592ea41c27763c5e6eb7f
GIT binary patch
literal 312
zcmV-80muG{P)<h;3K|Lk000e1NJLTq001Na001Ni1^@s6;Q*MJ00030Nkl<Zc-rll
zF$%&k6oy;$5cL8sF2O<QR-8OW@1O_hRUABm4m$K6YU$Xqi%?K7{()EMBJ_nsOVS^{
z!QuUU!%Nc|1Y^ehC{juR5(RTEA%tn6TvC{GZF0ASP%r^Yumu??JLMUJHYNSuffbl?
zh1TE>p3-bypiuo>p^LPeGgqjf!K!moOi)p8!4+zErZjvw^m>OZWGn6nHS|z4MdREK
z4OfV6{7eI3ODNS`;M5zTNb|ag{8;KCp0d6>lz1z&>3KZ}KX>TgLN^+$HLr_r)YN<B
z3LU`P(z}rA-E)N&-~=9qWmMl0%(+4#n1R^vS4T{};rNe<6Zrrz1^oezplKxl0000<
KMNUMnLSTY^3Wab0

literal 0
HcmV?d00001

diff --git a/app/src/main/res/drawable-mdpi/ic_stat_timetable.png b/app/src/main/res/drawable-mdpi/ic_stat_timetable.png
new file mode 100644
index 0000000000000000000000000000000000000000..dcfe95f26582f0cb2cee7b00e660fd2f50f7523e
GIT binary patch
literal 275
zcmV+u0qp*XP)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv0002mNkl<Zc-muN
z7`EX5|No*utPR9`c+~Jg)rgW~LBoFt2*sl&6so3y6oVyzH~@%OK{fY54Z?=|plVh@
z^+*yEEXRTP?|;$(NY4qp!3C7Gg(^G+4I)zTDX3msd=@xBT{D%8IGYO9>p+YJcc9@)
z3cf>%1(X1yELa9j-=yGWq*y@nIGBp22_KkBi3K%4e3byMA=?6a1d$PrBEp^4ad4Gd
z7I@((7P6>f0WIRd3W$GEP*i~QTH&*RABfjN6_N@-dRF7B0b!Q#0I>!cMFmI?&+uzJ
Z007MEy7feVSabjY002ovPDHLkV1gACY-j)g

literal 0
HcmV?d00001

diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_timetable.png b/app/src/main/res/drawable-xhdpi/ic_stat_timetable.png
new file mode 100644
index 0000000000000000000000000000000000000000..7264bd92a947d264f2b576b17645a1346557cf53
GIT binary patch
literal 358
zcmV-s0h#`ZP)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm0003kNkl<Zc-rlm
zF%AJi7=<-NqHqBhkgYTlH$ZE-gQ&K!Dv49LgKa1k4na$~t!OE*-Xv3OCK`S_lU?s6
zU!(Zne3Q(ut5jkb=Kqo;$uLDnFsGBAWm6!XNH8Z#528RJ1u(Y;24D$RW+@Wu&%Plx
zH$!)*t`VsJV)0XO1Xu7(vXAPxffJaiHAn>d;9zOt??m>XClLts&bx~04kZE$W1wf@
zg7A#a1qF&E5EwrrvLMJ(z(pufD1qFc0bZjN_?5t$B1t?7{G&ia@FZr^;31|!4zJN~
z&Ong&?}3N{6yPmHfscmG!NcJbnd+WP1Sa6(@Ok(Q#u9-x*nzu)#jDOOXh{UB`VI9)
z?}n|{>#A!Fx}YLGR29(FBEE$);Oj2cS>|7=3{&vD05k7d?=*zzl>h($07*qoM6N<$
Ef`ze{s{jB1

literal 0
HcmV?d00001

diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_timetable.png b/app/src/main/res/drawable-xxhdpi/ic_stat_timetable.png
new file mode 100644
index 0000000000000000000000000000000000000000..1fb37b092c4c26e76a349ab679d42edf8797c551
GIT binary patch
literal 459
zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2V4URX;uunK>+S6QUWWrD+?j8A
zZkYV&(VJD>>uw%lFL?i8$?W9i9sLSB$_hkXSMbGddd>ZVedRl)Z3~)Y*X`#nzr->1
zQ)~50%YtV$wwXow3MY+#Ml;NicpjtIJzq+0{R)GA%L(!y#M_RqUZfz<$l<bJCGQf(
zXHPcSOx^zJ?$Pe~Z~f+-7QMNp&sg6))!pu-xn-Bdg!#D&^PhI!`LzF_(S=zyQ=F&E
z9X9yADs=&K<>O0FZ+(#yUH8=SP~)FB>C+mkM8D7ZCm;P!;!XIS$yL)DPjzh$a@+HE
zMR^>PV21-smq9?50FYL?5W?#9GO#&yUM<KG3QWC)2WD+&Q3bOFU(`27nc4+yiFkIC
z9i&SsV9V(mM+Jxp-vgD?XQs;o^#G-W<v)1Usr`9$sQpb8o7J>_PWD~X-KRB{9p0ZY
zC1H+!rDC}b^Uw1OzCF!uYK%L?8MlGQM0@&^wmnn$AMWb=Iqz}$n^UDbHPY9Fx3%S*
v%H64cTX4U_RX*cik$<=6_40s%?${&gU0d$GUCOb13yACK>gTe~DWM4f-M7mX

literal 0
HcmV?d00001

diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_timetable.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_timetable.png
new file mode 100644
index 0000000000000000000000000000000000000000..a95cc4f5d9b0dbfcf5513827f0309aa1e2bf89e7
GIT binary patch
literal 659
zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7Ro>V2bi|aSW-r^>(hUcZ{Qi+w=|=
z7SBXMj%-2QOYT7$9hoAEQ5^61H}<V<<q-Ps*m6LKbBT~^P7VuaV9pi$2kM4DBp>VT
zo}B)Hmu2E!mn((o=Nd0_-YY-<^xc_V-)Eg>1X=_FN7{??mbJ}g>|4J1n&DB21!AoX
z$AedtI<P1(FmgCBFbOm;%xt!q==-Tv_ifeF2{PLhxg!sEoy@lXE#Q|nSLDA%j`7!%
zdNZe=t^0OdU_s!vQ;t7f*PRrOEc+O+ZRWM7;oP6R?0Ek^vY(t=<Wo0Ax+pyEl$Q&m
zmf7Wbcc#pL(vtIiz2gane`o5o`Jb-zZa4mUIW3=8G9kq8=FLDq4Tqa){}k@G^E_Dp
zlUpzLS#w?9lK6ef{6c&i^tU^+S!{frxa!CEy~$?bEAwta9Se4M0rP3e`z<Tfr@uCI
zFar7j=9vvVsmjOPdf<GZ)OKkN0alPNVQP-ZDZH2aw``Bj=bR%qIt1Yo(-wZ;v*pR-
z7YAl*BP-t^n5^su4km~J4gak+#D6M3Wd1Vt#ODc&6&dr3-4&U2cJxGBD5ra#YvXw!
zpR#vGL4)N_%M^zHEFbI2zsfwFqUZYW(r@Q)cdpL2&6j?#^v$&UPo~*yGgte+<ztqM
ze#O<aT^l6-20sdaBK_0mi_qKm|M?|0h#p=go1(dhL3Q@a-TR-MDF1e}>DciNZg~@Q
zH#zs6D74h=7Swg_HoLsz?2lV%c_&~IeuclDVSCvhefvfa4NwB`boFyt=akR{00hbj
ALI3~&

literal 0
HcmV?d00001

diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index bff422927..e6fe6da64 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -121,6 +121,9 @@
     <string name="timetable_time">Godziny</string>
     <string name="timetable_changes">Zmiany</string>
     <string name="timetable_no_items">Brak lekcji w tym dniu</string>
+    <string name="timetable_now">Teraz: %s</string>
+    <string name="timetable_next">Za chwilę: %s</string>
+    <string name="timetable_later">Później: %s</string>
     <!--Completed lessons-->
     <string name="completed_lessons_title">Lekcje zrealizowane</string>
     <string name="completed_lessons_button">Zobacz lekcje zrealizowane</string>
@@ -319,6 +322,7 @@
     <string name="pref_view_app_language">Język aplikacji</string>
     <string name="pref_notify_header">Powiadomienia</string>
     <string name="pref_notify_switch">Pokazuj powiadomienia</string>
+    <string name="pref_notify_upcoming_lessons_switch">Pokazuj powiadomienia o następnych lekcjach</string>
     <string name="pref_notify_debug_switch">Pokazuj powiadomienia debugowania</string>
     <string name="pref_services_header">Synchronizacja</string>
     <string name="pref_services_switch">Automatyczna aktualizacja</string>
@@ -344,6 +348,7 @@
     <string name="channel_new_message">Nowe wiadomości</string>
     <string name="channel_new_notes">Nowe uwagi</string>
     <string name="channel_push">Powiadomienia push</string>
+    <string name="channel_upcoming_lessons">Nadchodzące lekcje</string>
     <string name="channel_debug">Debugowanie</string>
     <!--Colors-->
     <string name="all_black">Czarny</string>
diff --git a/app/src/main/res/values/preferences_defaults.xml b/app/src/main/res/values/preferences_defaults.xml
index 29e8751e4..c8704a50b 100644
--- a/app/src/main/res/values/preferences_defaults.xml
+++ b/app/src/main/res/values/preferences_defaults.xml
@@ -13,6 +13,7 @@
     <string name="pref_default_services_interval">60</string>
     <bool name="pref_default_services_wifi_only">false</bool>
     <bool name="pref_default_notifications_enable">true</bool>
+    <bool name="pref_default_notification_upcoming_lessons_enable">false</bool>
     <bool name="pref_default_notification_debug">false</bool>
     <string name="pref_default_grade_modifier_plus">0.33</string>
     <string name="pref_default_grade_modifier_minus">0.33</string>
diff --git a/app/src/main/res/values/preferences_keys.xml b/app/src/main/res/values/preferences_keys.xml
index 6d683f3f8..1d43f79fd 100644
--- a/app/src/main/res/values/preferences_keys.xml
+++ b/app/src/main/res/values/preferences_keys.xml
@@ -14,6 +14,7 @@
     <string name="pref_key_services_wifi_only">services_disable_wifi_only</string>
     <string name="pref_key_services_force_sync">services_force_sync</string>
     <string name="pref_key_notifications_enable">notifications_enable</string>
+    <string name="pref_key_notifications_upcoming_lessons_enable">notifications_upcoming_lessons_enable</string>
     <string name="pref_key_notification_debug">notification_debug</string>
     <string name="pref_key_grade_modifier_plus">grade_modifier_plus</string>
     <string name="pref_key_grade_modifier_minus">grade_modifier_minus</string>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0a837c46b..bfaece677 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -130,6 +130,9 @@
     <string name="timetable_time">Hours</string>
     <string name="timetable_changes">Changes</string>
     <string name="timetable_no_items">No lessons this day</string>
+    <string name="timetable_now">Now: %s</string>
+    <string name="timetable_next">Next: %s</string>
+    <string name="timetable_later">Later: %s</string>
 
 
     <!--Completed lessons-->
@@ -356,6 +359,7 @@
 
     <string name="pref_notify_header">Notifications</string>
     <string name="pref_notify_switch">Show notifications</string>
+    <string name="pref_notify_upcoming_lessons_switch">Show upcoming lesson notifications</string>
     <string name="pref_notify_debug_switch">Show debug notifications</string>
 
     <string name="pref_services_header">Synchronization</string>
@@ -386,6 +390,7 @@
     <string name="channel_new_message">New messages</string>
     <string name="channel_new_notes">New notes</string>
     <string name="channel_push">Push notifications</string>
+    <string name="channel_upcoming_lessons">Upcoming lessons</string>
     <string name="channel_debug">Debug</string>
 
 
diff --git a/app/src/main/res/xml/scheme_preferences.xml b/app/src/main/res/xml/scheme_preferences.xml
index b4adabb98..d890fdb24 100644
--- a/app/src/main/res/xml/scheme_preferences.xml
+++ b/app/src/main/res/xml/scheme_preferences.xml
@@ -96,6 +96,11 @@
             app:iconSpaceReserved="false"
             app:key="@string/pref_key_notifications_enable"
             app:title="@string/pref_notify_switch" />
+        <SwitchPreferenceCompat
+            app:defaultValue="@bool/pref_default_notification_upcoming_lessons_enable"
+            app:iconSpaceReserved="false"
+            app:key="@string/pref_key_notifications_upcoming_lessons_enable"
+            app:title="@string/pref_notify_upcoming_lessons_switch" />
         <SwitchPreferenceCompat
             app:defaultValue="@bool/pref_default_notification_debug"
             app:iconSpaceReserved="false"