feat(android): notify when a show gains episodes

NewEpisodesWorker runs crawl_for_news every six hours on a network with
the battery not low, and posts one notification per show, grouped. A
setting under Playback turns it off and cancels the schedule.
This commit is contained in:
nvrl committed 2026-10-13 21:26:15 +02:00
1 parent 588a7fcb09
commit 4b9cbd26bc
5 files changed
+155

No files matched your search

@@ -21,6 +21,7 @@ import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.playback.NativeMpvHost
import io.narl.protonstream.playback.PlaybackService
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.NewEpisodesWorker
import io.narl.protonstream.sync.WatchSyncWorker
import io.narl.protonstream.ui.ProtonStreamApp
import io.narl.protonstream.ui.theme.AppearanceState
@@ -79,6 +80,7 @@ class MainActivity : ComponentActivity() {
playbackConnection,
Context.BIND_AUTO_CREATE,
)
NewEpisodesWorker.schedule(this)
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
@@ -56,6 +56,11 @@ class SettingsStore(context: Context) {
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
set(value) { preferences.edit { putBoolean(KEY_LIBRARY_GROUPED, value) } }
/** Whether a background crawl may say that a show gained episodes. */
var newEpisodeNotifications: Boolean
get() = preferences.getBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, true)
set(value) { preferences.edit { putBoolean(KEY_NEW_EPISODE_NOTIFICATIONS, value) } }
companion object {
/** The desktop's range, in the steps a phone's storage is bought in. */
val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16)
@@ -78,6 +83,7 @@ class SettingsStore(context: Context) {
private const val KEY_LIBRARY_SORT = "library_sort"
private const val KEY_LIBRARY_GROUPED = "library_grouped"
private const val KEY_LIBRARY_FILTER = "library_filter"
private const val KEY_NEW_EPISODE_NOTIFICATIONS = "new_episode_notifications"
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
}
}
@@ -0,0 +1,136 @@
package io.narl.protonstream.sync
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Constraints
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 io.narl.protonstream.MainActivity
import io.narl.protonstream.R
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.settings.SettingsStore
import uniffi.pstr_android.NewEpisodesRecord
import java.util.concurrent.TimeUnit
/**
* Look at the shares while the app is away, and say when a show gained episodes.
*
* The app crawls a share it has not looked at for six hours whenever it opens,
* which finds a new episode only once someone opens it to look. This runs the
* same crawl on WorkManager's schedule instead. It only reports files the
* crawl itself found, so a share the app crawled since says nothing twice.
*/
class NewEpisodesWorker(
appContext: Context,
parameters: WorkerParameters,
) : CoroutineWorker(appContext, parameters) {
override suspend fun doWork(): Result {
if (!SettingsStore(applicationContext).newEpisodeNotifications) return Result.success()
val news = runCatching { NativeRuntime.engine().crawlForNews() }.getOrElse { return Result.retry() }
if (news.isNotEmpty()) post(news)
return Result.success()
}
private fun post(news: List<NewEpisodesRecord>) {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
val manager = applicationContext.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "New episodes", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "A show in your library gained episodes"
},
)
val open = PendingIntent.getActivity(
applicationContext,
0,
Intent(applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val compat = NotificationManagerCompat.from(applicationContext)
// One per show, keyed by it, so a second crawl's news for the same show
// replaces the first rather than stacking under it.
news.forEach { item ->
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle(item.title)
.setContentText(describe(item))
.setContentIntent(open)
.setGroup(GROUP)
.setAutoCancel(true)
.build()
compat.notify(item.titleKey, NOTIFICATION_ID, notification)
}
if (news.size > 1) {
val summary = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle("New episodes")
.setContentText(news.joinToString { it.title })
.setContentIntent(open)
.setGroup(GROUP)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
compat.notify(SUMMARY_TAG, NOTIFICATION_ID, summary)
}
}
companion object {
private const val WORK_NAME = "new-episodes"
private const val CHANNEL_ID = "new-episodes"
private const val GROUP = "io.narl.protonstream.NEW_EPISODES"
private const val SUMMARY_TAG = "new-episodes-summary"
private const val NOTIFICATION_ID = 7
private const val INTERVAL_HOURS = 6L
/** What one show's notification says: "S01E04 and 2 more". */
fun describe(news: NewEpisodesRecord): String {
val more = news.count.toInt() - 1
val first = news.first
return when {
first == null -> if (news.count == 1u) "1 new episode" else "${news.count} new episodes"
more == 0 -> "$first is new"
more == 1 -> "$first and 1 more are new"
else -> "$first and $more more are new"
}
}
/**
* Keep the periodic look scheduled, or cancel it when the viewer
* turned it off. Called on every launch; keeping an existing schedule
* means a launch does not push the next look back.
*/
fun schedule(context: Context) {
val work = WorkManager.getInstance(context.applicationContext)
if (!SettingsStore(context).newEpisodeNotifications) {
work.cancelUniqueWork(WORK_NAME)
return
}
val request = PeriodicWorkRequestBuilder<NewEpisodesWorker>(INTERVAL_HOURS, TimeUnit.HOURS)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build(),
)
.build()
work.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
}
}
@@ -59,6 +59,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.sync.NewEpisodesWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -104,6 +105,7 @@ internal fun SettingsScreen(
val settings = remember { SettingsStore(context) }
var wifiOnly by remember { mutableStateOf(settings.wifiOnly) }
var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) }
var newEpisodes by remember { mutableStateOf(settings.newEpisodeNotifications) }
var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) }
var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) }
// Playback preferences are the shared store's, not this app's: they are the
@@ -207,6 +209,15 @@ internal fun SettingsScreen(
backgroundAudio = it
settings.backgroundAudio = it
}
SettingSwitch(
"New episode notifications",
"Looks at your shares every few hours and says when a show gains episodes",
newEpisodes,
) {
newEpisodes = it
settings.newEpisodeNotifications = it
NewEpisodesWorker.schedule(context)
}
}
SettingsPage.Storage -> SettingsSubPage(SettingsPage.Storage, padding, back) {
SettingSwitch("Download on Wi-Fi only", "Mobile data is never used for downloads", wifiOnly) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 68 KiB