diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24f9342c5..61a63bccf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -92,6 +92,7 @@ protolayout = "1.3.0" reactive-streams = "1.0.4" recyclerview = "1.4.0" registryDigitalCredentials = "1.0.0-alpha04" +room = "2.6.1" robolectric = "4.16.1" roborazzi = "1.59.0" spotless = "8.3.0" @@ -99,6 +100,7 @@ targetSdk = "37" tiles = "1.5.0" tracing = "1.3.0" truth = "1.4.4" +turbine = "1.1.0" tvComposeMaterial3 = "1.1.0-beta01" validatorPush = "1.0.0-alpha09" version-catalog-update = "1.1.0" @@ -206,6 +208,7 @@ androidx-protolayout-expression = { module = "androidx.wear.protolayout:protolay androidx-protolayout-material = { module = "androidx.wear.protolayout:protolayout-material", version.ref = "protolayout" } androidx-protolayout-material3 = { module = "androidx.wear.protolayout:protolayout-material3", version.ref = "protolayout" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +androidx-room-common = { module = "androidx.room:room-common", version.ref = "room" } androidx-registry-digitalcredentials-openid = { module = "androidx.credentials.registry:registry-digitalcredentials-openid", version.ref = "registryDigitalCredentials" } androidx-registry-digitalcredentials-mdoc = { module = "androidx.credentials.registry:registry-digitalcredentials-mdoc", version.ref = "registryDigitalCredentials" } androidx-registry-digitalcredentials-sdjwtvc = { module = "androidx.credentials.registry:registry-digitalcredentials-sdjwtvc", version.ref = "registryDigitalCredentials" } @@ -252,6 +255,7 @@ androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "and crossdeviceprompt = { module = "com.google.android.play:crossdeviceprompt", version.ref = "crossdeviceprompt" } firebase-ai = { module = "com.google.firebase:firebase-ai" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } +firebase-firestore = { module = "com.google.firebase:firebase-firestore" } glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "glide" } google-android-material = { module = "com.google.android.material:material", version.ref = "material" } google-ar-core = { module = "com.google.ar:core", version.ref = "google-ar-core" } @@ -287,6 +291,7 @@ roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose" roborazzi-rule = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi" } tv-compose-material = { module = "androidx.tv:tv-material", version.ref = "tvComposeMaterial3" } truth = { module = "com.google.truth:truth", version.ref = "truth" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } validator-push = { module = "com.google.android.wearable.watchface.validator:validator-push", version.ref = "validatorPush" } wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wearComposeMaterial" } wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wearComposeMaterial3" } diff --git a/kotlin/build.gradle.kts b/kotlin/build.gradle.kts index c290346fc..a2f02151a 100644 --- a/kotlin/build.gradle.kts +++ b/kotlin/build.gradle.kts @@ -53,8 +53,22 @@ android { } } dependencies { + // AndroidX + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.room.common) + + // Firebase + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.firestore) + + // KotlinX implementation(libs.kotlinx.coroutines.android) - testImplementation(libs.kotlinx.coroutines.test) + + // Testing testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.truth) + testImplementation(libs.turbine) } diff --git a/kotlin/src/main/kotlin/com/example/android/basics/AdoptForLargeTeams.kt b/kotlin/src/main/kotlin/com/example/android/basics/AdoptForLargeTeams.kt new file mode 100644 index 000000000..174b93565 --- /dev/null +++ b/kotlin/src/main/kotlin/com/example/android/basics/AdoptForLargeTeams.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.basics + +// [START android_kotlin_adopt_person] +data class Person(var firstName: String?, var lastName: String?) +// [END android_kotlin_adopt_person] + +fun String.foo(): String = this + +fun testExtensionFunction() { + // [START android_kotlin_adopt_foo] + // [START_EXCLUDE] + val placeholderBefore = "" + // [END_EXCLUDE] + + val myString: String = "hello" + val fooString = myString.foo() + + // [START_EXCLUDE] + val placeholderAfter = "" + // [END_EXCLUDE] + // [END android_kotlin_adopt_foo] +} + +class Foo { + fun baz() {} + fun zap() {} +} + +fun testLet() { + // [START android_kotlin_adopt_let] + val nullableFoo: Foo? = Foo() + + // This lambda executes only if nullableFoo is not null + // and `foo` is of the non-nullable Foo type + nullableFoo?.let { foo -> + foo.baz() + foo.zap() + } + // [END android_kotlin_adopt_let] +} + +fun testSmartCast() { + // [START android_kotlin_adopt_smartcast] + val nullableFoo: Foo? = null + if (nullableFoo != null) { + nullableFoo.baz() // Using !! or ?. isn't required; the Kotlin compiler infers non-nullability + nullableFoo.zap() // from guard condition; smart casts nullableFoo to Foo inside this block + } + // [END android_kotlin_adopt_smartcast] +} diff --git a/kotlin/src/main/kotlin/com/example/android/basics/StyleGuide.kt b/kotlin/src/main/kotlin/com/example/android/basics/StyleGuide.kt new file mode 100644 index 000000000..c9b56ae71 --- /dev/null +++ b/kotlin/src/main/kotlin/com/example/android/basics/StyleGuide.kt @@ -0,0 +1,422 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.basics + +import java.io.File +import java.util.logging.Logger +import java.nio.charset.Charset +import kotlin.annotation.AnnotationRetention.SOURCE +import kotlin.annotation.AnnotationTarget.FIELD +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.annotation.AnnotationTarget.PROPERTY_SETTER + +annotation class Composable +annotation class Test +class Disposable +val mutableInstance = Any() +val mutableInstance2 = Any() +class SomeMutableType +object Joiner { + fun on(c: Char): Joiner = this +} +@Target(FIELD) +annotation class JvmStatic + +private object SingleClassSnippet { + // [START android_kotlin_style_single_class] + // MyClass.kt + class MyClass { } + // [END android_kotlin_style_single_class] +} + +private object SingleClassWithExtensionsSnippet { + // [START android_kotlin_style_single_class_with_extensions] + // Bar.kt + class Bar { } + + fun Runnable.toBar(): Bar = Bar() + // [END android_kotlin_style_single_class_with_extensions] +} + +private object CollectionExtensionsSnippet { + // [START android_kotlin_style_collection_extensions] + // Map.kt + fun Set.map(func: (T) -> O): List = emptyList() + fun List.map(func: (T) -> O): List = emptyList() + // [END android_kotlin_style_collection_extensions] +} + +private object ExtensionsSnippet { + class MyResult + + // [START android_kotlin_style_extensions] + // extensions.kt + fun MyClass.process() = { /* ... */ } + fun MyResult.print() = { /* ... */ } + // [END android_kotlin_style_extensions] +} + +fun testBraces(string: String, DEFAULT_VALUE: String, value: Int) { + // [START android_kotlin_style_conditional_no_braces] + if (string.isEmpty()) return + + val result = + if (string.isEmpty()) DEFAULT_VALUE else string + + when (value) { + 0 -> return + // … + } + // [END android_kotlin_style_conditional_no_braces] +} + +fun condition(): Boolean = true +fun foo() {} + +fun testNonEmptyBlocks(): Runnable { + // [START android_kotlin_style_non_empty_blocks] + return Runnable { + while (condition()) { + foo() + } + } + // [END android_kotlin_style_non_empty_blocks] +} + +open class MyClass { + open fun foo() {} +} +class ProblemException : Exception() +fun something() {} +fun recover() {} +fun otherCondition(): Boolean = true +fun somethingElse() {} +fun lastThing() {} + +fun testEgyptianBrackets(): Any { + // [START android_kotlin_style_egyptian_brackets] + return object : MyClass() { + override fun foo() { + if (condition()) { + try { + something() + } catch (e: ProblemException) { + recover() + } + } else if (otherCondition()) { + somethingElse() + } else { + lastThing() + } + } + } + // [END android_kotlin_style_egyptian_brackets] +} +fun doSomething() {} + +fun testEmptyBlocks() { + // [START android_kotlin_style_empty_blocks] + try { + doSomething() + } catch (e: Exception) { + } // Okay + // [END android_kotlin_style_empty_blocks] +} + +fun testConditionalExpression(string: String) { + // [START android_kotlin_style_conditional_expression] + val value = if (string.isEmpty()) 0 else 1 // Okay + // [END android_kotlin_style_conditional_expression] +} + +fun testConditionalExpression2(string: String) { + // [START android_kotlin_style_conditional_expression_multiline] + val value = if (string.isEmpty()) { // Okay + 0 + } else { + 1 + } + // [END android_kotlin_style_conditional_expression_multiline] +} + +// [START android_kotlin_style_function_signature] +fun Iterable.joinToString( + separator: CharSequence = ", ", + prefix: CharSequence = "", + postfix: CharSequence = "" +): String { + // [START_EXCLUDE] + return "" + // [END_EXCLUDE] +} +// [END android_kotlin_style_function_signature] + +private object FunctionExpressionSnippet { + // [START android_kotlin_style_function_egyptian] + override fun toString(): String { + return "Hey" + } + // [END android_kotlin_style_function_egyptian] +} + +private object FunctionExpression2Snippet { + // [START android_kotlin_style_function_expression] + override fun toString(): String = "Hey" + // [END android_kotlin_style_function_expression] +} + +class EncodingRegistry { + companion object { + fun getInstance(): EncodingRegistry = EncodingRegistry() + } + fun getDefaultCharsetForPropertiesFiles(f: File): Charset? = null +} +class PropertyInitializerSnippet { + val file = File("") + // [START android_kotlin_style_property_initializer] + private val defaultCharset: Charset? = + EncodingRegistry.getInstance().getDefaultCharsetForPropertiesFiles(file) + // [END android_kotlin_style_property_initializer] +} + +private object PropertyAccessorsSnippet { + // [START android_kotlin_style_property_accessors] + var directory: File? = null + set(value) { + // … + } + // [END android_kotlin_style_property_accessors] +} + +private object PropertyAccessors2Snippet { + // [START android_kotlin_style_property_readonly] + val defaultExtension: String get() = "kt" + // [END android_kotlin_style_property_readonly] +} + +fun testHorizontalSpacing(list: List, ints: List, condition: () -> Boolean, it: Any) { + // [START android_kotlin_style_horizontal_keywords] + // Okay + for (i in 0..1) { + } + // [END android_kotlin_style_horizontal_keywords] + + if (condition()) { + // [START android_kotlin_style_horizontal_braces] + // Okay + } else { + } + // [END android_kotlin_style_horizontal_braces] + + // [START android_kotlin_style_horizontal_curly_braces] + // Okay + if (list.isEmpty()) { + } + // [END android_kotlin_style_horizontal_curly_braces] + + // [START android_kotlin_style_horizontal_operators] + // Okay + val two = 1 + 1 + // [END android_kotlin_style_horizontal_operators] + + // [START android_kotlin_style_horizontal_lambda_arrow] + // Okay + ints.map { value -> value.toString() } + // [END android_kotlin_style_horizontal_lambda_arrow] + + // [START android_kotlin_style_horizontal_member_reference] + // Okay + val toString = Any::toString + // [END android_kotlin_style_horizontal_member_reference] + + // [START android_kotlin_style_horizontal_dot_separator] + // Okay + it.toString() + // [END android_kotlin_style_horizontal_dot_separator] + + // [START android_kotlin_style_horizontal_range_operator] + // Okay + for (i in 1..4) { + print(i) + } + // [END android_kotlin_style_horizontal_range_operator] +} + +private object BaseClassSpacingSnippet { + // [START android_kotlin_style_horizontal_base_class] + // Okay + class Foo : Runnable + // [END android_kotlin_style_horizontal_base_class] + { + override fun run() {} + } +} + +private object GenericSpacingSnippet { + // [START android_kotlin_style_horizontal_generic] + // Okay + fun > max(a: T, b: T) + // [END android_kotlin_style_horizontal_generic] + { + // ... + } +} + +private object WhereSpacingSnippet { + // [START android_kotlin_style_horizontal_where] + // Okay + fun max(a: T, b: T) where T : Comparable {} + // [END android_kotlin_style_horizontal_where] +} + +private object CommaSpacingSnippet { + // [START android_kotlin_style_horizontal_comma] + // Okay + val oneAndTwo = listOf(1, 2) + // [END android_kotlin_style_horizontal_comma] +} + +private object InterfaceSpacingSnippet { + // [START android_kotlin_style_horizontal_interface] + // Okay + class Foo : Runnable + // [END android_kotlin_style_horizontal_interface] + { + override fun run() {} + } +} + +private object DoubleSlashSpacingSnippet { + // [START android_kotlin_style_horizontal_double_slash] + // Okay + var debugging = false // disabled by default + // [END android_kotlin_style_horizontal_double_slash] +} + +// [START android_kotlin_style_enum_single] +enum class Answer { YES, NO, MAYBE } +// [END android_kotlin_style_enum_single] + +private object EnumMultiSnippet { + // [START android_kotlin_style_enum_multi] + enum class Answer { + YES, + NO, + + MAYBE { + override fun toString() = """¯\_(ツ)_/¯""" + } + } + // [END android_kotlin_style_enum_multi] +} + +// [START android_kotlin_style_annotations_separate] +@Retention(SOURCE) +@Target(FUNCTION, PROPERTY_SETTER, FIELD) +annotation class Global +// [END android_kotlin_style_annotations_separate] + +private object AnnotationsSingleLineSnippet { + // [START android_kotlin_style_annotations_single_line] + @JvmField @Volatile + var disposable: Disposable? = null + // [END android_kotlin_style_annotations_single_line] +} + +private object AnnotationsSingleDeclarationSnippet { + // [START android_kotlin_style_annotations_single_declaration] + @Volatile var disposable: Disposable? = null + + @Test fun selectAll() { + // … + } + // [END android_kotlin_style_annotations_single_declaration] +} + +private object AnnotationsUseSiteSnippet { + // [START android_kotlin_style_annotations_use_site] + @field:[JvmStatic Volatile] + var disposable: Disposable? = null + // [END android_kotlin_style_annotations_use_site] +} + +private object TestNamingSnippet { + // [START android_kotlin_style_test_naming] + @Test fun pop_emptyStack() { + // … + } + // [END android_kotlin_style_test_naming] +} + +// [START android_kotlin_style_composable_naming] +@Composable +fun NameTag(name: String) { + // … +} +// [END android_kotlin_style_composable_naming] + +private object ConstantsSnippet { + // [START android_kotlin_style_constants] + const val NUMBER = 5 + val NAMES = listOf("Alice", "Bob") + val AGES = mapOf("Alice" to 35, "Bob" to 32) + val COMMA_JOINED = NAMES.joinToString(", ") + val EMPTY_ARRAY = arrayOf() + // [END android_kotlin_style_constants] +} + +private object NonConstantsSnippet { + // [START android_kotlin_style_non_constants] + val variable = "var" + val nonConstScalar = "non-const" + val mutableCollection: MutableSet = HashSet() + val mutableElements = listOf(mutableInstance) + val mutableValues = mapOf("Alice" to mutableInstance, "Bob" to mutableInstance2) + val logger = Logger.getLogger(MyClass::class.java.name) + val nonEmptyArray = arrayOf("these", "can", "change") + // [END android_kotlin_style_non_constants] +} + +// [START android_kotlin_style_backing_properties] +private var _table: Map? = null + +val table: Map + get() { + if (_table == null) { + _table = HashMap() + } + return _table ?: throw AssertionError() + } +// [END android_kotlin_style_backing_properties] + +private object KDocSnippet { + // [START android_kotlin_style_kdoc_block] + /** + * Multiple lines of KDoc text are written here, + * wrapped normally… + */ + fun method(arg: String) { + // … + } + // [END android_kotlin_style_kdoc_block] +} + +private object KDocSingleSnippet { + // [START android_kotlin_style_kdoc_single] + /** An especially short bit of KDoc. */ + // [END android_kotlin_style_kdoc_single] +} diff --git a/kotlin/src/main/kotlin/com/example/android/basics/flow/Flow.kt b/kotlin/src/main/kotlin/com/example/android/basics/flow/Flow.kt new file mode 100644 index 000000000..2b3519015 --- /dev/null +++ b/kotlin/src/main/kotlin/com/example/android/basics/flow/Flow.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.basics.flow + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.room.Dao +import androidx.room.Query +import com.google.firebase.firestore.DocumentReference +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +// Placeholder types for imports +class ArticleHeadline +class UserData { + fun isFavoriteTopic(topic: ArticleHeadline): Boolean = true +} +fun saveInCache(news: List) {} +fun lastCachedNews(): List = emptyList() +fun notifyError(exception: Throwable) {} +fun DocumentSnapshot.getEvents(): UserEvents = UserEvents() + +// [START android_kotlin_flow_create] +class NewsRemoteDataSource( + private val newsApi: NewsApi, + private val refreshIntervalMs: Long = 5000 +) { + val latestNews: Flow> = flow { + while (true) { + val latestNews = newsApi.fetchLatestNews() + emit(latestNews) // Emits the result of the request to the flow + delay(refreshIntervalMs) // Suspends the coroutine for some time + } + } +} + +// Interface that provides a way to make network requests with suspend functions +interface NewsApi { + suspend fun fetchLatestNews(): List +} +// [END android_kotlin_flow_create] + +// [START android_kotlin_flow_modify] +class NewsRepository( + private val newsRemoteDataSource: NewsRemoteDataSource, + private val userData: UserData +) { + /** + * Returns the favorite latest news applying transformations on the flow. + * These operations are lazy and don't trigger the flow. They just transform + * the current value emitted by the flow at that point in time. + */ + val favoriteLatestNews: Flow> = + newsRemoteDataSource.latestNews + // Intermediate operation to filter the list of favorite topics + .map { news -> news.filter { userData.isFavoriteTopic(it) } } + // Intermediate operation to save the latest news in the cache + .onEach { news -> saveInCache(news) } +} +// [END android_kotlin_flow_modify] + +// [START android_kotlin_flow_collect] +class LatestNewsViewModel( + private val newsRepository: NewsRepository +) : ViewModel() { + + init { + viewModelScope.launch { + // Trigger the flow and consume its elements using collect + newsRepository.favoriteLatestNews.collect { favoriteNews -> + // Update UI with the latest favorite news + } + } + } +} +// [END android_kotlin_flow_collect] + +private object ExceptionsSnippet { + // [START android_kotlin_flow_exceptions] + class LatestNewsViewModel( + private val newsRepository: NewsRepository + ) : ViewModel() { + + init { + viewModelScope.launch { + newsRepository.favoriteLatestNews + // Intermediate catch operator. If an exception is thrown, + // catch and update the UI + .catch { exception -> notifyError(exception) } + .collect { favoriteNews -> + // Update UI with the latest favorite news + } + } + } + } + // [END android_kotlin_flow_exceptions] +} + +private object EmitCachedSnippet { + // [START android_kotlin_flow_emit_cached] + class NewsRepository( + // [START_EXCLUDE] + private val newsRemoteDataSource: NewsRemoteDataSource, + private val userData: UserData + // [END_EXCLUDE] + ) { + val favoriteLatestNews: Flow> = + newsRemoteDataSource.latestNews + .map { news -> news.filter { userData.isFavoriteTopic(it) } } + .onEach { news -> saveInCache(news) } + // If an error happens, emit the last cached values + .catch { exception -> emit(lastCachedNews()) } + } + // [END android_kotlin_flow_emit_cached] +} + +private object FlowOnSnippet { + // [START android_kotlin_flow_flowon] + class NewsRepository( + private val newsRemoteDataSource: NewsRemoteDataSource, + private val userData: UserData, + private val defaultDispatcher: CoroutineDispatcher + ) { + val favoriteLatestNews: Flow> = + newsRemoteDataSource.latestNews + .map { news -> // Executes on the default dispatcher + news.filter { userData.isFavoriteTopic(it) } + } + .onEach { news -> // Executes on the default dispatcher + saveInCache(news) + } + // flowOn affects the upstream flow ↑ + .flowOn(defaultDispatcher) + // the downstream flow ↓ is not affected + .catch { exception -> // Executes in the consumer's context + emit(lastCachedNews()) + } + } + // [END android_kotlin_flow_flowon] +} + +private object FlowOnDataSourceSnippet { + // [START android_kotlin_flow_flowon_datasource] + class NewsRemoteDataSource( + // [START_EXCLUDE] + private val newsApi: NewsApi, + // [END_EXCLUDE] + private val ioDispatcher: CoroutineDispatcher + ) { + // [START_EXCLUDE silent] + val refreshIntervalMs: Long = 5000 + // [END_EXCLUDE] + + val latestNews: Flow> = flow { + // Executes on the IO dispatcher + // [START_EXCLUDE] + while (true) { + val latestNews = newsApi.fetchLatestNews() + emit(latestNews) + delay(refreshIntervalMs) + } + // [END_EXCLUDE] + } + .flowOn(ioDispatcher) + } + // [END android_kotlin_flow_flowon_datasource] +} + +class Example +// [START android_kotlin_flow_room] +@Dao +abstract class ExampleDao { + @Query("SELECT * FROM Example") + abstract fun getExamples(): Flow> +} +// [END android_kotlin_flow_room] + +class UserEvents + +class FirestoreUserEventsDataSource( + private val firestore: FirebaseFirestore +) { + // [START android_kotlin_flow_callback] + // Method to get user events from the Firestore database + fun getUserEvents(): Flow = callbackFlow { + + // Reference to use in Firestore + var eventsCollection: DocumentReference? = null + try { + eventsCollection = FirebaseFirestore.getInstance() + .collection("collection") + .document("app") + } catch (e: Throwable) { + // If Firebase cannot be initialized, close the stream of data + // flow consumers will stop collecting and the coroutine will resume + close(e) + } + + // Registers callback to firestore, which will be called on new events + val subscription = eventsCollection?.addSnapshotListener { snapshot, _ -> + if (snapshot == null) { + return@addSnapshotListener + } + // Sends events to the flow! Consumers will get the new events + try { + trySend(snapshot.getEvents()) + } catch (e: Throwable) { + // Event couldn't be sent to the flow + } + } + + // The callback inside awaitClose will be executed when the flow is + // either closed or cancelled. + // In this case, remove the callback from Firestore + awaitClose { subscription?.remove() } + } + // [END android_kotlin_flow_callback] +} diff --git a/kotlin/src/main/kotlin/com/example/android/basics/stateflow/StateFlowAndSharedFlow.kt b/kotlin/src/main/kotlin/com/example/android/basics/stateflow/StateFlowAndSharedFlow.kt new file mode 100644 index 000000000..1153df409 --- /dev/null +++ b/kotlin/src/main/kotlin/com/example/android/basics/stateflow/StateFlowAndSharedFlow.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.basics.stateflow + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch + +// Placeholder types +class ArticleHeadline + +class NewsRepository { + val favoriteLatestNews: Flow> = flow { emit(emptyList()) } + suspend fun refreshLatestNews() {} +} + +// [START android_kotlin_flow_stateflow_viewmodel] +class LatestNewsViewModel( + private val newsRepository: NewsRepository +) : ViewModel() { + + // Backing property to avoid state updates from other classes + private val _uiState = MutableStateFlow(LatestNewsUiState.Success(emptyList())) + // The UI collects from this StateFlow to get its state updates + val uiState: StateFlow = _uiState + + init { + viewModelScope.launch { + newsRepository.favoriteLatestNews + // Update UI with the latest favorite news + // Writes to the value property of MutableStateFlow, + // adding a new element to the flow and updating all + // of its collectors + .collect { favoriteNews -> + _uiState.value = LatestNewsUiState.Success(favoriteNews) + } + } + } +} + +// Represents different states for the LatestNews screen +sealed class LatestNewsUiState { + data class Success(val news: List) : LatestNewsUiState() + data class Error(val exception: Throwable) : LatestNewsUiState() +} +// [END android_kotlin_flow_stateflow_viewmodel] + +fun showFavoriteNews(news: List) {} +fun showError(exception: Throwable) {} + +// [START android_kotlin_flow_stateflow_activity] +class LatestNewsActivity : ComponentActivity() { + private val latestNewsViewModel: LatestNewsViewModel = TODO() // getViewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + // [START_EXCLUDE] + super.onCreate(savedInstanceState) + // [END_EXCLUDE] + // Start a coroutine in the lifecycle scope + lifecycleScope.launch { + // repeatOnLifecycle launches the block in a new coroutine every time the + // lifecycle is in the STARTED state (or above) and cancels it when it's STOPPED. + repeatOnLifecycle(Lifecycle.State.STARTED) { + // Trigger the flow and start listening for values. + // Note that this happens when lifecycle is STARTED and stops + // collecting when the lifecycle is STOPPED + latestNewsViewModel.uiState.collect { uiState -> + // New value received + when (uiState) { + is LatestNewsUiState.Success -> showFavoriteNews(uiState.news) + is LatestNewsUiState.Error -> showError(uiState.exception) + } + } + } + } + } +} +// [END android_kotlin_flow_stateflow_activity] + +// [START android_kotlin_flow_sharein] +class NewsRemoteDataSource( + private val externalScope: CoroutineScope +) { + val latestNews: Flow> = flow { + // [START_EXCLUDE] + emit(emptyList()) + // [END_EXCLUDE] + }.shareIn( + externalScope, + replay = 1, + started = SharingStarted.WhileSubscribed() + ) +} +// [END android_kotlin_flow_sharein] + +// [START android_kotlin_flow_sharedflow_tick] +// Class that centralizes when the content of the app needs to be refreshed +class TickHandler( + private val externalScope: CoroutineScope, + private val tickIntervalMs: Long = 5000 +) { + // Backing property to avoid flow emissions from other classes + private val _tickFlow = MutableSharedFlow(replay = 0) + val tickFlow: SharedFlow = _tickFlow + + init { + externalScope.launch { + while (true) { + _tickFlow.emit(Unit) + delay(tickIntervalMs) + } + } + } +} +// [END android_kotlin_flow_sharedflow_tick] + +private object SharedFlowTickSnippet { + // [START android_kotlin_flow_sharedflow_tick_part2] + class NewsRepository( + // ... + private val tickHandler: TickHandler, + private val externalScope: CoroutineScope + ) { + init { + externalScope.launch { + // Listen for tick updates + tickHandler.tickFlow.collect { + refreshLatestNews() + } + } + } + + suspend fun refreshLatestNews() { /* ... */ } + // ... + } + // [END android_kotlin_flow_sharedflow_tick_part2] +} diff --git a/kotlin/src/main/kotlin/com/example/android/coroutines/Coroutines.kt b/kotlin/src/main/kotlin/com/example/android/coroutines/Coroutines.kt new file mode 100644 index 000000000..134fadaf2 --- /dev/null +++ b/kotlin/src/main/kotlin/com/example/android/coroutines/Coroutines.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.coroutines + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class LoginResponse +class LoginResponseParser { + fun parse(i: java.io.InputStream): LoginResponse = LoginResponse() +} + +// [START android_kotlin_coroutines_index_repository] +sealed class Result { + data class Success(val data: T) : Result() + data class Error(val exception: Exception) : Result() +} + +private const val loginUrl = "https://example.com/login" + +// [START_EXCLUDE silent] +private object BlockingSnippet { +// [END_EXCLUDE] +class LoginRepository(private val responseParser: LoginResponseParser) { + // Function that makes the network request, blocking the current thread + fun makeLoginRequest( + jsonBody: String + ): Result { + val url = URL(loginUrl) + (url.openConnection() as? HttpURLConnection)?.run { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json; utf-8") + setRequestProperty("Accept", "application/json") + doOutput = true + outputStream.write(jsonBody.toByteArray()) + return Result.Success(responseParser.parse(inputStream)) + } + return Result.Error(Exception("Cannot open HttpURLConnection")) + } +} +// [END android_kotlin_coroutines_index_repository] + + // [START android_kotlin_coroutines_index_vm_blocking] + class LoginViewModel( + private val loginRepository: LoginRepository + ) : ViewModel() { + + fun login(username: String, token: String) { + val jsonBody = "{ username: \"$username\", token: \"$token\"}" + loginRepository.makeLoginRequest(jsonBody) + } + } + // [END android_kotlin_coroutines_index_vm_blocking] + + private object BackgroundSnippet { + // [START android_kotlin_coroutines_index_vm_background] + class LoginViewModel( + private val loginRepository: LoginRepository + ) : ViewModel() { + + fun login(username: String, token: String) { + // Create a new coroutine to move the execution off the UI thread + viewModelScope.launch(Dispatchers.IO) { + val jsonBody = "{ username: \"$username\", token: \"$token\"}" + loginRepository.makeLoginRequest(jsonBody) + } + } + } + // [END android_kotlin_coroutines_index_vm_background] + } +} + +// [START android_kotlin_coroutines_index_repository_withcontext] +class LoginRepository( + // [START_EXCLUDE] + private val responseParser: LoginResponseParser + // [END_EXCLUDE] +) { + // [START_EXCLUDE] + // ... + // [END_EXCLUDE] + suspend fun makeLoginRequest( + jsonBody: String + ): Result { + + // Move the execution of the coroutine to the I/O dispatcher + return withContext(Dispatchers.IO) { + // Blocking network request code + // [START_EXCLUDE silent] + Result.Success(LoginResponse()) + // [END_EXCLUDE] + } + } +} +// [END android_kotlin_coroutines_index_repository_withcontext] + +private object ViewModelWithContextSnippet { + // [START android_kotlin_coroutines_index_vm_mainsafe] + class LoginViewModel( + private val loginRepository: LoginRepository + ) : ViewModel() { + + fun login(username: String, token: String) { + + // Create a new coroutine on the UI thread + viewModelScope.launch { + val jsonBody = "{ username: \"$username\", token: \"$token\"}" + + // Make the network call and suspend execution until it finishes + val result = loginRepository.makeLoginRequest(jsonBody) + + // Display result of the network request to the user + when (result) { + is Result.Success -> { /* Happy path */ } + else -> { /* Show error in UI */ } + } + } + } + } + // [END android_kotlin_coroutines_index_vm_mainsafe] +} + +private object ViewModelExceptionSnippet { + // [START android_kotlin_coroutines_index_vm_exception] + class LoginViewModel( + private val loginRepository: LoginRepository + ) : ViewModel() { + + fun login(username: String, token: String) { + viewModelScope.launch { + val jsonBody = "{ username: \"$username\", token: \"$token\"}" + val result = try { + loginRepository.makeLoginRequest(jsonBody) + } catch (e: Exception) { + Result.Error(Exception("Network request failed")) + } + when (result) { + is Result.Success -> { /* Happy path */ } + else -> { /* Show error in UI */ } + } + } + } + } + // [END android_kotlin_coroutines_index_vm_exception] +} diff --git a/kotlin/src/main/kotlin/com/example/android/coroutines/CoroutinesAdv.kt b/kotlin/src/main/kotlin/com/example/android/coroutines/CoroutinesAdv.kt new file mode 100644 index 000000000..ead16d1e7 --- /dev/null +++ b/kotlin/src/main/kotlin/com/example/android/coroutines/CoroutinesAdv.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.coroutines + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +fun show(doc: String) {} +suspend fun fetchDoc(id: Int): String = "Doc $id" + +// [START android_kotlin_coroutines_adv_suspend] +suspend fun fetchDocs() { // Dispatchers.Main + val result = get("https://developer.android.com") // Dispatchers.IO for `get` + show(result) // Dispatchers.Main +} + +suspend fun get(url: String) = withContext(Dispatchers.IO) { + /* ... */ + // [START_EXCLUDE silent] + "result" + // [END_EXCLUDE] +} +// [END android_kotlin_coroutines_adv_suspend] + +class CoroutinesAdvMainSafety { + // [START android_kotlin_coroutines_adv_mainsafety] + suspend fun fetchDocs() { // Dispatchers.Main + val result = get("developer.android.com") // Dispatchers.Main + show(result) // Dispatchers.Main + } + + suspend fun get(url: String) = // Dispatchers.Main + withContext(Dispatchers.IO) { // Dispatchers.IO (main-safety block) + /* perform network IO here */ // Dispatchers.IO (main-safety block) + // [START_EXCLUDE silent] + "result" + // [END_EXCLUDE] + } // Dispatchers.Main + // [END android_kotlin_coroutines_adv_mainsafety] +} + +class ParallelDecomposition { + // [START android_kotlin_coroutines_adv_parallel] + suspend fun fetchTwoDocs() = + coroutineScope { + val deferredOne = async { fetchDoc(1) } + val deferredTwo = async { fetchDoc(2) } + deferredOne.await() + deferredTwo.await() + } + // [END android_kotlin_coroutines_adv_parallel] +} + +class ParallelDecompositionCollection { + // [START android_kotlin_coroutines_adv_parallel_collection] + suspend fun fetchTwoDocs() = // called on any Dispatcher (any thread, possibly Main) + coroutineScope { + val deferreds = listOf( // fetch two docs at the same time + async { fetchDoc(1) }, // async returns a result for the first doc + async { fetchDoc(2) } // async returns a result for the second doc + ) + deferreds.awaitAll() // use awaitAll to wait for both network requests + } + // [END android_kotlin_coroutines_adv_parallel_collection] +} + +// [START android_kotlin_coroutines_adv_scope] +class ExampleClass { + + // Job and Dispatcher are combined into a CoroutineContext which + // will be discussed shortly + val scope = CoroutineScope(Job() + Dispatchers.Main) + + fun exampleMethod() { + // Starts a new coroutine within the scope + scope.launch { + // New coroutine that can call suspend functions + fetchDocs() + } + } + + fun cleanUp() { + // Cancel the scope to cancel ongoing coroutines work + scope.cancel() + } +} +// [END android_kotlin_coroutines_adv_scope] + +private object JobSnippet { + // [START android_kotlin_coroutines_adv_job] + class ExampleClass { + // [START_EXCLUDE] + val scope = CoroutineScope(Job() + Dispatchers.Main) + val condition = true + // [END_EXCLUDE] + fun exampleMethod() { + // Handle to the coroutine, you can control its lifecycle + val job = scope.launch { + // New coroutine + } + + if (condition) { + // Cancel the coroutine started above, this doesn't affect the scope + // this coroutine was launched in + job.cancel() + } + } + } + // [END android_kotlin_coroutines_adv_job] +} + +private object ContextSnippet { + // [START android_kotlin_coroutines_adv_context] + class ExampleClass { + val scope = CoroutineScope(Job() + Dispatchers.Main) + + fun exampleMethod() { + // Starts a new coroutine on Dispatchers.Main as it's the scope's default + val job1 = scope.launch { + // New coroutine with CoroutineName = "coroutine" (default) + } + + // Starts a new coroutine on Dispatchers.Default + val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) { + // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden) + } + } + } + // [END android_kotlin_coroutines_adv_context] +} diff --git a/kotlin/src/main/kotlin/com/example/android/coroutines/bestpractices/CoroutinesBestPractices.kt b/kotlin/src/main/kotlin/com/example/android/coroutines/bestpractices/CoroutinesBestPractices.kt new file mode 100644 index 000000000..4e2701cc8 --- /dev/null +++ b/kotlin/src/main/kotlin/com/example/android/coroutines/bestpractices/CoroutinesBestPractices.kt @@ -0,0 +1,301 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.coroutines.bestpractices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import java.io.IOException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// Placeholder types +class ArticleHeadline +sealed class LatestNewsUiState { + data class Success(val news: Result>) : LatestNewsUiState() + object Loading : LatestNewsUiState() +} +class Article(val author: String = "") +class ArticleWithAuthor(article: Article, author: String) +class AuthorsRepository { + suspend fun getAuthor(id: String): String = "Author" + suspend fun getAllAuthors(): List = emptyList() +} +sealed class Result { + data class Success(val data: T) : Result() +} +fun mutableEmptyList(): MutableList = mutableListOf() + +private object GoodExample { + // [START android_kotlin_coroutines_best_practices_inject] + // DO inject Dispatchers + class NewsRepository( + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default + ) { + suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ } + } + // [START_EXCLUDE silent] +} +private object BadExample { + // [END_EXCLUDE] + + // DO NOT hardcode Dispatchers + class NewsRepository { + // DO NOT use Dispatchers.Default directly, inject it instead + suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ } + } + // [END android_kotlin_coroutines_best_practices_inject] +} + +// [START android_kotlin_coroutines_best_practices_mainsafe] +class NewsRepository(private val ioDispatcher: CoroutineDispatcher) { + + // As this operation is manually retrieving the news from the server + // using a blocking HttpURLConnection, it needs to move the execution + // to an IO dispatcher to make it main-safe + suspend fun fetchLatestNews(): List
{ + withContext(ioDispatcher) { /* ... implementation ... */ } + // [START_EXCLUDE silent] + return emptyList() + // [END_EXCLUDE] + } +} + +// This use case fetches the latest news and the associated author. +class GetLatestNewsWithAuthorsUseCase( + private val newsRepository: NewsRepository, + private val authorsRepository: AuthorsRepository +) { + // This method doesn't need to worry about moving the execution of the + // coroutine to a different thread as newsRepository is main-safe. + // The work done in the coroutine is lightweight as it only creates + // a list and add elements to it + suspend operator fun invoke(): Result> { + val news = newsRepository.fetchLatestNews() + + val response: MutableList = mutableEmptyList() + for (article in news) { + val author = authorsRepository.getAuthor(article.author) + response.add(ArticleWithAuthor(article, author)) + } + return Result.Success(response) + } +} +// [END android_kotlin_coroutines_best_practices_mainsafe] + +private object VmSnippetGood { + // [START android_kotlin_coroutines_best_practices_vm_good] + // DO create coroutines in the ViewModel + class LatestNewsViewModel( + private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase + ) : ViewModel() { + + private val _uiState = MutableStateFlow(LatestNewsUiState.Loading) + val uiState: StateFlow = _uiState + + fun loadNews() { + viewModelScope.launch { + val latestNewsWithAuthors = getLatestNewsWithAuthors() + _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors) + } + } + } + // [END android_kotlin_coroutines_best_practices_vm_good] +} + +private object VmSnippetBad { + // [START android_kotlin_coroutines_best_practices_vm_bad] + // Prefer observable state rather than suspend functions from the ViewModel + class LatestNewsViewModel( + private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase + ) : ViewModel() { + // DO NOT do this. News would probably need to be refreshed as well. + // Instead of exposing a single value with a suspend function, news should + // be exposed using a stream of data as in the code snippet above. + suspend fun loadNews() = getLatestNewsWithAuthors() + } + // [END android_kotlin_coroutines_best_practices_vm_bad] +} + +private object ImmutableSnippetGood { + // [START android_kotlin_coroutines_best_practices_immutable_good] + // DO expose immutable types + class LatestNewsViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(LatestNewsUiState.Loading) + val uiState: StateFlow = _uiState + + /* ... */ + } + // [END android_kotlin_coroutines_best_practices_immutable_good] +} + +private object ImmutableSnippetBad { + // [START android_kotlin_coroutines_best_practices_immutable_bad] + class LatestNewsViewModel : ViewModel() { + + // DO NOT expose mutable types + val uiState = MutableStateFlow(LatestNewsUiState.Loading) + + /* ... */ + } + // [END android_kotlin_coroutines_best_practices_immutable_bad] +} + +class Example +// [START android_kotlin_coroutines_best_practices_data_layer] +// Classes in the data and business layer expose +// either suspend functions or Flows +class ExampleRepository { + suspend fun makeNetworkRequest() { /* ... */ } + + fun getExamples(): Flow { + /* ... */ + // [START_EXCLUDE silent] + return flow { emit(Example()) } + // [END_EXCLUDE] + } +} +// [END android_kotlin_coroutines_best_practices_data_layer] + +class BooksRepository { + suspend fun getAllBooks(): List = emptyList() +} +class BookAndAuthors(books: List, authors: List) + +// [START android_kotlin_coroutines_best_practices_parallel] +class GetAllBooksAndAuthorsUseCase( + private val booksRepository: BooksRepository, + private val authorsRepository: AuthorsRepository, +) { + suspend fun getBookAndAuthors(): BookAndAuthors { + // In parallel, fetch books and authors and return when both requests + // complete and the data is ready + return coroutineScope { + val books = async { booksRepository.getAllBooks() } + val authors = async { authorsRepository.getAllAuthors() } + BookAndAuthors(books.await(), authors.await()) + } + } +} +// [END android_kotlin_coroutines_best_practices_parallel] + +open class ArticlesDataSource { + fun bookmarkArticle(article: Article) {} + fun isBookmarked(article: Article): Boolean = true +} + +private object ExternalSnippet { + // [START android_kotlin_coroutines_best_practices_external] + class ArticlesRepository( + private val articlesDataSource: ArticlesDataSource, + private val externalScope: CoroutineScope, + ) { + // As we want to complete bookmarking the article even if the user moves + // away from the screen, the work is done creating a new coroutine + // from an external scope + suspend fun bookmarkArticle(article: Article) { + externalScope.launch { articlesDataSource.bookmarkArticle(article) } + .join() // Wait for the coroutine to complete + } + } + // [END android_kotlin_coroutines_best_practices_external] +} + +// [START android_kotlin_coroutines_best_practices_global_good] +// DO inject an external scope instead of using GlobalScope. +// GlobalScope can be used indirectly. Here as a default parameter makes sense. +class ArticlesRepository( + private val articlesDataSource: ArticlesDataSource, + private val externalScope: CoroutineScope = GlobalScope, + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default +) { + // As we want to complete bookmarking the article even if the user moves + // away from the screen, the work is done creating a new coroutine + // from an external scope + suspend fun bookmarkArticle(article: Article) { + externalScope.launch(defaultDispatcher) { + articlesDataSource.bookmarkArticle(article) + } + .join() // Wait for the coroutine to complete + } +} +// [END android_kotlin_coroutines_best_practices_global_good] + +private object GlobalSnippetBad { + // [START android_kotlin_coroutines_best_practices_global_bad] + // DO NOT use GlobalScope directly + class ArticlesRepository( + private val articlesDataSource: ArticlesDataSource, + ) { + // As we want to complete bookmarking the article even if the user moves away + // from the screen, the work is done creating a new coroutine with GlobalScope + suspend fun bookmarkArticle(article: Article) { + GlobalScope.launch { + articlesDataSource.bookmarkArticle(article) + } + .join() // Wait for the coroutine to complete + } + } + // [END android_kotlin_coroutines_best_practices_global_bad] +} + +class File +fun readFile(f: File) {} + +fun testCancellable(someScope: CoroutineScope, files: List) { + // [START android_kotlin_coroutines_best_practices_cancellable] + someScope.launch { + for (file in files) { + ensureActive() // Check for cancellation + readFile(file) + } + } + // [END android_kotlin_coroutines_best_practices_cancellable] +} + +class LoginRepository { + fun login(u: String, t: String) {} +} + +// [START android_kotlin_coroutines_best_practices_login_vm] +class LoginViewModel( + private val loginRepository: LoginRepository +) : ViewModel() { + + fun login(username: String, token: String) { + viewModelScope.launch { + try { + loginRepository.login(username, token) + // Update UI, user logged in successfully + } catch (exception: IOException) { + // Update UI, login attempt failed + } + } + } +} +// [END android_kotlin_coroutines_best_practices_login_vm] diff --git a/kotlin/src/test/kotlin/com/example/android/basics/flow/FlowTest.kt b/kotlin/src/test/kotlin/com/example/android/basics/flow/FlowTest.kt new file mode 100644 index 000000000..f3dbc4ad9 --- /dev/null +++ b/kotlin/src/test/kotlin/com/example/android/basics/flow/FlowTest.kt @@ -0,0 +1,287 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.basics.flow + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +// Placeholder types +const val ITEM_1 = 1 +val ALL_MESSAGES = listOf("Hello", "How are you?", "Bye") +class MyUnitUnderTest(repository: MyRepository) + +interface MyRepository { + fun observeChatMessages(): Flow = ALL_MESSAGES.asFlow() + fun scores(): Flow = flow {} +} + +// [START android_kotlin_flow_test_fake] +class MyFakeRepository : MyRepository { + fun observeCount() = flow { + emit(ITEM_1) + } +} +// [END android_kotlin_flow_test_fake] + +class FlowTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // [START android_kotlin_flow_test_verify] + @Test + fun myTest() { + // Given a class with fake dependencies: + val sut = MyUnitUnderTest(MyFakeRepository()) + // Trigger and verify + // ... + } + // [END android_kotlin_flow_test_verify] + + // [START android_kotlin_flow_test_assert] + @Test + fun myRepositoryTest() = runTest { + // [START_EXCLUDE silent] + val fakeSource1 = 1 + val fakeSource2 = 2 + + class MyRepository(a: Int, b: Int) { + val counter: Flow = flow { emit(ITEM_1) } + } + + // [END_EXCLUDE] + // Given a repository that combines values from two data sources: + val repository = MyRepository(fakeSource1, fakeSource2) + + // When the repository emits a value + val firstItem = repository.counter.first() // Returns the first item in the flow + + // Then check it's the expected item + assertEquals(ITEM_1, firstItem) + } + // [END android_kotlin_flow_test_assert] + + // [START android_kotlin_flow_test_assert_list] + @Test + fun myRepositoryTestList() = runTest { + val repository = MyFakeRepository() + // Given a repository with a fake data source that emits ALL_MESSAGES + val messages = repository.observeChatMessages().toList() + + // When all messages are emitted then they should be ALL_MESSAGES + assertEquals(ALL_MESSAGES, messages) + } + // [END android_kotlin_flow_test_assert_list] + + @Suppress("UNUSED_VARIABLE") + suspend fun testTestingOperators(outputFlow: Flow, predicate: (Int) -> Boolean) { + // [START android_kotlin_flow_test_operators] + // Take the second item + outputFlow.drop(1).first() + + // Take the first 5 items + outputFlow.take(5).toList() + + // Takes the first item verifying that the flow is closed after that + outputFlow.single() + + // Finite data streams + // Verify that the flow emits exactly N elements (optional predicate) + outputFlow.count() + outputFlow.count(predicate) + // [END android_kotlin_flow_test_operators] + } + + // [START android_kotlin_flow_test_continuous] + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun continuouslyCollect() = runTest { + val dataSource = FakeDataSource() + val repository = Repository(dataSource) + + val values = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + repository.scores().toList(values) + } + + dataSource.emit(1) + assertEquals(10, values[0]) // Assert on the list contents + + dataSource.emit(2) + dataSource.emit(3) + assertEquals(30, values[2]) + + assertEquals(3, values.size) // Assert the number of items collected + } + // [END android_kotlin_flow_test_continuous] + + // [START android_kotlin_flow_test_turbine] + @Test + fun usingTurbine() = runTest { + val dataSource = FakeDataSource() + val repository = Repository(dataSource) + + repository.scores().test { + // Make calls that will trigger value changes only within test{} + dataSource.emit(1) + assertEquals(10, awaitItem()) + + dataSource.emit(2) + awaitItem() // Ignore items if needed, can also use skip(n) + + dataSource.emit(3) + assertEquals(30, awaitItem()) + } + } + // [END android_kotlin_flow_test_turbine] + + // [START android_kotlin_flow_test_hot_fake] + @Test + fun testHotFakeRepository() = runTest { + val fakeRepository = FakeRepository() + val viewModel = MyViewModel(fakeRepository) + + assertEquals(0, viewModel.score.value) // Assert on the initial value + + // Start collecting values from the Repository + viewModel.initialize() + + // Then we can send in values one by one, which the ViewModel will collect + fakeRepository.emit(1) + assertEquals(1, viewModel.score.value) + + fakeRepository.emit(2) + fakeRepository.emit(3) + assertEquals(3, viewModel.score.value) // Assert on the latest value + } + // [END android_kotlin_flow_test_hot_fake] + + // [START android_kotlin_flow_test_lazily] + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testLazilySharingViewModel() = runTest { + val fakeRepository = HotFakeRepository() + val viewModel = MyViewModelWithStateIn(fakeRepository) + + // Create an empty collector for the StateFlow + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.score.collect {} + } + + assertEquals(0, viewModel.score.value) // Can assert initial value + + // Trigger-assert like before + fakeRepository.emit(1) + assertEquals(1, viewModel.score.value) + + fakeRepository.emit(2) + fakeRepository.emit(3) + assertEquals(3, viewModel.score.value) + } + // [END android_kotlin_flow_test_lazily] +} + +// [START android_kotlin_flow_test_continuous_source] +class Repository(private val dataSource: DataSource) { + fun scores(): Flow { + return dataSource.counts().map { it * 10 } + } +} + +class FakeDataSource : DataSource { + private val flow = MutableSharedFlow() + suspend fun emit(value: Int) = flow.emit(value) + override fun counts(): Flow = flow +} +// [END android_kotlin_flow_test_continuous_source] + +interface DataSource { + fun counts(): Flow +} + +// [START android_kotlin_flow_test_stateflow_vm] +class MyViewModel(private val myRepository: MyRepository) : ViewModel() { + private val _score = MutableStateFlow(0) + val score: StateFlow = _score.asStateFlow() + + fun initialize() { + viewModelScope.launch { + myRepository.scores().collect { score -> + _score.value = score + } + } + } +} +// [END android_kotlin_flow_test_stateflow_vm] + +// [START android_kotlin_flow_test_stateflow_fake] +class FakeRepository : MyRepository { + private val flow = MutableSharedFlow() + suspend fun emit(value: Int) = flow.emit(value) + override fun scores(): Flow = flow +} +// [END android_kotlin_flow_test_stateflow_fake] + +class HotFakeRepository : MyRepository { + private val flow = MutableSharedFlow() + suspend fun emit(value: Int) = flow.emit(value) + override fun scores(): Flow = flow +} + +// [START android_kotlin_flow_test_statein] +class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() { + val score: StateFlow = myRepository.scores() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0) +} +// [END android_kotlin_flow_test_statein] diff --git a/kotlin/src/test/kotlin/com/example/android/coroutines/bestpractices/CoroutinesBestPracticesTest.kt b/kotlin/src/test/kotlin/com/example/android/coroutines/bestpractices/CoroutinesBestPracticesTest.kt new file mode 100644 index 000000000..bf2be8849 --- /dev/null +++ b/kotlin/src/test/kotlin/com/example/android/coroutines/bestpractices/CoroutinesBestPracticesTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.coroutines.bestpractices + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FakeArticlesDataSource : ArticlesDataSource() + +// [START android_kotlin_coroutines_best_practices_test] +class ArticlesRepositoryTest { + + @Test + fun testBookmarkArticle() = runTest { + // Pass the testScheduler provided by runTest's coroutine scope to + // the test dispatcher + val testDispatcher = UnconfinedTestDispatcher(testScheduler) + + val articlesDataSource = FakeArticlesDataSource() + val repository = ArticlesRepository( + articlesDataSource, + defaultDispatcher = testDispatcher + ) + val article = Article() + repository.bookmarkArticle(article) + assertThat(articlesDataSource.isBookmarked(article)).isTrue() + } +} +// [END android_kotlin_coroutines_best_practices_test]