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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,22 @@ These apply from Phase 2 on. If Phase 2 refines them, update this section in the
- Code that needs nothing from Android lives in plain Kotlin/JVM modules with no Android imports: the YouTube layer, the domain models, the MVI base types and the reducers. See ADR 0006.
- Only the YouTube layer module may use NewPipeExtractor. It maps every NewPipeExtractor type to Dewfall's own models, so nothing else in the app knows which library sits underneath.
- Every screen uses MVI: an immutable State, an Intent sealed interface, one-time Effects, and a reducer that is a pure function. The ViewModel runs side effects. Every reducer has unit tests.
- Every screen's ViewModel extends MviViewModel from `:core:mvi`. Intents include the results of the ViewModel's work, and effects stay pending as state until the UI reports them handled. docs/architecture.md describes the MVI contract.
- Playback state never goes into screen state. The player lives in a MediaSessionService and exposes playback state as its own Flow. Position updates run only as often as the visible UI needs them.
- Download, sync and cleanup logic must not depend on any screen being open, because background WorkManager jobs reuse it. Background jobs run only on unmetered networks while charging.
- No hardcoded user-facing text. Every string goes in resources, and every icon has a content description.
- Colors come from MaterialTheme color roles. Never hardcode colors. Every screen must work in light, dark and dynamic color.
- Every screen draws edge to edge and handles window insets. Backgrounds may sit under the system bars, but no text or controls sit under the system bars or the display cutout. Android 16 and later give apps that target them no way to opt out. See ADR 0006.
- Annotation processors for Kotlin code, such as Hilt's and Room's compilers, run through KSP. The kotlin-kapt plugin doesn't work with AGP's built-in Kotlin, and Google recommends KSP over its com.android.legacy-kapt fallback. Ask first before adding kotlin-kapt or com.android.legacy-kapt.
- Use coroutines and Flow. Never use GlobalScope. Inject dispatchers so tests can replace them.
- Use coroutines and Flow. Never use GlobalScope. Inject dispatchers so tests can replace them. The one exception is viewModelScope's Main dispatcher: ViewModels take no dispatcher, and their tests replace Main with Dispatchers.setMain.
- The database uses Room 3, in the androidx.room3 package. Room 2 is deprecated.
- Room schemas are exported into core/database/schemas/. Commit the schema files that the build writes, and never edit them by hand. The first public release is the first GitHub release, including a pre-release, published from a version tag. A tag made only to test the release workflow, with no published release, doesn't count, and nightly APKs don't count. Until then, the database stays at version 1, and a schema change needs no migration. From the first public release on, every schema change raises the version and needs a migration and a migration test. Never use destructive migrations.

## Testing rules

- New logic ships with tests in the same pull request.
- Write tests with kotlin.test's annotations and assertions. They run on JUnit 6 in plain Kotlin/JVM modules and on JUnit 4 in Android modules. docs/testing.md explains how to test reducers and ViewModels.
- ViewModel tests replace the Main dispatcher with Dispatchers.setMain before each test and reset it after.
- Unit tests never touch the network. The YouTube layer is tested against saved responses. Live YouTube checks run only in the scheduled CI workflow.
- Screens get screenshot tests in light, dark and large font.
- Never delete, skip or weaken a test to make it pass. If a test looks wrong, explain why and ask.
Expand All @@ -110,6 +113,7 @@ Run these from the repository root. The build needs JDK 17 installed, because ev

- Build the debug APK: `./gradlew assembleDebug`. The APK goes to app/build/outputs/apk/debug/app-debug.apk.
- Run the unit tests: `./gradlew test`. It runs the tests of the plain Kotlin/JVM modules and the debug unit tests of the Android modules.
- Run one module's unit tests: `./gradlew :core:mvi:test` for a plain Kotlin/JVM module, or `./gradlew :core:data:testDebugUnitTest` for an Android module. Add `--tests` with a class name to run one test class. The HTML report goes to build/reports/tests/ in the module.
- Check formatting: `./gradlew spotlessCheck`. It checks every Kotlin file and Kotlin build script in the repository, build-logic included, against ktfmt in kotlinlang style.
- Fix formatting: `./gradlew spotlessApply`. It rewrites the same files in place. Run it before you commit.
- Run detekt: `./gradlew detekt`. It checks the Kotlin code in src/main and src/test of every module, and the code in build-logic, without type resolution. Any finding fails the build. Reports go to build/reports/detekt/ in each project.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
}

configureJavaToolchain()
configureAndroidUnitTests()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
}

configureJavaToolchain()
configureAndroidUnitTests()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class JvmLibraryConventionPlugin : Plugin<Project> {
}

configureJavaToolchain()
configureJvmUnitTests()

// Lint's general checks. Its API check doesn't run here, so configureAndroidApiCheck
// checks these modules against the minSdk instead.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.github.dewfallapp.buildlogic

import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.withType

/**
* Unit tests in plain Kotlin/JVM modules run on JUnit 6, through the JUnit Platform. Tests use
* kotlin.test's annotations and assertions, so they read the same as in Android modules. See
* docs/testing.md.
*/
internal fun Project.configureJvmUnitTests() {
tasks.withType<Test>().configureEach { useJUnitPlatform() }

val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
// The BOM also lifts the JUnit 5 artifacts that kotlin-test-junit5 asks for to JUnit 6.
add("testImplementation", platform(libs.findLibrary("junit-bom").get()))
add("testImplementation", libs.findLibrary("kotlin-test-junit5").get())
add("testImplementation", libs.findLibrary("kotlinx-coroutines-test").get())
// Gradle 9 no longer puts the JUnit Platform launcher on the test runtime classpath.
add("testRuntimeOnly", libs.findLibrary("junit-platform-launcher").get())
}
}

/**
* Local unit tests in Android modules run on JUnit 4, AGP's default, because Robolectric and
* Compose's test rules need it. Tests use kotlin.test's annotations and assertions, as in plain
* Kotlin/JVM modules. See docs/testing.md.
*/
internal fun Project.configureAndroidUnitTests() {
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
add("testImplementation", libs.findLibrary("junit4").get())
add("testImplementation", libs.findLibrary("kotlin-test-junit").get())
add("testImplementation", libs.findLibrary("kotlinx-coroutines-test").get())
}
}
8 changes: 8 additions & 0 deletions core/mvi/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
plugins {
id("dewfall.jvm.library")
}

dependencies {
// MviViewModel extends Lifecycle's ViewModel and exposes StateFlows, so the modules that use it
// see both. This module compiles against Lifecycle's JVM build. Android modules get its Android
// build, which has the same API.
api(libs.androidx.lifecycle.viewmodel)
api(libs.kotlinx.coroutines.core)
}
22 changes: 22 additions & 0 deletions core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/MviTypes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.github.dewfallapp.core.mvi

/**
* A screen's state. Each screen declares one immutable class for it, usually a data class with val
* properties and read-only collections. After the initial state, only the screen's reducer makes
* new ones. See docs/architecture.md#the-mvi-contract.
*/
interface MviState

/**
* Something that happens to a screen: what the user does, or the result of work that the ViewModel
* ran, such as a load that finished. Each screen declares a sealed interface that extends this one,
* and names results in the past tense, such as Loaded.
*/
interface MviIntent

/**
* A one-time instruction to the UI, such as showing a message or opening another screen. The
* ViewModel holds it as pending until the UI reports that it handled it. Each screen declares a
* sealed interface that extends this one.
*/
interface MviEffect
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.github.dewfallapp.core.mvi

import androidx.lifecycle.ViewModel
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

/**
* The base class of every screen's ViewModel. It holds the screen's state and the effects the UI
* hasn't handled yet, and runs every intent through the screen's reducer. Subclasses start the work
* that intents need in [runSideEffects], and report results back through [dispatch].
*
* Both the state and the pending effects live as long as the ViewModel. They survive configuration
* changes and the UI stopping, but not process death. See docs/architecture.md#the-mvi-contract.
*/
abstract class MviViewModel<S : MviState, I : MviIntent, E : MviEffect>(
initialState: S,
private val reducer: Reducer<S, I, E>,
) : ViewModel() {

private val mutableState = MutableStateFlow(initialState)

/** The screen's state. */
val state: StateFlow<S> = mutableState.asStateFlow()

private val mutablePendingEffects = MutableStateFlow<List<PendingEffect<E>>>(emptyList())

/**
* The effects the UI hasn't handled yet, oldest first. The UI handles the first one, then calls
* [effectHandled] with its id. Until then, the effect stays here.
*/
val pendingEffects: StateFlow<List<PendingEffect<E>>> = mutablePendingEffects.asStateFlow()

private val lastEffectId = AtomicLong()

/**
* The single entry point for intents: what the user does, and the results of work that
* [runSideEffects] started. Runs the reducer, adds its effects to [pendingEffects], then calls
* [runSideEffects]. Call it on the main thread, as the UI and viewModelScope do.
*/
fun dispatch(intent: I) {
lateinit var next: Next<S, E>
// update runs the reducer again if another thread changed the state in between. The
// reducer is pure, so that's safe, and only the result that update stores counts.
mutableState.update { current -> reducer.reduce(current, intent).also { next = it }.state }
if (next.effects.isNotEmpty()) {
val added = next.effects.map { PendingEffect(lastEffectId.incrementAndGet(), it) }
mutablePendingEffects.update { it + added }
}
runSideEffects(intent)
}

/** Removes the pending effect with this id, once the UI has handled it. */
fun effectHandled(id: Long) {
mutablePendingEffects.update { pending -> pending.filterNot { it.id == id } }
}

/**
* Starts the work that an intent needs, such as a load, in viewModelScope. It runs after the
* reducer, so state.value is already the new state. Results come back through [dispatch] as
* intents of their own.
*/
protected abstract fun runSideEffects(intent: I)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.github.dewfallapp.core.mvi

/**
* An effect that the UI hasn't handled yet. The id is unique within one ViewModel, so the UI can
* report exactly which effect it handled, even when two effects are equal.
*/
data class PendingEffect<out E : MviEffect>(val id: Long, val effect: E)
16 changes: 16 additions & 0 deletions core/mvi/src/main/kotlin/io/github/dewfallapp/core/mvi/Reducer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.github.dewfallapp.core.mvi

/**
* Turns the old state and an intent into the new state and any one-time effects. It must be a pure
* function, with no coroutines, I/O, clock, randomness or shared mutable state, so it can be tested
* without a device. Every reducer has unit tests.
*/
fun interface Reducer<S : MviState, in I : MviIntent, out E : MviEffect> {
fun reduce(state: S, intent: I): Next<S, E>
}

/** What a reducer returns: the new state, and the effects for the UI to handle, in order. */
data class Next<out S : MviState, out E : MviEffect>(
val state: S,
val effects: List<E> = emptyList(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package io.github.dewfallapp.core.mvi

import io.github.dewfallapp.core.mvi.counter.CounterEffect
import io.github.dewfallapp.core.mvi.counter.CounterIntent
import io.github.dewfallapp.core.mvi.counter.CounterReducer
import io.github.dewfallapp.core.mvi.counter.CounterState
import io.github.dewfallapp.core.mvi.counter.CounterViewModel
import io.github.dewfallapp.core.mvi.counter.SavedCount
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain

/** Tests MviViewModel through the counter example in the counter package. */
// setMain, resetMain, UnconfinedTestDispatcher and advanceUntilIdle are still marked experimental.
@OptIn(ExperimentalCoroutinesApi::class)
class MviViewModelTest {
@BeforeTest
fun setUp() {
// viewModelScope runs on Dispatchers.Main, which local tests don't have. Every
// TestDispatcher made after this, including runTest's, shares this one's scheduler.
Dispatchers.setMain(UnconfinedTestDispatcher())
}

@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}

@Test
fun `dispatch runs the reducer and shows the new state`() = runTest {
val viewModel = counterViewModel()

viewModel.dispatch(CounterIntent.Increment)

assertEquals(CounterState(count = 1), viewModel.state.value)
}

@Test
fun `a load result comes back through the reducer`() = runTest {
val viewModel = counterViewModel(savedCount = 2)

viewModel.dispatch(CounterIntent.Load)
assertEquals(CounterState(isLoading = true), viewModel.state.value)

// Runs the load. Its delay takes no real time.
advanceUntilIdle()
assertEquals(CounterState(count = 2), viewModel.state.value)
}

@Test
fun `an effect stays pending until effectHandled`() = runTest {
val viewModel = counterViewModel()
viewModel.incrementToLimit()

viewModel.dispatch(CounterIntent.Increment)

// Reading the effect doesn't remove it, so a UI that stops, or a handler that's cancelled,
// gets it again.
val pending = viewModel.pendingEffects.value
assertEquals(listOf(CounterEffect.LimitReached), pending.map { it.effect })
viewModel.dispatch(CounterIntent.Load)
assertEquals(pending, viewModel.pendingEffects.value)

viewModel.effectHandled(pending.single().id)

assertEquals(emptyList(), viewModel.pendingEffects.value)
}

@Test
fun `effectHandled removes only that effect`() = runTest {
val viewModel = counterViewModel()
viewModel.incrementToLimit()
viewModel.dispatch(CounterIntent.Increment)
viewModel.dispatch(CounterIntent.Reset)
val (limitReached, wasReset) = viewModel.pendingEffects.value

viewModel.effectHandled(wasReset.id)

assertEquals(listOf(limitReached), viewModel.pendingEffects.value)
}

@Test
fun `several effects keep their order`() = runTest {
val viewModel = counterViewModel()
viewModel.incrementToLimit()

viewModel.dispatch(CounterIntent.Increment)
viewModel.dispatch(CounterIntent.Increment)
viewModel.dispatch(CounterIntent.Reset)

val pending = viewModel.pendingEffects.value
assertEquals(
listOf(
CounterEffect.LimitReached,
CounterEffect.LimitReached,
CounterEffect.WasReset(CounterReducer.LIMIT),
),
pending.map { it.effect },
)
// Equal effects still get their own ids, in the order they were added.
val ids = pending.map { it.id }
assertTrue(ids.zipWithNext().all { (earlier, later) -> earlier < later })
}

private fun TestScope.counterViewModel(savedCount: Int = 0) =
CounterViewModel(SavedCount(savedCount, StandardTestDispatcher(testScheduler)))

private fun CounterViewModel.incrementToLimit() =
repeat(CounterReducer.LIMIT) { dispatch(CounterIntent.Increment) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.github.dewfallapp.core.mvi.counter

import io.github.dewfallapp.core.mvi.MviEffect
import io.github.dewfallapp.core.mvi.MviIntent
import io.github.dewfallapp.core.mvi.MviState

// A small example screen that shows the whole MVI pattern. It lives in test code only. In the app,
// a feature's State, Intent, Effect and reducer go in its -logic module, and its ViewModel in its
// -ui module. See docs/architecture.md#the-mvi-contract.

data class CounterState(val count: Int = 0, val isLoading: Boolean = false) : MviState

sealed interface CounterIntent : MviIntent {
/** The user taps the plus button. */
data object Increment : CounterIntent

/** The user taps the reset button. */
data object Reset : CounterIntent

/** The user asks for the saved count. */
data object Load : CounterIntent

/** The saved count arrived. CounterViewModel sends this when its load finishes. */
data class Loaded(val count: Int) : CounterIntent
}

sealed interface CounterEffect : MviEffect {
/** Tell the user that the count can't go higher. */
data object LimitReached : CounterEffect

/** Tell the user what the count was before the reset. */
data class WasReset(val previousCount: Int) : CounterEffect
}
Loading