summaryrefslogtreecommitdiff
path: root/app/src/main/java/org
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-22 01:08:22 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-22 01:08:22 +0000
commit7f324168464934d711953dc748fbc7c65eb88f4b (patch)
tree1bac6e3957ceca6736083dc4c1684c1d67941305 /app/src/main/java/org
parent78bc7b3c48eae1b68d3f35c81ea2b959d8136477 (diff)
Add a 5am morning-fetch alarm so articles are fresh before you open the app
FeedFetchWorker's periodic 60-minute WorkManager job can (and does) get silently deferred for hours by Doze on an idle overnight device. An exact alarm (setExactAndAllowWhileIdle) wakes the device at a fixed 5am to run a fetch, without requesting the much broader "ignore battery optimizations" exemption -- battery optimization stays on otherwise, just this one daily wake-up. Android 12+ requires a one-time user grant for exact alarms (no in-app permission dialog); MainActivity requests it via Settings if not already granted, tracked across rotation via savedInstanceState so a config change doesn't re-trigger the Settings screen. The alarm also gets re-armed on BOOT_COMPLETED, since AlarmManager alarms don't survive a device restart on their own.
Diffstat (limited to 'app/src/main/java/org')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt59
-rw-r--r--app/src/main/java/org/terst/feedreader/work/MorningFetchReceiver.kt80
2 files changed, 139 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
index 9add10a..38c04e4 100644
--- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
@@ -1,6 +1,12 @@
package org.terst.feedreader.ui
+import android.app.AlarmManager
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
+import android.provider.Settings
+import android.util.Log
import android.webkit.CookieManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -16,9 +22,13 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import org.terst.feedreader.work.FeedFetchWorker
+import org.terst.feedreader.work.MorningFetchReceiver
import java.net.URLDecoder
import java.net.URLEncoder
+private const val TAG = "MainActivity"
+private const val KEY_REQUESTED_EXACT_ALARM = "requestedExactAlarmPermission"
+
const val UNREAD_FEED_ID = "__unread__"
const val SAVED_FEED_ID = "__saved__"
const val OLDER_FEED_ID = "__older__"
@@ -32,7 +42,9 @@ fun decodeNavArg(value: String): String = URLDecoder.decode(value, "UTF-8")
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ requestedExactAlarmPermission = savedInstanceState?.getBoolean(KEY_REQUESTED_EXACT_ALARM) ?: false
FeedFetchWorker.schedulePeriodic(applicationContext)
+ maybeScheduleMorningFetch()
// Explicit, even though it defaults on -- BrowserScreen relies on cookies set
// during a site login surviving into the reader's later extraction requests.
CookieManager.getInstance().setAcceptCookie(true)
@@ -46,6 +58,53 @@ class MainActivity : ComponentActivity() {
}
}
}
+
+ // Re-checked on resume too, not just onCreate -- returning here from the "Alarms &
+ // reminders" Settings screen (below) is an onResume, not a fresh onCreate, and the
+ // alarm should get armed the moment permission is actually granted rather than
+ // needing a full app relaunch.
+ override fun onResume() {
+ super.onResume()
+ maybeScheduleMorningFetch()
+ }
+
+ // onCreate's own onStart/onResume fires once automatically as part of normal startup,
+ // right after onCreate -- before the user has done anything -- so without this guard,
+ // a not-yet-granted permission would launch the Settings screen twice back to back
+ // (once from onCreate, immediately again from that auto-triggered onResume). Restored
+ // from savedInstanceState in onCreate (see below), not left as a plain field that
+ // silently resets to false on every config change -- a rotation while still ungranted
+ // used to re-trigger the Settings screen every time the device rotated.
+ private var requestedExactAlarmPermission = false
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putBoolean(KEY_REQUESTED_EXACT_ALARM, requestedExactAlarmPermission)
+ }
+
+ // Runs unconditionally on every onCreate/onResume -- must never let an exception
+ // escape from here, or a device/OEM quirk around the exact-alarm APIs would crash
+ // the app on every single launch. A wake-up alarm failing to schedule just isn't
+ // worth that; worst case it's logged and tries again next launch.
+ private fun maybeScheduleMorningFetch() {
+ try {
+ val alarmManager = getSystemService(AlarmManager::class.java) ?: return
+ val canScheduleExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
+ if (canScheduleExact) {
+ MorningFetchReceiver.scheduleNext(applicationContext)
+ } else if (!requestedExactAlarmPermission) {
+ requestedExactAlarmPermission = true
+ // Android 12+ only: exact alarms require a one-time user grant via Settings,
+ // there's no in-app permission dialog for this one. Sends the user straight to
+ // this app's page in the "Alarms & reminders" settings screen.
+ startActivity(
+ Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Uri.parse("package:$packageName"))
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to schedule or request permission for morning fetch", e)
+ }
+ }
}
@Composable
diff --git a/app/src/main/java/org/terst/feedreader/work/MorningFetchReceiver.kt b/app/src/main/java/org/terst/feedreader/work/MorningFetchReceiver.kt
new file mode 100644
index 0000000..883fb07
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/work/MorningFetchReceiver.kt
@@ -0,0 +1,80 @@
+package org.terst.feedreader.work
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.util.Log
+import java.util.Calendar
+
+private const val TAG = "MorningFetch"
+
+/**
+ * Wakes the device at a fixed time every morning to run a feed fetch, so the app has
+ * fresh articles ready before the user ever opens it -- rather than relying purely on
+ * FeedFetchWorker's 60-minute WorkManager periodic job, which Doze/battery management
+ * can (and does) silently defer for hours on an idle overnight device. Uses an exact
+ * alarm (setExactAndAllowWhileIdle) specifically so this one wake survives Doze without
+ * requesting the much broader "ignore battery optimizations" exemption -- the user wants
+ * battery optimization left on otherwise, just this one daily wake-up.
+ */
+class MorningFetchReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ try {
+ // A reboot only needs the alarm re-armed (AlarmManager alarms don't survive a
+ // restart) -- an actual fetch isn't due just because the phone restarted.
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED) {
+ FeedFetchWorker.fetchNow(context)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to enqueue morning fetch for action=${intent.action}", e)
+ }
+ scheduleNext(context)
+ }
+
+ companion object {
+ private const val HOUR = 5
+ private const val MINUTE = 0
+ private const val ACTION_MORNING_FETCH = "org.terst.feedreader.MORNING_FETCH"
+
+ private fun nextTriggerMillis(): Long {
+ val now = Calendar.getInstance()
+ val next = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, HOUR)
+ set(Calendar.MINUTE, MINUTE)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ }
+ if (next.timeInMillis <= now.timeInMillis) {
+ next.add(Calendar.DAY_OF_MONTH, 1)
+ }
+ return next.timeInMillis
+ }
+
+ /** No-ops quietly if the exact-alarm permission isn't granted yet (Android 12+) --
+ * MainActivity is what prompts for that; this just skips scheduling until it's there.
+ * Also survives any failure from AlarmManager itself (logged, not crashed): this runs
+ * unconditionally from MainActivity.onCreate/onResume on every single launch, so an
+ * unguarded exception here would crash the app on every open -- a background wake-up
+ * alarm failing to schedule isn't worth that; just log it and let the next periodic
+ * WorkManager fetch or next app open try again. */
+ fun scheduleNext(context: Context) {
+ try {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
+ return
+ }
+ val intent = Intent(context, MorningFetchReceiver::class.java).setAction(ACTION_MORNING_FETCH)
+ val pendingIntent = PendingIntent.getBroadcast(
+ context, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTriggerMillis(), pendingIntent)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to schedule morning fetch alarm", e)
+ }
+ }
+ }
+}