Merge branch 'main' into jvmToolchain

pull/583/head
Simon Marquis 2 years ago committed by GitHub
commit ddabba8e4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -10,29 +10,29 @@ jobs:
android-ci: android-ci:
runs-on: macos-12 runs-on: macos-12
strategy:
matrix:
device-config: [ "pixel4api30aospatd", "pixelcapi30aospatd" ]
steps: steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3 - uses: actions/setup-java@v3
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '11' java-version: 17
- uses: actions/checkout@v3 - uses: gradle/gradle-build-action@v2
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v2 uses: android-actions/setup-android@v2
- name: Build AndroidTest apps
run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest
- name: Run instrumented tests with GMD - name: Run instrumented tests with GMD
run: ./gradlew cleanManagedDevices --unused-only && run: ./gradlew cleanManagedDevices --unused-only &&
./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 ./gradlew ciDemoDebugAndroidTest -Dorg.gradle.workers.max=1
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true --info -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
- name: Upload test reports - name: Upload test reports
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: test-reports name: test-reports
path: | path: '**/build/reports/androidTests'
'**/*/build/reports/androidTests/'

@ -24,11 +24,11 @@ jobs:
- name: Copy CI gradle.properties - name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 11 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: 11 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v2
@ -48,15 +48,22 @@ jobs:
- name: Upload build outputs (APKs) - name: Upload build outputs (APKs)
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: build-outputs name: APKs
path: app/build/outputs path: '**/build/outputs/apk/**/*.apk'
- name: Upload build reports - name: Upload lint reports (HTML)
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: build-reports name: lint-reports
path: app/build/reports path: '**/build/reports/lint-results-*.html'
- name: Upload test results (XML)
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: '**/build/test-results/test*UnitTest/**.xml'
androidTest: androidTest:
needs: build needs: build
@ -73,15 +80,18 @@ jobs:
- name: Copy CI gradle.properties - name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 11 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: 11 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v2
- name: Build AndroidTest apps
run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest --daemon
- name: Run instrumentation tests - name: Run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
with: with:
@ -90,11 +100,11 @@ jobs:
disable-animations: true disable-animations: true
disk-size: 6000M disk-size: 6000M
heap-size: 600M heap-size: 600M
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest --daemon
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: test-reports-${{ matrix.api-level }} name: test-reports-${{ matrix.api-level }}
path: '*/build/reports/androidTests' path: '**/build/reports/androidTests'

@ -20,11 +20,11 @@ jobs:
- name: Copy CI gradle.properties - name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 11 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: 11 java-version: 17
- name: Build app - name: Build app
run: ./gradlew :app:assembleDemoRelease run: ./gradlew :app:assembleDemoRelease

@ -0,0 +1,2 @@
# This file can be used to trigger an internal build by changing the number below
3

@ -47,7 +47,7 @@ android {
missingDimensionStrategy(FlavorDimension.contentType.name, NiaFlavor.demo.name) missingDimensionStrategy(FlavorDimension.contentType.name, NiaFlavor.demo.name)
} }
packagingOptions { packaging {
resources { resources {
excludes.add("/META-INF/{AL2.0,LGPL2.1}") excludes.add("/META-INF/{AL2.0,LGPL2.1}")
} }
@ -55,7 +55,7 @@ android {
namespace = "com.google.samples.apps.niacatalog" namespace = "com.google.samples.apps.niacatalog"
buildTypes { buildTypes {
val release by getting { release {
// To publish on the Play store a private signing key is required, but to allow anyone // To publish on the Play store a private signing key is required, but to allow anyone
// who clones the code to sign and run the release variant, use the debug signing key. // who clones the code to sign and run the release variant, use the debug signing key.
// TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
@ -65,9 +65,7 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:ui"))
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:ui"))
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.accompanist.flowlayout)
} }

@ -17,6 +17,8 @@
package com.google.samples.apps.niacatalog.ui package com.google.samples.apps.niacatalog.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
@ -36,7 +38,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
@ -54,6 +55,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
/** /**
* Now in Android component catalog. * Now in Android component catalog.
*/ */
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun NiaCatalog() { fun NiaCatalog() {
NiaTheme { NiaTheme {
@ -75,7 +77,7 @@ fun NiaCatalog() {
} }
item { Text("Buttons", Modifier.padding(top = 16.dp)) } item { Text("Buttons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
NiaButton(onClick = {}) { NiaButton(onClick = {}) {
Text(text = "Enabled") Text(text = "Enabled")
} }
@ -89,7 +91,7 @@ fun NiaCatalog() {
} }
item { Text("Disabled buttons", Modifier.padding(top = 16.dp)) } item { Text("Disabled buttons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
NiaButton( NiaButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
@ -112,7 +114,7 @@ fun NiaCatalog() {
} }
item { Text("Buttons with leading icons", Modifier.padding(top = 16.dp)) } item { Text("Buttons with leading icons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
NiaButton( NiaButton(
onClick = {}, onClick = {},
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
@ -138,7 +140,7 @@ fun NiaCatalog() {
} }
item { Text("Disabled buttons with leading icons", Modifier.padding(top = 16.dp)) } item { Text("Disabled buttons with leading icons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
NiaButton( NiaButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
@ -168,7 +170,7 @@ fun NiaCatalog() {
item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) } item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) }
item { Text("Chips", Modifier.padding(top = 16.dp)) } item { Text("Chips", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
var firstChecked by remember { mutableStateOf(false) } var firstChecked by remember { mutableStateOf(false) }
NiaFilterChip( NiaFilterChip(
selected = firstChecked, selected = firstChecked,
@ -197,7 +199,7 @@ fun NiaCatalog() {
} }
item { Text("Icon buttons", Modifier.padding(top = 16.dp)) } item { Text("Icon buttons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
var firstChecked by remember { mutableStateOf(false) } var firstChecked by remember { mutableStateOf(false) }
NiaIconToggleButton( NiaIconToggleButton(
checked = firstChecked, checked = firstChecked,
@ -270,7 +272,7 @@ fun NiaCatalog() {
} }
item { Text("View toggle", Modifier.padding(top = 16.dp)) } item { Text("View toggle", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
var firstExpanded by remember { mutableStateOf(false) } var firstExpanded by remember { mutableStateOf(false) }
NiaViewToggleButton( NiaViewToggleButton(
expanded = firstExpanded, expanded = firstExpanded,
@ -296,7 +298,7 @@ fun NiaCatalog() {
} }
item { Text("Tags", Modifier.padding(top = 16.dp)) } item { Text("Tags", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
NiaTopicTag( NiaTopicTag(
followed = true, followed = true,
onClick = {}, onClick = {},

@ -3,4 +3,16 @@
# Obsfuscation must be disabled for the build variant that generates Baseline Profile, otherwise # Obsfuscation must be disabled for the build variant that generates Baseline Profile, otherwise
# wrong symbols would be generated. The generated Baseline Profile will be properly applied when generated # wrong symbols would be generated. The generated Baseline Profile will be properly applied when generated
# without obfuscation and your app is being obfuscated. # without obfuscation and your app is being obfuscated.
-dontobfuscate -dontobfuscate
# Please add these rules to your existing keep rules in order to suppress warnings.
# This is generated automatically by the Android Gradle plugin.
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE

@ -14,22 +14,22 @@
* limitations under the License. * limitations under the License.
*/ */
import com.google.samples.apps.nowinandroid.NiaBuildType import com.google.samples.apps.nowinandroid.NiaBuildType
import com.android.build.api.dsl.ManagedVirtualDevice
plugins { plugins {
id("nowinandroid.android.application") id("nowinandroid.android.application")
id("nowinandroid.android.application.compose") id("nowinandroid.android.application.compose")
id("nowinandroid.android.application.flavors")
id("nowinandroid.android.application.jacoco") id("nowinandroid.android.application.jacoco")
id("nowinandroid.android.hilt") id("nowinandroid.android.hilt")
id("jacoco") id("jacoco")
id("nowinandroid.firebase-perf") id("nowinandroid.android.application.firebase")
} }
android { android {
defaultConfig { defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid" applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 4 versionCode = 5
versionName = "0.0.4" // X.Y.Z; X = Major, Y = minor, Z = Patch level versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph // Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
@ -39,7 +39,7 @@ android {
} }
buildTypes { buildTypes {
val debug by getting { debug {
applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix
} }
val release by getting { val release by getting {
@ -52,7 +52,7 @@ android {
// TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
val benchmark by creating { create("benchmark") {
// Enable all the optimizations from release build through initWith(release). // Enable all the optimizations from release build through initWith(release).
initWith(release) initWith(release)
matchingFallbacks.add("release") matchingFallbacks.add("release")
@ -65,7 +65,7 @@ android {
} }
} }
packagingOptions { packaging {
resources { resources {
excludes.add("/META-INF/{AL2.0,LGPL2.1}") excludes.add("/META-INF/{AL2.0,LGPL2.1}")
} }
@ -74,17 +74,6 @@ android {
unitTests { unitTests {
isIncludeAndroidResources = true isIncludeAndroidResources = true
} }
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
} }
namespace = "com.google.samples.apps.nowinandroid" namespace = "com.google.samples.apps.nowinandroid"
} }
@ -94,6 +83,7 @@ dependencies {
implementation(project(":feature:foryou")) implementation(project(":feature:foryou"))
implementation(project(":feature:bookmarks")) implementation(project(":feature:bookmarks"))
implementation(project(":feature:topic")) implementation(project(":feature:topic"))
implementation(project(":feature:search"))
implementation(project(":feature:settings")) implementation(project(":feature:settings"))
implementation(project(":core:common")) implementation(project(":core:common"))
@ -101,6 +91,7 @@ dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:analytics"))
implementation(project(":sync:work")) implementation(project(":sync:work"))
@ -129,7 +120,6 @@ dependencies {
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
implementation(libs.coil.kt) implementation(libs.coil.kt)
implementation(libs.coil.kt.svg)
} }
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13 // androidx.test is forcing JUnit, 4.12. This forces it to use 4.13

@ -0,0 +1,125 @@
{
"project_info": {
"project_number": "YourProjectId",
"project_id": "abc",
"storage_bucket": "abc"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "Your:App:Id",
"android_client_info": {
"package_name": "com.google.samples.apps.nowinandroid"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "Your:App:Id",
"android_client_info": {
"package_name": "com.google.samples.apps.nowinandroid.demo.debug"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "Your:App:Id",
"android_client_info": {
"package_name": "com.google.samples.apps.nowinandroid.demo.benchmark"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "Your:App:Id",
"android_client_info": {
"package_name": "com.google.samples.apps.nowinandroid.benchmark"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "Your:App:Id",
"android_client_info": {
"package_name": "com.google.samples.apps.nowinandroid.debug"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "Your:App:Id",
"android_client_info": {
"package_name": "com.google.samples.apps.nowinandroid.demo"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

@ -24,3 +24,13 @@
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. # @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault -keepattributes RuntimeVisibleAnnotations,AnnotationDefault
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE

@ -16,12 +16,14 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
@ -34,10 +36,10 @@ import com.google.samples.apps.nowinandroid.R
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
import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.properties.ReadOnlyProperty
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.interests.R as FeatureInterestsR import com.google.samples.apps.nowinandroid.feature.interests.R as FeatureInterestsR
@ -69,35 +71,19 @@ class NavigationTest {
@get:Rule(order = 2) @get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<MainActivity>() val composeTestRule = createAndroidComposeRule<MainActivity>()
// The strings used for matching in these tests private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
private lateinit var done: String ReadOnlyProperty<Any?, String> { _, _ -> activity.getString(resId) }
private lateinit var navigateUp: String
private lateinit var forYouLoading: String
private lateinit var forYou: String
private lateinit var interests: String
private lateinit var sampleTopic: String
private lateinit var appName: String
private lateinit var saved: String
private lateinit var settings: String
private lateinit var brand: String
private lateinit var ok: String
@Before // The strings used for matching in these tests
fun setup() { private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up)
composeTestRule.activity.apply { private val forYou by composeTestRule.stringResource(FeatureForyouR.string.for_you)
done = getString(FeatureForyouR.string.done) private val interests by composeTestRule.stringResource(FeatureInterestsR.string.interests)
navigateUp = getString(FeatureForyouR.string.navigate_up) private val sampleTopic = "Headlines"
forYouLoading = getString(FeatureForyouR.string.for_you_loading) private val appName by composeTestRule.stringResource(R.string.app_name)
forYou = getString(FeatureForyouR.string.for_you) private val saved by composeTestRule.stringResource(BookmarksR.string.saved)
interests = getString(FeatureInterestsR.string.interests) private val settings by composeTestRule.stringResource(SettingsR.string.top_app_bar_action_icon_description)
sampleTopic = "Headlines" private val brand by composeTestRule.stringResource(SettingsR.string.brand_android)
appName = getString(R.string.app_name) private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text)
saved = getString(BookmarksR.string.saved)
settings = getString(SettingsR.string.top_app_bar_action_icon_description)
brand = getString(SettingsR.string.brand_android)
ok = getString(SettingsR.string.dismiss_dialog_button_text)
}
}
@Test @Test
fun firstScreen_isForYou() { fun firstScreen_isForYou() {

@ -25,7 +25,10 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.testharness.TestHarness import com.google.accompanist.testharness.TestHarness
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
@ -63,6 +66,11 @@ class NavigationUiTest {
@get:Rule(order = 2) @get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>() val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = TestNewsRepository(),
userDataRepository = TestUserDataRepository(),
)
@Inject @Inject
lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor
@ -81,6 +89,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -100,6 +109,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -119,6 +129,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -138,6 +149,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -157,6 +169,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -176,6 +189,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -195,6 +209,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -214,6 +229,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
@ -233,6 +249,7 @@ class NavigationUiTest {
DpSize(maxWidth, maxHeight), DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }

@ -30,6 +30,9 @@ import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.createGraph import androidx.navigation.createGraph
import androidx.navigation.testing.TestNavHostController import androidx.navigation.testing.TestNavHostController
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -56,6 +59,9 @@ class NiaAppStateTest {
// Create the test dependencies. // Create the test dependencies.
private val networkMonitor = TestNetworkMonitor() private val networkMonitor = TestNetworkMonitor()
private val userNewsResourceRepository =
CompositeUserNewsResourceRepository(TestNewsRepository(), TestUserDataRepository())
// Subject under test. // Subject under test.
private lateinit var state: NiaAppState private lateinit var state: NiaAppState
@ -67,10 +73,11 @@ class NiaAppStateTest {
val navController = rememberTestNavController() val navController = rememberTestNavController()
state = remember(navController) { state = remember(navController) {
NiaAppState( NiaAppState(
windowSizeClass = getCompactWindowClass(),
navController = navController, navController = navController,
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -92,6 +99,7 @@ class NiaAppStateTest {
state = rememberNiaAppState( state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -105,10 +113,11 @@ class NiaAppStateTest {
fun niaAppState_showBottomBar_compact() = runTest { fun niaAppState_showBottomBar_compact() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = getCompactWindowClass(),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -120,10 +129,11 @@ class NiaAppStateTest {
fun niaAppState_showNavRail_medium() = runTest { fun niaAppState_showNavRail_medium() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -135,10 +145,11 @@ class NiaAppStateTest {
fun niaAppState_showNavRail_large() = runTest { fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -150,10 +161,11 @@ class NiaAppStateTest {
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
) )
} }

@ -19,6 +19,13 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!--
Firebase automatically adds the AD_ID permission, even though we don't use it. If you use this
permission you must declare how you're using it to Google Play, otherwise the app will be
rejected when publishing it. To avoid this we remove the permission entirely.
-->
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/>
<application <application
android:name=".NiaApplication" android:name=".NiaApplication"
android:allowBackup="true" android:allowBackup="true"
@ -39,6 +46,12 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Disable Firebase analytics by default. This setting is overwritten for the `prod`
flavor -->
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
<!-- Disable collection of AD_ID for all build variants -->
<meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
</application> </application>
</manifest> </manifest>

@ -24,6 +24,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -37,6 +38,9 @@ import androidx.metrics.performance.JankStats
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
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.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
@ -61,6 +65,12 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var analyticsHelper: AnalyticsHelper
@Inject
lateinit var userNewsResourceRepository: UserNewsResourceRepository
val viewModel: MainActivityViewModel by viewModels() val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -104,15 +114,18 @@ class MainActivity : ComponentActivity() {
onDispose {} onDispose {}
} }
NiaTheme( CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
darkTheme = darkTheme, NiaTheme(
androidTheme = shouldUseAndroidTheme(uiState), darkTheme = darkTheme,
disableDynamicTheming = shouldDisableDynamicTheming(uiState), androidTheme = shouldUseAndroidTheme(uiState),
) { disableDynamicTheming = shouldDisableDynamicTheming(uiState),
NiaApp( ) {
networkMonitor = networkMonitor, NiaApp(
windowSizeClass = calculateWindowSizeClass(this), networkMonitor = networkMonitor,
) windowSizeClass = calculateWindowSizeClass(this),
userNewsResourceRepository = userNewsResourceRepository,
)
}
} }
} }
} }

@ -19,33 +19,24 @@ package com.google.samples.apps.nowinandroid
import android.app.Application import android.app.Application
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.decode.SvgDecoder
import com.google.samples.apps.nowinandroid.sync.initializers.Sync import com.google.samples.apps.nowinandroid.sync.initializers.Sync
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import javax.inject.Provider
/** /**
* [Application] class for NiA * [Application] class for NiA
*/ */
@HiltAndroidApp @HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory { class NiaApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var imageLoader: Provider<ImageLoader>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date. // Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this) Sync.initialize(context = this)
} }
/** override fun newImageLoader(): ImageLoader = imageLoader.get()
* Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this
* format. During Coil's initialization it will call `applicationContext.newImageLoader()` to
* obtain an ImageLoader.
*
* @see <a href="https://github.com/coil-kt/coil/blob/main/coil-singleton/src/main/java/coil/Coil.kt">Coil</a>
*/
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(SvgDecoder.Factory())
}
.build()
}
} }

@ -18,14 +18,16 @@ package com.google.samples.apps.nowinandroid.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
/** /**
* Top-level navigation graph. Navigation is organized as explained at * Top-level navigation graph. Navigation is organized as explained at
@ -36,10 +38,11 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
*/ */
@Composable @Composable
fun NiaNavHost( fun NiaNavHost(
navController: NavHostController, appState: NiaAppState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute, startDestination: String = forYouNavigationRoute,
) { ) {
val navController = appState.navController
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
@ -48,6 +51,11 @@ fun NiaNavHost(
// TODO: handle topic clicks from each top level destination // TODO: handle topic clicks from each top level destination
forYouScreen(onTopicClick = {}) forYouScreen(onTopicClick = {})
bookmarksScreen(onTopicClick = {}) bookmarksScreen(onTopicClick = {})
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToTopic,
)
interestsGraph( interestsGraph(
onTopicClick = { topicId -> onTopicClick = { topicId ->
navController.navigateToTopic(topicId) navController.navigateToTopic(topicId)

@ -21,7 +21,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumedWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -44,17 +44,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
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.NiaGradientBackground
@ -77,15 +80,16 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class, ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class,
ExperimentalLifecycleComposeApi::class,
) )
@Composable @Composable
fun NiaApp( fun NiaApp(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
appState: NiaAppState = rememberNiaAppState( appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass, windowSizeClass = windowSizeClass,
userNewsResourceRepository = userNewsResourceRepository,
), ),
) { ) {
val shouldShowGradientBackground = val shouldShowGradientBackground =
@ -130,8 +134,10 @@ fun NiaApp(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
if (appState.shouldShowBottomBar) { if (appState.shouldShowBottomBar) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
NiaBottomBar( NiaBottomBar(
destinations = appState.topLevelDestinations, destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination, onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination, currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"), modifier = Modifier.testTag("NiaBottomBar"),
@ -143,7 +149,7 @@ fun NiaApp(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.consumedWindowInsets(padding) .consumeWindowInsets(padding)
.windowInsetsPadding( .windowInsetsPadding(
WindowInsets.safeDrawing.only( WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal, WindowInsetsSides.Horizontal,
@ -167,6 +173,10 @@ fun NiaApp(
if (destination != null) { if (destination != null) {
NiaTopAppBar( NiaTopAppBar(
titleRes = destination.titleTextId, titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_navigation_icon_description,
),
actionIcon = NiaIcons.Settings, actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource( actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description, id = settingsR.string.top_app_bar_action_icon_description,
@ -175,10 +185,11 @@ fun NiaApp(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
onActionClick = { appState.setShowSettingsDialog(true) }, onActionClick = { appState.setShowSettingsDialog(true) },
onNavigationClick = { appState.navigateToSearch() },
) )
} }
NiaNavHost(appState.navController) NiaNavHost(appState)
} }
// TODO: We may want to add padding or spacer when the snackbar is shown so that // TODO: We may want to add padding or spacer when the snackbar is shown so that
@ -213,6 +224,7 @@ private fun NiaNavRail(
imageVector = icon.imageVector, imageVector = icon.imageVector,
contentDescription = null, contentDescription = null,
) )
is DrawableResourceIcon -> Icon( is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id), painter = painterResource(id = icon.id),
contentDescription = null, contentDescription = null,
@ -220,6 +232,7 @@ private fun NiaNavRail(
} }
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }
@ -228,6 +241,7 @@ private fun NiaNavRail(
@Composable @Composable
private fun NiaBottomBar( private fun NiaBottomBar(
destinations: List<TopLevelDestination>, destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit, onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?, currentDestination: NavDestination?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -236,6 +250,7 @@ private fun NiaBottomBar(
modifier = modifier, modifier = modifier,
) { ) {
destinations.forEach { destination -> destinations.forEach { destination ->
val hasUnread = destinationsWithUnreadResources.contains(destination)
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
NiaNavigationBarItem( NiaNavigationBarItem(
selected = selected, selected = selected,
@ -259,11 +274,31 @@ private fun NiaBottomBar(
} }
}, },
label = { Text(stringResource(destination.iconTextId)) }, label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) notificationDot() else Modifier,
) )
} }
} }
} }
@Composable
private fun notificationDot(): Modifier {
val tertiaryColor = MaterialTheme.colorScheme.tertiary
return Modifier.drawWithContent {
drawContent()
drawCircle(
tertiaryColor,
radius = 5.dp.toPx(),
// This is based on the dimensions of the NavigationBar's "indicator pill";
// however, its parameters are private, so we must depend on them implicitly
// (NavigationBarTokens.ActiveIndicatorWidth = 64.dp)
center = center + Offset(
64.dp.toPx() * .45f,
32.dp.toPx() * -.45f - 6.dp.toPx(),
),
)
}
}
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any { this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false it.route?.contains(destination.name, true) ?: false

@ -33,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
import androidx.tracing.trace import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksRoute import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksRoute
@ -41,12 +42,15 @@ import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavi
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@ -54,12 +58,25 @@ import kotlinx.coroutines.flow.stateIn
fun rememberNiaAppState( fun rememberNiaAppState(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
coroutineScope: CoroutineScope = rememberCoroutineScope(), coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
): NiaAppState { ): NiaAppState {
NavigationTrackingSideEffect(navController) NavigationTrackingSideEffect(navController)
return remember(navController, coroutineScope, windowSizeClass, networkMonitor) { return remember(
NiaAppState(navController, coroutineScope, windowSizeClass, networkMonitor) navController,
coroutineScope,
windowSizeClass,
networkMonitor,
userNewsResourceRepository,
) {
NiaAppState(
navController,
coroutineScope,
windowSizeClass,
networkMonitor,
userNewsResourceRepository,
)
} }
} }
@ -69,6 +86,7 @@ class NiaAppState(
val coroutineScope: CoroutineScope, val coroutineScope: CoroutineScope,
val windowSizeClass: WindowSizeClass, val windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
) { ) {
val currentDestination: NavDestination? val currentDestination: NavDestination?
@Composable get() = navController @Composable get() = navController
@ -105,6 +123,22 @@ class NiaAppState(
*/ */
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.values().asList() val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.values().asList()
/**
* The top level destinations that have unread news resources.
*/
val topLevelDestinationsWithUnreadResources: StateFlow<Set<TopLevelDestination>> =
userNewsResourceRepository.observeAllForFollowedTopics()
.combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources ->
setOfNotNull(
FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
)
}.stateIn(
coroutineScope,
SharingStarted.WhileSubscribed(5_000),
initialValue = emptySet(),
)
/** /**
* UI logic for navigating to a top level destination in the app. Top level destinations have * UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you * only one copy of the destination of the back stack, and save and restore state whenever you
@ -139,6 +173,10 @@ class NiaAppState(
fun setShowSettingsDialog(shouldShow: Boolean) { fun setShowSettingsDialog(shouldShow: Boolean) {
shouldShowSettingsDialog = shouldShow shouldShowSettingsDialog = shouldShow
} }
fun navigateToSearch() {
navController.navigateToSearch()
}
} }
/** /**

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2023 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
http://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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- Enable Firebase analytics for `prod` builds -->
<meta-data
tools:replace="android:value"
android:name="firebase_analytics_collection_deactivated"
android:value="false" />
</application>
</manifest>

@ -39,7 +39,7 @@ android {
// 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
// for easy local/CI testing. // for easy local/CI testing.
val benchmark by creating { create("benchmark") {
// Keep the build type debuggable so we can attach a debugger if needed. // Keep the build type debuggable so we can attach a debugger if needed.
isDebuggable = true isDebuggable = true
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
@ -68,13 +68,13 @@ android {
} }
dependencies { dependencies {
implementation(libs.androidx.benchmark.macro)
implementation(libs.androidx.test.core) implementation(libs.androidx.test.core)
implementation(libs.androidx.test.espresso.core) implementation(libs.androidx.test.espresso.core)
implementation(libs.androidx.test.ext) implementation(libs.androidx.test.ext)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.rules) implementation(libs.androidx.test.rules)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.uiautomator) implementation(libs.androidx.test.uiautomator)
implementation(libs.androidx.benchmark.macro)
} }
androidComponents { androidComponents {

@ -14,6 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
`kotlin-dsl` `kotlin-dsl`
} }
@ -21,12 +23,22 @@ plugins {
group = "com.google.samples.apps.nowinandroid.buildlogic" group = "com.google.samples.apps.nowinandroid.buildlogic"
java { java {
// Up to Java 11 APIs are available through desugaring
// https://developer.android.com/studio/write/java11-minimal-support-table
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
}
dependencies { dependencies {
compileOnly(libs.android.gradlePlugin) compileOnly(libs.android.gradlePlugin)
compileOnly(libs.firebase.crashlytics.gradle)
compileOnly(libs.firebase.performance.gradle)
compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
} }
@ -73,9 +85,13 @@ gradlePlugin {
id = "nowinandroid.android.room" id = "nowinandroid.android.room"
implementationClass = "AndroidRoomConventionPlugin" implementationClass = "AndroidRoomConventionPlugin"
} }
register("firebase-perf") { register("androidFirebase") {
id = "nowinandroid.firebase-perf" id = "nowinandroid.android.application.firebase"
implementationClass = "FirebasePerfConventionPlugin" implementationClass = "AndroidApplicationFirebaseConventionPlugin"
}
register("androidFlavors") {
id = "nowinandroid.android.application.flavors"
implementationClass = "AndroidApplicationFlavorsConventionPlugin"
} }
} }
} }

@ -14,10 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configureKotlinAndroidToolchain import com.google.samples.apps.nowinandroid.configureKotlinAndroidToolchain
import com.google.samples.apps.nowinandroid.configurePrintApksTask import com.google.samples.apps.nowinandroid.configurePrintApksTask
@ -37,7 +36,6 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 33 defaultConfig.targetSdk = 33
configureFlavors(this)
configureGradleManagedDevices(this) configureGradleManagedDevices(this)
} }
extensions.configure<ApplicationAndroidComponentsExtension> { extensions.configure<ApplicationAndroidComponentsExtension> {

@ -0,0 +1,56 @@
/*
* 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.dsl.ApplicationExtension
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
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.dependencies
import org.gradle.kotlin.dsl.getByType
class AndroidApplicationFirebaseConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.gms.google-services")
apply("com.google.firebase.firebase-perf")
apply("com.google.firebase.crashlytics")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
val bom = libs.findLibrary("firebase-bom").get()
add("implementation", platform(bom))
"implementation"(libs.findLibrary("firebase.analytics").get())
"implementation"(libs.findLibrary("firebase.performance").get())
"implementation"(libs.findLibrary("firebase.crashlytics").get())
}
extensions.configure<ApplicationExtension> {
buildTypes.configureEach {
// Disable the Crashlytics mapping file upload. This feature should only be
// enabled if a Firebase backend is available and configured in
// google-services.json.
configure<CrashlyticsExtension> {
mappingFileUploadEnabled = false
}
}
}
}
}
}

@ -1,5 +1,5 @@
/* /*
* Copyright 2022 The Android Open Source Project * Copyright 2023 The Android Open Source Project
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,16 +14,18 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureFlavors
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
class FirebasePerfConventionPlugin : Plugin<Project> { class AndroidApplicationFlavorsConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
pluginManager.findPlugin("com.google.firebase.firebase-perf").apply { extensions.configure<ApplicationExtension> {
version = "1.4.1" configureFlavors(this)
} }
} }
} }
} }

@ -49,6 +49,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", project(":core:data")) add("implementation", project(":core:data"))
add("implementation", project(":core:common")) add("implementation", project(":core:common"))
add("implementation", project(":core:domain")) add("implementation", project(":core:domain"))
add("implementation", project(":core:analytics"))
add("testImplementation", kotlin("test")) add("testImplementation", kotlin("test"))
add("testImplementation", project(":core:testing")) add("testImplementation", project(":core:testing"))

@ -14,14 +14,11 @@
* limitations under the License. * 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.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.kotlin
class AndroidHiltConventionPlugin : Plugin<Project> { class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {

@ -14,7 +14,6 @@
* 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.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
@ -22,6 +21,7 @@ import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configureKotlinAndroidToolchain import com.google.samples.apps.nowinandroid.configureKotlinAndroidToolchain
import com.google.samples.apps.nowinandroid.configurePrintApksTask import com.google.samples.apps.nowinandroid.configurePrintApksTask
import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests
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
@ -47,6 +47,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this) configurePrintApksTask(this)
disableUnnecessaryAndroidTests(target)
} }
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs") val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
configurations.configureEach { configurations.configureEach {

@ -21,6 +21,8 @@ import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.File import java.io.File
/** /**
@ -40,16 +42,18 @@ internal fun Project.configureAndroidCompose(
kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString() kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString()
} }
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters()
}
dependencies { dependencies {
val bom = libs.findLibrary("androidx-compose-bom").get() val bom = libs.findLibrary("androidx-compose-bom").get()
add("implementation", platform(bom)) add("implementation", platform(bom))
add("androidTestImplementation", platform(bom)) add("androidTestImplementation", platform(bom))
} }
} }
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters()
}
}
} }
private fun Project.buildComposeMetricsParameters(): List<String> { private fun Project.buildComposeMetricsParameters(): List<String> {

@ -0,0 +1,35 @@
/*
* Copyright 2023 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.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Project
/**
* Disable unnecessary Android instrumented tests for the [project] if there is no `androidTest` folder.
* Otherwise, these projects would be compiled, packaged, installed and ran only to end-up with the following message:
*
* > Starting 0 tests on AVD
*
* Note: this could be improved by checking other potential sourceSets based on buildTypes and flavors.
*/
internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests(
project: Project,
) = beforeVariants {
it.enableAndroidTest = it.enableAndroidTest
&& project.projectDir.resolve("src/androidTest").exists()
}

@ -18,9 +18,8 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ManagedVirtualDevice import com.android.build.api.dsl.ManagedVirtualDevice
import org.gradle.api.Project import org.gradle.kotlin.dsl.get
import org.gradle.kotlin.dsl.invoke import org.gradle.kotlin.dsl.invoke
import java.util.Locale
/** /**
* Configure project for Gradle managed devices * Configure project for Gradle managed devices
@ -28,16 +27,17 @@ import java.util.Locale
internal fun configureGradleManagedDevices( internal fun configureGradleManagedDevices(
commonExtension: CommonExtension<*, *, *, *>, commonExtension: CommonExtension<*, *, *, *>,
) { ) {
val deviceConfigs = listOf( val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd")
DeviceConfig("Pixel 4", 30, "aosp-atd"), val pixel6 = DeviceConfig("Pixel 6", 31, "aosp")
DeviceConfig("Pixel 6", 31, "aosp"), val pixelC = DeviceConfig("Pixel C", 30, "aosp-atd")
DeviceConfig("Pixel C", 30, "aosp-atd"),
) val allDevices = listOf(pixel4, pixel6, pixelC)
val ciDevices = listOf(pixel4, pixelC)
commonExtension.testOptions { commonExtension.testOptions {
managedDevices { managedDevices {
devices { devices {
deviceConfigs.forEach { deviceConfig -> allDevices.forEach { deviceConfig ->
maybeCreate(deviceConfig.taskName, ManagedVirtualDevice::class.java).apply { maybeCreate(deviceConfig.taskName, ManagedVirtualDevice::class.java).apply {
device = deviceConfig.device device = deviceConfig.device
apiLevel = deviceConfig.apiLevel apiLevel = deviceConfig.apiLevel
@ -45,6 +45,13 @@ internal fun configureGradleManagedDevices(
} }
} }
} }
groups {
maybeCreate("ci").apply {
ciDevices.forEach { deviceConfig ->
targetDevices.add(devices[deviceConfig.taskName])
}
}
}
} }
} }
} }
@ -55,7 +62,7 @@ private data class DeviceConfig(
val systemImageSource: String, val systemImageSource: String,
) { ) {
val taskName = buildString { val taskName = buildString {
append(device.toLowerCase(Locale.ROOT).replace(" ", "")) append(device.lowercase().replace(" ", ""))
append("api") append("api")
append(apiLevel.toString()) append(apiLevel.toString())
append(systemImageSource.replace("-", "")) append(systemImageSource.replace("-", ""))

@ -27,6 +27,7 @@ import org.gradle.kotlin.dsl.withType
import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.testing.jacoco.plugins.JacocoPluginExtension
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
import org.gradle.testing.jacoco.tasks.JacocoReport import org.gradle.testing.jacoco.tasks.JacocoReport
import java.util.Locale
private val coverageExclusions = listOf( private val coverageExclusions = listOf(
// Android // Android
@ -36,6 +37,10 @@ private val coverageExclusions = listOf(
"**/Manifest*.*" "**/Manifest*.*"
) )
private fun String.capitalize() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
internal fun Project.configureJacoco( internal fun Project.configureJacoco(
androidComponentsExtension: AndroidComponentsExtension<*, *, *>, androidComponentsExtension: AndroidComponentsExtension<*, *, *>,
) { ) {

@ -25,8 +25,10 @@ import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/** /**
* Configure base Kotlin with Android options * Configure base Kotlin with Android options
@ -42,27 +44,29 @@ internal fun Project.configureKotlinAndroid(
} }
compileOptions { compileOptions {
// Up to Java 11 APIs are available through desugaring
// https://developer.android.com/studio/write/java11-minimal-support-table
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
}
// Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions { kotlinOptions {
// Set JVM target to 17
jvmTarget = JavaVersion.VERSION_11.toString()
// Treat all Kotlin warnings as errors (disabled by default) // Treat all Kotlin warnings as errors (disabled by default)
// Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
val warningsAsErrors: String? by project val warningsAsErrors: String? by project
allWarningsAsErrors = warningsAsErrors.toBoolean() allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs = freeCompilerArgs + listOf( freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn", "-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow // Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlinx.coroutines.FlowPreview",
"-opt-in=kotlin.Experimental",
) )
// Set JVM target to 11
jvmTarget = JavaVersion.VERSION_11.toString()
} }
} }

@ -16,8 +16,8 @@ enum class FlavorDimension {
// These two product flavors reflect this behaviour. // These two product flavors reflect this behaviour.
@Suppress("EnumEntryName") @Suppress("EnumEntryName")
enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) { enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) {
demo(FlavorDimension.contentType), demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"),
prod(FlavorDimension.contentType, ".prod") prod(FlavorDimension.contentType, )
} }
fun Project.configureFlavors( fun Project.configureFlavors(

Binary file not shown.

@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

@ -22,13 +22,16 @@ buildscript {
// Android Build Server // Android Build Server
maven { url = uri("../nowinandroid-prebuilts/m2repository") } maven { url = uri("../nowinandroid-prebuilts/m2repository") }
} }
} }
// Lists all plugins used throughout the project without applying them.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.firebase.crashlytics) apply false
alias(libs.plugins.firebase.perf) apply false
alias(libs.plugins.gms) apply false
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.secrets) apply false alias(libs.plugins.secrets) apply false

@ -25,44 +25,26 @@ export JAVA_HOME="$(cd $DIR/../../../prebuilts/studio/jdk/jdk11/linux && pwd )"
echo "JAVA_HOME=$JAVA_HOME" echo "JAVA_HOME=$JAVA_HOME"
export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )" export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )"
echo "ANDROID_HOME=$ANDROID_HOME" echo "ANDROID_HOME=$ANDROID_HOME"
cd $DIR
# Build echo "Copying google-services.json"
GRADLE_PARAMS=" --stacktrace" cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app
$DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS}
BUILD_RESULT=$?
# Demo debug echo "Copying local.properties"
cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR cp $DIR/../nowinandroid-prebuilts/local.properties $DIR
# Demo release cd $DIR
cp $APP_OUT/apk/demo/release/app-demo-release.apk $DIST_DIR
# Prod debug # Build the prodRelease variant
cp $APP_OUT/apk/prod/debug/app-prod-debug.apk $DIST_DIR/app-prod-debug.apk GRADLE_PARAMS=" --stacktrace -Puse-google-services"
$DIR/gradlew :app:clean :app:assembleProdRelease :app:bundleProdRelease ${GRADLE_PARAMS}
BUILD_RESULT=$?
# Prod release # Prod release apk
cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk
#cp $APP_OUT/mapping/release/mapping.txt $DIST_DIR/mobile-release-apk-mapping.txt # Prod release bundle
# Build App Bundles
# Don't clean here, otherwise all apks are gone.
$DIR/gradlew :app:bundle ${GRADLE_PARAMS}
# Demo debug
cp $APP_OUT/bundle/demoDebug/app-demo-debug.aab $DIST_DIR/app-demo-debug.aab
# Demo release
cp $APP_OUT/bundle/demoRelease/app-demo-release.aab $DIST_DIR/app-demo-release.aab
# Prod debug
cp $APP_OUT/bundle/prodDebug/app-prod-debug.aab $DIST_DIR/app-prod-debug.aab
# Prod release
cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab
#cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt # Prod release bundle mapping
BUILD_RESULT=$? cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt
exit $BUILD_RESULT exit $BUILD_RESULT

@ -0,0 +1 @@
/build

@ -0,0 +1,32 @@
/*
* Copyright 2023 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.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.hilt")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.analytics"
}
dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.core.ktx)
implementation(libs.firebase.analytics)
implementation(libs.kotlinx.coroutines.android)
}

@ -0,0 +1,29 @@
/*
* Copyright 2023 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.analytics
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
@Binds
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: StubAnalyticsHelper): AnalyticsHelper
}

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2023 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
http://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.
-->
<manifest />

@ -0,0 +1,58 @@
/*
* Copyright 2023 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.analytics
/**
* Represents an analytics event.
*
* @param type - the event type. Wherever possible use one of the standard
* event `Types`, however, if there is no suitable event type already defined, a custom event can be
* defined as long as it is configured in your backend analytics system (for example, by creating a
* Firebase Analytics custom event).
*
* @param extras - list of parameters which supply additional context to the event. See `Param`.
*/
data class AnalyticsEvent(
val type: String,
val extras: List<Param> = emptyList(),
) {
// Standard analytics types.
class Types {
companion object {
const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME)
}
}
/**
* A key-value pair used to supply extra context to an analytics event.
*
* @param key - the parameter key. Wherever possible use one of the standard `ParamKeys`,
* however, if no suitable key is available you can define your own as long as it is configured
* in your backend analytics system (for example, by creating a Firebase Analytics custom
* parameter).
*
* @param value - the parameter value.
*/
data class Param(val key: String, val value: String)
// Standard parameter keys.
class ParamKeys {
companion object {
const val SCREEN_NAME = "screen_name"
}
}
}

@ -0,0 +1,25 @@
/*
* Copyright 2023 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.analytics
/**
* Interface for logging analytics events. See `FirebaseAnalyticsHelper` and
* `StubAnalyticsHelper` for implementations.
*/
interface AnalyticsHelper {
fun logEvent(event: AnalyticsEvent)
}

@ -0,0 +1,24 @@
/*
* Copyright 2023 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.analytics
/**
* Implementation of AnalyticsHelper which does nothing. Useful for tests and previews.
*/
class NoOpAnalyticsHelper : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) = Unit
}

@ -0,0 +1,34 @@
/*
* Copyright 2023 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.analytics
import android.util.Log
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "StubAnalyticsHelper"
/**
* An implementation of AnalyticsHelper just writes the events to logcat. Used in builds where no
* analytics events should be sent to a backend.
*/
@Singleton
class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) {
Log.d(TAG, "Received analytics event: $event")
}
}

@ -0,0 +1,28 @@
/*
* Copyright 2023 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.analytics
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Global key used to obtain access to the AnalyticsHelper through a CompositionLocal.
*/
val LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {
// Provide a default AnalyticsHelper which does nothing. This is so that tests and previews
// do not have to provide one. For real app builds provide a different implementation.
NoOpAnalyticsHelper()
}

@ -0,0 +1,40 @@
/*
* Copyright 2023 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.analytics
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.ktx.Firebase
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
@Binds
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper
companion object {
@Provides
@Singleton
fun provideFirebaseAnalytics(): FirebaseAnalytics { return Firebase.analytics }
}
}

@ -0,0 +1,41 @@
/*
* Copyright 2023 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.analytics
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.logEvent
import javax.inject.Inject
/**
* Implementation of `AnalyticsHelper` which logs events to a Firebase backend.
*/
class FirebaseAnalyticsHelper @Inject constructor(
private val firebaseAnalytics: FirebaseAnalytics,
) : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) {
firebaseAnalytics.logEvent(event.type) {
for (extra in event.extras) {
// Truncate parameter keys and values according to firebase maximum length values.
param(
key = extra.key.take(40),
value = extra.value.take(100),
)
}
}
}
}

@ -24,5 +24,6 @@ import kotlin.annotation.AnnotationRetention.RUNTIME
annotation class Dispatcher(val niaDispatcher: NiaDispatchers) annotation class Dispatcher(val niaDispatcher: NiaDispatchers)
enum class NiaDispatchers { enum class NiaDispatchers {
Default,
IO, IO,
} }

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.network.di package com.google.samples.apps.nowinandroid.core.network.di
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -31,4 +32,8 @@ object DispatchersModule {
@Provides @Provides
@Dispatcher(IO) @Dispatcher(IO)
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Dispatcher(Default)
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
} }

@ -18,9 +18,13 @@ package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.di.DataModule import com.google.samples.apps.nowinandroid.core.data.di.DataModule
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
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.repository.fake.FakeNewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository
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 com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
@ -50,6 +54,16 @@ interface TestDataModule {
userDataRepository: FakeUserDataRepository, userDataRepository: FakeUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds
fun bindsRecentSearchRepository(
recentSearchRepository: FakeRecentSearchRepository,
): RecentSearchRepository
@Binds
fun bindsSearchContentsRepository(
searchContentsRepository: FakeSearchContentsRepository,
): SearchContentsRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor, networkMonitor: AlwaysOnlineNetworkMonitor,

@ -25,23 +25,24 @@ android {
testOptions { testOptions {
unitTests { unitTests {
isIncludeAndroidResources = true isIncludeAndroidResources = true
isReturnDefaultValues = true
} }
} }
} }
dependencies { dependencies {
implementation(project(":core:analytics"))
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:model"))
implementation(project(":core:database")) implementation(project(":core:database"))
implementation(project(":core:datastore")) implementation(project(":core:datastore"))
implementation(project(":core:model"))
implementation(project(":core:network")) implementation(project(":core:network"))
implementation(project(":core:notifications"))
testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
}
testImplementation(project(":core:datastore-test"))
testImplementation(project(":core:testing"))
}

@ -16,10 +16,14 @@
package com.google.samples.apps.nowinandroid.core.data.di package com.google.samples.apps.nowinandroid.core.data.di
import com.google.samples.apps.nowinandroid.core.data.repository.DefaultRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.DefaultSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository
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.RecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
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.ConnectivityManagerNetworkMonitor
@ -48,6 +52,16 @@ interface DataModule {
userDataRepository: OfflineFirstUserDataRepository, userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds
fun bindsRecentSearchRepository(
recentSearchRepository: DefaultRecentSearchRepository,
): RecentSearchRepository
@Binds
fun bindsSearchContentsRepository(
searchContentsRepository: DefaultSearchContentsRepository,
): SearchContentsRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor, networkMonitor: ConnectivityManagerNetworkMonitor,

@ -0,0 +1,33 @@
/*
* Copyright 2023 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.di
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface UserNewsResourceRepositoryModule {
@Binds
fun bindsUserNewsResourceRepository(
userDataRepository: CompositeUserNewsResourceRepository,
): UserNewsResourceRepository
}

@ -0,0 +1,31 @@
/*
* Copyright 2023 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.RecentSearchQueryEntity
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
data class RecentSearchQuery(
val query: String,
val queriedDate: Instant = Clock.System.now(),
)
fun RecentSearchQueryEntity.asExternalModel() = RecentSearchQuery(
query = query,
queriedDate = queriedDate,
)

@ -0,0 +1,84 @@
/*
* Copyright 2023 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.repository
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) {
val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved"
val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id"
logEvent(
AnalyticsEvent(
type = eventType,
extras = listOf(
Param(key = paramKey, value = newsResourceId),
),
),
)
}
fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: Boolean) {
val eventType = if (isFollowed) "topic_followed" else "topic_unfollowed"
val paramKey = if (isFollowed) "followed_topic_id" else "unfollowed_topic_id"
logEvent(
AnalyticsEvent(
type = eventType,
extras = listOf(
Param(key = paramKey, value = followedTopicId),
),
),
)
}
fun AnalyticsHelper.logThemeChanged(themeName: String) =
logEvent(
AnalyticsEvent(
type = "theme_changed",
extras = listOf(
Param(key = "theme_name", value = themeName),
),
),
)
fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =
logEvent(
AnalyticsEvent(
type = "dark_theme_config_changed",
extras = listOf(
Param(key = "dark_theme_config", value = darkThemeConfigName),
),
),
)
fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =
logEvent(
AnalyticsEvent(
type = "dynamic_color_preference_changed",
extras = listOf(
Param(key = "dynamic_color_preference", value = useDynamicColor.toString()),
),
),
)
fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) {
val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset"
logEvent(
AnalyticsEvent(type = eventType),
)
}

@ -0,0 +1,69 @@
/*
* Copyright 2023 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.repository
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a
* [UserDataRepository].
*/
class CompositeUserNewsResourceRepository @Inject constructor(
val newsRepository: NewsRepository,
val userDataRepository: UserDataRepository,
) : UserNewsResourceRepository {
/**
* Returns available news resources (joined with user data) matching the given query.
*/
override fun observeAll(
query: NewsResourceQuery,
): Flow<List<UserNewsResource>> =
newsRepository.getNewsResources(query)
.combine(userDataRepository.userData) { newsResources, userData ->
newsResources.mapToUserNewsResources(userData)
}
/**
* Returns available news resources (joined with user data) for the followed topics.
*/
override fun observeAllForFollowedTopics(): Flow<List<UserNewsResource>> =
userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged()
.flatMapLatest { followedTopics ->
when {
followedTopics.isEmpty() -> flowOf(emptyList())
else -> observeAll(NewsResourceQuery(filterTopicIds = followedTopics))
}
}
override fun observeAllBookmarked(): Flow<List<UserNewsResource>> =
userDataRepository.userData.map { it.bookmarkedNewsResources }.distinctUntilChanged()
.flatMapLatest { bookmarkedNewsResources ->
when {
bookmarkedNewsResources.isEmpty() -> flowOf(emptyList())
else -> observeAll(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources))
}
}
}

@ -0,0 +1,55 @@
/*
* Copyright 2023 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.repository
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import javax.inject.Inject
class DefaultRecentSearchRepository @Inject constructor(
private val recentSearchQueryDao: RecentSearchQueryDao,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) {
withContext(ioDispatcher) {
recentSearchQueryDao.insertOrReplaceRecentSearchQuery(
RecentSearchQueryEntity(
query = searchQuery,
queriedDate = Clock.System.now(),
),
)
}
}
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries ->
searchQueries.map {
it.asExternalModel()
}
}
override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries()
}

@ -0,0 +1,85 @@
/*
* Copyright 2023 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.repository
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import javax.inject.Inject
class DefaultSearchContentsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val newsResourceFtsDao: NewsResourceFtsDao,
private val topicDao: TopicDao,
private val topicFtsDao: TopicFtsDao,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : SearchContentsRepository {
override suspend fun populateFtsData() {
withContext(ioDispatcher) {
newsResourceFtsDao.insertAll(
newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() },
)
topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() })
}
}
override fun searchContents(searchQuery: String): Flow<SearchResult> {
// Surround the query by asterisks to match the query when it's in the middle of
// a word
val newsResourceIds = newsResourceFtsDao.searchAllNewsResources("*$searchQuery*")
val topicIds = topicFtsDao.searchAllTopics("*$searchQuery*")
val newsResourcesFlow = newsResourceIds
.mapLatest { it.toSet() }
.distinctUntilChanged()
.flatMapLatest {
newsResourceDao.getNewsResources(useFilterNewsIds = true, filterNewsIds = it)
}
val topicsFlow = topicIds
.mapLatest { it.toSet() }
.distinctUntilChanged()
.flatMapLatest(topicDao::getTopicEntities)
return combine(newsResourcesFlow, topicsFlow) { newsResources, topics ->
SearchResult(
topics = topics.map { it.asExternalModel() },
newsResources = newsResources.map { it.asExternalModel() },
)
}
}
override fun getSearchContentsCount(): Flow<Int> =
combine(
newsResourceFtsDao.getCount(),
topicFtsDao.getCount(),
) { newsResourceCount, topicsCount ->
newsResourceCount + topicsCount
}
}

@ -21,18 +21,30 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** /**
* Data layer implementation for [NewsResource] * Encapsulation class for query parameters for [NewsResource]
*/ */
interface NewsRepository : Syncable { data class NewsResourceQuery(
/**
* Topic ids to filter for. Null means any topic id will match.
*/
val filterTopicIds: Set<String>? = null,
/** /**
* Returns available news resources as a stream. * News ids to filter for. Null means any news id will match.
*/ */
fun getNewsResources(): Flow<List<NewsResource>> val filterNewsIds: Set<String>? = null,
)
/**
* Data layer implementation for [NewsResource]
*/
interface NewsRepository : Syncable {
/** /**
* Returns available news resources as a stream filtered by topics. * Returns available news resources that match the specified [query].
*/ */
fun getNewsResources( fun getNewsResources(
filterTopicIds: Set<String> = emptySet(), query: NewsResourceQuery = NewsResourceQuery(
filterTopicIds = null,
filterNewsIds = null,
),
): Flow<List<NewsResource>> ): Flow<List<NewsResource>>
} }

@ -27,31 +27,39 @@ import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsRes
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
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
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.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
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.notifications.Notifier
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
// Heuristic value to optimize for serialization and deserialization cost on client and server
// for each news resource batch.
private const val SYNC_BATCH_SIZE = 40
/** /**
* Disk storage backed implementation of the [NewsRepository]. * Disk storage backed implementation of the [NewsRepository].
* Reads are exclusively from local storage to support offline access. * Reads are exclusively from local storage to support offline access.
*/ */
class OfflineFirstNewsRepository @Inject constructor( class OfflineFirstNewsRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val newsResourceDao: NewsResourceDao, private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao, private val topicDao: TopicDao,
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
private val notifier: Notifier,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> =
newsResourceDao.getNewsResources()
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResources( override fun getNewsResources(
filterTopicIds: Set<String>, query: NewsResourceQuery,
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources( ): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
filterTopicIds = filterTopicIds, useFilterTopicIds = query.filterTopicIds != null,
filterTopicIds = query.filterTopicIds ?: emptySet(),
useFilterNewsIds = query.filterNewsIds != null,
filterNewsIds = query.filterNewsIds ?: emptySet(),
) )
.map { it.map(PopulatedNewsResource::asExternalModel) } .map { it.map(PopulatedNewsResource::asExternalModel) }
@ -66,26 +74,63 @@ class OfflineFirstNewsRepository @Inject constructor(
}, },
modelDeleter = newsResourceDao::deleteNewsResources, modelDeleter = newsResourceDao::deleteNewsResources,
modelUpdater = { changedIds -> modelUpdater = { changedIds ->
val networkNewsResources = network.getNewsResources(ids = changedIds) val userData = niaPreferencesDataSource.userData.first()
val hasOnboarded = userData.shouldHideOnboarding
val followedTopicIds = userData.followedTopics
// TODO: Make this more efficient, there is no need to retrieve populated
// news resources when all that's needed are the ids
val existingNewsResourceIdsThatHaveChanged = when {
hasOnboarded -> newsResourceDao.getNewsResources(
useFilterTopicIds = true,
filterTopicIds = followedTopicIds,
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet(),
)
.first()
.map { it.entity.id }
.toSet()
// No need to retrieve anything if notifications won't be sent
else -> emptySet()
}
// Obtain the news resources which have changed from the network and upsert them locally
changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->
val networkNewsResources = network.getNewsResources(ids = chunkedIds)
// Order of invocation matters to satisfy id and foreign key constraints!
topicDao.insertOrIgnoreTopics(
topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id),
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources.map(
NetworkNewsResource::asEntity,
),
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.flatten(),
)
}
// Order of invocation matters to satisfy id and foreign key constraints! if (hasOnboarded) {
val addedNewsResources = newsResourceDao.getNewsResources(
useFilterTopicIds = true,
filterTopicIds = followedTopicIds,
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet() - existingNewsResourceIdsThatHaveChanged,
)
.first()
.map(PopulatedNewsResource::asExternalModel)
topicDao.insertOrIgnoreTopics( if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources)
topicEntities = networkNewsResources }
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id),
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity),
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.flatten(),
)
}, },
) )
} }

@ -16,6 +16,8 @@
package com.google.samples.apps.nowinandroid.core.data.repository package com.google.samples.apps.nowinandroid.core.data.repository
import androidx.annotation.VisibleForTesting
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
@ -25,29 +27,49 @@ import javax.inject.Inject
class OfflineFirstUserDataRepository @Inject constructor( class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource, private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val analyticsHelper: AnalyticsHelper,
) : UserDataRepository { ) : UserDataRepository {
override val userData: Flow<UserData> = override val userData: Flow<UserData> =
niaPreferencesDataSource.userData niaPreferencesDataSource.userData
@VisibleForTesting
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) = override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds) niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) = override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) {
niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed) niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed)
analyticsHelper.logTopicFollowToggled(followedTopicId, followed)
}
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) = override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
analyticsHelper.logNewsResourceBookmarkToggled(
newsResourceId = newsResourceId,
isBookmarked = bookmarked,
)
}
override suspend fun setThemeBrand(themeBrand: ThemeBrand) = override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) =
niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed)
override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
niaPreferencesDataSource.setThemeBrand(themeBrand) niaPreferencesDataSource.setThemeBrand(themeBrand)
analyticsHelper.logThemeChanged(themeBrand.name)
}
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
analyticsHelper.logDarkThemeConfigChanged(darkThemeConfig.name)
}
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) = override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor) niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
analyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) = override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
analyticsHelper.logOnboardingStateChanged(shouldHideOnboarding)
}
} }

@ -0,0 +1,41 @@
/*
* Copyright 2023 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.repository
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import kotlinx.coroutines.flow.Flow
/**
* Data layer interface for the recent searches.
*/
interface RecentSearchRepository {
/**
* Get the recent search queries up to the number of queries specified as [limit].
*/
fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>>
/**
* Insert or replace the [searchQuery] as part of the recent searches.
*/
suspend fun insertOrReplaceRecentSearch(searchQuery: String)
/**
* Clear the recent searches.
*/
suspend fun clearRecentSearches()
}

@ -0,0 +1,38 @@
/*
* Copyright 2023 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.repository
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
import kotlinx.coroutines.flow.Flow
/**
* Data layer interface for the search feature.
*/
interface SearchContentsRepository {
/**
* Populate the fts tables for the search contents.
*/
suspend fun populateFtsData()
/**
* Query the contents matched with the [searchQuery] and returns it as a [Flow] of [SearchResult]
*/
fun searchContents(searchQuery: String): Flow<SearchResult>
fun getSearchContentsCount(): Flow<Int>
}

@ -43,6 +43,11 @@ interface UserDataRepository {
*/ */
suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
/**
* Updates the viewed status for a news resource
*/
suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean)
/** /**
* Sets the desired theme brand. * Sets the desired theme brand.
*/ */

@ -0,0 +1,45 @@
/*
* Copyright 2023 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.repository
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.coroutines.flow.Flow
/**
* Data layer implementation for [UserNewsResource]
*/
interface UserNewsResourceRepository {
/**
* Returns available news resources as a stream.
*/
fun observeAll(
query: NewsResourceQuery = NewsResourceQuery(
filterTopicIds = null,
filterNewsIds = null,
),
): Flow<List<UserNewsResource>>
/**
* Returns available news resources for the user's followed topics as a stream.
*/
fun observeAllForFollowedTopics(): Flow<List<UserNewsResource>>
/**
* Returns the user's bookmarked news resources as a stream.
*/
fun observeAllBookmarked(): Flow<List<UserNewsResource>>
}

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.Synchronizer 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.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
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.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -43,26 +44,28 @@ class FakeNewsRepository @Inject constructor(
private val datasource: FakeNiaNetworkDataSource, private val datasource: FakeNiaNetworkDataSource,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> =
flow {
emit(
datasource.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel),
)
}.flowOn(ioDispatcher)
override fun getNewsResources( override fun getNewsResources(
filterTopicIds: Set<String>, query: NewsResourceQuery,
): Flow<List<NewsResource>> = ): Flow<List<NewsResource>> =
flow { flow {
emit( emit(
datasource datasource
.getNewsResources() .getNewsResources()
.filter { it.topics.intersect(filterTopicIds).isNotEmpty() } .filter { networkNewsResource ->
// Filter out any news resources which don't match the current query.
// If no query parameters (filterTopicIds or filterNewsIds) are specified
// then the news resource is returned.
listOfNotNull(
true,
query.filterNewsIds?.contains(networkNewsResource.id),
query.filterTopicIds?.let { filterTopicIds ->
networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty()
},
)
.all(true::equals)
}
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel), .map(NewsResourceEntity::asExternalModel),
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)

@ -0,0 +1,35 @@
/*
* Copyright 2023 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.repository.fake
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/**
* Fake implementation of the [RecentSearchRepository]
*/
class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { /* no-op */ }
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
flowOf(emptyList())
override suspend fun clearRecentSearches() { /* no-op */ }
}

@ -0,0 +1,33 @@
/*
* Copyright 2023 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.repository.fake
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/**
* Fake implementation of the [SearchContentsRepository]
*/
class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
override suspend fun populateFtsData() { /* no-op */ }
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()
override fun getSearchContentsCount(): Flow<Int> = flowOf(1)
}

@ -47,6 +47,9 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
} }
override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) =
niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed)
override suspend fun setThemeBrand(themeBrand: ThemeBrand) { override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
niaPreferencesDataSource.setThemeBrand(themeBrand) niaPreferencesDataSource.setThemeBrand(themeBrand)
} }

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
/** /**
* Reports on if synchronization is in progress * Reports on if synchronization is in progress
*/ */
interface SyncStatusMonitor { interface SyncManager {
val isSyncing: Flow<Boolean> val isSyncing: Flow<Boolean>
fun requestSync()
} }

@ -1,5 +1,5 @@
/* /*
* Copyright 2022 The Android Open Source Project * Copyright 2023 The Android Open Source Project
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,37 +14,37 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.domain package com.google.samples.apps.nowinandroid.core.data
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
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.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class GetUserNewsResourcesUseCaseTest { class CompositeUserNewsResourceRepositoryTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val newsRepository = TestNewsRepository() private val newsRepository = TestNewsRepository()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
val useCase = GetUserNewsResourcesUseCase(newsRepository, userDataRepository) private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = newsRepository,
userDataRepository = userDataRepository,
)
@Test @Test
fun whenNoFilters_allNewsResourcesAreReturned() = runTest { fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the user news resources stream. // Obtain the user news resources flow.
val userNewsResources = useCase() val userNewsResources = userNewsResourceRepository.observeAll()
// Send some news resources and user data into the data repositories. // Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
@ -67,7 +67,14 @@ class GetUserNewsResourcesUseCaseTest {
@Test @Test
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id. // Obtain a stream of user news resources for the given topic id.
val userNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id)) val userNewsResources =
userNewsResourceRepository.observeAll(
NewsResourceQuery(
filterTopicIds = setOf(
sampleTopic1.id,
),
),
)
// Send test data into the repositories. // Send test data into the repositories.
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
@ -81,6 +88,51 @@ class GetUserNewsResourcesUseCaseTest {
userNewsResources.first(), userNewsResources.first(),
) )
} }
@Test
fun whenFilteredByFollowedTopics_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id.
val userNewsResources =
userNewsResourceRepository.observeAllForFollowedTopics()
// Send test data into the repositories.
val userData = emptyUserData.copy(
followedTopics = setOf(sampleTopic1.id),
)
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(userData)
// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.mapToUserNewsResources(userData),
userNewsResources.first(),
)
}
@Test
fun whenFilteredByBookmarkedResources_matchingNewsResourcesAreReturned() = runTest {
// Obtain the bookmarked user news resources flow.
val userNewsResources = userNewsResourceRepository.observeAllBookmarked()
// Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources)
// Construct the test user data with bookmarks and followed topics.
val userData = emptyUserData.copy(
bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id),
followedTopics = setOf(sampleTopic1.id),
)
userDataRepository.setUserData(userData)
// Check that the correct news resources are returned with their bookmarked state.
assertEquals(
listOf(sampleNewsResources[0], sampleNewsResources[2]).mapToUserNewsResources(userData),
userNewsResources.first(),
)
}
} }
private val sampleTopic1 = Topic( private val sampleTopic1 = Topic(

@ -14,16 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.domain package com.google.samples.apps.nowinandroid.core.data
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
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.Article import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@ -68,6 +68,7 @@ class UserNewsResourceTest {
val userData = UserData( val userData = UserData(
bookmarkedNewsResources = setOf("N1"), bookmarkedNewsResources = setOf("N1"),
viewedNewsResources = setOf("N1"),
followedTopics = setOf("T1"), followedTopics = setOf("T1"),
themeBrand = DEFAULT, themeBrand = DEFAULT,
darkThemeConfig = FOLLOW_SYSTEM, darkThemeConfig = FOLLOW_SYSTEM,

@ -27,32 +27,44 @@ 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.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.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
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
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.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
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.testing.notifications.TestNotifier
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OfflineFirstNewsRepositoryTest { class OfflineFirstNewsRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstNewsRepository private lateinit var subject: OfflineFirstNewsRepository
private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource
private lateinit var newsResourceDao: TestNewsResourceDao private lateinit var newsResourceDao: TestNewsResourceDao
private lateinit var topicDao: TestTopicDao private lateinit var topicDao: TestTopicDao
private lateinit var network: TestNiaNetworkDataSource private lateinit var network: TestNiaNetworkDataSource
private lateinit var notifier: TestNotifier
private lateinit var synchronizer: Synchronizer private lateinit var synchronizer: Synchronizer
@get:Rule @get:Rule
@ -60,25 +72,29 @@ class OfflineFirstNewsRepositoryTest {
@Before @Before
fun setup() { fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore(testScope),
)
newsResourceDao = TestNewsResourceDao() newsResourceDao = TestNewsResourceDao()
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
notifier = TestNotifier()
synchronizer = TestSynchronizer( synchronizer = TestSynchronizer(
NiaPreferencesDataSource( niaPreferencesDataSource,
tmpFolder.testUserPreferencesDataStore(),
),
) )
subject = OfflineFirstNewsRepository( subject = OfflineFirstNewsRepository(
niaPreferencesDataSource = niaPreferencesDataSource,
newsResourceDao = newsResourceDao, newsResourceDao = newsResourceDao,
topicDao = topicDao, topicDao = topicDao,
network = network, network = network,
notifier = notifier,
) )
} }
@Test @Test
fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() = fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() =
runTest { testScope.runTest {
assertEquals( assertEquals(
newsResourceDao.getNewsResources() newsResourceDao.getNewsResources()
.first() .first()
@ -90,23 +106,28 @@ class OfflineFirstNewsRepositoryTest {
@Test @Test
fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() = fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() =
runTest { testScope.runTest {
assertEquals( assertEquals(
newsResourceDao.getNewsResources( expected = newsResourceDao.getNewsResources(
filterTopicIds = filteredInterestsIds, filterTopicIds = filteredInterestsIds,
useFilterTopicIds = true,
) )
.first() .first()
.map(PopulatedNewsResource::asExternalModel), .map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources( actual = subject.getNewsResources(
filterTopicIds = filteredInterestsIds, query = NewsResourceQuery(
filterTopicIds = filteredInterestsIds,
),
) )
.first(), .first(),
) )
assertEquals( assertEquals(
emptyList(), expected = emptyList(),
subject.getNewsResources( actual = subject.getNewsResources(
filterTopicIds = nonPresentInterestsIds, query = NewsResourceQuery(
filterTopicIds = nonPresentInterestsIds,
),
) )
.first(), .first(),
) )
@ -114,7 +135,9 @@ class OfflineFirstNewsRepositoryTest {
@Test @Test
fun offlineFirstNewsRepository_sync_pulls_from_network() = fun offlineFirstNewsRepository_sync_pulls_from_network() =
runTest { testScope.runTest {
// User has not onboarded
niaPreferencesDataSource.setShouldHideOnboarding(false)
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val newsResourcesFromNetwork = network.getNewsResources() val newsResourcesFromNetwork = network.getNewsResources()
@ -126,20 +149,26 @@ class OfflineFirstNewsRepositoryTest {
.map(PopulatedNewsResource::asExternalModel) .map(PopulatedNewsResource::asExternalModel)
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id), newsResourcesFromNetwork.map(NewsResource::id).sorted(),
newsResourcesFromDb.map(NewsResource::id), newsResourcesFromDb.map(NewsResource::id).sorted(),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources), expected = network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
// Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty())
} }
@Test @Test
fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() = fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest { testScope.runTest {
// User has not onboarded
niaPreferencesDataSource.setShouldHideOnboarding(false)
val newsResourcesFromNetwork = network.getNewsResources() val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
@ -167,20 +196,26 @@ class OfflineFirstNewsRepositoryTest {
// Assert that items marked deleted on the network have been deleted locally // Assert that items marked deleted on the network have been deleted locally
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id) - deletedItems, expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(),
newsResourcesFromDb.map(NewsResource::id), actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources), expected = network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
// Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty())
} }
@Test @Test
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() =
runTest { testScope.runTest {
// User has not onboarded
niaPreferencesDataSource.setShouldHideOnboarding(false)
// Set news version to 7 // Set news version to 7
synchronizer.updateChangeListVersions { synchronizer.updateChangeListVersions {
copy(newsResourceVersion = 7) copy(newsResourceVersion = 7)
@ -206,43 +241,116 @@ class OfflineFirstNewsRepositoryTest {
.map(PopulatedNewsResource::asExternalModel) .map(PopulatedNewsResource::asExternalModel)
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id), expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
newsResourcesFromDb.map(NewsResource::id), actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
changeList.last().changeListVersion, expected = changeList.last().changeListVersion,
synchronizer.getChangeListVersions().newsResourceVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
// Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty())
} }
@Test @Test
fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() = fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() =
runTest { testScope.runTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
assertEquals( assertEquals(
network.getNewsResources() expected = network.getNewsResources()
.map(NetworkNewsResource::topicEntityShells) .map(NetworkNewsResource::topicEntityShells)
.flatten() .flatten()
.distinctBy(TopicEntity::id), .distinctBy(TopicEntity::id)
topicDao.getTopicEntities() .sortedBy(TopicEntity::toString),
.first(), actual = topicDao.getTopicEntities()
.first()
.sortedBy(TopicEntity::toString),
) )
} }
@Test @Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =
runTest { testScope.runTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
assertEquals( assertEquals(
network.getNewsResources() expected = network.getNewsResources()
.map(NetworkNewsResource::topicCrossReferences) .map(NetworkNewsResource::topicCrossReferences)
.flatten()
.distinct() .distinct()
.flatten(), .sortedBy(NewsResourceTopicCrossRef::toString),
newsResourceDao.topicCrossReferences, actual = newsResourceDao.topicCrossReferences
.sortedBy(NewsResourceTopicCrossRef::toString),
)
}
@Test
fun offlineFirstNewsRepository_sends_notifications_for_newly_synced_news_that_is_followed() =
testScope.runTest {
// User has onboarded
niaPreferencesDataSource.setShouldHideOnboarding(true)
val networkNewsResources = network.getNewsResources()
// Follow roughly half the topics
val followedTopicIds = networkNewsResources
.flatMap(NetworkNewsResource::topicEntityShells)
.mapNotNull { topic ->
when (topic.id.chars().sum() % 2) {
0 -> topic.id
else -> null
}
}
.toSet()
// Set followed topics
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
subject.syncWith(synchronizer)
val followedNewsResourceIdsFromNetwork = networkNewsResources
.filter { (it.topics intersect followedTopicIds).isNotEmpty() }
.map(NetworkNewsResource::id)
.sorted()
// Notifier should have been called with only news resources that have topics
// that the user follows
assertEquals(
expected = followedNewsResourceIdsFromNetwork,
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
) )
} }
@Test
fun offlineFirstNewsRepository_does_not_send_notifications_for_existing_news_resources() =
testScope.runTest {
// User has onboarded
niaPreferencesDataSource.setShouldHideOnboarding(true)
val networkNewsResources = network.getNewsResources()
.map(NetworkNewsResource::asEntity)
val newsResources = networkNewsResources
.map(NewsResourceEntity::asExternalModel)
// Prepopulate dao with news resources
newsResourceDao.upsertNewsResources(networkNewsResources)
val followedTopicIds = newsResources
.flatMap(NewsResource::topics)
.map(Topic::id)
.toSet()
// Follow all topics
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
subject.syncWith(synchronizer)
// Notifier should not have been called bc all news resources existed previously
assertTrue(notifier.addedNewsResources.isEmpty())
}
} }

@ -29,6 +29,8 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@ -38,6 +40,8 @@ import kotlin.test.assertEquals
class OfflineFirstTopicsRepositoryTest { class OfflineFirstTopicsRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstTopicsRepository private lateinit var subject: OfflineFirstTopicsRepository
private lateinit var topicDao: TopicDao private lateinit var topicDao: TopicDao
@ -56,7 +60,7 @@ class OfflineFirstTopicsRepositoryTest {
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
niaPreferences = NiaPreferencesDataSource( niaPreferences = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore(), tmpFolder.testUserPreferencesDataStore(testScope),
) )
synchronizer = TestSynchronizer(niaPreferences) synchronizer = TestSynchronizer(niaPreferences)
@ -68,7 +72,7 @@ class OfflineFirstTopicsRepositoryTest {
@Test @Test
fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() = fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() =
runTest { testScope.runTest {
assertEquals( assertEquals(
topicDao.getTopicEntities() topicDao.getTopicEntities()
.first() .first()
@ -80,7 +84,7 @@ class OfflineFirstTopicsRepositoryTest {
@Test @Test
fun offlineFirstTopicsRepository_sync_pulls_from_network() = fun offlineFirstTopicsRepository_sync_pulls_from_network() =
runTest { testScope.runTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val networkTopics = network.getTopics() val networkTopics = network.getTopics()
@ -103,7 +107,7 @@ class OfflineFirstTopicsRepositoryTest {
@Test @Test
fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() = fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() =
runTest { testScope.runTest {
// Set topics version to 10 // Set topics version to 10
synchronizer.updateChangeListVersions { synchronizer.updateChangeListVersions {
copy(topicVersion = 10) copy(topicVersion = 10)
@ -133,7 +137,7 @@ class OfflineFirstTopicsRepositoryTest {
@Test @Test
fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() = fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest { testScope.runTest {
val networkTopics = network.getTopics() val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity) .map(NetworkTopic::asEntity)
.map(TopicEntity::asExternalModel) .map(TopicEntity::asExternalModel)

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.data.repository package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
@ -23,6 +24,8 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@ -33,30 +36,37 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
class OfflineFirstUserDataRepositoryTest { class OfflineFirstUserDataRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstUserDataRepository private lateinit var subject: OfflineFirstUserDataRepository
private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource
private val analyticsHelper = NoOpAnalyticsHelper()
@get:Rule @get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before @Before
fun setup() { fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource( niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore(), tmpFolder.testUserPreferencesDataStore(testScope),
) )
subject = OfflineFirstUserDataRepository( subject = OfflineFirstUserDataRepository(
niaPreferencesDataSource = niaPreferencesDataSource, niaPreferencesDataSource = niaPreferencesDataSource,
analyticsHelper,
) )
} }
@Test @Test
fun offlineFirstUserDataRepository_default_user_data_is_correct() = fun offlineFirstUserDataRepository_default_user_data_is_correct() =
runTest { testScope.runTest {
assertEquals( assertEquals(
UserData( UserData(
bookmarkedNewsResources = emptySet(), bookmarkedNewsResources = emptySet(),
viewedNewsResources = emptySet(),
followedTopics = emptySet(), followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
@ -69,7 +79,7 @@ class OfflineFirstUserDataRepositoryTest {
@Test @Test
fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.toggleFollowedTopicId(followedTopicId = "0", followed = true) subject.toggleFollowedTopicId(followedTopicId = "0", followed = true)
assertEquals( assertEquals(
@ -100,7 +110,7 @@ class OfflineFirstUserDataRepositoryTest {
@Test @Test
fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2")) subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2"))
assertEquals( assertEquals(
@ -122,7 +132,7 @@ class OfflineFirstUserDataRepositoryTest {
@Test @Test
fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true) subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true)
assertEquals( assertEquals(
@ -152,8 +162,39 @@ class OfflineFirstUserDataRepositoryTest {
} }
@Test @Test
fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() =
runTest { runTest {
subject.setNewsResourceViewed(newsResourceId = "0", viewed = true)
assertEquals(
setOf("0"),
subject.userData
.map { it.viewedNewsResources }
.first(),
)
subject.setNewsResourceViewed(newsResourceId = "1", viewed = true)
assertEquals(
setOf("0", "1"),
subject.userData
.map { it.viewedNewsResources }
.first(),
)
assertEquals(
niaPreferencesDataSource.userData
.map { it.viewedNewsResources }
.first(),
subject.userData
.map { it.viewedNewsResources }
.first(),
)
}
@Test
fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() =
testScope.runTest {
subject.setThemeBrand(ThemeBrand.ANDROID) subject.setThemeBrand(ThemeBrand.ANDROID)
assertEquals( assertEquals(
@ -173,7 +214,7 @@ class OfflineFirstUserDataRepositoryTest {
@Test @Test
fun offlineFirstUserDataRepository_set_dynamic_color_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_dynamic_color_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.setDynamicColorPreference(true) subject.setDynamicColorPreference(true)
assertEquals( assertEquals(
@ -193,7 +234,7 @@ class OfflineFirstUserDataRepositoryTest {
@Test @Test
fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.setDarkThemeConfig(DarkThemeConfig.DARK) subject.setDarkThemeConfig(DarkThemeConfig.DARK)
assertEquals( assertEquals(
@ -213,7 +254,7 @@ class OfflineFirstUserDataRepositoryTest {
@Test @Test
fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() = fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() =
runTest { testScope.runTest {
subject.setFollowedTopicIds(setOf("1")) subject.setFollowedTopicIds(setOf("1"))
subject.setShouldHideOnboarding(true) subject.setShouldHideOnboarding(true)
assertTrue(subject.userData.first().shouldHideOnboarding) assertTrue(subject.userData.first().shouldHideOnboarding)

@ -21,12 +21,10 @@ import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEnti
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
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.model.data.NewsResourceType.Video
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.datetime.Instant
val filteredInterestsIds = setOf("1") val filteredInterestsIds = setOf("1")
val nonPresentInterestsIds = setOf("2") val nonPresentInterestsIds = setOf("2")
@ -37,56 +35,72 @@ val nonPresentInterestsIds = setOf("2")
class TestNewsResourceDao : NewsResourceDao { class TestNewsResourceDao : NewsResourceDao {
private var entitiesStateFlow = MutableStateFlow( private var entitiesStateFlow = MutableStateFlow(
listOf( emptyList<NewsResourceEntity>(),
NewsResourceEntity(
id = "1",
title = "news",
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
),
),
) )
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf() internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
override fun getNewsResources(): Flow<List<PopulatedNewsResource>> =
entitiesStateFlow.map {
it.map(NewsResourceEntity::asPopulatedNewsResource)
}
override fun getNewsResources( override fun getNewsResources(
useFilterTopicIds: Boolean,
filterTopicIds: Set<String>, filterTopicIds: Set<String>,
useFilterNewsIds: Boolean,
filterNewsIds: Set<String>,
): Flow<List<PopulatedNewsResource>> = ): Flow<List<PopulatedNewsResource>> =
getNewsResources() entitiesStateFlow
.map { newsResourceEntities ->
newsResourceEntities.map { entity ->
entity.asPopulatedNewsResource(topicCrossReferences)
}
}
.map { resources -> .map { resources ->
resources.filter { resource -> var result = resources
resource.topics.any { it.id in filterTopicIds } if (useFilterTopicIds) {
result = result.filter { resource ->
resource.topics.any { it.id in filterTopicIds }
}
}
if (useFilterNewsIds) {
result = result.filter { resource ->
resource.entity.id in filterNewsIds
}
} }
result
} }
override suspend fun getOneOffNewsResources(): List<PopulatedNewsResource> = emptyList()
override suspend fun insertOrIgnoreNewsResources( override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>, entities: List<NewsResourceEntity>,
): List<Long> { ): List<Long> {
entitiesStateFlow.value = entities entitiesStateFlow.update { oldValues ->
// Old values come first so new values don't overwrite them
(oldValues + entities)
.distinctBy(NewsResourceEntity::id)
.sortedWith(
compareBy(NewsResourceEntity::publishDate).reversed(),
)
}
// Assume no conflicts on insert // Assume no conflicts on insert
return entities.map { it.id.toLong() } return entities.map { it.id.toLong() }
} }
override suspend fun updateNewsResources(entities: List<NewsResourceEntity>) {
throw NotImplementedError("Unused in tests")
}
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) { override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
entitiesStateFlow.value = newsResourceEntities entitiesStateFlow.update { oldValues ->
// New values come first so they overwrite old values
(newsResourceEntities + oldValues)
.distinctBy(NewsResourceEntity::id)
.sortedWith(
compareBy(NewsResourceEntity::publishDate).reversed(),
)
}
} }
override suspend fun insertOrIgnoreTopicCrossRefEntities( override suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>, newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
) { ) {
topicCrossReferences = newsResourceTopicCrossReferences // Keep old values over new ones
topicCrossReferences = (topicCrossReferences + newsResourceTopicCrossReferences)
.distinctBy { it.newsResourceId to it.topicId }
} }
override suspend fun deleteNewsResources(ids: List<String>) { override suspend fun deleteNewsResources(ids: List<String>) {
@ -97,16 +111,20 @@ class TestNewsResourceDao : NewsResourceDao {
} }
} }
private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource( private fun NewsResourceEntity.asPopulatedNewsResource(
topicCrossReferences: List<NewsResourceTopicCrossRef>,
) = PopulatedNewsResource(
entity = this, entity = this,
topics = listOf( topics = topicCrossReferences
TopicEntity( .filter { it.newsResourceId == id }
id = filteredInterestsIds.random(), .map { newsResourceTopicCrossRef ->
name = "name", TopicEntity(
shortDescription = "short description", id = newsResourceTopicCrossRef.topicId,
longDescription = "long description", name = "name",
url = "URL", shortDescription = "short description",
imageUrl = "image URL", longDescription = "long description",
), url = "URL",
), imageUrl = "image URL",
)
},
) )

@ -29,16 +29,7 @@ import kotlinx.coroutines.flow.update
class TestTopicDao : TopicDao { class TestTopicDao : TopicDao {
private var entitiesStateFlow = MutableStateFlow( private var entitiesStateFlow = MutableStateFlow(
listOf( emptyList<TopicEntity>(),
TopicEntity(
id = "1",
name = "Topic",
shortDescription = "short description",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
),
),
) )
override fun getTopicEntity(topicId: String): Flow<TopicEntity> { override fun getTopicEntity(topicId: String): Flow<TopicEntity> {
@ -52,18 +43,21 @@ class TestTopicDao : TopicDao {
getTopicEntities() getTopicEntities()
.map { topics -> topics.filter { it.id in ids } } .map { topics -> topics.filter { it.id in ids } }
override suspend fun getOneOffTopicEntities(): List<TopicEntity> = emptyList()
override suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> { override suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> {
entitiesStateFlow.value = topicEntities // Keep old values over new values
// Assume no conflicts on insert entitiesStateFlow.update { oldValues ->
(oldValues + topicEntities).distinctBy(TopicEntity::id)
}
return topicEntities.map { it.id.toLong() } return topicEntities.map { it.id.toLong() }
} }
override suspend fun updateTopics(entities: List<TopicEntity>) {
throw NotImplementedError("Unused in tests")
}
override suspend fun upsertTopics(entities: List<TopicEntity>) { override suspend fun upsertTopics(entities: List<TopicEntity>) {
entitiesStateFlow.value = entities // Overwrite old values with new values
entitiesStateFlow.update { oldValues ->
(entities + oldValues).distinctBy(TopicEntity::id)
}
} }
override suspend fun deleteTopics(ids: List<String>) { override suspend fun deleteTopics(ids: List<String>) {

@ -14,10 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.dsl.ManagedVirtualDevice
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
@ -31,20 +27,6 @@ android {
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
} }
namespace = "com.google.samples.apps.nowinandroid.core.database" namespace = "com.google.samples.apps.nowinandroid.core.database"
testOptions {
// TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523)
managedDevices {
devices {
maybeCreate<ManagedVirtualDevice>("pixel4api30").apply {
device = "Pixel 4"
apiLevel = 30
// ATDs currently support only API level 30.
systemImageSource = "aosp-atd"
}
}
}
}
} }
dependencies { dependencies {
@ -54,4 +36,4 @@ dependencies {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
androidTestImplementation(project(":core:testing")) androidTestImplementation(project(":core:testing"))
} }

@ -0,0 +1,282 @@
{
"formatVersion": 1,
"database": {
"version": 13,
"identityHash": "b6b299e53da623b16360975581ebfcfe",
"entities": [
{
"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": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"news_resource_id",
"topic_id"
]
},
"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"
]
}
]
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [],
"tableName": "newsResourcesFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "newsResourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
},
{
"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": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [],
"tableName": "topicsFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "topicId",
"columnName": "topicId",
"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
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"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, 'b6b299e53da623b16360975581ebfcfe')"
]
}
}

@ -0,0 +1,308 @@
{
"formatVersion": 1,
"database": {
"version": 14,
"identityHash": "51271b81bde7c7997d67fb23c8f31780",
"entities": [
{
"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": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"news_resource_id",
"topic_id"
]
},
"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"
]
}
]
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [],
"tableName": "newsResourcesFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "newsResourceId",
"columnName": "newsResourceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
},
{
"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": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [],
"tableName": "topicsFts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "topicId",
"columnName": "topicId",
"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
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "recentSearchQueries",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `queriedDate` INTEGER NOT NULL, PRIMARY KEY(`query`))",
"fields": [
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "queriedDate",
"columnName": "queriedDate",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"query"
]
},
"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, '51271b81bde7c7997d67fb23c8f31780')"
]
}
}

@ -84,6 +84,44 @@ class NewsResourceDaoTest {
) )
} }
@Test
fun newsResourceDao_filters_items_by_news_ids_by_descending_publish_date() = runTest {
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
millisSinceEpoch = 0,
),
testNewsResource(
id = "1",
millisSinceEpoch = 3,
),
testNewsResource(
id = "2",
millisSinceEpoch = 1,
),
testNewsResource(
id = "3",
millisSinceEpoch = 2,
),
)
newsResourceDao.upsertNewsResources(
newsResourceEntities,
)
val savedNewsResourceEntities = newsResourceDao.getNewsResources(
useFilterNewsIds = true,
filterNewsIds = setOf("3", "0"),
)
.first()
assertEquals(
listOf("3", "0"),
savedNewsResourceEntities.map {
it.entity.id
},
)
}
@Test @Test
fun newsResourceDao_filters_items_by_topic_ids_by_descending_publish_date() = runTest { fun newsResourceDao_filters_items_by_topic_ids_by_descending_publish_date() = runTest {
val topicEntities = listOf( val topicEntities = listOf(
@ -132,6 +170,7 @@ class NewsResourceDaoTest {
) )
val filteredNewsResources = newsResourceDao.getNewsResources( val filteredNewsResources = newsResourceDao.getNewsResources(
useFilterTopicIds = true,
filterTopicIds = topicEntities filterTopicIds = topicEntities
.map(TopicEntity::id) .map(TopicEntity::id)
.toSet(), .toSet(),
@ -143,6 +182,68 @@ class NewsResourceDaoTest {
) )
} }
@Test
fun newsResourceDao_filters_items_by_news_and_topic_ids_by_descending_publish_date() = runTest {
val topicEntities = listOf(
testTopicEntity(
id = "1",
name = "1",
),
testTopicEntity(
id = "2",
name = "2",
),
)
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
millisSinceEpoch = 0,
),
testNewsResource(
id = "1",
millisSinceEpoch = 3,
),
testNewsResource(
id = "2",
millisSinceEpoch = 1,
),
testNewsResource(
id = "3",
millisSinceEpoch = 2,
),
)
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef(
newsResourceId = index.toString(),
topicId = topicEntity.id,
)
}
topicDao.insertOrIgnoreTopics(
topicEntities = topicEntities,
)
newsResourceDao.upsertNewsResources(
newsResourceEntities,
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossRefEntities,
)
val filteredNewsResources = newsResourceDao.getNewsResources(
useFilterTopicIds = true,
filterTopicIds = topicEntities
.map(TopicEntity::id)
.toSet(),
useFilterNewsIds = true,
filterNewsIds = setOf("1"),
).first()
assertEquals(
listOf("1"),
filteredNewsResources.map { it.entity.id },
)
}
@Test @Test
fun newsResourceDao_deletes_items_by_ids() = fun newsResourceDao_deletes_items_by_ids() =
runTest { runTest {

@ -17,7 +17,10 @@
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.NewsResourceDao import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
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.dao.TopicFtsDao
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -35,4 +38,19 @@ object DaosModule {
fun providesNewsResourceDao( fun providesNewsResourceDao(
database: NiaDatabase, database: NiaDatabase,
): NewsResourceDao = database.newsResourceDao() ): NewsResourceDao = database.newsResourceDao()
@Provides
fun providesTopicFtsDao(
database: NiaDatabase,
): TopicFtsDao = database.topicFtsDao()
@Provides
fun providesNewsResourceFtsDao(
database: NiaDatabase,
): NewsResourceFtsDao = database.newsResourceFtsDao()
@Provides
fun providesRecentSearchQueryDao(
database: NiaDatabase,
): RecentSearchQueryDao = database.recentSearchQueryDao()
} }

@ -21,10 +21,16 @@ 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.NewsResourceDao import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
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.dao.TopicFtsDao
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.NewsResourceFtsEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
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.TopicFtsEntity
import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter
import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeConverter import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeConverter
@ -32,9 +38,12 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
entities = [ entities = [
NewsResourceEntity::class, NewsResourceEntity::class,
NewsResourceTopicCrossRef::class, NewsResourceTopicCrossRef::class,
NewsResourceFtsEntity::class,
TopicEntity::class, TopicEntity::class,
TopicFtsEntity::class,
RecentSearchQueryEntity::class,
], ],
version = 12, version = 14,
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),
@ -47,6 +56,8 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 9, to = 10), AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class), AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),
AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class), AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class),
AutoMigration(from = 12, to = 13),
AutoMigration(from = 13, to = 14),
], ],
exportSchema = true, exportSchema = true,
) )
@ -57,4 +68,7 @@ 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 newsResourceDao(): NewsResourceDao abstract fun newsResourceDao(): NewsResourceDao
abstract fun topicFtsDao(): TopicFtsDao
abstract fun newsResourceFtsDao(): NewsResourceFtsDao
abstract fun recentSearchQueryDao(): RecentSearchQueryDao
} }

@ -21,7 +21,6 @@ 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.Transaction
import androidx.room.Update
import androidx.room.Upsert import androidx.room.Upsert
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
@ -34,43 +33,48 @@ import kotlinx.coroutines.flow.Flow
*/ */
@Dao @Dao
interface NewsResourceDao { interface NewsResourceDao {
@Transaction
@Query(
value = """
SELECT * FROM news_resources
ORDER BY publish_date DESC
""",
)
fun getNewsResources(): Flow<List<PopulatedNewsResource>>
/**
* Fetches news resources that match the query parameters
*/
@Transaction @Transaction
@Query( @Query(
value = """ value = """
SELECT * FROM news_resources SELECT * FROM news_resources
WHERE id in WHERE
( CASE WHEN :useFilterNewsIds
SELECT news_resource_id FROM news_resources_topics THEN id IN (:filterNewsIds)
WHERE topic_id IN (:filterTopicIds) ELSE 1
) END
AND
CASE WHEN :useFilterTopicIds
THEN id IN
(
SELECT news_resource_id FROM news_resources_topics
WHERE topic_id IN (:filterTopicIds)
)
ELSE 1
END
ORDER BY publish_date DESC ORDER BY publish_date DESC
""", """,
) )
fun getNewsResources( fun getNewsResources(
useFilterTopicIds: Boolean = false,
filterTopicIds: Set<String> = emptySet(), filterTopicIds: Set<String> = emptySet(),
useFilterNewsIds: Boolean = false,
filterNewsIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>> ): Flow<List<PopulatedNewsResource>>
@Transaction
@Query(value = "SELECT * FROM news_resources ORDER BY publish_date DESC")
suspend fun getOneOffNewsResources(): List<PopulatedNewsResource>
/** /**
* Inserts [entities] into the db if they don't exist, and ignores those that do * Inserts [entities] into the db if they don't exist, and ignores those that do
*/ */
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreNewsResources(entities: List<NewsResourceEntity>): List<Long> suspend fun insertOrIgnoreNewsResources(entities: List<NewsResourceEntity>): List<Long>
/**
* Updates [entities] in the db that match the primary key, and no-ops if they don't
*/
@Update
suspend fun updateNewsResources(entities: List<NewsResourceEntity>)
/** /**
* Inserts or updates [newsResourceEntities] in the db under the specified primary keys * Inserts or updates [newsResourceEntities] in the db under the specified primary keys
*/ */

@ -0,0 +1,39 @@
/*
* Copyright 2023 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 com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [NewsResourceFtsEntity] access.
*/
@Dao
interface NewsResourceFtsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(newsResources: List<NewsResourceFtsEntity>)
@Query("SELECT newsResourceId FROM newsResourcesFts WHERE newsResourcesFts MATCH :query")
fun searchAllNewsResources(query: String): Flow<List<String>>
@Query("SELECT count(*) FROM newsResourcesFts")
fun getCount(): Flow<Int>
}

@ -0,0 +1,38 @@
/*
* Copyright 2023 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.Query
import androidx.room.Upsert
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [RecentSearchQueryEntity] access
*/
@Dao
interface RecentSearchQueryDao {
@Query(value = "SELECT * FROM recentSearchQueries ORDER BY queriedDate DESC LIMIT :limit")
fun getRecentSearchQueryEntities(limit: Int): Flow<List<RecentSearchQueryEntity>>
@Upsert
suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity)
@Query(value = "DELETE FROM recentSearchQueries")
suspend fun clearRecentSearchQueries()
}

@ -20,7 +20,6 @@ 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.Update
import androidx.room.Upsert 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
@ -41,6 +40,9 @@ interface TopicDao {
@Query(value = "SELECT * FROM topics") @Query(value = "SELECT * FROM topics")
fun getTopicEntities(): Flow<List<TopicEntity>> fun getTopicEntities(): Flow<List<TopicEntity>>
@Query(value = "SELECT * FROM topics")
suspend fun getOneOffTopicEntities(): List<TopicEntity>
@Query( @Query(
value = """ value = """
SELECT * FROM topics SELECT * FROM topics
@ -55,12 +57,6 @@ interface TopicDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long>
/**
* Updates [entities] in the db that match the primary key, and no-ops if they don't
*/
@Update
suspend fun updateTopics(entities: List<TopicEntity>)
/** /**
* Inserts or updates [entities] in the db under the specified primary keys * Inserts or updates [entities] in the db under the specified primary keys
*/ */

@ -0,0 +1,39 @@
/*
* Copyright 2023 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 com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity
import kotlinx.coroutines.flow.Flow
/**
* DAO for [TopicFtsEntity] access.
*/
@Dao
interface TopicFtsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(topics: List<TopicFtsEntity>)
@Query("SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query")
fun searchAllTopics(query: String): Flow<List<String>>
@Query("SELECT count(*) FROM topicsFts")
fun getCount(): Flow<Int>
}

@ -0,0 +1,44 @@
/*
* Copyright 2023 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.Fts4
/**
* Fts entity for the news resources. See https://developer.android.com/reference/androidx/room/Fts4.
*/
@Entity(tableName = "newsResourcesFts")
@Fts4
data class NewsResourceFtsEntity(
@ColumnInfo(name = "newsResourceId")
val newsResourceId: String,
@ColumnInfo(name = "title")
val title: String,
@ColumnInfo(name = "content")
val content: String,
)
fun NewsResourceEntity.asFtsEntity() = NewsResourceFtsEntity(
newsResourceId = id,
title = title,
content = content,
)

@ -49,3 +49,9 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource(
type = entity.type, type = entity.type,
topics = topics.map(TopicEntity::asExternalModel), topics = topics.map(TopicEntity::asExternalModel),
) )
fun PopulatedNewsResource.asFtsEntity() = NewsResourceFtsEntity(
newsResourceId = entity.id,
title = entity.title,
content = entity.content,
)

@ -0,0 +1,35 @@
/*
* Copyright 2023 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 database entity that stored recent search queries.
*/
@Entity(
tableName = "recentSearchQueries",
)
data class RecentSearchQueryEntity(
@PrimaryKey
val query: String,
@ColumnInfo
val queriedDate: Instant,
)

@ -0,0 +1,48 @@
/*
* Copyright 2023 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.Fts4
/**
* Fts entity for the topic. See https://developer.android.com/reference/androidx/room/Fts4.
*/
@Entity(tableName = "topicsFts")
@Fts4
data class TopicFtsEntity(
@ColumnInfo(name = "topicId")
val topicId: String,
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "shortDescription")
val shortDescription: String,
@ColumnInfo(name = "longDescription")
val longDescription: String,
)
fun TopicEntity.asFtsEntity() = TopicFtsEntity(
topicId = id,
name = name,
shortDescription = shortDescription,
longDescription = longDescription,
)

@ -24,7 +24,8 @@ android {
dependencies { dependencies {
api(project(":core:datastore")) api(project(":core:datastore"))
implementation(project(":core:testing"))
api(libs.androidx.dataStore.core) api(libs.androidx.dataStore.core)
implementation(project(":core:common"))
implementation(project(":core:testing"))
} }

@ -21,10 +21,15 @@ import androidx.datastore.core.DataStoreFactory
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule import com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import javax.inject.Singleton import javax.inject.Singleton
@ -38,16 +43,23 @@ object TestDataStoreModule {
@Provides @Provides
@Singleton @Singleton
fun providesUserPreferencesDataStore( fun providesUserPreferencesDataStore(
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
userPreferencesSerializer: UserPreferencesSerializer, userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder, tmpFolder: TemporaryFolder,
): DataStore<UserPreferences> = ): DataStore<UserPreferences> =
tmpFolder.testUserPreferencesDataStore(userPreferencesSerializer) tmpFolder.testUserPreferencesDataStore(
// TODO: Provide an application-wide CoroutineScope in the DI graph
coroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher),
userPreferencesSerializer = userPreferencesSerializer,
)
} }
fun TemporaryFolder.testUserPreferencesDataStore( fun TemporaryFolder.testUserPreferencesDataStore(
coroutineScope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(), userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(),
) = DataStoreFactory.create( ) = DataStoreFactory.create(
serializer = userPreferencesSerializer, serializer = userPreferencesSerializer,
scope = coroutineScope,
) { ) {
newFile("user_preferences_test.pb") newFile("user_preferences_test.pb")
} }

@ -14,13 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import com.google.protobuf.gradle.builtins
import com.google.protobuf.gradle.generateProtoTasks
import com.google.protobuf.gradle.protobuf
import com.google.protobuf.gradle.protoc
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
@ -33,6 +26,11 @@ android {
consumerProguardFiles("consumer-proguard-rules.pro") consumerProguardFiles("consumer-proguard-rules.pro")
} }
namespace = "com.google.samples.apps.nowinandroid.core.datastore" namespace = "com.google.samples.apps.nowinandroid.core.datastore"
testOptions {
unitTests {
isReturnDefaultValues = true
}
}
} }
// Setup protobuf configuration, generating lite Java and Kotlin classes // Setup protobuf configuration, generating lite Java and Kotlin classes
@ -43,10 +41,10 @@ protobuf {
generateProtoTasks { generateProtoTasks {
all().forEach { task -> all().forEach { task ->
task.builtins { task.builtins {
val java by registering { register("java") {
option("lite") option("lite")
} }
val kotlin by registering { register("kotlin") {
option("lite") option("lite")
} }
} }
@ -57,12 +55,10 @@ protobuf {
dependencies { dependencies {
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:model")) implementation(project(":core:model"))
testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.dataStore.core) implementation(libs.androidx.dataStore.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
testImplementation(project(":core:datastore-test"))
testImplementation(project(":core:testing"))
} }

@ -33,6 +33,7 @@ class NiaPreferencesDataSource @Inject constructor(
.map { .map {
UserData( UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys, bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys,
viewedNewsResources = it.viewedNewsResourceIdsMap.keys,
followedTopics = it.followedTopicIdsMap.keys, followedTopics = it.followedTopicIdsMap.keys,
themeBrand = when (it.themeBrand) { themeBrand = when (it.themeBrand) {
null, null,
@ -137,6 +138,18 @@ class NiaPreferencesDataSource @Inject constructor(
} }
} }
suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
userPreferences.updateData {
it.copy {
if (viewed) {
viewedNewsResourceIds.put(newsResourceId, true)
} else {
viewedNewsResourceIds.remove(newsResourceId)
}
}
}
}
suspend fun getChangeListVersions() = userPreferences.data suspend fun getChangeListVersions() = userPreferences.data
.map { .map {
ChangeListVersions( ChangeListVersions(

@ -40,6 +40,7 @@ message UserPreferences {
map<string, bool> followed_topic_ids = 13; map<string, bool> followed_topic_ids = 13;
map<string, bool> followed_author_ids = 14; map<string, bool> followed_author_ids = 14;
map<string, bool> bookmarked_news_resource_ids = 15; map<string, bool> bookmarked_news_resource_ids = 15;
map<string, bool> viewed_news_resource_ids = 20;
ThemeBrandProto theme_brand = 16; ThemeBrandProto theme_brand = 16;
DarkThemeConfigProto dark_theme_config = 17; DarkThemeConfigProto dark_theme_config = 17;
@ -47,4 +48,6 @@ message UserPreferences {
bool should_hide_onboarding = 18; bool should_hide_onboarding = 18;
bool use_dynamic_color = 19; bool use_dynamic_color = 19;
// NEXT AVAILABLE ID: 21
} }

@ -18,6 +18,8 @@ package com.google.samples.apps.nowinandroid.core.datastore
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
@ -27,6 +29,9 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
class NiaPreferencesDataSourceTest { class NiaPreferencesDataSourceTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: NiaPreferencesDataSource private lateinit var subject: NiaPreferencesDataSource
@get:Rule @get:Rule
@ -35,54 +40,56 @@ class NiaPreferencesDataSourceTest {
@Before @Before
fun setup() { fun setup() {
subject = NiaPreferencesDataSource( subject = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore(), tmpFolder.testUserPreferencesDataStore(testScope),
) )
} }
@Test @Test
fun shouldHideOnboardingIsFalseByDefault() = runTest { fun shouldHideOnboardingIsFalseByDefault() = testScope.runTest {
assertFalse(subject.userData.first().shouldHideOnboarding) assertFalse(subject.userData.first().shouldHideOnboarding)
} }
@Test @Test
fun userShouldHideOnboardingIsTrueWhenSet() = runTest { fun userShouldHideOnboardingIsTrueWhenSet() = testScope.runTest {
subject.setShouldHideOnboarding(true) subject.setShouldHideOnboarding(true)
assertTrue(subject.userData.first().shouldHideOnboarding) assertTrue(subject.userData.first().shouldHideOnboarding)
} }
@Test @Test
fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest { fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() =
// Given: user completes onboarding by selecting a single topic. testScope.runTest {
subject.toggleFollowedTopicId("1", true) // Given: user completes onboarding by selecting a single topic.
subject.setShouldHideOnboarding(true) subject.toggleFollowedTopicId("1", true)
subject.setShouldHideOnboarding(true)
// When: they unfollow that topic. // When: they unfollow that topic.
subject.toggleFollowedTopicId("1", false) subject.toggleFollowedTopicId("1", false)
// Then: onboarding should be shown again // Then: onboarding should be shown again
assertFalse(subject.userData.first().shouldHideOnboarding) assertFalse(subject.userData.first().shouldHideOnboarding)
} }
@Test @Test
fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest { fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() =
// Given: user completes onboarding by selecting several topics. testScope.runTest {
subject.setFollowedTopicIds(setOf("1", "2")) // Given: user completes onboarding by selecting several topics.
subject.setShouldHideOnboarding(true) subject.setFollowedTopicIds(setOf("1", "2"))
subject.setShouldHideOnboarding(true)
// When: they unfollow those topics. // When: they unfollow those topics.
subject.setFollowedTopicIds(emptySet()) subject.setFollowedTopicIds(emptySet())
// Then: onboarding should be shown again // Then: onboarding should be shown again
assertFalse(subject.userData.first().shouldHideOnboarding) assertFalse(subject.userData.first().shouldHideOnboarding)
} }
@Test @Test
fun shouldUseDynamicColorFalseByDefault() = runTest { fun shouldUseDynamicColorFalseByDefault() = testScope.runTest {
assertFalse(subject.userData.first().useDynamicColor) assertFalse(subject.userData.first().useDynamicColor)
} }
@Test @Test
fun userShouldUseDynamicColorIsTrueWhenSet() = runTest { fun userShouldUseDynamicColorIsTrueWhenSet() = testScope.runTest {
subject.setDynamicColorPreference(true) subject.setDynamicColorPreference(true)
assertTrue(subject.userData.first().useDynamicColor) assertTrue(subject.userData.first().useDynamicColor)
} }

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

Loading…
Cancel
Save