diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-08-22 22:35:54 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-08-22 22:35:54 +0000 |
| commit | 796b6dffd920ec9228430dcca1a1f4ec855ebfc7 (patch) | |
| tree | 758e451d4f3ec04667709977dba654913d1ccffb /app/src/main/java/org/terst/feedreader | |
| parent | 55cdfc8fab6ddd1052593f8fd53dbb5bf730cc63 (diff) | |
Replace background auto-refresh with fetch-on-launch + explicit button
Periodic hourly WorkManager fetches and the 5am exact-alarm morning fetch
both refreshed without any visible trigger, which surprised the user more
than it helped. Refreshing now only ever happens on a fresh app launch
(MainActivity.onCreate) or an explicit tap of the new refresh button on the
Unread/Older screens' top bar (wired to the fetch-status state that was
already being computed there but never rendered). Drops MorningFetchReceiver
entirely along with its exact-alarm permission and boot receiver.
Diffstat (limited to 'app/src/main/java/org/terst/feedreader')
4 files changed, 58 insertions, 184 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt index a3edebe..7a99050 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -5,6 +5,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,11 +16,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -185,14 +189,19 @@ fun ArticleListScreen( topBar = { TopAppBar( title = { - Text( - when (feedId) { - UNREAD_FEED_ID -> "Unread" - OLDER_FEED_ID -> "Older" - SAVED_FEED_ID -> "Saved" - else -> specificFeedTitle ?: "Articles" + Column { + Text( + when (feedId) { + UNREAD_FEED_ID -> "Unread" + OLDER_FEED_ID -> "Older" + SAVED_FEED_ID -> "Saved" + else -> specificFeedTitle ?: "Articles" + } + ) + if (showFetchStatus && isFetching) { + Text(fetchStatusText, style = MaterialTheme.typography.labelSmall) } - ) + } }, navigationIcon = { // Unread is the app's landing screen (root of the back stack), so there's @@ -206,6 +215,29 @@ fun ArticleListScreen( Icon(Icons.Default.ArrowBack, contentDescription = "Back") } } + }, + // Only two things ever trigger a fetch now: a fresh cold launch and this + // button -- no periodic or scheduled background refresh any more. Scoped to + // the two aggregate views (same showFetchStatus condition the snackbar/status + // text above already uses) since a fetch always touches every subscription at + // once, not just whatever single feed screen happens to be open. + actions = { + if (showFetchStatus) { + IconButton( + onClick = { FeedFetchWorker.fetchNow(context) }, + enabled = !isFetching + ) { + if (isFetching) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current + ) + } else { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + } + } } ) }, 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 38c04e4..85bc2b3 100644 --- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt @@ -1,12 +1,6 @@ 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 @@ -22,13 +16,9 @@ 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__" @@ -42,9 +32,10 @@ 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() + // The only implicit fetch trigger left: a fresh cold launch. No periodic + // WorkManager job and no background alarm any more -- per user request, refreshing + // only ever happens here or from an explicit tap on the refresh button. + FeedFetchWorker.fetchNow(applicationContext) // 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) @@ -58,53 +49,6 @@ 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/FeedFetchWorker.kt b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt index 9c2962f..ecd186b 100644 --- a/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt +++ b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt @@ -2,9 +2,6 @@ package org.terst.feedreader.work import android.content.Context import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf @@ -25,26 +22,26 @@ import org.terst.feedreader.data.await import okhttp3.Request import java.security.MessageDigest import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.TimeUnit /** - * Fetches every subscribed feed on a schedule, parses new items, and - * inserts them (existing articles are left untouched -- see ArticleDao.insertNew's - * IGNORE conflict strategy). Uses conditional GET (ETag / Last-Modified) so - * an unchanged feed costs a 304 instead of a full re-download and re-parse. + * Fetches every subscribed feed, parses new items, and inserts them (existing + * articles are left untouched -- see ArticleDao.insertNew's IGNORE conflict + * strategy). Uses conditional GET (ETag / Last-Modified) so an unchanged feed + * costs a 304 instead of a full re-download and re-parse. Only ever triggered by + * fetchNow -- a fresh app launch or an explicit tap on the refresh button, never + * a background schedule. */ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { private val client = Net.client override suspend fun doWork(): Result { - // WorkManager's "unique work name" dedup does NOT prevent this: schedulePeriodic - // and fetchNow use two different unique-work names (periodic and one-time requests - // can't share one), so an hourly periodic run and a pull-to-refresh/morning-alarm - // fetch firing at the same moment both start doWork() concurrently -- every feed - // gets fetched twice, and both instances race writes to the same feed rows' - // etag/lastModified/lastFetchedAt. This mutex is process-local, which is exactly - // right here: WorkManager runs this worker in the app's own process, never a + // fetchNow uses ExistingWorkPolicy.KEEP against a single unique work name, so + // WorkManager itself already dedups a rapid double-tap of the refresh button or an + // overlapping cold-launch fetch. This mutex covers the remaining case: a manual + // fetchNow firing while doWork() from a still-running instance hasn't finished yet + // (e.g. a very slow feed run overlapping the next launch). Process-local, which is + // exactly right here: WorkManager runs this worker in the app's own process, never a // separate one, so a single in-memory lock covers every real overlap case. if (!fetchMutex.tryLock()) { return Result.success() @@ -159,11 +156,8 @@ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWor } companion object { - private const val UNIQUE_WORK_NAME = "feed_fetch_periodic" - - // Common tag on every enqueue (periodic AND one-off) so ArticleListScreen can - // observe "is a fetch happening right now" via a single getWorkInfosByTagFlow - // query, regardless of which of the two triggered it. + // Tagged so ArticleListScreen can observe "is a fetch happening right now" via a + // single getWorkInfosByTagFlow query. const val FETCH_WORK_TAG = "feed_fetch" const val KEY_CURRENT = "current" const val KEY_TOTAL = "total" @@ -177,23 +171,7 @@ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWor private val fetchMutex = Mutex() - fun schedulePeriodic(context: Context) { - val request = PeriodicWorkRequestBuilder<FeedFetchWorker>(60, TimeUnit.MINUTES) - .setConstraints( - androidx.work.Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) - .addTag(FETCH_WORK_TAG) - .build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - UNIQUE_WORK_NAME, - ExistingPeriodicWorkPolicy.KEEP, - request - ) - } - - /** One-off immediate fetch, e.g. a manual pull-to-refresh. */ + /** One-off immediate fetch: a fresh app launch or an explicit refresh-button tap. */ fun fetchNow(context: Context) { val request = androidx.work.OneTimeWorkRequestBuilder<FeedFetchWorker>() .addTag(FETCH_WORK_TAG) diff --git a/app/src/main/java/org/terst/feedreader/work/MorningFetchReceiver.kt b/app/src/main/java/org/terst/feedreader/work/MorningFetchReceiver.kt deleted file mode 100644 index 883fb07..0000000 --- a/app/src/main/java/org/terst/feedreader/work/MorningFetchReceiver.kt +++ /dev/null @@ -1,80 +0,0 @@ -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) - } - } - } -} |
