diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-25 17:09:13 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-25 17:09:13 +0000 |
| commit | 593ee7740de24aae22ddec1dc5a2e083fff06bd3 (patch) | |
| tree | a7cb89b34e2ff5f8ad32073a1078f70468daf0c0 | |
| parent | f04a01c672c01f4913d4b9bbaf2fc4febbaec08c (diff) | |
Slow drip: work through an archive a few episodes at a time
The second of Phase 2's three tracks. A show can be set to release its back
catalogue on a cadence -- one a day, oldest first, by default -- and while drip
is on the show lists only what has been released rather than the raw feed. A 600
episode archive then reads as a show you are keeping up with instead of a wall.
A release always enqueues, and deliberately does not go through the rule engine.
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.
Three pieces of arithmetic worth keeping, all in DripSchedule and all tested:
The clock advances by whole intervals rather than to now. Moving it to now
discards the part-interval already elapsed, and a six-hourly worker against a
daily drip loses hours every day until "one a day" means one every day and a
bit.
A long absence is capped at ten and the excess is forgiven rather than carried.
A month away owes thirty episodes; handing those over empties the archive into
the queue, which is the thing the feature exists to avoid. Carrying the debt
instead would just pay it out as a capped burst on each of the next three runs.
Switching drip on settles what it already owes: everything played or already
queued is released up front, because hiding episodes you have heard makes a show
followed for a year look like it has three, and hiding one mid-queue strands it
where nothing can reach it. Re-enabling starts the cadence from now rather than
paying off a debt accrued while it was off.
Visibility is a query parameter, not stored state, so switching drip off
restores the whole catalogue without touching a row. Android Auto browses the
same set. Feed reconciliation deliberately still sees everything -- matching
against the visible set alone would read a held-back episode as missing and
re-insert it on every refresh.
RFC 5005 archive paging is what gives a drip anything to drip: FeedArchiveWalker
follows prev-archive pages, resolving relative hrefs against the page that
carried them, and stops on a loop, a page cap, or a fetch failure -- keeping what
it collected and reporting why, because half an archive is useful and a silent
half surfaces months later as "why did this show run out?".
Two gaps found while wiring it up, both fixed here: the subscription screen had
no snackbar at all, so a refresh started from it reported nothing and an archive
fetch would have been silent; and both toggles were bare Switches with no
accessible name, which TalkBack cannot announce and the UI checker cannot tap.
Schema v2 -> v3. The four non-null drip columns carry SQL defaults because
SQLite cannot add a NOT NULL column without one, declared on the entity too so
the migration and the exported schema describe the same table. Verified twice:
the migration test now covers 1 -> 3 straight through, and a real v2 database
seeded with a subscription, an episode at position 424242 and a rule was
upgraded in place on a device with all three surviving and drip defaulting off.
OPML moved to its own view model rather than growing ListenViewModel past its
function limit again -- it shares nothing with the library, is used by one
screen, and the settings screen now depends on it alone.
The exported-schema trap from the last commit happened again, after I had
written the warning down. Step 9 of the pre-commit hook now refuses a commit
that modifies an already-committed schema JSON, since a note was demonstrably
not enough.
No permission changes. 333 unit tests green, detekt and lint clean, coverage
63.63 -> 65.30.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
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." @@ -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)) + } +} |
