summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rwxr-xr-x.githooks/pre-commit28
-rw-r--r--CLAUDE.md34
-rw-r--r--app/schemas/org.terst.listen.data.AppDatabase/3.json487
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt48
-rw-r--r--app/src/main/java/org/terst/listen/ListenApp.kt11
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt24
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt81
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt29
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt3
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt37
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedArchiveWalker.kt116
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt8
-rw-r--r--app/src/main/java/org/terst/listen/queue/DripReleaser.kt83
-rw-r--r--app/src/main/java/org/terst/listen/queue/DripSchedule.kt52
-rw-r--r--app/src/main/java/org/terst/listen/ui/DripSettings.kt137
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt135
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt6
-rw-r--r--app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt87
-rw-r--r--app/src/main/java/org/terst/listen/ui/RulesScreen.kt9
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt23
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt87
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt6
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt65
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt80
-rw-r--r--app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt124
-rw-r--r--app/src/test/java/org/terst/listen/queue/DripReleaserTest.kt261
-rw-r--r--app/src/test/java/org/terst/listen/queue/DripScheduleTest.kt106
28 files changed, 2062 insertions, 107 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 5088ccb..a5b6c8e 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-63.63
+65.30
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 32b492a..4a3bcc5 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -9,23 +9,23 @@ fail() {
exit 1
}
-echo "== [1/8] Build =="
+echo "== [1/9] Build =="
./gradlew :app:compileDebugKotlin -q
-echo "== [2/8] Tests =="
+echo "== [2/9] Tests =="
./gradlew :app:testDebugUnitTest -q
-echo "== [3/8] Static analysis: lint =="
+echo "== [3/9] Static analysis: lint =="
if ! ./gradlew :app:lintDebug -q; then
fail "lint failed -- fix all lint ERRORS before committing (warnings are fine to accumulate, see CLAUDE.md)."
fi
-echo "== [4/8] Static analysis: detekt (code-quality auditor) =="
+echo "== [4/9] Static analysis: detekt (code-quality auditor) =="
if ! ./gradlew :app:detekt -q; then
fail "detekt found new issues beyond the tracked baseline. Fix them, or if they're genuinely pre-existing debt being touched incidentally, regenerate the baseline with './gradlew :app:detektBaseline' and explain why in the commit message."
fi
-echo "== [5/8] Coverage must not decrease =="
+echo "== [5/9] Coverage must not decrease =="
./gradlew :app:jacocoTestReport -q
REPORT="app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml"
BASELINE_FILE=".coverage-baseline"
@@ -59,7 +59,7 @@ else
echo "pre-commit: coverage ${BASELINE}% -> ${CURRENT}%"
fi
-echo "== [6/8] WebView JavaScript justification =="
+echo "== [6/9] WebView JavaScript justification =="
STAGED_KT=$(git diff --cached --name-only --diff-filter=ACM -- '*.kt' || true)
if [ -n "$STAGED_KT" ]; then
for f in $STAGED_KT; do
@@ -73,14 +73,14 @@ if [ -n "$STAGED_KT" ]; then
done
fi
-echo "== [7/8] Manifest permission diff =="
+echo "== [7/9] Manifest permission diff =="
if git diff --cached --name-only | grep -q '^app/src/main/AndroidManifest.xml$'; then
if git diff --cached -- app/src/main/AndroidManifest.xml | grep -qE '^\+.*<uses-permission'; then
echo "pre-commit: WARNING -- AndroidManifest.xml adds a new <uses-permission>. Make sure the commit message says why."
fi
fi
-echo "== [8/8] Secret scan =="
+echo "== [8/9] Secret scan =="
if [ -z "${SECRET_SCAN_OVERRIDE:-}" ]; then
SECRET_HIT=$(git diff --cached -U0 -- . ':!*.lock' | grep -E '^\+' | grep -viE '^\+\+\+' | grep -E '(api[_-]?key|secret|password|token)["'\'' ]*[:=]["'\'' ]*[A-Za-z0-9_\-]{16,}' || true)
if [ -n "$SECRET_HIT" ]; then
@@ -91,4 +91,16 @@ else
echo "pre-commit: secret scan skipped (SECRET_SCAN_OVERRIDE set)"
fi
+echo "== [9/9] Exported Room schemas are append-only =="
+# A schema JSON already committed describes a database version that exists on a phone. Room
+# rewrites the file for whatever version the @Database annotation declares, so editing an entity
+# before bumping that number silently overwrites the record of the old shape -- and the migration
+# test then validates happily against a schema that never shipped. It happened twice building
+# Phase 2, and a note in CLAUDE.md did not prevent the second time.
+SCHEMA_EDITS=$(git diff --cached --name-only --diff-filter=M -- 'app/schemas/**/*.json' || true)
+if [ -n "$SCHEMA_EDITS" ]; then
+ echo "$SCHEMA_EDITS" >&2
+ fail "an already-committed Room schema was modified (see above). Bump the database version first so a new file is written, then restore these with: git checkout -- app/schemas/"
+fi
+
echo "pre-commit: all checks passed."
diff --git a/CLAUDE.md b/CLAUDE.md
index bca2a68..ab38a4b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -94,6 +94,9 @@ claims three separate times across these repos.
6. **Schema migrations** — if `AppDatabase.kt`'s `version` changed, a migration test must be
touched in the same diff. Room tracks by version; a wrong migration corrupts a real,
non-reproducible library.
+7. **Exported schemas are append-only** — a commit that *modifies* an existing
+ `app/schemas/**/*.json` is refused. Adding a new one is how a version bump is supposed to
+ look; changing an old one means the record of what is on the phone has been overwritten.
## State
@@ -108,8 +111,10 @@ library and voice search. 207 unit tests plus instrumented tests covering the me
title, duration, age and season, and actions to queue top/bottom, mark played, auto-download, or
pin a speed. `RuleEngine` decides, `RuleApplier` carries it out, `RuleSummary` describes a rule
in one line, and every claimed episode records which rule fired and why.
-- **Track 2, slow-drip archives — not started.** Includes walking RFC 5005 `prev-archive` pages;
- `ParsedFeed.previousArchiveUrl` is already parsed and tested and nothing consumes it.
+- **Track 2, slow-drip archives — done.** Per-show cadence (`DripSchedule` decides what is owed,
+ `DripReleaser` pays it out), a released-set the show and Android Auto both list, and
+ `FeedArchiveWalker` following RFC 5005 `prev-archive` pages to fetch a back catalogue. A drip
+ release always enqueues and deliberately bypasses the rule engine.
- **Track 3, simple skip — not started.** Manual per-show offsets (`skipIntroMs`/`skipOutroMs`
exist on `SubscriptionEntity` and nothing reads them) and Podcasting 2.0 chapter JSON via
`EpisodeEntity.chaptersUrl`, which the parser already captures.
@@ -194,8 +199,12 @@ library and voice search. 207 unit tests plus instrumented tests covering the me
for whatever version is declared, so editing `EpisodeEntity` while the database still says
`version = 1` silently overwrites `schemas/1.json` with the new shape. That file is the only
record of what is on the phone, and once it matches the new code the migration test validates
- happily against a schema that never existed. It happened building the rules table; the fix was
- `git checkout -- app/schemas/`.
+ happily against a schema that never existed.
+
+ This happened twice in one session -- once building the rules table, once building drip --
+ the second time *after* writing this warning down. Step 9 of the pre-commit hook now refuses a
+ commit that modifies an already-committed schema JSON, because a note was demonstrably not
+ enough. If it fires: bump the version first, then `git checkout -- app/schemas/`.
- **The migration test needs two pieces of plumbing that fail obscurely.** `MigrationTestHelper`
reads the exported schemas from the instrumented test APK's assets, so `schemas/` is wired into
@@ -215,6 +224,23 @@ library and voice search. 207 unit tests plus instrumented tests covering the me
episode screen is absent from the dump and plainly visible in a screenshot. Treat a missing
line in a dump as inconclusive, not as absence.
+- **A bare `Switch` has no name.** It announces nothing to TalkBack and `android-ui tap` cannot
+ find it, so both toggles carry an explicit `contentDescription` -- and the drip one says "Turn
+ slow drip on or off" rather than "Slow drip", because a description identical to the heading
+ beside it makes the tap ambiguous.
+
+- **`connectedDebugAndroidTest` can wipe the app's data.** A run cleared `listen.db` on the
+ emulator mid-session. It is the test harness's install path, not a migration: a real v2
+ database seeded with a subscription, an episode at position 424242 and a rule was upgraded in
+ place by launching the v3 build, and all three survived with drip defaulting to off. Do not
+ read an empty database after an instrumented run as a migration failure -- but do check.
+
+- **The drip released-set is a query parameter, not stored state.** `observeVisible`/`getVisible`
+ take `dripEnabled` and hide unreleased episodes only when it is true, so switching drip off
+ restores the whole catalogue without touching a row, and switching it back on resumes where it
+ left off. `getForSubscription` deliberately still returns everything: feed reconciliation must
+ see held-back episodes or it re-inserts them on every refresh.
+
- **XML comments cannot contain `--`.** The house prose style uses it constantly and it makes
the manifest unparseable, with a merge error that doesn't name the line.
diff --git a/app/schemas/org.terst.listen.data.AppDatabase/3.json b/app/schemas/org.terst.listen.data.AppDatabase/3.json
new file mode 100644
index 0000000..e3f6d98
--- /dev/null
+++ b/app/schemas/org.terst.listen.data.AppDatabase/3.json
@@ -0,0 +1,487 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 3,
+ "identityHash": "f792bfdf2f97994f6ba518a935f019ca",
+ "entities": [
+ {
+ "tableName": "subscriptions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, `addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, `lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, `skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, `dripEnabled` INTEGER NOT NULL DEFAULT 0, `dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000, `dripCount` INTEGER NOT NULL DEFAULT 1, `dripOldestFirst` INTEGER NOT NULL DEFAULT 1, `dripLastReleaseAtMs` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "feedUrl",
+ "columnName": "feedUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "link",
+ "columnName": "link",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "addedAtMs",
+ "columnName": "addedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "etag",
+ "columnName": "etag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastModified",
+ "columnName": "lastModified",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastFetchedAtMs",
+ "columnName": "lastFetchedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastError",
+ "columnName": "lastError",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "playbackSpeed",
+ "columnName": "playbackSpeed",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "skipIntroMs",
+ "columnName": "skipIntroMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "skipOutroMs",
+ "columnName": "skipOutroMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dripEnabled",
+ "columnName": "dripEnabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "dripIntervalMs",
+ "columnName": "dripIntervalMs",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "86400000"
+ },
+ {
+ "fieldPath": "dripCount",
+ "columnName": "dripCount",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "dripOldestFirst",
+ "columnName": "dripOldestFirst",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "dripLastReleaseAtMs",
+ "columnName": "dripLastReleaseAtMs",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_subscriptions_feedUrl",
+ "unique": true,
+ "columnNames": [
+ "feedUrl"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)"
+ }
+ ]
+ },
+ {
+ "tableName": "episodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "guid",
+ "columnName": "guid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaUrl",
+ "columnName": "mediaUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaType",
+ "columnName": "mediaType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaLengthBytes",
+ "columnName": "mediaLengthBytes",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "durationMs",
+ "columnName": "durationMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "publishedAtMs",
+ "columnName": "publishedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "seasonNumber",
+ "columnName": "seasonNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "episodeNumber",
+ "columnName": "episodeNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "chaptersUrl",
+ "columnName": "chaptersUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "discoveredAtMs",
+ "columnName": "discoveredAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "positionMs",
+ "columnName": "positionMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playedAtMs",
+ "columnName": "playedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "downloadState",
+ "columnName": "downloadState",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localPath",
+ "columnName": "localPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "downloadedAtMs",
+ "columnName": "downloadedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dripReleasedAtMs",
+ "columnName": "dripReleasedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "appliedRuleId",
+ "columnName": "appliedRuleId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "appliedRuleReason",
+ "columnName": "appliedRuleReason",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "speedOverride",
+ "columnName": "speedOverride",
+ "affinity": "REAL"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_episodes_subscriptionId",
+ "unique": false,
+ "columnNames": [
+ "subscriptionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)"
+ },
+ {
+ "name": "index_episodes_subscriptionId_guid",
+ "unique": true,
+ "columnNames": [
+ "subscriptionId",
+ "guid"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)"
+ },
+ {
+ "name": "index_episodes_publishedAtMs",
+ "unique": false,
+ "columnNames": [
+ "publishedAtMs"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "subscriptionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "queue",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "episodeId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_queue_position",
+ "unique": false,
+ "columnNames": [
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "episodes",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "episodeId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "rules",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT, `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER, `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL, `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "enabled",
+ "columnName": "enabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "titleContains",
+ "columnName": "titleContains",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "titleExcludes",
+ "columnName": "titleExcludes",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "minDurationMs",
+ "columnName": "minDurationMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "maxDurationMs",
+ "columnName": "maxDurationMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "maxAgeMs",
+ "columnName": "maxAgeMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "seasonNumber",
+ "columnName": "seasonNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "action",
+ "columnName": "action",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "autoDownload",
+ "columnName": "autoDownload",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "speed",
+ "columnName": "speed",
+ "affinity": "REAL"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_rules_position",
+ "unique": false,
+ "columnNames": [
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_rules_position` ON `${TABLE_NAME}` (`position`)"
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f792bfdf2f97994f6ba518a935f019ca')"
+ ]
+ }
+} \ No newline at end of file
diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
index c1c276a..6c40126 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -62,4 +62,52 @@ class MigrationTest {
assertEquals(0, cursor.getInt(0))
}
}
+
+ /**
+ * Straight from 1 to 3, the path a phone that skipped a build actually takes.
+ *
+ * Testing 2 to 3 alone would miss a chain that only works when every step has been applied
+ * in the same session, which is not how an installed app upgrades.
+ */
+ @Test
+ fun migrates1To3KeepingEpisodesAndDefaultingDrip() {
+ helper.createDatabase(name, 1).use { db ->
+ db.execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0)"""
+ )
+ db.execSQL(
+ """INSERT INTO episodes
+ (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs,
+ positionMs, downloadState)
+ VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000,
+ 98765, 'NONE')"""
+ )
+ }
+
+ val db = helper.runMigrationsAndValidate(
+ name, 3, true, AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3,
+ )
+
+ // An existing show must come out with drip off, not half-configured: a default that
+ // arrived as enabled would hide every episode of every show the listener already had.
+ db.query(
+ """SELECT dripEnabled, dripIntervalMs, dripCount, dripOldestFirst, dripLastReleaseAtMs
+ FROM subscriptions WHERE id = 's1'"""
+ ).use { cursor ->
+ cursor.moveToFirst()
+ assertEquals(0, cursor.getInt(0))
+ assertEquals(86_400_000L, cursor.getLong(1))
+ assertEquals(1, cursor.getInt(2))
+ assertEquals(1, cursor.getInt(3))
+ assertEquals(true, cursor.isNull(4))
+ }
+
+ db.query("SELECT positionMs, dripReleasedAtMs FROM episodes WHERE id = 'e1'").use { cursor ->
+ cursor.moveToFirst()
+ assertEquals("the crown jewel must survive two migrations too", 98765L, cursor.getLong(0))
+ assertEquals(true, cursor.isNull(1))
+ }
+ }
}
diff --git a/app/src/main/java/org/terst/listen/ListenApp.kt b/app/src/main/java/org/terst/listen/ListenApp.kt
index 66aada9..89a32d2 100644
--- a/app/src/main/java/org/terst/listen/ListenApp.kt
+++ b/app/src/main/java/org/terst/listen/ListenApp.kt
@@ -4,6 +4,10 @@ import android.app.Application
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
import org.terst.listen.data.Graph
import org.terst.listen.work.Scheduling
@@ -13,6 +17,13 @@ class ListenApp : Application(), SingletonImageLoader.Factory {
// Scheduled on every launch but enqueued with KEEP, so an existing schedule is left
// alone rather than having its interval reset each time the app opens.
Scheduling.schedule(this)
+
+ // Slow-drip also runs on launch, not only from the worker. WorkManager defers a
+ // periodic job freely, and a drip that has not paid out because the phone decided the
+ // job could wait is indistinguishable from one that is broken.
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ Graph.get(this@ListenApp).drip.releaseAll()
+ }
}
// Routes every AsyncImage through the app's own OkHttpClient -- see AppGraph.imageLoader.
diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
index 51c57b2..f779b06 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -25,7 +25,7 @@ class Converters {
QueueEntity::class,
RuleEntity::class,
],
- version = 2,
+ version = 3,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -62,6 +62,26 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * Slow-drip: per-show cadence, and the release mark on each episode.
+ *
+ * The four non-null columns carry SQL defaults because SQLite cannot add a NOT NULL
+ * column without one. The same defaults are declared on the entity, so the exported
+ * schema and this statement describe the same table.
+ */
+ val MIGRATION_2_3 = object : Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `dripEnabled` INTEGER NOT NULL DEFAULT 0")
+ db.execSQL(
+ "ALTER TABLE `subscriptions` ADD COLUMN `dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000"
+ )
+ db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `dripCount` INTEGER NOT NULL DEFAULT 1")
+ db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `dripOldestFirst` INTEGER NOT NULL DEFAULT 1")
+ db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `dripLastReleaseAtMs` INTEGER")
+ db.execSQL("ALTER TABLE `episodes` ADD COLUMN `dripReleasedAtMs` INTEGER")
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -70,7 +90,7 @@ abstract class AppDatabase : RoomDatabase() {
Room.databaseBuilder(context, AppDatabase::class.java, "listen.db")
// Listed one by one rather than spread from an array: every migration here is a
// deliberate addition, and the list is the record of them.
- .addMigrations(MIGRATION_1_2)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
// No fallbackToDestructiveMigration. A listener's playback positions and play
// history are not reproducible from anywhere, so a missing migration must fail
// loudly rather than silently wipe the library.
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index 67f405f..6b2d334 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -58,16 +58,93 @@ interface SubscriptionDao {
@Query("UPDATE subscriptions SET playbackSpeed = :speed WHERE id = :id")
suspend fun updateSpeed(id: String, speed: Float)
+
+ @Query("SELECT * FROM subscriptions WHERE dripEnabled = 1")
+ suspend fun dripEnabled(): List<SubscriptionEntity>
+
+ @Query(
+ """UPDATE subscriptions
+ SET dripEnabled = :enabled, dripIntervalMs = :intervalMs, dripCount = :count,
+ dripOldestFirst = :oldestFirst
+ WHERE id = :id"""
+ )
+ suspend fun updateDrip(
+ id: String,
+ enabled: Boolean,
+ intervalMs: Long,
+ count: Int,
+ oldestFirst: Boolean,
+ )
+
+ @Query("UPDATE subscriptions SET dripLastReleaseAtMs = :atMs WHERE id = :id")
+ suspend fun setDripMark(id: String, atMs: Long?)
}
@Dao
interface EpisodeDao {
- @Query("SELECT * FROM episodes WHERE subscriptionId = :subscriptionId ORDER BY publishedAtMs DESC")
- fun observeForSubscription(subscriptionId: String): Flow<List<EpisodeEntity>>
+ /**
+ * What a show displays.
+ *
+ * [dripEnabled] hides everything slow-drip has not released yet -- the synthetic released-set
+ * a drip subscription shows instead of the raw feed. It is a parameter rather than a join so
+ * that turning drip off needs no data change: the column is simply ignored.
+ */
+ @Query(
+ """SELECT * FROM episodes
+ WHERE subscriptionId = :subscriptionId
+ AND (:dripEnabled = 0 OR dripReleasedAtMs IS NOT NULL)
+ ORDER BY publishedAtMs DESC"""
+ )
+ fun observeVisible(subscriptionId: String, dripEnabled: Boolean): Flow<List<EpisodeEntity>>
+
+ /** The same set, for Android Auto's browse tree, which cannot observe a Flow. */
+ @Query(
+ """SELECT * FROM episodes
+ WHERE subscriptionId = :subscriptionId
+ AND (:dripEnabled = 0 OR dripReleasedAtMs IS NOT NULL)
+ ORDER BY publishedAtMs DESC"""
+ )
+ suspend fun getVisible(subscriptionId: String, dripEnabled: Boolean): List<EpisodeEntity>
+ /**
+ * Every episode, released or not.
+ *
+ * Feed reconciliation must see the held-back ones: matching against the visible set alone
+ * would read an unreleased episode as missing and insert it again on every refresh.
+ */
@Query("SELECT * FROM episodes WHERE subscriptionId = :subscriptionId")
suspend fun getForSubscription(subscriptionId: String): List<EpisodeEntity>
+ @Query(
+ """SELECT * FROM episodes
+ WHERE subscriptionId = :subscriptionId AND dripReleasedAtMs IS NULL
+ ORDER BY COALESCE(publishedAtMs, discoveredAtMs) ASC LIMIT :limit"""
+ )
+ suspend fun unreleasedOldestFirst(subscriptionId: String, limit: Int): List<EpisodeEntity>
+
+ @Query(
+ """SELECT * FROM episodes
+ WHERE subscriptionId = :subscriptionId AND dripReleasedAtMs IS NULL
+ ORDER BY COALESCE(publishedAtMs, discoveredAtMs) DESC LIMIT :limit"""
+ )
+ suspend fun unreleasedNewestFirst(subscriptionId: String, limit: Int): List<EpisodeEntity>
+
+ @Query("UPDATE episodes SET dripReleasedAtMs = :atMs WHERE id = :id")
+ suspend fun markDripReleased(id: String, atMs: Long)
+
+ /**
+ * Releases everything already heard or already queued, used when drip is switched on.
+ *
+ * Without it, enabling drip on a show followed for a year makes it look like it has three
+ * episodes, and strands anything mid-queue where nothing can reach it.
+ */
+ @Query(
+ """UPDATE episodes SET dripReleasedAtMs = :atMs
+ WHERE subscriptionId = :subscriptionId AND dripReleasedAtMs IS NULL
+ AND (playedAtMs IS NOT NULL OR id IN (SELECT episodeId FROM queue))"""
+ )
+ suspend fun releaseConsumed(subscriptionId: String, atMs: Long)
+
@Query("SELECT * FROM episodes WHERE id = :id")
suspend fun getById(id: String): EpisodeEntity?
diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt
index 6e88fd0..f366aab 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -1,5 +1,6 @@
package org.terst.listen.data
+import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@@ -44,8 +45,28 @@ data class SubscriptionEntity(
/** Tier 2 of the skip feature: manual offsets, always available when detection is wrong. */
val skipIntroMs: Long = 0,
val skipOutroMs: Long = 0,
+
+ /**
+ * Slow-drip: release a back catalogue a few episodes at a time rather than all at once.
+ *
+ * While enabled, the show lists only released episodes -- a synthetic set rather than the
+ * raw feed -- so a 600-episode archive reads as a show you are keeping up with.
+ */
+ // SQLite cannot add a NOT NULL column without a default, so these carry one explicitly.
+ // Declaring it on the entity as well keeps the migration and the exported schema agreeing;
+ // without it the migration validates against a column Room did not expect.
+ @ColumnInfo(defaultValue = "0") val dripEnabled: Boolean = false,
+ @ColumnInfo(defaultValue = "86400000") val dripIntervalMs: Long = DEFAULT_DRIP_INTERVAL_MS,
+ @ColumnInfo(defaultValue = "1") val dripCount: Int = 1,
+ /** Archives are usually worth hearing in order; a few are not, hence the choice. */
+ @ColumnInfo(defaultValue = "1") val dripOldestFirst: Boolean = true,
+ /** When the last batch was released. Null means drip has never paid out for this show. */
+ val dripLastReleaseAtMs: Long? = null,
)
+/** One a day, the cadence a drip subscription is for. */
+const val DEFAULT_DRIP_INTERVAL_MS: Long = 24 * 60 * 60 * 1000L
+
@Entity(
tableName = "episodes",
foreignKeys = [
@@ -89,6 +110,14 @@ data class EpisodeEntity(
val localPath: String? = null,
val downloadedAtMs: Long? = null,
+ /**
+ * When slow-drip released this episode, or null while it is still held back.
+ *
+ * Only meaningful on a show with [SubscriptionEntity.dripEnabled]; elsewhere it is ignored,
+ * so switching drip off makes the whole catalogue visible again without touching a row.
+ */
+ val dripReleasedAtMs: Long? = null,
+
/** Which auto-queue rule claimed this episode, and the account that rule gave for it. */
val appliedRuleId: String? = null,
val appliedRuleReason: String? = null,
diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt
index cbc4807..4f39c13 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -8,6 +8,7 @@ import org.terst.listen.download.ChaquopyYouTubeSource
import org.terst.listen.download.EpisodeDownloader
import org.terst.listen.feed.FeedFetcher
import org.terst.listen.feed.FeedResolver
+import org.terst.listen.queue.DripReleaser
import org.terst.listen.queue.QueueOrdering
import org.terst.listen.queue.RetentionPolicy
import org.terst.listen.queue.RuleApplier
@@ -53,6 +54,8 @@ class AppGraph(private val context: Context) {
val ruleApplier: RuleApplier by lazy { RuleApplier(ruleDao, episodes, queue) }
+ val drip: DripReleaser by lazy { DripReleaser(subscriptions, episodes, queue) }
+
val repository: SubscriptionRepository by lazy {
SubscriptionRepository(
subscriptions,
diff --git a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
index 6ffe874..5a341ff 100644
--- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
+++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
@@ -2,6 +2,7 @@ package org.terst.listen.data
import org.terst.listen.feed.FeedFetchResult
import org.terst.listen.feed.FeedResolver
+import org.terst.listen.feed.FeedArchiveWalker
import org.terst.listen.feed.FeedFetcher
import org.terst.listen.feed.ParsedFeed
import java.util.UUID
@@ -13,6 +14,14 @@ sealed interface RefreshOutcome {
data class Failed(val message: String) : RefreshOutcome
}
+/** Outcome of walking a feed's RFC 5005 archive pages. */
+sealed interface ArchiveOutcome {
+ /** [stoppedEarly] is null when the walk reached the end of the chain. */
+ data class Fetched(val newEpisodes: Int, val pages: Int, val stoppedEarly: String?) : ArchiveOutcome
+ data object NoArchive : ArchiveOutcome
+ data class Failed(val message: String) : ArchiveOutcome
+}
+
private const val SAVED_FEED_URL = "listen:saved"
private const val SAVED_TITLE = "Saved videos"
@@ -89,6 +98,34 @@ class SubscriptionRepository(
subscriptions.getAll().associate { it.id to refresh(it) }
/**
+ * Pulls a show's back catalogue by walking its RFC 5005 `prev-archive` chain.
+ *
+ * What slow-drip needs to have anything to drip: plenty of feeds carry only recent episodes.
+ * The current page is fetched afresh rather than relying on stored validators, because a 304
+ * would carry no archive link to follow.
+ *
+ * Reported as a back catalogue, not as newly published episodes -- the rule engine must not
+ * see it, for the same reason a first subscribe does not hand its history over.
+ */
+ suspend fun fetchArchive(subscription: SubscriptionEntity): ArchiveOutcome {
+ val current = when (val fetched = fetcher.fetch(subscription.feedUrl, null, null)) {
+ is FeedFetchResult.Failed -> return ArchiveOutcome.Failed(fetched.message)
+ FeedFetchResult.NotModified ->
+ return ArchiveOutcome.Failed("Server reported not-modified for an unconditional request")
+ is FeedFetchResult.Fetched -> fetched.feed
+ }
+
+ val startUrl = current.previousArchiveUrl ?: return ArchiveOutcome.NoArchive
+ val walk = FeedArchiveWalker(fetcher).walk(startUrl)
+
+ val stored = episodes.getForSubscription(subscription.id)
+ val result = EpisodeMatcher.match(subscription.id, stored, walk.episodes, now())
+ if (result.inserts.isNotEmpty()) episodes.insertAll(result.inserts)
+
+ return ArchiveOutcome.Fetched(result.inserts.size, walk.pages, walk.stoppedEarly)
+ }
+
+ /**
* Reconciles a fetched feed against what is stored.
*
* [report] is false for the first import of a new subscription. That import discovers the
diff --git a/app/src/main/java/org/terst/listen/feed/FeedArchiveWalker.kt b/app/src/main/java/org/terst/listen/feed/FeedArchiveWalker.kt
new file mode 100644
index 0000000..e537764
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/feed/FeedArchiveWalker.kt
@@ -0,0 +1,116 @@
+package org.terst.listen.feed
+
+import java.net.URI
+import java.net.URISyntaxException
+
+/** What an archive walk found, and why it stopped if it did not reach the end. */
+data class ArchiveWalk(
+ val episodes: List<ParsedEpisode>,
+ val pages: Int,
+ /** Null when the chain ran out on its own; otherwise what cut the walk short. */
+ val stoppedEarly: String? = null,
+)
+
+/**
+ * Walks RFC 5005 `prev-archive` pages back through a publisher's catalogue.
+ *
+ * This is what makes slow-drip worth anything. Plenty of feeds carry only recent episodes, and
+ * without paging a drip subscription started today can only drip what the current page exposes
+ * -- point it at a 600-episode backlog and find 50.
+ *
+ * Every stop is reported rather than swallowed, and a partial walk keeps what it collected: half
+ * an archive is useful, and a silent half is the kind of failure that surfaces months later as
+ * "why did this show run out?".
+ */
+class FeedArchiveWalker(
+ private val fetcher: FeedFetcher,
+ private val maxPages: Int = DEFAULT_MAX_PAGES,
+) {
+
+ suspend fun walk(startUrl: String?): ArchiveWalk {
+ val episodes = mutableListOf<ParsedEpisode>()
+ val visited = mutableSetOf<String>()
+ var next = startUrl
+ var pages = 0
+ var stopped: String? = null
+ var walking = true
+
+ while (walking) {
+ val page = next
+ val step = when {
+ page == null -> Step.Done
+ // Real publishers do produce archives that point back at themselves, and
+ // following one costs bandwidth all the way to the page cap rather than failing.
+ !visited.add(page) -> Step.Stop("the archive links back on itself")
+ pages >= maxPages -> Step.Stop("stopped after $maxPages pages")
+ else -> step(page)
+ }
+
+ when (step) {
+ Step.Done -> walking = false
+ is Step.Stop -> {
+ stopped = step.reason
+ walking = false
+ }
+
+ is Step.Page -> {
+ episodes += step.episodes
+ pages++
+ next = step.next
+ }
+ }
+ }
+
+ return ArchiveWalk(episodes, pages, stopped)
+ }
+
+ private sealed interface Step {
+ data class Page(val episodes: List<ParsedEpisode>, val next: String?) : Step
+ data class Stop(val reason: String) : Step
+
+ /** The chain ran out on its own, which is a completed walk rather than a stop. */
+ data object Done : Step
+ }
+
+ private suspend fun step(page: String): Step =
+ when (val fetched = fetcher.fetch(page, null, null)) {
+ is FeedFetchResult.Failed -> Step.Stop(fetched.message)
+
+ // Nothing was sent to make a 304 possible, so a server claiming one is not going to
+ // hand over the page however many times it is asked.
+ FeedFetchResult.NotModified -> Step.Stop("server reported not-modified")
+
+ is FeedFetchResult.Fetched -> {
+ val href = fetched.feed.previousArchiveUrl
+ val next = href?.let { resolve(it, against = page) }
+ when {
+ href == null -> Step.Page(fetched.feed.episodes, null)
+ next?.isSuccess == true -> Step.Page(fetched.feed.episodes, next.getOrNull())
+ else -> Step.Stop(
+ """could not follow the archive link "$href": """ +
+ (next?.exceptionOrNull()?.message ?: "malformed")
+ )
+ }
+ }
+ }
+
+ /**
+ * RFC 5005 hrefs are routinely relative, and relative to the page that carried them.
+ *
+ * A link that will not resolve ends the walk with its reason attached rather than quietly:
+ * "the archive stopped here" and "the archive link was malformed" look identical from the
+ * outside, and only one of them is worth telling the publisher about.
+ */
+ private fun resolve(href: String, against: String): Result<String> = try {
+ Result.success(URI(against).resolve(href).toString())
+ } catch (e: URISyntaxException) {
+ Result.failure(e)
+ } catch (e: IllegalArgumentException) {
+ Result.failure(e)
+ }
+
+ private companion object {
+ /** Enough for a decade of weekly episodes, and a floor under a chain that never ends. */
+ const val DEFAULT_MAX_PAGES = 50
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
index eeffe7c..33b928e 100644
--- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
+++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
@@ -111,8 +111,9 @@ class ListenSessionCallback(
val show = graph.subscriptions.getById(id)
// Newest first, and capped: a feed here held 2753 episodes, and neither a car
// screen nor the browser protocol wants that list.
- graph.episodes.getForSubscription(id)
- .sortedByDescending { it.publishedAtMs ?: 0 }
+ // The show's visible set, not every row: a drip subscription browses in the
+ // car as the same synthetic released-set the phone shows.
+ graph.episodes.getVisible(id, show?.dripEnabled == true)
.take(MAX_EPISODES)
.map { LibraryTree.episode(it, show?.title, show?.imageUrl) }
}
@@ -202,9 +203,8 @@ class ListenSessionCallback(
val showHits = shows.filter { it.title.lowercase().contains(needle) }
val episodeHits = shows.flatMap { show ->
- graph.episodes.getForSubscription(show.id)
+ graph.episodes.getVisible(show.id, show.dripEnabled)
.filter { it.title.lowercase().contains(needle) }
- .sortedByDescending { it.publishedAtMs ?: 0 }
.take(MAX_EPISODE_HITS_PER_SHOW)
.map { LibraryTree.episode(it, show.title, show.imageUrl) }
}
diff --git a/app/src/main/java/org/terst/listen/queue/DripReleaser.kt b/app/src/main/java/org/terst/listen/queue/DripReleaser.kt
new file mode 100644
index 0000000..e6c7b6b
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/queue/DripReleaser.kt
@@ -0,0 +1,83 @@
+package org.terst.listen.queue
+
+import org.terst.listen.data.EpisodeDao
+import org.terst.listen.data.QueueRepository
+import org.terst.listen.data.SubscriptionDao
+import org.terst.listen.data.SubscriptionEntity
+
+/**
+ * Pays out what [DripSchedule] decided a slow-drip subscription owes.
+ *
+ * A release always enqueues, and deliberately does not go through [RuleEngine]. Drip decides
+ * *when* you see an episode and the rules decide *what happens* to one -- running a released
+ * episode through the rules as well would let a rule silently cancel a release, which reads as
+ * the drip being broken.
+ */
+class DripReleaser(
+ private val subscriptions: SubscriptionDao,
+ private val episodes: EpisodeDao,
+ private val queue: QueueRepository,
+ private val now: () -> Long = System::currentTimeMillis,
+) {
+
+ /** Returns how many episodes were released across every drip subscription. */
+ suspend fun releaseAll(): Int = subscriptions.dripEnabled().sumOf { release(it) }
+
+ suspend fun release(subscription: SubscriptionEntity): Int {
+ val nowMs = now()
+ val decision = DripSchedule.due(subscription, nowMs) ?: return 0
+
+ val batch = if (subscription.dripOldestFirst) {
+ episodes.unreleasedOldestFirst(subscription.id, decision.count)
+ } else {
+ episodes.unreleasedNewestFirst(subscription.id, decision.count)
+ }
+
+ for (episode in batch) {
+ episodes.markDripReleased(episode.id, nowMs)
+ queue.append(episode.id)
+ }
+
+ // The clock advances even when the archive had nothing left. Leaving it behind would
+ // build a debt that pays out in a burst the next time the publisher posts.
+ subscriptions.setDripMark(subscription.id, decision.nextMarkMs)
+ return batch.size
+ }
+
+ /**
+ * Switches drip on for a show and settles what it already owes you.
+ *
+ * Anything played or already queued is released up front: hiding episodes you have heard
+ * would make a show followed for a year look like it has three, and hiding one mid-queue
+ * would strand it somewhere nothing can reach.
+ */
+ suspend fun enable(subscriptionId: String, intervalMs: Long, count: Int, oldestFirst: Boolean) {
+ val wasEnabled = subscriptions.getById(subscriptionId)?.dripEnabled == true
+ subscriptions.updateDrip(subscriptionId, true, intervalMs, count, oldestFirst)
+ episodes.releaseConsumed(subscriptionId, now())
+
+ // Switching it on starts the cadence from now. Keeping a mark left over from the last
+ // time drip was on would make the show owe every interval since, and pay it out as a
+ // capped burst the moment it is re-enabled. Adjusting the settings of a drip that is
+ // already running leaves its clock where it is.
+ if (!wasEnabled) subscriptions.setDripMark(subscriptionId, null)
+ }
+
+ /**
+ * Switches drip off, leaving every release mark in place.
+ *
+ * Visibility reads the show's flag, not the marks, so the catalogue reappears without a
+ * data change -- and switching drip back on resumes where it left off rather than starting
+ * the archive again.
+ */
+ suspend fun disable(subscriptionId: String) {
+ val stored = subscriptions.getById(subscriptionId) ?: return
+ subscriptions.updateDrip(
+ subscriptionId,
+ enabled = false,
+ intervalMs = stored.dripIntervalMs,
+ count = stored.dripCount,
+ oldestFirst = stored.dripOldestFirst,
+ )
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/queue/DripSchedule.kt b/app/src/main/java/org/terst/listen/queue/DripSchedule.kt
new file mode 100644
index 0000000..789fdab
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/queue/DripSchedule.kt
@@ -0,0 +1,52 @@
+package org.terst.listen.queue
+
+import org.terst.listen.data.SubscriptionEntity
+
+/** How many episodes a drip owes, and where its clock moves to once they are released. */
+data class DripDecision(val count: Int, val nextMarkMs: Long)
+
+/**
+ * When a slow-drip subscription owes episodes, and how many.
+ *
+ * Pure and storage-free like the rest of this package. The arithmetic is where a drip goes
+ * wrong: one that drifts stops being daily, and one that pays back a month of absence in a
+ * single run is not a drip at all.
+ */
+object DripSchedule {
+
+ /**
+ * The most one run will release, however long the app has been closed.
+ *
+ * A month away from a daily drip owes thirty episodes; handing those over at once empties
+ * the archive into the queue, which is the exact thing the feature exists to avoid.
+ */
+ private const val MAX_PER_RUN = 10
+
+ /** Null when nothing is owed. */
+ fun due(subscription: SubscriptionEntity, nowMs: Long): DripDecision? {
+ if (!subscription.dripEnabled) return null
+ // Stored values are not to be trusted: a zero interval divides by zero, and a zero
+ // count would release nothing while still advancing the clock.
+ if (subscription.dripIntervalMs <= 0 || subscription.dripCount <= 0) return null
+
+ val last = subscription.dripLastReleaseAtMs
+ // Never paid out: switching drip on should put something in the queue now, not
+ // tomorrow, and the clock starts from this moment.
+ ?: return DripDecision(subscription.dripCount, nowMs)
+
+ val elapsed = nowMs - last
+ if (elapsed < subscription.dripIntervalMs) return null
+
+ val periods = elapsed / subscription.dripIntervalMs
+ val owed = (periods * subscription.dripCount).coerceAtMost(MAX_PER_RUN.toLong()).toInt()
+
+ // The mark advances by whole intervals, not to now: moving it to now would discard the
+ // part-interval already elapsed, and a six-hourly worker against a daily drip would
+ // lose hours every day until "one a day" meant one every day and a bit.
+ //
+ // It advances by the full count even when the release was capped. Carrying the excess
+ // would make the next run pay out another capped batch, and the one after that, until
+ // the debt cleared -- which is the burst this cap exists to prevent.
+ return DripDecision(owed, last + periods * subscription.dripIntervalMs)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/DripSettings.kt b/app/src/main/java/org/terst/listen/ui/DripSettings.kt
new file mode 100644
index 0000000..9b8188a
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/DripSettings.kt
@@ -0,0 +1,137 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import org.terst.listen.data.SubscriptionEntity
+import java.util.concurrent.TimeUnit
+
+/** How often a drip pays out. Anything slower than a fortnight is indistinguishable from off. */
+private val DRIP_DAYS = listOf(1L, 2L, 3L, 7L, 14L)
+
+/** How many at a time. Past three it stops being a drip. */
+private val DRIP_COUNTS = listOf(1, 2, 3)
+
+/**
+ * Slow-drip controls for one show.
+ *
+ * Sits on the show rather than in settings because the cadence is a property of the archive you
+ * are working through, not of the app.
+ */
+@Composable
+fun DripSettings(
+ subscription: SubscriptionEntity,
+ busy: Boolean,
+ onEnable: (intervalMs: Long, count: Int, oldestFirst: Boolean) -> Unit,
+ onDisable: () -> Unit,
+ onFetchArchive: () -> Unit,
+) {
+ Column(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Switch(
+ // Named, because a bare switch has nothing for a screen reader to announce --
+ // and nothing for the UI checker to tap by.
+ modifier = Modifier.semantics { contentDescription = "Turn slow drip on or off" },
+ checked = subscription.dripEnabled,
+ onCheckedChange = { wanted ->
+ if (wanted) {
+ onEnable(
+ subscription.dripIntervalMs,
+ subscription.dripCount,
+ subscription.dripOldestFirst,
+ )
+ } else {
+ onDisable()
+ }
+ },
+ )
+ Column(Modifier.padding(start = 12.dp)) {
+ Text("Slow drip", style = MaterialTheme.typography.titleSmall)
+ Text(dripSummary(subscription), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+
+ if (subscription.dripEnabled) {
+ DripCadence(subscription, onEnable)
+ }
+
+ OutlinedButton(onClick = onFetchArchive, enabled = !busy) { Text("Fetch archive") }
+ Text(
+ "Pulls older episodes by following the feed's archive pages. Many publishers do not " +
+ "offer them, in which case a drip can only work through what the feed lists today.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
+@Composable
+private fun DripCadence(
+ subscription: SubscriptionEntity,
+ onEnable: (Long, Int, Boolean) -> Unit,
+) {
+ val days = TimeUnit.MILLISECONDS.toDays(subscription.dripIntervalMs)
+
+ Text("One batch every", style = MaterialTheme.typography.bodySmall)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ DRIP_DAYS.forEach { option ->
+ DripChip(if (option == 1L) "day" else "$option days", days == option) {
+ onEnable(TimeUnit.DAYS.toMillis(option), subscription.dripCount, subscription.dripOldestFirst)
+ }
+ }
+ }
+
+ Text("Episodes per batch", style = MaterialTheme.typography.bodySmall)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ DRIP_COUNTS.forEach { option ->
+ DripChip("$option", subscription.dripCount == option) {
+ onEnable(subscription.dripIntervalMs, option, subscription.dripOldestFirst)
+ }
+ }
+ }
+
+ Text("Working through the archive", style = MaterialTheme.typography.bodySmall)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ DripChip("Oldest first", subscription.dripOldestFirst) {
+ onEnable(subscription.dripIntervalMs, subscription.dripCount, true)
+ }
+ DripChip("Newest first", !subscription.dripOldestFirst) {
+ onEnable(subscription.dripIntervalMs, subscription.dripCount, false)
+ }
+ }
+}
+
+@Composable
+private fun DripChip(label: String, selected: Boolean, onClick: () -> Unit) {
+ if (selected) {
+ Button(onClick = onClick) { Text(label) }
+ } else {
+ OutlinedButton(onClick = onClick) { Text(label) }
+ }
+}
+
+private fun dripSummary(subscription: SubscriptionEntity): String {
+ if (!subscription.dripEnabled) {
+ return "Off. The whole catalogue is listed."
+ }
+ val days = TimeUnit.MILLISECONDS.toDays(subscription.dripIntervalMs)
+ val every = if (days == 1L) "day" else "$days days"
+ val order = if (subscription.dripOldestFirst) "oldest" else "newest"
+ return "${subscription.dripCount} $order first, every $every. Only released episodes are listed."
+}
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index 678ff47..23ba532 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import org.terst.listen.data.ArchiveOutcome
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
import org.terst.listen.data.RefreshOutcome
@@ -18,13 +19,24 @@ import org.terst.listen.data.SubscriptionKind
import org.terst.listen.feed.FeedResolver
import org.terst.listen.feed.SubscriptionResolver
import org.terst.listen.feed.YouTubeUrls
-import android.net.Uri
-import org.terst.listen.opml.Opml
-import org.terst.listen.opml.OpmlFeed
/** Transient message for the UI. Errors are surfaced, never only logged. */
data class Banner(val text: String, val isError: Boolean = false)
+/**
+ * An archive walk, reported in full.
+ *
+ * A partial walk is flagged as an error even though episodes did arrive: a chain that stopped
+ * early means the rest of the catalogue is missing, and a plain success message would leave the
+ * show looking permanently short with no indication why.
+ */
+private fun ArchiveOutcome.Fetched.toBanner(): Banner {
+ val found = "$newEpisodes older episode(s) from $pages page(s)"
+ return stoppedEarly
+ ?.let { Banner("$found, then stopped: $it", isError = true) }
+ ?: Banner(found)
+}
+
class ListenViewModel(app: Application) : AndroidViewModel(app) {
private val graph = Graph.get(app)
@@ -48,68 +60,20 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
private val _refreshing = MutableStateFlow(false)
val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
- // --- OPML ----------------------------------------------------------------------------
-
- /**
- * Imports subscriptions from an OPML file.
- *
- * Reports how many were added and how many failed. Silently importing 40 of 60 feeds is the
- * kind of partial success that only surfaces weeks later when a show never appears.
- */
- fun importOpml(uri: Uri) {
- viewModelScope.launch {
- val feeds = runCatching {
- getApplication<Application>().contentResolver.openInputStream(uri)
- ?.use { Opml.parse(it) }
- .orEmpty()
- }.getOrElse {
- _banner.value = Banner("Could not read that file: ${it.message}", isError = true)
- return@launch
- }
-
- if (feeds.isEmpty()) {
- _banner.value = Banner("No subscriptions found in that file", isError = true)
- return@launch
- }
-
- var added = 0
- var failed = 0
- for (feed in feeds) {
- graph.repository.subscribe(feed.xmlUrl).fold(
- onSuccess = { added++ },
- onFailure = { failed++ },
- )
- }
- _banner.value = if (failed > 0) {
- Banner("Imported $added, $failed failed", isError = true)
- } else {
- Banner("Imported $added subscription(s)")
- }
- }
- }
-
- fun exportOpml(uri: Uri) {
- viewModelScope.launch {
- val feeds = graph.subscriptions.getAll().map {
- OpmlFeed(title = it.title, xmlUrl = it.feedUrl, htmlUrl = it.link)
- }
- runCatching {
- getApplication<Application>().contentResolver.openOutputStream(uri)
- ?.use { Opml.write(feeds, it) }
- }.fold(
- onSuccess = { _banner.value = Banner("Exported ${feeds.size} subscription(s)") },
- onFailure = { _banner.value = Banner("Export failed: ${it.message}", isError = true) },
- )
- }
- }
-
fun dismissBanner() {
_banner.value = null
}
suspend fun episode(id: String): EpisodeEntity? = graph.episodes.getById(id)
- fun episodesOf(subscriptionId: String) = graph.episodes.observeForSubscription(subscriptionId)
+ /**
+ * A show's episodes as it displays them.
+ *
+ * [dripEnabled] comes from the subscription rather than being looked up here, so the list
+ * and the drip switch above it can never disagree about which set is being shown.
+ */
+ fun episodesOf(subscriptionId: String, dripEnabled: Boolean) =
+ graph.episodes.observeVisible(subscriptionId, dripEnabled)
/**
* Adds a subscription from a pasted or shared URL.
@@ -185,6 +149,59 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
}
}
+ // --- slow drip -------------------------------------------------------------------------
+
+ /**
+ * Turns slow-drip on for a show, or adjusts a running one, and pays out anything owed.
+ *
+ * Releasing straight away is deliberate: flipping the switch and seeing nothing happen for
+ * a day reads as the feature not working.
+ */
+ fun enableDrip(subscriptionId: String, intervalMs: Long, count: Int, oldestFirst: Boolean) {
+ viewModelScope.launch {
+ graph.drip.enable(subscriptionId, intervalMs, count, oldestFirst)
+ val released = graph.subscriptions.getById(subscriptionId)?.let { graph.drip.release(it) } ?: 0
+ _banner.value = if (released > 0) {
+ Banner("Dripping — $released episode(s) queued")
+ } else {
+ Banner("Dripping — nothing to release yet")
+ }
+ }
+ }
+
+ fun disableDrip(subscriptionId: String) {
+ viewModelScope.launch {
+ graph.drip.disable(subscriptionId)
+ _banner.value = Banner("Drip off — the whole catalogue is back")
+ }
+ }
+
+ /**
+ * Pulls a show's back catalogue by walking its RFC 5005 archive pages.
+ *
+ * Reported in detail rather than as a bare success: plenty of publishers do not implement
+ * paging at all, and "nothing happened" needs to be distinguishable from "it failed".
+ */
+ fun fetchArchive(subscriptionId: String) {
+ if (_refreshing.value) return
+ _refreshing.value = true
+ viewModelScope.launch {
+ val subscription = graph.subscriptions.getById(subscriptionId)
+ if (subscription == null) {
+ _refreshing.value = false
+ return@launch
+ }
+ val outcome = graph.repository.fetchArchive(subscription)
+ _refreshing.value = false
+ _banner.value = when (outcome) {
+ ArchiveOutcome.NoArchive ->
+ Banner("This feed publishes no archive pages, so there is nothing older to fetch")
+ is ArchiveOutcome.Failed -> Banner(outcome.message, isError = true)
+ is ArchiveOutcome.Fetched -> outcome.toBanner()
+ }
+ }
+ }
+
fun unsubscribe(id: String) {
viewModelScope.launch { graph.subscriptions.delete(id) }
}
diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
index 12d18b5..9632b02 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -19,10 +19,11 @@ class MainActivity : ComponentActivity() {
private val viewModel: ListenViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
private val rulesViewModel: RulesViewModel by viewModels()
+ private val opmlViewModel: OpmlViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContent { ListenTheme { ListenApp(viewModel, playerViewModel, rulesViewModel) } }
+ setContent { ListenTheme { ListenApp(viewModel, playerViewModel, rulesViewModel, opmlViewModel) } }
}
}
@@ -44,6 +45,7 @@ private fun ListenApp(
viewModel: ListenViewModel,
player: PlayerViewModel,
rulesViewModel: RulesViewModel,
+ opmlViewModel: OpmlViewModel,
) {
val nav = rememberNavController()
@@ -65,7 +67,7 @@ private fun ListenApp(
}
episodeRoute(nav, viewModel, player)
composable("settings") {
- SettingsScreen(vm = viewModel, player = player,
+ SettingsScreen(opml = opmlViewModel, player = player,
onOpenRules = { nav.navigate("rules") }, onBack = { nav.popBackStack() })
}
rulesRoutes(nav, rulesViewModel)
diff --git a/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt b/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt
new file mode 100644
index 0000000..54e95ed
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt
@@ -0,0 +1,87 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import android.net.Uri
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.listen.data.Graph
+import org.terst.listen.opml.Opml
+import org.terst.listen.opml.OpmlFeed
+
+/**
+ * Importing and exporting subscriptions as OPML.
+ *
+ * Its own view model rather than more surface on [ListenViewModel], which had grown into the
+ * entry point for the library, the queue, episodes, refreshing and now slow-drip. This concern
+ * shares nothing with those: it reads and writes one file through the content resolver, it is
+ * used by one screen, and separating it lets the settings screen stop depending on the library
+ * view model altogether.
+ */
+class OpmlViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val graph = Graph.get(app)
+
+ private val _banner = MutableStateFlow<Banner?>(null)
+ val banner: StateFlow<Banner?> = _banner.asStateFlow()
+
+ /**
+ * Imports subscriptions from an OPML file.
+ *
+ * Reports how many were added and how many failed. Silently importing 40 of 60 feeds is the
+ * kind of partial success that only surfaces weeks later when a show never appears.
+ */
+ fun importOpml(uri: Uri) {
+ viewModelScope.launch {
+ val feeds = runCatching {
+ getApplication<Application>().contentResolver.openInputStream(uri)
+ ?.use { Opml.parse(it) }
+ .orEmpty()
+ }.getOrElse {
+ _banner.value = Banner("Could not read that file: ${it.message}", isError = true)
+ return@launch
+ }
+
+ if (feeds.isEmpty()) {
+ _banner.value = Banner("No subscriptions found in that file", isError = true)
+ return@launch
+ }
+
+ var added = 0
+ var failed = 0
+ for (feed in feeds) {
+ graph.repository.subscribe(feed.xmlUrl).fold(
+ onSuccess = { added++ },
+ onFailure = { failed++ },
+ )
+ }
+ _banner.value = if (failed > 0) {
+ Banner("Imported $added, $failed failed", isError = true)
+ } else {
+ Banner("Imported $added subscription(s)")
+ }
+ }
+ }
+
+ fun exportOpml(uri: Uri) {
+ viewModelScope.launch {
+ val feeds = graph.subscriptions.getAll().map {
+ OpmlFeed(title = it.title, xmlUrl = it.feedUrl, htmlUrl = it.link)
+ }
+ runCatching {
+ getApplication<Application>().contentResolver.openOutputStream(uri)
+ ?.use { Opml.write(feeds, it) }
+ }.fold(
+ onSuccess = { _banner.value = Banner("Exported ${feeds.size} subscription(s)") },
+ onFailure = { _banner.value = Banner("Export failed: ${it.message}", isError = true) },
+ )
+ }
+ }
+
+ fun dismissBanner() {
+ _banner.value = null
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/RulesScreen.kt b/app/src/main/java/org/terst/listen/ui/RulesScreen.kt
index afada54..a32eff2 100644
--- a/app/src/main/java/org/terst/listen/ui/RulesScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/RulesScreen.kt
@@ -33,6 +33,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.terst.listen.data.RuleEntity
import org.terst.listen.queue.RuleSummary
@@ -147,6 +149,11 @@ private fun ReorderableCollectionItemScope.RuleRow(
style = MaterialTheme.typography.bodySmall,
)
}
- Switch(checked = rule.enabled, onCheckedChange = onToggle)
+ // Named for the same reason as the drip switch: a bare toggle announces nothing.
+ Switch(
+ modifier = Modifier.semantics { contentDescription = "Enable ${rule.name}" },
+ checked = rule.enabled,
+ onCheckedChange = onToggle,
+ )
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
index fc8900b..1d8da1b 100644
--- a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
@@ -21,11 +21,16 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Snackbar
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -40,23 +45,34 @@ private val OPML_MIME_TYPES = arrayOf("text/x-opml", "text/xml", "application/xm
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
- vm: ListenViewModel,
+ opml: OpmlViewModel,
player: PlayerViewModel,
onOpenRules: () -> Unit,
onBack: () -> Unit,
) {
val nowPlaying by player.nowPlaying.collectAsState()
val sleepRemaining by player.sleepRemainingMs.collectAsState()
+ val banner by opml.banner.collectAsState()
+ val snackbar = remember { SnackbarHostState() }
+
+ // Import and export reported nothing from this screen until now: the result was set on a
+ // banner only the library screen rendered, so it surfaced on the way back rather than here.
+ LaunchedEffect(banner) {
+ banner?.let {
+ snackbar.showSnackbar(it.text)
+ opml.dismissBanner()
+ }
+ }
// The system file picker rather than a permission-hungry file browser: it grants access to
// exactly the one file the user chose and nothing else.
val importLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
- ) { uri -> uri?.let(vm::importOpml) }
+ ) { uri -> uri?.let(opml::importOpml) }
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("text/x-opml")
- ) { uri -> uri?.let(vm::exportOpml) }
+ ) { uri -> uri?.let(opml::exportOpml) }
Scaffold(
topBar = {
@@ -69,6 +85,7 @@ fun SettingsScreen(
},
)
},
+ snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } },
) { padding ->
Column(
Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
index 8f3bc70..42e9b1d 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -61,11 +61,22 @@ fun SubscriptionScreen(
subscriptionId: String,
nav: SubscriptionNav,
) {
- val episodes by vm.episodesOf(subscriptionId).collectAsState(initial = emptyList())
var confirming by remember { mutableStateOf(false) }
val refreshing by vm.refreshing.collectAsState()
+ val banner by vm.banner.collectAsState()
val nowPlaying by player.nowPlaying.collectAsState()
val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId }
+ val snackbar = remember { SnackbarHostState() }
+
+ // This screen had no snackbar until slow-drip needed one, so a refresh started from here
+ // reported nothing. Fetching an archive is slower still and can half-succeed, which is
+ // exactly the case that must not be silent.
+ LaunchedEffect(banner) {
+ banner?.let {
+ snackbar.showSnackbar(it.text)
+ vm.dismissBanner()
+ }
+ }
Scaffold(
topBar = {
@@ -77,6 +88,7 @@ fun SubscriptionScreen(
onBack = nav.back,
)
},
+ snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } },
bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) },
) { padding ->
// Confirmed, because unsubscribing cascades: the episodes go, and with them every
@@ -92,26 +104,65 @@ fun SubscriptionScreen(
},
)
- LazyColumn(Modifier.padding(padding).fillMaxSize()) {
- item { SubscriptionHeader(subscription) }
- items(episodes, key = { it.id }) { episode ->
- EpisodeRow(
- episode = episode,
- artworkUrl = Artwork.preferredUrl(episode.imageUrl, subscription?.imageUrl),
- actions = EpisodeActions(
- play = { player.play(episode) },
- enqueue = { vm.enqueue(episode.id) },
- playNext = { vm.playNext(episode.id) },
- download = { vm.download(episode) },
- openDetails = { nav.openEpisode(episode.id) },
- togglePlayed = {
- if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id)
- },
- ),
+ SubscriptionBody(
+ vm = vm,
+ player = player,
+ subscription = subscription,
+ nav = nav,
+ modifier = Modifier.padding(padding),
+ )
+ }
+}
+
+/** The scrolling part: the show, its drip settings, and the episodes it currently lists. */
+@Composable
+private fun SubscriptionBody(
+ vm: ListenViewModel,
+ player: PlayerViewModel,
+ subscription: SubscriptionEntity?,
+ nav: SubscriptionNav,
+ modifier: Modifier,
+) {
+ val busy by vm.refreshing.collectAsState()
+ // Which set the show lists is the show's own business, so it is read here rather than
+ // passed in: the list and the drip switch above it can never disagree.
+ val dripEnabled = subscription?.dripEnabled == true
+ val episodes by vm.episodesOf(subscription?.id.orEmpty(), dripEnabled)
+ .collectAsState(initial = emptyList())
+
+ LazyColumn(modifier.fillMaxSize()) {
+ item { SubscriptionHeader(subscription) }
+ subscription?.let { show ->
+ item {
+ DripSettings(
+ subscription = show,
+ busy = busy,
+ onEnable = { intervalMs, count, oldestFirst ->
+ vm.enableDrip(show.id, intervalMs, count, oldestFirst)
+ },
+ onDisable = { vm.disableDrip(show.id) },
+ onFetchArchive = { vm.fetchArchive(show.id) },
)
- HorizontalDivider()
+ HorizontalDivider(Modifier.padding(top = 12.dp))
}
}
+ items(episodes, key = { it.id }) { episode ->
+ EpisodeRow(
+ episode = episode,
+ artworkUrl = Artwork.preferredUrl(episode.imageUrl, subscription?.imageUrl),
+ actions = EpisodeActions(
+ play = { player.play(episode) },
+ enqueue = { vm.enqueue(episode.id) },
+ playNext = { vm.playNext(episode.id) },
+ download = { vm.download(episode) },
+ openDetails = { nav.openEpisode(episode.id) },
+ togglePlayed = {
+ if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id)
+ },
+ ),
+ )
+ HorizontalDivider()
+ }
}
}
diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt
index 7a7b483..3f829ab 100644
--- a/app/src/main/java/org/terst/listen/work/Workers.kt
+++ b/app/src/main/java/org/terst/listen/work/Workers.kt
@@ -22,12 +22,16 @@ import java.util.concurrent.TimeUnit
class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
- val outcomes = Graph.get(applicationContext).repository.refreshAll()
+ val graph = Graph.get(applicationContext)
+ val outcomes = graph.repository.refreshAll()
val failed = outcomes.values.count { it is RefreshOutcome.Failed }
+ // After the refresh, so a drip release can pick up anything that just arrived.
+ val released = graph.drip.releaseAll()
return Result.success(
androidx.work.Data.Builder()
.putInt("refreshed", outcomes.size - failed)
.putInt("failed", failed)
+ .putInt("released", released)
.build()
)
}
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index 3aee040..d5bc993 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -44,6 +44,29 @@ class FakeSubscriptionDao : SubscriptionDao {
replace(id) { it.copy(playbackSpeed = speed) }
}
+ override suspend fun dripEnabled() = rows.filter { it.dripEnabled }
+
+ override suspend fun updateDrip(
+ id: String,
+ enabled: Boolean,
+ intervalMs: Long,
+ count: Int,
+ oldestFirst: Boolean,
+ ) {
+ replace(id) {
+ it.copy(
+ dripEnabled = enabled,
+ dripIntervalMs = intervalMs,
+ dripCount = count,
+ dripOldestFirst = oldestFirst,
+ )
+ }
+ }
+
+ override suspend fun setDripMark(id: String, atMs: Long?) {
+ replace(id) { it.copy(dripLastReleaseAtMs = atMs) }
+ }
+
private fun replace(id: String, transform: (SubscriptionEntity) -> SubscriptionEntity) {
val index = rows.indexOfFirst { it.id == id }
if (index >= 0) rows[index] = transform(rows[index])
@@ -56,12 +79,50 @@ class FakeEpisodeDao : EpisodeDao {
/** Lets a test observe intermediate states that the final row no longer shows. */
var onUpdate: ((EpisodeEntity) -> Unit)? = null
- override fun observeForSubscription(subscriptionId: String): Flow<List<EpisodeEntity>> =
- flowOf(rows.filter { it.subscriptionId == subscriptionId })
+ /** Set by [FakeQueueDao] wiring so releaseConsumed can see what is queued, as SQL does. */
+ var queuedIds: () -> Set<String> = { emptySet() }
+
+ private fun visible(subscriptionId: String, dripEnabled: Boolean) =
+ rows.filter {
+ it.subscriptionId == subscriptionId && (!dripEnabled || it.dripReleasedAtMs != null)
+ }.sortedByDescending { it.publishedAtMs ?: 0 }
+
+ override fun observeVisible(subscriptionId: String, dripEnabled: Boolean): Flow<List<EpisodeEntity>> =
+ flowOf(visible(subscriptionId, dripEnabled))
+
+ override suspend fun getVisible(subscriptionId: String, dripEnabled: Boolean) =
+ visible(subscriptionId, dripEnabled)
override suspend fun getForSubscription(subscriptionId: String) =
rows.filter { it.subscriptionId == subscriptionId }
+ private fun unreleased(subscriptionId: String) =
+ rows.filter { it.subscriptionId == subscriptionId && it.dripReleasedAtMs == null }
+
+ override suspend fun unreleasedOldestFirst(subscriptionId: String, limit: Int) =
+ unreleased(subscriptionId)
+ .sortedBy { it.publishedAtMs ?: it.discoveredAtMs }
+ .take(limit)
+
+ override suspend fun unreleasedNewestFirst(subscriptionId: String, limit: Int) =
+ unreleased(subscriptionId)
+ .sortedByDescending { it.publishedAtMs ?: it.discoveredAtMs }
+ .take(limit)
+
+ override suspend fun markDripReleased(id: String, atMs: Long) {
+ replace(id) { it.copy(dripReleasedAtMs = atMs) }
+ }
+
+ override suspend fun releaseConsumed(subscriptionId: String, atMs: Long) {
+ val queued = queuedIds()
+ rows.replaceAll { row ->
+ val eligible = row.subscriptionId == subscriptionId &&
+ row.dripReleasedAtMs == null &&
+ (row.playedAtMs != null || row.id in queued)
+ if (eligible) row.copy(dripReleasedAtMs = atMs) else row
+ }
+ }
+
override suspend fun getById(id: String) = rows.firstOrNull { it.id == id }
override suspend fun insertAll(episodes: List<EpisodeEntity>) {
diff --git a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
index 13457c7..93942ed 100644
--- a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
+++ b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
@@ -276,6 +276,86 @@ class SubscriptionRepositoryTest {
assertEquals(emptyList<Any>(), discovered)
}
+ // --- RFC 5005 archive paging ----------------------------------------------------------
+
+ private fun archivePage(items: String, prevArchive: String? = null) = """<?xml version="1.0"?>
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>
+ <title>A Show</title><link>https://example.com</link><description>d</description>
+ ${prevArchive?.let { """<atom:link rel="prev-archive" href="$it"/>""" }.orEmpty()}
+ $items
+ </channel></rss>""".trimIndent()
+
+ private val archivedItem = """<item><title>Old</title><guid>g-old</guid>
+ <enclosure url="https://cdn/old.mp3" length="10" type="audio/mpeg"/></item>"""
+
+ @Test
+ fun `fetching the archive stores the older episodes`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+
+ // The current page again to read its archive link, then the archive page itself.
+ server.enqueue(MockResponse().setBody(archivePage(oneItem, server.url("/page2.xml").toString())))
+ server.enqueue(MockResponse().setBody(archivePage(archivedItem)))
+
+ val outcome = repo.fetchArchive(subs.rows.single())
+
+ assertTrue(outcome.toString(), outcome is ArchiveOutcome.Fetched)
+ assertEquals(1, (outcome as ArchiveOutcome.Fetched).newEpisodes)
+ assertEquals(2, eps.rows.size)
+ }
+
+ @Test
+ fun `a feed with no archive link says so rather than failing`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setBody(feed()))
+
+ assertEquals(ArchiveOutcome.NoArchive, repo.fetchArchive(subs.rows.single()))
+ }
+
+ /** An archive is a back catalogue, same as a first subscribe: the rules must not see it. */
+ @Test
+ fun `an archive is not handed to the rule engine`() = runTest {
+ val discovered = mutableListOf<List<EpisodeEntity>>()
+ val reporting = repoReporting(discovered)
+ server.enqueue(MockResponse().setBody(feed()))
+ reporting.subscribe(url())
+ discovered.clear()
+
+ server.enqueue(MockResponse().setBody(archivePage(oneItem, server.url("/page2.xml").toString())))
+ server.enqueue(MockResponse().setBody(archivePage(archivedItem)))
+ reporting.fetchArchive(subs.rows.single())
+
+ assertEquals("a back catalogue must not enqueue itself", emptyList<Any>(), discovered)
+ }
+
+ @Test
+ fun `a failure reaching the feed is reported, not swallowed`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setResponseCode(500))
+
+ val outcome = repo.fetchArchive(subs.rows.single())
+ assertTrue(outcome.toString(), outcome is ArchiveOutcome.Failed)
+ }
+
+ /** A partial walk still keeps its episodes; the reason comes back with them. */
+ @Test
+ fun `a partial archive keeps what it reached and reports why it stopped`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+
+ server.enqueue(MockResponse().setBody(archivePage(oneItem, server.url("/page2.xml").toString())))
+ server.enqueue(
+ MockResponse().setBody(archivePage(archivedItem, server.url("/page3.xml").toString()))
+ )
+ server.enqueue(MockResponse().setResponseCode(500))
+
+ val outcome = repo.fetchArchive(subs.rows.single()) as ArchiveOutcome.Fetched
+ assertEquals(1, outcome.newEpisodes)
+ assertNotNull(outcome.stoppedEarly)
+ }
+
@Test
fun `refreshAll reports per subscription`() = runTest {
server.enqueue(MockResponse().setBody(feed()))
diff --git a/app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt b/app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt
new file mode 100644
index 0000000..b37e07c
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt
@@ -0,0 +1,124 @@
+package org.terst.listen.feed
+
+import kotlinx.coroutines.test.runTest
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Walking RFC 5005 `prev-archive` pages back through a publisher's catalogue.
+ *
+ * This is what makes slow-drip worth anything: plenty of feeds only carry recent episodes, and
+ * without paging a drip subscription can only drip what the current page happens to expose.
+ *
+ * Every stopping condition is tested. This follows links a stranger controls, and the failure
+ * modes -- a loop, an endless chain, a page that 500s halfway -- are all things real feeds do.
+ */
+class FeedArchiveWalkerTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var walker: FeedArchiveWalker
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ walker = FeedArchiveWalker(FeedFetcher(OkHttpClient()))
+ }
+
+ @After
+ fun tearDown() = server.shutdown()
+
+ private fun page(items: String, prevArchive: String? = null) = """<?xml version="1.0"?>
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>
+ <title>An archive</title><description>d</description>
+ ${prevArchive?.let { """<atom:link rel="prev-archive" href="$it"/>""" }.orEmpty()}
+ $items
+ </channel></rss>""".trimIndent()
+
+ private fun item(guid: String) = """<item><title>$guid</title><guid>$guid</guid>
+ <enclosure url="https://cdn/$guid.mp3" length="10" type="audio/mpeg"/></item>"""
+
+ private fun url(path: String) = server.url(path).toString()
+
+ @Test
+ fun `a feed with no archive link walks nowhere`() = runTest {
+ val walk = walker.walk(null)
+ assertEquals(0, walk.pages)
+ assertEquals(emptyList<ParsedEpisode>(), walk.episodes)
+ assertNull(walk.stoppedEarly)
+ }
+
+ @Test
+ fun `it follows the chain and collects every page`() = runTest {
+ server.enqueue(MockResponse().setBody(page(item("p2a") + item("p2b"), url("/page3.xml"))))
+ server.enqueue(MockResponse().setBody(page(item("p3a"))))
+
+ val walk = walker.walk(url("/page2.xml"))
+
+ assertEquals(2, walk.pages)
+ assertEquals(listOf("p2a", "p2b", "p3a"), walk.episodes.map { it.guid })
+ assertNull("a completed walk is not an early stop", walk.stoppedEarly)
+ }
+
+ /** RFC 5005 hrefs are routinely relative; resolved against the page that carried them. */
+ @Test
+ fun `a relative archive link resolves against the page it came from`() = runTest {
+ server.enqueue(MockResponse().setBody(page(item("p2a"), "../archive/page3.xml")))
+ server.enqueue(MockResponse().setBody(page(item("p3a"))))
+
+ val walk = walker.walk(url("/feeds/page2.xml"))
+
+ assertEquals(2, walk.pages)
+ assertEquals(listOf("p2a", "p3a"), walk.episodes.map { it.guid })
+ server.takeRequest()
+ assertEquals("/archive/page3.xml", server.takeRequest().path)
+ }
+
+ /** Publishers do produce archives that link back on themselves. */
+ @Test
+ fun `a loop stops the walk and keeps what it found`() = runTest {
+ server.enqueue(MockResponse().setBody(page(item("p2a"), url("/page2.xml"))))
+
+ val walk = walker.walk(url("/page2.xml"))
+
+ assertEquals(1, walk.pages)
+ assertEquals(listOf("p2a"), walk.episodes.map { it.guid })
+ assertNotNull(walk.stoppedEarly)
+ assertTrue(walk.stoppedEarly.orEmpty(), walk.stoppedEarly.orEmpty().contains("itself"))
+ }
+
+ @Test
+ fun `an endless chain stops at the page cap`() = runTest {
+ repeat(5) { index ->
+ server.enqueue(MockResponse().setBody(page(item("p$index"), url("/page${index + 1}.xml"))))
+ }
+ val capped = FeedArchiveWalker(FeedFetcher(OkHttpClient()), maxPages = 3)
+
+ val walk = capped.walk(url("/page0.xml"))
+
+ assertEquals(3, walk.pages)
+ assertEquals(3, walk.episodes.size)
+ assertTrue(walk.stoppedEarly.orEmpty(), walk.stoppedEarly.orEmpty().contains("3"))
+ }
+
+ /** A partial archive is worth keeping; the reason is worth reporting rather than swallowing. */
+ @Test
+ fun `a failure partway keeps the pages already fetched and says why`() = runTest {
+ server.enqueue(MockResponse().setBody(page(item("p2a"), url("/page3.xml"))))
+ server.enqueue(MockResponse().setResponseCode(500))
+
+ val walk = walker.walk(url("/page2.xml"))
+
+ assertEquals(1, walk.pages)
+ assertEquals(listOf("p2a"), walk.episodes.map { it.guid })
+ assertTrue(walk.stoppedEarly.orEmpty(), walk.stoppedEarly.orEmpty().contains("500"))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/queue/DripReleaserTest.kt b/app/src/test/java/org/terst/listen/queue/DripReleaserTest.kt
new file mode 100644
index 0000000..d5fedf7
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/queue/DripReleaserTest.kt
@@ -0,0 +1,261 @@
+package org.terst.listen.queue
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakeQueueDao
+import org.terst.listen.data.FakeSubscriptionDao
+import org.terst.listen.data.QueueRepository
+import org.terst.listen.data.SubscriptionEntity
+import java.util.concurrent.TimeUnit
+
+/** Paying out a slow-drip subscription: what gets released, in what order, and into the queue. */
+class DripReleaserTest {
+
+ private val now = 1_700_000_000_000L
+ private val day = TimeUnit.DAYS.toMillis(1)
+
+ private lateinit var subscriptions: FakeSubscriptionDao
+ private lateinit var episodes: FakeEpisodeDao
+ private lateinit var queueDao: FakeQueueDao
+ private lateinit var releaser: DripReleaser
+
+ @Before
+ fun setUp() {
+ subscriptions = FakeSubscriptionDao()
+ episodes = FakeEpisodeDao()
+ queueDao = FakeQueueDao()
+ queueDao.episodeSource = { episodes.rows.toList() }
+ episodes.queuedIds = { queueDao.entries.map { it.episodeId }.toSet() }
+ releaser = DripReleaser(subscriptions, episodes, QueueRepository(queueDao)) { now }
+ }
+
+ private suspend fun show(
+ dripEnabled: Boolean = true,
+ dripCount: Int = 1,
+ dripOldestFirst: Boolean = true,
+ dripLastReleaseAtMs: Long? = now - day,
+ ): SubscriptionEntity {
+ val entity = SubscriptionEntity(
+ id = "s1",
+ feedUrl = "https://example.com/feed.xml",
+ title = "An archive",
+ addedAtMs = now - 30 * day,
+ dripEnabled = dripEnabled,
+ dripIntervalMs = day,
+ dripCount = dripCount,
+ dripOldestFirst = dripOldestFirst,
+ dripLastReleaseAtMs = dripLastReleaseAtMs,
+ )
+ subscriptions.insert(entity)
+ return entity
+ }
+
+ private fun episode(
+ id: String,
+ publishedAtMs: Long,
+ playedAtMs: Long? = null,
+ dripReleasedAtMs: Long? = null,
+ ) {
+ episodes.rows += EpisodeEntity(
+ id = id,
+ subscriptionId = "s1",
+ guid = id,
+ title = id,
+ mediaUrl = "https://cdn/$id.mp3",
+ mediaType = "audio/mpeg",
+ publishedAtMs = publishedAtMs,
+ discoveredAtMs = now,
+ playedAtMs = playedAtMs,
+ dripReleasedAtMs = dripReleasedAtMs,
+ )
+ }
+
+ private fun archive() {
+ episode("oldest", now - 3 * day)
+ episode("middle", now - 2 * day)
+ episode("newest", now - day)
+ }
+
+ private suspend fun queued() = queueDao.getEntries().map { it.episodeId }
+
+ private fun released() = episodes.rows.filter { it.dripReleasedAtMs != null }.map { it.id }
+
+ @Test
+ fun `a show without drip is left alone`() = runTest {
+ show(dripEnabled = false)
+ archive()
+
+ assertEquals(0, releaser.releaseAll())
+ assertEquals(emptyList<String>(), released())
+ }
+
+ @Test
+ fun `an owed batch releases the oldest and queues it`() = runTest {
+ show()
+ archive()
+
+ assertEquals(1, releaser.releaseAll())
+ assertEquals(listOf("oldest"), released())
+ assertEquals(listOf("oldest"), queued())
+ }
+
+ /** Peter's choice: a drip release always enqueues, independent of the auto-queue rules. */
+ @Test
+ fun `a batch lands in the queue in release order`() = runTest {
+ show(dripCount = 3)
+ archive()
+
+ releaser.releaseAll()
+
+ assertEquals(listOf("oldest", "middle", "newest"), queued())
+ }
+
+ @Test
+ fun `newest first when the show asks for it`() = runTest {
+ show(dripOldestFirst = false)
+ archive()
+
+ releaser.releaseAll()
+
+ assertEquals(listOf("newest"), released())
+ }
+
+ @Test
+ fun `the clock advances so the next run does not pay out again`() = runTest {
+ val entity = show()
+ archive()
+
+ releaser.releaseAll()
+ assertEquals(now, subscriptions.rows.single().dripLastReleaseAtMs)
+
+ assertEquals("a second run in the same moment owes nothing", 0, releaser.releaseAll())
+ assertEquals(listOf("oldest"), released())
+ assertNotNull(entity)
+ }
+
+ @Test
+ fun `nothing owed changes nothing`() = runTest {
+ show(dripLastReleaseAtMs = now)
+ archive()
+
+ assertEquals(0, releaser.releaseAll())
+ assertEquals(emptyList<String>(), released())
+ }
+
+ /**
+ * An exhausted archive still advances the clock. Leaving it behind would make the show owe
+ * an ever-growing debt that pays out in a burst the next time the publisher posts.
+ */
+ @Test
+ fun `an archive with nothing left still advances the clock`() = runTest {
+ show(dripCount = 5)
+ episode("only", now - 3 * day)
+
+ assertEquals(1, releaser.releaseAll())
+ assertEquals(now, subscriptions.rows.single().dripLastReleaseAtMs)
+ }
+
+ @Test
+ fun `an already released episode is not released twice`() = runTest {
+ show()
+ episode("done", now - 3 * day, dripReleasedAtMs = now - 2 * day)
+ episode("next", now - 2 * day)
+
+ releaser.releaseAll()
+
+ assertEquals(listOf("next"), queued())
+ }
+
+ // --- switching drip on --------------------------------------------------------------------
+
+ /**
+ * Turning drip on mid-life must not empty the show.
+ *
+ * Episodes already heard were consumed; hiding them would make a show you have been
+ * following for a year look like it has three episodes. Unplayed ones are what the drip is
+ * for, so they stay held back.
+ */
+ @Test
+ fun `enabling drip releases what was already played and holds back the rest`() = runTest {
+ show(dripEnabled = false, dripLastReleaseAtMs = null)
+ episode("heard", now - 3 * day, playedAtMs = now - 2 * day)
+ episode("unheard", now - 2 * day)
+
+ releaser.enable("s1", intervalMs = day, count = 1, oldestFirst = true)
+
+ assertNotNull(episodes.rows.first { it.id == "heard" }.dripReleasedAtMs)
+ assertNull(episodes.rows.first { it.id == "unheard" }.dripReleasedAtMs)
+ }
+
+ /** Something already in the queue is in flight; hiding it would strand it there. */
+ @Test
+ fun `enabling drip releases whatever is already queued`() = runTest {
+ show(dripEnabled = false, dripLastReleaseAtMs = null)
+ episode("queued", now - 3 * day)
+ QueueRepository(queueDao).append("queued")
+
+ releaser.enable("s1", intervalMs = day, count = 1, oldestFirst = true)
+
+ assertNotNull(episodes.rows.first { it.id == "queued" }.dripReleasedAtMs)
+ }
+
+ @Test
+ fun `enabling drip stores the settings and turns it on`() = runTest {
+ show(dripEnabled = false, dripLastReleaseAtMs = null)
+
+ releaser.enable("s1", intervalMs = 2 * day, count = 3, oldestFirst = false)
+
+ val stored = subscriptions.rows.single()
+ assertEquals(true, stored.dripEnabled)
+ assertEquals(2 * day, stored.dripIntervalMs)
+ assertEquals(3, stored.dripCount)
+ assertEquals(false, stored.dripOldestFirst)
+ }
+
+ /**
+ * Turning drip back on starts the cadence from now.
+ *
+ * A mark left over from the last time it was on would make the show owe every interval
+ * since, and pay it out as a capped burst the moment the switch is flipped.
+ */
+ @Test
+ fun `re-enabling drip starts the cadence again rather than paying off a debt`() = runTest {
+ show(dripEnabled = false, dripLastReleaseAtMs = now - 30 * day)
+ archive()
+
+ releaser.enable("s1", intervalMs = day, count = 1, oldestFirst = true)
+
+ assertNull(subscriptions.rows.single().dripLastReleaseAtMs)
+ assertEquals(1, releaser.releaseAll())
+ }
+
+ /** Editing the cadence of a running drip must not restart its clock. */
+ @Test
+ fun `adjusting a running drip leaves its clock where it is`() = runTest {
+ show(dripEnabled = true, dripLastReleaseAtMs = now - day / 2)
+ archive()
+
+ releaser.enable("s1", intervalMs = day, count = 3, oldestFirst = true)
+
+ assertEquals(now - day / 2, subscriptions.rows.single().dripLastReleaseAtMs)
+ assertEquals("still mid-interval, so nothing is owed", 0, releaser.releaseAll())
+ }
+
+ /** Switching it off must not lose the release marks; the catalogue simply becomes visible. */
+ @Test
+ fun `disabling drip leaves the release marks alone`() = runTest {
+ show()
+ episode("out", now - 3 * day, dripReleasedAtMs = now - day)
+
+ releaser.disable("s1")
+
+ assertEquals(false, subscriptions.rows.single().dripEnabled)
+ assertNotNull(episodes.rows.single().dripReleasedAtMs)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/queue/DripScheduleTest.kt b/app/src/test/java/org/terst/listen/queue/DripScheduleTest.kt
new file mode 100644
index 0000000..2f035b3
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/queue/DripScheduleTest.kt
@@ -0,0 +1,106 @@
+package org.terst.listen.queue
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.terst.listen.data.SubscriptionEntity
+import java.util.concurrent.TimeUnit
+
+/**
+ * When a drip subscription owes episodes, and how many.
+ *
+ * Pure and tested directly because the arithmetic is where this feature goes wrong: a drip that
+ * drifts stops being daily, and one that pays back a month of absence all at once is not a drip.
+ */
+class DripScheduleTest {
+
+ private val now = 1_700_000_000_000L
+ private val day = TimeUnit.DAYS.toMillis(1)
+
+ private val show = SubscriptionEntity(
+ id = "s1",
+ feedUrl = "https://example.com/feed.xml",
+ title = "An archive",
+ addedAtMs = now - 30 * day,
+ dripEnabled = true,
+ dripIntervalMs = day,
+ dripCount = 1,
+ )
+
+ @Test
+ fun `a show without drip owes nothing`() {
+ assertNull(DripSchedule.due(show.copy(dripEnabled = false), now))
+ }
+
+ /** Switching drip on should put something in the queue now, not tomorrow. */
+ @Test
+ fun `a drip just switched on releases its first batch immediately`() {
+ val decision = DripSchedule.due(show.copy(dripLastReleaseAtMs = null), now)
+ assertEquals(1, decision?.count)
+ assertEquals(now, decision?.nextMarkMs)
+ }
+
+ @Test
+ fun `nothing is owed before the interval is up`() {
+ assertNull(DripSchedule.due(show.copy(dripLastReleaseAtMs = now - day / 2), now))
+ }
+
+ @Test
+ fun `one interval owes one batch`() {
+ val last = now - day
+ val decision = DripSchedule.due(show.copy(dripLastReleaseAtMs = last), now)
+ assertEquals(1, decision?.count)
+ assertEquals(last + day, decision?.nextMarkMs)
+ }
+
+ @Test
+ fun `the count is per interval, not per release`() {
+ val last = now - 3 * day
+ val decision = DripSchedule.due(show.copy(dripLastReleaseAtMs = last, dripCount = 2), now)
+ assertEquals(6, decision?.count)
+ }
+
+ /**
+ * The mark advances by whole intervals rather than to now.
+ *
+ * Moving it to now would throw away the part-interval that has already elapsed, and with a
+ * worker that runs every six hours against a daily drip that loses hours every day -- so a
+ * "one a day" show slowly becomes one every day and a bit.
+ */
+ @Test
+ fun `the mark advances by whole intervals so the cadence does not drift`() {
+ val last = now - (2 * day + day / 4)
+ val decision = DripSchedule.due(show.copy(dripLastReleaseAtMs = last), now)
+ assertEquals(2, decision?.count)
+ assertEquals(last + 2 * day, decision?.nextMarkMs)
+ }
+
+ /**
+ * A month away should not empty the archive into the queue. The excess is forgiven rather
+ * than carried, so the next run starts from a clean slate instead of paying out again.
+ */
+ @Test
+ fun `a long absence is capped, and the excess is forgiven not carried`() {
+ val last = now - 30 * day
+ val decision = DripSchedule.due(show.copy(dripLastReleaseAtMs = last), now)
+ assertEquals(10, decision?.count)
+ assertEquals(last + 30 * day, decision?.nextMarkMs)
+ }
+
+ /** A zero or negative interval would divide by zero; a stored value is not to be trusted. */
+ @Test
+ fun `a nonsensical interval owes nothing`() {
+ assertNull(DripSchedule.due(show.copy(dripIntervalMs = 0, dripLastReleaseAtMs = now - day), now))
+ }
+
+ @Test
+ fun `a zero count owes nothing`() {
+ assertNull(DripSchedule.due(show.copy(dripCount = 0, dripLastReleaseAtMs = now - day), now))
+ }
+
+ /** A clock that has gone backwards must not produce a negative release. */
+ @Test
+ fun `a mark in the future owes nothing`() {
+ assertNull(DripSchedule.due(show.copy(dripLastReleaseAtMs = now + day), now))
+ }
+}