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! 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: 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 - [ ] 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) - [ ] Appropriate docs were updated (if necessary)
Is this your first Pull Request? Is this your first Pull Request?

@ -34,10 +34,10 @@ jobs:
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v2
- name: Check spotless - name: Check spotless
run: ./gradlew spotlessCheck --stacktrace run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache --stacktrace
- name: Check lint - name: Check lint
run: ./gradlew lintDebug --stacktrace run: ./gradlew lintDemoDebug --stacktrace
- name: Build all build type and flavor permutations - name: Build all build type and flavor permutations
run: ./gradlew assemble --stacktrace run: ./gradlew assemble --stacktrace
@ -61,7 +61,7 @@ jobs:
androidTest: androidTest:
needs: build needs: build
runs-on: macOS-latest # enables hardware acceleration in the virtual machine runs-on: macOS-latest # enables hardware acceleration in the virtual machine
timeout-minutes: 45 timeout-minutes: 55
strategy: strategy:
matrix: matrix:
api-level: [23, 26, 30] 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 { plugins {
id("nowinandroid.android.application") id("nowinandroid.android.application")
id("nowinandroid.android.application.compose") id("nowinandroid.android.application.compose")
id("nowinandroid.spotless")
} }
android { android {
@ -33,6 +32,7 @@ android {
excludes.add("/META-INF/{AL2.0,LGPL2.1}") excludes.add("/META-INF/{AL2.0,LGPL2.1}")
} }
} }
namespace = "com.google.samples.apps.niacatalog"
} }
dependencies { dependencies {

@ -14,8 +14,7 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.niacatalog">
<application <application
android:allowBackup="true" 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 * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import com.google.samples.apps.nowinandroid.Flavor
import com.google.samples.apps.nowinandroid.FlavorDimension
plugins { plugins {
id("nowinandroid.android.application") id("nowinandroid.android.application")
id("nowinandroid.android.application.compose") id("nowinandroid.android.application.compose")
id("nowinandroid.android.application.jacoco") id("nowinandroid.android.application.jacoco")
kotlin("kapt") id("nowinandroid.android.hilt")
id("jacoco") id("jacoco")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
id("nowinandroid.firebase-perf") 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 { packagingOptions {
resources { resources {
excludes.add("/META-INF/{AL2.0,LGPL2.1}") excludes.add("/META-INF/{AL2.0,LGPL2.1}")
@ -90,6 +73,7 @@ android {
isIncludeAndroidResources = true isIncludeAndroidResources = true
} }
} }
namespace = "com.google.samples.apps.nowinandroid"
} }
dependencies { dependencies {
@ -103,7 +87,8 @@ dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:navigation")) implementation(project(":core:navigation"))
implementation(project(":sync")) implementation(project(":sync:work"))
implementation(project(":sync:sync-test"))
androidTestImplementation(project(":core:testing")) androidTestImplementation(project(":core:testing"))
androidTestImplementation(project(":core:datastore-test")) androidTestImplementation(project(":core:datastore-test"))
@ -124,17 +109,13 @@ dependencies {
implementation(libs.coil.kt) implementation(libs.coil.kt)
implementation(libs.coil.kt.svg) implementation(libs.coil.kt.svg)
}
implementation(libs.hilt.android) // androidx.test is forcing JUnit, 4.12. This forces it to use 4.13
kapt(libs.hilt.compiler) configurations.configureEach {
kaptAndroidTest(libs.hilt.compiler) resolutionStrategy {
force(libs.junit4)
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13 // Temporary workaround for https://issuetracker.google.com/174733673
configurations.configureEach { force("org.objenesis:objenesis:2.6")
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.Espresso
import androidx.test.espresso.NoActivityResumedException import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity 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.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
@ -70,11 +71,11 @@ class NavigationTest {
@Before @Before
fun setup() { fun setup() {
composeTestRule.activity.apply { composeTestRule.activity.apply {
done = getString(R.string.done) done = getString(FeatureForyouR.string.done)
navigateUp = getString(R.string.navigate_up) navigateUp = getString(FeatureForyouR.string.navigate_up)
forYouLoading = getString(R.string.for_you_loading) forYouLoading = getString(FeatureForyouR.string.for_you_loading)
forYou = getString(R.string.for_you) forYou = getString(FeatureForyouR.string.for_you)
interests = getString(R.string.interests) interests = getString(FeatureInterestsR.string.interests)
sampleTopic = "Headlines" sampleTopic = "Headlines"
} }
} }

@ -15,8 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools">
package="com.google.samples.apps.nowinandroid">
<uses-permission android:name="android.permission.INTERNET" /> <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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -45,6 +44,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy 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.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.NiaNavigationBar
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail 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.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon 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.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.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -66,13 +67,20 @@ fun NiaApp(
appState: NiaAppState = rememberNiaAppState(windowSizeClass) appState: NiaAppState = rememberNiaAppState(windowSizeClass)
) { ) {
NiaTheme { 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( Scaffold(
modifier = Modifier.semantics { modifier = Modifier.semantics {
testTagsAsResourceId = true testTagsAsResourceId = true
}, },
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = { bottomBar = {
if (appState.shouldShowBottomBar) { if (appState.shouldShowBottomBar) {
NiaBottomBar( NiaBottomBar(
@ -158,42 +166,32 @@ private fun NiaBottomBar(
onNavigateToDestination: (TopLevelDestination) -> Unit, onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination? currentDestination: NavDestination?
) { ) {
// Wrap the navigation bar in a surface so the color behind the system NiaNavigationBar {
// navigation is equal to the container color of the navigation bar. destinations.forEach { destination ->
Surface(color = MaterialTheme.colorScheme.surface) { val selected =
NiaNavigationBar( currentDestination?.hierarchy?.any { it.route == destination.route } == true
modifier = Modifier.windowInsetsPadding( NiaNavigationBarItem(
WindowInsets.safeDrawing.only( selected = selected,
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom onClick = { onNavigateToDestination(destination) },
) icon = {
val icon = if (selected) {
destination.selectedIcon
} else {
destination.unselectedIcon
}
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
)
is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id),
contentDescription = null
)
}
},
label = { Text(stringResource(destination.iconTextId)) }
) )
) {
destinations.forEach { destination ->
val selected =
currentDestination?.hierarchy?.any { it.route == destination.route } == true
NiaNavigationBarItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
icon = {
val icon = if (selected) {
destination.selectedIcon
} else {
destination.unselectedIcon
}
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
)
is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id),
contentDescription = null
)
}
},
label = { Text(stringResource(destination.iconTextId)) }
)
}
} }
} }
} }

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

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

@ -29,8 +29,6 @@ setup.
Current list of convention plugins: 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.application`](convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt),
[`nowinandroid.android.library`](convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt), [`nowinandroid.android.library`](convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt),
[`nowinandroid.android.test`](convention/src/main/kotlin/AndroidTestConventionPlugin.kt): [`nowinandroid.android.test`](convention/src/main/kotlin/AndroidTestConventionPlugin.kt):

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

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

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

@ -26,9 +26,8 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
pluginManager.apply { pluginManager.apply {
apply("com.android.library") apply("nowinandroid.android.library")
apply("org.jetbrains.kotlin.android") apply("nowinandroid.android.hilt")
apply("org.jetbrains.kotlin.kapt")
} }
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
defaultConfig { defaultConfig {
@ -58,9 +57,6 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get()) add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("implementation", libs.findLibrary("kotlinx.coroutines.android").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. * 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.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.artifacts.VersionCatalogsExtension
@ -37,18 +40,17 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
defaultConfig.targetSdk = 33 defaultConfig.targetSdk = 33
configureFlavors(this) configureFlavors(this)
} }
extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this)
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs") val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies { configurations.configureEach {
configurations.configureEach { resolutionStrategy {
resolutionStrategy { force(libs.findLibrary("junit4").get())
force(libs.findLibrary("junit4").get()) // Temporary workaround for https://issuetracker.google.com/174733673
// Temporary workaround for https://issuetracker.google.com/174733673 force("org.objenesis:objenesis:2.6")
force("org.objenesis:objenesis:2.6")
}
} }
} }
} }
} }
}
}

@ -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 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 com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project import org.gradle.api.Project
@ -24,6 +27,11 @@ fun Project.configureFlavors(
Flavor.values().forEach{ Flavor.values().forEach{
create(it.name) { create(it.name) {
dimension = it.dimension.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.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlin.Experimental", "-opt-in=kotlin.Experimental",
// Enable experimental kotlinx serialization APIs
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
) )
// Set JVM target to 1.8 // 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. * limitations under the License.
*/ */
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
dependencyResolutionManagement { dependencyResolutionManagement {
repositories { repositories {
google() google()
@ -28,4 +26,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "build-logic"
include(":convention") include(":convention")

@ -31,5 +31,4 @@ plugins {
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false
alias(libs.plugins.secrets) 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 { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
kotlin("kapt") id("nowinandroid.android.hilt")
id("nowinandroid.spotless") }
android {
namespace = "com.google.samples.apps.nowinandroid.core.common"
} }
dependencies { dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
testImplementation(project(":core:testing")) testImplementation(project(":core:testing"))
} }

@ -14,7 +14,6 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.nowinandroid.core.common">
</manifest> </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 { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
kotlin("kapt") id("nowinandroid.android.hilt")
id("dagger.hilt.android.plugin") }
id("nowinandroid.spotless")
android {
namespace = "com.google.samples.apps.nowinandroid.core.data.test"
} }
dependencies { dependencies {
api(project(":core:data")) api(project(":core:data"))
implementation(project(":core:testing")) 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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.nowinandroid.core.data.test">
</manifest> </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.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository 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.repository.fake.FakeUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
@ -55,4 +56,9 @@ interface TestDataModule {
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository userDataRepository: FakeUserDataRepository
): UserDataRepository ): 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 { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
kotlin("kapt") id("nowinandroid.android.hilt")
id("kotlinx-serialization") id("kotlinx-serialization")
id("dagger.hilt.android.plugin") }
id("nowinandroid.spotless")
android {
namespace = "com.google.samples.apps.nowinandroid.core.data"
} }
dependencies { dependencies {
@ -32,10 +34,9 @@ dependencies {
testImplementation(project(":core:testing")) testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test")) testImplementation(project(":core:datastore-test"))
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json) 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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.nowinandroid.core.data"> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest> </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.OfflineFirstUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository 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.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.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -52,4 +54,9 @@ interface DataModule {
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository userDataRepository: OfflineFirstUserDataRepository
): UserDataRepository ): 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 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.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.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef 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( fun NetworkNewsResource.asEntity() = NewsResourceEntity(
id = id, id = id,
episodeId = episodeId,
title = title, title = title,
content = content, content = content,
url = url, url = url,
@ -38,7 +36,6 @@ fun NetworkNewsResource.asEntity() = NewsResourceEntity(
fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity( fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
id = id, id = id,
episodeId = episodeId,
title = title, title = title,
content = content, content = content,
url = url, url = url,
@ -47,18 +44,6 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
type = type, 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 shell [AuthorEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB * 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.asEntity
import com.google.samples.apps.nowinandroid.core.data.model.authorCrossReferences 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.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.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells 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.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.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao 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.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.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@ -47,7 +44,6 @@ import kotlinx.coroutines.flow.map
*/ */
class OfflineFirstNewsRepository @Inject constructor( class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao, private val newsResourceDao: NewsResourceDao,
private val episodeDao: EpisodeDao,
private val authorDao: AuthorDao, private val authorDao: AuthorDao,
private val topicDao: TopicDao, private val topicDao: TopicDao,
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
@ -93,11 +89,6 @@ class OfflineFirstNewsRepository @Inject constructor(
.flatten() .flatten()
.distinctBy(AuthorEntity::id) .distinctBy(AuthorEntity::id)
) )
episodeDao.insertOrIgnoreEpisodes(
episodeEntities = networkNewsResources
.map(NetworkNewsResource::episodeEntityShell)
.distinctBy(EpisodeEntity::id)
)
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity) .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. * 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( interface NetworkMonitor {
val id: String, val isOnline: Flow<Boolean>
val name: String, }
val publishDate: Instant,
val alternateVideo: String?,
val alternateAudio: String?,
val newsResources: List<NewsResource>,
val authors: List<Author>
)

@ -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.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor 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.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -71,7 +69,6 @@ class NetworkEntityKtTest {
val networkModel = val networkModel =
NetworkNewsResource( NetworkNewsResource(
id = "0", id = "0",
episodeId = "2",
title = "title", title = "title",
content = "content", content = "content",
url = "url", url = "url",
@ -82,7 +79,6 @@ class NetworkEntityKtTest {
val entity = networkModel.asEntity() val entity = networkModel.asEntity()
assertEquals("0", entity.id) assertEquals("0", entity.id)
assertEquals("2", entity.episodeId)
assertEquals("title", entity.title) assertEquals("title", entity.title)
assertEquals("content", entity.content) assertEquals("content", entity.content)
assertEquals("url", entity.url) assertEquals("url", entity.url)
@ -93,7 +89,6 @@ class NetworkEntityKtTest {
val expandedNetworkModel = val expandedNetworkModel =
NetworkNewsResourceExpanded( NetworkNewsResourceExpanded(
id = "0", id = "0",
episodeId = "2",
title = "title", title = "title",
content = "content", content = "content",
url = "url", url = "url",
@ -105,7 +100,6 @@ class NetworkEntityKtTest {
val entityFromExpanded = expandedNetworkModel.asEntity() val entityFromExpanded = expandedNetworkModel.asEntity()
assertEquals("0", entityFromExpanded.id) assertEquals("0", entityFromExpanded.id)
assertEquals("2", entityFromExpanded.episodeId)
assertEquals("title", entityFromExpanded.title) assertEquals("title", entityFromExpanded.title)
assertEquals("content", entityFromExpanded.content) assertEquals("content", entityFromExpanded.content)
assertEquals("url", entityFromExpanded.url) assertEquals("url", entityFromExpanded.url)
@ -113,39 +107,4 @@ class NetworkEntityKtTest {
assertEquals(Instant.fromEpochMilliseconds(1), entityFromExpanded.publishDate) assertEquals(Instant.fromEpochMilliseconds(1), entityFromExpanded.publishDate)
assertEquals(Article, entityFromExpanded.type) 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.asEntity
import com.google.samples.apps.nowinandroid.core.data.model.authorCrossReferences 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.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.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells 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.CollectionType
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestAuthorDao 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.TestNewsResourceDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource 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.TestTopicDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds 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.data.testdoubles.nonPresentInterestsIds
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity 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.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.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@ -57,8 +53,6 @@ class OfflineFirstNewsRepositoryTest {
private lateinit var newsResourceDao: TestNewsResourceDao private lateinit var newsResourceDao: TestNewsResourceDao
private lateinit var episodeDao: TestEpisodeDao
private lateinit var authorDao: TestAuthorDao private lateinit var authorDao: TestAuthorDao
private lateinit var topicDao: TestTopicDao private lateinit var topicDao: TestTopicDao
@ -73,7 +67,6 @@ class OfflineFirstNewsRepositoryTest {
@Before @Before
fun setup() { fun setup() {
newsResourceDao = TestNewsResourceDao() newsResourceDao = TestNewsResourceDao()
episodeDao = TestEpisodeDao()
authorDao = TestAuthorDao() authorDao = TestAuthorDao()
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
@ -85,7 +78,6 @@ class OfflineFirstNewsRepositoryTest {
subject = OfflineFirstNewsRepository( subject = OfflineFirstNewsRepository(
newsResourceDao = newsResourceDao, newsResourceDao = newsResourceDao,
episodeDao = episodeDao,
authorDao = authorDao, authorDao = authorDao,
topicDao = topicDao, topicDao = topicDao,
network = network, 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 @Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =
runTest { runTest {

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

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

@ -62,6 +62,10 @@ class TestTopicDao : TopicDao {
throw NotImplementedError("Unused in tests") throw NotImplementedError("Unused in tests")
} }
override suspend fun upsertTopics(entities: List<TopicEntity>) {
entitiesStateFlow.value = entities
}
override suspend fun deleteTopics(ids: List<String>) { override suspend fun deleteTopics(ids: List<String>) {
val idSet = ids.toSet() val idSet = ids.toSet()
entitiesStateFlow.update { entities -> 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( val populatedNewsResource = PopulatedNewsResource(
entity = NewsResourceEntity( entity = NewsResourceEntity(
id = "1", id = "1",
episodeId = "0",
title = "news", title = "news",
content = "Hilt", content = "Hilt",
url = "url", url = "url",
@ -38,13 +37,6 @@ class PopulatedNewsResourceKtTest {
type = Video, type = Video,
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
), ),
episode = EpisodeEntity(
id = "4",
name = "episode 4",
publishDate = Instant.fromEpochMilliseconds(2),
alternateAudio = "audio",
alternateVideo = "video",
),
authors = listOf( authors = listOf(
AuthorEntity( AuthorEntity(
id = "2", id = "2",
@ -71,7 +63,6 @@ class PopulatedNewsResourceKtTest {
assertEquals( assertEquals(
NewsResource( NewsResource(
id = "1", id = "1",
episodeId = "0",
title = "news", title = "news",
content = "Hilt", content = "Hilt",
url = "url", url = "url",

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

@ -18,10 +18,8 @@
plugins { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
kotlin("kapt") id("nowinandroid.android.hilt")
id("dagger.hilt.android.plugin")
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("nowinandroid.spotless")
} }
android { android {
@ -35,6 +33,7 @@ android {
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
} }
namespace = "com.google.samples.apps.nowinandroid.core.database"
} }
dependencies { dependencies {
@ -47,8 +46,5 @@ dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
androidTestImplementation(project(":core:testing")) 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 androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase 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.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.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
@ -38,7 +37,6 @@ import org.junit.Test
class NewsResourceDaoTest { class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao private lateinit var newsResourceDao: NewsResourceDao
private lateinit var episodeDao: EpisodeDao
private lateinit var topicDao: TopicDao private lateinit var topicDao: TopicDao
private lateinit var authorDao: AuthorDao private lateinit var authorDao: AuthorDao
private lateinit var db: NiaDatabase private lateinit var db: NiaDatabase
@ -51,7 +49,6 @@ class NewsResourceDaoTest {
NiaDatabase::class.java NiaDatabase::class.java
).build() ).build()
newsResourceDao = db.newsResourceDao() newsResourceDao = db.newsResourceDao()
episodeDao = db.episodeDao()
topicDao = db.topicDao() topicDao = db.topicDao()
authorDao = db.authorDao() authorDao = db.authorDao()
} }
@ -76,13 +73,6 @@ class NewsResourceDaoTest {
millisSinceEpoch = 2, millisSinceEpoch = 2,
), ),
) )
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
episodeDao.insertOrIgnoreEpisodes(
episodeEntityShells
)
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities newsResourceEntities
) )
@ -128,9 +118,6 @@ class NewsResourceDaoTest {
millisSinceEpoch = 2, millisSinceEpoch = 2,
), ),
) )
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity -> val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef( NewsResourceTopicCrossRef(
newsResourceId = index.toString(), newsResourceId = index.toString(),
@ -141,9 +128,6 @@ class NewsResourceDaoTest {
topicDao.insertOrIgnoreTopics( topicDao.insertOrIgnoreTopics(
topicEntities = topicEntities topicEntities = topicEntities
) )
episodeDao.insertOrIgnoreEpisodes(
episodeEntities = episodeEntityShells
)
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities newsResourceEntities
) )
@ -193,9 +177,6 @@ class NewsResourceDaoTest {
millisSinceEpoch = 2, millisSinceEpoch = 2,
), ),
) )
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
val newsResourceAuthorCrossRefEntities = authorEntities.mapIndexed { index, authorEntity -> val newsResourceAuthorCrossRefEntities = authorEntities.mapIndexed { index, authorEntity ->
NewsResourceAuthorCrossRef( NewsResourceAuthorCrossRef(
newsResourceId = index.toString(), newsResourceId = index.toString(),
@ -204,7 +185,6 @@ class NewsResourceDaoTest {
} }
authorDao.upsertAuthors(authorEntities) authorDao.upsertAuthors(authorEntities)
episodeDao.upsertEpisodes(episodeEntityShells)
newsResourceDao.upsertNewsResources(newsResourceEntities) newsResourceDao.upsertNewsResources(newsResourceEntities)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities) newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities)
@ -266,9 +246,7 @@ class NewsResourceDaoTest {
millisSinceEpoch = 10, millisSinceEpoch = 10,
), ),
) )
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity -> val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef( NewsResourceTopicCrossRef(
newsResourceId = index.toString(), newsResourceId = index.toString(),
@ -286,7 +264,6 @@ class NewsResourceDaoTest {
topicDao.upsertTopics(topicEntities) topicDao.upsertTopics(topicEntities)
authorDao.upsertAuthors(authorEntities) authorDao.upsertAuthors(authorEntities)
episodeDao.upsertEpisodes(episodeEntityShells)
newsResourceDao.upsertNewsResources(newsResourceEntities) newsResourceDao.upsertNewsResources(newsResourceEntities)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(newsResourceTopicCrossRefEntities) newsResourceDao.insertOrIgnoreTopicCrossRefEntities(newsResourceTopicCrossRefEntities)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities) newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(newsResourceAuthorCrossRefEntities)
@ -327,11 +304,6 @@ class NewsResourceDaoTest {
millisSinceEpoch = 2, millisSinceEpoch = 2,
), ),
) )
val episodeEntityShells = newsResourceEntities
.map(NewsResourceEntity::episodeEntityShell)
.distinct()
episodeDao.upsertEpisodes(episodeEntityShells)
newsResourceDao.upsertNewsResources(newsResourceEntities) newsResourceDao.upsertNewsResources(newsResourceEntities)
val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 } val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 }
@ -379,7 +351,6 @@ private fun testNewsResource(
millisSinceEpoch: Long = 0 millisSinceEpoch: Long = 0
) = NewsResourceEntity( ) = NewsResourceEntity(
id = id, id = id,
episodeId = "0",
title = "", title = "",
content = "", content = "",
url = "", url = "",
@ -387,11 +358,3 @@ private fun testNewsResource(
publishDate = Instant.fromEpochMilliseconds(millisSinceEpoch), publishDate = Instant.fromEpochMilliseconds(millisSinceEpoch),
type = NewsResourceType.DAC, 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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.nowinandroid.core.database">
</manifest> </manifest>

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

@ -16,6 +16,8 @@
package com.google.samples.apps.nowinandroid.core.database package com.google.samples.apps.nowinandroid.core.database
import androidx.room.DeleteColumn
import androidx.room.DeleteTable
import androidx.room.RenameColumn import androidx.room.RenameColumn
import androidx.room.migration.AutoMigrationSpec 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 * from and Y is the schema version you're migrating to. The class should implement
* `AutoMigrationSpec`. * `AutoMigrationSpec`.
*/ */
class DatabaseMigrations { object DatabaseMigrations {
@RenameColumn( @RenameColumn(
tableName = "topics", tableName = "topics",
@ -34,4 +36,18 @@ class DatabaseMigrations {
toColumnName = "shortDescription" toColumnName = "shortDescription"
) )
class Schema2to3 : AutoMigrationSpec 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.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao 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.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao 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.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.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef 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( @Database(
entities = [ entities = [
AuthorEntity::class, AuthorEntity::class,
EpisodeAuthorCrossRef::class,
EpisodeEntity::class,
NewsResourceAuthorCrossRef::class, NewsResourceAuthorCrossRef::class,
NewsResourceEntity::class, NewsResourceEntity::class,
NewsResourceTopicCrossRef::class, NewsResourceTopicCrossRef::class,
TopicEntity::class, TopicEntity::class,
], ],
version = 10, version = 11,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class), 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 = 7, to = 8),
AutoMigration(from = 8, to = 9), AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10), AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class)
], ],
exportSchema = true, exportSchema = true,
) )
@ -65,6 +61,5 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
abstract class NiaDatabase : RoomDatabase() { abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao abstract fun topicDao(): TopicDao
abstract fun authorDao(): AuthorDao abstract fun authorDao(): AuthorDao
abstract fun episodeDao(): EpisodeDao
abstract fun newsResourceDao(): NewsResourceDao abstract fun newsResourceDao(): NewsResourceDao
} }

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

@ -20,8 +20,8 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update import androidx.room.Update
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -64,12 +64,8 @@ interface TopicDao {
/** /**
* Inserts or updates [entities] in the db under the specified primary keys * Inserts or updates [entities] in the db under the specified primary keys
*/ */
@Transaction @Upsert
suspend fun upsertTopics(entities: List<TopicEntity>) = upsert( suspend fun upsertTopics(entities: List<TopicEntity>)
items = entities,
insertMany = ::insertOrIgnoreTopics,
updateMany = ::updateTopics
)
/** /**
* Deletes rows in the db matching the specified [ids] * 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 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 * It has a many to many relationship with both entities
*/ */
@Entity( @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.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
@ -27,27 +25,13 @@ import kotlinx.datetime.Instant
/** /**
* Defines an NiA news resource. * Defines an NiA news resource.
* It is the child in a 1 to many relationship with [EpisodeEntity]
*/ */
@Entity( @Entity(
tableName = "news_resources", tableName = "news_resources"
foreignKeys = [
ForeignKey(
entity = EpisodeEntity::class,
parentColumns = ["id"],
childColumns = ["episode_id"],
onDelete = ForeignKey.CASCADE
),
],
indices = [
Index(value = ["episode_id"])
]
) )
data class NewsResourceEntity( data class NewsResourceEntity(
@PrimaryKey @PrimaryKey
val id: String, val id: String,
@ColumnInfo(name = "episode_id")
val episodeId: String,
val title: String, val title: String,
val content: String, val content: String,
val url: String, val url: String,
@ -60,7 +44,6 @@ data class NewsResourceEntity(
fun NewsResourceEntity.asExternalModel() = NewsResource( fun NewsResourceEntity.asExternalModel() = NewsResource(
id = id, id = id,
episodeId = episodeId,
title = title, title = title,
content = content, content = content,
url = url, 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( data class PopulatedNewsResource(
@Embedded @Embedded
val entity: NewsResourceEntity, val entity: NewsResourceEntity,
@Relation(
parentColumn = "episode_id",
entityColumn = "id"
)
val episode: EpisodeEntity,
@Relation( @Relation(
parentColumn = "id", parentColumn = "id",
entityColumn = "id", entityColumn = "id",
@ -56,7 +51,6 @@ data class PopulatedNewsResource(
fun PopulatedNewsResource.asExternalModel() = NewsResource( fun PopulatedNewsResource.asExternalModel() = NewsResource(
id = entity.id, id = entity.id,
episodeId = entity.episodeId,
title = entity.title, title = entity.title,
content = entity.content, content = entity.content,
url = entity.url, 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 { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
kotlin("kapt") id("nowinandroid.android.hilt")
id("dagger.hilt.android.plugin") }
id("nowinandroid.spotless")
android {
namespace = "com.google.samples.apps.nowinandroid.core.datastore.test"
} }
dependencies { dependencies {
@ -25,8 +27,4 @@ dependencies {
implementation(project(":core:testing")) implementation(project(":core:testing"))
api(libs.androidx.dataStore.core) 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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.nowinandroid.core.datastore.test">
</manifest> </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 { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
kotlin("kapt") id("nowinandroid.android.hilt")
id("dagger.hilt.android.plugin")
alias(libs.plugins.protobuf) alias(libs.plugins.protobuf)
id("nowinandroid.spotless")
} }
android { android {
defaultConfig { defaultConfig {
consumerProguardFiles("consumer-proguard-rules.pro") consumerProguardFiles("consumer-proguard-rules.pro")
} }
namespace = "com.google.samples.apps.nowinandroid.core.datastore"
} }
// Setup protobuf configuration, generating lite Java and Kotlin classes // Setup protobuf configuration, generating lite Java and Kotlin classes
@ -65,13 +64,4 @@ dependencies {
implementation(libs.androidx.dataStore.core) implementation(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite) 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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.nowinandroid.core.datastore">
</manifest> </manifest>

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

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

@ -24,7 +24,6 @@ message UserPreferences {
repeated int32 deprecated_int_followed_topic_ids = 1; repeated int32 deprecated_int_followed_topic_ids = 1;
int32 topicChangeListVersion = 3; int32 topicChangeListVersion = 3;
int32 authorChangeListVersion = 4; int32 authorChangeListVersion = 4;
int32 episodeChangeListVersion = 5;
int32 newsResourceChangeListVersion = 6; int32 newsResourceChangeListVersion = 6;
repeated int32 deprecated_int_followed_author_ids = 7; repeated int32 deprecated_int_followed_author_ids = 7;
bool has_done_int_to_string_id_migration = 8; 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")
id("nowinandroid.android.library.compose") id("nowinandroid.android.library.compose")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
id("nowinandroid.spotless")
} }
android { android {
@ -27,6 +26,7 @@ android {
lint { lint {
checkDependencies = true checkDependencies = true
} }
namespace = "com.google.samples.apps.nowinandroid.core.designsystem"
} }
dependencies { dependencies {

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

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

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

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component package com.google.samples.apps.nowinandroid.core.designsystem.component
import android.content.res.Configuration
import androidx.compose.animation.animateColor import androidx.compose.animation.animateColor
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing 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.Canvas
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable 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.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -125,14 +124,25 @@ fun NiaLoadingWheel(
} }
} }
@Preview( @Composable
name = "Loading Wheel Light Preview", fun NiaOverlayLoadingWheel(
uiMode = Configuration.UI_MODE_NIGHT_NO, contentDesc: String,
) modifier: Modifier = Modifier
@Preview( ) {
name = "Loading Wheel Dark Preview", Surface(
uiMode = Configuration.UI_MODE_NIGHT_YES, 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 @Composable
fun NiaLoadingWheelPreview() { fun NiaLoadingWheelPreview() {
NiaTheme { 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 ROTATION_TIME = 12000
private const val NUM_OF_LINES = 12 private const val NUM_OF_LINES = 12

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

@ -72,7 +72,9 @@ fun NiaToggleButton(
onClick = { onCheckedChange(!checked) }, onClick = { onCheckedChange(!checked) },
modifier = modifier modifier = modifier
.size(size) .size(size)
.toggleable(value = checked, enabled = enabled, role = Role.Button, onValueChange = {}) .toggleable(value = checked, enabled = enabled, role = Role.Button, onValueChange = {
onCheckedChange(!checked)
})
.drawBehind { .drawBehind {
drawCircle( drawCircle(
color = if (checked) checkedBackgroundColor else backgroundColor, 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.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme 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.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NiaTopAppBar( fun NiaTopAppBar(
@StringRes titleRes: Int, @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") @Preview("Top App Bar")
@Composable @Composable
fun NiaTopAppBarPreview() { 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") @Suppress("DSL_SCOPE_VIOLATION")
plugins { plugins {
id("kotlin") id("kotlin")
id("nowinandroid.spotless")
} }
dependencies { dependencies {

@ -30,7 +30,6 @@ import kotlinx.datetime.toInstant
*/ */
data class NewsResource( data class NewsResource(
val id: String, val id: String,
val episodeId: String,
val title: String, val title: String,
val content: String, val content: String,
val url: String, val url: String,
@ -44,7 +43,6 @@ data class NewsResource(
val previewNewsResources = listOf( val previewNewsResources = listOf(
NewsResource( NewsResource(
id = "1", id = "1",
episodeId = "60",
title = "Android Basics with Compose", 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", 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", url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html",
@ -64,7 +62,6 @@ val previewNewsResources = listOf(
), ),
NewsResource( NewsResource(
id = "2", id = "2",
episodeId = "52",
title = "Thanks for helping us reach 1M YouTube Subscribers", title = "Thanks for helping us reach 1M YouTube Subscribers",
content = "Thank you everyone for following the Now in Android series and everything the " + 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 " + "Android Developers YouTube channel has to offer. During the Android Developer " +
@ -79,7 +76,6 @@ val previewNewsResources = listOf(
), ),
NewsResource( NewsResource(
id = "3", id = "3",
episodeId = "52",
title = "Transformations and customisations in the Paging Library", title = "Transformations and customisations in the Paging Library",
content = "A demonstration of different operations that can be performed " + content = "A demonstration of different operations that can be performed " +
"with Paging. Transformations like inserting separators, when to " + "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 { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
kotlin("kapt") id("nowinandroid.android.hilt")
id("dagger.hilt.android.plugin") }
alias(libs.plugins.ksp)
id("nowinandroid.spotless") android {
namespace = "com.google.samples.apps.nowinandroid.core.navigation"
} }
dependencies { dependencies {
api(libs.androidx.hilt.navigation.compose) api(libs.androidx.hilt.navigation.compose)
api(libs.androidx.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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.nowinandroid.core.navigation">
</manifest> </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 * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
kotlin("kapt") id("nowinandroid.android.hilt")
id("kotlinx-serialization") id("kotlinx-serialization")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
} }
android {
buildFeatures {
buildConfig = true
}
namespace = "com.google.samples.apps.nowinandroid.core.network"
}
secrets { secrets {
defaultPropertiesFileName = "secrets.defaults.properties" defaultPropertiesFileName = "secrets.defaults.properties"
} }
@ -40,7 +46,4 @@ dependencies {
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.retrofit.core) implementation(libs.retrofit.core)
implementation(libs.retrofit.kotlin.serialization) 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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
package="com.google.samples.apps.nowinandroid.core.network">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
</manifest> </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