fix: improve event scheduling logic on reboot (#743)

* fix: call `goAsync()` before doing background work in boot completed receiver

It's the recommended mechanism. Otherwise, there's a good chance that the system may kill the process as soon as `onReceive()` returns.

* fix: remove `canScheduleExactAlarms()` check for Android 13 and above

`AlarmManager.canScheduleExactAlarms()` should return `true` for `USE_EXACT_ALARM` as per the source code, but skipping the check shouldn't hurt. It might also help in case OEMs have customized this behavior.

See: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/apex/jobscheduler/service/java/com/android/server/alarm/AlarmManagerService.java#2850

* fix: reschedule events when time is set or when timezone changes

* refactor: delegate startup work to work manager

 - This receiver was doing too much work and likely timed out when there are many events. Using a dedicated job should prevent such issues.
 - The `android.permission.FOREGROUND_SERVICE` permission is required prior to Android 12 for expedited work.

* fix: reschedule events on app startup when alarms are missing

This should help with recovery in case the app was force-stopped or if the alarms were cleared somehow, e.g., by battery optimization or if the OS ate the BOOT_COMPLETED broadcast for some reason.

* docs: update changelog


Refs: https://github.com/FossifyOrg/Calendar/issues/217
This commit is contained in:
Naveen Singh
2025-08-31 02:39:04 +05:30
committed by GitHub
parent 361faf1c90
commit 57a6f2da8f
11 changed files with 202 additions and 34 deletions

View File

@@ -5,12 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- Declined events will no longer trigger notifications ([#732])
### Fixed
- Fixed incorrect widget font size on foldable devices ([#337])
- Fixed missing or delayed reminders in some cases ([#217])
## [1.6.0] - 2025-08-21
### Added
@@ -117,6 +117,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#148]: https://github.com/FossifyOrg/Calendar/issues/148
[#196]: https://github.com/FossifyOrg/Calendar/issues/196
[#217]: https://github.com/FossifyOrg/Calendar/issues/217
[#262]: https://github.com/FossifyOrg/Calendar/issues/262
[#337]: https://github.com/FossifyOrg/Calendar/issues/337
[#394]: https://github.com/FossifyOrg/Calendar/issues/394

View File

@@ -145,6 +145,7 @@ dependencies {
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.print)
implementation(libs.bundles.room)
implementation(libs.androidx.work.runtime.ktx)
ksp(libs.androidx.room.compiler)
detektPlugins(libs.compose.detekt)
}

View File

@@ -24,6 +24,10 @@
android:name="android.permission.USE_FINGERPRINT"
tools:node="remove" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE"
android:maxSdkVersion="32" />
<queries>
<package android:name="org.fossify.contacts.debug" />
<package android:name="org.fossify.contacts" />
@@ -39,7 +43,7 @@
android:required="false" />
<application
android:name="org.fossify.commons.FossifyApp"
android:name=".App"
android:allowBackup="true"
android:appCategory="productivity"
android:icon="@mipmap/ic_launcher"
@@ -281,14 +285,16 @@
android:exported="false" />
<receiver
android:name=".receivers.BootCompletedReceiver"
android:name=".receivers.RescheduleEventsReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
@@ -296,6 +302,9 @@
android:name=".receivers.AutomaticBackupReceiver"
android:exported="false" />
<receiver android:name=".receivers.DummyAlarmReceiver"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -0,0 +1,14 @@
package org.fossify.calendar
import org.fossify.calendar.extensions.hasDummyAlarm
import org.fossify.calendar.jobs.AppStartupWorker
import org.fossify.commons.FossifyApp
class App : FossifyApp() {
override fun onCreate() {
super.onCreate()
if (!hasDummyAlarm()) {
AppStartupWorker.start(this)
}
}
}

View File

@@ -45,6 +45,7 @@ import org.fossify.calendar.helpers.DEFAULT_START_TIME_NEXT_FULL_HOUR
import org.fossify.calendar.helpers.DELETE_ALL_OCCURRENCES
import org.fossify.calendar.helpers.DELETE_FUTURE_OCCURRENCES
import org.fossify.calendar.helpers.DELETE_SELECTED_OCCURRENCE
import org.fossify.calendar.helpers.DUMMY_ALARM_REQUEST_CODE
import org.fossify.calendar.helpers.EVENT_ID
import org.fossify.calendar.helpers.EVENT_OCCURRENCE_TS
import org.fossify.calendar.helpers.EventsHelper
@@ -81,6 +82,7 @@ import org.fossify.calendar.models.ListSectionMonth
import org.fossify.calendar.models.Task
import org.fossify.calendar.receivers.AutomaticBackupReceiver
import org.fossify.calendar.receivers.CalDAVSyncReceiver
import org.fossify.calendar.receivers.DummyAlarmReceiver
import org.fossify.calendar.receivers.NotificationReceiver
import org.fossify.calendar.services.MarkCompletedService
import org.fossify.calendar.services.SnoozeService
@@ -116,6 +118,7 @@ import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.commons.helpers.isOreoPlus
import org.fossify.commons.helpers.isRPlus
import org.fossify.commons.helpers.isSPlus
import org.fossify.commons.helpers.isTiramisuPlus
import org.joda.time.DateTime
import org.joda.time.DateTimeConstants
import org.joda.time.Days
@@ -123,6 +126,7 @@ import org.joda.time.LocalDate
import java.io.File
import java.io.FileOutputStream
import java.util.Calendar
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
@@ -847,7 +851,7 @@ fun Context.getFirstDayOfWeek(date: DateTime): String {
}
fun Context.getFirstDayOfWeekDt(date: DateTime): DateTime {
var today = date.withTimeAtStartOfDay()
val today = date.withTimeAtStartOfDay()
var currentDate = today
if (!config.startWeekWithCurrentDay) {
val firstDayOfWeek = config.firstDayOfWeek
@@ -997,13 +1001,26 @@ fun Context.addImportIdsToTasks(callback: () -> Unit) {
fun Context.getAlarmManager() = getSystemService(Context.ALARM_SERVICE) as AlarmManager
fun Context.setExactAlarm(triggerAtMillis: Long, operation: PendingIntent, type: Int = AlarmManager.RTC_WAKEUP) {
val alarmManager = getAlarmManager()
fun Context.setExactAlarm(
triggerAtMillis: Long,
operation: PendingIntent,
type: Int = AlarmManager.RTC_WAKEUP
) = with(getAlarmManager()) {
try {
if (isSPlus() && alarmManager.canScheduleExactAlarms() || !isSPlus()) {
alarmManager.setExactAndAllowWhileIdle(type, triggerAtMillis, operation)
} else {
alarmManager.setAndAllowWhileIdle(type, triggerAtMillis, operation)
when {
// USE_EXACT_ALARM *cannot* be revoked by users on Android 13+
isTiramisuPlus() -> setExactAndAllowWhileIdle(type, triggerAtMillis, operation)
// SCHEDULE_EXACT_ALARM *may* be revoked by users/system on Android 12
isSPlus() && canScheduleExactAlarms() -> {
setExactAndAllowWhileIdle(type, triggerAtMillis, operation)
}
// No special permissions are needed *before* Android 12
!isSPlus() -> setExactAndAllowWhileIdle(type, triggerAtMillis, operation)
// Fallback to *inexact* alarms for Android 12. This will cause delayed reminders.
else -> setAndAllowWhileIdle(type, triggerAtMillis, operation)
}
} catch (e: Exception) {
showErrorToast(e)
@@ -1023,3 +1040,25 @@ fun Context.getWeekNumberWidth(): Int {
0
}
}
/**
* Returns true if the dummy alarm is already scheduled.
*/
fun Context.hasDummyAlarm(): Boolean {
return PendingIntent.getBroadcast(
this, DUMMY_ALARM_REQUEST_CODE,
Intent(this, DummyAlarmReceiver::class.java),
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
) != null
}
fun Context.scheduleDummyAlarm() {
setExactAlarm(
triggerAtMillis = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1),
operation = PendingIntent.getBroadcast(
this, DUMMY_ALARM_REQUEST_CODE,
Intent(this, DummyAlarmReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
}

View File

@@ -13,6 +13,7 @@ const val ROW_COUNT = 6
const val COLUMN_COUNT = 7
const val SCHEDULE_CALDAV_REQUEST_CODE = 10000
const val AUTOMATIC_BACKUP_REQUEST_CODE = 10001
const val DUMMY_ALARM_REQUEST_CODE = 10002
const val FETCH_INTERVAL = 3 * MONTH_SECONDS
const val MAX_SEARCH_YEAR =
2051218800L // 2035, limit search results for events repeating indefinitely

View File

@@ -0,0 +1,76 @@
package org.fossify.calendar.jobs
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.fossify.calendar.R
import org.fossify.calendar.extensions.checkAndBackupEventsOnBoot
import org.fossify.calendar.extensions.recheckCalDAVCalendars
import org.fossify.calendar.extensions.scheduleAllEvents
import org.fossify.calendar.extensions.scheduleDummyAlarm
import org.fossify.calendar.extensions.scheduleNextAutomaticBackup
import org.fossify.commons.extensions.notificationManager
/**
* Does everything that needs to be done on device boot, app updates, etc.
*/
class AppStartupWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = with(applicationContext) {
try {
scheduleAllEvents()
scheduleDummyAlarm()
scheduleNextAutomaticBackup()
checkAndBackupEventsOnBoot()
} catch (_: Throwable) {
return Result.retry()
}
recheckCalDAVCalendars(true) {}
Result.success()
}
// expedited work on before Android 12 requires a foreground service
override suspend fun getForegroundInfo(): ForegroundInfo {
val channelId = "app_startup_worker"
val channelName = applicationContext.getString(R.string.app_name)
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_MIN).apply {
setSound(null, null)
applicationContext.notificationManager.createNotificationChannel(this)
}
val notification = NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.ic_calendar_vector)
.setContentTitle(applicationContext.getString(R.string.app_name))
.setOngoing(true)
.build()
return ForegroundInfo(42, notification)
}
companion object {
fun start(
context: Context,
replaceExistingWork: Boolean = false
) {
WorkManager.getInstance(context)
.enqueueUniqueWork(
"initialize_fossify_calendar",
if (replaceExistingWork) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<AppStartupWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
)
}
}
}

View File

@@ -1,22 +0,0 @@
package org.fossify.calendar.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.fossify.calendar.extensions.*
import org.fossify.commons.helpers.ensureBackgroundThread
class BootCompletedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
ensureBackgroundThread {
context.apply {
scheduleAllEvents()
notifyRunningEvents()
recheckCalDAVCalendars(true) {}
scheduleNextAutomaticBackup()
checkAndBackupEventsOnBoot()
}
}
}
}

View File

@@ -0,0 +1,12 @@
package org.fossify.calendar.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.fossify.calendar.extensions.scheduleDummyAlarm
class DummyAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
context.scheduleDummyAlarm()
}
}

View File

@@ -0,0 +1,35 @@
package org.fossify.calendar.receivers
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.fossify.calendar.extensions.notifyRunningEvents
import org.fossify.calendar.jobs.AppStartupWorker
import org.fossify.commons.helpers.ensureBackgroundThread
class RescheduleEventsReceiver : BroadcastReceiver() {
@SuppressLint("UnsafeProtectedBroadcastReceiver")
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
AppStartupWorker.start(
context = context,
replaceExistingWork = action == Intent.ACTION_TIME_CHANGED
|| action == Intent.ACTION_TIMEZONE_CHANGED
)
val shouldNotifyRunningEvents = action != Intent.ACTION_TIME_CHANGED
&& action != Intent.ACTION_TIMEZONE_CHANGED
&& action != Intent.ACTION_MY_PACKAGE_REPLACED
if (shouldNotifyRunningEvents) {
val result = goAsync()
ensureBackgroundThread {
context.notifyRunningEvents()
result.finish()
}
}
}
}

View File

@@ -11,6 +11,7 @@ multidex = "2.0.1"
print = "1.1.0"
constraintlayout = "2.2.1"
swiperefreshlayout = "1.1.0"
work = "2.9.1"
#Room
room = "2.7.2"
#Fossify
@@ -29,6 +30,7 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo
androidx-multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" }
androidx-print = { module = "androidx.print:print", version.ref = "print" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
#Room
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }