Merge branch 'main' into patch-2

pull/1837/head
Milosz Moczkowski 3 years ago committed by GitHub
commit 3ca31bedec

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

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

@ -90,7 +90,7 @@ jobs:
disable-animations: true disable-animations: true
disk-size: 6000M disk-size: 6000M
heap-size: 600M heap-size: 600M
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest
- name: Upload test reports - name: Upload test reports
if: always() if: always()

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

@ -28,9 +28,6 @@
<option name="JD_PRESERVE_LINE_FEEDS" value="true" /> <option name="JD_PRESERVE_LINE_FEEDS" value="true" />
</JavaCodeStyleSettings> </JavaCodeStyleSettings>
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="99" /> <option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="99" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="99" /> <option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="99" />
<option name="IMPORT_NESTED_CLASSES" value="true" /> <option name="IMPORT_NESTED_CLASSES" value="true" />

@ -16,22 +16,27 @@
--> -->
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<!-- <!--
Baseline Profiles improve code execution speed by around 30% from the first launch by avoiding interpretation and just-in-time (JIT) compilation steps for included code paths. Baseline Profiles improve code execution speed by around 30% from the first launch by avoiding
interpretation and just-in-time (JIT) compilation steps for included code paths.
More information at http://d.android.com/baseline-profiles. More information at http://d.android.com/baseline-profiles.
In this run configuration we leverage rerun parameter that always reruns the requested task regardless of cache.
We also leverage enable-display parameter to be able to verify the generator works as intended.
--> -->
<configuration default="false" name="Generate Demo Baseline Profile" type="GradleRunConfiguration" factoryName="Gradle"> <configuration default="false" name="Generate Demo Baseline Profile" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings> <ExternalSystemSettings>
<option name="executionName" /> <option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" /> <option name="externalSystemIdString" value="GRADLE" />
<!-- TODO Once we use Gradle wrapper 7.6, we can use rerun instead of rerun-tasks that will skip cache only for one task --> <option name="scriptParameters" value="-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile" />
<option name="scriptParameters" value="--rerun-tasks -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile" />
<option name="taskDescriptions"> <option name="taskDescriptions">
<list /> <list />
</option> </option>
<option name="taskNames"> <option name="taskNames">
<list> <list>
<option value=":benchmark:pixel6Api31DemoBenchmarkAndroidTest" /> <option value=":benchmark:pixel6Api31atdDemoBenchmarkAndroidTest" />
<option value="--rerun" />
<option value="--enable-display" />
</list> </list>
</option> </option>
<option name="vmOptions" /> <option name="vmOptions" />

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

@ -1,3 +1,21 @@
/*
* 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.samples.apps.nowinandroid.FlavorDimension
import com.google.samples.apps.nowinandroid.NiaFlavor
/* /*
* Copyright 2022 The Android Open Source Project * Copyright 2022 The Android Open Source Project
* *
@ -21,10 +39,12 @@ plugins {
android { android {
defaultConfig { defaultConfig {
applicationId = "com.google.samples.apps.niacatalog" applicationId = "com.google.samples.apps.niacatalog"
versionCode = 1
versionName = "0.0.1" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// The UI catalog does not depend on content from the app, however, it depends on modules // The UI catalog does not depend on content from the app, however, it depends on modules
// which do, so we must specify a default value for the contentType dimension. // which do, so we must specify a default value for the contentType dimension.
missingDimensionStrategy("contentType", "demo") missingDimensionStrategy(FlavorDimension.contentType.name, NiaFlavor.demo.name)
} }
packagingOptions { packagingOptions {
@ -33,12 +53,20 @@ android {
} }
} }
namespace = "com.google.samples.apps.niacatalog" namespace = "com.google.samples.apps.niacatalog"
buildTypes {
val release by getting {
// To publish on the Play store a private signing key is required, but to allow anyone
// who clones the code to sign and run the release variant, use the debug signing key.
// TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
signingConfig = signingConfigs.getByName("debug")
}
}
} }
dependencies { dependencies {
implementation(project(":core:ui"))
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:ui"))
implementation(libs.androidx.activity.compose)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
} implementation(libs.androidx.activity.compose)
}

@ -37,16 +37,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaDropdownMenuButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilledButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOutlinedButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOutlinedButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTab import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTab
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRow import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTabRow
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaViewToggleButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaViewToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
@ -66,7 +65,7 @@ fun NiaCatalog() {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
item { item {
Text( Text(
@ -77,7 +76,7 @@ fun NiaCatalog() {
item { Text("Buttons", Modifier.padding(top = 16.dp)) } item { Text("Buttons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(onClick = {}) { NiaButton(onClick = {}) {
Text(text = "Enabled") Text(text = "Enabled")
} }
NiaOutlinedButton(onClick = {}) { NiaOutlinedButton(onClick = {}) {
@ -91,326 +90,82 @@ fun NiaCatalog() {
item { Text("Disabled buttons", Modifier.padding(top = 16.dp)) } item { Text("Disabled buttons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton( NiaButton(
onClick = {},
enabled = false
) {
Text(text = "Disabled")
}
NiaOutlinedButton(
onClick = {},
enabled = false
) {
Text(text = "Disabled")
}
NiaTextButton(
onClick = {},
enabled = false
) {
Text(text = "Disabled")
}
}
}
item { Text("Buttons with leading icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
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)
}
)
}
}
item { Text("Disabled buttons with leading icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item { Text("Buttons with trailing icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item { Text("Disabled buttons with trailing icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
enabled = false,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item { Text("Small buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
small = true
) {
Text(text = "Enabled")
}
NiaOutlinedButton(
onClick = {},
small = true
) {
Text(text = "Enabled")
}
NiaTextButton(
onClick = {},
small = true
) {
Text(text = "Enabled")
}
}
}
item { Text("Disabled small buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
enabled = false,
small = true
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
small = true
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
small = true
) { ) {
Text(text = "Disabled") Text(text = "Disabled")
} }
} }
} }
item { Text("Small buttons with leading icons", Modifier.padding(top = 16.dp)) } item { Text("Buttons with leading icons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton( NiaButton(
onClick = {}, onClick = {},
small = true,
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
small = true,
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
small = true,
text = { Text(text = "Enabled") }, text = { Text(text = "Enabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
} }
} }
item { item { Text("Disabled buttons with leading icons", Modifier.padding(top = 16.dp)) }
Text(
"Disabled small buttons with leading icons",
Modifier.padding(top = 16.dp)
)
}
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton( NiaButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
small = true,
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaOutlinedButton( NiaOutlinedButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
small = true,
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
) )
NiaTextButton( NiaTextButton(
onClick = {}, onClick = {},
enabled = false, enabled = false,
small = true,
text = { Text(text = "Disabled") }, text = { Text(text = "Disabled") },
leadingIcon = { leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(imageVector = NiaIcons.Add, contentDescription = null)
} },
)
}
}
item { Text("Small buttons with trailing icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
}
}
item {
Text(
"Disabled small buttons with trailing icons",
Modifier.padding(top = 16.dp)
)
}
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaOutlinedButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
)
NiaTextButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
trailingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
) )
} }
} }
item { Text("Dropdown menu", Modifier.padding(top = 16.dp)) } item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) }
item {
NiaDropdownMenuButton(
text = { Text("Newest first") },
items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {},
itemText = { item -> Text(item) }
)
}
item { Text("Chips", Modifier.padding(top = 16.dp)) } item { Text("Chips", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
@ -418,81 +173,98 @@ fun NiaCatalog() {
NiaFilterChip( NiaFilterChip(
selected = firstChecked, selected = firstChecked,
onSelectedChange = { checked -> firstChecked = checked }, onSelectedChange = { checked -> firstChecked = checked },
label = { Text(text = "Enabled".uppercase()) } label = { Text(text = "Enabled") },
) )
var secondChecked by remember { mutableStateOf(true) } var secondChecked by remember { mutableStateOf(true) }
NiaFilterChip( NiaFilterChip(
selected = secondChecked, selected = secondChecked,
onSelectedChange = { checked -> secondChecked = checked }, onSelectedChange = { checked -> secondChecked = checked },
label = { Text(text = "Enabled".uppercase()) } label = { Text(text = "Enabled") },
)
NiaFilterChip(
selected = false,
onSelectedChange = {},
enabled = false,
label = { Text(text = "Disabled") },
) )
var thirdChecked by remember { mutableStateOf(true) }
NiaFilterChip( NiaFilterChip(
selected = thirdChecked, selected = true,
onSelectedChange = { checked -> thirdChecked = checked }, onSelectedChange = {},
enabled = false, enabled = false,
label = { Text(text = "Disabled".uppercase()) } label = { Text(text = "Disabled") },
) )
} }
} }
item { Text("Toggle buttons", Modifier.padding(top = 16.dp)) } item { Text("Icon buttons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
var firstChecked by remember { mutableStateOf(false) } var firstChecked by remember { mutableStateOf(false) }
NiaToggleButton( NiaIconToggleButton(
checked = firstChecked, checked = firstChecked,
onCheckedChange = { checked -> firstChecked = checked }, onCheckedChange = { checked -> firstChecked = checked },
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
} },
) )
var secondChecked by remember { mutableStateOf(true) } var secondChecked by remember { mutableStateOf(true) }
NiaToggleButton( NiaIconToggleButton(
checked = secondChecked, checked = secondChecked,
onCheckedChange = { checked -> secondChecked = checked }, onCheckedChange = { checked -> secondChecked = checked },
icon = { icon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder), painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null contentDescription = null,
) )
}, },
checkedIcon = { checkedIcon = {
Icon( Icon(
painter = painterResource(id = NiaIcons.Bookmark), painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null contentDescription = null,
) )
} },
) )
var thirdChecked by remember { mutableStateOf(false) } NiaIconToggleButton(
NiaToggleButton( checked = false,
checked = thirdChecked, onCheckedChange = {},
onCheckedChange = { checked -> thirdChecked = checked },
icon = { icon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null,
)
}, },
checkedIcon = { checkedIcon = {
Icon(imageVector = NiaIcons.Check, contentDescription = null) Icon(
} painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null,
)
},
enabled = false,
) )
var fourthChecked by remember { mutableStateOf(true) } NiaIconToggleButton(
NiaToggleButton( checked = true,
checked = fourthChecked, onCheckedChange = {},
onCheckedChange = { checked -> fourthChecked = checked },
icon = { icon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null) Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null,
)
}, },
checkedIcon = { checkedIcon = {
Icon(imageVector = NiaIcons.Check, contentDescription = null) Icon(
} painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null,
)
},
enabled = false,
) )
} }
} }
@ -504,51 +276,42 @@ fun NiaCatalog() {
expanded = firstExpanded, expanded = firstExpanded,
onExpandedChange = { expanded -> firstExpanded = expanded }, onExpandedChange = { expanded -> firstExpanded = expanded },
compactText = { Text(text = "Compact view") }, compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") } expandedText = { Text(text = "Expanded view") },
) )
var secondExpanded by remember { mutableStateOf(true) } var secondExpanded by remember { mutableStateOf(true) }
NiaViewToggleButton( NiaViewToggleButton(
expanded = secondExpanded, expanded = secondExpanded,
onExpandedChange = { expanded -> secondExpanded = expanded }, onExpandedChange = { expanded -> secondExpanded = expanded },
compactText = { Text(text = "Compact view") }, compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") } expandedText = { Text(text = "Expanded view") },
)
NiaViewToggleButton(
expanded = false,
onExpandedChange = {},
compactText = { Text(text = "Disabled") },
expandedText = { Text(text = "Disabled") },
enabled = false,
) )
} }
} }
item { Text("Tags", Modifier.padding(top = 16.dp)) } item { Text("Tags", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(mainAxisSpacing = 16.dp) { FlowRow(mainAxisSpacing = 16.dp) {
var expandedTopicId by remember { mutableStateOf<String?>(null) }
var firstFollowed by remember { mutableStateOf(false) }
NiaTopicTag( NiaTopicTag(
expanded = expandedTopicId == "Topic 1", followed = true,
followed = firstFollowed, onClick = {},
onDropMenuToggle = { show ->
expandedTopicId = if (show) "Topic 1" else null
},
onFollowClick = { firstFollowed = true },
onUnfollowClick = { firstFollowed = false },
onBrowseClick = {},
text = { Text(text = "Topic 1".uppercase()) }, text = { Text(text = "Topic 1".uppercase()) },
followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") }
) )
var secondFollowed by remember { mutableStateOf(true) }
NiaTopicTag( NiaTopicTag(
expanded = expandedTopicId == "Topic 2", followed = false,
followed = secondFollowed, onClick = {},
onDropMenuToggle = { show ->
expandedTopicId = if (show) "Topic 2" else null
},
onFollowClick = { secondFollowed = true },
onUnfollowClick = { secondFollowed = false },
onBrowseClick = {},
text = { Text(text = "Topic 2".uppercase()) }, text = { Text(text = "Topic 2".uppercase()) },
followText = { Text(text = "Follow") }, )
unFollowText = { Text(text = "Unfollow") }, NiaTopicTag(
browseText = { Text(text = "Browse topic") } followed = false,
onClick = {},
text = { Text(text = "Disabled".uppercase()) },
enabled = false,
) )
} }
} }
@ -561,7 +324,7 @@ fun NiaCatalog() {
NiaTab( NiaTab(
selected = selectedTabIndex == index, selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index }, onClick = { selectedTabIndex = index },
text = { Text(text = title) } text = { Text(text = title) },
) )
} }
} }
@ -573,12 +336,12 @@ fun NiaCatalog() {
val icons = listOf( val icons = listOf(
NiaIcons.UpcomingBorder, NiaIcons.UpcomingBorder,
NiaIcons.MenuBookBorder, NiaIcons.MenuBookBorder,
NiaIcons.BookmarksBorder NiaIcons.BookmarksBorder,
) )
val selectedIcons = listOf( val selectedIcons = listOf(
NiaIcons.Upcoming, NiaIcons.Upcoming,
NiaIcons.MenuBook, NiaIcons.MenuBook,
NiaIcons.Bookmarks NiaIcons.Bookmarks,
) )
val tagIcon = NiaIcons.Tag val tagIcon = NiaIcons.Tag
NiaNavigationBar { NiaNavigationBar {
@ -590,7 +353,7 @@ fun NiaCatalog() {
} else { } else {
Icon( Icon(
painter = painterResource(id = icons[index]), painter = painterResource(id = icons[index]),
contentDescription = item contentDescription = item,
) )
} }
}, },
@ -600,13 +363,13 @@ fun NiaCatalog() {
} else { } else {
Icon( Icon(
painter = painterResource(id = selectedIcons[index]), painter = painterResource(id = selectedIcons[index]),
contentDescription = item contentDescription = item,
) )
} }
}, },
label = { Text(item) }, label = { Text(item) },
selected = selectedItem == index, selected = selectedItem == index,
onClick = { selectedItem = index } onClick = { selectedItem = index },
) )
} }
} }

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

@ -1,5 +1,5 @@
/* /*
* Copyright 2021 The Android Open Source Project * Copyright 2022 The Android Open Source Project
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -13,21 +13,23 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import com.google.samples.apps.nowinandroid.NiaBuildType
plugins { plugins {
id("nowinandroid.android.application") id("nowinandroid.android.application")
id("nowinandroid.android.application.compose") id("nowinandroid.android.application.compose")
id("nowinandroid.android.application.flavors")
id("nowinandroid.android.application.jacoco") id("nowinandroid.android.application.jacoco")
id("nowinandroid.android.hilt") id("nowinandroid.android.hilt")
id("jacoco") id("jacoco")
id("nowinandroid.firebase-perf") id("nowinandroid.android.application.firebase")
} }
android { android {
defaultConfig { defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid" applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 3 versionCode = 5
versionName = "0.0.3" // X.Y.Z; X = Major, Y = minor, Z = Patch level versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph // Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
@ -38,10 +40,11 @@ android {
buildTypes { buildTypes {
val debug by getting { val debug by getting {
applicationIdSuffix = ".debug" applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix
} }
val release by getting { val release by getting {
isMinifyEnabled = true isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// To publish on the Play store a private signing key is required, but to allow anyone // To publish on the Play store a private signing key is required, but to allow anyone
@ -57,9 +60,8 @@ android {
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
// Only use benchmark proguard rules // Only use benchmark proguard rules
proguardFiles("benchmark-rules.pro") proguardFiles("benchmark-rules.pro")
// FIXME enabling minification breaks access to demo backend. isMinifyEnabled = true
isMinifyEnabled = false applicationIdSuffix = NiaBuildType.BENCHMARK.applicationIdSuffix
applicationIdSuffix = ".benchmark"
} }
} }
@ -77,7 +79,6 @@ android {
} }
dependencies { dependencies {
implementation(project(":feature:author"))
implementation(project(":feature:interests")) implementation(project(":feature:interests"))
implementation(project(":feature:foryou")) implementation(project(":feature:foryou"))
implementation(project(":feature:bookmarks")) implementation(project(":feature:bookmarks"))
@ -89,6 +90,7 @@ dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:analytics"))
implementation(project(":sync:work")) implementation(project(":sync:work"))
@ -97,8 +99,10 @@ dependencies {
androidTestImplementation(project(":core:data-test")) androidTestImplementation(project(":core:data-test"))
androidTestImplementation(project(":core:network")) androidTestImplementation(project(":core:network"))
androidTestImplementation(libs.androidx.navigation.testing) androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness)
androidTestImplementation(kotlin("test")) androidTestImplementation(kotlin("test"))
debugImplementation(libs.androidx.compose.ui.testManifest) debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(project(":ui-test-hilt-manifest"))
implementation(libs.accompanist.systemuicontroller) implementation(libs.accompanist.systemuicontroller)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@ -115,7 +119,6 @@ dependencies {
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
implementation(libs.coil.kt) implementation(libs.coil.kt)
implementation(libs.coil.kt.svg)
} }
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13 // androidx.test is forcing JUnit, 4.12. This forces it to use 4.13

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

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

@ -0,0 +1,244 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.accompanist.testharness.TestHarness
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
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.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@HiltAndroidTest
class NavigationUiTest {
/**
* Manages the components' state and is used to perform injection on your test
*/
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
/**
* 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)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
@Inject
lateinit var networkMonitor: NetworkMonitor
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun compactWidth_compactHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_mediumHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_expandedHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
}

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

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

File diff suppressed because it is too large Load Diff

@ -24,6 +24,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -37,16 +38,18 @@ import androidx.metrics.performance.JankStats
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint @AndroidEntryPoint
@ -61,6 +64,9 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var analyticsHelper: AnalyticsHelper
val viewModel: MainActivityViewModel by viewModels() val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -104,14 +110,17 @@ class MainActivity : ComponentActivity() {
onDispose {} onDispose {}
} }
NiaTheme( CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
darkTheme = darkTheme, NiaTheme(
androidTheme = shouldUseAndroidTheme(uiState) darkTheme = darkTheme,
) { androidTheme = shouldUseAndroidTheme(uiState),
NiaApp( disableDynamicTheming = shouldDisableDynamicTheming(uiState),
networkMonitor = networkMonitor, ) {
windowSizeClass = calculateWindowSizeClass(this), NiaApp(
) networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this),
)
}
} }
} }
} }
@ -131,7 +140,7 @@ class MainActivity : ComponentActivity() {
* Returns `true` if the Android theme should be used, as a function of the [uiState]. * Returns `true` if the Android theme should be used, as a function of the [uiState].
*/ */
@Composable @Composable
fun shouldUseAndroidTheme( private fun shouldUseAndroidTheme(
uiState: MainActivityUiState, uiState: MainActivityUiState,
): Boolean = when (uiState) { ): Boolean = when (uiState) {
Loading -> false Loading -> false
@ -141,12 +150,23 @@ fun shouldUseAndroidTheme(
} }
} }
/**
* Returns `true` if the dynamic color is disabled, as a function of the [uiState].
*/
@Composable
private fun shouldDisableDynamicTheming(
uiState: MainActivityUiState,
): Boolean = when (uiState) {
Loading -> false
is Success -> !uiState.userData.useDynamicColor
}
/** /**
* Returns `true` if dark theme should be used, as a function of the [uiState] and the * Returns `true` if dark theme should be used, as a function of the [uiState] and the
* current system context. * current system context.
*/ */
@Composable @Composable
fun shouldUseDarkTheme( private fun shouldUseDarkTheme(
uiState: MainActivityUiState, uiState: MainActivityUiState,
): Boolean = when (uiState) { ): Boolean = when (uiState) {
Loading -> isSystemInDarkTheme() Loading -> isSystemInDarkTheme()

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

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

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

@ -20,8 +20,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.author.navigation.authorScreen
import com.google.samples.apps.nowinandroid.feature.author.navigation.navigateToAuthor
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
@ -39,28 +37,27 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
@Composable @Composable
fun NiaNavHost( fun NiaNavHost(
navController: NavHostController, navController: NavHostController,
onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute startDestination: String = forYouNavigationRoute,
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
modifier = modifier, modifier = modifier,
) { ) {
forYouScreen() // TODO: handle topic clicks from each top level destination
bookmarksScreen() forYouScreen(onTopicClick = {})
bookmarksScreen(onTopicClick = {})
interestsGraph( interestsGraph(
navigateToTopic = { topicId -> onTopicClick = { topicId ->
navController.navigateToTopic(topicId) navController.navigateToTopic(topicId)
}, },
navigateToAuthor = { authorId ->
navController.navigateToAuthor(authorId)
},
nestedGraphs = { nestedGraphs = {
topicScreen(onBackClick) topicScreen(
authorScreen(onBackClick) onBackClick = navController::popBackStack,
} onTopicClick = {},
)
},
) )
} }
} }

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

@ -16,11 +16,12 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumedWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -44,12 +45,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
@ -65,16 +65,17 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class, ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class,
ExperimentalLifecycleComposeApi::class
) )
@Composable @Composable
fun NiaApp( fun NiaApp(
@ -82,109 +83,105 @@ fun NiaApp(
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
appState: NiaAppState = rememberNiaAppState( appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass windowSizeClass = windowSizeClass,
), ),
) { ) {
val background: @Composable (@Composable () -> Unit) -> Unit = val shouldShowGradientBackground =
when (appState.currentTopLevelDestination) { appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
TopLevelDestination.FOR_YOU -> {
content ->
NiaGradientBackground(content = content)
}
else -> { content -> NiaBackground(content = content) }
}
background {
val snackbarHostState = remember { SnackbarHostState() } NiaBackground {
NiaGradientBackground(
Scaffold( gradientColors = if (shouldShowGradientBackground) {
modifier = Modifier.semantics { LocalGradientColors.current
testTagsAsResourceId = true } else {
GradientColors()
}, },
containerColor = Color.Transparent, ) {
contentColor = MaterialTheme.colorScheme.onBackground, val snackbarHostState = remember { SnackbarHostState() }
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
if (destination != null) {
NiaTopAppBar(
// When the nav rail is displayed, the top app bar will, by default
// overlap it. This means that the top most item in the nav rail
// won't be tappable. A workaround is to position the top app bar
// behind the nav rail using zIndex.
modifier = Modifier.zIndex(-1F),
titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
onActionClick = { appState.setShowSettingsDialog(true) }
)
}
},
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination
)
}
}
) { padding ->
val isOffline by appState.isOffline.collectAsStateWithLifecycle() val isOffline by appState.isOffline.collectAsStateWithLifecycle()
// If user is not connected to the internet show a snack bar to inform them. // If user is not connected to the internet show a snack bar to inform them.
val notConnected = stringResource(R.string.not_connected) val notConnectedMessage = stringResource(R.string.not_connected)
LaunchedEffect(isOffline) { LaunchedEffect(isOffline) {
if (isOffline) snackbarHostState.showSnackbar( if (isOffline) {
message = notConnected, snackbarHostState.showSnackbar(
duration = Indefinite message = notConnectedMessage,
) duration = Indefinite,
)
}
} }
if (appState.shouldShowSettingsDialog) { if (appState.shouldShowSettingsDialog) {
SettingsDialog( SettingsDialog(
onDismiss = { appState.setShowSettingsDialog(false) } onDismiss = { appState.setShowSettingsDialog(false) },
) )
} }
Row( Scaffold(
Modifier modifier = Modifier.semantics {
.fillMaxSize() testTagsAsResourceId = true
.windowInsetsPadding( },
WindowInsets.safeDrawing.only( containerColor = Color.Transparent,
WindowInsetsSides.Horizontal contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"),
) )
) }
) { },
if (appState.shouldShowNavRail) { ) { padding ->
NiaNavRail( Row(
destinations = appState.topLevelDestinations, Modifier
onNavigateToDestination = appState::navigateToTopLevelDestination, .fillMaxSize()
currentDestination = appState.currentDestination, .padding(padding)
modifier = Modifier.safeDrawingPadding() .consumeWindowInsets(padding)
) .windowInsetsPadding(
} WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal,
),
),
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding(),
)
}
NiaNavHost( Column(Modifier.fillMaxSize()) {
navController = appState.navController, // Show the top app bar on top level destinations.
onBackClick = appState::onBackClick, val destination = appState.currentTopLevelDestination
if (destination != null) {
NiaTopAppBar(
titleRes = destination.titleTextId,
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description,
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
),
onActionClick = { appState.setShowSettingsDialog(true) },
)
}
modifier = Modifier NiaNavHost(appState.navController)
.padding(padding) }
.consumedWindowInsets(padding)
)
// TODO: We may want to add padding or spacer when the snackbar is shown so that // TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it. // content doesn't display behind it.
}
} }
} }
} }
@ -212,15 +209,15 @@ private fun NiaNavRail(
when (icon) { when (icon) {
is ImageVectorIcon -> Icon( is ImageVectorIcon -> Icon(
imageVector = icon.imageVector, imageVector = icon.imageVector,
contentDescription = null contentDescription = null,
) )
is DrawableResourceIcon -> Icon( is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id), painter = painterResource(id = icon.id),
contentDescription = null contentDescription = null,
) )
} }
}, },
label = { Text(stringResource(destination.iconTextId)) } label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }
@ -230,9 +227,12 @@ private fun NiaNavRail(
private fun NiaBottomBar( private fun NiaBottomBar(
destinations: List<TopLevelDestination>, destinations: List<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit, onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination? currentDestination: NavDestination?,
modifier: Modifier = Modifier,
) { ) {
NiaNavigationBar { NiaNavigationBar(
modifier = modifier,
) {
destinations.forEach { destination -> destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
NiaNavigationBarItem( NiaNavigationBarItem(
@ -247,16 +247,16 @@ private fun NiaBottomBar(
when (icon) { when (icon) {
is ImageVectorIcon -> Icon( is ImageVectorIcon -> Icon(
imageVector = icon.imageVector, imageVector = icon.imageVector,
contentDescription = null contentDescription = null,
) )
is DrawableResourceIcon -> Icon( is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id), painter = painterResource(id = icon.id),
contentDescription = null contentDescription = null,
) )
} }
}, },
label = { Text(stringResource(destination.iconTextId)) } label = { Text(stringResource(destination.iconTextId)) },
) )
} }
} }

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

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2023 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- Enable Firebase analytics for `prod` builds -->
<meta-data
tools:replace="android:value"
android:name="firebase_analytics_collection_deactivated"
android:value="false" />
</application>
</manifest>

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.dsl.ManagedVirtualDevice import com.android.build.api.dsl.ManagedVirtualDevice
import com.google.samples.apps.nowinandroid.NiaBuildType
import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureFlavors
plugins { plugins {
@ -26,6 +27,8 @@ android {
defaultConfig { defaultConfig {
minSdk = 23 minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "APP_BUILD_TYPE_SUFFIX", "\"\"")
} }
buildFeatures { buildFeatures {
@ -41,39 +44,37 @@ android {
isDebuggable = true isDebuggable = true
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
matchingFallbacks.add("release") matchingFallbacks.add("release")
buildConfigField(
"String",
"APP_BUILD_TYPE_SUFFIX",
"\"${NiaBuildType.BENCHMARK.applicationIdSuffix ?: ""}\""
)
} }
} }
// Use the same flavor dimensions as the application to allow generating Baseline Profiles on prod, // Use the same flavor dimensions as the application to allow generating Baseline Profiles on prod,
// which is more close to what will be shipped to users (no fake data), but has ability to run the // which is more close to what will be shipped to users (no fake data), but has ability to run the
// benchmarks on demo, so we benchmark on stable data. // benchmarks on demo, so we benchmark on stable data.
configureFlavors(this) configureFlavors(this) { flavor ->
buildConfigField(
"String",
"APP_FLAVOR_SUFFIX",
"\"${flavor.applicationIdSuffix ?: ""}\""
)
}
targetProjectPath = ":app" targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true experimentalProperties["android.experimental.self-instrumenting"] = true
testOptions {
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "aosp"
}
}
}
}
} }
dependencies { dependencies {
implementation(libs.androidx.benchmark.macro)
implementation(libs.androidx.test.core) implementation(libs.androidx.test.core)
implementation(libs.androidx.test.espresso.core) implementation(libs.androidx.test.espresso.core)
implementation(libs.androidx.test.ext) implementation(libs.androidx.test.ext)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.rules) implementation(libs.androidx.test.rules)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.uiautomator) implementation(libs.androidx.test.uiautomator)
implementation(libs.androidx.benchmark.macro)
implementation(libs.androidx.profileinstaller)
} }
androidComponents { androidComponents {

@ -0,0 +1,48 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.test.uiautomator
import androidx.test.uiautomator.HasChildrenOp.AT_LEAST
import androidx.test.uiautomator.HasChildrenOp.AT_MOST
import androidx.test.uiautomator.HasChildrenOp.EXACTLY
// These helpers need to be in the androidx.test.uiautomator package,
// because the abstract class has package local method that needs to be implemented.
/**
* Condition will be satisfied if given element has specified count of children
*/
fun untilHasChildren(
childCount: Int = 1,
op: HasChildrenOp = AT_LEAST,
): UiObject2Condition<Boolean> {
return object : UiObject2Condition<Boolean>() {
override fun apply(element: UiObject2): Boolean {
return when (op) {
AT_LEAST -> element.childCount >= childCount
EXACTLY -> element.childCount == childCount
AT_MOST -> element.childCount <= childCount
}
}
}
}
enum class HasChildrenOp {
AT_LEAST,
EXACTLY,
AT_MOST,
}

@ -16,16 +16,25 @@
package com.google.samples.apps.nowinandroid package com.google.samples.apps.nowinandroid
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import com.google.samples.apps.nowinandroid.benchmarks.BuildConfig import com.google.samples.apps.nowinandroid.benchmarks.BuildConfig
/** /**
* Convenience parameter to use proper package name with regards to build type and build flavor. * Convenience parameter to use proper package name with regards to build type and build flavor.
*/ */
val PACKAGE_NAME = StringBuilder("com.google.samples.apps.nowinandroid").apply { val PACKAGE_NAME = buildString {
if (BuildConfig.FLAVOR != "prod") { append("com.google.samples.apps.nowinandroid")
append(".${BuildConfig.FLAVOR}") append(BuildConfig.APP_FLAVOR_SUFFIX)
} append(BuildConfig.APP_BUILD_TYPE_SUFFIX)
if (BuildConfig.BUILD_TYPE != "release") { }
append(".${BuildConfig.BUILD_TYPE}")
} fun UiDevice.flingElementDownUp(element: UiObject2) {
}.toString() // Set some margin from the sides to prevent triggering system navigation
element.setGestureMargin(displayWidth / 5)
element.fling(Direction.DOWN)
waitForIdle()
element.fling(Direction.UP)
}

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

@ -18,11 +18,19 @@ package com.google.samples.apps.nowinandroid.bookmarks
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction 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() { fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() {
val feedList = device.findObject(By.res("bookmarks:feed")) val feedList = device.findObject(By.res("bookmarks:feed"))
feedList.fling(Direction.DOWN) device.flingElementDownUp(feedList)
device.waitForIdle()
feedList.fling(Direction.UP)
} }

@ -18,27 +18,73 @@ package com.google.samples.apps.nowinandroid.foryou
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import androidx.test.uiautomator.untilHasChildren
import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.forYouWaitForContent() { fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded // Wait until content is loaded by checking if topics are loaded
device.wait(Until.hasObject(By.text("What are you interested in?")), 30_000) 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 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)
} }
fun MacrobenchmarkScope.forYouSelectAuthors() { /**
val authors = device.findObject(By.res("forYou:authors")) * Selects some topics, which will show the feed content for them.
// select some authors to show some feed content * [recheckTopicsIfChecked] Topics may be already checked from the previous iteration.
repeat(3) { index -> */
val author = authors.children[index % authors.childCount] fun MacrobenchmarkScope.forYouSelectTopics(recheckTopicsIfChecked: Boolean = false) {
author.click() val topics = device.findObject(By.res("forYou:topicSelection"))
device.waitForIdle()
// Set gesture margin from sides not to trigger system gesture navigation
val horizontalMargin = 10 * topics.visibleBounds.width() / 100
topics.setGestureMargins(horizontalMargin, 0, horizontalMargin, 0)
// Select some topics to show some feed content
var index = 0
var visited = 0
while (visited < 3) {
// Selecting some topics, which will populate items in the feed.
val topic = topics.children[index % topics.childCount]
// Find the checkable element to figure out whether it's checked or not
val topicCheckIcon = topic.findObject(By.checkable(true))
// Topic icon may not be visible if it's out of the screen boundaries
// If that's the case, let's try another index
if (topicCheckIcon == null) {
index++
continue
}
when {
// Topic wasn't checked, so just do that
!topicCheckIcon.isChecked -> {
topic.click()
device.waitForIdle()
}
// Topic was checked already and we want to recheck it, so just do it twice
recheckTopicsIfChecked -> {
repeat(2) {
topic.click()
device.waitForIdle()
}
}
else -> {
// Topic is checked, but we don't recheck it
}
}
index++
visited++
} }
} }
fun MacrobenchmarkScope.forYouScrollFeedDownUp() { fun MacrobenchmarkScope.forYouScrollFeedDownUp() {
val feedList = device.findObject(By.res("forYou:feed")) val feedList = device.findObject(By.res("forYou:feed"))
feedList.fling(Direction.DOWN) device.flingElementDownUp(feedList)
device.waitForIdle()
feedList.fling(Direction.UP)
} }

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

@ -18,21 +18,24 @@ package com.google.samples.apps.nowinandroid.interests
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp
fun MacrobenchmarkScope.interestsScrollTopicsDownUp() { fun MacrobenchmarkScope.goToInterestsScreen() {
val topicsList = device.findObject(By.res("interests:topics")) device.findObject(By.text("Interests")).click()
topicsList.fling(Direction.DOWN)
device.waitForIdle() device.waitForIdle()
topicsList.fling(Direction.UP) // 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.interestsScrollPeopleDownUp() { fun MacrobenchmarkScope.interestsScrollTopicsDownUp() {
val peopleList = device.findObject(By.res("interests:people")) val topicsList = device.findObject(By.res("interests:topics"))
peopleList.fling(Direction.DOWN) device.flingElementDownUp(topicsList)
device.waitForIdle()
peopleList.fling(Direction.UP)
} }
fun MacrobenchmarkScope.interestsWaitForTopics() { fun MacrobenchmarkScope.interestsWaitForTopics() {

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

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

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

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

@ -0,0 +1,58 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
class AndroidApplicationFirebaseConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.gms.google-services")
apply("com.google.firebase.firebase-perf")
apply("com.google.firebase.crashlytics")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
val bom = libs.findLibrary("firebase-bom").get()
add("implementation", platform(bom))
"implementation"(libs.findLibrary("firebase.analytics").get())
"implementation"(libs.findLibrary("firebase.performance").get())
"implementation"(libs.findLibrary("firebase.crashlytics").get())
}
extensions.configure<ApplicationAndroidComponentsExtension> {
finalizeDsl {
it.buildTypes.forEach { buildType ->
// Disable the Crashlytics mapping file upload. This feature should only be
// enabled if a Firebase backend is available and configured in
// google-services.json.
buildType.configure<CrashlyticsExtension> {
mappingFileUploadEnabled = false
}
}
}
}
}
}
}

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

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

@ -14,21 +14,20 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureJacoco
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.kotlin
class AndroidHiltConventionPlugin : Plugin<Project> { class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply("org.jetbrains.kotlin.kapt")
apply("dagger.hilt.android.plugin") apply("dagger.hilt.android.plugin")
// KAPT must go last to avoid build warnings.
// See: https://stackoverflow.com/questions/70550883/warning-the-following-options-were-not-recognized-by-any-processor-dagger-f
apply("org.jetbrains.kotlin.kapt")
} }
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs") val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

@ -14,10 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin import org.gradle.api.Plugin
@ -40,6 +40,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 33 defaultConfig.targetSdk = 33
configureFlavors(this) configureFlavors(this)
configureGradleManagedDevices(this)
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this) configurePrintApksTask(this)

@ -0,0 +1,63 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.devtools.ksp.gradle.KspExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.process.CommandLineArgumentProvider
import java.io.File
class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
extensions.configure<KspExtension> {
// The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations.
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
add("implementation", libs.findLibrary("room.runtime").get())
add("implementation", libs.findLibrary("room.ktx").get())
add("ksp", libs.findLibrary("room.compiler").get())
}
}
}
/**
* https://issuetracker.google.com/issues/132245929
* [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas)
*/
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File,
) : CommandLineArgumentProvider {
override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}")
}
}

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

@ -0,0 +1,63 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ManagedVirtualDevice
import org.gradle.api.Project
import org.gradle.kotlin.dsl.invoke
import java.util.Locale
/**
* Configure project for Gradle managed devices
*/
internal fun configureGradleManagedDevices(
commonExtension: CommonExtension<*, *, *, *>,
) {
val deviceConfigs = listOf(
DeviceConfig("Pixel 4", 30, "aosp-atd"),
DeviceConfig("Pixel 6", 31, "aosp"),
DeviceConfig("Pixel C", 30, "aosp-atd"),
)
commonExtension.testOptions {
managedDevices {
devices {
deviceConfigs.forEach { deviceConfig ->
maybeCreate(deviceConfig.taskName, ManagedVirtualDevice::class.java).apply {
device = deviceConfig.device
apiLevel = deviceConfig.apiLevel
systemImageSource = deviceConfig.systemImageSource
}
}
}
}
}
}
private data class DeviceConfig(
val device: String,
val apiLevel: Int,
val systemImageSource: String,
) {
val taskName = buildString {
append(device.toLowerCase(Locale.ROOT).replace(" ", ""))
append("api")
append(apiLevel.toString())
append(systemImageSource.replace("-", ""))
}
}

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

@ -0,0 +1,27 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid
/**
* This is shared between :app and :benchmarks module to provide configurations type safety.
*/
@Suppress("unused")
enum class NiaBuildType(val applicationIdSuffix: String? = null) {
DEBUG(".debug"),
RELEASE,
BENCHMARK(".benchmark")
}

@ -2,10 +2,11 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.ApplicationProductFlavor import com.android.build.api.dsl.ApplicationProductFlavor
import com.android.build.api.dsl.ApplicationVariantDimension
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ProductFlavor
import org.gradle.api.Project import org.gradle.api.Project
@Suppress("EnumEntryName")
enum class FlavorDimension { enum class FlavorDimension {
contentType contentType
} }
@ -13,20 +14,23 @@ enum class FlavorDimension {
// The content for the app can either come from local static data which is useful for demo // The content for the app can either come from local static data which is useful for demo
// purposes, or from a production backend server which supplies up-to-date, real content. // purposes, or from a production backend server which supplies up-to-date, real content.
// These two product flavors reflect this behaviour. // These two product flavors reflect this behaviour.
enum class Flavor (val dimension : FlavorDimension, val applicationIdSuffix : String? = null) { @Suppress("EnumEntryName")
demo(FlavorDimension.contentType), enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) {
prod(FlavorDimension.contentType, ".prod") demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"),
prod(FlavorDimension.contentType, )
} }
fun Project.configureFlavors( fun Project.configureFlavors(
commonExtension: CommonExtension<*, *, *, *> commonExtension: CommonExtension<*, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {}
) { ) {
commonExtension.apply { commonExtension.apply {
flavorDimensions += FlavorDimension.contentType.name flavorDimensions += FlavorDimension.contentType.name
productFlavors { productFlavors {
Flavor.values().forEach{ NiaFlavor.values().forEach {
create(it.name) { create(it.name) {
dimension = it.dimension.name dimension = it.dimension.name
flavorConfigurationBlock(this, it)
if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) { if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) {
if (it.applicationIdSuffix != null) { if (it.applicationIdSuffix != null) {
this.applicationIdSuffix = it.applicationIdSuffix this.applicationIdSuffix = it.applicationIdSuffix
@ -36,4 +40,4 @@ fun Project.configureFlavors(
} }
} }
} }
} }

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

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

@ -0,0 +1,32 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("nowinandroid.android.library")
id("nowinandroid.android.library.compose")
id("nowinandroid.android.hilt")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.analytics"
}
dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.core.ktx)
implementation(libs.firebase.analytics)
implementation(libs.kotlinx.coroutines.android)
}

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

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2023 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest />

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

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

@ -1,5 +1,5 @@
/* /*
* Copyright 2021 The Android Open Source Project * Copyright 2023 The Android Open Source Project
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,10 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.network.fake package com.google.samples.apps.nowinandroid.core.analytics
object FakeDataSource { /**
const val AUTHORS = "authors.json" * Implementation of AnalyticsHelper which does nothing. Useful for tests and previews.
const val DATA = "data.json" */
const val TOPICS = "topics.json" class NoOpAnalyticsHelper : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) = Unit
} }

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

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

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

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

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

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

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

@ -17,11 +17,9 @@
package com.google.samples.apps.nowinandroid.core.data.test package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.di.DataModule import com.google.samples.apps.nowinandroid.core.data.di.DataModule
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeAuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
@ -34,31 +32,26 @@ import dagger.hilt.testing.TestInstallIn
@Module @Module
@TestInstallIn( @TestInstallIn(
components = [SingletonComponent::class], components = [SingletonComponent::class],
replaces = [DataModule::class] replaces = [DataModule::class],
) )
interface TestDataModule { interface TestDataModule {
@Binds @Binds
fun bindsTopicRepository( fun bindsTopicRepository(
fakeTopicsRepository: FakeTopicsRepository fakeTopicsRepository: FakeTopicsRepository,
): TopicsRepository ): TopicsRepository
@Binds
fun bindsAuthorRepository(
fakeAuthorsRepository: FakeAuthorsRepository
): AuthorsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
fakeNewsRepository: FakeNewsRepository fakeNewsRepository: FakeNewsRepository,
): NewsRepository ): NewsRepository
@Binds @Binds
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository userDataRepository: FakeUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor networkMonitor: AlwaysOnlineNetworkMonitor,
): NetworkMonitor ): NetworkMonitor
} }

@ -31,18 +31,18 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:analytics"))
implementation(project(":core:common")) implementation(project(":core:common"))
implementation(project(":core:model"))
implementation(project(":core:database")) implementation(project(":core:database"))
implementation(project(":core:datastore")) implementation(project(":core:datastore"))
implementation(project(":core:model"))
implementation(project(":core:network")) implementation(project(":core:network"))
implementation(project(":core:notifications"))
testImplementation(project(":core:testing"))
testImplementation(project(":core:datastore-test"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
testImplementation(project(":core:datastore-test"))
testImplementation(project(":core:testing"))
} }

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

@ -16,9 +16,7 @@
package com.google.samples.apps.nowinandroid.core.data.di package com.google.samples.apps.nowinandroid.core.data.di
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstAuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository
@ -37,26 +35,21 @@ interface DataModule {
@Binds @Binds
fun bindsTopicRepository( fun bindsTopicRepository(
topicsRepository: OfflineFirstTopicsRepository topicsRepository: OfflineFirstTopicsRepository,
): TopicsRepository ): TopicsRepository
@Binds
fun bindsAuthorsRepository(
authorsRepository: OfflineFirstAuthorsRepository
): AuthorsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository newsRepository: OfflineFirstNewsRepository,
): NewsRepository ): NewsRepository
@Binds @Binds
fun bindsUserDataRepository( fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor networkMonitor: ConnectivityManagerNetworkMonitor,
): NetworkMonitor ): NetworkMonitor
} }

@ -1,29 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
fun NetworkAuthor.asEntity() = AuthorEntity(
id = id,
name = name,
imageUrl = imageUrl,
twitter = twitter,
mediumPage = mediumPage,
bio = bio,
)

@ -16,8 +16,6 @@
package com.google.samples.apps.nowinandroid.core.data.model package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -44,22 +42,6 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
type = type, type = type,
) )
/**
* A shell [AuthorEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB
*/
fun NetworkNewsResource.authorEntityShells() =
authors.map { authorId ->
AuthorEntity(
id = authorId,
name = "",
imageUrl = "",
twitter = "",
mediumPage = "",
bio = "",
)
}
/** /**
* A shell [TopicEntity] to fulfill the foreign key constraint when inserting * A shell [TopicEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB * a [NewsResourceEntity] into the DB
@ -80,14 +62,6 @@ fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef>
topics.map { topicId -> topics.map { topicId ->
NewsResourceTopicCrossRef( NewsResourceTopicCrossRef(
newsResourceId = id, newsResourceId = id,
topicId = topicId topicId = topicId,
)
}
fun NetworkNewsResource.authorCrossReferences(): List<NewsResourceAuthorCrossRef> =
authors.map { authorId ->
NewsResourceAuthorCrossRef(
newsResourceId = id,
authorId = authorId
) )
} }

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

@ -0,0 +1,84 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) {
val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved"
val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id"
logEvent(
AnalyticsEvent(
type = eventType,
extras = listOf(
Param(key = paramKey, value = newsResourceId),
),
),
)
}
fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: Boolean) {
val eventType = if (isFollowed) "topic_followed" else "topic_unfollowed"
val paramKey = if (isFollowed) "followed_topic_id" else "unfollowed_topic_id"
logEvent(
AnalyticsEvent(
type = eventType,
extras = listOf(
Param(key = paramKey, value = followedTopicId),
),
),
)
}
fun AnalyticsHelper.logThemeChanged(themeName: String) =
logEvent(
AnalyticsEvent(
type = "theme_changed",
extras = listOf(
Param(key = "theme_name", value = themeName),
),
),
)
fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =
logEvent(
AnalyticsEvent(
type = "dark_theme_config_changed",
extras = listOf(
Param(key = "dark_theme_config", value = darkThemeConfigName),
),
),
)
fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =
logEvent(
AnalyticsEvent(
type = "dynamic_color_preference_changed",
extras = listOf(
Param(key = "dynamic_color_preference", value = useDynamicColor.toString()),
),
),
)
fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) {
val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset"
logEvent(
AnalyticsEvent(type = eventType),
)
}

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

@ -1,68 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.changeListSync
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Disk storage backed implementation of the [AuthorsRepository].
* Reads are exclusively from local storage to support offline access.
*/
class OfflineFirstAuthorsRepository @Inject constructor(
private val authorDao: AuthorDao,
private val network: NiaNetworkDataSource,
) : AuthorsRepository {
override fun getAuthorStream(id: String): Flow<Author> =
authorDao.getAuthorEntityStream(id).map {
it.asExternalModel()
}
override fun getAuthorsStream(): Flow<List<Author>> =
authorDao.getAuthorEntitiesStream()
.map { it.map(AuthorEntity::asExternalModel) }
override suspend fun syncWith(synchronizer: Synchronizer): Boolean =
synchronizer.changeListSync(
versionReader = ChangeListVersions::authorVersion,
changeListFetcher = { currentVersion ->
network.getAuthorChangeList(after = currentVersion)
},
versionUpdater = { latestVersion ->
copy(authorVersion = latestVersion)
},
modelDeleter = authorDao::deleteAuthors,
modelUpdater = { changedIds ->
val networkAuthors = network.getAuthors(ids = changedIds)
authorDao.upsertAuthors(
entities = networkAuthors.map(NetworkAuthor::asEntity)
)
}
)
}

@ -19,14 +19,10 @@ package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.changeListSync import com.google.samples.apps.nowinandroid.core.data.changeListSync
import com.google.samples.apps.nowinandroid.core.data.model.asEntity import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.model.authorCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.authorEntityShells
import com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences import com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@ -34,9 +30,15 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject import com.google.samples.apps.nowinandroid.core.notifications.Notifier
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
// Heuristic value to optimize for serialization and deserialization cost on client and server
// for each news resource batch.
private const val SYNC_BATCH_SIZE = 40
/** /**
* Disk storage backed implementation of the [NewsRepository]. * Disk storage backed implementation of the [NewsRepository].
@ -44,21 +46,18 @@ import kotlinx.coroutines.flow.map
*/ */
class OfflineFirstNewsRepository @Inject constructor( class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao, private val newsResourceDao: NewsResourceDao,
private val authorDao: AuthorDao,
private val topicDao: TopicDao, private val topicDao: TopicDao,
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
private val notifier: Notifier,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResourcesStream(): Flow<List<NewsResource>> = override fun getNewsResources(
newsResourceDao.getNewsResourcesStream() query: NewsResourceQuery,
.map { it.map(PopulatedNewsResource::asExternalModel) } ): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
useFilterTopicIds = query.filterTopicIds != null,
override fun getNewsResourcesStream( filterTopicIds = query.filterTopicIds ?: emptySet(),
filterAuthorIds: Set<String>, useFilterNewsIds = query.filterNewsIds != null,
filterTopicIds: Set<String> filterNewsIds = query.filterNewsIds ?: emptySet(),
): Flow<List<NewsResource>> = newsResourceDao.getNewsResourcesStream(
filterAuthorIds = filterAuthorIds,
filterTopicIds = filterTopicIds
) )
.map { it.map(PopulatedNewsResource::asExternalModel) } .map { it.map(PopulatedNewsResource::asExternalModel) }
@ -73,38 +72,53 @@ class OfflineFirstNewsRepository @Inject constructor(
}, },
modelDeleter = newsResourceDao::deleteNewsResources, modelDeleter = newsResourceDao::deleteNewsResources,
modelUpdater = { changedIds -> modelUpdater = { changedIds ->
val networkNewsResources = network.getNewsResources(ids = changedIds) // TODO: Make this more efficient, there is no need to retrieve populated
// news resources when all that's needed are the ids
val existingNewsResourceIds = newsResourceDao.getNewsResources(
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet(),
)
.first()
.map { it.entity.id }
.toSet()
// Order of invocation matters to satisfy id and foreign key constraints! changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->
val networkNewsResources = network.getNewsResources(ids = chunkedIds)
topicDao.insertOrIgnoreTopics( // Order of invocation matters to satisfy id and foreign key constraints!
topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells) topicDao.insertOrIgnoreTopics(
.flatten() topicEntities = networkNewsResources
.distinctBy(TopicEntity::id) .map(NetworkNewsResource::topicEntityShells)
) .flatten()
authorDao.insertOrIgnoreAuthors( .distinctBy(TopicEntity::id),
authorEntities = networkNewsResources )
.map(NetworkNewsResource::authorEntityShells) newsResourceDao.upsertNewsResources(
.flatten() newsResourceEntities = networkNewsResources.map(
.distinctBy(AuthorEntity::id) NetworkNewsResource::asEntity,
) ),
newsResourceDao.upsertNewsResources( )
newsResourceEntities = networkNewsResources newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
.map(NetworkNewsResource::asEntity) newsResourceTopicCrossReferences = networkNewsResources
) .map(NetworkNewsResource::topicCrossReferences)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities( .distinct()
newsResourceTopicCrossReferences = networkNewsResources .flatten(),
.map(NetworkNewsResource::topicCrossReferences) )
.distinct() }
.flatten()
) val addedNewsResources = newsResourceDao.getNewsResources(
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities( useFilterNewsIds = true,
newsResourceAuthorCrossReferences = networkNewsResources filterNewsIds = changedIds.toSet(),
.map(NetworkNewsResource::authorCrossReferences)
.distinct()
.flatten()
) )
} .first()
.filter { !existingNewsResourceIds.contains(it.entity.id) }
.map(PopulatedNewsResource::asExternalModel)
// TODO: Define business logic for notifications on first time sync.
// we probably do not want to send notifications on first install.
// We can easily check if the change list version is 0 and not send notifications
// if it is.
if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources)
},
) )
} }

@ -26,9 +26,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Disk storage backed implementation of the [TopicsRepository]. * Disk storage backed implementation of the [TopicsRepository].
@ -39,8 +39,8 @@ class OfflineFirstTopicsRepository @Inject constructor(
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
) : TopicsRepository { ) : TopicsRepository {
override fun getTopicsStream(): Flow<List<Topic>> = override fun getTopics(): Flow<List<Topic>> =
topicDao.getTopicEntitiesStream() topicDao.getTopicEntities()
.map { it.map(TopicEntity::asExternalModel) } .map { it.map(TopicEntity::asExternalModel) }
override fun getTopic(id: String): Flow<Topic> = override fun getTopic(id: String): Flow<Topic> =
@ -59,8 +59,8 @@ class OfflineFirstTopicsRepository @Inject constructor(
modelUpdater = { changedIds -> modelUpdater = { changedIds ->
val networkTopics = network.getTopics(ids = changedIds) val networkTopics = network.getTopics(ids = changedIds)
topicDao.upsertTopics( topicDao.upsertTopics(
entities = networkTopics.map(NetworkTopic::asEntity) entities = networkTopics.map(NetworkTopic::asEntity),
) )
} },
) )
} }

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

@ -24,7 +24,7 @@ interface TopicsRepository : Syncable {
/** /**
* Gets the available topics as a stream * Gets the available topics as a stream
*/ */
fun getTopicsStream(): Flow<List<Topic>> fun getTopics(): Flow<List<Topic>>
/** /**
* Gets data for a specific topic * Gets data for a specific topic

@ -26,7 +26,7 @@ interface UserDataRepository {
/** /**
* Stream of [UserData] * Stream of [UserData]
*/ */
val userDataStream: Flow<UserData> val userData: Flow<UserData>
/** /**
* Sets the user's currently followed topics * Sets the user's currently followed topics
@ -38,16 +38,6 @@ interface UserDataRepository {
*/ */
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean)
/**
* Sets the user's currently followed authors
*/
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>)
/**
* Toggles the user's newly followed/unfollowed author
*/
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean)
/** /**
* Updates the bookmarked status for a news resource * Updates the bookmarked status for a news resource
*/ */
@ -63,6 +53,11 @@ interface UserDataRepository {
*/ */
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
/**
* Sets the preferred dynamic color config.
*/
suspend fun setDynamicColorPreference(useDynamicColor: Boolean)
/** /**
* Sets whether the user has completed the onboarding process. * Sets whether the user has completed the onboarding process.
*/ */

@ -1,72 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Author
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.FakeAssetManager
import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import java.io.InputStream
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 kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
/**
* Fake implementation of the [AuthorsRepository] that returns hardcoded authors.
*
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeAuthorsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json,
private val assets: FakeAssetManager,
) : AuthorsRepository {
override fun getAuthorsStream(): Flow<List<Author>> = flow {
emit(
assets.open(FakeDataSource.AUTHORS)
.use<InputStream, List<NetworkAuthor>>(networkJson::decodeFromStream)
.map {
Author(
id = it.id,
name = it.name,
imageUrl = it.imageUrl,
twitter = it.twitter,
mediumPage = it.mediumPage,
bio = it.bio,
)
}
)
}
.flowOn(ioDispatcher)
override fun getAuthorStream(id: String): Flow<Author> {
return getAuthorsStream().map { it.first { author -> author.id == id } }
}
override suspend fun syncWith(synchronizer: Synchronizer) = true
}

@ -19,22 +19,19 @@ package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeAssetManager import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.serialization.json.Json import javax.inject.Inject
import kotlinx.serialization.json.decodeFromStream
/** /**
* Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String. * Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String.
@ -44,39 +41,33 @@ import kotlinx.serialization.json.decodeFromStream
*/ */
class FakeNewsRepository @Inject constructor( class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json, private val datasource: FakeNiaNetworkDataSource,
private val assets: FakeAssetManager,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResourcesStream(): Flow<List<NewsResource>> = override fun getNewsResources(
flow { query: NewsResourceQuery,
emit(
assets.open(FakeDataSource.DATA)
.use<InputStream, List<NetworkNewsResource>>(networkJson::decodeFromStream)
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
)
}
.flowOn(ioDispatcher)
override fun getNewsResourcesStream(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>,
): Flow<List<NewsResource>> = ): Flow<List<NewsResource>> =
flow { flow {
emit( emit(
assets.open(FakeDataSource.DATA).use { stream -> datasource
networkJson.decodeFromStream<List<NetworkNewsResource>>(stream) .getNewsResources()
.filter { .filter { networkNewsResource ->
it.authors.intersect(filterAuthorIds).isNotEmpty() || // Filter out any news resources which don't match the current query.
it.topics.intersect(filterTopicIds).isNotEmpty() // If no query parameters (filterTopicIds or filterNewsIds) are specified
} // then the news resource is returned.
.map(NetworkNewsResource::asEntity) listOfNotNull(
.map(NewsResourceEntity::asExternalModel) true,
} query.filterNewsIds?.contains(networkNewsResource.id),
query.filterTopicIds?.let { filterTopicIds ->
networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty()
},
)
.all(true::equals)
}
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel),
) )
} }.flowOn(ioDispatcher)
.flowOn(ioDispatcher)
override suspend fun syncWith(synchronizer: Synchronizer) = true override suspend fun syncWith(synchronizer: Synchronizer) = true
} }

@ -21,18 +21,13 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeAssetManager import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json import javax.inject.Inject
import kotlinx.serialization.json.decodeFromStream
/** /**
* Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and * Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and
@ -43,29 +38,25 @@ import kotlinx.serialization.json.decodeFromStream
*/ */
class FakeTopicsRepository @Inject constructor( class FakeTopicsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json, private val datasource: FakeNiaNetworkDataSource,
private val assets: FakeAssetManager,
) : TopicsRepository { ) : TopicsRepository {
override fun getTopicsStream(): Flow<List<Topic>> = flow { override fun getTopics(): Flow<List<Topic>> = flow {
emit( emit(
assets.open(FakeDataSource.TOPICS) datasource.getTopics().map {
.use<InputStream, List<NetworkTopic>>(networkJson::decodeFromStream) Topic(
.map { id = it.id,
Topic( name = it.name,
id = it.id, shortDescription = it.shortDescription,
name = it.name, longDescription = it.longDescription,
shortDescription = it.shortDescription, url = it.url,
longDescription = it.longDescription, imageUrl = it.imageUrl,
url = it.url, )
imageUrl = it.imageUrl },
)
}
) )
} }.flowOn(ioDispatcher)
.flowOn(ioDispatcher)
override fun getTopic(id: String): Flow<Topic> { override fun getTopic(id: String): Flow<Topic> {
return getTopicsStream().map { it.first { topic -> topic.id == id } } return getTopics().map { it.first { topic -> topic.id == id } }
} }
override suspend fun syncWith(synchronizer: Synchronizer) = true override suspend fun syncWith(synchronizer: Synchronizer) = true

@ -16,17 +16,16 @@
package com.google.samples.apps.nowinandroid.core.data.repository.fake package com.google.samples.apps.nowinandroid.core.data.repository.fake
import com.google.samples.apps.nowinandroid.core.data.repository.AuthorsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/** /**
* Fake implementation of the [AuthorsRepository] that returns hardcoded authors. * Fake implementation of the [UserDataRepository] that returns hardcoded user data.
* *
* This allows us to run the app with fake data, without needing an internet connection or working * This allows us to run the app with fake data, without needing an internet connection or working
* backend. * backend.
@ -35,8 +34,8 @@ class FakeUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource, private val niaPreferencesDataSource: NiaPreferencesDataSource,
) : UserDataRepository { ) : UserDataRepository {
override val userDataStream: Flow<UserData> = override val userData: Flow<UserData> =
niaPreferencesDataSource.userDataStream niaPreferencesDataSource.userData
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) = override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds) niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
@ -44,14 +43,6 @@ class FakeUserDataRepository @Inject constructor(
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) = override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed) niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed)
override suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
niaPreferencesDataSource.setFollowedAuthorIds(followedAuthorIds)
}
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed)
}
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) { override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
} }
@ -64,6 +55,10 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
} }
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
} }

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

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

@ -17,33 +17,15 @@
package com.google.samples.apps.nowinandroid.core.data.model package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Article
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NetworkEntityKtTest { class NetworkEntityKtTest {
@Test
fun network_author_can_be_mapped_to_author_entity() {
val networkModel = NetworkAuthor(
id = "0",
name = "Test",
imageUrl = "something",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
val entity = networkModel.asEntity()
assertEquals("0", entity.id)
assertEquals("Test", entity.name)
assertEquals("something", entity.imageUrl)
}
@Test @Test
fun network_topic_can_be_mapped_to_topic_entity() { fun network_topic_can_be_mapped_to_topic_entity() {
val networkModel = NetworkTopic( val networkModel = NetworkTopic(

@ -1,180 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository
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.testdoubles.CollectionType
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestAuthorDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class OfflineFirstAuthorsRepositoryTest {
private lateinit var subject: OfflineFirstAuthorsRepository
private lateinit var authorDao: AuthorDao
private lateinit var network: TestNiaNetworkDataSource
private lateinit var synchronizer: Synchronizer
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before
fun setup() {
authorDao = TestAuthorDao()
network = TestNiaNetworkDataSource()
val niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore()
)
synchronizer = TestSynchronizer(niaPreferencesDataSource)
subject = OfflineFirstAuthorsRepository(
authorDao = authorDao,
network = network,
)
}
@Test
fun offlineFirstAuthorsRepository_Authors_stream_is_backed_by_Authors_dao() =
runTest {
assertEquals(
authorDao.getAuthorEntitiesStream()
.first()
.map(AuthorEntity::asExternalModel),
subject.getAuthorsStream()
.first()
)
}
@Test
fun offlineFirstAuthorsRepository_sync_pulls_from_network() =
runTest {
subject.syncWith(synchronizer)
val networkAuthors = network.getAuthors()
.map(NetworkAuthor::asEntity)
val dbAuthors = authorDao.getAuthorEntitiesStream()
.first()
assertEquals(
networkAuthors.map(AuthorEntity::id),
dbAuthors.map(AuthorEntity::id)
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Authors),
synchronizer.getChangeListVersions().authorVersion
)
}
@Test
fun offlineFirstAuthorsRepository_incremental_sync_pulls_from_network() =
runTest {
// Set author version to 5
synchronizer.updateChangeListVersions {
copy(authorVersion = 5)
}
subject.syncWith(synchronizer)
val changeList = network.changeListsAfter(
CollectionType.Authors,
version = 5
)
val changeListIds = changeList
.map(NetworkChangeList::id)
.toSet()
val network = network.getAuthors()
.map(NetworkAuthor::asEntity)
.filter { it.id in changeListIds }
val db = authorDao.getAuthorEntitiesStream()
.first()
assertEquals(
network.map(AuthorEntity::id),
db.map(AuthorEntity::id)
)
// After sync version should be updated
assertEquals(
changeList.last().changeListVersion,
synchronizer.getChangeListVersions().authorVersion
)
}
@Test
fun offlineFirstAuthorsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest {
val networkAuthors = network.getAuthors()
.map(NetworkAuthor::asEntity)
.map(AuthorEntity::asExternalModel)
// Delete half of the items on the network
val deletedItems = networkAuthors
.map(Author::id)
.partition { it.chars().sum() % 2 == 0 }
.first
.toSet()
deletedItems.forEach {
network.editCollection(
collectionType = CollectionType.Authors,
id = it,
isDelete = true
)
}
subject.syncWith(synchronizer)
val dbAuthors = authorDao.getAuthorEntitiesStream()
.first()
.map(AuthorEntity::asExternalModel)
// Assert that items marked deleted on the network have been deleted locally
assertEquals(
networkAuthors.map(Author::id) - deletedItems,
dbAuthors.map(Author::id)
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Authors),
synchronizer.getChangeListVersions().authorVersion
)
}
}

@ -18,19 +18,16 @@ package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity import com.google.samples.apps.nowinandroid.core.data.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.model.authorCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.authorEntityShells
import com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences import com.google.samples.apps.nowinandroid.core.data.model.topicCrossReferences
import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells import com.google.samples.apps.nowinandroid.core.data.model.topicEntityShells
import com.google.samples.apps.nowinandroid.core.data.testdoubles.CollectionType import com.google.samples.apps.nowinandroid.core.data.testdoubles.CollectionType
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestAuthorDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNewsResourceDao import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNewsResourceDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestTopicDao import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestTopicDao
import com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds import com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds
import com.google.samples.apps.nowinandroid.core.data.testdoubles.nonPresentInterestsIds import com.google.samples.apps.nowinandroid.core.data.testdoubles.nonPresentInterestsIds
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@ -39,26 +36,31 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import kotlin.test.assertEquals import com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstNewsRepositoryTest { class OfflineFirstNewsRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstNewsRepository private lateinit var subject: OfflineFirstNewsRepository
private lateinit var newsResourceDao: TestNewsResourceDao private lateinit var newsResourceDao: TestNewsResourceDao
private lateinit var authorDao: TestAuthorDao
private lateinit var topicDao: TestTopicDao private lateinit var topicDao: TestTopicDao
private lateinit var network: TestNiaNetworkDataSource private lateinit var network: TestNiaNetworkDataSource
private lateinit var notifier: TestNotifier
private lateinit var synchronizer: Synchronizer private lateinit var synchronizer: Synchronizer
@get:Rule @get:Rule
@ -67,111 +69,98 @@ class OfflineFirstNewsRepositoryTest {
@Before @Before
fun setup() { fun setup() {
newsResourceDao = TestNewsResourceDao() newsResourceDao = TestNewsResourceDao()
authorDao = TestAuthorDao()
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
notifier = TestNotifier()
synchronizer = TestSynchronizer( synchronizer = TestSynchronizer(
NiaPreferencesDataSource( NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore() tmpFolder.testUserPreferencesDataStore(testScope),
) ),
) )
subject = OfflineFirstNewsRepository( subject = OfflineFirstNewsRepository(
newsResourceDao = newsResourceDao, newsResourceDao = newsResourceDao,
authorDao = authorDao,
topicDao = topicDao, topicDao = topicDao,
network = network, network = network,
notifier = notifier,
) )
} }
@Test @Test
fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() = fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() =
runTest { testScope.runTest {
assertEquals( assertEquals(
newsResourceDao.getNewsResourcesStream() newsResourceDao.getNewsResources()
.first() .first()
.map(PopulatedNewsResource::asExternalModel), .map(PopulatedNewsResource::asExternalModel),
subject.getNewsResourcesStream() subject.getNewsResources()
.first() .first(),
) )
} }
@Test @Test
fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() = fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() =
runTest { testScope.runTest {
assertEquals( assertEquals(
newsResourceDao.getNewsResourcesStream( expected = newsResourceDao.getNewsResources(
filterTopicIds = filteredInterestsIds, filterTopicIds = filteredInterestsIds,
useFilterTopicIds = true,
) )
.first() .first()
.map(PopulatedNewsResource::asExternalModel), .map(PopulatedNewsResource::asExternalModel),
subject.getNewsResourcesStream( actual = subject.getNewsResources(
filterTopicIds = filteredInterestsIds, query = NewsResourceQuery(
filterTopicIds = filteredInterestsIds,
),
) )
.first() .first(),
) )
assertEquals( assertEquals(
emptyList(), expected = emptyList(),
subject.getNewsResourcesStream( actual = subject.getNewsResources(
filterTopicIds = nonPresentInterestsIds, query = NewsResourceQuery(
filterTopicIds = nonPresentInterestsIds,
),
) )
.first() .first(),
)
}
@Test
fun offlineFirstNewsRepository_news_resources_for_author_is_backed_by_news_resource_dao() =
runTest {
assertEquals(
newsResourceDao.getNewsResourcesStream(
filterAuthorIds = filteredInterestsIds
)
.first()
.map(PopulatedNewsResource::asExternalModel),
subject.getNewsResourcesStream(
filterAuthorIds = filteredInterestsIds
)
.first()
)
assertEquals(
emptyList(),
subject.getNewsResourcesStream(
filterAuthorIds = nonPresentInterestsIds
)
.first()
) )
} }
@Test @Test
fun offlineFirstNewsRepository_sync_pulls_from_network() = fun offlineFirstNewsRepository_sync_pulls_from_network() =
runTest { testScope.runTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val newsResourcesFromNetwork = network.getNewsResources() val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
val newsResourcesFromDb = newsResourceDao.getNewsResourcesStream() val newsResourcesFromDb = newsResourceDao.getNewsResources()
.first() .first()
.map(PopulatedNewsResource::asExternalModel) .map(PopulatedNewsResource::asExternalModel)
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id), newsResourcesFromNetwork.map(NewsResource::id).sorted(),
newsResourcesFromDb.map(NewsResource::id) newsResourcesFromDb.map(NewsResource::id).sorted(),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources), expected = network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
// Notifier should have been called with new news resources
assertEquals(
expected = newsResourcesFromDb.map(NewsResource::id).sorted(),
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
) )
} }
@Test @Test
fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() = fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest { testScope.runTest {
val newsResourcesFromNetwork = network.getNewsResources() val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity) .map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
@ -187,32 +176,39 @@ class OfflineFirstNewsRepositoryTest {
network.editCollection( network.editCollection(
collectionType = CollectionType.NewsResources, collectionType = CollectionType.NewsResources,
id = it, id = it,
isDelete = true isDelete = true,
) )
} }
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val newsResourcesFromDb = newsResourceDao.getNewsResourcesStream() val newsResourcesFromDb = newsResourceDao.getNewsResources()
.first() .first()
.map(PopulatedNewsResource::asExternalModel) .map(PopulatedNewsResource::asExternalModel)
// Assert that items marked deleted on the network have been deleted locally // Assert that items marked deleted on the network have been deleted locally
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id) - deletedItems, expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(),
newsResourcesFromDb.map(NewsResource::id) actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.NewsResources), expected = network.latestChangeListVersion(CollectionType.NewsResources),
synchronizer.getChangeListVersions().newsResourceVersion actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
// Notifier should have been called with news resources from network that are not
// deleted
assertEquals(
expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(),
actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
) )
} }
@Test @Test
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() =
runTest { testScope.runTest {
// Set news version to 7 // Set news version to 7
synchronizer.updateChangeListVersions { synchronizer.updateChangeListVersions {
copy(newsResourceVersion = 7) copy(newsResourceVersion = 7)
@ -222,7 +218,7 @@ class OfflineFirstNewsRepositoryTest {
val changeList = network.changeListsAfter( val changeList = network.changeListsAfter(
CollectionType.NewsResources, CollectionType.NewsResources,
version = 7 version = 7,
) )
val changeListIds = changeList val changeListIds = changeList
.map(NetworkChangeList::id) .map(NetworkChangeList::id)
@ -233,77 +229,58 @@ class OfflineFirstNewsRepositoryTest {
.map(NewsResourceEntity::asExternalModel) .map(NewsResourceEntity::asExternalModel)
.filter { it.id in changeListIds } .filter { it.id in changeListIds }
val newsResourcesFromDb = newsResourceDao.getNewsResourcesStream() val newsResourcesFromDb = newsResourceDao.getNewsResources()
.first() .first()
.map(PopulatedNewsResource::asExternalModel) .map(PopulatedNewsResource::asExternalModel)
assertEquals( assertEquals(
newsResourcesFromNetwork.map(NewsResource::id), expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
newsResourcesFromDb.map(NewsResource::id) actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
changeList.last().changeListVersion, expected = changeList.last().changeListVersion,
synchronizer.getChangeListVersions().newsResourceVersion actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
}
@Test
fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() =
runTest {
subject.syncWith(synchronizer)
// Notifier should have been called with only added news resources from network
assertEquals( assertEquals(
network.getNewsResources() expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
.map(NetworkNewsResource::topicEntityShells) actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(),
.flatten()
.distinctBy(TopicEntity::id),
topicDao.getTopicEntitiesStream()
.first()
) )
} }
@Test @Test
fun offlineFirstNewsRepository_sync_saves_shell_author_entities() = fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() =
runTest { testScope.runTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
assertEquals( assertEquals(
network.getNewsResources() expected = network.getNewsResources()
.map(NetworkNewsResource::authorEntityShells) .map(NetworkNewsResource::topicEntityShells)
.flatten() .flatten()
.distinctBy(AuthorEntity::id), .distinctBy(TopicEntity::id)
authorDao.getAuthorEntitiesStream() .sortedBy(TopicEntity::toString),
actual = topicDao.getTopicEntities()
.first() .first()
.sortedBy(TopicEntity::toString),
) )
} }
@Test @Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =
runTest { testScope.runTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
assertEquals( assertEquals(
network.getNewsResources() expected = network.getNewsResources()
.map(NetworkNewsResource::topicCrossReferences) .map(NetworkNewsResource::topicCrossReferences)
.flatten()
.distinct() .distinct()
.flatten(), .sortedBy(NewsResourceTopicCrossRef::toString),
newsResourceDao.topicCrossReferences actual = newsResourceDao.topicCrossReferences
) .sortedBy(NewsResourceTopicCrossRef::toString),
}
@Test
fun offlineFirstNewsRepository_sync_saves_author_cross_references() =
runTest {
subject.syncWith(synchronizer)
assertEquals(
network.getNewsResources()
.map(NetworkNewsResource::authorCrossReferences)
.distinct()
.flatten(),
newsResourceDao.authorCrossReferences
) )
} }
} }

@ -28,16 +28,20 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstTopicsRepositoryTest { class OfflineFirstTopicsRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstTopicsRepository private lateinit var subject: OfflineFirstTopicsRepository
private lateinit var topicDao: TopicDao private lateinit var topicDao: TopicDao
@ -56,54 +60,54 @@ class OfflineFirstTopicsRepositoryTest {
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
niaPreferences = NiaPreferencesDataSource( niaPreferences = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore() tmpFolder.testUserPreferencesDataStore(testScope),
) )
synchronizer = TestSynchronizer(niaPreferences) synchronizer = TestSynchronizer(niaPreferences)
subject = OfflineFirstTopicsRepository( subject = OfflineFirstTopicsRepository(
topicDao = topicDao, topicDao = topicDao,
network = network network = network,
) )
} }
@Test @Test
fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() = fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() =
runTest { testScope.runTest {
assertEquals( assertEquals(
topicDao.getTopicEntitiesStream() topicDao.getTopicEntities()
.first() .first()
.map(TopicEntity::asExternalModel), .map(TopicEntity::asExternalModel),
subject.getTopicsStream() subject.getTopics()
.first() .first(),
) )
} }
@Test @Test
fun offlineFirstTopicsRepository_sync_pulls_from_network() = fun offlineFirstTopicsRepository_sync_pulls_from_network() =
runTest { testScope.runTest {
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val networkTopics = network.getTopics() val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity) .map(NetworkTopic::asEntity)
val dbTopics = topicDao.getTopicEntitiesStream() val dbTopics = topicDao.getTopicEntities()
.first() .first()
assertEquals( assertEquals(
networkTopics.map(TopicEntity::id), networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id) dbTopics.map(TopicEntity::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
@Test @Test
fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() = fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() =
runTest { testScope.runTest {
// Set topics version to 10 // Set topics version to 10
synchronizer.updateChangeListVersions { synchronizer.updateChangeListVersions {
copy(topicVersion = 10) copy(topicVersion = 10)
@ -116,24 +120,24 @@ class OfflineFirstTopicsRepositoryTest {
// Drop 10 to simulate the first 10 items being unchanged // Drop 10 to simulate the first 10 items being unchanged
.drop(10) .drop(10)
val dbTopics = topicDao.getTopicEntitiesStream() val dbTopics = topicDao.getTopicEntities()
.first() .first()
assertEquals( assertEquals(
networkTopics.map(TopicEntity::id), networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id) dbTopics.map(TopicEntity::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
@Test @Test
fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() = fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() =
runTest { testScope.runTest {
val networkTopics = network.getTopics() val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity) .map(NetworkTopic::asEntity)
.map(TopicEntity::asExternalModel) .map(TopicEntity::asExternalModel)
@ -149,26 +153,26 @@ class OfflineFirstTopicsRepositoryTest {
network.editCollection( network.editCollection(
collectionType = CollectionType.Topics, collectionType = CollectionType.Topics,
id = it, id = it,
isDelete = true isDelete = true,
) )
} }
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val dbTopics = topicDao.getTopicEntitiesStream() val dbTopics = topicDao.getTopicEntities()
.first() .first()
.map(TopicEntity::asExternalModel) .map(TopicEntity::asExternalModel)
// Assert that items marked deleted on the network have been deleted locally // Assert that items marked deleted on the network have been deleted locally
assertEquals( assertEquals(
networkTopics.map(Topic::id) - deletedItems, networkTopics.map(Topic::id) - deletedItems,
dbTopics.map(Topic::id) dbTopics.map(Topic::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion synchronizer.getChangeListVersions().topicVersion,
) )
} }
} }

@ -16,189 +16,218 @@
package com.google.samples.apps.nowinandroid.core.data.repository package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class OfflineFirstUserDataRepositoryTest { class OfflineFirstUserDataRepositoryTest {
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: OfflineFirstUserDataRepository private lateinit var subject: OfflineFirstUserDataRepository
private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource
private val analyticsHelper = NoOpAnalyticsHelper()
@get:Rule @get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before @Before
fun setup() { fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource( niaPreferencesDataSource = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore() tmpFolder.testUserPreferencesDataStore(testScope),
) )
subject = OfflineFirstUserDataRepository( subject = OfflineFirstUserDataRepository(
niaPreferencesDataSource = niaPreferencesDataSource niaPreferencesDataSource = niaPreferencesDataSource,
analyticsHelper,
) )
} }
@Test @Test
fun offlineFirstUserDataRepository_default_user_data_is_correct() = fun offlineFirstUserDataRepository_default_user_data_is_correct() =
runTest { testScope.runTest {
assertEquals( assertEquals(
UserData( UserData(
bookmarkedNewsResources = emptySet(), bookmarkedNewsResources = emptySet(),
followedTopics = emptySet(), followedTopics = emptySet(),
followedAuthors = emptySet(),
themeBrand = ThemeBrand.DEFAULT, themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
shouldHideOnboarding = false useDynamicColor = false,
shouldHideOnboarding = false,
), ),
subject.userDataStream.first() subject.userData.first(),
) )
} }
@Test @Test
fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.toggleFollowedTopicId(followedTopicId = "0", followed = true) subject.toggleFollowedTopicId(followedTopicId = "0", followed = true)
assertEquals( assertEquals(
setOf("0"), setOf("0"),
subject.userDataStream subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
subject.toggleFollowedTopicId(followedTopicId = "1", followed = true) subject.toggleFollowedTopicId(followedTopicId = "1", followed = true)
assertEquals( assertEquals(
setOf("0", "1"), setOf("0", "1"),
subject.userDataStream subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
assertEquals( assertEquals(
niaPreferencesDataSource.userDataStream niaPreferencesDataSource.userData
.map { it.followedTopics } .map { it.followedTopics }
.first(), .first(),
subject.userDataStream subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
} }
@Test @Test
fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2")) subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2"))
assertEquals( assertEquals(
setOf("1", "2"), setOf("1", "2"),
subject.userDataStream subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
assertEquals( assertEquals(
niaPreferencesDataSource.userDataStream niaPreferencesDataSource.userData
.map { it.followedTopics } .map { it.followedTopics }
.first(), .first(),
subject.userDataStream subject.userData
.map { it.followedTopics } .map { it.followedTopics }
.first() .first(),
) )
} }
@Test @Test
fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true) subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true)
assertEquals( assertEquals(
setOf("0"), setOf("0"),
subject.userDataStream subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true) subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true)
assertEquals( assertEquals(
setOf("0", "1"), setOf("0", "1"),
subject.userDataStream subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
assertEquals( assertEquals(
niaPreferencesDataSource.userDataStream niaPreferencesDataSource.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first(), .first(),
subject.userDataStream subject.userData
.map { it.bookmarkedNewsResources } .map { it.bookmarkedNewsResources }
.first() .first(),
) )
} }
@Test @Test
fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.setThemeBrand(ThemeBrand.ANDROID) subject.setThemeBrand(ThemeBrand.ANDROID)
assertEquals( assertEquals(
ThemeBrand.ANDROID, ThemeBrand.ANDROID,
subject.userDataStream subject.userData
.map { it.themeBrand } .map { it.themeBrand }
.first() .first(),
) )
assertEquals( assertEquals(
ThemeBrand.ANDROID, ThemeBrand.ANDROID,
niaPreferencesDataSource niaPreferencesDataSource
.userDataStream .userData
.map { it.themeBrand } .map { it.themeBrand }
.first() .first(),
)
}
@Test
fun offlineFirstUserDataRepository_set_dynamic_color_delegates_to_nia_preferences() =
testScope.runTest {
subject.setDynamicColorPreference(true)
assertEquals(
true,
subject.userData
.map { it.useDynamicColor }
.first(),
)
assertEquals(
true,
niaPreferencesDataSource
.userData
.map { it.useDynamicColor }
.first(),
) )
} }
@Test @Test
fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() =
runTest { testScope.runTest {
subject.setDarkThemeConfig(DarkThemeConfig.DARK) subject.setDarkThemeConfig(DarkThemeConfig.DARK)
assertEquals( assertEquals(
DarkThemeConfig.DARK, DarkThemeConfig.DARK,
subject.userDataStream subject.userData
.map { it.darkThemeConfig } .map { it.darkThemeConfig }
.first() .first(),
) )
assertEquals( assertEquals(
DarkThemeConfig.DARK, DarkThemeConfig.DARK,
niaPreferencesDataSource niaPreferencesDataSource
.userDataStream .userData
.map { it.darkThemeConfig } .map { it.darkThemeConfig }
.first() .first(),
) )
} }
@Test @Test
fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() = fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() =
runTest { testScope.runTest {
subject.setFollowedTopicIds(setOf("1")) subject.setFollowedTopicIds(setOf("1"))
subject.setShouldHideOnboarding(true) subject.setShouldHideOnboarding(true)
assertTrue(subject.userDataStream.first().shouldHideOnboarding) assertTrue(subject.userData.first().shouldHideOnboarding)
subject.setFollowedTopicIds(emptySet()) subject.setFollowedTopicIds(emptySet())
assertFalse(subject.userDataStream.first().shouldHideOnboarding) assertFalse(subject.userData.first().shouldHideOnboarding)
} }
} }

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

@ -1,70 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.AuthorDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
/**
* Test double for [AuthorDao]
*/
class TestAuthorDao : AuthorDao {
private var entitiesStateFlow = MutableStateFlow(
listOf(
AuthorEntity(
id = "1",
name = "Topic",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
)
)
override fun getAuthorEntitiesStream(): Flow<List<AuthorEntity>> =
entitiesStateFlow
override fun getAuthorEntityStream(authorId: String): Flow<AuthorEntity> {
throw NotImplementedError("Unused in tests")
}
override suspend fun insertOrIgnoreAuthors(authorEntities: List<AuthorEntity>): List<Long> {
entitiesStateFlow.value = authorEntities
// Assume no conflicts on insert
return authorEntities.map { it.id.toLong() }
}
override suspend fun updateAuthors(entities: List<AuthorEntity>) {
throw NotImplementedError("Unused in tests")
}
override suspend fun upsertAuthors(entities: List<AuthorEntity>) {
entitiesStateFlow.value = entities
}
override suspend fun deleteAuthors(ids: List<String>) {
val idSet = ids.toSet()
entitiesStateFlow.update { entities ->
entities.filterNot { idSet.contains(it.id) }
}
}
}

@ -17,18 +17,14 @@
package com.google.samples.apps.nowinandroid.core.data.testdoubles package com.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao
import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceAuthorCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.datetime.Instant
val filteredInterestsIds = setOf("1") val filteredInterestsIds = setOf("1")
val nonPresentInterestsIds = setOf("2") val nonPresentInterestsIds = setOf("2")
@ -39,44 +35,45 @@ val nonPresentInterestsIds = setOf("2")
class TestNewsResourceDao : NewsResourceDao { class TestNewsResourceDao : NewsResourceDao {
private var entitiesStateFlow = MutableStateFlow( private var entitiesStateFlow = MutableStateFlow(
listOf( emptyList<NewsResourceEntity>(),
NewsResourceEntity(
id = "1",
title = "news",
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
)
)
) )
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf() internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
internal var authorCrossReferences: List<NewsResourceAuthorCrossRef> = listOf() override fun getNewsResources(
useFilterTopicIds: Boolean,
override fun getNewsResourcesStream(): Flow<List<PopulatedNewsResource>> = filterTopicIds: Set<String>,
entitiesStateFlow.map { useFilterNewsIds: Boolean,
it.map(NewsResourceEntity::asPopulatedNewsResource) filterNewsIds: Set<String>,
}
override fun getNewsResourcesStream(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>
): Flow<List<PopulatedNewsResource>> = ): Flow<List<PopulatedNewsResource>> =
getNewsResourcesStream() entitiesStateFlow
.map { it.map(NewsResourceEntity::asPopulatedNewsResource) }
.map { resources -> .map { resources ->
resources.filter { resource -> var result = resources
resource.topics.any { it.id in filterTopicIds } || if (useFilterTopicIds) {
resource.authors.any { it.id in filterAuthorIds } result = result.filter { resource ->
resource.topics.any { it.id in filterTopicIds }
}
} }
if (useFilterNewsIds) {
result = result.filter { resource ->
resource.entity.id in filterNewsIds
}
}
result
} }
override suspend fun insertOrIgnoreNewsResources( override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity> entities: List<NewsResourceEntity>,
): List<Long> { ): List<Long> {
entitiesStateFlow.value = entities entitiesStateFlow.update { oldValues ->
// Old values come first so new values don't overwrite them
(oldValues + entities)
.distinctBy(NewsResourceEntity::id)
.sortedWith(
compareBy(NewsResourceEntity::publishDate).reversed(),
)
}
// Assume no conflicts on insert // Assume no conflicts on insert
return entities.map { it.id.toLong() } return entities.map { it.id.toLong() }
} }
@ -86,19 +83,22 @@ class TestNewsResourceDao : NewsResourceDao {
} }
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) { override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
entitiesStateFlow.value = newsResourceEntities entitiesStateFlow.update { oldValues ->
// New values come first so they overwrite old values
(newsResourceEntities + oldValues)
.distinctBy(NewsResourceEntity::id)
.sortedWith(
compareBy(NewsResourceEntity::publishDate).reversed(),
)
}
} }
override suspend fun insertOrIgnoreTopicCrossRefEntities( override suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef> newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
) { ) {
topicCrossReferences = newsResourceTopicCrossReferences // Keep old values over new ones
} topicCrossReferences = (topicCrossReferences + newsResourceTopicCrossReferences)
.distinctBy { it.newsResourceId to it.topicId }
override suspend fun insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences: List<NewsResourceAuthorCrossRef>
) {
authorCrossReferences = newsResourceAuthorCrossReferences
} }
override suspend fun deleteNewsResources(ids: List<String>) { override suspend fun deleteNewsResources(ids: List<String>) {
@ -111,16 +111,6 @@ class TestNewsResourceDao : NewsResourceDao {
private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource( private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource(
entity = this, entity = this,
authors = listOf(
AuthorEntity(
id = "id",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf( topics = listOf(
TopicEntity( TopicEntity(
id = filteredInterestsIds.random(), id = filteredInterestsIds.random(),
@ -129,6 +119,6 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
), ),
) )

@ -18,7 +18,6 @@ package com.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkAuthor
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -28,8 +27,7 @@ import kotlinx.serialization.json.Json
enum class CollectionType { enum class CollectionType {
Topics, Topics,
Authors, NewsResources,
NewsResources
} }
/** /**
@ -37,19 +35,18 @@ enum class CollectionType {
*/ */
class TestNiaNetworkDataSource : NiaNetworkDataSource { class TestNiaNetworkDataSource : NiaNetworkDataSource {
private val source = FakeNiaNetworkDataSource(UnconfinedTestDispatcher(), Json) private val source = FakeNiaNetworkDataSource(
UnconfinedTestDispatcher(),
Json { ignoreUnknownKeys = true },
)
private val allTopics = runBlocking { source.getTopics() } private val allTopics = runBlocking { source.getTopics() }
private val allAuthors = runBlocking { source.getAuthors() }
private val allNewsResources = runBlocking { source.getNewsResources() } private val allNewsResources = runBlocking { source.getNewsResources() }
private val changeLists: MutableMap<CollectionType, List<NetworkChangeList>> = mutableMapOf( private val changeLists: MutableMap<CollectionType, List<NetworkChangeList>> = mutableMapOf(
CollectionType.Topics to allTopics CollectionType.Topics to allTopics
.mapToChangeList(idGetter = NetworkTopic::id), .mapToChangeList(idGetter = NetworkTopic::id),
CollectionType.Authors to allAuthors
.mapToChangeList(idGetter = NetworkAuthor::id),
CollectionType.NewsResources to allNewsResources CollectionType.NewsResources to allNewsResources
.mapToChangeList(idGetter = NetworkNewsResource::id), .mapToChangeList(idGetter = NetworkNewsResource::id),
) )
@ -57,27 +54,18 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> = override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
allTopics.matchIds( allTopics.matchIds(
ids = ids, ids = ids,
idGetter = NetworkTopic::id idGetter = NetworkTopic::id,
)
override suspend fun getAuthors(ids: List<String>?): List<NetworkAuthor> =
allAuthors.matchIds(
ids = ids,
idGetter = NetworkAuthor::id
) )
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> = override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds( allNewsResources.matchIds(
ids = ids, ids = ids,
idGetter = NetworkNewsResource::id idGetter = NetworkNewsResource::id,
) )
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> = override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
changeLists.getValue(CollectionType.Topics).after(after) changeLists.getValue(CollectionType.Topics).after(after)
override suspend fun getAuthorChangeList(after: Int?): List<NetworkChangeList> =
changeLists.getValue(CollectionType.Authors).after(after)
override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> = override suspend fun getNewsResourceChangeList(after: Int?): List<NetworkChangeList> =
changeLists.getValue(CollectionType.NewsResources).after(after) changeLists.getValue(CollectionType.NewsResources).after(after)
@ -114,7 +102,7 @@ fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> =
*/ */
private fun <T> List<T>.matchIds( private fun <T> List<T>.matchIds(
ids: List<String>?, ids: List<String>?,
idGetter: (T) -> String idGetter: (T) -> String,
) = when (ids) { ) = when (ids) {
null -> this null -> this
else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } } else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } }
@ -125,7 +113,7 @@ private fun <T> List<T>.matchIds(
* [after] simulates which models have changed by excluding items before it * [after] simulates which models have changed by excluding items before it
*/ */
private fun <T> List<T>.mapToChangeList( private fun <T> List<T>.mapToChangeList(
idGetter: (T) -> String idGetter: (T) -> String,
) = mapIndexed { index, item -> ) = mapIndexed { index, item ->
NetworkChangeList( NetworkChangeList(
id = idGetter(item), id = idGetter(item),

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

@ -16,13 +16,12 @@
package com.google.samples.apps.nowinandroid.core.database.model package com.google.samples.apps.nowinandroid.core.database.model
import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class PopulatedNewsResourceKtTest { class PopulatedNewsResourceKtTest {
@Test @Test
@ -37,16 +36,6 @@ class PopulatedNewsResourceKtTest {
type = Video, type = Video,
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
), ),
authors = listOf(
AuthorEntity(
id = "2",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf( topics = listOf(
TopicEntity( TopicEntity(
id = "3", id = "3",
@ -55,7 +44,7 @@ class PopulatedNewsResourceKtTest {
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
), ),
) )
val newsResource = populatedNewsResource.asExternalModel() val newsResource = populatedNewsResource.asExternalModel()
@ -69,16 +58,6 @@ class PopulatedNewsResourceKtTest {
headerImageUrl = "headerImageUrl", headerImageUrl = "headerImageUrl",
type = Video, type = Video,
publishDate = Instant.fromEpochMilliseconds(1), publishDate = Instant.fromEpochMilliseconds(1),
authors = listOf(
Author(
id = "2",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf( topics = listOf(
Topic( Topic(
id = "3", id = "3",
@ -87,10 +66,10 @@ class PopulatedNewsResourceKtTest {
longDescription = "long description", longDescription = "long description",
url = "URL", url = "URL",
imageUrl = "image URL", imageUrl = "image URL",
) ),
) ),
), ),
newsResource newsResource,
) )
} }
} }

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

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

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

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

Loading…
Cancel
Save