summaryrefslogtreecommitdiff
path: root/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main')
-rw-r--r--app/src/main/AndroidManifest.xml20
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt46
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt64
-rw-r--r--app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt52
-rw-r--r--app/src/main/java/org/terst/feedreader/work/MorningFetchReceiver.kt80
5 files changed, 58 insertions, 204 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 325a2cf..a91bd3a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,13 +3,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
- <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
- <!-- For the 5am morning-fetch alarm (MorningFetchReceiver): lets it wake the device
- at an exact time even under Doze, without the much broader "ignore battery
- optimizations" exemption (user explicitly wants battery optimization left on
- otherwise). Android 12+ requires the user grant this once via Settings; MainActivity
- prompts for it if not already granted. -->
- <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application
android:label="@string/app_name"
@@ -47,18 +40,5 @@
</intent-filter>
</activity>
- <!--
- Fires the 5am morning fetch (exact alarm, set up by MorningFetchReceiver.scheduleNext,
- called from MainActivity). Also re-arms that alarm after a reboot: an exact alarm
- set via AlarmManager does NOT survive a device restart on its own.
- -->
- <receiver
- android:name=".work.MorningFetchReceiver"
- android:exported="false">
- <intent-filter>
- <action android:name="android.intent.action.BOOT_COMPLETED" />
- </intent-filter>
- </receiver>
-
</application>
</manifest>
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)
- }
- }
- }
-}