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
disk-size: 6000M
heap-size: 600M
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest
script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest
- name: Upload test reports
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 name="taskNames">
<list>
<option value=":benchmark:pixel6Api31DemoBenchmarkAndroidTest" />
<option value=":benchmark:pixel6Api31atdDemoBenchmarkAndroidTest" />
<option value="--rerun" />
<option value="--enable-display" />
</list>

@ -30,9 +30,15 @@ in.
# Development Environment
**Now in Android** uses the Gradle build system and can be imported directly into the latest stable
version of Android Studio (available [here](https://developer.android.com/studio)). The `debug`
build can be built and run using the default configuration.
**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)).
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
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 com.google.accompanist.flowlayout.FlowRow
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.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
@ -66,7 +65,7 @@ fun NiaCatalog() {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Text(
@ -93,19 +92,19 @@ fun NiaCatalog() {
FlowRow(mainAxisSpacing = 16.dp) {
NiaButton(
onClick = {},
enabled = false
enabled = false,
) {
Text(text = "Disabled")
}
NiaOutlinedButton(
onClick = {},
enabled = false
enabled = false,
) {
Text(text = "Disabled")
}
NiaTextButton(
onClick = {},
enabled = false
enabled = false,
) {
Text(text = "Disabled")
}
@ -119,21 +118,21 @@ fun NiaCatalog() {
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
NiaOutlinedButton(
onClick = {},
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
NiaTextButton(
onClick = {},
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
}
}
@ -146,7 +145,7 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
NiaOutlinedButton(
onClick = {},
@ -154,7 +153,7 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
NiaTextButton(
onClick = {},
@ -162,28 +161,11 @@ fun NiaCatalog() {
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
}
}
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 {
FlowRow(mainAxisSpacing = 16.dp) {
@ -191,25 +173,25 @@ fun NiaCatalog() {
NiaFilterChip(
selected = firstChecked,
onSelectedChange = { checked -> firstChecked = checked },
label = { Text(text = "Enabled") }
label = { Text(text = "Enabled") },
)
var secondChecked by remember { mutableStateOf(true) }
NiaFilterChip(
selected = secondChecked,
onSelectedChange = { checked -> secondChecked = checked },
label = { Text(text = "Enabled") }
label = { Text(text = "Enabled") },
)
NiaFilterChip(
selected = false,
onSelectedChange = {},
enabled = false,
label = { Text(text = "Disabled") }
label = { Text(text = "Disabled") },
)
NiaFilterChip(
selected = true,
onSelectedChange = {},
enabled = false,
label = { Text(text = "Disabled") }
label = { Text(text = "Disabled") },
)
}
}
@ -223,15 +205,15 @@ fun NiaCatalog() {
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null
contentDescription = null,
)
}
},
)
var secondChecked by remember { mutableStateOf(true) }
NiaIconToggleButton(
@ -240,15 +222,15 @@ fun NiaCatalog() {
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null
contentDescription = null,
)
}
},
)
NiaIconToggleButton(
checked = false,
@ -256,16 +238,16 @@ fun NiaCatalog() {
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null
contentDescription = null,
)
},
enabled = false
enabled = false,
)
NiaIconToggleButton(
checked = true,
@ -273,16 +255,16 @@ fun NiaCatalog() {
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null
contentDescription = null,
)
},
enabled = false
enabled = false,
)
}
}
@ -294,68 +276,42 @@ fun NiaCatalog() {
expanded = firstExpanded,
onExpandedChange = { expanded -> firstExpanded = expanded },
compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") }
expandedText = { Text(text = "Expanded view") },
)
var secondExpanded by remember { mutableStateOf(true) }
NiaViewToggleButton(
expanded = secondExpanded,
onExpandedChange = { expanded -> secondExpanded = expanded },
compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") }
expandedText = { Text(text = "Expanded view") },
)
NiaViewToggleButton(
expanded = false,
onExpandedChange = {},
compactText = { Text(text = "Disabled") },
expandedText = { Text(text = "Disabled") },
enabled = false
enabled = false,
)
}
}
item { Text("Tags", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
var expandedTopicId by remember { mutableStateOf<String?>(null) }
var firstFollowed by remember { mutableStateOf(false) }
NiaTopicTag(
expanded = expandedTopicId == "Topic 1",
followed = firstFollowed,
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) "Topic 1" else null
},
onFollowClick = { firstFollowed = true },
onUnfollowClick = { firstFollowed = false },
onBrowseClick = {},
followed = true,
onClick = {},
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(
expanded = expandedTopicId == "Topic 2",
followed = secondFollowed,
onDropdownMenuToggle = { show ->
expandedTopicId = if (show) "Topic 2" else null
},
onFollowClick = { secondFollowed = true },
onUnfollowClick = { secondFollowed = false },
onBrowseClick = {},
followed = false,
onClick = {},
text = { Text(text = "Topic 2".uppercase()) },
followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") }
)
NiaTopicTag(
expanded = false,
followed = false,
onDropdownMenuToggle = {},
onFollowClick = {},
onUnfollowClick = {},
onBrowseClick = {},
onClick = {},
text = { Text(text = "Disabled".uppercase()) },
enabled = false
enabled = false,
)
}
}
@ -368,7 +324,7 @@ fun NiaCatalog() {
NiaTab(
selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index },
text = { Text(text = title) }
text = { Text(text = title) },
)
}
}
@ -380,12 +336,12 @@ fun NiaCatalog() {
val icons = listOf(
NiaIcons.UpcomingBorder,
NiaIcons.MenuBookBorder,
NiaIcons.BookmarksBorder
NiaIcons.BookmarksBorder,
)
val selectedIcons = listOf(
NiaIcons.Upcoming,
NiaIcons.MenuBook,
NiaIcons.Bookmarks
NiaIcons.Bookmarks,
)
val tagIcon = NiaIcons.Tag
NiaNavigationBar {
@ -397,7 +353,7 @@ fun NiaCatalog() {
} else {
Icon(
painter = painterResource(id = icons[index]),
contentDescription = item
contentDescription = item,
)
}
},
@ -407,13 +363,13 @@ fun NiaCatalog() {
} else {
Icon(
painter = painterResource(id = selectedIcons[index]),
contentDescription = item
contentDescription = item,
)
}
},
label = { Text(item) },
selected = selectedItem == index,
onClick = { selectedItem = index }
onClick = { selectedItem = index },
)
}
}

@ -4,3 +4,15 @@
# wrong symbols would be generated. The generated Baseline Profile will be properly applied when generated
# without obfuscation and your app is being obfuscated.
-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 {
id("nowinandroid.android.application")
id("nowinandroid.android.application.compose")
id("nowinandroid.android.application.flavors")
id("nowinandroid.android.application.jacoco")
id("nowinandroid.android.hilt")
id("jacoco")
id("nowinandroid.firebase-perf")
id("nowinandroid.android.application.firebase")
}
android {
defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 3
versionName = "0.0.3" // X.Y.Z; X = Major, Y = minor, Z = Patch level
versionCode = 5
versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
@ -89,6 +90,7 @@ dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:data"))
implementation(project(":core:model"))
implementation(project(":core:analytics"))
implementation(project(":sync:work"))
@ -117,7 +119,6 @@ dependencies {
implementation(libs.androidx.profileinstaller)
implementation(libs.coil.kt)
implementation(libs.coil.kt.svg)
}
// 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.assertIsOn
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.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@ -29,10 +31,6 @@ import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.feature.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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -40,6 +38,10 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
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.
@ -57,7 +59,8 @@ class NavigationTest {
* 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.
*/
@BindValue @get:Rule(order = 1)
@BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
@ -165,7 +168,6 @@ class NavigationTest {
@Test
fun topLevelDestinations_showTopBarWithTitle() {
composeTestRule.apply {
// Verify that the top bar contains the app name on the first screen.
onNodeWithText(appName).assertExists()
@ -207,14 +209,18 @@ class NavigationTest {
@Test
fun whenSettingsDialogDismissed_previousScreenIsDisplayed() {
composeTestRule.apply {
// Navigate to the saved screen, open the settings dialog, then close it.
onNodeWithText(saved).performClick()
onNodeWithContentDescription(settings).performClick()
onNodeWithText(ok).performClick()
// 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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import javax.inject.Inject
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import javax.inject.Inject
/**
* 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
* 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()
/**
@ -77,9 +78,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -90,53 +91,53 @@ class NavigationUiTest {
}
@Test
fun mediumWidth_compactHeight_showsNavigationBar() {
fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_compactHeight_showsNavigationBar() {
fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compcatWidth_mediumHeight_showsNavigationBar() {
fun compactWidth_mediumHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -153,9 +154,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -172,9 +173,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -191,9 +192,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -210,9 +211,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight)
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
}
@ -229,9 +230,9 @@ class NavigationUiTest {
BoxWithConstraints {
NiaApp(
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.testing.TestNavHostController
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.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Tests [NiaAppState].
@ -70,7 +70,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(),
navController = navController,
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -91,7 +91,7 @@ class NiaAppStateTest {
composeTestRule.setContent {
state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor
networkMonitor = networkMonitor,
)
}
@ -108,7 +108,7 @@ class NiaAppStateTest {
windowSizeClass = getCompactWindowClass(),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -123,7 +123,7 @@ class NiaAppStateTest {
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -133,13 +133,12 @@ class NiaAppStateTest {
@Test
fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent {
state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -149,13 +148,12 @@ class NiaAppStateTest {
@Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
navController = NavHostController(LocalContext.current),
networkMonitor = networkMonitor,
coroutineScope = backgroundScope
coroutineScope = backgroundScope,
)
}
@ -163,7 +161,7 @@ class NiaAppStateTest {
networkMonitor.setConnected(false)
assertEquals(
true,
state.isOffline.value
state.isOffline.value,
)
}

@ -19,6 +19,13 @@
<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
android:name=".NiaApplication"
android:allowBackup="true"
@ -39,6 +46,12 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</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>
</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.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -37,16 +38,18 @@ import androidx.metrics.performance.JankStats
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
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.designsystem.theme.NiaTheme
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.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
@ -61,6 +64,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var analyticsHelper: AnalyticsHelper
val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
@ -104,9 +110,11 @@ class MainActivity : ComponentActivity() {
onDispose {}
}
CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
NiaTheme(
darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState)
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
NiaApp(
networkMonitor = networkMonitor,
@ -115,6 +123,7 @@ class MainActivity : ComponentActivity() {
}
}
}
}
override fun onResume() {
super.onResume()
@ -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
* 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.model.data.UserData
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class MainActivityViewModel @Inject constructor(
userDataRepository: UserDataRepository
userDataRepository: UserDataRepository,
) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
Success(it)
}.stateIn(
scope = viewModelScope,
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 coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.SvgDecoder
import com.google.samples.apps.nowinandroid.sync.initializers.Sync
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import javax.inject.Provider
/**
* [Application] class for NiA
*/
@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var imageLoader: Provider<ImageLoader>
override fun onCreate() {
super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this)
}
/**
* 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()
}
override fun newImageLoader(): ImageLoader = imageLoader.get()
}

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

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

@ -34,24 +34,24 @@ enum class TopLevelDestination(
val selectedIcon: Icon,
val unselectedIcon: Icon,
val iconTextId: Int,
val titleTextId: Int
val titleTextId: Int,
) {
FOR_YOU(
selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming),
unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder),
iconTextId = forYouR.string.for_you,
titleTextId = R.string.app_name
titleTextId = R.string.app_name,
),
BOOKMARKS(
selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks),
unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder),
iconTextId = bookmarksR.string.saved,
titleTextId = bookmarksR.string.saved
titleTextId = bookmarksR.string.saved,
),
INTERESTS(
selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3),
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.WindowInsets
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.only
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.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
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.ImageVectorIcon
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.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class,
ExperimentalLifecycleComposeApi::class
)
@Composable
fun NiaApp(
@ -83,19 +83,20 @@ fun NiaApp(
networkMonitor: NetworkMonitor,
appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass
windowSizeClass = windowSizeClass,
),
) {
val background: @Composable (@Composable () -> Unit) -> Unit =
when (appState.currentTopLevelDestination) {
TopLevelDestination.FOR_YOU -> {
content ->
NiaGradientBackground(content = content)
}
else -> { content -> NiaBackground(content = content) }
}
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
background {
NiaBackground {
NiaGradientBackground(
gradientColors = if (shouldShowGradientBackground) {
LocalGradientColors.current
} else {
GradientColors()
},
) {
val snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
@ -103,15 +104,17 @@ fun NiaApp(
// If user is not connected to the internet show a snack bar to inform them.
val notConnectedMessage = stringResource(R.string.not_connected)
LaunchedEffect(isOffline) {
if (isOffline) snackbarHostState.showSnackbar(
if (isOffline) {
snackbarHostState.showSnackbar(
message = notConnectedMessage,
duration = Indefinite
duration = Indefinite,
)
}
}
if (appState.shouldShowSettingsDialog) {
SettingsDialog(
onDismiss = { appState.setShowSettingsDialog(false) }
onDismiss = { appState.setShowSettingsDialog(false) },
)
}
@ -129,21 +132,21 @@ fun NiaApp(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar")
modifier = Modifier.testTag("NiaBottomBar"),
)
}
}
},
) { padding ->
Row(
Modifier
.fillMaxSize()
.padding(padding)
.consumedWindowInsets(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
)
)
WindowInsetsSides.Horizontal,
),
),
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
@ -152,7 +155,7 @@ fun NiaApp(
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding()
.safeDrawingPadding(),
)
}
@ -164,19 +167,16 @@ fun NiaApp(
titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description
id = settingsR.string.top_app_bar_action_icon_description,
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
containerColor = Color.Transparent,
),
onActionClick = { appState.setShowSettingsDialog(true) }
onActionClick = { appState.setShowSettingsDialog(true) },
)
}
NiaNavHost(
navController = appState.navController,
onBackClick = appState::onBackClick
)
NiaNavHost(appState.navController)
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that
@ -185,6 +185,7 @@ fun NiaApp(
}
}
}
}
@Composable
private fun NiaNavRail(
@ -208,15 +209,15 @@ private fun NiaNavRail(
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
contentDescription = null,
)
is DrawableResourceIcon -> Icon(
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>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
NiaNavigationBar(
modifier = modifier
modifier = modifier,
) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
@ -246,16 +247,16 @@ private fun NiaBottomBar(
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
contentDescription = null,
)
is DrawableResourceIcon -> Icon(
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
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
@ -56,7 +55,7 @@ fun rememberNiaAppState(
windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController()
navController: NavHostController = rememberNavController(),
): NiaAppState {
NavigationTrackingSideEffect(navController)
return remember(navController, coroutineScope, windowSizeClass, networkMonitor) {
@ -87,8 +86,7 @@ class NiaAppState(
private set
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar
@ -98,7 +96,7 @@ class NiaAppState(
.stateIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
initialValue = false,
)
/**
@ -138,10 +136,6 @@ class NiaAppState(
}
}
fun onBackClick() {
navController.popBackStack()
}
fun setShowSettingsDialog(shouldShow: Boolean) {
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"
experimentalProperties["android.experimental.self-instrumenting"] = true
testOptions {
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "aosp"
}
}
}
}
}
dependencies {

@ -28,7 +28,7 @@ import androidx.test.uiautomator.HasChildrenOp.EXACTLY
*/
fun untilHasChildren(
childCount: Int = 1,
op: HasChildrenOp = AT_LEAST
op: HasChildrenOp = AT_LEAST,
): UiObject2Condition<Boolean> {
return object : UiObject2Condition<Boolean>() {
override fun apply(element: UiObject2): Boolean {
@ -44,5 +44,5 @@ fun untilHasChildren(
enum class HasChildrenOp {
AT_LEAST,
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.junit4.BaselineProfileRule
import androidx.test.uiautomator.By
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.forYouSelectTopics
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 org.junit.Rule
import org.junit.Test
@ -46,24 +47,16 @@ class BaselineProfileGenerator {
// Scroll the feed critical user journey
forYouWaitForContent()
forYouSelectTopics(true)
forYouScrollFeedDownUp()
// Navigate to saved screen
device.findObject(By.text("Saved")).click()
device.waitForIdle()
goToBookmarksScreen()
// TODO: we need to implement adding stuff to bookmarks before able to scroll it
// bookmarksScrollFeedDownUp()
// Navigate to interests screen
device.findObject(By.text("Interests")).click()
device.waitForIdle()
goToInterestsScreen()
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.test.uiautomator.By
import androidx.test.uiautomator.Until
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() {
val feedList = device.findObject(By.res("bookmarks:feed"))
device.flingElementDownUp(feedList)

@ -23,12 +23,65 @@ import androidx.test.uiautomator.untilHasChildren
import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded by checking if authors are loaded
device.wait(Until.gone(By.res("forYou:loadingWheel")), 5_000)
// Wait until content is loaded by checking if topics are loaded
device.wait(Until.gone(By.res("loadingWheel")), 5_000)
// Sometimes, the loading wheel is gone, but the content is not loaded yet
// So we'll wait here for authors to be sure
val obj = device.findObject(By.res("forYou:authors"))
obj.wait(untilHasChildren(), 30_000)
// So we'll wait here for topics to be sure
val obj = device.findObject(By.res("forYou:topicSelection"))
// 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() {

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

@ -21,16 +21,23 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
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() {
val topicsList = device.findObject(By.res("interests:topics"))
device.flingElementDownUp(topicsList)
}
fun MacrobenchmarkScope.interestsScrollPeopleDownUp() {
val peopleList = device.findObject(By.res("interests:people"))
device.flingElementDownUp(peopleList)
}
fun MacrobenchmarkScope.interestsWaitForTopics() {
device.wait(Until.hasObject(By.text("Accessibility")), 30_000)
}

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

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

@ -21,13 +21,16 @@ plugins {
group = "com.google.samples.apps.nowinandroid.buildlogic"
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.firebase.performance.gradle)
compileOnly(libs.firebase.crashlytics.gradle)
compileOnly(libs.ksp.gradlePlugin)
}
gradlePlugin {
@ -68,9 +71,17 @@ gradlePlugin {
id = "nowinandroid.android.hilt"
implementationClass = "AndroidHiltConventionPlugin"
}
register("firebase-perf") {
id = "nowinandroid.firebase-perf"
implementationClass = "FirebasePerfConventionPlugin"
register("androidRoom") {
id = "nowinandroid.android.room"
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.
*/
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin
@ -34,7 +34,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 33
configureFlavors(this)
configureGradleManagedDevices(this)
}
extensions.configure<ApplicationAndroidComponentsExtension> {
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");
* you may not use this file except in compliance with the License.
@ -14,16 +14,18 @@
* 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.Project
import org.gradle.kotlin.dsl.configure
class FirebasePerfConventionPlugin : Plugin<Project> {
class AndroidApplicationFlavorsConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.findPlugin("com.google.firebase.firebase-perf").apply {
version = "1.4.1"
extensions.configure<ApplicationExtension> {
configureFlavors(this)
}
}
}
}

@ -14,7 +14,9 @@
* limitations under the License.
*/
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
@ -35,6 +37,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
configureGradleManagedDevices(this)
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
@ -46,6 +49,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", project(":core:data"))
add("implementation", project(":core:common"))
add("implementation", project(":core:domain"))
add("implementation", project(":core:analytics"))
add("testImplementation", kotlin("test"))
add("testImplementation", project(":core:testing"))

@ -14,14 +14,11 @@
* limitations under the License.
*/
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureJacoco
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.kotlin
class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {

@ -14,10 +14,10 @@
* limitations under the License.
*/
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin
@ -40,6 +40,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 33
configureFlavors(this)
configureGradleManagedDevices(this)
}
extensions.configure<LibraryAndroidComponentsExtension> {
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.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import org.gradle.api.Plugin
import org.gradle.api.Project
@ -31,6 +32,7 @@ class AndroidTestConventionPlugin : Plugin<Project> {
extensions.configure<TestExtension> {
configureKotlinAndroid(this)
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 {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
@ -59,8 +59,8 @@ internal fun Project.configureKotlinAndroid(
"-opt-in=kotlin.Experimental",
)
// Set JVM target to 1.8
jvmTarget = JavaVersion.VERSION_1_8.toString()
// Set JVM target to 11
jvmTarget = JavaVersion.VERSION_11.toString()
}
}

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

@ -22,13 +22,17 @@ buildscript {
// Android Build Server
maven { url = uri("../nowinandroid-prebuilts/m2repository") }
}
}
// Lists all plugins used throughout the project without applying them.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.jvm) 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.ksp) 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"
export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )"
echo "ANDROID_HOME=$ANDROID_HOME"
cd $DIR
# Build
GRADLE_PARAMS=" --stacktrace"
$DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS}
BUILD_RESULT=$?
echo "Copying google-services.json"
cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app
# Demo debug
cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR
echo "Copying local.properties"
cp $DIR/../nowinandroid-prebuilts/local.properties $DIR
# Demo release
cp $APP_OUT/apk/demo/release/app-demo-release.apk $DIST_DIR
cd $DIR
# Prod debug
cp $APP_OUT/apk/prod/debug/app-prod-debug.apk $DIST_DIR/app-prod-debug.apk
# Build the prodRelease variant
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/mapping/release/mapping.txt $DIST_DIR/mobile-release-apk-mapping.txt
# 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
# Prod release bundle
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
BUILD_RESULT=$?
# Prod release bundle mapping
cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt
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"?>
<!--
Copyright 2022 The Android Open Source Project
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.
@ -14,8 +14,4 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources>
<string name="follow">Follow</string>
<string name="unfollow">Unfollow</string>
<string name="browse_topic">Browse topic</string>
</resources>
<manifest />

@ -0,0 +1,58 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.analytics
/**
* Represents an analytics event.
*
* @param type - the event type. Wherever possible use one of the standard
* event `Types`, however, if there is no suitable event type already defined, a custom event can be
* defined as long as it is configured in your backend analytics system (for example, by creating a
* Firebase Analytics custom event).
*
* @param extras - list of parameters which supply additional context to the event. See `Param`.
*/
data class AnalyticsEvent(
val type: String,
val extras: List<Param> = emptyList(),
) {
// Standard analytics types.
class Types {
companion object {
const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME)
}
}
/**
* A key-value pair used to supply extra context to an analytics event.
*
* @param key - the parameter key. Wherever possible use one of the standard `ParamKeys`,
* however, if no suitable key is available you can define your own as long as it is configured
* in your backend analytics system (for example, by creating a Firebase Analytics custom
* parameter).
*
* @param value - the parameter value.
*/
data class Param(val key: String, val value: String)
// Standard parameter keys.
class ParamKeys {
companion object {
const val SCREEN_NAME = "screen_name"
}
}
}

@ -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");
* you may not use this file except in compliance with the License.
@ -14,14 +14,12 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
package com.google.samples.apps.nowinandroid.core.analytics
/**
* 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(
val newsResource: NewsResource,
val isSaved: Boolean,
)
interface AnalyticsHelper {
fun logEvent(event: AnalyticsEvent)
}

@ -0,0 +1,24 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.analytics
/**
* Implementation of AnalyticsHelper which does nothing. Useful for tests and previews.
*/
class NoOpAnalyticsHelper : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) = Unit
}

@ -0,0 +1,34 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.analytics
import android.util.Log
import javax.inject.Inject
import javax.inject.Singleton
private const val TAG = "StubAnalyticsHelper"
/**
* An implementation of AnalyticsHelper just writes the events to logcat. Used in builds where no
* analytics events should be sent to a backend.
*/
@Singleton
class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) {
Log.d(TAG, "Received analytics event: $event")
}
}

@ -0,0 +1,28 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.analytics
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Global key used to obtain access to the AnalyticsHelper through a CompositionLocal.
*/
val LocalAnalyticsHelper = staticCompositionLocalOf<AnalyticsHelper> {
// Provide a default AnalyticsHelper which does nothing. This is so that tests and previews
// do not have to provide one. For real app builds provide a different implementation.
NoOpAnalyticsHelper()
}

@ -0,0 +1,40 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.analytics
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.ktx.Firebase
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
@Binds
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper
companion object {
@Provides
@Singleton
fun provideFirebaseAnalytics(): FirebaseAnalytics { return Firebase.analytics }
}
}

@ -0,0 +1,41 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.analytics
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.logEvent
import javax.inject.Inject
/**
* Implementation of `AnalyticsHelper` which logs events to a Firebase backend.
*/
class FirebaseAnalyticsHelper @Inject constructor(
private val firebaseAnalytics: FirebaseAnalytics,
) : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) {
firebaseAnalytics.logEvent(event.type) {
for (extra in event.extras) {
// Truncate parameter keys and values according to firebase maximum length values.
param(
key = extra.key.take(40),
value = extra.value.take(100),
)
}
}
}
}

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

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

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

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

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

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

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

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

@ -25,5 +25,5 @@ fun NetworkTopic.asEntity() = TopicEntity(
shortDescription = shortDescription,
longDescription = longDescription,
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
/**
* 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,
)
/**
* Returns available news resources as a stream filtered by topics.
* Data layer implementation for [NewsResource]
*/
interface NewsRepository : Syncable {
/**
* Returns available news resources that match the specified [query].
*/
fun getNewsResources(
filterTopicIds: Set<String> = emptySet(),
query: NewsResourceQuery = NewsResourceQuery(
filterTopicIds = null,
filterNewsIds = null,
),
): 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.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
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].
@ -44,14 +48,13 @@ class OfflineFirstNewsRepository @Inject constructor(
private val network: NiaNetworkDataSource,
) : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> =
newsResourceDao.getNewsResources()
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResources(
filterTopicIds: Set<String>
query: NewsResourceQuery,
): 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) }
@ -66,7 +69,8 @@ class OfflineFirstNewsRepository @Inject constructor(
},
modelDeleter = newsResourceDao::deleteNewsResources,
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!
@ -74,18 +78,20 @@ class OfflineFirstNewsRepository @Inject constructor(
topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id)
.distinctBy(TopicEntity::id),
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity)
newsResourceEntities = networkNewsResources.map(
NetworkNewsResource::asEntity,
),
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.flatten()
.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.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Disk storage backed implementation of the [TopicsRepository].
@ -59,8 +59,8 @@ class OfflineFirstTopicsRepository @Inject constructor(
modelUpdater = { changedIds ->
val networkTopics = network.getTopics(ids = changedIds)
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
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.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val analyticsHelper: AnalyticsHelper,
) : UserDataRepository {
override val userData: Flow<UserData> =
niaPreferencesDataSource.userData
@VisibleForTesting
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) {
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)
analyticsHelper.logNewsResourceBookmarkToggled(
newsResourceId = newsResourceId,
isBookmarked = bookmarked,
)
}
override suspend fun setThemeBrand(themeBrand: ThemeBrand) =
override suspend fun setThemeBrand(themeBrand: ThemeBrand) {
niaPreferencesDataSource.setThemeBrand(themeBrand)
analyticsHelper.logThemeChanged(themeBrand.name)
}
override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) =
override suspend fun setDarkThemeConfig(darkThemeConfig: 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)
analyticsHelper.logOnboardingStateChanged(shouldHideOnboarding)
}
}

@ -53,6 +53,11 @@ interface UserDataRepository {
*/
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.
*/

@ -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.model.asEntity
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.asExternalModel
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.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
/**
* 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(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource
private val datasource: FakeNiaNetworkDataSource,
) : NewsRepository {
override fun getNewsResources(): Flow<List<NewsResource>> =
flow {
emit(
datasource.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
)
}.flowOn(ioDispatcher)
override fun getNewsResources(
filterTopicIds: Set<String>,
query: NewsResourceQuery,
): Flow<List<NewsResource>> =
flow {
emit(
datasource
.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(NewsResourceEntity::asExternalModel)
.map(NewsResourceEntity::asExternalModel),
)
}.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.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* 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(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource
private val datasource: FakeNiaNetworkDataSource,
) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> = flow {
emit(
@ -49,9 +49,9 @@ class FakeTopicsRepository @Inject constructor(
shortDescription = it.shortDescription,
longDescription = it.longDescription,
url = it.url,
imageUrl = it.imageUrl
imageUrl = it.imageUrl,
)
}
},
)
}.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.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Fake implementation of the [UserDataRepository] that returns hardcoded user data.
@ -55,6 +55,10 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
}
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
}

@ -26,14 +26,14 @@ import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import javax.inject.Inject
class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context
@ApplicationContext private val context: Context,
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>()
@ -54,7 +54,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
networkCapabilities: NetworkCapabilities,
) {
channel.trySend(connectivityManager.isCurrentlyConnected())
}
@ -64,7 +64,7 @@ class ConnectivityManagerNetworkMonitor @Inject constructor(
Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(),
callback
callback,
)
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.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertEquals
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.nonPresentInterestsIds
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.TopicEntity
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.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstNewsRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstNewsRepository
private lateinit var newsResourceDao: TestNewsResourceDao
@ -65,8 +70,8 @@ class OfflineFirstNewsRepositoryTest {
network = TestNiaNetworkDataSource()
synchronizer = TestSynchronizer(
NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
)
tmpFolder.testUserPreferencesDataStore(testScope),
),
)
subject = OfflineFirstNewsRepository(
@ -78,43 +83,48 @@ class OfflineFirstNewsRepositoryTest {
@Test
fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() =
runTest {
testScope.runTest {
assertEquals(
newsResourceDao.getNewsResources()
.first()
.map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources()
.first()
.first(),
)
}
@Test
fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() =
runTest {
testScope.runTest {
assertEquals(
newsResourceDao.getNewsResources(
expected = newsResourceDao.getNewsResources(
filterTopicIds = filteredInterestsIds,
useFilterTopicIds = true,
)
.first()
.map(PopulatedNewsResource::asExternalModel),
subject.getNewsResources(
actual = subject.getNewsResources(
query = NewsResourceQuery(
filterTopicIds = filteredInterestsIds,
),
)
.first()
.first(),
)
assertEquals(
emptyList(),
subject.getNewsResources(
expected = emptyList(),
actual = subject.getNewsResources(
query = NewsResourceQuery(
filterTopicIds = nonPresentInterestsIds,
),
)
.first()
.first(),
)
}
@Test
fun offlineFirstNewsRepository_sync_pulls_from_network() =
runTest {
testScope.runTest {
subject.syncWith(synchronizer)
val newsResourcesFromNetwork = network.getNewsResources()
@ -126,20 +136,20 @@ class OfflineFirstNewsRepositoryTest {
.map(PopulatedNewsResource::asExternalModel)
assertEquals(
newsResourcesFromNetwork.map(NewsResource::id),
newsResourcesFromDb.map(NewsResource::id)
newsResourcesFromNetwork.map(NewsResource::id).sorted(),
newsResourcesFromDb.map(NewsResource::id).sorted(),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion
expected = network.latestChangeListVersion(CollectionType.NewsResources),
actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
}
@Test
fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest {
testScope.runTest {
val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
@ -155,7 +165,7 @@ class OfflineFirstNewsRepositoryTest {
network.editCollection(
collectionType = CollectionType.NewsResources,
id = it,
isDelete = true
isDelete = true,
)
}
@ -167,20 +177,20 @@ class OfflineFirstNewsRepositoryTest {
// Assert that items marked deleted on the network have been deleted locally
assertEquals(
newsResourcesFromNetwork.map(NewsResource::id) - deletedItems,
newsResourcesFromDb.map(NewsResource::id)
expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(),
actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion
expected = network.latestChangeListVersion(CollectionType.NewsResources),
actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
}
@Test
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() =
runTest {
testScope.runTest {
// Set news version to 7
synchronizer.updateChangeListVersions {
copy(newsResourceVersion = 7)
@ -190,7 +200,7 @@ class OfflineFirstNewsRepositoryTest {
val changeList = network.changeListsAfter(
CollectionType.NewsResources,
version = 7
version = 7,
)
val changeListIds = changeList
.map(NetworkChangeList::id)
@ -206,43 +216,47 @@ class OfflineFirstNewsRepositoryTest {
.map(PopulatedNewsResource::asExternalModel)
assertEquals(
newsResourcesFromNetwork.map(NewsResource::id),
newsResourcesFromDb.map(NewsResource::id)
expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
)
// After sync version should be updated
assertEquals(
changeList.last().changeListVersion,
synchronizer.getChangeListVersions().newsResourceVersion
expected = changeList.last().changeListVersion,
actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
}
@Test
fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() =
runTest {
testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
network.getNewsResources()
expected = network.getNewsResources()
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id),
topicDao.getTopicEntities()
.distinctBy(TopicEntity::id)
.sortedBy(TopicEntity::toString),
actual = topicDao.getTopicEntities()
.first()
.sortedBy(TopicEntity::toString),
)
}
@Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =
runTest {
testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
network.getNewsResources()
expected = network.getNewsResources()
.map(NetworkNewsResource::topicCrossReferences)
.flatten()
.distinct()
.flatten(),
newsResourceDao.topicCrossReferences
.sortedBy(NewsResourceTopicCrossRef::toString),
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.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstTopicsRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstTopicsRepository
private lateinit var topicDao: TopicDao
@ -56,31 +60,31 @@ class OfflineFirstTopicsRepositoryTest {
topicDao = TestTopicDao()
network = TestNiaNetworkDataSource()
niaPreferences = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
tmpFolder.testUserPreferencesDataStore(testScope),
)
synchronizer = TestSynchronizer(niaPreferences)
subject = OfflineFirstTopicsRepository(
topicDao = topicDao,
network = network
network = network,
)
}
@Test
fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() =
runTest {
testScope.runTest {
assertEquals(
topicDao.getTopicEntities()
.first()
.map(TopicEntity::asExternalModel),
subject.getTopics()
.first()
.first(),
)
}
@Test
fun offlineFirstTopicsRepository_sync_pulls_from_network() =
runTest {
testScope.runTest {
subject.syncWith(synchronizer)
val networkTopics = network.getTopics()
@ -91,19 +95,19 @@ class OfflineFirstTopicsRepositoryTest {
assertEquals(
networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id)
dbTopics.map(TopicEntity::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion
synchronizer.getChangeListVersions().topicVersion,
)
}
@Test
fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() =
runTest {
testScope.runTest {
// Set topics version to 10
synchronizer.updateChangeListVersions {
copy(topicVersion = 10)
@ -121,19 +125,19 @@ class OfflineFirstTopicsRepositoryTest {
assertEquals(
networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id)
dbTopics.map(TopicEntity::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion
synchronizer.getChangeListVersions().topicVersion,
)
}
@Test
fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest {
testScope.runTest {
val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity)
.map(TopicEntity::asExternalModel)
@ -149,7 +153,7 @@ class OfflineFirstTopicsRepositoryTest {
network.editCollection(
collectionType = CollectionType.Topics,
id = it,
isDelete = true
isDelete = true,
)
}
@ -162,13 +166,13 @@ class OfflineFirstTopicsRepositoryTest {
// Assert that items marked deleted on the network have been deleted locally
assertEquals(
networkTopics.map(Topic::id) - deletedItems,
dbTopics.map(Topic::id)
dbTopics.map(Topic::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion
synchronizer.getChangeListVersions().topicVersion,
)
}
}

@ -16,66 +16,76 @@
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.test.testUserPreferencesDataStore
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.UserData
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class OfflineFirstUserDataRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstUserDataRepository
private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource
private val analyticsHelper = NoOpAnalyticsHelper()
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before
fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
tmpFolder.testUserPreferencesDataStore(testScope),
)
subject = OfflineFirstUserDataRepository(
niaPreferencesDataSource = niaPreferencesDataSource
niaPreferencesDataSource = niaPreferencesDataSource,
analyticsHelper,
)
}
@Test
fun offlineFirstUserDataRepository_default_user_data_is_correct() =
runTest {
testScope.runTest {
assertEquals(
UserData(
bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false
useDynamicColor = false,
shouldHideOnboarding = false,
),
subject.userData.first()
subject.userData.first(),
)
}
@Test
fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =
runTest {
testScope.runTest {
subject.toggleFollowedTopicId(followedTopicId = "0", followed = true)
assertEquals(
setOf("0"),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
subject.toggleFollowedTopicId(followedTopicId = "1", followed = true)
@ -84,7 +94,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0", "1"),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
assertEquals(
@ -93,20 +103,20 @@ class OfflineFirstUserDataRepositoryTest {
.first(),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
}
@Test
fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() =
runTest {
testScope.runTest {
subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2"))
assertEquals(
setOf("1", "2"),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
assertEquals(
@ -115,20 +125,20 @@ class OfflineFirstUserDataRepositoryTest {
.first(),
subject.userData
.map { it.followedTopics }
.first()
.first(),
)
}
@Test
fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() =
runTest {
testScope.runTest {
subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true)
assertEquals(
setOf("0"),
subject.userData
.map { it.bookmarkedNewsResources }
.first()
.first(),
)
subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true)
@ -137,7 +147,7 @@ class OfflineFirstUserDataRepositoryTest {
setOf("0", "1"),
subject.userData
.map { it.bookmarkedNewsResources }
.first()
.first(),
)
assertEquals(
@ -146,53 +156,73 @@ class OfflineFirstUserDataRepositoryTest {
.first(),
subject.userData
.map { it.bookmarkedNewsResources }
.first()
.first(),
)
}
@Test
fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() =
runTest {
testScope.runTest {
subject.setThemeBrand(ThemeBrand.ANDROID)
assertEquals(
ThemeBrand.ANDROID,
subject.userData
.map { it.themeBrand }
.first()
.first(),
)
assertEquals(
ThemeBrand.ANDROID,
niaPreferencesDataSource
.userData
.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
fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() =
runTest {
testScope.runTest {
subject.setDarkThemeConfig(DarkThemeConfig.DARK)
assertEquals(
DarkThemeConfig.DARK,
subject.userData
.map { it.darkThemeConfig }
.first()
.first(),
)
assertEquals(
DarkThemeConfig.DARK,
niaPreferencesDataSource
.userData
.map { it.darkThemeConfig }
.first()
.first(),
)
}
@Test
fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() =
runTest {
testScope.runTest {
subject.setFollowedTopicIds(setOf("1"))
subject.setShouldHideOnboarding(true)
assertTrue(subject.userData.first().shouldHideOnboarding)

@ -24,12 +24,12 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
* Test synchronizer that delegates to [NiaPreferencesDataSource]
*/
class TestSynchronizer(
private val niaPreferences: NiaPreferencesDataSource
private val niaPreferences: NiaPreferencesDataSource,
) : Synchronizer {
override suspend fun getChangeListVersions(): ChangeListVersions =
niaPreferences.getChangeListVersions()
override suspend fun updateChangeListVersions(
update: ChangeListVersions.() -> ChangeListVersions
update: ChangeListVersions.() -> ChangeListVersions,
) = 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.PopulatedNewsResource
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.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.datetime.Instant
val filteredInterestsIds = setOf("1")
val nonPresentInterestsIds = setOf("2")
@ -37,40 +35,45 @@ val nonPresentInterestsIds = setOf("2")
class TestNewsResourceDao : NewsResourceDao {
private var entitiesStateFlow = MutableStateFlow(
listOf(
NewsResourceEntity(
id = "1",
title = "news",
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
)
)
emptyList<NewsResourceEntity>(),
)
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
override fun getNewsResources(): Flow<List<PopulatedNewsResource>> =
entitiesStateFlow.map {
it.map(NewsResourceEntity::asPopulatedNewsResource)
}
override fun getNewsResources(
filterTopicIds: Set<String>
useFilterTopicIds: Boolean,
filterTopicIds: Set<String>,
useFilterNewsIds: Boolean,
filterNewsIds: Set<String>,
): Flow<List<PopulatedNewsResource>> =
getNewsResources()
entitiesStateFlow
.map { it.map(NewsResourceEntity::asPopulatedNewsResource) }
.map { resources ->
resources.filter { resource ->
var result = resources
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(
entities: List<NewsResourceEntity>
entities: List<NewsResourceEntity>,
): 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
return entities.map { it.id.toLong() }
}
@ -80,13 +83,22 @@ class TestNewsResourceDao : NewsResourceDao {
}
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(
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>) {
@ -107,6 +119,6 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
)
),
),
)

@ -27,7 +27,7 @@ import kotlinx.serialization.json.Json
enum class CollectionType {
Topics,
NewsResources
NewsResources,
}
/**
@ -37,7 +37,7 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
private val source = FakeNiaNetworkDataSource(
UnconfinedTestDispatcher(),
Json { ignoreUnknownKeys = true }
Json { ignoreUnknownKeys = true },
)
private val allTopics = runBlocking { source.getTopics() }
@ -54,13 +54,13 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
allTopics.matchIds(
ids = ids,
idGetter = NetworkTopic::id
idGetter = NetworkTopic::id,
)
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds(
ids = ids,
idGetter = NetworkNewsResource::id
idGetter = NetworkNewsResource::id,
)
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(
ids: List<String>?,
idGetter: (T) -> String
idGetter: (T) -> String,
) = when (ids) {
null -> this
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
*/
private fun <T> List<T>.mapToChangeList(
idGetter: (T) -> String
idGetter: (T) -> String,
) = mapIndexed { index, item ->
NetworkChangeList(
id = idGetter(item),

@ -29,16 +29,7 @@ import kotlinx.coroutines.flow.update
class TestTopicDao : TopicDao {
private var entitiesStateFlow = MutableStateFlow(
listOf(
TopicEntity(
id = "1",
name = "Topic",
shortDescription = "short description",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
)
)
emptyList<TopicEntity>(),
)
override fun getTopicEntity(topicId: String): Flow<TopicEntity> {
@ -53,8 +44,10 @@ class TestTopicDao : TopicDao {
.map { topics -> topics.filter { it.id in ids } }
override suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> {
entitiesStateFlow.value = topicEntities
// Assume no conflicts on insert
// Keep old values over new values
entitiesStateFlow.update { oldValues ->
(oldValues + topicEntities).distinctBy(TopicEntity::id)
}
return topicEntities.map { it.id.toLong() }
}
@ -63,7 +56,10 @@ class TestTopicDao : TopicDao {
}
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>) {

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

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

@ -13,25 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.jacoco")
id("nowinandroid.android.hilt")
alias(libs.plugins.ksp)
id("nowinandroid.android.room")
}
android {
defaultConfig {
// 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.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
namespace = "com.google.samples.apps.nowinandroid.core.database"
}
@ -39,10 +34,6 @@ android {
dependencies {
implementation(project(":core:model"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)

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

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

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

@ -46,7 +46,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10),
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,
)

@ -34,29 +34,36 @@ import kotlinx.coroutines.flow.Flow
*/
@Dao
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
@Query(
value = """
SELECT * FROM news_resources
WHERE id in
WHERE
CASE WHEN :useFilterNewsIds
THEN id IN (:filterNewsIds)
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
"""
""",
)
fun getNewsResources(
useFilterTopicIds: Boolean = false,
filterTopicIds: Set<String> = emptySet(),
useFilterNewsIds: Boolean = false,
filterNewsIds: Set<String> = emptySet(),
): Flow<List<PopulatedNewsResource>>
/**
@ -79,7 +86,7 @@ interface NewsResourceDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
)
/**
@ -89,7 +96,7 @@ interface NewsResourceDao {
value = """
DELETE FROM news_resources
WHERE id in (:ids)
"""
""",
)
suspend fun deleteNewsResources(ids: List<String>)
}

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

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

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

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

@ -24,6 +24,7 @@ android {
dependencies {
api(project(":core:datastore"))
implementation(project(":core:common"))
implementation(project(":core:testing"))
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.UserPreferencesSerializer
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.Provides
import dagger.hilt.components.SingletonComponent
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 javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DataStoreModule::class]
replaces = [DataStoreModule::class],
)
object TestDataStoreModule {
@Provides
@Singleton
fun providesUserPreferencesDataStore(
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder
tmpFolder: TemporaryFolder,
): 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(
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer()
coroutineScope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(),
) = DataStoreFactory.create(
serializer = userPreferencesSerializer,
scope = coroutineScope,
) {
newFile("user_preferences_test.pb")
}

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

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

Loading…
Cancel
Save