summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/AdReportsDbTest.kt99
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt45
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt28
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt17
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt34
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt4
-rw-r--r--app/src/main/java/org/terst/listen/playback/AdJump.kt36
-rw-r--r--app/src/main/java/org/terst/listen/playback/AdReportGesture.kt119
-rw-r--r--app/src/main/java/org/terst/listen/playback/AdReportStore.kt36
-rw-r--r--app/src/main/java/org/terst/listen/playback/AdReporter.kt42
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt20
-rw-r--r--app/src/main/java/org/terst/listen/playback/NotificationControls.kt37
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt67
-rw-r--r--app/src/test/java/org/terst/listen/playback/AdJumpTest.kt93
-rw-r--r--app/src/test/java/org/terst/listen/playback/AdReportGestureTest.kt150
-rw-r--r--app/src/test/java/org/terst/listen/playback/AdReporterTest.kt99
16 files changed, 910 insertions, 16 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/AdReportsDbTest.kt b/app/src/androidTest/java/org/terst/listen/data/AdReportsDbTest.kt
new file mode 100644
index 0000000..e6274f8
--- /dev/null
+++ b/app/src/androidTest/java/org/terst/listen/data/AdReportsDbTest.kt
@@ -0,0 +1,99 @@
+package org.terst.listen.data
+
+import androidx.room.Room
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.terst.listen.playback.AdReportGesture
+import org.terst.listen.playback.AdReportStore
+
+/**
+ * The whole gesture against the real table: press, press again, correct, withdraw.
+ *
+ * Driven through [AdReportGesture] rather than by writing rows by hand, because the thing worth
+ * proving is that one claim stays one row however many times he presses.
+ */
+@RunWith(AndroidJUnit4::class)
+class AdReportsDbTest {
+
+ private lateinit var db: AppDatabase
+ private lateinit var gesture: AdReportGesture
+ private lateinit var store: AdReportStore
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ AppDatabase::class.java,
+ ).build()
+ gesture = AdReportGesture(correctionWindowMs = 20_000)
+ store = AdReportStore(db.adReports())
+ }
+
+ @After
+ fun tearDown() = db.close()
+
+ @Test
+ fun pressingTwiceLeavesOneClaimCoveringBoth() = runBlocking {
+ store.apply(gesture.press("e1", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000))
+ store.apply(gesture.press("e1", positionMs = 645_000, jumpToMs = 690_000, nowMs = 6_000))
+
+ val rows = db.adReports().getForEpisode("e1")
+ assertEquals("two presses, one pod, one row", 1, rows.size)
+ assertEquals(600_000L, rows.single().startMs)
+ assertEquals(690_000L, rows.single().endMs)
+ assertFalse("nothing has corrected the end yet", rows.single().endCorrected)
+ }
+
+ @Test
+ fun comingBackTrimsTheClaimAndSaysSoWasMeasured() = runBlocking {
+ store.apply(gesture.press("e1", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000))
+ store.apply(gesture.seekedBack("e1", landedAtMs = 622_000, nowMs = 6_000))
+
+ val row = db.adReports().getForEpisode("e1").single()
+ assertEquals(600_000L, row.startMs)
+ assertEquals(622_000L, row.endMs)
+ assertTrue("he heard the programme resume here, so the end is measured", row.endCorrected)
+ }
+
+ @Test
+ fun comingBackPastThePressRemovesTheClaimEntirely() = runBlocking {
+ store.apply(gesture.press("e1", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000))
+ store.apply(gesture.seekedBack("e1", landedAtMs = 598_000, nowMs = 6_000))
+
+ assertEquals(emptyList<AdReportEntity>(), db.adReports().getForEpisode("e1"))
+ }
+
+ @Test
+ fun ordinaryNavigationWritesNothing() = runBlocking {
+ store.apply(gesture.seekedBack("e1", landedAtMs = 400_000, nowMs = 1_000))
+
+ assertEquals(emptyList<AdReportEntity>(), db.adReports().getForEpisode("e1"))
+ }
+
+ @Test
+ fun separatePodsAreSeparateClaims() = runBlocking {
+ store.apply(gesture.press("e1", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000))
+ store.apply(gesture.press("e1", positionMs = 1_200_000, jumpToMs = 1_245_000, nowMs = 600_000))
+
+ assertEquals(2, db.adReports().getForEpisode("e1").size)
+ assertEquals(listOf(600_000L, 1_200_000L), db.adReports().getAll().map { it.startMs })
+ }
+
+ @Test
+ fun aClaimNeedsNoEpisodeRow() = runBlocking {
+ // Nothing has inserted an episode here, and the insert still succeeds. That is the
+ // no-foreign-key decision holding: the corpus should outlive whichever episode happened
+ // to be on the phone when the label was given.
+ store.apply(gesture.press("gone", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000))
+
+ assertEquals(1, db.adReports().getAll().size)
+ }
+}
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 0124562..836ec3d 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -653,4 +653,49 @@ class MigrationTest {
assertEquals(0, it.getInt(1))
}
}
+
+ /**
+ * The manual-report table arrives empty and accepts a row. Nothing else moves: a report is
+ * new evidence, not a change to anything already stored.
+ */
+ @Test
+ fun migrate15To16AddsAdReportsAndLeavesEverythingElseAlone() {
+ helper.createDatabase(name, 15).apply {
+ execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled,
+ dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters, bedtime)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0,
+ 0, 86400000, 1, 1, 1, 0)"""
+ )
+ 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,
+ 141421, 'NONE')"""
+ )
+ close()
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 16, true, AppDatabase.MIGRATION_15_16)
+
+ db.query("SELECT COUNT(*) FROM ad_reports").use {
+ it.moveToFirst()
+ assertEquals(0, it.getInt(0))
+ }
+ db.execSQL(
+ "INSERT INTO ad_reports (episodeId, startMs, endMs, reportedAtMs, correctionWindowMs) " +
+ "VALUES ('e1', 600000, 645000, 99, 20000)"
+ )
+ db.query("SELECT endMs, endCorrected FROM ad_reports WHERE episodeId = 'e1'").use {
+ it.moveToFirst()
+ assertEquals(645000L, it.getLong(0))
+ assertEquals("an uncorrected end is where the jump landed, which is a guess", 0, it.getInt(1))
+ }
+ db.query("SELECT positionMs FROM episodes WHERE id = 'e1'").use {
+ it.moveToFirst()
+ assertEquals("the crown jewel must survive", 141421L, it.getLong(0))
+ }
+ }
}
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 3d5d54e..636dd9f 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -30,8 +30,9 @@ class Converters {
ShowMidrollEntity::class,
EpisodeMidrollEntity::class,
BreakVerdictEntity::class,
+ AdReportEntity::class,
],
- version = 15,
+ version = 16,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -44,6 +45,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun fingerprints(): ShowFingerprintDao
abstract fun midrolls(): MidrollDao
abstract fun breakVerdicts(): BreakVerdictDao
+ abstract fun adReports(): AdReportDao
companion object {
@Volatile private var instance: AppDatabase? = null
@@ -403,6 +405,28 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * Advertising the listener reported himself, which detection missed.
+ *
+ * A new table only. Nothing already stored changes meaning: a report is evidence added
+ * beside the detector's, not a correction to it.
+ */
+ val MIGRATION_15_16 = object : Migration(15, 16) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """CREATE TABLE IF NOT EXISTS `ad_reports` (
+ `episodeId` TEXT NOT NULL, `startMs` INTEGER NOT NULL,
+ `endMs` INTEGER NOT NULL, `reportedAtMs` INTEGER NOT NULL,
+ `correctionWindowMs` INTEGER NOT NULL,
+ `endCorrected` INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY(`episodeId`, `startMs`))"""
+ )
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS `index_ad_reports_episodeId` ON `ad_reports` (`episodeId`)"
+ )
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -415,7 +439,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6,
MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10,
MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14,
- MIGRATION_14_15,
+ MIGRATION_14_15, MIGRATION_15_16,
)
.addCallback(SEED_DEFAULT_FILTERS)
// No fallbackToDestructiveMigration. A listener's playback positions and play
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 bdbc28e..08f8c45 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -534,3 +534,20 @@ interface BreakVerdictDao {
@Query("DELETE FROM break_verdicts WHERE episodeId = :episodeId AND startMs = :startMs AND endMs = :endMs")
suspend fun delete(episodeId: String, startMs: Long, endMs: Long)
}
+
+/** Advertising the listener pointed at himself; see [AdReportEntity]. */
+@Dao
+interface AdReportDao {
+ @Query("SELECT * FROM ad_reports WHERE episodeId = :episodeId ORDER BY startMs")
+ suspend fun getForEpisode(episodeId: String): List<AdReportEntity>
+
+ /** Oldest first, which is the order they should be pushed to the server in. */
+ @Query("SELECT * FROM ad_reports ORDER BY reportedAtMs")
+ suspend fun getAll(): List<AdReportEntity>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(report: AdReportEntity)
+
+ @Query("DELETE FROM ad_reports WHERE episodeId = :episodeId AND startMs = :startMs")
+ suspend fun delete(episodeId: String, startMs: Long)
+}
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 4ae9faa..6deaef3 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -508,6 +508,40 @@ data class BreakVerdictEntity(
)
/**
+ * A stretch of audio the listener said was advertising, by pressing the notification button.
+ *
+ * The opposite evidence to [BreakVerdictEntity]: that one corrects a break detection found, this
+ * one reports advertising detection missed entirely. Detection accounts for roughly 38% of the
+ * advertising the feeds imply, so most of what he hears is undetected and only he can point at
+ * it.
+ *
+ * No foreign key, for the same reason as [BreakVerdictEntity]: the label should outlive the
+ * episode row. Keyed by start rather than by the whole range, because a second press extends the
+ * same claim and must replace it rather than add a row beside it.
+ */
+@Entity(
+ tableName = "ad_reports",
+ primaryKeys = ["episodeId", "startMs"],
+ indices = [Index(value = ["episodeId"])],
+)
+data class AdReportEntity(
+ val episodeId: String,
+ val startMs: Long,
+ val endMs: Long,
+ val reportedAtMs: Long,
+ /** The window the gesture was judged by; see [org.terst.listen.playback.AdReport]. */
+ val correctionWindowMs: Long,
+ /**
+ * Whether he came back and trimmed the end.
+ *
+ * An uncorrected end is wherever the jump landed, which is a guess. A corrected one is where
+ * he heard the programme resume, which is measured. A seeded search should not treat the two
+ * as equally good.
+ */
+ @ColumnInfo(defaultValue = "0") val endCorrected: Boolean = false,
+)
+
+/**
* One entry in the single Up Next queue.
*
* Position is a plain ordinal rewritten on reorder. A personal queue is short enough that the
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 0cee350..2b972b8 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -19,6 +19,7 @@ import org.terst.listen.feed.FeedFetcher
import org.terst.listen.feed.FeedResolver
import org.terst.listen.fingerprint.FileMidrollClipStore
import org.terst.listen.fingerprint.MidrollClipStore
+import org.terst.listen.playback.AdReportStore
import org.terst.listen.playback.PlaybackAdvancer
import org.terst.listen.playback.PlaybackSources
import org.terst.listen.playback.VerdictRecorder
@@ -68,6 +69,7 @@ class AppGraph(private val context: Context) {
val fingerprints: ShowFingerprintDao get() = db.fingerprints()
val midrolls: MidrollDao get() = db.midrolls()
val breakVerdicts: BreakVerdictDao get() = db.breakVerdicts()
+ val adReports: AdReportDao get() = db.adReports()
val libraryDir: File by lazy {
File(context.filesDir, "audio").also { it.mkdirs() }
@@ -149,6 +151,8 @@ class AppGraph(private val context: Context) {
val verdictRecorder: VerdictRecorder by lazy { VerdictRecorder(breakVerdicts) }
+ val adReportStore: AdReportStore by lazy { AdReportStore(adReports) }
+
val repository: SubscriptionRepository by lazy {
SubscriptionRepository(
subscriptions,
diff --git a/app/src/main/java/org/terst/listen/playback/AdJump.kt b/app/src/main/java/org/terst/listen/playback/AdJump.kt
new file mode 100644
index 0000000..99622c1
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/AdJump.kt
@@ -0,0 +1,36 @@
+package org.terst.listen.playback
+
+import org.terst.listen.data.BreakVerdictEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+
+/**
+ * How far "this is an ad" jumps.
+ *
+ * The press means "get me past this advertisement", so the jump is the best estimate available
+ * at that moment rather than a constant. If detection already found a break here, its end is a
+ * measured answer and is used. Otherwise the default stands in.
+ *
+ * The size of that default is the trade between too small -- several presses to clear one pod --
+ * and too large, which lands him in the programme and makes him correct constantly. Advertising
+ * is sold in roughly 15-second units grouped into pods, and he presses partway through one, so
+ * the typical *remaining* duration is what this estimates. Erring small is deliberate: a second
+ * press costs one tap and improves the label, while over-shooting costs him content.
+ */
+object AdJump {
+
+ /** Three 15-second units. See the class comment for why it is not larger. */
+ const val DEFAULT_JUMP_MS = 45_000L
+
+ fun targetFor(
+ ranges: List<EpisodeMidrollEntity>,
+ positionMs: Long,
+ durationMs: Long?,
+ verdicts: List<BreakVerdictEntity> = emptyList(),
+ ): Long {
+ val detected = BreakVerdicts.withoutInvalidated(ranges, verdicts)
+ .firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
+ ?.endMs
+ val target = detected ?: (positionMs + DEFAULT_JUMP_MS)
+ return if (durationMs != null) target.coerceAtMost(durationMs) else target
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/AdReportGesture.kt b/app/src/main/java/org/terst/listen/playback/AdReportGesture.kt
new file mode 100644
index 0000000..e30abf8
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/AdReportGesture.kt
@@ -0,0 +1,119 @@
+package org.terst.listen.playback
+
+/**
+ * One stretch of audio the listener said was advertising.
+ *
+ * A press is a timestamp, not a range, so [endMs] starts as wherever the jump took him and is
+ * corrected downwards if he comes back. [correctionWindowMs] is stored rather than read from
+ * configuration so that changing the window later cannot retroactively reinterpret old labels.
+ */
+data class AdReport(
+ val episodeId: String,
+ val startMs: Long,
+ val endMs: Long,
+ val reportedAtMs: Long,
+ val correctionWindowMs: Long,
+)
+
+/** What a press or a seek-back meant. */
+sealed interface ReportMove {
+ /** Advertising claimed from here; seek to [toMs]. */
+ data class Jump(val toMs: Long, val report: AdReport) : ReportMove
+
+ /** He came back, so the claim ends where he landed. */
+ data class Corrected(val report: AdReport) : ReportMove
+
+ /** He came back past the press: there was no advertising there. */
+ data class Withdrawn(val report: AdReport) : ReportMove
+
+ /** Ordinary navigation. Says nothing about advertising. */
+ data object NotALabel : ReportMove
+}
+
+/**
+ * The "this is an ad" button, which is one button and four meanings.
+ *
+ * Detection finds roughly 38% of the advertising the feeds imply, so most of what he hears is
+ * undetected and the popup on a mark can only ever correct what was already found. This is the
+ * other direction: a positive label, given at the one moment he actually notices, without
+ * unlocking anything.
+ *
+ * | gesture | means |
+ * |---------------------------------------|----------------------------------------|
+ * | press | advertising starts at or before here |
+ * | press again | still advertising -- extend |
+ * | back, landing inside the jumped span | over-shot -- the end is where I landed |
+ * | back, landing at or before the press | that was not advertising |
+ *
+ * The two back cases are told apart by where he lands against a span recorded exactly, not by
+ * guessing, and the jump size never enters into it. Outside the correction window a seek is
+ * ordinary navigation and must not be read as a label at all.
+ *
+ * Holds the one open claim. Not thread-safe: the service drives it from the main thread.
+ */
+class AdReportGesture(private val correctionWindowMs: Long = DEFAULT_WINDOW_MS) {
+
+ private var open: AdReport? = null
+
+ /**
+ * Claims advertising from [positionMs], extending the open claim when this press continues
+ * the last one. Returns where to seek to.
+ */
+ fun press(episodeId: String, positionMs: Long, jumpToMs: Long, nowMs: Long): ReportMove.Jump {
+ val continuing = current(episodeId, nowMs)
+ val report = AdReport(
+ episodeId = episodeId,
+ // The start of the claim survives every later press: he is labelling one pod, and
+ // the first press is the earliest point he can vouch for.
+ startMs = continuing?.startMs ?: positionMs,
+ endMs = jumpToMs,
+ reportedAtMs = nowMs,
+ correctionWindowMs = correctionWindowMs,
+ )
+ open = report
+ return ReportMove.Jump(toMs = jumpToMs, report = report)
+ }
+
+ /**
+ * What landing at [landedAtMs] after seeking backwards meant.
+ *
+ * Every backwards seek arrives here, whatever pressed it -- notification, headphone remote,
+ * the in-app bar, a dragged slider. That is the point: he corrects with whatever is to hand,
+ * and only the landing position carries the meaning.
+ */
+ fun seekedBack(episodeId: String, landedAtMs: Long, nowMs: Long): ReportMove {
+ val report = current(episodeId, nowMs) ?: return ReportMove.NotALabel
+ // Landing beyond the span is not a correction to it; he jumped somewhere else entirely.
+ if (landedAtMs >= report.endMs) return ReportMove.NotALabel
+
+ if (landedAtMs <= report.startMs) {
+ open = null
+ return ReportMove.Withdrawn(report)
+ }
+
+ // Stays open, and the window is refreshed: hunting back for where the programme resumes
+ // takes several presses, and the last landing is the one he settled on. Settling short
+ // of the true end under-claims, which is the safe direction -- it never cuts programme.
+ val corrected = report.copy(endMs = landedAtMs, reportedAtMs = nowMs)
+ open = corrected
+ return ReportMove.Corrected(corrected)
+ }
+
+ /** Drops the open claim. Called when the playing episode changes. */
+ fun forget() {
+ open = null
+ }
+
+ private fun current(episodeId: String, nowMs: Long): AdReport? =
+ open?.takeIf { it.episodeId == episodeId && nowMs - it.reportedAtMs <= correctionWindowMs }
+
+ companion object {
+ /**
+ * How long after a press a backwards seek still counts as a correction.
+ *
+ * Long enough to get the phone out of a pocket, short enough that it is unmistakably
+ * connected to the press. Later than this and a seek is just a seek.
+ */
+ const val DEFAULT_WINDOW_MS = 20_000L
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/AdReportStore.kt b/app/src/main/java/org/terst/listen/playback/AdReportStore.kt
new file mode 100644
index 0000000..2843f67
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/AdReportStore.kt
@@ -0,0 +1,36 @@
+package org.terst.listen.playback
+
+import org.terst.listen.data.AdReportDao
+import org.terst.listen.data.AdReportEntity
+
+/**
+ * Writes down what the gesture decided.
+ *
+ * Every move is persisted as it happens rather than when the claim looks finished, because
+ * "finished" is only knowable in hindsight -- the correction window expiring is not an event, and
+ * the media process can be killed at any point in the middle of one.
+ */
+class AdReportStore(private val dao: AdReportDao) {
+
+ suspend fun apply(move: ReportMove) {
+ when (move) {
+ // Keyed on the start, which every press of one pod shares, so extending replaces the
+ // row rather than adding a second claim beside it.
+ is ReportMove.Jump -> dao.upsert(move.report.row(endCorrected = false))
+ is ReportMove.Corrected -> dao.upsert(move.report.row(endCorrected = true))
+ // He took it back. Gone entirely rather than stored as a negative: the detector found
+ // nothing here, so there is nothing for a "no" to be about.
+ is ReportMove.Withdrawn -> dao.delete(move.report.episodeId, move.report.startMs)
+ ReportMove.NotALabel -> Unit
+ }
+ }
+
+ private fun AdReport.row(endCorrected: Boolean) = AdReportEntity(
+ episodeId = episodeId,
+ startMs = startMs,
+ endMs = endMs,
+ reportedAtMs = reportedAtMs,
+ correctionWindowMs = correctionWindowMs,
+ endCorrected = endCorrected,
+ )
+}
diff --git a/app/src/main/java/org/terst/listen/playback/AdReporter.kt b/app/src/main/java/org/terst/listen/playback/AdReporter.kt
new file mode 100644
index 0000000..4912a74
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/AdReporter.kt
@@ -0,0 +1,42 @@
+package org.terst.listen.playback
+
+/**
+ * The "this is an ad" button, joined up: the gesture that reads the presses, the estimate that
+ * decides how far to jump, and the table the claim lands in.
+ *
+ * One object so the service has one call per event and the three parts cannot be wired up
+ * inconsistently -- a press that jumps without recording, or records without jumping, is worse
+ * than either on its own.
+ */
+class AdReporter(
+ private val gesture: AdReportGesture,
+ private val store: AdReportStore,
+ private val now: () -> Long = System::currentTimeMillis,
+) {
+
+ /** Records the claim and returns where to seek to. */
+ suspend fun pressed(
+ episodeId: String,
+ positionMs: Long,
+ durationMs: Long?,
+ context: PlayingContext,
+ ): Long {
+ val target = AdJump.targetFor(context.midrolls, positionMs, durationMs, context.verdicts)
+ val move = gesture.press(episodeId, positionMs, jumpToMs = target, nowMs = now())
+ store.apply(move)
+ return move.toMs
+ }
+
+ /**
+ * Tells the gesture about a backwards seek, whatever caused it.
+ *
+ * Most are ordinary navigation and change nothing; the gesture decides which. Calling this
+ * for every backwards seek is deliberate -- he corrects with whatever control is to hand.
+ */
+ suspend fun seekedBack(episodeId: String, landedAtMs: Long) {
+ store.apply(gesture.seekedBack(episodeId, landedAtMs, nowMs = now()))
+ }
+
+ /** The playing episode changed, so no claim is open any more. */
+ fun forget() = gesture.forget()
+}
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 72fe5de..f0c9ae3 100644
--- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
+++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
@@ -28,6 +28,12 @@ import org.terst.listen.data.Graph
class ListenSessionCallback(
private val context: Context,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main),
+ /**
+ * What a "this is an ad" press does. Supplied by [PlaybackService] rather than built here,
+ * because the claim has to be judged against the episode's detected breaks and its length,
+ * which only the component holding the player knows.
+ */
+ private val onReportAd: () -> Unit = {},
) : MediaLibraryService.MediaLibrarySession.Callback {
private val graph by lazy { Graph.get(context) }
@@ -42,13 +48,20 @@ class ListenSessionCallback(
val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.add(NotificationControls.seekBackCommand)
.add(NotificationControls.seekForwardCommand)
+ .add(NotificationControls.reportAdCommand)
.build()
// Previous/next stay available -- removing them to free car-notification slots also
// removed the headphone skip button, which stopped working entirely. PodcastPlayer
// redefines them as skip-forward/back instead, so they are useful rather than absent.
- // Only previous-media-item goes, since there is no previous item and he does not want
- // the control.
+ //
+ // The two *media-item* commands go, and only those: there is one item loaded, so moving
+ // between items is meaningless, and a control that does nothing is worse than no control.
+ // COMMAND_SEEK_TO_PREVIOUS and COMMAND_SEEK_TO_NEXT -- the ones a headphone remote and a
+ // Bluetooth stack actually send -- are untouched and must stay that way.
+ //
+ // Nothing here is removed to make room in the notification any more; the slots on
+ // NotificationControls' buttons do that without touching commands at all.
val playerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
@@ -57,7 +70,7 @@ class ListenSessionCallback(
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(sessionCommands)
.setAvailablePlayerCommands(playerCommands)
- .setCustomLayout(ImmutableList.copyOf(NotificationControls.customLayout()))
+ .setMediaButtonPreferences(ImmutableList.copyOf(NotificationControls.mediaButtonPreferences()))
.build()
}
@@ -70,6 +83,7 @@ class ListenSessionCallback(
when (customCommand.customAction) {
NotificationControls.ACTION_SEEK_BACK -> session.player.seekBack()
NotificationControls.ACTION_SEEK_FORWARD -> session.player.seekForward()
+ NotificationControls.ACTION_REPORT_AD -> onReportAd()
else -> return Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED))
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
diff --git a/app/src/main/java/org/terst/listen/playback/NotificationControls.kt b/app/src/main/java/org/terst/listen/playback/NotificationControls.kt
index 5c8d7c5..21f3959 100644
--- a/app/src/main/java/org/terst/listen/playback/NotificationControls.kt
+++ b/app/src/main/java/org/terst/listen/playback/NotificationControls.kt
@@ -7,13 +7,21 @@ import androidx.media3.session.CommandButton
import androidx.media3.session.SessionCommand
/**
- * Seek buttons for the notification, lock screen and Bluetooth controls.
+ * What the notification, lock screen, Bluetooth controls and car head unit show.
*
- * Media3's default layout offers previous/next *track*, which is right for an album and wrong
- * for a podcast: with a single item loaded there is no next track, so the notification showed a
- * previous button and nothing else. What a listener actually wants there is skip-back and
- * skip-forward by seconds, and those are not default buttons -- they have to be published as
- * custom commands.
+ * Media3's defaults offer previous/next *track*, which is right for an album and wrong for a
+ * podcast: with a single item loaded there is no next track. [PodcastPlayer] redefines those two
+ * as skip-back and skip-forward by seconds so a headphone remote does something useful, and this
+ * published skip-back-15 / forward-25 pair beside them -- which left the notification carrying
+ * four buttons doing two jobs.
+ *
+ * These are media button *preferences*, not a custom layout, so the duplication goes: a button
+ * claiming [CommandButton.SLOT_BACK] or [CommandButton.SLOT_FORWARD] takes that slot instead of
+ * the default previous/next button, and nothing is removed from the player's available commands
+ * to achieve it. That matters -- removing commands to free notification slots once silently
+ * killed the headphone skip button, and this route never touches them.
+ *
+ * The reclaimed slot carries "this is an ad". See [AdReportGesture] for what a press means.
*
* The seek amounts themselves live on the player ([PlaybackService] sets them), so these buttons
* and the in-app bar cannot drift apart.
@@ -23,9 +31,11 @@ object NotificationControls {
const val ACTION_SEEK_BACK = "org.terst.listen.SEEK_BACK"
const val ACTION_SEEK_FORWARD = "org.terst.listen.SEEK_FORWARD"
+ const val ACTION_REPORT_AD = "org.terst.listen.REPORT_AD"
val seekBackCommand = SessionCommand(ACTION_SEEK_BACK, Bundle.EMPTY)
val seekForwardCommand = SessionCommand(ACTION_SEEK_FORWARD, Bundle.EMPTY)
+ val reportAdCommand = SessionCommand(ACTION_REPORT_AD, Bundle.EMPTY)
/**
* Media3 ships numbered icons for 5, 10, 15 and 30 seconds only.
@@ -33,17 +43,28 @@ object NotificationControls {
* Back is 15, so it gets the numbered icon. Forward is 25 and there is no 25 icon, so it
* gets the plain arrow rather than one captioned with a number the button doesn't do.
*/
- fun customLayout(): List<CommandButton> = listOf(
+ fun mediaButtonPreferences(): List<CommandButton> = listOf(
CommandButton.Builder()
.setDisplayName("Back 15 seconds")
.setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_back_15)
.setSessionCommand(seekBackCommand)
+ .setSlots(CommandButton.SLOT_BACK)
.build(),
CommandButton.Builder()
.setDisplayName("Forward 25 seconds")
.setIconResId(androidx.media3.session.R.drawable.media3_icon_skip_forward)
.setSessionCommand(seekForwardCommand)
+ .setSlots(CommandButton.SLOT_FORWARD)
+ .build(),
+ // A circle-and-slash rather than another arrow: it must not be mistakable for the
+ // forward button in a pocket, which is the one real risk of putting it next to one.
+ CommandButton.Builder()
+ .setDisplayName("This is an ad")
+ .setIconResId(androidx.media3.session.R.drawable.media3_icon_block)
+ .setSessionCommand(reportAdCommand)
+ // Beside forward where there is room, in the overflow where there is not. Never in
+ // a seek slot: it would then replace a control he uses constantly.
+ .setSlots(CommandButton.SLOT_FORWARD_SECONDARY, CommandButton.SLOT_OVERFLOW)
.build(),
)
-
}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
index 603ec5b..3a2543b 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -37,6 +37,9 @@ class PlaybackService : MediaLibraryService() {
private var session: MediaLibrarySession? = null
private var writer: PositionWriter? = null
private var advancer: PlaybackAdvancer? = null
+
+ /** The "this is an ad" button, which lives on the notification. See [AdReportGesture]. */
+ private var reporter: AdReporter? = null
private val progress = PlaybackProgress()
/**
@@ -70,19 +73,28 @@ class PlaybackService : MediaLibraryService() {
.build()
player = exo
+
+ // Read before the session is built: the session's callback needs the reporter, because
+ // a "this is an ad" press is answered from the player's position.
+ val graph = Graph.get(this)
+ reporter = AdReporter(AdReportGesture(), graph.adReportStore)
+
// A library session rather than a plain one: Android Auto only treats an app as
// driving-friendly if it can browse a media library, and without that the head unit
// can't start or choose anything.
// Wrapped so next/previous mean skip-forward/back by seconds; see PodcastPlayer.
- session = MediaLibrarySession.Builder(this, PodcastPlayer(exo), ListenSessionCallback(this, scope))
- .setCustomLayout(NotificationControls.customLayout())
+ session = MediaLibrarySession.Builder(
+ this,
+ PodcastPlayer(exo),
+ ListenSessionCallback(this, scope, onReportAd = ::reportAd),
+ )
+ .setMediaButtonPreferences(NotificationControls.mediaButtonPreferences())
// Without this the notification is inert to a tap -- the transport buttons work and
// the body does nothing, which reads as the app being broken rather than as a
// deliberate omission.
.setSessionActivity(openAppIntent())
.build()
- val graph = Graph.get(this)
writer = PositionWriter(ExoPlayhead(exo), graph.playbackState)
advancer = graph.advancer
@@ -136,6 +148,28 @@ class PlaybackService : MediaLibraryService() {
finish(finished)
}
+ /**
+ * Every seek, whatever asked for it -- the notification, a headphone remote, the in-app
+ * bar, a dragged slider.
+ *
+ * Only backwards ones mean anything here, and most of those are ordinary navigation;
+ * [AdReportGesture] decides which few are corrections to a claim he just made. Hooking
+ * the discontinuity rather than each control is the point: he corrects with whatever is
+ * to hand, and adding a control later must not quietly stop being heard.
+ */
+ override fun onPositionDiscontinuity(
+ oldPosition: Player.PositionInfo,
+ newPosition: Player.PositionInfo,
+ reason: Int,
+ ) {
+ if (reason != Player.DISCONTINUITY_REASON_SEEK) return
+ if (newPosition.positionMs >= oldPosition.positionMs) return
+ val episodeId = newPosition.mediaItem?.mediaId ?: return
+ val current = reporter ?: return
+ val landedAtMs = newPosition.positionMs
+ scope.launch { current.seekedBack(episodeId, landedAtMs) }
+ }
+
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
// Where the prepare clock starts. This is the one hook both play paths reach: the
// service's own queue advance, and a tap in the UI, which sets the item through a
@@ -144,6 +178,9 @@ class PlaybackService : MediaLibraryService() {
val uri = mediaItem.localConfiguration?.uri?.scheme
prepareTrace.start(id, durationMs = null, local = uri == null || uri == "file")
}
+ // Whatever he was labelling, he has left it. A claim open across an episode change
+ // would read the next episode's first seek-back as a correction to the last one's.
+ reporter?.forget()
// The cached show and chapters belong to whatever is playing; refresh them whenever
// that changes, including when something else in the app starts an episode.
loadContextFor(mediaItem?.mediaId)
@@ -171,6 +208,7 @@ class PlaybackService : MediaLibraryService() {
player = null
writer = null
advancer = null
+ reporter = null
super.onDestroy()
}
@@ -224,6 +262,29 @@ class PlaybackService : MediaLibraryService() {
}
}
+ /**
+ * "This is an ad": records the claim, then jumps past it.
+ *
+ * Recorded before the seek rather than after, so the span he is about to jump over is what
+ * gets stored. The jump is answered from this episode's detected breaks when there is one
+ * here -- see [AdJump] -- which is why this lives with the player rather than in the session
+ * callback.
+ */
+ private fun reportAd() {
+ val exo = player ?: return
+ val current = reporter ?: return
+ val episodeId = exo.currentMediaItem?.mediaId ?: return
+ val positionMs = exo.currentPosition
+ val durationMs = exo.duration.takeIf { it > 0 }
+ val playing = context
+ scope.launch {
+ val target = current.pressed(episodeId, positionMs, durationMs, playing)
+ // Re-checked because the write suspended: a queue advance in that window would
+ // otherwise move the next episode's playhead by this episode's press.
+ if (player?.currentMediaItem?.mediaId == episodeId) player?.seekTo(target)
+ }
+ }
+
/** Marks the episode played, moves the queue on, and starts what follows or stops. */
private fun finish(episodeId: String) {
val current = advancer ?: return
diff --git a/app/src/test/java/org/terst/listen/playback/AdJumpTest.kt b/app/src/test/java/org/terst/listen/playback/AdJumpTest.kt
new file mode 100644
index 0000000..3421aa2
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/AdJumpTest.kt
@@ -0,0 +1,93 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.terst.listen.data.BreakVerdictEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+
+/**
+ * How far "this is an ad" jumps.
+ *
+ * A press means "get me past this advertisement", so the jump is the best estimate available at
+ * that moment rather than a constant.
+ */
+class AdJumpTest {
+
+ private fun range(startMs: Long, endMs: Long, kind: String = "ad") =
+ EpisodeMidrollEntity(
+ episodeId = "ep",
+ candidateId = "c",
+ startMs = startMs,
+ endMs = endMs,
+ kind = kind,
+ )
+
+ @Test
+ fun `with nothing known it jumps the default`() {
+ assertEquals(
+ 600_000 + AdJump.DEFAULT_JUMP_MS,
+ AdJump.targetFor(emptyList(), positionMs = 600_000, durationMs = 3_600_000),
+ )
+ }
+
+ @Test
+ fun `inside a detected break it jumps to that break's end, which is already known`() {
+ val ranges = listOf(range(590_000, 700_000))
+
+ assertEquals(700_000L, AdJump.targetFor(ranges, positionMs = 600_000, durationMs = 3_600_000))
+ }
+
+ @Test
+ fun `a detected break that ends sooner than the default still wins`() {
+ val ranges = listOf(range(595_000, 620_000))
+
+ assertEquals(620_000L, AdJump.targetFor(ranges, positionMs = 600_000, durationMs = 3_600_000))
+ }
+
+ @Test
+ fun `a break he has marked wrong is not used as the estimate`() {
+ val ranges = listOf(range(590_000, 700_000))
+ val verdicts = listOf(
+ BreakVerdictEntity(
+ episodeId = "ep",
+ startMs = 590_000,
+ endMs = 700_000,
+ verdict = BreakVerdicts.INVALID,
+ decidedAtMs = 0,
+ )
+ )
+
+ assertEquals(
+ 600_000 + AdJump.DEFAULT_JUMP_MS,
+ AdJump.targetFor(ranges, positionMs = 600_000, durationMs = 3_600_000, verdicts = verdicts),
+ )
+ }
+
+ @Test
+ fun `a break starting ahead of the playhead is not what he is hearing`() {
+ val ranges = listOf(range(700_000, 800_000))
+
+ assertEquals(
+ 600_000 + AdJump.DEFAULT_JUMP_MS,
+ AdJump.targetFor(ranges, positionMs = 600_000, durationMs = 3_600_000),
+ )
+ }
+
+ @Test
+ fun `the jump never runs off the end of the episode`() {
+ assertEquals(600_000L, AdJump.targetFor(emptyList(), positionMs = 580_000, durationMs = 600_000))
+ }
+
+ @Test
+ fun `an unknown length is no reason not to jump`() {
+ assertEquals(
+ 600_000 + AdJump.DEFAULT_JUMP_MS,
+ AdJump.targetFor(emptyList(), positionMs = 600_000, durationMs = null),
+ )
+ }
+
+ @Test
+ fun `the default is a plausible remaining advertisement, in whole ad units`() {
+ assertEquals(0L, AdJump.DEFAULT_JUMP_MS % 15_000L)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/AdReportGestureTest.kt b/app/src/test/java/org/terst/listen/playback/AdReportGestureTest.kt
new file mode 100644
index 0000000..2f25391
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/AdReportGestureTest.kt
@@ -0,0 +1,150 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The "this is an ad" gesture, which is one button and four meanings.
+ */
+class AdReportGestureTest {
+
+ private val gesture = AdReportGesture(correctionWindowMs = 20_000L)
+
+ @Test
+ fun `a press claims advertising from here and jumps`() {
+ val move = gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ assertEquals(645_000L, move.toMs)
+ assertEquals(600_000L, move.report.startMs)
+ assertEquals(645_000L, move.report.endMs)
+ }
+
+ @Test
+ fun `pressing again extends the same claim rather than starting a second`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ val again = gesture.press("ep", positionMs = 645_000, jumpToMs = 690_000, nowMs = 6_000)
+
+ assertEquals(600_000L, again.report.startMs)
+ assertEquals(690_000L, again.report.endMs)
+ }
+
+ @Test
+ fun `a press long after the last one is a separate break`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ val later = gesture.press("ep", positionMs = 1_200_000, jumpToMs = 1_245_000, nowMs = 600_000)
+
+ assertEquals(1_200_000L, later.report.startMs)
+ }
+
+ @Test
+ fun `a press on a different episode is a separate break`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ val other = gesture.press("other", positionMs = 10_000, jumpToMs = 55_000, nowMs = 6_000)
+
+ assertEquals(10_000L, other.report.startMs)
+ assertEquals("other", other.report.episodeId)
+ }
+
+ @Test
+ fun `seeking back into the jumped span moves the end to where he landed`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ val move = gesture.seekedBack("ep", landedAtMs = 630_000, nowMs = 6_000)
+
+ assertTrue("got $move", move is ReportMove.Corrected)
+ assertEquals(600_000L, (move as ReportMove.Corrected).report.startMs)
+ assertEquals(630_000L, move.report.endMs)
+ }
+
+ @Test
+ fun `a second seek back corrects the end again`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+ gesture.seekedBack("ep", landedAtMs = 630_000, nowMs = 6_000)
+
+ val move = gesture.seekedBack("ep", landedAtMs = 615_000, nowMs = 11_000)
+
+ assertEquals(615_000L, (move as ReportMove.Corrected).report.endMs)
+ }
+
+ @Test
+ fun `seeking back to before the press withdraws the claim`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ val move = gesture.seekedBack("ep", landedAtMs = 598_000, nowMs = 6_000)
+
+ assertTrue("got $move", move is ReportMove.Withdrawn)
+ assertEquals(600_000L, (move as ReportMove.Withdrawn).report.startMs)
+ }
+
+ @Test
+ fun `landing exactly back at the press withdraws it -- nothing was there`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ val move = gesture.seekedBack("ep", landedAtMs = 600_000, nowMs = 6_000)
+
+ assertTrue("got $move", move is ReportMove.Withdrawn)
+ }
+
+ @Test
+ fun `a withdrawn claim is gone, so the next seek back is ordinary navigation`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+ gesture.seekedBack("ep", landedAtMs = 598_000, nowMs = 6_000)
+
+ assertEquals(ReportMove.NotALabel, gesture.seekedBack("ep", landedAtMs = 583_000, nowMs = 11_000))
+ }
+
+ @Test
+ fun `seeking back long after the press is ordinary navigation`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ assertEquals(ReportMove.NotALabel, gesture.seekedBack("ep", landedAtMs = 630_000, nowMs = 600_000))
+ }
+
+ @Test
+ fun `seeking back with no claim open is ordinary navigation`() {
+ assertEquals(ReportMove.NotALabel, gesture.seekedBack("ep", landedAtMs = 630_000, nowMs = 1_000))
+ }
+
+ @Test
+ fun `seeking back on a different episode is ordinary navigation`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ assertEquals(ReportMove.NotALabel, gesture.seekedBack("other", landedAtMs = 630_000, nowMs = 6_000))
+ }
+
+ @Test
+ fun `landing past the end of the jump is not a correction`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ assertEquals(ReportMove.NotALabel, gesture.seekedBack("ep", landedAtMs = 660_000, nowMs = 6_000))
+ }
+
+ @Test
+ fun `each correction refreshes the window, so a hunt for the content start holds together`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+ gesture.seekedBack("ep", landedAtMs = 630_000, nowMs = 15_000)
+
+ val move = gesture.seekedBack("ep", landedAtMs = 615_000, nowMs = 30_000)
+
+ assertEquals(615_000L, (move as ReportMove.Corrected).report.endMs)
+ }
+
+ @Test
+ fun `the window the label was judged by is stored with it`() {
+ val move = gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+
+ assertEquals(20_000L, move.report.correctionWindowMs)
+ }
+
+ @Test
+ fun `changing episode closes the claim`() {
+ gesture.press("ep", positionMs = 600_000, jumpToMs = 645_000, nowMs = 1_000)
+ gesture.forget()
+
+ assertEquals(ReportMove.NotALabel, gesture.seekedBack("ep", landedAtMs = 630_000, nowMs = 6_000))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/playback/AdReporterTest.kt b/app/src/test/java/org/terst/listen/playback/AdReporterTest.kt
new file mode 100644
index 0000000..6ead0de
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/AdReporterTest.kt
@@ -0,0 +1,99 @@
+package org.terst.listen.playback
+
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.terst.listen.data.AdReportDao
+import org.terst.listen.data.AdReportEntity
+import org.terst.listen.data.EpisodeMidrollEntity
+
+/** The press, the jump and the row, joined up. */
+class AdReporterTest {
+
+ private val dao = FakeAdReportDao()
+ private var clock = 1_000L
+ private val reporter = AdReporter(
+ gesture = AdReportGesture(correctionWindowMs = 20_000),
+ store = AdReportStore(dao),
+ now = { clock },
+ )
+
+ private fun range(startMs: Long, endMs: Long) =
+ EpisodeMidrollEntity(episodeId = "e1", candidateId = "c", startMs = startMs, endMs = endMs)
+
+ @Test
+ fun `a press jumps the default and records the claim`() = runBlocking {
+ val target = reporter.pressed("e1", positionMs = 600_000, durationMs = 3_600_000, context = PlayingContext())
+
+ assertEquals(600_000 + AdJump.DEFAULT_JUMP_MS, target)
+ assertEquals(600_000L, dao.rows.single().startMs)
+ assertEquals(600_000 + AdJump.DEFAULT_JUMP_MS, dao.rows.single().endMs)
+ }
+
+ @Test
+ fun `inside a detected break the press jumps to its measured end`() = runBlocking {
+ val context = PlayingContext(midrolls = listOf(range(590_000, 712_000)))
+
+ val target = reporter.pressed("e1", positionMs = 600_000, durationMs = 3_600_000, context = context)
+
+ assertEquals(712_000L, target)
+ }
+
+ @Test
+ fun `coming back inside the jump trims the claim`() = runBlocking {
+ reporter.pressed("e1", positionMs = 600_000, durationMs = 3_600_000, context = PlayingContext())
+ clock = 6_000
+
+ reporter.seekedBack("e1", landedAtMs = 620_000)
+
+ assertEquals(620_000L, dao.rows.single().endMs)
+ assertTrue(dao.rows.single().endCorrected)
+ }
+
+ @Test
+ fun `coming back past the press removes the claim`() = runBlocking {
+ reporter.pressed("e1", positionMs = 600_000, durationMs = 3_600_000, context = PlayingContext())
+ clock = 6_000
+
+ reporter.seekedBack("e1", landedAtMs = 599_000)
+
+ assertEquals(emptyList<AdReportEntity>(), dao.rows)
+ }
+
+ @Test
+ fun `a seek back with nothing claimed records nothing`() = runBlocking {
+ reporter.seekedBack("e1", landedAtMs = 100_000)
+
+ assertEquals(emptyList<AdReportEntity>(), dao.rows)
+ }
+
+ @Test
+ fun `changing episode ends the gesture, so the next seek back is navigation`() = runBlocking {
+ reporter.pressed("e1", positionMs = 600_000, durationMs = 3_600_000, context = PlayingContext())
+ reporter.forget()
+ clock = 6_000
+
+ reporter.seekedBack("e1", landedAtMs = 620_000)
+
+ assertEquals("the claim stands as pressed", 600_000 + AdJump.DEFAULT_JUMP_MS, dao.rows.single().endMs)
+ }
+}
+
+private class FakeAdReportDao : AdReportDao {
+ val rows = mutableListOf<AdReportEntity>()
+
+ override suspend fun getForEpisode(episodeId: String) =
+ rows.filter { it.episodeId == episodeId }.sortedBy { it.startMs }
+
+ override suspend fun getAll() = rows.sortedBy { it.reportedAtMs }
+
+ override suspend fun upsert(report: AdReportEntity) {
+ rows.removeAll { it.episodeId == report.episodeId && it.startMs == report.startMs }
+ rows += report
+ }
+
+ override suspend fun delete(episodeId: String, startMs: Long) {
+ rows.removeAll { it.episodeId == episodeId && it.startMs == startMs }
+ }
+}