Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ FEED_WORKER_CONCURRENCY=5
FEED_REQUEST_TIMEOUT_MS=15000
FEED_MAX_RESPONSE_BYTES=33554432 # 32 MiB limit for large RSS XML feeds
FEED_MAX_STORED_EPISODES=200
# How long a healthy feed waits before the background worker checks it again.
# Only subscriptions that asked for new-episode notifications are refreshed on
# this schedule; everything else refreshes when a listener opens it. Clamped to
# 15 minutes .. 24 hours. Conditional requests keep it cheap (304, no body).
FEED_REFRESH_INTERVAL_MS=3600000

# Web Push (required for notifications while the browser is closed).
# Generate once with: cd services/api && go run ./cmd/vapid
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Calm, distraction-free listening — with optional account-backed cross-device s
| :--- | :--- |
| **Discovery & Search** | iTunes Top Charts discovery, iTunes/Podcast Index search, multi-select preferred/hidden genres, per-podcast hiding, add any feed by direct RSS URL |
| **Languages** | Spoken-language filtering (not just storefront region) for Discover and Search, language + genre search filters, fully translated English/German interface (add a language with one JSON file) |
| **Playback** | Web Audio player, Media Session and Remote Playback APIs, playback-speed control, per-podcast controls, live scrubbing with chapter markers, jump-back after a large seek, a sleep timer that counts listening time rather than wall-clock time, a transcript that follows the playhead, timestamp handoff links, listening-time tracking, keyboard shortcuts |
| **Playback** | Web Audio player, Media Session and Remote Playback APIs, playback-speed control, per-podcast controls, live scrubbing with chapter markers, jump-back after a large seek, a sleep timer that counts listening time rather than wall-clock time, a transcript that follows the playhead, episode sharing with a timestamped link, listening-time tracking, keyboard shortcuts |
| **Library** | Subscriptions with folders, queue plus reusable named queues, smart queues built from saved rules, favorites, timestamp bookmarks, OPML import/export plus an optional auto-updating OPML backup file |
| **Accounts (optional)** | Argon2id hashing, Base32 recovery codes, HttpOnly session cookies, Bearer device tokens |
| **Sync** | Subscriptions, favorites, playback state, listening sessions, queue, podcast settings, and global settings via monotonic cursor pull/push and idempotent writes; settings merge per field, so two devices editing different preferences do not revert each other |
Expand Down Expand Up @@ -206,6 +206,7 @@ The backend is configured entirely through environment variables. Copy [`.env.ex
| `FEED_WORKER_CONCURRENCY` | `5` | Background feed-refresh workers |
| `FEED_MAX_RESPONSE_BYTES` | `33554432` | Max RSS body size in bytes (32 MiB; SSRF/DoS guard) |
| `FEED_MAX_STORED_EPISODES` | `200` | Recent metadata-cache rows retained per podcast; rows referenced by user state are preserved |
| `FEED_REFRESH_INTERVAL_MS` | `3600000` | How long a healthy feed waits before the background worker rechecks it (15 min – 24 h). Governs how promptly new-episode notifications arrive |
| `WEB_PUSH_VAPID_PUBLIC_KEY` / `_PRIVATE_KEY` | empty | Enables server-sent browser notifications; generate once with `cd services/api && go run ./cmd/vapid` |
| `WEB_PUSH_VAPID_SUBJECT` | `PUBLIC_BASE_URL` | VAPID contact URI (`https:` or `mailto:`) |
| `KC_AUDIO_EFFECTS_PROXY_ENABLED` | `true` | Relay fallback for CORS-blocked browser effects/downloads; set `false` to avoid relay bandwidth |
Expand Down
4 changes: 2 additions & 2 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ android {

defaultConfig {
applicationId = "net.koalastuff.koalacast"
versionCode = 42
versionName = "0.11.2"
versionCode = 43
versionName = "0.11.3"
}

signingConfigs {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package net.koalastuff.koalacast

import android.app.Activity
import android.app.Application
import android.os.Bundle
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.annotation.OptIn
Expand Down Expand Up @@ -88,6 +90,7 @@ class KoalaCastApplication : Application(), SingletonImageLoader.Factory, Config
// here makes the route button usable before playback starts; failures are
// non-fatal on devices without Google Play services.
runCatching { Cast.getSingletonInstance(this).initialize() }
registerActivityLifecycleCallbacks(foregroundWatcher)
applicationScope.launch {
// Must run before account origin selection, sync startup, workers, or any
// repository request. A token created for an HTTP origin is never moved
Expand Down Expand Up @@ -156,6 +159,33 @@ class KoalaCastApplication : Application(), SingletonImageLoader.Factory, Config
.crossfade(true)
.build()

/**
* Tells the sync coordinator when the app is actually on screen, so a return
* from the background syncs at once instead of waiting out the remainder of
* the periodic tick. Counting started activities rather than tracking a
* single one keeps a configuration change or a second task from reading as a
* trip to the background.
*/
private val foregroundWatcher = object : Application.ActivityLifecycleCallbacks {
private var startedActivities = 0

override fun onActivityStarted(activity: Activity) {
startedActivities += 1
if (startedActivities == 1) syncCoordinator.setForeground(true)
}

override fun onActivityStopped(activity: Activity) {
startedActivities = (startedActivities - 1).coerceAtLeast(0)
if (startedActivities == 0) syncCoordinator.setForeground(false)
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}

private companion object {
val SUBSCRIPTION_ARTWORK_DP = intArrayOf(40, 56, 160)
}
Expand Down
7 changes: 7 additions & 0 deletions apps/android/core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ plugins {

android {
namespace = "net.koalastuff.koalacast.core.data"

// This module owns the notification strings the background workers show, and
// those are the only user-facing text a listener sees without opening the
// app. Merged resources on the unit-test classpath are what lets a test
// assert the German ones are actually translated instead of silently
// falling back to English.
testOptions.unitTests.isIncludeAndroidResources = true
}

androidComponents {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import net.koalastuff.koalacast.core.data.R
import net.koalastuff.koalacast.core.data.db.PodcastSettingsDao
import net.koalastuff.koalacast.core.data.db.SubscriptionDao
import net.koalastuff.koalacast.core.data.auth.SecureAccountStore
Expand Down Expand Up @@ -125,7 +126,7 @@ class ContentRefreshWorker @AssistedInject constructor(
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"New podcast episodes",
applicationContext.getString(R.string.new_episodes_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
),
)
Expand All @@ -139,15 +140,24 @@ class ContentRefreshWorker @AssistedInject constructor(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
// Every one of these strings used to be an English literal, so the one
// part of the app a listener sees without opening it was the one part
// that ignored their language.
val first = updates.first()
val shows = updates.map { it.first }.distinct().size
val text = if (updates.size == 1) {
"${first.first}: ${first.second}"
applicationContext.getString(R.string.new_episodes_single, first.first, first.second)
} else {
"${updates.size} new episodes from ${updates.map { it.first }.distinct().size} shows"
applicationContext.resources.getQuantityString(
R.plurals.new_episodes_shows,
shows,
updates.size,
shows,
)
}
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("New in KoalaCast")
.setContentTitle(applicationContext.getString(R.string.new_episodes_title))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setAutoCancel(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package net.koalastuff.koalacast.core.data.repository

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import net.koalastuff.koalacast.core.data.auth.SecureAccountStore
import net.koalastuff.koalacast.core.data.di.ApplicationScope
Expand All @@ -17,6 +20,21 @@ class SyncCoordinator @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
) {
private var job: Job? = null
private val foreground = MutableStateFlow(false)

/**
* Reported by the application from the activity lifecycle.
*
* Returning to the app after listening somewhere else is exactly when a
* stale library is most visible, and the periodic tick alone made that a
* coin flip: the process is frozen while it is cached, so the remainder of
* a 45-second delay is only counted once the app is on screen again. An
* episode finished in the browser could therefore still be sitting there
* unplayed for the better part of a minute after opening the app.
*/
fun setForeground(value: Boolean) {
foreground.value = value
}

fun start() {
if (job != null) return
Expand All @@ -26,9 +44,20 @@ class SyncCoordinator @Inject constructor(
sync.signedOut()
return@collectLatest
}
while (true) {
sync.syncNow()
delay(INTERVAL_MS)
coroutineScope {
// `drop(1)` discards the state at subscription time: the loop
// below already syncs once here, so only a real transition
// back into the foreground should add a run.
launch {
foreground.drop(1).collect { visible -> if (visible) sync.syncNow() }
}
// The tick is deliberately not gated on the foreground.
// Playback continues with the screen off, and progress from
// that listening has to keep reaching the account.
while (true) {
sync.syncNow()
delay(INTERVAL_MS)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,13 @@ class SyncRepository @Inject constructor(
addedAt = payload.long("added_at").takeIf { it > 0 }
?: change.clientTimestamp.takeIf { it > 0 }
?: System.currentTimeMillis(),
// Without this the entity default silently rewrote updatedAt to
// addedAt, so a folder or inbox-mode edit made elsewhere landed
// here dated to the day the show was subscribed.
updatedAt = payload.long("updated_at").takeIf { it > 0 }
?: payload.long("added_at").takeIf { it > 0 }
?: change.clientTimestamp.takeIf { it > 0 }
?: System.currentTimeMillis(),
inboxMode = payload.string("inbox_mode")
.takeIf { it == SubscriptionEntity.INBOX_MODE_LATEST }
?: SubscriptionEntity.INBOX_MODE_ALL,
Expand Down Expand Up @@ -912,22 +919,31 @@ class SyncRepository @Inject constructor(
put("client_timestamp", item.lastPlayedAt)
}

/**
* Clamped to the server's own ceilings for a listening session. Past them the
* push is rejected with a 400, so this is not cosmetic: an unclamped session
* is a record that can never be uploaded. A session paused and resumed across
* days reaches the span limit on its own.
*/
private fun listeningPayload(item: ListeningSessionEntity) = buildJsonObject {
val endedAt = maxOf(item.endedAt, item.startedAt)
put("id", item.id)
put("episode_id", item.episodeId)
put("podcast_id", item.podcastId)
put("title", item.title)
put("podcast_title", item.podcastTitle)
put("categories", JsonArray(item.categories.map(::JsonPrimitive)))
put("started_at", item.startedAt)
put("ended_at", item.endedAt)
put("wall_clock_ms", item.wallClockMs)
put("audio_listened_ms", item.audioListenedMs)
put("speed_saved_ms", item.speedSavedMs)
put("silence_saved_ms", item.silenceSavedMs)
put("manual_skipped_ms", item.manualSkippedMs)
put("intro_outro_skipped_ms", item.introOutroSkippedMs)
put("speed_weighted_ms", item.speedWeightedMs)
// Hold the span, not the end: the end is what every last-writer-wins
// comparison keys on.
put("started_at", maxOf(item.startedAt, endedAt - MAX_SESSION_SPAN_MS))
put("ended_at", endedAt)
put("wall_clock_ms", item.wallClockMs.coerceIn(0, MAX_SESSION_SPAN_MS))
put("audio_listened_ms", item.audioListenedMs.coerceIn(0, MAX_SESSION_METRIC_MS))
put("speed_saved_ms", item.speedSavedMs.coerceIn(0, MAX_SESSION_METRIC_MS))
put("silence_saved_ms", item.silenceSavedMs.coerceIn(0, MAX_SESSION_METRIC_MS))
put("manual_skipped_ms", item.manualSkippedMs.coerceIn(0, MAX_SESSION_METRIC_MS))
put("intro_outro_skipped_ms", item.introOutroSkippedMs.coerceIn(0, MAX_SESSION_METRIC_MS))
put("speed_weighted_ms", item.speedWeightedMs.coerceIn(0, MAX_SESSION_METRIC_MS))
}

private fun queuePayload(items: List<QueueItemEntity>, updatedAt: Long) = buildJsonObject {
Expand Down Expand Up @@ -1089,6 +1105,8 @@ class SyncRepository @Inject constructor(
const val PAGE_LIMIT = 500
const val PUSH_BATCH = 250
const val MAX_SYNCED_DOWNLOAD_BUDGET_BYTES = 10L * 1024 * 1024 * 1024
const val MAX_SESSION_SPAN_MS = 7L * 24 * 60 * 60 * 1000
const val MAX_SESSION_METRIC_MS = MAX_SESSION_SPAN_MS * 4
val GENERAL_SYNC_ENTITIES = setOf(
"subscription",
"favorite",
Expand Down
7 changes: 7 additions & 0 deletions apps/android/core/data/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@
<string name="download_notification_title">Podcast-Folge</string>
<string name="download_notification_progress">Wird geladen …</string>
<string name="download_notification_percent">%1$d %%</string>
<string name="new_episodes_channel_name">Neue Podcast-Folgen</string>
<string name="new_episodes_title">Neu in KoalaCast</string>
<string name="new_episodes_single">%1$s: %2$s</string>
<plurals name="new_episodes_shows">
<item quantity="one">%1$d neue Folgen von einem Podcast</item>
<item quantity="other">%1$d neue Folgen von %2$d Podcasts</item>
</plurals>
</resources>
7 changes: 7 additions & 0 deletions apps/android/core/data/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@
<string name="download_notification_title">Podcast episode</string>
<string name="download_notification_progress">Downloading…</string>
<string name="download_notification_percent">%1$d%%</string>
<string name="new_episodes_channel_name">New podcast episodes</string>
<string name="new_episodes_title">New in KoalaCast</string>
<string name="new_episodes_single">%1$s: %2$s</string>
<plurals name="new_episodes_shows">
<item quantity="one">%1$d new episodes from one show</item>
<item quantity="other">%1$d new episodes from %2$d shows</item>
</plurals>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package net.koalastuff.koalacast.core.data.repository

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.util.Locale
import net.koalastuff.koalacast.core.data.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
* The new-episode notification is the one part of the app a listener sees
* without opening it, and it used to be the one part that ignored their
* language: every string was an English literal in the worker.
*
* These also pin the format arguments. A plural whose placeholders disagree with
* the call site does not fail to compile — it throws while building the
* notification, in a background worker, where nobody sees it.
*/
@RunWith(RobolectricTestRunner::class)
class NewEpisodeNotificationStringsTest {

private fun context(): Context = ApplicationProvider.getApplicationContext()

@Test
@Config(qualifiers = "en")
fun `english strings resolve and format`() {
val context = context()
assertEquals("New in KoalaCast", context.getString(R.string.new_episodes_title))
assertEquals(
"The Vergecast: Episode 1",
context.getString(R.string.new_episodes_single, "The Vergecast", "Episode 1"),
)
assertEquals(
"5 new episodes from 2 shows",
context.resources.getQuantityString(R.plurals.new_episodes_shows, 2, 5, 2),
)
}

@Test
@Config(qualifiers = "de")
fun `german strings are translated, not the english fallback`() {
val context = context()
val title = context.getString(R.string.new_episodes_title)
assertEquals("Neu in KoalaCast", title)
assertEquals(
"Neue Podcast-Folgen",
context.getString(R.string.new_episodes_channel_name),
)

val summary = context.resources.getQuantityString(R.plurals.new_episodes_shows, 2, 5, 2)
assertEquals("5 neue Folgen von 2 Podcasts", summary)
assertTrue(
"the German summary must not fall back to English",
!summary.contains("new episodes"),
)
}

@Test
@Config(qualifiers = "de")
fun `the single-episode line carries both arguments in german`() {
val line = context().getString(R.string.new_episodes_single, "Crime Junkie", "Folge 12")
assertEquals("Crime Junkie: Folge 12", line)
}

@Test
@Config(qualifiers = "de")
fun `one show still reads correctly`() {
val summary = context().resources.getQuantityString(R.plurals.new_episodes_shows, 1, 3, 1)
assertEquals("3 neue Folgen von einem Podcast", summary)
assertEquals(Locale.GERMAN.language, context().resources.configuration.locales[0].language)
}
}
Loading