Merge branch 'main' into av/remove-dolphin-preview-todos

pull/297/head
Don Turner 2 years ago committed by GitHub
commit 7ca17f68ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -6,7 +6,7 @@ label: 'triage me'
Thank you for opening a Pull Request!
Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [ ] Make sure to open a GitHub issue as a bug/feature request before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea
- [ ] Ensure the tests and linter pass (`./gradlew spotlessApply` to automatically apply formatting)
- [ ] Ensure the tests and linter pass (`./gradlew --init-script gradle/init.gradle.kts spotlessApply` to automatically apply formatting)
- [ ] Appropriate docs were updated (if necessary)
Is this your first Pull Request?

@ -34,10 +34,10 @@ jobs:
uses: gradle/gradle-build-action@v2
- name: Check spotless
run: ./gradlew spotlessCheck --stacktrace
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace
- name: Check lint
run: ./gradlew lintDebug --stacktrace
run: ./gradlew lintDemoDebug --stacktrace
- name: Build all build type and flavor permutations
run: ./gradlew assemble --stacktrace
@ -61,7 +61,7 @@ jobs:
androidTest:
needs: build
runs-on: macOS-latest # enables hardware acceleration in the virtual machine
timeout-minutes: 45
timeout-minutes: 55
strategy:
matrix:
api-level: [23, 26, 30]

@ -0,0 +1,3 @@
# :app-nia-catalog module
![Dependency graph](../docs/images/graphs/dep_graph_app_nia_catalog.png)

@ -16,7 +16,6 @@
plugins {
id("nowinandroid.android.application")
id("nowinandroid.android.application.compose")
id("nowinandroid.spotless")
}
android {
@ -33,6 +32,7 @@ android {
excludes.add("/META-INF/{AL2.0,LGPL2.1}")
}
}
namespace = "com.google.samples.apps.niacatalog"
}
dependencies {

@ -14,8 +14,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.niacatalog">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"

@ -0,0 +1,3 @@
# :app module
![Dependency graph](../docs/images/graphs/dep_graph_app.png)

@ -13,17 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.Flavor
import com.google.samples.apps.nowinandroid.FlavorDimension
plugins {
id("nowinandroid.android.application")
id("nowinandroid.android.application.compose")
id("nowinandroid.android.application.jacoco")
kotlin("kapt")
id("nowinandroid.android.hilt")
id("jacoco")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
id("nowinandroid.firebase-perf")
}
@ -67,19 +63,6 @@ android {
}
}
// @see Flavor for more details on the app product flavors.
flavorDimensions += FlavorDimension.contentType.name
productFlavors {
Flavor.values().forEach {
create(it.name) {
dimension = it.dimension.name
if (it.applicationIdSuffix != null) {
applicationIdSuffix = it.applicationIdSuffix
}
}
}
}
packagingOptions {
resources {
excludes.add("/META-INF/{AL2.0,LGPL2.1}")
@ -90,6 +73,7 @@ android {
isIncludeAndroidResources = true
}
}
namespace = "com.google.samples.apps.nowinandroid"
}
dependencies {
@ -103,7 +87,8 @@ dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:navigation"))
implementation(project(":sync"))
implementation(project(":sync:work"))
implementation(project(":sync:sync-test"))
androidTestImplementation(project(":core:testing"))
androidTestImplementation(project(":core:datastore-test"))
@ -124,17 +109,13 @@ dependencies {
implementation(libs.coil.kt)
implementation(libs.coil.kt.svg)
}
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
kaptAndroidTest(libs.hilt.compiler)
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13
configurations.configureEach {
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13
configurations.configureEach {
resolutionStrategy {
force(libs.junit4)
// Temporary workaround for https://issuetracker.google.com/174733673
force("org.objenesis:objenesis:2.6")
}
}
}

@ -25,7 +25,8 @@ import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.interests.R as FeatureInterestsR
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -70,11 +71,11 @@ class NavigationTest {
@Before
fun setup() {
composeTestRule.activity.apply {
done = getString(R.string.done)
navigateUp = getString(R.string.navigate_up)
forYouLoading = getString(R.string.for_you_loading)
forYou = getString(R.string.for_you)
interests = getString(R.string.interests)
done = getString(FeatureForyouR.string.done)
navigateUp = getString(FeatureForyouR.string.navigate_up)
forYouLoading = getString(FeatureForyouR.string.for_you_loading)
forYou = getString(FeatureForyouR.string.for_you)
interests = getString(FeatureInterestsR.string.interests)
sampleTopic = "Headlines"
}
}

@ -15,8 +15,7 @@
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.google.samples.apps.nowinandroid">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />

@ -31,7 +31,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
@ -45,6 +44,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail
@ -52,6 +52,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavig
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouDestination
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -66,13 +67,20 @@ fun NiaApp(
appState: NiaAppState = rememberNiaAppState(windowSizeClass)
) {
NiaTheme {
NiaBackground {
val background: @Composable (@Composable () -> Unit) -> Unit =
when (appState.currentDestination?.route) {
ForYouDestination.route -> { content -> NiaGradientBackground(content = content) }
else -> { content -> NiaBackground(content = content) }
}
background {
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
@ -158,16 +166,7 @@ private fun NiaBottomBar(
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?
) {
// Wrap the navigation bar in a surface so the color behind the system
// navigation is equal to the container color of the navigation bar.
Surface(color = MaterialTheme.colorScheme.surface) {
NiaNavigationBar(
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
)
)
) {
NiaNavigationBar {
destinations.forEach { destination ->
val selected =
currentDestination?.hierarchy?.any { it.route == destination.route } == true
@ -195,5 +194,4 @@ private fun NiaBottomBar(
)
}
}
}
}

@ -110,7 +110,7 @@ class NiaAppState(
* @param route: Optional route to navigate to in case the destination contains arguments.
*/
fun navigate(destination: NiaNavigationDestination, route: String? = null) {
trace("Navigation: $destination") {
trace("Navigation: ${destination.route}") {
if (destination is TopLevelDestination) {
navController.navigate(route ?: destination.route) {
// Pop up to the start destination of the graph to

@ -19,7 +19,6 @@ import com.google.samples.apps.nowinandroid.configureFlavors
plugins {
id("nowinandroid.android.test")
id("nowinandroid.spotless")
}
android {
@ -30,6 +29,10 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
buildConfig = true
}
buildTypes {
// This benchmark buildType is used for benchmarking, and should function like your
// release build (for example, with minification on). It's signed with a debug key

@ -29,8 +29,6 @@ setup.
Current list of convention plugins:
- [`nowinandroid.spotless`](convention/src/main/kotlin/SpotlessConventionPlugin.kt):
Configures spotless.
- [`nowinandroid.android.application`](convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt),
[`nowinandroid.android.library`](convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt),
[`nowinandroid.android.test`](convention/src/main/kotlin/AndroidTestConventionPlugin.kt):

@ -28,7 +28,6 @@ java {
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin)
}
gradlePlugin {
@ -65,9 +64,9 @@ gradlePlugin {
id = "nowinandroid.android.test"
implementationClass = "AndroidTestConventionPlugin"
}
register("spotless") {
id = "nowinandroid.spotless"
implementationClass = "SpotlessConventionPlugin"
register("androidHilt") {
id = "nowinandroid.android.hilt"
implementationClass = "AndroidHiltConventionPlugin"
}
register("firebase-perf") {
id = "nowinandroid.firebase-perf"

@ -14,7 +14,7 @@
* limitations under the License.
*/
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
@ -24,7 +24,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
val extension = extensions.getByType<BaseAppModuleExtension>()
val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)
}
}

@ -14,8 +14,11 @@
* limitations under the License.
*/
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@ -28,9 +31,13 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
apply("org.jetbrains.kotlin.android")
}
extensions.configure<BaseAppModuleExtension> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 33
configureFlavors(this)
}
extensions.configure<ApplicationAndroidComponentsExtension> {
configurePrintApksTask(this)
}
}
}

@ -26,9 +26,8 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.kapt")
apply("nowinandroid.android.library")
apply("nowinandroid.android.hilt")
}
extensions.configure<LibraryExtension> {
defaultConfig {
@ -58,9 +57,6 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("implementation", libs.findLibrary("kotlinx.coroutines.android").get())
add("implementation", libs.findLibrary("hilt.android").get())
add("kapt", libs.findLibrary("hilt.compiler").get())
}
}
}

@ -0,0 +1,44 @@
/*
* Copyright 2022 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.
*/
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureJacoco
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.kotlin
class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.kapt")
apply("dagger.hilt.android.plugin")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
"implementation"(libs.findLibrary("hilt.android").get())
"kapt"(libs.findLibrary("hilt.compiler").get())
"kaptAndroidTest"(libs.findLibrary("hilt.compiler").get())
}
}
}
}

@ -14,9 +14,12 @@
* limitations under the License.
*/
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
@ -37,9 +40,10 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
defaultConfig.targetSdk = 33
configureFlavors(this)
}
extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this)
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
configurations.configureEach {
resolutionStrategy {
force(libs.findLibrary("junit4").get())
@ -49,6 +53,4 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
}
}
}
}
}

@ -1,53 +0,0 @@
/*
* Copyright 2022 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.
*/
import com.diffplug.gradle.spotless.SpotlessExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
class SpotlessConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.diffplug.spotless")
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
extensions.configure<SpotlessExtension> {
kotlin {
target("**/*.kt")
targetExclude("**/build/**/*.kt")
ktlint(libs.findVersion("ktlint").get().toString()).userData(mapOf("android" to "true"))
licenseHeaderFile(rootProject.file("spotless/copyright.kt"))
}
format("kts") {
target("**/*.kts")
targetExclude("**/build/**/*.kts")
// Look for the first line that doesn't have a block comment (assumed to be the license)
licenseHeaderFile(rootProject.file("spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)")
}
format("xml") {
target("**/*.xml")
targetExclude("**/build/**/*.xml")
// Look for the first XML tag that isn't a comment (<!--) or the xml declaration (<?xml)
licenseHeaderFile(rootProject.file("spotless/copyright.xml"), "(<[^!?])")
}
}
}
}
}

@ -1,5 +1,8 @@
package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.ApplicationProductFlavor
import com.android.build.api.dsl.ApplicationVariantDimension
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
@ -24,6 +27,11 @@ fun Project.configureFlavors(
Flavor.values().forEach{
create(it.name) {
dimension = it.dimension.name
if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) {
if (it.applicationIdSuffix != null) {
this.applicationIdSuffix = it.applicationIdSuffix
}
}
}
}
}

@ -57,8 +57,6 @@ internal fun Project.configureKotlinAndroid(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlin.Experimental",
// Enable experimental kotlinx serialization APIs
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
)
// Set JVM target to 1.8

@ -0,0 +1,98 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.BuiltArtifactsLoader
import com.android.build.api.variant.HasAndroidTest
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.TaskAction
import java.io.File
internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtension<*, *, *>) {
extension.onVariants { variant ->
if (variant is HasAndroidTest) {
val loader = variant.artifacts.getBuiltArtifactsLoader()
val artifact = variant.androidTest?.artifacts?.get(SingleArtifact.APK)
val javaSources = variant.androidTest?.sources?.java?.all
val kotlinSources = variant.androidTest?.sources?.kotlin?.all
val testSources = if (javaSources != null && kotlinSources != null) {
javaSources.zip(kotlinSources) { javaDirs, kotlinDirs ->
javaDirs + kotlinDirs
}
} else javaSources ?: kotlinSources
if (artifact != null && testSources != null) {
tasks.register(
"${variant.name}PrintTestApk",
PrintApkLocationTask::class.java
) {
apkFolder.set(artifact)
builtArtifactsLoader.set(loader)
variantName.set(variant.name)
sources.set(testSources)
}
}
}
}
}
internal abstract class PrintApkLocationTask : DefaultTask() {
@get:InputDirectory
abstract val apkFolder: DirectoryProperty
@get:InputFiles
abstract val sources: ListProperty<Directory>
@get:Internal
abstract val builtArtifactsLoader: Property<BuiltArtifactsLoader>
@get:Input
abstract val variantName: Property<String>
@TaskAction
fun taskAction() {
val hasFiles = sources.orNull?.any { directory ->
directory.asFileTree.files.any {
it.isFile && it.parentFile.path.contains("build${File.separator}generated").not()
}
} ?: throw RuntimeException("Cannot check androidTest sources")
// Don't print APK location if there are no androidTest source files
if (!hasFiles) {
return
}
val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get())
?: throw RuntimeException("Cannot load APKs")
if (builtArtifacts.elements.size != 1)
throw RuntimeException("Expected one APK !")
val apk = File(builtArtifacts.elements.single().outputFile).toPath()
println(apk)
}
}

@ -14,8 +14,6 @@
* limitations under the License.
*/
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
dependencyResolutionManagement {
repositories {
google()
@ -28,4 +26,5 @@ dependencyResolutionManagement {
}
}
rootProject.name = "build-logic"
include(":convention")

@ -31,5 +31,4 @@ plugins {
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.secrets) apply false
alias(libs.plugins.spotless) apply false
}

@ -0,0 +1,3 @@
# :core:common module
![Dependency graph](../../docs/images/graphs/dep_graph_core_common.png)

@ -16,14 +16,14 @@
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
kotlin("kapt")
id("nowinandroid.spotless")
id("nowinandroid.android.hilt")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.common"
}
dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
testImplementation(project(":core:testing"))
}

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.common">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -0,0 +1,3 @@
# :core:data-test module
![Dependency graph](../../docs/images/graphs/dep_graph_core_data_test.png)

@ -15,16 +15,14 @@
*/
plugins {
id("nowinandroid.android.library")
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
id("nowinandroid.android.hilt")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.data.test"
}
dependencies {
api(project(":core:data"))
implementation(project(":core:testing"))
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
kaptAndroidTest(libs.hilt.compiler)
}

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.data.test">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -0,0 +1,26 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor {
override val isOnline: Flow<Boolean> = flowOf(true)
}

@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeAuthor
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
@ -55,4 +56,9 @@ interface TestDataModule {
fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository
): UserDataRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor
): NetworkMonitor
}

@ -0,0 +1,3 @@
# :core:data module
![Dependency graph](../../docs/images/graphs/dep_graph_core_data.png)

@ -16,10 +16,12 @@
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
kotlin("kapt")
id("nowinandroid.android.hilt")
id("kotlinx-serialization")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.data"
}
dependencies {
@ -32,10 +34,9 @@ dependencies {
testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
}

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.data">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

@ -24,6 +24,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTop
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -52,4 +54,9 @@ interface DataModule {
fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository
): UserDataRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor
): NetworkMonitor
}

@ -1,37 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.network.model.NetworkEpisode
import com.google.samples.apps.nowinandroid.core.network.model.NetworkEpisodeExpanded
fun NetworkEpisode.asEntity() = EpisodeEntity(
id = id,
name = name,
publishDate = publishDate,
alternateVideo = alternateVideo,
alternateAudio = alternateAudio,
)
fun NetworkEpisodeExpanded.asEntity() = EpisodeEntity(
id = id,
name = name,
publishDate = publishDate,
alternateVideo = alternateVideo,
alternateAudio = alternateAudio,
)

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
@ -27,7 +26,6 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResour
fun NetworkNewsResource.asEntity() = NewsResourceEntity(
id = id,
episodeId = episodeId,
title = title,
content = content,
url = url,
@ -38,7 +36,6 @@ fun NetworkNewsResource.asEntity() = NewsResourceEntity(
fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
id = id,
episodeId = episodeId,
title = title,
content = content,
url = url,
@ -47,18 +44,6 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
type = type,
)
/**
* A shell [EpisodeEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB
*/
fun NetworkNewsResource.episodeEntityShell() = EpisodeEntity(
id = episodeId,
name = "",
publishDate = publishDate,
alternateVideo = null,
alternateAudio = null,
)
/**
* A shell [AuthorEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB

@ -21,15 +21,12 @@ import com.google.samples.apps.nowinandroid.core.data.changeListSync
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.model.authorCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.authorEntityShells
import com.google.samples.apps.nowinandroid.core.data.model.episodeEntityShell
import com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.dao.EpisodeDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@ -47,7 +44,6 @@ import kotlinx.coroutines.flow.map
*/
class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val episodeDao: EpisodeDao,
private val authorDao: AuthorDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
@ -93,11 +89,6 @@ class OfflineFirstNewsRepository @Inject constructor(
.flatten()
.distinctBy(AuthorEntity::id)
)
episodeDao.insertOrIgnoreEpisodes(
episodeEntities = networkNewsResources
.map(NetworkNewsResource::episodeEntityShell)
.distinctBy(EpisodeEntity::id)
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity)

@ -0,0 +1,78 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.data.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest.Builder
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow<Boolean> {
val callback = object : NetworkCallback() {
override fun onAvailable(network: Network) {
channel.trySend(true)
}
override fun onLost(network: Network) {
channel.trySend(false)
}
}
val connectivityManager = context.getSystemService<ConnectivityManager>()
connectivityManager?.registerNetworkCallback(
Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(),
callback
)
channel.trySend(connectivityManager.isCurrentlyConnected())
awaitClose {
connectivityManager?.unregisterNetworkCallback(callback)
}
}
.conflate()
@Suppress("DEPRECATION")
private fun ConnectivityManager?.isCurrentlyConnected() = when (this) {
null -> false
else -> when {
VERSION.SDK_INT >= VERSION_CODES.M ->
activeNetwork
?.let(::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
?: false
else -> activeNetworkInfo?.isConnected ?: false
}
}
}

@ -14,19 +14,13 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.model.data
package com.google.samples.apps.nowinandroid.core.data.util
import kotlinx.datetime.Instant
import kotlinx.coroutines.flow.Flow
/**
* External data layer representation of an NiA episode
* Utility for reporting app connectivity status
*/
data class Episode(
val id: String,
val name: String,
val publishDate: Instant,
val alternateVideo: String?,
val alternateAudio: String?,
val newsResources: List<NewsResource>,
val authors: List<Author>
)
interface NetworkMonitor {
val isOnline: Flow<Boolean>
}

@ -0,0 +1,26 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.data.util
import kotlinx.coroutines.flow.Flow
/**
* Reports on if synchronization is in progress
*/
interface SyncStatusMonitor {
val isSyncing: Flow<Boolean>
}

@ -18,8 +18,6 @@ package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkEpisode
import com.google.samples.apps.nowinandroid.core.network.model.NetworkEpisodeExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -71,7 +69,6 @@ class NetworkEntityKtTest {
val networkModel =
NetworkNewsResource(
id = "0",
episodeId = "2",
title = "title",
content = "content",
url = "url",
@ -82,7 +79,6 @@ class NetworkEntityKtTest {
val entity = networkModel.asEntity()
assertEquals("0", entity.id)
assertEquals("2", entity.episodeId)
assertEquals("title", entity.title)
assertEquals("content", entity.content)
assertEquals("url", entity.url)
@ -93,7 +89,6 @@ class NetworkEntityKtTest {
val expandedNetworkModel =
NetworkNewsResourceExpanded(
id = "0",
episodeId = "2",
title = "title",
content = "content",
url = "url",
@ -105,7 +100,6 @@ class NetworkEntityKtTest {
val entityFromExpanded = expandedNetworkModel.asEntity()
assertEquals("0", entityFromExpanded.id)
assertEquals("2", entityFromExpanded.episodeId)
assertEquals("title", entityFromExpanded.title)
assertEquals("content", entityFromExpanded.content)
assertEquals("url", entityFromExpanded.url)
@ -113,39 +107,4 @@ class NetworkEntityKtTest {
assertEquals(Instant.fromEpochMilliseconds(1), entityFromExpanded.publishDate)
assertEquals(Article, entityFromExpanded.type)
}
@Test
fun network_episode_can_be_mapped_to_episode_entity() {
val networkModel = NetworkEpisode(
id = "0",
name = "name",
publishDate = Instant.fromEpochMilliseconds(1),
alternateVideo = "alternateVideo",
alternateAudio = "alternateAudio",
)
val entity = networkModel.asEntity()
assertEquals("0", entity.id)
assertEquals("name", entity.name)
assertEquals("alternateVideo", entity.alternateVideo)
assertEquals("alternateAudio", entity.alternateAudio)
assertEquals(Instant.fromEpochMilliseconds(1), entity.publishDate)
val expandedNetworkModel =
NetworkEpisodeExpanded(
id = "0",
name = "name",
publishDate = Instant.fromEpochMilliseconds(1),
alternateVideo = "alternateVideo",
alternateAudio = "alternateAudio",
)
val entityFromExpanded = expandedNetworkModel.asEntity()
assertEquals("0", entityFromExpanded.id)
assertEquals("name", entityFromExpanded.name)
assertEquals("alternateVideo", entityFromExpanded.alternateVideo)
assertEquals("alternateAudio", entityFromExpanded.alternateAudio)
assertEquals(Instant.fromEpochMilliseconds(1), entityFromExpanded.publishDate)
}
}

@ -20,21 +20,17 @@ import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.model.authorCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.authorEntityShells
import com.google.samples.apps.nowinandroid.core.data.model.episodeEntityShell
import com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells
import com.google.samples.apps.nowinandroid.core.data.testdoubles.CollectionType
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestAuthorDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestEpisodeDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNewsResourceDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestTopicDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds
import com.google.samples.apps.nowinandroid.core.data.testdoubles.nonPresentInterestsIds
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@ -57,8 +53,6 @@ class OfflineFirstNewsRepositoryTest {
private lateinit var newsResourceDao: TestNewsResourceDao
private lateinit var episodeDao: TestEpisodeDao
private lateinit var authorDao: TestAuthorDao
private lateinit var topicDao: TestTopicDao
@ -73,7 +67,6 @@ class OfflineFirstNewsRepositoryTest {
@Before
fun setup() {
newsResourceDao = TestNewsResourceDao()
episodeDao = TestEpisodeDao()
authorDao = TestAuthorDao()
topicDao = TestTopicDao()
network = TestNiaNetworkDataSource()
@ -85,7 +78,6 @@ class OfflineFirstNewsRepositoryTest {
subject = OfflineFirstNewsRepository(
newsResourceDao = newsResourceDao,
episodeDao = episodeDao,
authorDao = authorDao,
topicDao = topicDao,
network = network,
@ -287,21 +279,6 @@ class OfflineFirstNewsRepositoryTest {
)
}
@Test
fun offlineFirstNewsRepository_sync_saves_shell_episode_entities() =
runTest {
subject.syncWith(synchronizer)
assertEquals(
network.getNewsResources()
.map(NetworkNewsResource::episodeEntityShell)
.distinctBy(EpisodeEntity::id),
episodeDao.getEpisodesStream()
.first()
.map(PopulatedEpisode::entity)
)
}
@Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =
runTest {

@ -57,6 +57,10 @@ class TestAuthorDao : AuthorDao {
throw NotImplementedError("Unused in tests")
}
override suspend fun upsertAuthors(entities: List<AuthorEntity>) {
entitiesStateFlow.value = entities
}
override suspend fun deleteAuthors(ids: List<String>) {
val idSet = ids.toSet()
entitiesStateFlow.update { entities ->

@ -1,72 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.EpisodeDao
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.datetime.Instant
/**
* Test double for [EpisodeDao]
*/
class TestEpisodeDao : EpisodeDao {
private var entitiesStateFlow = MutableStateFlow(
listOf(
EpisodeEntity(
id = "1",
name = "Episode",
publishDate = Instant.fromEpochMilliseconds(0),
alternateVideo = null,
alternateAudio = null,
)
)
)
override fun getEpisodesStream(): Flow<List<PopulatedEpisode>> =
entitiesStateFlow.map {
it.map(EpisodeEntity::asPopulatedEpisode)
}
override suspend fun insertOrIgnoreEpisodes(episodeEntities: List<EpisodeEntity>): List<Long> {
entitiesStateFlow.value = episodeEntities
// Assume no conflicts on insert
return episodeEntities.map { it.id.toLong() }
}
override suspend fun updateEpisodes(entities: List<EpisodeEntity>) {
throw NotImplementedError("Unused in tests")
}
override suspend fun deleteEpisodes(ids: List<String>) {
val idSet = ids.toSet()
entitiesStateFlow.update { entities ->
entities.filterNot { idSet.contains(it.id) }
}
}
}
private fun EpisodeEntity.asPopulatedEpisode() = PopulatedEpisode(
entity = this,
newsResources = emptyList(),
authors = emptyList(),
)

@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
@ -43,7 +42,6 @@ class TestNewsResourceDao : NewsResourceDao {
listOf(
NewsResourceEntity(
id = "1",
episodeId = "0",
title = "news",
content = "Hilt",
url = "url",
@ -87,6 +85,10 @@ class TestNewsResourceDao : NewsResourceDao {
throw NotImplementedError("Unused in tests")
}
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
entitiesStateFlow.value = newsResourceEntities
}
override suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>
) {
@ -109,16 +111,9 @@ class TestNewsResourceDao : NewsResourceDao {
private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource(
entity = this,
episode = EpisodeEntity(
id = this.episodeId,
name = "episode 4",
publishDate = Instant.fromEpochMilliseconds(2),
alternateAudio = "audio",
alternateVideo = "video",
),
authors = listOf(
AuthorEntity(
id = this.episodeId,
id = "id",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",

@ -28,7 +28,6 @@ import kotlinx.serialization.json.Json
enum class CollectionType {
Topics,
Authors,
Episodes,
NewsResources
}
@ -53,7 +52,6 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
.mapToChangeList(idGetter = NetworkTopic::id),
CollectionType.Authors to allAuthors
.mapToChangeList(idGetter = NetworkAuthor::id),
CollectionType.Episodes to listOf(),
CollectionType.NewsResources to allNewsResources
.mapToChangeList(idGetter = NetworkNewsResource::id),
)

@ -62,6 +62,10 @@ class TestTopicDao : TopicDao {
throw NotImplementedError("Unused in tests")
}
override suspend fun upsertTopics(entities: List<TopicEntity>) {
entitiesStateFlow.value = entities
}
override suspend fun deleteTopics(ids: List<String>) {
val idSet = ids.toSet()
entitiesStateFlow.update { entities ->

@ -1,98 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.database.model
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.Episode
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
import org.junit.Test
class PopulatedEpisodeKtTest {
@Test
fun populated_episode_can_be_mapped_to_episode() {
val populatedEpisode = PopulatedEpisode(
entity = EpisodeEntity(
id = "0",
name = "Test",
publishDate = Instant.fromEpochMilliseconds(1),
alternateAudio = "audio",
alternateVideo = "video"
),
newsResources = listOf(
NewsResourceEntity(
id = "1",
episodeId = "0",
title = "news",
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
)
),
authors = listOf(
AuthorEntity(
id = "2",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
)
val episode = populatedEpisode.asExternalModel()
assertEquals(
Episode(
id = "0",
name = "Test",
publishDate = Instant.fromEpochMilliseconds(1),
alternateAudio = "audio",
alternateVideo = "video",
newsResources = listOf(
NewsResource(
id = "1",
episodeId = "0",
title = "news",
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
authors = listOf(),
topics = listOf()
)
),
authors = listOf(
Author(
id = "2",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
),
episode
)
}
}

@ -30,7 +30,6 @@ class PopulatedNewsResourceKtTest {
val populatedNewsResource = PopulatedNewsResource(
entity = NewsResourceEntity(
id = "1",
episodeId = "0",
title = "news",
content = "Hilt",
url = "url",
@ -38,13 +37,6 @@ class PopulatedNewsResourceKtTest {
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
),
episode = EpisodeEntity(
id = "4",
name = "episode 4",
publishDate = Instant.fromEpochMilliseconds(2),
alternateAudio = "audio",
alternateVideo = "video",
),
authors = listOf(
AuthorEntity(
id = "2",
@ -71,7 +63,6 @@ class PopulatedNewsResourceKtTest {
assertEquals(
NewsResource(
id = "1",
episodeId = "0",
title = "news",
content = "Hilt",
url = "url",

@ -0,0 +1,3 @@
# :core:database module
![Dependency graph](../../docs/images/graphs/dep_graph_core_database.png)

@ -18,10 +18,8 @@
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("nowinandroid.android.hilt")
alias(libs.plugins.ksp)
id("nowinandroid.spotless")
}
android {
@ -35,6 +33,7 @@ android {
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
namespace = "com.google.samples.apps.nowinandroid.core.database"
}
dependencies {
@ -47,8 +46,5 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
androidTestImplementation(project(":core:testing"))
}

@ -0,0 +1,314 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "2f83f889f6d8a96243f4ce387adbc604",
"entities": [
{
"tableName": "authors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `image_url` TEXT NOT NULL, `twitter` TEXT NOT NULL DEFAULT '', `medium_page` TEXT NOT NULL DEFAULT '', `bio` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "twitter",
"columnName": "twitter",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "mediumPage",
"columnName": "medium_page",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "bio",
"columnName": "bio",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "news_resources_authors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `author_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `author_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`author_id`) REFERENCES `authors`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "news_resource_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authorId",
"columnName": "author_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"news_resource_id",
"author_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_news_resources_authors_news_resource_id",
"unique": false,
"columnNames": [
"news_resource_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_authors_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)"
},
{
"name": "index_news_resources_authors_author_id",
"unique": false,
"columnNames": [
"author_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_authors_author_id` ON `${TABLE_NAME}` (`author_id`)"
}
],
"foreignKeys": [
{
"table": "news_resources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"news_resource_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "authors",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"author_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "news_resources",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "headerImageUrl",
"columnName": "header_image_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "publishDate",
"columnName": "publish_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "news_resources_topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "news_resource_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "topicId",
"columnName": "topic_id",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"news_resource_id",
"topic_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_news_resources_topics_news_resource_id",
"unique": false,
"columnNames": [
"news_resource_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)"
},
{
"name": "index_news_resources_topics_topic_id",
"unique": false,
"columnNames": [
"topic_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)"
}
],
"foreignKeys": [
{
"table": "news_resources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"news_resource_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "topics",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"topic_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "topics",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "longDescription",
"columnName": "longDescription",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "imageUrl",
"columnName": "imageUrl",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2f83f889f6d8a96243f4ce387adbc604')"
]
}
}

@ -21,7 +21,6 @@ import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
@ -38,7 +37,6 @@ import org.junit.Test
class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao
private lateinit var episodeDao: EpisodeDao
private lateinit var topicDao: TopicDao
private lateinit var authorDao: AuthorDao
private lateinit var db: NiaDatabase
@ -51,7 +49,6 @@ class NewsResourceDaoTest {
NiaDatabase::class.java
).build()
newsResourceDao = db.newsResourceDao()
episodeDao = db.episodeDao()
topicDao = db.topicDao()
authorDao = db.authorDao()
}
@ -76,13 +73,6 @@ class NewsResourceDaoTest {
millisSinceEpoch = 2,
),
)
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
episodeDao.insertOrIgnoreEpisodes(
episodeEntityShells
)
newsResourceDao.upsertNewsResources(
newsResourceEntities
)
@ -128,9 +118,6 @@ class NewsResourceDaoTest {
millisSinceEpoch = 2,
),
)
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef(
newsResourceId = index.toString(),
@ -141,9 +128,6 @@ class NewsResourceDaoTest {
topicDao.insertOrIgnoreTopics(
topicEntities = topicEntities
)
episodeDao.insertOrIgnoreEpisodes(
episodeEntities = episodeEntityShells
)
newsResourceDao.upsertNewsResources(
newsResourceEntities
)
@ -193,9 +177,6 @@ class NewsResourceDaoTest {
millisSinceEpoch = 2,
),
)
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
val newsResourceAuthorCrossRefEntities = authorEntities.mapIndexed { index, authorEntity ->
NewsResourceAuthorCrossRef(
newsResourceId = index.toString(),
@ -204,7 +185,6 @@ class NewsResourceDaoTest {
}
authorDao.upsertAuthors(authorEntities)
episodeDao.upsertEpisodes(episodeEntityShells)
newsResourceDao.upsertNewsResources(newsResourceEntities)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities)
@ -266,9 +246,7 @@ class NewsResourceDaoTest {
millisSinceEpoch = 10,
),
)
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef(
newsResourceId = index.toString(),
@ -286,7 +264,6 @@ class NewsResourceDaoTest {
topicDao.upsertTopics(topicEntities)
authorDao.upsertAuthors(authorEntities)
episodeDao.upsertEpisodes(episodeEntityShells)
newsResourceDao.upsertNewsResources(newsResourceEntities)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(newsResourceTopicCrossRefEntities)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities)
@ -327,11 +304,6 @@ class NewsResourceDaoTest {
millisSinceEpoch = 2,
),
)
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
episodeDao.upsertEpisodes(episodeEntityShells)
newsResourceDao.upsertNewsResources(newsResourceEntities)
val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 }
@ -379,7 +351,6 @@ private fun testNewsResource(
millisSinceEpoch: Long = 0
) = NewsResourceEntity(
id = id,
episodeId = "0",
title = "",
content = "",
url = "",
@ -387,11 +358,3 @@ private fun testNewsResource(
publishDate = Instant.fromEpochMilliseconds(millisSinceEpoch),
type = NewsResourceType.DAC,
)
private fun NewsResourceEntity.episodeEntityShell() = EpisodeEntity(
id = episodeId,
name = "",
publishDate = Instant.fromEpochMilliseconds(0),
alternateVideo = null,
alternateAudio = null,
)

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.database">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.core.database
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.dao.EpisodeDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import dagger.Module
@ -38,11 +37,6 @@ object DaosModule {
database: NiaDatabase,
): TopicDao = database.topicDao()
@Provides
fun providesEpisodeDao(
database: NiaDatabase,
): EpisodeDao = database.episodeDao()
@Provides
fun providesNewsResourceDao(
database: NiaDatabase,

@ -16,6 +16,8 @@
package com.google.samples.apps.nowinandroid.core.database
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RenameColumn
import androidx.room.migration.AutoMigrationSpec
@ -26,7 +28,7 @@ import androidx.room.migration.AutoMigrationSpec
* from and Y is the schema version you're migrating to. The class should implement
* `AutoMigrationSpec`.
*/
class DatabaseMigrations {
object DatabaseMigrations {
@RenameColumn(
tableName = "topics",
@ -34,4 +36,18 @@ class DatabaseMigrations {
toColumnName = "shortDescription"
)
class Schema2to3 : AutoMigrationSpec
@DeleteColumn(
tableName = "news_resources",
columnName = "episode_id"
)
@DeleteTable.Entries(
DeleteTable(
tableName = "episodes_authors"
),
DeleteTable(
tableName = "episodes"
)
)
class Schema10to11 : AutoMigrationSpec
}

@ -21,12 +21,9 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.dao.EpisodeDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
@ -37,14 +34,12 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
@Database(
entities = [
AuthorEntity::class,
EpisodeAuthorCrossRef::class,
EpisodeEntity::class,
NewsResourceAuthorCrossRef::class,
NewsResourceEntity::class,
NewsResourceTopicCrossRef::class,
TopicEntity::class,
],
version = 10,
version = 11,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
@ -55,6 +50,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class)
],
exportSchema = true,
)
@ -65,6 +61,5 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao
abstract fun authorDao(): AuthorDao
abstract fun episodeDao(): EpisodeDao
abstract fun newsResourceDao(): NewsResourceDao
}

@ -20,8 +20,8 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import kotlinx.coroutines.flow.Flow
@ -56,12 +56,8 @@ interface AuthorDao {
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
@Transaction
suspend fun upsertAuthors(entities: List<AuthorEntity>) = upsert(
items = entities,
insertMany = ::insertOrIgnoreAuthors,
updateMany = ::updateAuthors
)
@Upsert
suspend fun upsertAuthors(entities: List<AuthorEntity>)
/**
* Deletes rows in the db matching the specified [ids]

@ -1,71 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode
import com.google.samples.apps.nowinandroid.core.model.data.Episode
import kotlinx.coroutines.flow.Flow
/**
* DAO for [EpisodeEntity] and [Episode] access
*/
@Dao
interface EpisodeDao {
@Transaction
@Query(value = "SELECT * FROM episodes")
fun getEpisodesStream(): Flow<List<PopulatedEpisode>>
/**
* Inserts [episodeEntities] into the db if they don't exist, and ignores those that do
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreEpisodes(episodeEntities: List<EpisodeEntity>): List<Long>
/**
* Updates [entities] in the db that match the primary key, and no-ops if they don't
*/
@Update
suspend fun updateEpisodes(entities: List<EpisodeEntity>)
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
@Transaction
suspend fun upsertEpisodes(entities: List<EpisodeEntity>) = upsert(
items = entities,
insertMany = ::insertOrIgnoreEpisodes,
updateMany = ::updateEpisodes
)
/**
* Deletes rows in the db matching the specified [ids]
*/
@Query(
value = """
DELETE FROM episodes
WHERE id in (:ids)
"""
)
suspend fun deleteEpisodes(ids: List<String>)
}

@ -22,6 +22,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
@ -80,12 +81,8 @@ interface NewsResourceDao {
/**
* Inserts or updates [newsResourceEntities] in the db under the specified primary keys
*/
@Transaction
suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) = upsert(
items = newsResourceEntities,
insertMany = ::insertOrIgnoreNewsResources,
updateMany = ::updateNewsResources
)
@Upsert
suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopicCrossRefEntities(

@ -20,8 +20,8 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import kotlinx.coroutines.flow.Flow
@ -64,12 +64,8 @@ interface TopicDao {
/**
* Inserts or updates [entities] in the db under the specified primary keys
*/
@Transaction
suspend fun upsertTopics(entities: List<TopicEntity>) = upsert(
items = entities,
insertMany = ::insertOrIgnoreTopics,
updateMany = ::updateTopics
)
@Upsert
suspend fun upsertTopics(entities: List<TopicEntity>)
/**
* Deletes rows in the db matching the specified [ids]

@ -1,37 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.database.dao
/**
* Performs an upsert by first attempting to insert [items] using [insertMany] with the the result
* of the inserts returned.
*
* Items that were not inserted due to conflicts are then updated using [updateMany]
*/
suspend fun <T> upsert(
items: List<T>,
insertMany: suspend (List<T>) -> List<Long>,
updateMany: suspend (List<T>) -> Unit,
) {
val insertResults = insertMany(items)
val updateList = items.zip(insertResults)
.mapNotNull { (item, insertResult) ->
if (insertResult == -1L) item else null
}
if (updateList.isNotEmpty()) updateMany(updateList)
}

@ -22,7 +22,7 @@ import androidx.room.PrimaryKey
import com.google.samples.apps.nowinandroid.core.model.data.Author
/**
* Defines an author for either an [EpisodeEntity] or [NewsResourceEntity].
* Defines an author for [NewsResourceEntity].
* It has a many to many relationship with both entities
*/
@Entity(

@ -1,54 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
/**
* Cross reference for many to many relationship between [EpisodeEntity] and [AuthorEntity]
*/
@Entity(
tableName = "episodes_authors",
primaryKeys = ["episode_id", "author_id"],
foreignKeys = [
ForeignKey(
entity = EpisodeEntity::class,
parentColumns = ["id"],
childColumns = ["episode_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = AuthorEntity::class,
parentColumns = ["id"],
childColumns = ["author_id"],
onDelete = ForeignKey.CASCADE
),
],
indices = [
Index(value = ["episode_id"]),
Index(value = ["author_id"]),
],
)
data class EpisodeAuthorCrossRef(
@ColumnInfo(name = "episode_id")
val episodeId: String,
@ColumnInfo(name = "author_id")
val authorId: String,
)

@ -1,41 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* Defines an NiA episode.
* It is a parent in a 1 to many relationship with [NewsResourceEntity]
*/
@Entity(
tableName = "episodes",
)
data class EpisodeEntity(
@PrimaryKey
val id: String,
val name: String,
@ColumnInfo(name = "publish_date")
val publishDate: Instant,
@ColumnInfo(name = "alternate_video")
val alternateVideo: String?,
@ColumnInfo(name = "alternate_audio")
val alternateAudio: String?,
)

@ -18,8 +18,6 @@ package com.google.samples.apps.nowinandroid.core.database.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
@ -27,27 +25,13 @@ import kotlinx.datetime.Instant
/**
* Defines an NiA news resource.
* It is the child in a 1 to many relationship with [EpisodeEntity]
*/
@Entity(
tableName = "news_resources",
foreignKeys = [
ForeignKey(
entity = EpisodeEntity::class,
parentColumns = ["id"],
childColumns = ["episode_id"],
onDelete = ForeignKey.CASCADE
),
],
indices = [
Index(value = ["episode_id"])
]
tableName = "news_resources"
)
data class NewsResourceEntity(
@PrimaryKey
val id: String,
@ColumnInfo(name = "episode_id")
val episodeId: String,
val title: String,
val content: String,
val url: String,
@ -60,7 +44,6 @@ data class NewsResourceEntity(
fun NewsResourceEntity.asExternalModel() = NewsResource(
id = id,
episodeId = episodeId,
title = title,
content = content,
url = url,

@ -1,55 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.database.model
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import com.google.samples.apps.nowinandroid.core.model.data.Episode
/**
* External data layer representation of an NiA episode
*/
data class PopulatedEpisode(
@Embedded
val entity: EpisodeEntity,
@Relation(
parentColumn = "id",
entityColumn = "episode_id"
)
val newsResources: List<NewsResourceEntity>,
@Relation(
parentColumn = "id",
entityColumn = "id",
associateBy = Junction(
value = EpisodeAuthorCrossRef::class,
parentColumn = "episode_id",
entityColumn = "author_id",
)
)
val authors: List<AuthorEntity>
)
fun PopulatedEpisode.asExternalModel() = Episode(
id = entity.id,
name = entity.name,
publishDate = entity.publishDate,
alternateVideo = entity.alternateVideo,
alternateAudio = entity.alternateAudio,
newsResources = newsResources.map(NewsResourceEntity::asExternalModel),
authors = authors.map(AuthorEntity::asExternalModel)
)

@ -27,11 +27,6 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
data class PopulatedNewsResource(
@Embedded
val entity: NewsResourceEntity,
@Relation(
parentColumn = "episode_id",
entityColumn = "id"
)
val episode: EpisodeEntity,
@Relation(
parentColumn = "id",
entityColumn = "id",
@ -56,7 +51,6 @@ data class PopulatedNewsResource(
fun PopulatedNewsResource.asExternalModel() = NewsResource(
id = entity.id,
episodeId = entity.episodeId,
title = entity.title,
content = entity.content,
url = entity.url,

@ -0,0 +1,3 @@
# :core:datastore-test module
![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore_test.png)

@ -15,9 +15,11 @@
*/
plugins {
id("nowinandroid.android.library")
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
id("nowinandroid.android.hilt")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.datastore.test"
}
dependencies {
@ -25,8 +27,4 @@ dependencies {
implementation(project(":core:testing"))
api(libs.androidx.dataStore.core)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
kaptAndroidTest(libs.hilt.compiler)
}

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.datastore.test">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -0,0 +1,3 @@
# :core:datastore module
![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore.png)

@ -24,16 +24,15 @@ import com.google.protobuf.gradle.protoc
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
kotlin("kapt")
id("dagger.hilt.android.plugin")
id("nowinandroid.android.hilt")
alias(libs.plugins.protobuf)
id("nowinandroid.spotless")
}
android {
defaultConfig {
consumerProguardFiles("consumer-proguard-rules.pro")
}
namespace = "com.google.samples.apps.nowinandroid.core.datastore"
}
// Setup protobuf configuration, generating lite Java and Kotlin classes
@ -65,13 +64,4 @@ dependencies {
implementation(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
kaptAndroidTest(libs.hilt.compiler)
}
// TODO b/239411851, Remove kapt workaround configuration
kapt {
correctErrorTypes = true
}

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.datastore">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -22,6 +22,5 @@ package com.google.samples.apps.nowinandroid.core.datastore
data class ChangeListVersions(
val topicVersion: Int = -1,
val authorVersion: Int = -1,
val episodeVersion: Int = -1,
val newsResourceVersion: Int = -1,
)

@ -116,7 +116,6 @@ class NiaPreferencesDataSource @Inject constructor(
ChangeListVersions(
topicVersion = it.topicChangeListVersion,
authorVersion = it.authorChangeListVersion,
episodeVersion = it.episodeChangeListVersion,
newsResourceVersion = it.newsResourceChangeListVersion,
)
}
@ -132,7 +131,6 @@ class NiaPreferencesDataSource @Inject constructor(
ChangeListVersions(
topicVersion = currentPreferences.topicChangeListVersion,
authorVersion = currentPreferences.authorChangeListVersion,
episodeVersion = currentPreferences.episodeChangeListVersion,
newsResourceVersion = currentPreferences.newsResourceChangeListVersion
)
)
@ -140,7 +138,6 @@ class NiaPreferencesDataSource @Inject constructor(
currentPreferences.copy {
topicChangeListVersion = updatedChangeListVersions.topicVersion
authorChangeListVersion = updatedChangeListVersions.authorVersion
episodeChangeListVersion = updatedChangeListVersions.episodeVersion
newsResourceChangeListVersion = updatedChangeListVersions.newsResourceVersion
}
}

@ -24,7 +24,6 @@ message UserPreferences {
repeated int32 deprecated_int_followed_topic_ids = 1;
int32 topicChangeListVersion = 3;
int32 authorChangeListVersion = 4;
int32 episodeChangeListVersion = 5;
int32 newsResourceChangeListVersion = 6;
repeated int32 deprecated_int_followed_author_ids = 7;
bool has_done_int_to_string_id_migration = 8;

@ -0,0 +1,3 @@
# :core:designsystem module
![Dependency graph](../../docs/images/graphs/dep_graph_core_designsystem.png)

@ -17,7 +17,6 @@ plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco")
id("nowinandroid.spotless")
}
android {
@ -27,6 +26,7 @@ android {
lint {
checkDependencies = true
}
namespace = "com.google.samples.apps.nowinandroid.core.designsystem"
}
dependencies {

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.designsystem">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -139,7 +139,7 @@ fun NiaGradientBackground(
*/
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Light theme")
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark theme")
private annotation class ThemePreviews
annotation class ThemePreviews
@ThemePreviews
@Composable

@ -16,13 +16,13 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -58,13 +58,13 @@ fun NiaFilterChip(
},
modifier = modifier,
enabled = enabled,
selectedIcon = {
trailingIcon = {
Icon(
imageVector = NiaIcons.Check,
contentDescription = null
)
},
shape = Shapes.Full,
shape = CircleShape,
border = FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.onBackground,
selectedBorderColor = MaterialTheme.colorScheme.onBackground,

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component
import android.content.res.Configuration
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
@ -31,6 +30,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
@ -44,7 +44,6 @@ import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import kotlinx.coroutines.launch
@ -125,14 +124,25 @@ fun NiaLoadingWheel(
}
}
@Preview(
name = "Loading Wheel Light Preview",
uiMode = Configuration.UI_MODE_NIGHT_NO,
)
@Preview(
name = "Loading Wheel Dark Preview",
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
@Composable
fun NiaOverlayLoadingWheel(
contentDesc: String,
modifier: Modifier = Modifier
) {
Surface(
shape = RoundedCornerShape(60.dp),
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.83f),
modifier = modifier
.size(60.dp),
) {
NiaLoadingWheel(
contentDesc = contentDesc,
)
}
}
@ThemePreviews
@Composable
fun NiaLoadingWheelPreview() {
NiaTheme {
@ -142,5 +152,15 @@ fun NiaLoadingWheelPreview() {
}
}
@ThemePreviews
@Composable
fun NiaOverlayLoadingWheelPreview() {
NiaTheme {
Surface {
NiaOverlayLoadingWheel(contentDesc = "LoadingWheel")
}
}
}
private const val ROTATION_TIME = 12000
private const val NUM_OF_LINES = 12

@ -88,7 +88,6 @@ fun NiaNavigationBar(
) {
NavigationBar(
modifier = modifier,
containerColor = NiaNavigationDefaults.NavigationContainerColor,
contentColor = NiaNavigationDefaults.navigationContentColor(),
tonalElevation = 0.dp,
content = content
@ -155,7 +154,7 @@ fun NiaNavigationRail(
) {
NavigationRail(
modifier = modifier,
containerColor = NiaNavigationDefaults.NavigationContainerColor,
containerColor = Color.Transparent,
contentColor = NiaNavigationDefaults.navigationContentColor(),
header = header,
content = content
@ -166,7 +165,6 @@ fun NiaNavigationRail(
* Now in Android navigation default values.
*/
object NiaNavigationDefaults {
val NavigationContainerColor = Color.Transparent
@Composable
fun navigationContentColor() = MaterialTheme.colorScheme.onSurfaceVariant
@Composable

@ -72,7 +72,9 @@ fun NiaToggleButton(
onClick = { onCheckedChange(!checked) },
modifier = modifier
.size(size)
.toggleable(value = checked, enabled = enabled, role = Role.Button, onValueChange = {})
.toggleable(value = checked, enabled = enabled, role = Role.Button, onValueChange = {
onCheckedChange(!checked)
})
.drawBehind {
drawCircle(
color = if (checked) checkedBackgroundColor else backgroundColor,

@ -21,6 +21,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -33,6 +34,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NiaTopAppBar(
@StringRes titleRes: Int,
@ -70,6 +72,36 @@ fun NiaTopAppBar(
)
}
/**
* Top app bar with action, displayed on the right
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NiaTopAppBar(
@StringRes titleRes: Int,
actionIcon: ImageVector,
actionIconContentDescription: String?,
modifier: Modifier = Modifier,
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
onActionClick: () -> Unit = {}
) {
CenterAlignedTopAppBar(
title = { Text(text = stringResource(id = titleRes)) },
actions = {
IconButton(onClick = onActionClick) {
Icon(
imageVector = actionIcon,
contentDescription = actionIconContentDescription,
tint = MaterialTheme.colorScheme.onSurface
)
}
},
colors = colors,
modifier = modifier
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview("Top App Bar")
@Composable
fun NiaTopAppBarPreview() {

@ -0,0 +1,3 @@
# :core:model module
![Dependency graph](../../docs/images/graphs/dep_graph_core_model.png)

@ -17,7 +17,6 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("kotlin")
id("nowinandroid.spotless")
}
dependencies {

@ -30,7 +30,6 @@ import kotlinx.datetime.toInstant
*/
data class NewsResource(
val id: String,
val episodeId: String,
val title: String,
val content: String,
val url: String,
@ -44,7 +43,6 @@ data class NewsResource(
val previewNewsResources = listOf(
NewsResource(
id = "1",
episodeId = "60",
title = "Android Basics with Compose",
content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. Youll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Androids modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey",
url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
@ -64,7 +62,6 @@ val previewNewsResources = listOf(
),
NewsResource(
id = "2",
episodeId = "52",
title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " +
"Android Developers YouTube channel has to offer. During the Android Developer " +
@ -79,7 +76,6 @@ val previewNewsResources = listOf(
),
NewsResource(
id = "3",
episodeId = "52",
title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " +

@ -0,0 +1,3 @@
# :core:navigation module
![Dependency graph](../../docs/images/graphs/dep_graph_core_navigation.png)

@ -18,16 +18,14 @@
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
kotlin("kapt")
id("dagger.hilt.android.plugin")
alias(libs.plugins.ksp)
id("nowinandroid.spotless")
id("nowinandroid.android.hilt")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.navigation"
}
dependencies {
api(libs.androidx.hilt.navigation.compose)
api(libs.androidx.navigation.compose)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
}

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.navigation">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

@ -0,0 +1,3 @@
# :core:network module
![Dependency graph](../../docs/images/graphs/dep_graph_core_network.png)

@ -13,16 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
kotlin("kapt")
id("nowinandroid.android.hilt")
id("kotlinx-serialization")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
}
android {
buildFeatures {
buildConfig = true
}
namespace = "com.google.samples.apps.nowinandroid.core.network"
}
secrets {
defaultPropertiesFileName = "secrets.defaults.properties"
}
@ -40,7 +46,4 @@ dependencies {
implementation(libs.okhttp.logging)
implementation(libs.retrofit.core)
implementation(libs.retrofit.kotlin.serialization)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
}

@ -14,8 +14,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.network">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

@ -1,52 +0,0 @@
/*
* Copyright 2022 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.google.samples.apps.nowinandroid.core.network.model
import com.google.samples.apps.nowinandroid.core.model.data.Episode
import com.google.samples.apps.nowinandroid.core.network.model.util.InstantSerializer
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
/**
* Network representation of [Episode] when fetched from /episodes
*/
@Serializable
data class NetworkEpisode(
val id: String,
val name: String,
@Serializable(InstantSerializer::class)
val publishDate: Instant,
val alternateVideo: String?,
val alternateAudio: String?,
val newsResources: List<String> = listOf(),
val authors: List<String> = listOf(),
)
/**
* Network representation of [Episode] when fetched from /episodes/{id}
*/
@Serializable
data class NetworkEpisodeExpanded(
val id: String,
val name: String,
@Serializable(InstantSerializer::class)
val publishDate: Instant,
val alternateVideo: String,
val alternateAudio: String,
val newsResources: List<NetworkNewsResource> = listOf(),
val authors: List<NetworkAuthor> = listOf(),
)

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save