Merge branch 'main' into ui-polish

pull/470/head
Chris Sinco 2 years ago
commit 049750dd51

@ -0,0 +1,6 @@
# https://editorconfig.org/
# This configuration is used by ktlint when spotless invokes it
[*.{kt,kts}]
ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=true

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

@ -90,7 +90,7 @@ 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
- name: Upload test reports - name: Upload test reports
if: always() if: always()

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

@ -34,7 +34,7 @@
</option> </option>
<option name="taskNames"> <option name="taskNames">
<list> <list>
<option value=":benchmark:pixel6Api31DemoBenchmarkAndroidTest" /> <option value=":benchmark:pixel6Api31atdDemoBenchmarkAndroidTest" />
<option value="--rerun" /> <option value="--rerun" />
<option value="--enable-display" /> <option value="--enable-display" />
</list> </list>

@ -30,9 +30,15 @@ in.
# Development Environment # Development Environment
**Now in Android** uses the Gradle build system and can be imported directly into the latest stable **Now in Android** uses the Gradle build system and can be imported directly into Android Studio (make sure you are using the latest stable version available [here](https://developer.android.com/studio)).
version of Android Studio (available [here](https://developer.android.com/studio)). The `debug`
build can be built and run using the default configuration. Change the run configuration to `app`.
![image](https://user-images.githubusercontent.com/873212/210559920-ef4a40c5-c8e0-478b-bb00-4879a8cf184a.png)
The `demoDebug` and `demoRelease` build variants can be built and run (the `prod` variants use a backend server which is not currently publicly available).
![image](https://user-images.githubusercontent.com/873212/210560507-44045dc5-b6d5-41ca-9746-f0f7acf22f8e.png)
Once you're up and running, you can refer to the learning journeys below to get a better Once you're up and running, you can refer to the learning journeys below to get a better
understanding of which libraries and tools are being used, the reasoning behind the approaches to understanding of which libraries and tools are being used, the reasoning behind the approaches to

@ -38,7 +38,6 @@ 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.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.NiaDropdownMenuButton
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
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
@ -66,7 +65,7 @@ fun NiaCatalog() {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
item { item {
Text( Text(
@ -93,19 +92,19 @@ fun NiaCatalog() {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
NiaButton( NiaButton(
onClick = {}, onClick = {},
enabled = false enabled = false,
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
enabled = false enabled = false,
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
enabled = false enabled = false,
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
@ -119,21 +118,21 @@ fun NiaCatalog() {
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
} }
} }
@ -146,7 +145,7 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
@ -154,7 +153,7 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
@ -162,28 +161,11 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
} }
} }
item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) } item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaDropdownMenuButton(
text = { Text("Enabled") },
items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {},
itemText = { item -> Text(item) }
)
NiaDropdownMenuButton(
text = { Text("Disabled") },
items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {},
itemText = { item -> Text(item) },
enabled = false
)
}
}
item { Text("Chips", Modifier.padding(top = 16.dp)) } item { Text("Chips", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
@ -191,25 +173,25 @@ fun NiaCatalog() {
NiaFilterChip( NiaFilterChip(
selected = firstChecked, selected = firstChecked,
onSelectedChange = { checked -> firstChecked = checked }, onSelectedChange = { checked -> firstChecked = checked },
label = { Text(text = "Enabled") } label = { Text(text = "Enabled") },
) )
var secondChecked by remember { mutableStateOf(true) } var secondChecked by remember { mutableStateOf(true) }
NiaFilterChip( NiaFilterChip(
selected = secondChecked, selected = secondChecked,
onSelectedChange = { checked -> secondChecked = checked }, onSelectedChange = { checked -> secondChecked = checked },
label = { Text(text = "Enabled") } label = { Text(text = "Enabled") },
) )
NiaFilterChip( NiaFilterChip(
selected = false, selected = false,
onSelectedChange = {}, onSelectedChange = {},
enabled = false, enabled = false,
label = { Text(text = "Disabled") } label = { Text(text = "Disabled") },
) )
NiaFilterChip( NiaFilterChip(
selected = true, selected = true,
onSelectedChange = {}, onSelectedChange = {},
enabled = false, enabled = false,
label = { Text(text = "Disabled") } label = { Text(text = "Disabled") },
) )
} }
} }
@ -223,15 +205,15 @@ fun NiaCatalog() {
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
} },
) )
var secondChecked by remember { mutableStateOf(true) } var secondChecked by remember { mutableStateOf(true) }
NiaIconToggleButton( NiaIconToggleButton(
@ -240,15 +222,15 @@ fun NiaCatalog() {
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
} },
) )
NiaIconToggleButton( NiaIconToggleButton(
checked = false, checked = false,
@ -256,16 +238,16 @@ fun NiaCatalog() {
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
}, },
enabled = false enabled = false,
) )
NiaIconToggleButton( NiaIconToggleButton(
checked = true, checked = true,
@ -273,16 +255,16 @@ fun NiaCatalog() {
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
}, },
enabled = false enabled = false,
) )
} }
} }
@ -294,68 +276,42 @@ fun NiaCatalog() {
expanded = firstExpanded, expanded = firstExpanded,
onExpandedChange = { expanded -> firstExpanded = expanded }, onExpandedChange = { expanded -> firstExpanded = expanded },
compactText = { Text(text = "Compact view") }, compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") } expandedText = { Text(text = "Expanded view") },
) )
var secondExpanded by remember { mutableStateOf(true) } var secondExpanded by remember { mutableStateOf(true) }
NiaViewToggleButton( NiaViewToggleButton(
expanded = secondExpanded, expanded = secondExpanded,
onExpandedChange = { expanded -> secondExpanded = expanded }, onExpandedChange = { expanded -> secondExpanded = expanded },
compactText = { Text(text = "Compact view") }, compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") } expandedText = { Text(text = "Expanded view") },
) )
NiaViewToggleButton( NiaViewToggleButton(
expanded = false, expanded = false,
onExpandedChange = {}, onExpandedChange = {},
compactText = { Text(text = "Disabled") }, compactText = { Text(text = "Disabled") },
expandedText = { Text(text = "Disabled") }, expandedText = { Text(text = "Disabled") },
enabled = false enabled = false,
) )
} }
} }
item { Text("Tags", Modifier.padding(top = 16.dp)) } item { Text("Tags", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
var expandedTopicId by remember { mutableStateOf<String?>(null) }
var firstFollowed by remember { mutableStateOf(false) }
NiaTopicTag( NiaTopicTag(
expanded = expandedTopicId == "Topic 1", followed = true,
followed = firstFollowed, onClick = {},
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) "Topic 1" else null
},
onFollowClick = { firstFollowed = true },
onUnfollowClick = { firstFollowed = false },
onBrowseClick = {},
text = { Text(text = "Topic 1".uppercase()) }, text = { Text(text = "Topic 1".uppercase()) },
followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") }
) )
var secondFollowed by remember { mutableStateOf(true) }
NiaTopicTag( NiaTopicTag(
expanded = expandedTopicId == "Topic 2", followed = false,
followed = secondFollowed, onClick = {},
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) "Topic 2" else null
},
onFollowClick = { secondFollowed = true },
onUnfollowClick = { secondFollowed = false },
onBrowseClick = {},
text = { Text(text = "Topic 2".uppercase()) }, text = { Text(text = "Topic 2".uppercase()) },
followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") }
) )
NiaTopicTag( NiaTopicTag(
expanded = false,
followed = false, followed = false,
onDropdownMenuToggle = {}, onClick = {},
onFollowClick = {},
onUnfollowClick = {},
onBrowseClick = {},
text = { Text(text = "Disabled".uppercase()) }, text = { Text(text = "Disabled".uppercase()) },
enabled = false enabled = false,
) )
} }
} }
@ -368,7 +324,7 @@ fun NiaCatalog() {
NiaTab( NiaTab(
selected = selectedTabIndex == index, selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index }, onClick = { selectedTabIndex = index },
text = { Text(text = title) } text = { Text(text = title) },
) )
} }
} }
@ -380,12 +336,12 @@ fun NiaCatalog() {
val icons = listOf( val icons = listOf(
NiaIcons.UpcomingBorder, NiaIcons.UpcomingBorder,
NiaIcons.MenuBookBorder, NiaIcons.MenuBookBorder,
NiaIcons.BookmarksBorder NiaIcons.BookmarksBorder,
) )
val selectedIcons = listOf( val selectedIcons = listOf(
NiaIcons.Upcoming, NiaIcons.Upcoming,
NiaIcons.MenuBook, NiaIcons.MenuBook,
NiaIcons.Bookmarks NiaIcons.Bookmarks,
) )
val tagIcon = NiaIcons.Tag val tagIcon = NiaIcons.Tag
NiaNavigationBar { NiaNavigationBar {
@ -397,7 +353,7 @@ fun NiaCatalog() {
} else { } else {
Icon( Icon(
painter = painterResource(id = icons[index]), painter = painterResource(id = icons[index]),
contentDescription = item contentDescription = item,
) )
} }
}, },
@ -407,13 +363,13 @@ fun NiaCatalog() {
} else { } else {
Icon( Icon(
painter = painterResource(id = selectedIcons[index]), painter = painterResource(id = selectedIcons[index]),
contentDescription = item contentDescription = item,
) )
} }
}, },
label = { Text(item) }, label = { Text(item) },
selected = selectedItem == index, selected = selectedItem == index,
onClick = { selectedItem = index } onClick = { selectedItem = index },
) )
} }
} }

@ -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

@ -18,17 +18,18 @@ import com.google.samples.apps.nowinandroid.NiaBuildType
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 = 3 versionCode = 5
versionName = "0.0.3" // 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"
@ -89,6 +90,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"))
@ -117,7 +119,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"
}

@ -19,9 +19,11 @@ package com.google.samples.apps.nowinandroid.ui
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.hasTestTag
import androidx.compose.ui.test.hasText
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.onLast
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
@ -29,10 +31,6 @@ import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity import com.google.samples.apps.nowinandroid.MainActivity
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
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.interests.R as FeatureInterestsR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR
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
@ -40,6 +38,10 @@ 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 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.interests.R as FeatureInterestsR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR
/** /**
* Tests all the navigation flows that are handled by the navigation library. * Tests all the navigation flows that are handled by the navigation library.
@ -57,7 +59,8 @@ class NavigationTest {
* Create a temporary folder used to create a Data Store file. This guarantees that * Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash. * the file is removed in between each test, preventing a crash.
*/ */
@BindValue @get:Rule(order = 1) @BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/** /**
@ -165,7 +168,6 @@ class NavigationTest {
@Test @Test
fun topLevelDestinations_showTopBarWithTitle() { fun topLevelDestinations_showTopBarWithTitle() {
composeTestRule.apply { composeTestRule.apply {
// Verify that the top bar contains the app name on the first screen. // Verify that the top bar contains the app name on the first screen.
onNodeWithText(appName).assertExists() onNodeWithText(appName).assertExists()
@ -207,14 +209,18 @@ class NavigationTest {
@Test @Test
fun whenSettingsDialogDismissed_previousScreenIsDisplayed() { fun whenSettingsDialogDismissed_previousScreenIsDisplayed() {
composeTestRule.apply { composeTestRule.apply {
// Navigate to the saved screen, open the settings dialog, then close it. // Navigate to the saved screen, open the settings dialog, then close it.
onNodeWithText(saved).performClick() onNodeWithText(saved).performClick()
onNodeWithContentDescription(settings).performClick() onNodeWithContentDescription(settings).performClick()
onNodeWithText(ok).performClick() onNodeWithText(ok).performClick()
// Check that the saved screen is still visible and selected. // Check that the saved screen is still visible and selected.
onAllNodesWithText(saved).onLast().assertIsSelected() onNode(
hasText(saved) and
hasAnyAncestor(
hasTestTag("NiaBottomBar") or hasTestTag("NiaNavRail"),
),
).assertIsSelected()
} }
} }

@ -30,11 +30,11 @@ import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActi
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 javax.inject.Inject
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 javax.inject.Inject
/** /**
* Tests that the navigation UI is rendered correctly on different screen sizes. * Tests that the navigation UI is rendered correctly on different screen sizes.
@ -53,7 +53,8 @@ class NavigationUiTest {
* Create a temporary folder used to create a Data Store file. This guarantees that * Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash. * the file is removed in between each test, preventing a crash.
*/ */
@BindValue @get:Rule(order = 1) @BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/** /**
@ -77,9 +78,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -90,53 +91,53 @@ class NavigationUiTest {
} }
@Test @Test
fun mediumWidth_compactHeight_showsNavigationBar() { fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent { composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) { TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
} }
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
} }
@Test @Test
fun expandedWidth_compactHeight_showsNavigationBar() { fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent { composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) { TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
} }
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed() composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist() composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
} }
@Test @Test
fun compcatWidth_mediumHeight_showsNavigationBar() { fun compactWidth_mediumHeight_showsNavigationBar() {
composeTestRule.setContent { composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 500.dp)) { TestHarness(size = DpSize(400.dp, 500.dp)) {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -153,9 +154,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -172,9 +173,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -191,9 +192,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -210,9 +211,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }
@ -229,9 +230,9 @@ class NavigationUiTest {
BoxWithConstraints { BoxWithConstraints {
NiaApp( NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize( windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight) DpSize(maxWidth, maxHeight),
), ),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
} }

@ -31,15 +31,15 @@ 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.testing.util.TestNetworkMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** /**
* Tests [NiaAppState]. * Tests [NiaAppState].
@ -70,7 +70,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
navController = navController, navController = navController,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -91,7 +91,7 @@ class NiaAppStateTest {
composeTestRule.setContent { composeTestRule.setContent {
state = rememberNiaAppState( state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor networkMonitor = networkMonitor,
) )
} }
@ -108,7 +108,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(), windowSizeClass = getCompactWindowClass(),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -123,7 +123,7 @@ class NiaAppStateTest {
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -133,13 +133,12 @@ class NiaAppStateTest {
@Test @Test
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)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -149,13 +148,12 @@ class NiaAppStateTest {
@Test @Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
coroutineScope = backgroundScope coroutineScope = backgroundScope,
) )
} }
@ -163,7 +161,7 @@ class NiaAppStateTest {
networkMonitor.setConnected(false) networkMonitor.setConnected(false)
assertEquals( assertEquals(
true, true,
state.isOffline.value state.isOffline.value,
) )
} }

@ -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>

File diff suppressed because it is too large Load Diff

@ -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,16 +38,18 @@ 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.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
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint @AndroidEntryPoint
@ -61,6 +64,9 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var analyticsHelper: AnalyticsHelper
val viewModel: MainActivityViewModel by viewModels() val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -104,14 +110,17 @@ class MainActivity : ComponentActivity() {
onDispose {} onDispose {}
} }
NiaTheme( CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
darkTheme = darkTheme, NiaTheme(
androidTheme = shouldUseAndroidTheme(uiState) darkTheme = darkTheme,
) { androidTheme = shouldUseAndroidTheme(uiState),
NiaApp( disableDynamicTheming = shouldDisableDynamicTheming(uiState),
networkMonitor = networkMonitor, ) {
windowSizeClass = calculateWindowSizeClass(this), NiaApp(
) networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this),
)
}
} }
} }
} }
@ -141,6 +150,17 @@ private fun shouldUseAndroidTheme(
} }
} }
/**
* Returns `true` if the dynamic color is disabled, as a function of the [uiState].
*/
@Composable
private fun shouldDisableDynamicTheming(
uiState: MainActivityUiState,
): Boolean = when (uiState) {
Loading -> false
is Success -> !uiState.userData.useDynamicColor
}
/** /**
* Returns `true` if dark theme should be used, as a function of the [uiState] and the * Returns `true` if dark theme should be used, as a function of the [uiState] and the
* current system context. * current system context.

@ -23,22 +23,22 @@ import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
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.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainActivityViewModel @Inject constructor( class MainActivityViewModel @Inject constructor(
userDataRepository: UserDataRepository userDataRepository: UserDataRepository,
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map { val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
Success(it) Success(it)
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
initialValue = Loading, initialValue = Loading,
started = SharingStarted.WhileSubscribed(5_000) started = SharingStarted.WhileSubscribed(5_000),
) )
} }

@ -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 https://github.com/coil-kt/coil/blob/main/coil-singleton/src/main/java/coil/Coil.kt#L63
*/
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.components {
add(SvgDecoder.Factory())
}
.build()
}
} }

@ -47,7 +47,7 @@ object JankStatsModule {
@Provides @Provides
fun providesJankStats( fun providesJankStats(
window: Window, window: Window,
frameListener: JankStats.OnFrameListener frameListener: JankStats.OnFrameListener,
): JankStats { ): JankStats {
return JankStats.createAndTrack(window, frameListener) return JankStats.createAndTrack(window, frameListener)
} }

@ -37,24 +37,27 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
@Composable @Composable
fun NiaNavHost( fun NiaNavHost(
navController: NavHostController, navController: NavHostController,
onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute startDestination: String = forYouNavigationRoute,
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
modifier = modifier, modifier = modifier,
) { ) {
forYouScreen() // TODO: handle topic clicks from each top level destination
bookmarksScreen() forYouScreen(onTopicClick = {})
bookmarksScreen(onTopicClick = {})
interestsGraph( interestsGraph(
navigateToTopic = { topicId -> onTopicClick = { topicId ->
navController.navigateToTopic(topicId) navController.navigateToTopic(topicId)
}, },
nestedGraphs = { nestedGraphs = {
topicScreen(onBackClick) topicScreen(
} onBackClick = navController::popBackStack,
onTopicClick = {},
)
},
) )
} }
} }

@ -34,24 +34,24 @@ enum class TopLevelDestination(
val selectedIcon: Icon, val selectedIcon: Icon,
val unselectedIcon: Icon, val unselectedIcon: Icon,
val iconTextId: Int, val iconTextId: Int,
val titleTextId: Int val titleTextId: Int,
) { ) {
FOR_YOU( FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming), selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder), unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you, iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name titleTextId = R.string.app_name,
), ),
BOOKMARKS( BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks), selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder), unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved, iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved titleTextId = bookmarksR.string.saved,
), ),
INTERESTS( INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
iconTextId = interestsR.string.interests, iconTextId = interestsR.string.interests,
titleTextId = interestsR.string.interests titleTextId = interestsR.string.interests,
) ),
} }

@ -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
@ -50,7 +50,6 @@ 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.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
@ -66,16 +65,17 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class, ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class,
ExperimentalLifecycleComposeApi::class
) )
@Composable @Composable
fun NiaApp( fun NiaApp(
@ -83,104 +83,105 @@ fun NiaApp(
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
appState: NiaAppState = rememberNiaAppState( appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass windowSizeClass = windowSizeClass,
), ),
) { ) {
val background: @Composable (@Composable () -> Unit) -> Unit = val shouldShowGradientBackground =
when (appState.currentTopLevelDestination) { appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
TopLevelDestination.FOR_YOU -> {
content ->
NiaGradientBackground(content = content)
}
else -> { content -> NiaBackground(content = content) }
}
background { NiaBackground {
val snackbarHostState = remember { SnackbarHostState() } NiaGradientBackground(
gradientColors = if (shouldShowGradientBackground) {
val isOffline by appState.isOffline.collectAsStateWithLifecycle() LocalGradientColors.current
} else {
// If user is not connected to the internet show a snack bar to inform them. GradientColors()
val notConnectedMessage = stringResource(R.string.not_connected) },
LaunchedEffect(isOffline) { ) {
if (isOffline) snackbarHostState.showSnackbar( val snackbarHostState = remember { SnackbarHostState() }
message = notConnectedMessage,
duration = Indefinite
)
}
if (appState.shouldShowSettingsDialog) { val isOffline by appState.isOffline.collectAsStateWithLifecycle()
SettingsDialog(
onDismiss = { appState.setShowSettingsDialog(false) }
)
}
Scaffold( // If user is not connected to the internet show a snack bar to inform them.
modifier = Modifier.semantics { val notConnectedMessage = stringResource(R.string.not_connected)
testTagsAsResourceId = true LaunchedEffect(isOffline) {
}, if (isOffline) {
containerColor = Color.Transparent, snackbarHostState.showSnackbar(
contentColor = MaterialTheme.colorScheme.onBackground, message = notConnectedMessage,
contentWindowInsets = WindowInsets(0, 0, 0, 0), duration = Indefinite,
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar")
) )
} }
} }
) { padding ->
Row(
Modifier
.fillMaxSize()
.padding(padding)
.consumedWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
)
)
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding()
)
}
Column(Modifier.fillMaxSize()) { if (appState.shouldShowSettingsDialog) {
// Show the top app bar on top level destinations. SettingsDialog(
val destination = appState.currentTopLevelDestination onDismiss = { appState.setShowSettingsDialog(false) },
if (destination != null) { )
NiaTopAppBar( }
titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings, Scaffold(
actionIconContentDescription = stringResource( modifier = Modifier.semantics {
id = settingsR.string.top_app_bar_action_icon_description testTagsAsResourceId = true
), },
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = Color.Transparent,
containerColor = Color.Transparent contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"),
)
}
},
) { padding ->
Row(
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal,
), ),
onActionClick = { appState.setShowSettingsDialog(true) } ),
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding(),
) )
} }
NiaNavHost( Column(Modifier.fillMaxSize()) {
navController = appState.navController, // Show the top app bar on top level destinations.
onBackClick = appState::onBackClick val destination = appState.currentTopLevelDestination
) if (destination != null) {
} NiaTopAppBar(
titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description,
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
),
onActionClick = { appState.setShowSettingsDialog(true) },
)
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that NiaNavHost(appState.navController)
// content doesn't display behind it. }
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
}
} }
} }
} }
@ -208,15 +209,15 @@ private fun NiaNavRail(
when (icon) { when (icon) {
is ImageVectorIcon -> Icon( is ImageVectorIcon -> Icon(
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,
) )
} }
}, },
label = { Text(stringResource(destination.iconTextId)) } label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }
@ -227,10 +228,10 @@ private fun NiaBottomBar(
destinations: List<TopLevelDestination>, destinations: List<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit, onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?, currentDestination: NavDestination?,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
NiaNavigationBar( NiaNavigationBar(
modifier = modifier modifier = modifier,
) { ) {
destinations.forEach { destination -> destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
@ -246,16 +247,16 @@ private fun NiaBottomBar(
when (icon) { when (icon) {
is ImageVectorIcon -> Icon( is ImageVectorIcon -> Icon(
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,
) )
} }
}, },
label = { Text(stringResource(destination.iconTextId)) } label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -56,7 +55,7 @@ fun rememberNiaAppState(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
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(navController, coroutineScope, windowSizeClass, networkMonitor) {
@ -87,8 +86,7 @@ class NiaAppState(
private set private set
val shouldShowBottomBar: Boolean val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact || get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
val shouldShowNavRail: Boolean val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar get() = !shouldShowBottomBar
@ -98,7 +96,7 @@ class NiaAppState(
.stateIn( .stateIn(
scope = coroutineScope, scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = false initialValue = false,
) )
/** /**
@ -138,10 +136,6 @@ class NiaAppState(
} }
} }
fun onBackClick() {
navController.popBackStack()
}
fun setShowSettingsDialog(shouldShow: Boolean) { fun setShowSettingsDialog(shouldShow: Boolean) {
shouldShowSettingsDialog = shouldShow shouldShowSettingsDialog = shouldShow
} }

@ -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>

@ -65,18 +65,6 @@ android {
targetProjectPath = ":app" targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true experimentalProperties["android.experimental.self-instrumenting"] = true
testOptions {
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "aosp"
}
}
}
}
} }
dependencies { dependencies {

@ -28,7 +28,7 @@ import androidx.test.uiautomator.HasChildrenOp.EXACTLY
*/ */
fun untilHasChildren( fun untilHasChildren(
childCount: Int = 1, childCount: Int = 1,
op: HasChildrenOp = AT_LEAST op: HasChildrenOp = AT_LEAST,
): UiObject2Condition<Boolean> { ): UiObject2Condition<Boolean> {
return object : UiObject2Condition<Boolean>() { return object : UiObject2Condition<Boolean>() {
override fun apply(element: UiObject2): Boolean { override fun apply(element: UiObject2): Boolean {
@ -44,5 +44,5 @@ fun untilHasChildren(
enum class HasChildrenOp { enum class HasChildrenOp {
AT_LEAST, AT_LEAST,
EXACTLY, EXACTLY,
AT_MOST AT_MOST,
} }

@ -18,11 +18,12 @@ package com.google.samples.apps.nowinandroid.baselineprofile
import androidx.benchmark.macro.ExperimentalBaselineProfilesApi import androidx.benchmark.macro.ExperimentalBaselineProfilesApi
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.interests.interestsScrollPeopleDownUp import com.google.samples.apps.nowinandroid.interests.goToInterestsScreen
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -46,24 +47,16 @@ class BaselineProfileGenerator {
// Scroll the feed critical user journey // Scroll the feed critical user journey
forYouWaitForContent() forYouWaitForContent()
forYouSelectTopics(true)
forYouScrollFeedDownUp() forYouScrollFeedDownUp()
// Navigate to saved screen // Navigate to saved screen
device.findObject(By.text("Saved")).click() goToBookmarksScreen()
device.waitForIdle()
// TODO: we need to implement adding stuff to bookmarks before able to scroll it // TODO: we need to implement adding stuff to bookmarks before able to scroll it
// bookmarksScrollFeedDownUp() // bookmarksScrollFeedDownUp()
// Navigate to interests screen // Navigate to interests screen
device.findObject(By.text("Interests")).click() goToInterestsScreen()
device.waitForIdle()
interestsScrollTopicsDownUp() interestsScrollTopicsDownUp()
// Navigate to people tab
device.findObject(By.text("People")).click()
device.waitForIdle()
interestsScrollPeopleDownUp()
} }
} }

@ -18,8 +18,18 @@ package com.google.samples.apps.nowinandroid.bookmarks
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.goToBookmarksScreen() {
device.findObject(By.text("Saved")).click()
device.waitForIdle()
// Wait until saved title are shown on screen
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000)
val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Saved")), 2_000)
}
fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() { fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() {
val feedList = device.findObject(By.res("bookmarks:feed")) val feedList = device.findObject(By.res("bookmarks:feed"))
device.flingElementDownUp(feedList) device.flingElementDownUp(feedList)

@ -23,12 +23,65 @@ import androidx.test.uiautomator.untilHasChildren
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.forYouWaitForContent() { fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded by checking if authors are loaded // Wait until content is loaded by checking if topics are loaded
device.wait(Until.gone(By.res("forYou:loadingWheel")), 5_000) device.wait(Until.gone(By.res("loadingWheel")), 5_000)
// Sometimes, the loading wheel is gone, but the content is not loaded yet // Sometimes, the loading wheel is gone, but the content is not loaded yet
// So we'll wait here for authors to be sure // So we'll wait here for topics to be sure
val obj = device.findObject(By.res("forYou:authors")) val obj = device.findObject(By.res("forYou:topicSelection"))
obj.wait(untilHasChildren(), 30_000) // Timeout here is quite big, because sometimes data loading takes a long time!
obj.wait(untilHasChildren(), 60_000)
}
/**
* Selects some topics, which will show the feed content for them.
* [recheckTopicsIfChecked] Topics may be already checked from the previous iteration.
*/
fun MacrobenchmarkScope.forYouSelectTopics(recheckTopicsIfChecked: Boolean = false) {
val topics = device.findObject(By.res("forYou:topicSelection"))
// Set gesture margin from sides not to trigger system gesture navigation
val horizontalMargin = 10 * topics.visibleBounds.width() / 100
topics.setGestureMargins(horizontalMargin, 0, horizontalMargin, 0)
// Select some topics to show some feed content
var index = 0
var visited = 0
while (visited < 3) {
// Selecting some topics, which will populate items in the feed.
val topic = topics.children[index % topics.childCount]
// Find the checkable element to figure out whether it's checked or not
val topicCheckIcon = topic.findObject(By.checkable(true))
// Topic icon may not be visible if it's out of the screen boundaries
// If that's the case, let's try another index
if (topicCheckIcon == null) {
index++
continue
}
when {
// Topic wasn't checked, so just do that
!topicCheckIcon.isChecked -> {
topic.click()
device.waitForIdle()
}
// Topic was checked already and we want to recheck it, so just do it twice
recheckTopicsIfChecked -> {
repeat(2) {
topic.click()
device.waitForIdle()
}
}
else -> {
// Topic is checked, but we don't recheck it
}
}
index++
visited++
}
} }
fun MacrobenchmarkScope.forYouScrollFeedDownUp() { fun MacrobenchmarkScope.forYouScrollFeedDownUp() {

@ -47,9 +47,10 @@ class ScrollForYouFeedBenchmark {
// Start the app // Start the app
pressHome() pressHome()
startActivityAndWait() startActivityAndWait()
} },
) { ) {
forYouWaitForContent() forYouWaitForContent()
forYouSelectTopics()
forYouScrollFeedDownUp() forYouScrollFeedDownUp()
} }
} }

@ -21,16 +21,23 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.goToInterestsScreen() {
device.findObject(By.text("Interests")).click()
device.waitForIdle()
// Wait until interests are shown on screen
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000)
val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Interests")), 2_000)
// Wait until content is loaded by checking if interests are loaded
device.wait(Until.gone(By.res("loadingWheel")), 5_000)
}
fun MacrobenchmarkScope.interestsScrollTopicsDownUp() { fun MacrobenchmarkScope.interestsScrollTopicsDownUp() {
val topicsList = device.findObject(By.res("interests:topics")) val topicsList = device.findObject(By.res("interests:topics"))
device.flingElementDownUp(topicsList) device.flingElementDownUp(topicsList)
} }
fun MacrobenchmarkScope.interestsScrollPeopleDownUp() {
val peopleList = device.findObject(By.res("interests:people"))
device.flingElementDownUp(peopleList)
}
fun MacrobenchmarkScope.interestsWaitForTopics() { fun MacrobenchmarkScope.interestsWaitForTopics() {
device.wait(Until.hasObject(By.text("Accessibility")), 30_000) device.wait(Until.hasObject(By.text("Accessibility")), 30_000)
} }

@ -51,7 +51,7 @@ class TopicsScreenRecompositionBenchmark {
// Navigate to interests screen // Navigate to interests screen
device.findObject(By.text("Interests")).click() device.findObject(By.text("Interests")).click()
device.waitForIdle() device.waitForIdle()
} },
) { ) {
interestsWaitForTopics() interestsWaitForTopics()
repeat(3) { repeat(3) {

@ -66,7 +66,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
@Test @Test
fun startupBaselineProfileDisabled() = startup( fun startupBaselineProfileDisabled() = startup(
CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1) CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1),
) )
@Test @Test
@ -83,7 +83,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
startupMode = startupMode, startupMode = startupMode,
setupBlock = { setupBlock = {
pressHome() pressHome()
} },
) { ) {
startActivityAndWait() startActivityAndWait()
// Waits until the content is ready to capture Time To Full Display // Waits until the content is ready to capture Time To Full Display

@ -21,13 +21,16 @@ plugins {
group = "com.google.samples.apps.nowinandroid.buildlogic" group = "com.google.samples.apps.nowinandroid.buildlogic"
java { java {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_11
} }
dependencies { dependencies {
compileOnly(libs.android.gradlePlugin) compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.firebase.performance.gradle)
compileOnly(libs.firebase.crashlytics.gradle)
compileOnly(libs.ksp.gradlePlugin)
} }
gradlePlugin { gradlePlugin {
@ -68,9 +71,17 @@ gradlePlugin {
id = "nowinandroid.android.hilt" id = "nowinandroid.android.hilt"
implementationClass = "AndroidHiltConventionPlugin" implementationClass = "AndroidHiltConventionPlugin"
} }
register("firebase-perf") { register("androidRoom") {
id = "nowinandroid.firebase-perf" id = "nowinandroid.android.room"
implementationClass = "FirebasePerfConventionPlugin" implementationClass = "AndroidRoomConventionPlugin"
}
register("androidFirebase") {
id = "nowinandroid.android.application.firebase"
implementationClass = "AndroidApplicationFirebaseConventionPlugin"
}
register("androidFlavors") {
id = "nowinandroid.android.application.flavors"
implementationClass = "AndroidApplicationFlavorsConventionPlugin"
} }
} }
} }

@ -14,9 +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.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.configurePrintApksTask import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin import org.gradle.api.Plugin
@ -34,7 +34,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 33 defaultConfig.targetSdk = 33
configureFlavors(this) configureGradleManagedDevices(this)
} }
extensions.configure<ApplicationAndroidComponentsExtension> { extensions.configure<ApplicationAndroidComponentsExtension> {
configurePrintApksTask(this) configurePrintApksTask(this)

@ -0,0 +1,58 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
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<ApplicationAndroidComponentsExtension> {
finalizeDsl {
it.buildTypes.forEach { buildType ->
// Disable the Crashlytics mapping file upload. This feature should only be
// enabled if a Firebase backend is available and configured in
// google-services.json.
buildType.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)
} }
} }
} }
} }

@ -14,7 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.gradle.LibraryExtension import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
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
@ -35,6 +37,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
testInstrumentationRunner = testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
} }
configureGradleManagedDevices(this)
} }
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs") val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
@ -46,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,10 +14,10 @@
* 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
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.configurePrintApksTask import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin import org.gradle.api.Plugin
@ -40,6 +40,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 33 defaultConfig.targetSdk = 33
configureFlavors(this) configureFlavors(this)
configureGradleManagedDevices(this)
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this) configurePrintApksTask(this)

@ -0,0 +1,63 @@
/*
* 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.google.devtools.ksp.gradle.KspExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.process.CommandLineArgumentProvider
import java.io.File
class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
extensions.configure<KspExtension> {
// The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations.
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
add("implementation", libs.findLibrary("room.runtime").get())
add("implementation", libs.findLibrary("room.ktx").get())
add("ksp", libs.findLibrary("room.compiler").get())
}
}
}
/**
* https://issuetracker.google.com/issues/132245929
* [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas)
*/
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File,
) : CommandLineArgumentProvider {
override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}")
}
}

@ -15,6 +15,7 @@
*/ */
import com.android.build.gradle.TestExtension import com.android.build.gradle.TestExtension
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
@ -31,6 +32,7 @@ class AndroidTestConventionPlugin : Plugin<Project> {
extensions.configure<TestExtension> { extensions.configure<TestExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 31 defaultConfig.targetSdk = 31
configureGradleManagedDevices(this)
} }
} }
} }

@ -0,0 +1,63 @@
/*
* 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.dsl.CommonExtension
import com.android.build.api.dsl.ManagedVirtualDevice
import org.gradle.api.Project
import org.gradle.kotlin.dsl.invoke
import java.util.Locale
/**
* Configure project for Gradle managed devices
*/
internal fun configureGradleManagedDevices(
commonExtension: CommonExtension<*, *, *, *>,
) {
val deviceConfigs = listOf(
DeviceConfig("Pixel 4", 30, "aosp-atd"),
DeviceConfig("Pixel 6", 31, "aosp"),
DeviceConfig("Pixel C", 30, "aosp-atd"),
)
commonExtension.testOptions {
managedDevices {
devices {
deviceConfigs.forEach { deviceConfig ->
maybeCreate(deviceConfig.taskName, ManagedVirtualDevice::class.java).apply {
device = deviceConfig.device
apiLevel = deviceConfig.apiLevel
systemImageSource = deviceConfig.systemImageSource
}
}
}
}
}
}
private data class DeviceConfig(
val device: String,
val apiLevel: Int,
val systemImageSource: String,
) {
val taskName = buildString {
append(device.toLowerCase(Locale.ROOT).replace(" ", ""))
append("api")
append(apiLevel.toString())
append(systemImageSource.replace("-", ""))
}
}

@ -40,8 +40,8 @@ internal fun Project.configureKotlinAndroid(
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
@ -59,8 +59,8 @@ internal fun Project.configureKotlinAndroid(
"-opt-in=kotlin.Experimental", "-opt-in=kotlin.Experimental",
) )
// Set JVM target to 1.8 // Set JVM target to 11
jvmTarget = JavaVersion.VERSION_1_8.toString() 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(

@ -22,13 +22,17 @@ 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.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,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.
*/
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(libs.kotlinx.coroutines.android)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.core.ktx)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
}

@ -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
}

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
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,8 +14,4 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<resources> <manifest />
<string name="follow">Follow</string>
<string name="unfollow">Unfollow</string>
<string name="browse_topic">Browse topic</string>
</resources>

@ -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"
}
}
}

@ -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,14 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.domain.model package com.google.samples.apps.nowinandroid.core.analytics
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
/** /**
* A [NewsResource] with the additional information for whether it is saved. * Interface for logging analytics events. See `FirebaseAnalyticsHelper` and
* `StubAnalyticsHelper` for implementations.
*/ */
data class SaveableNewsResource( interface AnalyticsHelper {
val newsResource: NewsResource, fun logEvent(event: AnalyticsEvent)
val isSaved: Boolean, }
)

@ -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,5 @@ import kotlin.annotation.AnnotationRetention.RUNTIME
annotation class Dispatcher(val niaDispatcher: NiaDispatchers) annotation class Dispatcher(val niaDispatcher: NiaDispatchers)
enum class NiaDispatchers { enum class NiaDispatchers {
IO IO,
} }

@ -17,10 +17,10 @@
package com.google.samples.apps.nowinandroid.core.result package com.google.samples.apps.nowinandroid.core.result
import app.cash.turbine.test import app.cash.turbine.test
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class ResultKtTest { class ResultKtTest {
@ -38,11 +38,12 @@ class ResultKtTest {
when (val errorResult = awaitItem()) { when (val errorResult = awaitItem()) {
is Result.Error -> assertEquals( is Result.Error -> assertEquals(
"Test Done", "Test Done",
errorResult.exception?.message errorResult.exception?.message,
) )
Result.Loading, Result.Loading,
is Result.Success -> throw IllegalStateException( is Result.Success,
"The flow should have emitted an Error Result" -> throw IllegalStateException(
"The flow should have emitted an Error Result",
) )
} }

@ -17,9 +17,9 @@
package com.google.samples.apps.nowinandroid.core.data.test package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor { class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor {
override val isOnline: Flow<Boolean> = flowOf(true) override val isOnline: Flow<Boolean> = flowOf(true)

@ -32,26 +32,26 @@ import dagger.hilt.testing.TestInstallIn
@Module @Module
@TestInstallIn( @TestInstallIn(
components = [SingletonComponent::class], components = [SingletonComponent::class],
replaces = [DataModule::class] replaces = [DataModule::class],
) )
interface TestDataModule { interface TestDataModule {
@Binds @Binds
fun bindsTopicRepository( fun bindsTopicRepository(
fakeTopicsRepository: FakeTopicsRepository fakeTopicsRepository: FakeTopicsRepository,
): TopicsRepository ): TopicsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
fakeNewsRepository: FakeNewsRepository fakeNewsRepository: FakeNewsRepository,
): NewsRepository ): NewsRepository
@Binds @Binds
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository userDataRepository: FakeUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor networkMonitor: AlwaysOnlineNetworkMonitor,
): NetworkMonitor ): NetworkMonitor
} }

@ -35,6 +35,7 @@ dependencies {
implementation(project(":core:database")) implementation(project(":core:database"))
implementation(project(":core:datastore")) implementation(project(":core:datastore"))
implementation(project(":core:network")) implementation(project(":core:network"))
implementation(project(":core:analytics"))
testImplementation(project(":core:testing")) testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test")) testImplementation(project(":core:datastore-test"))

@ -19,9 +19,9 @@ package com.google.samples.apps.nowinandroid.core.data
import android.util.Log import android.util.Log
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.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlin.coroutines.cancellation.CancellationException
/** /**
* Interface marker for a class that manages synchronization between local data and a remote * Interface marker for a class that manages synchronization between local data and a remote
@ -62,7 +62,7 @@ private suspend fun <T> suspendRunCatching(block: suspend () -> T): Result<T> =
Log.i( Log.i(
"suspendRunCatching", "suspendRunCatching",
"Failed to evaluate a suspendRunCatchingBlock. Returning failure Result", "Failed to evaluate a suspendRunCatchingBlock. Returning failure Result",
exception exception,
) )
Result.failure(exception) Result.failure(exception)
} }
@ -116,10 +116,10 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
flow4: Flow<T4>, flow4: Flow<T4>,
flow5: Flow<T5>, flow5: Flow<T5>,
flow6: Flow<T6>, flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine( ): Flow<R> = combine(
combine(flow, flow2, flow3, ::Triple), combine(flow, flow2, flow3, ::Triple),
combine(flow4, flow5, flow6, ::Triple) combine(flow4, flow5, flow6, ::Triple),
) { t1, t2 -> ) { t1, t2 ->
transform( transform(
t1.first, t1.first,
@ -127,6 +127,6 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
t1.third, t1.third,
t2.first, t2.first,
t2.second, t2.second,
t2.third t2.third,
) )
} }

@ -35,21 +35,21 @@ interface DataModule {
@Binds @Binds
fun bindsTopicRepository( fun bindsTopicRepository(
topicsRepository: OfflineFirstTopicsRepository topicsRepository: OfflineFirstTopicsRepository,
): TopicsRepository ): TopicsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository newsRepository: OfflineFirstNewsRepository,
): NewsRepository ): NewsRepository
@Binds @Binds
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor networkMonitor: ConnectivityManagerNetworkMonitor,
): NetworkMonitor ): NetworkMonitor
} }

@ -62,6 +62,6 @@ fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef>
topics.map { topicId -> topics.map { topicId ->
NewsResourceTopicCrossRef( NewsResourceTopicCrossRef(
newsResourceId = id, newsResourceId = id,
topicId = topicId topicId = topicId,
) )
} }

@ -25,5 +25,5 @@ fun NetworkTopic.asEntity() = TopicEntity(
shortDescription = shortDescription, shortDescription = shortDescription,
longDescription = longDescription, longDescription = longDescription,
url = url, url = url,
imageUrl = imageUrl imageUrl = imageUrl,
) )

@ -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),
)
}

@ -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>>
} }

@ -30,9 +30,13 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
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 javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
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].
@ -44,14 +48,13 @@ class OfflineFirstNewsRepository @Inject constructor(
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
) : 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 +69,29 @@ class OfflineFirstNewsRepository @Inject constructor(
}, },
modelDeleter = newsResourceDao::deleteNewsResources, modelDeleter = newsResourceDao::deleteNewsResources,
modelUpdater = { changedIds -> modelUpdater = { changedIds ->
val networkNewsResources = network.getNewsResources(ids = changedIds) changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->
val networkNewsResources = network.getNewsResources(ids = chunkedIds)
// Order of invocation matters to satisfy id and foreign key constraints! // Order of invocation matters to satisfy id and foreign key constraints!
topicDao.insertOrIgnoreTopics( topicDao.insertOrIgnoreTopics(
topicEntities = networkNewsResources topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells) .map(NetworkNewsResource::topicEntityShells)
.flatten() .flatten()
.distinctBy(TopicEntity::id) .distinctBy(TopicEntity::id),
) )
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources newsResourceEntities = networkNewsResources.map(
.map(NetworkNewsResource::asEntity) NetworkNewsResource::asEntity,
) ),
newsResourceDao.insertOrIgnoreTopicCrossRefEntities( )
newsResourceTopicCrossReferences = networkNewsResources newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
.map(NetworkNewsResource::topicCrossReferences) newsResourceTopicCrossReferences = networkNewsResources
.distinct() .map(NetworkNewsResource::topicCrossReferences)
.flatten() .distinct()
) .flatten(),
} )
}
},
) )
} }

@ -26,9 +26,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
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.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Disk storage backed implementation of the [TopicsRepository]. * Disk storage backed implementation of the [TopicsRepository].
@ -59,8 +59,8 @@ class OfflineFirstTopicsRepository @Inject constructor(
modelUpdater = { changedIds -> modelUpdater = { changedIds ->
val networkTopics = network.getTopics(ids = changedIds) val networkTopics = network.getTopics(ids = changedIds)
topicDao.upsertTopics( topicDao.upsertTopics(
entities = networkTopics.map(NetworkTopic::asEntity) entities = networkTopics.map(NetworkTopic::asEntity),
) )
} },
) )
} }

@ -16,35 +16,57 @@
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
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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 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) {
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)
}
} }

@ -53,6 +53,11 @@ interface UserDataRepository {
*/ */
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
/**
* Sets the preferred dynamic color config.
*/
suspend fun setDynamicColorPreference(useDynamicColor: Boolean)
/** /**
* Sets whether the user has completed the onboarding process. * Sets whether the user has completed the onboarding process.
*/ */

@ -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
@ -26,11 +27,11 @@ import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
/** /**
* Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String. * Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String.
@ -40,29 +41,31 @@ import kotlinx.coroutines.flow.flowOn
*/ */
class FakeNewsRepository @Inject constructor( class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
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)

@ -22,12 +22,12 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
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.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and * Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and
@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.map
*/ */
class FakeTopicsRepository @Inject constructor( class FakeTopicsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource private val datasource: FakeNiaNetworkDataSource,
) : TopicsRepository { ) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> = flow { override fun getTopics(): Flow<List<Topic>> = flow {
emit( emit(
@ -49,9 +49,9 @@ class FakeTopicsRepository @Inject constructor(
shortDescription = it.shortDescription, shortDescription = it.shortDescription,
longDescription = it.longDescription, longDescription = it.longDescription,
url = it.url, url = it.url,
imageUrl = it.imageUrl imageUrl = it.imageUrl,
) )
} },
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)

@ -21,8 +21,8 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
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
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/** /**
* Fake implementation of the [UserDataRepository] that returns hardcoded user data. * Fake implementation of the [UserDataRepository] that returns hardcoded user data.
@ -55,6 +55,10 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
} }
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
} }

@ -26,14 +26,14 @@ import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import javax.inject.Inject
class ConnectivityManagerNetworkMonitor @Inject constructor( class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context,
) : NetworkMonitor { ) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow { override val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>() val connectivityManager = context.getSystemService<ConnectivityManager>()
@ -54,7 +54,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
override fun onCapabilitiesChanged( override fun onCapabilitiesChanged(
network: Network, network: Network,
networkCapabilities: NetworkCapabilities networkCapabilities: NetworkCapabilities,
) { ) {
channel.trySend(connectivityManager.isCurrentlyConnected()) channel.trySend(connectivityManager.isCurrentlyConnected())
} }
@ -64,7 +64,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
Builder() Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(), .build(),
callback callback,
) )
channel.trySend(connectivityManager.isCurrentlyConnected()) channel.trySend(connectivityManager.isCurrentlyConnected())

@ -20,9 +20,9 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Art
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NetworkEntityKtTest { class NetworkEntityKtTest {

@ -27,6 +27,7 @@ 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
@ -35,16 +36,20 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen
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.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 kotlin.test.assertEquals
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
class OfflineFirstNewsRepositoryTest { class OfflineFirstNewsRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstNewsRepository private lateinit var subject: OfflineFirstNewsRepository
private lateinit var newsResourceDao: TestNewsResourceDao private lateinit var newsResourceDao: TestNewsResourceDao
@ -65,8 +70,8 @@ class OfflineFirstNewsRepositoryTest {
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
synchronizer = TestSynchronizer( synchronizer = TestSynchronizer(
NiaPreferencesDataSource( NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore() tmpFolder.testUserPreferencesDataStore(testScope),
) ),
) )
subject = OfflineFirstNewsRepository( subject = OfflineFirstNewsRepository(
@ -78,43 +83,48 @@ class OfflineFirstNewsRepositoryTest {
@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()
.map(PopulatedNewsResource::asExternalModel), .map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources() subject.getNewsResources()
.first() .first(),
) )
} }
@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(),
) )
} }
@Test @Test
fun offlineFirstNewsRepository_sync_pulls_from_network() = fun offlineFirstNewsRepository_sync_pulls_from_network() =
runTest { testScope.runTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val newsResourcesFromNetwork = network.getNewsResources() val newsResourcesFromNetwork = network.getNewsResources()
@ -126,20 +136,20 @@ 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,
) )
} }
@Test @Test
fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() = fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest { testScope.runTest {
val newsResourcesFromNetwork = network.getNewsResources() val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
@ -155,7 +165,7 @@ class OfflineFirstNewsRepositoryTest {
network.editCollection( network.editCollection(
collectionType = CollectionType.NewsResources, collectionType = CollectionType.NewsResources,
id = it, id = it,
isDelete = true isDelete = true,
) )
} }
@ -167,20 +177,20 @@ 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,
) )
} }
@Test @Test
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() =
runTest { testScope.runTest {
// Set news version to 7 // Set news version to 7
synchronizer.updateChangeListVersions { synchronizer.updateChangeListVersions {
copy(newsResourceVersion = 7) copy(newsResourceVersion = 7)
@ -190,7 +200,7 @@ class OfflineFirstNewsRepositoryTest {
val changeList = network.changeListsAfter( val changeList = network.changeListsAfter(
CollectionType.NewsResources, CollectionType.NewsResources,
version = 7 version = 7,
) )
val changeListIds = changeList val changeListIds = changeList
.map(NetworkChangeList::id) .map(NetworkChangeList::id)
@ -206,43 +216,47 @@ 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,
) )
} }
@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),
actual = topicDao.getTopicEntities()
.first() .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),
) )
} }
} }

@ -28,16 +28,20 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
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.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 kotlin.test.assertEquals
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
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,31 +60,31 @@ 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)
subject = OfflineFirstTopicsRepository( subject = OfflineFirstTopicsRepository(
topicDao = topicDao, topicDao = topicDao,
network = network network = network,
) )
} }
@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()
.map(TopicEntity::asExternalModel), .map(TopicEntity::asExternalModel),
subject.getTopics() subject.getTopics()
.first() .first(),
) )
} }
@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()
@ -91,19 +95,19 @@ class OfflineFirstTopicsRepositoryTest {
assertEquals( assertEquals(
networkTopics.map(TopicEntity::id), networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id) dbTopics.map(TopicEntity::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
@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)
@ -121,19 +125,19 @@ class OfflineFirstTopicsRepositoryTest {
assertEquals( assertEquals(
networkTopics.map(TopicEntity::id), networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id) dbTopics.map(TopicEntity::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
@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)
@ -149,7 +153,7 @@ class OfflineFirstTopicsRepositoryTest {
network.editCollection( network.editCollection(
collectionType = CollectionType.Topics, collectionType = CollectionType.Topics,
id = it, id = it,
isDelete = true isDelete = true,
) )
} }
@ -162,13 +166,13 @@ class OfflineFirstTopicsRepositoryTest {
// 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(
networkTopics.map(Topic::id) - deletedItems, networkTopics.map(Topic::id) - deletedItems,
dbTopics.map(Topic::id) dbTopics.map(Topic::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
} }

@ -16,66 +16,76 @@
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
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand 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 kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
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
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.assertFalse
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(),
followedTopics = emptySet(), followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false useDynamicColor = false,
shouldHideOnboarding = false,
), ),
subject.userData.first() subject.userData.first(),
) )
} }
@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(
setOf("0"), setOf("0"),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
subject.toggleFollowedTopicId(followedTopicId = "1", followed = true) subject.toggleFollowedTopicId(followedTopicId = "1", followed = true)
@ -84,7 +94,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0", "1"), setOf("0", "1"),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
assertEquals( assertEquals(
@ -93,20 +103,20 @@ class OfflineFirstUserDataRepositoryTest {
.first(), .first(),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
} }
@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(
setOf("1", "2"), setOf("1", "2"),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
assertEquals( assertEquals(
@ -115,20 +125,20 @@ class OfflineFirstUserDataRepositoryTest {
.first(), .first(),
subject.userData subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
} }
@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(
setOf("0"), setOf("0"),
subject.userData subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true) subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true)
@ -137,7 +147,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0", "1"), setOf("0", "1"),
subject.userData subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
assertEquals( assertEquals(
@ -146,53 +156,73 @@ class OfflineFirstUserDataRepositoryTest {
.first(), .first(),
subject.userData subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
} }
@Test @Test
fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.setThemeBrand(ThemeBrand.ANDROID) subject.setThemeBrand(ThemeBrand.ANDROID)
assertEquals( assertEquals(
ThemeBrand.ANDROID, ThemeBrand.ANDROID,
subject.userData subject.userData
.map { it.themeBrand } .map { it.themeBrand }
.first() .first(),
) )
assertEquals( assertEquals(
ThemeBrand.ANDROID, ThemeBrand.ANDROID,
niaPreferencesDataSource niaPreferencesDataSource
.userData .userData
.map { it.themeBrand } .map { it.themeBrand }
.first() .first(),
)
}
@Test
fun offlineFirstUserDataRepository_set_dynamic_color_delegates_to_nia_preferences() =
testScope.runTest {
subject.setDynamicColorPreference(true)
assertEquals(
true,
subject.userData
.map { it.useDynamicColor }
.first(),
)
assertEquals(
true,
niaPreferencesDataSource
.userData
.map { it.useDynamicColor }
.first(),
) )
} }
@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(
DarkThemeConfig.DARK, DarkThemeConfig.DARK,
subject.userData subject.userData
.map { it.darkThemeConfig } .map { it.darkThemeConfig }
.first() .first(),
) )
assertEquals( assertEquals(
DarkThemeConfig.DARK, DarkThemeConfig.DARK,
niaPreferencesDataSource niaPreferencesDataSource
.userData .userData
.map { it.darkThemeConfig } .map { it.darkThemeConfig }
.first() .first(),
) )
} }
@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)

@ -24,12 +24,12 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
* Test synchronizer that delegates to [NiaPreferencesDataSource] * Test synchronizer that delegates to [NiaPreferencesDataSource]
*/ */
class TestSynchronizer( class TestSynchronizer(
private val niaPreferences: NiaPreferencesDataSource private val niaPreferences: NiaPreferencesDataSource,
) : Synchronizer { ) : Synchronizer {
override suspend fun getChangeListVersions(): ChangeListVersions = override suspend fun getChangeListVersions(): ChangeListVersions =
niaPreferences.getChangeListVersions() niaPreferences.getChangeListVersions()
override suspend fun updateChangeListVersions( override suspend fun updateChangeListVersions(
update: ChangeListVersions.() -> ChangeListVersions update: ChangeListVersions.() -> ChangeListVersions,
) = niaPreferences.updateChangeListVersion(update) ) = niaPreferences.updateChangeListVersion(update)
} }

@ -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,40 +35,45 @@ 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(
filterTopicIds: Set<String> useFilterTopicIds: Boolean,
filterTopicIds: Set<String>,
useFilterNewsIds: Boolean,
filterNewsIds: Set<String>,
): Flow<List<PopulatedNewsResource>> = ): Flow<List<PopulatedNewsResource>> =
getNewsResources() entitiesStateFlow
.map { it.map(NewsResourceEntity::asPopulatedNewsResource) }
.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 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() }
} }
@ -80,13 +83,22 @@ class TestNewsResourceDao : NewsResourceDao {
} }
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>) {
@ -107,6 +119,6 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
), ),
) )

@ -27,7 +27,7 @@ import kotlinx.serialization.json.Json
enum class CollectionType { enum class CollectionType {
Topics, Topics,
NewsResources NewsResources,
} }
/** /**
@ -37,7 +37,7 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
private val source = FakeNiaNetworkDataSource( private val source = FakeNiaNetworkDataSource(
UnconfinedTestDispatcher(), UnconfinedTestDispatcher(),
Json { ignoreUnknownKeys = true } Json { ignoreUnknownKeys = true },
) )
private val allTopics = runBlocking { source.getTopics() } private val allTopics = runBlocking { source.getTopics() }
@ -54,13 +54,13 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> = override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
allTopics.matchIds( allTopics.matchIds(
ids = ids, ids = ids,
idGetter = NetworkTopic::id idGetter = NetworkTopic::id,
) )
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> = override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds( allNewsResources.matchIds(
ids = ids, ids = ids,
idGetter = NetworkNewsResource::id idGetter = NetworkNewsResource::id,
) )
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> = override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
@ -102,7 +102,7 @@ fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> =
*/ */
private fun <T> List<T>.matchIds( private fun <T> List<T>.matchIds(
ids: List<String>?, ids: List<String>?,
idGetter: (T) -> String idGetter: (T) -> String,
) = when (ids) { ) = when (ids) {
null -> this null -> this
else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } } else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } }
@ -113,7 +113,7 @@ private fun <T> List<T>.matchIds(
* [after] simulates which models have changed by excluding items before it * [after] simulates which models have changed by excluding items before it
*/ */
private fun <T> List<T>.mapToChangeList( private fun <T> List<T>.mapToChangeList(
idGetter: (T) -> String idGetter: (T) -> String,
) = mapIndexed { index, item -> ) = mapIndexed { index, item ->
NetworkChangeList( NetworkChangeList(
id = idGetter(item), id = idGetter(item),

@ -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> {
@ -53,8 +44,10 @@ class TestTopicDao : TopicDao {
.map { topics -> topics.filter { it.id in ids } } .map { topics -> topics.filter { it.id in ids } }
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() }
} }
@ -63,7 +56,10 @@ class TestTopicDao : TopicDao {
} }
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>) {

@ -19,9 +19,9 @@ package com.google.samples.apps.nowinandroid.core.database.model
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 kotlin.test.assertEquals
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class PopulatedNewsResourceKtTest { class PopulatedNewsResourceKtTest {
@Test @Test
@ -44,7 +44,7 @@ class PopulatedNewsResourceKtTest {
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
), ),
) )
val newsResource = populatedNewsResource.asExternalModel() val newsResource = populatedNewsResource.asExternalModel()
@ -66,10 +66,10 @@ class PopulatedNewsResourceKtTest {
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
) ),
), ),
newsResource newsResource,
) )
} }
} }

@ -17,8 +17,8 @@
package com.google.samples.apps.nowinandroid.core.database.util package com.google.samples.apps.nowinandroid.core.database.util
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlin.test.assertEquals
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceTypeConverterTest { class NewsResourceTypeConverterTest {
@ -26,7 +26,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_video() { fun test_room_news_resource_type_converter_for_video() {
assertEquals( assertEquals(
NewsResourceType.Video, NewsResourceType.Video,
NewsResourceTypeConverter().stringToNewsResourceType("Video 📺") NewsResourceTypeConverter().stringToNewsResourceType("Video 📺"),
) )
} }
@ -34,7 +34,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_article() { fun test_room_news_resource_type_converter_for_article() {
assertEquals( assertEquals(
NewsResourceType.Article, NewsResourceType.Article,
NewsResourceTypeConverter().stringToNewsResourceType("Article 📚") NewsResourceTypeConverter().stringToNewsResourceType("Article 📚"),
) )
} }
@ -42,7 +42,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_api_change() { fun test_room_news_resource_type_converter_for_api_change() {
assertEquals( assertEquals(
NewsResourceType.APIChange, NewsResourceType.APIChange,
NewsResourceTypeConverter().stringToNewsResourceType("API change") NewsResourceTypeConverter().stringToNewsResourceType("API change"),
) )
} }
@ -50,7 +50,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_codelab() { fun test_room_news_resource_type_converter_for_codelab() {
assertEquals( assertEquals(
NewsResourceType.Codelab, NewsResourceType.Codelab,
NewsResourceTypeConverter().stringToNewsResourceType("Codelab") NewsResourceTypeConverter().stringToNewsResourceType("Codelab"),
) )
} }
@ -58,7 +58,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_podcast() { fun test_room_news_resource_type_converter_for_podcast() {
assertEquals( assertEquals(
NewsResourceType.Podcast, NewsResourceType.Podcast,
NewsResourceTypeConverter().stringToNewsResourceType("Podcast 🎙") NewsResourceTypeConverter().stringToNewsResourceType("Podcast 🎙"),
) )
} }
@ -66,7 +66,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_docs() { fun test_room_news_resource_type_converter_for_docs() {
assertEquals( assertEquals(
NewsResourceType.Docs, NewsResourceType.Docs,
NewsResourceTypeConverter().stringToNewsResourceType("Docs 📑") NewsResourceTypeConverter().stringToNewsResourceType("Docs 📑"),
) )
} }
@ -74,7 +74,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_event() { fun test_room_news_resource_type_converter_for_event() {
assertEquals( assertEquals(
NewsResourceType.Event, NewsResourceType.Event,
NewsResourceTypeConverter().stringToNewsResourceType("Event 📆") NewsResourceTypeConverter().stringToNewsResourceType("Event 📆"),
) )
} }
@ -82,7 +82,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_dac() { fun test_room_news_resource_type_converter_for_dac() {
assertEquals( assertEquals(
NewsResourceType.DAC, NewsResourceType.DAC,
NewsResourceTypeConverter().stringToNewsResourceType("DAC") NewsResourceTypeConverter().stringToNewsResourceType("DAC"),
) )
} }
@ -90,7 +90,7 @@ class NewsResourceTypeConverterTest {
fun test_room_news_resource_type_converter_for_umm() { fun test_room_news_resource_type_converter_for_umm() {
assertEquals( assertEquals(
NewsResourceType.Unknown, NewsResourceType.Unknown,
NewsResourceTypeConverter().stringToNewsResourceType("umm") NewsResourceTypeConverter().stringToNewsResourceType("umm"),
) )
} }
} }

@ -13,25 +13,20 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed // TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION") @Suppress("DSL_SCOPE_VIOLATION")
plugins { plugins {
id("nowinandroid.android.library") id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco") id("nowinandroid.android.library.jacoco")
id("nowinandroid.android.hilt") id("nowinandroid.android.hilt")
alias(libs.plugins.ksp) id("nowinandroid.android.room")
} }
android { android {
defaultConfig { defaultConfig {
// The schemas directory contains a schema file for each version of the Room database. testInstrumentationRunner =
// This is required to enable Room auto migrations. "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
} }
namespace = "com.google.samples.apps.nowinandroid.core.database" namespace = "com.google.samples.apps.nowinandroid.core.database"
} }
@ -39,12 +34,8 @@ android {
dependencies { dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
androidTestImplementation(project(":core:testing")) androidTestImplementation(project(":core:testing"))
} }

@ -25,12 +25,12 @@ import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopi
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.model.data.NewsResourceType import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType
import kotlin.test.assertEquals
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.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceDaoTest { class NewsResourceDaoTest {
@ -43,7 +43,7 @@ class NewsResourceDaoTest {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder( db = Room.inMemoryDatabaseBuilder(
context, context,
NiaDatabase::class.java NiaDatabase::class.java,
).build() ).build()
newsResourceDao = db.newsResourceDao() newsResourceDao = db.newsResourceDao()
topicDao = db.topicDao() topicDao = db.topicDao()
@ -70,7 +70,7 @@ class NewsResourceDaoTest {
), ),
) )
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities newsResourceEntities,
) )
val savedNewsResourceEntities = newsResourceDao.getNewsResources() val savedNewsResourceEntities = newsResourceDao.getNewsResources()
@ -80,7 +80,45 @@ class NewsResourceDaoTest {
listOf(3L, 2L, 1L, 0L), listOf(3L, 2L, 1L, 0L),
savedNewsResourceEntities.map { savedNewsResourceEntities.map {
it.asExternalModel().publishDate.toEpochMilliseconds() it.asExternalModel().publishDate.toEpochMilliseconds()
} },
)
}
@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
},
) )
} }
@ -89,11 +127,11 @@ class NewsResourceDaoTest {
val topicEntities = listOf( val topicEntities = listOf(
testTopicEntity( testTopicEntity(
id = "1", id = "1",
name = "1" name = "1",
), ),
testTopicEntity( testTopicEntity(
id = "2", id = "2",
name = "2" name = "2",
), ),
) )
val newsResourceEntities = listOf( val newsResourceEntities = listOf(
@ -117,21 +155,22 @@ class NewsResourceDaoTest {
val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity -> val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity ->
NewsResourceTopicCrossRef( NewsResourceTopicCrossRef(
newsResourceId = index.toString(), newsResourceId = index.toString(),
topicId = topicEntity.id topicId = topicEntity.id,
) )
} }
topicDao.insertOrIgnoreTopics( topicDao.insertOrIgnoreTopics(
topicEntities = topicEntities topicEntities = topicEntities,
) )
newsResourceDao.upsertNewsResources( newsResourceDao.upsertNewsResources(
newsResourceEntities newsResourceEntities,
) )
newsResourceDao.insertOrIgnoreTopicCrossRefEntities( newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossRefEntities newsResourceTopicCrossRefEntities,
) )
val filteredNewsResources = newsResourceDao.getNewsResources( val filteredNewsResources = newsResourceDao.getNewsResources(
useFilterTopicIds = true,
filterTopicIds = topicEntities filterTopicIds = topicEntities
.map(TopicEntity::id) .map(TopicEntity::id)
.toSet(), .toSet(),
@ -139,7 +178,69 @@ class NewsResourceDaoTest {
assertEquals( assertEquals(
listOf("1", "0"), listOf("1", "0"),
filteredNewsResources.map { it.entity.id } filteredNewsResources.map { it.entity.id },
)
}
@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 },
) )
} }
@ -169,7 +270,7 @@ class NewsResourceDaoTest {
val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 } val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 }
newsResourceDao.deleteNewsResources( newsResourceDao.deleteNewsResources(
toDelete.map(NewsResourceEntity::id) toDelete.map(NewsResourceEntity::id),
) )
assertEquals( assertEquals(
@ -177,26 +278,26 @@ class NewsResourceDaoTest {
.toSet(), .toSet(),
newsResourceDao.getNewsResources().first() newsResourceDao.getNewsResources().first()
.map { it.entity.id } .map { it.entity.id }
.toSet() .toSet(),
) )
} }
} }
private fun testTopicEntity( private fun testTopicEntity(
id: String = "0", id: String = "0",
name: String name: String,
) = TopicEntity( ) = TopicEntity(
id = id, id = id,
name = name, name = name,
shortDescription = "", shortDescription = "",
longDescription = "", longDescription = "",
url = "", url = "",
imageUrl = "" imageUrl = "",
) )
private fun testNewsResource( private fun testNewsResource(
id: String = "0", id: String = "0",
millisSinceEpoch: Long = 0 millisSinceEpoch: Long = 0,
) = NewsResourceEntity( ) = NewsResourceEntity(
id = id, id = id,
title = "", title = "",

@ -33,31 +33,31 @@ object DatabaseMigrations {
@RenameColumn( @RenameColumn(
tableName = "topics", tableName = "topics",
fromColumnName = "description", fromColumnName = "description",
toColumnName = "shortDescription" toColumnName = "shortDescription",
) )
class Schema2to3 : AutoMigrationSpec class Schema2to3 : AutoMigrationSpec
@DeleteColumn( @DeleteColumn(
tableName = "news_resources", tableName = "news_resources",
columnName = "episode_id" columnName = "episode_id",
) )
@DeleteTable.Entries( @DeleteTable.Entries(
DeleteTable( DeleteTable(
tableName = "episodes_authors" tableName = "episodes_authors",
), ),
DeleteTable( DeleteTable(
tableName = "episodes" tableName = "episodes",
) ),
) )
class Schema10to11 : AutoMigrationSpec class Schema10to11 : AutoMigrationSpec
@DeleteTable.Entries( @DeleteTable.Entries(
DeleteTable( DeleteTable(
tableName = "news_resources_authors" tableName = "news_resources_authors",
), ),
DeleteTable( DeleteTable(
tableName = "authors" tableName = "authors",
) ),
) )
class Schema11to12 : AutoMigrationSpec class Schema11to12 : AutoMigrationSpec
} }

@ -35,6 +35,6 @@ object DatabaseModule {
): NiaDatabase = Room.databaseBuilder( ): NiaDatabase = Room.databaseBuilder(
context, context,
NiaDatabase::class.java, NiaDatabase::class.java,
"nia-database" "nia-database",
).build() ).build()
} }

@ -46,7 +46,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 8, to = 9), AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10), AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class), 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),
], ],
exportSchema = true, exportSchema = true,
) )

@ -34,29 +34,36 @@ 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>>
/** /**
@ -79,7 +86,7 @@ interface NewsResourceDao {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopicCrossRefEntities( suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef> newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
) )
/** /**
@ -89,7 +96,7 @@ interface NewsResourceDao {
value = """ value = """
DELETE FROM news_resources DELETE FROM news_resources
WHERE id in (:ids) WHERE id in (:ids)
""" """,
) )
suspend fun deleteNewsResources(ids: List<String>) suspend fun deleteNewsResources(ids: List<String>)
} }

@ -34,7 +34,7 @@ interface TopicDao {
value = """ value = """
SELECT * FROM topics SELECT * FROM topics
WHERE id = :topicId WHERE id = :topicId
""" """,
) )
fun getTopicEntity(topicId: String): Flow<TopicEntity> fun getTopicEntity(topicId: String): Flow<TopicEntity>
@ -45,7 +45,7 @@ interface TopicDao {
value = """ value = """
SELECT * FROM topics SELECT * FROM topics
WHERE id IN (:ids) WHERE id IN (:ids)
""" """,
) )
fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>>
@ -74,7 +74,7 @@ interface TopicDao {
value = """ value = """
DELETE FROM topics DELETE FROM topics
WHERE id in (:ids) WHERE id in (:ids)
""" """,
) )
suspend fun deleteTopics(ids: List<String>) suspend fun deleteTopics(ids: List<String>)
} }

@ -27,7 +27,7 @@ import kotlinx.datetime.Instant
* Defines an NiA news resource. * Defines an NiA news resource.
*/ */
@Entity( @Entity(
tableName = "news_resources" tableName = "news_resources",
) )
data class NewsResourceEntity( data class NewsResourceEntity(
@PrimaryKey @PrimaryKey
@ -50,5 +50,5 @@ fun NewsResourceEntity.asExternalModel() = NewsResource(
headerImageUrl = headerImageUrl, headerImageUrl = headerImageUrl,
publishDate = publishDate, publishDate = publishDate,
type = type, type = type,
topics = listOf() topics = listOf(),
) )

@ -32,13 +32,13 @@ import androidx.room.Index
entity = NewsResourceEntity::class, entity = NewsResourceEntity::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["news_resource_id"], childColumns = ["news_resource_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE,
), ),
ForeignKey( ForeignKey(
entity = TopicEntity::class, entity = TopicEntity::class,
parentColumns = ["id"], parentColumns = ["id"],
childColumns = ["topic_id"], childColumns = ["topic_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE,
), ),
], ],
indices = [ indices = [

@ -34,9 +34,9 @@ data class PopulatedNewsResource(
value = NewsResourceTopicCrossRef::class, value = NewsResourceTopicCrossRef::class,
parentColumn = "news_resource_id", parentColumn = "news_resource_id",
entityColumn = "topic_id", entityColumn = "topic_id",
) ),
) )
val topics: List<TopicEntity> val topics: List<TopicEntity>,
) )
fun PopulatedNewsResource.asExternalModel() = NewsResource( fun PopulatedNewsResource.asExternalModel() = NewsResource(
@ -47,5 +47,5 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource(
headerImageUrl = entity.headerImageUrl, headerImageUrl = entity.headerImageUrl,
publishDate = entity.publishDate, publishDate = entity.publishDate,
type = entity.type, type = entity.type,
topics = topics.map(TopicEntity::asExternalModel) topics = topics.map(TopicEntity::asExternalModel),
) )

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

@ -21,33 +21,45 @@ 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 javax.inject.Singleton 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
@Module @Module
@TestInstallIn( @TestInstallIn(
components = [SingletonComponent::class], components = [SingletonComponent::class],
replaces = [DataStoreModule::class] replaces = [DataStoreModule::class],
) )
object TestDataStoreModule { 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(
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer() coroutineScope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(),
) = DataStoreFactory.create( ) = DataStoreFactory.create(
serializer = userPreferencesSerializer, serializer = userPreferencesSerializer,
scope = coroutineScope,
) { ) {
newFile("user_preferences_test.pb") newFile("user_preferences_test.pb")
} }

@ -30,14 +30,14 @@ object IntToStringIdsMigration : DataMigration<UserPreferences> {
// Migrate topic ids // Migrate topic ids
deprecatedFollowedTopicIds.clear() deprecatedFollowedTopicIds.clear()
deprecatedFollowedTopicIds.addAll( deprecatedFollowedTopicIds.addAll(
currentData.deprecatedIntFollowedTopicIdsList.map(Int::toString) currentData.deprecatedIntFollowedTopicIdsList.map(Int::toString),
) )
deprecatedIntFollowedTopicIds.clear() deprecatedIntFollowedTopicIds.clear()
// Migrate author ids // Migrate author ids
deprecatedFollowedAuthorIds.clear() deprecatedFollowedAuthorIds.clear()
deprecatedFollowedAuthorIds.addAll( deprecatedFollowedAuthorIds.addAll(
currentData.deprecatedIntFollowedAuthorIdsList.map(Int::toString) currentData.deprecatedIntFollowedAuthorIdsList.map(Int::toString),
) )
deprecatedIntFollowedAuthorIds.clear() deprecatedIntFollowedAuthorIds.clear()

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

Loading…
Cancel
Save