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
disk-size: 6000M
heap-size: 600M
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest
script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest
- name: Upload test reports
if: always()

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

@ -28,9 +28,6 @@
<option name="JD_PRESERVE_LINE_FEEDS" value="true" />
</JavaCodeStyleSettings>
<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_FOR_MEMBERS" value="99" />
<option name="IMPORT_NESTED_CLASSES" value="true" />

@ -16,22 +16,27 @@
-->
<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.
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">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<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="--rerun-tasks -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile" />
<option name="scriptParameters" value="-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":benchmark:pixel6Api31DemoBenchmarkAndroidTest" />
<option value=":benchmark:pixel6Api31atdDemoBenchmarkAndroidTest" />
<option value="--rerun" />
<option value="--enable-display" />
</list>
</option>
<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](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
in or follow specific authors.
in.
## Screenshots
@ -30,9 +30,15 @@ in or follow specific authors.
# Development Environment
**Now in Android** uses the Gradle build system and can be imported directly into the latest stable
version of Android Studio (available [here](https://developer.android.com/studio)). The `debug`
build can be built and run using the default configuration.
**Now in Android** uses the Gradle build system and can be imported directly into Android Studio (make sure you are using the latest stable version available [here](https://developer.android.com/studio)).
Change the run configuration to `app`.
![image](https://user-images.githubusercontent.com/873212/210559920-ef4a40c5-c8e0-478b-bb00-4879a8cf184a.png)
The `demoDebug` and `demoRelease` build variants can be built and run (the `prod` variants use a backend server which is not currently publicly available).
![image](https://user-images.githubusercontent.com/873212/210560507-44045dc5-b6d5-41ca-9746-f0f7acf22f8e.png)
Once you're up and running, you can refer to the learning journeys below to get a better
understanding of which libraries and tools are being used, the reasoning behind the approaches to

@ -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
*
@ -21,10 +39,12 @@ plugins {
android {
defaultConfig {
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
// which do, so we must specify a default value for the contentType dimension.
missingDimensionStrategy("contentType", "demo")
missingDimensionStrategy(FlavorDimension.contentType.name, NiaFlavor.demo.name)
}
packagingOptions {
@ -33,12 +53,20 @@ android {
}
}
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 {
implementation(project(":core:ui"))
implementation(project(":core:designsystem"))
implementation(libs.androidx.activity.compose)
implementation(project(":core:ui"))
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.unit.dp
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.NiaFilledButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton
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.NiaOutlinedButton
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.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.NiaViewToggleButton
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
@ -66,7 +65,7 @@ fun NiaCatalog() {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Text(
@ -77,7 +76,7 @@ fun NiaCatalog() {
item { Text("Buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(onClick = {}) {
NiaButton(onClick = {}) {
Text(text = "Enabled")
}
NiaOutlinedButton(onClick = {}) {
@ -91,326 +90,82 @@ fun NiaCatalog() {
item { Text("Disabled buttons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
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(
NiaButton(
onClick = {},
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")
}
NiaOutlinedButton(
onClick = {},
enabled = false,
small = true
) {
Text(text = "Disabled")
}
NiaTextButton(
onClick = {},
enabled = false,
small = true
) {
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 {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
NiaButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
NiaOutlinedButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
NiaTextButton(
onClick = {},
small = true,
text = { Text(text = "Enabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
}
}
item {
Text(
"Disabled small buttons with leading icons",
Modifier.padding(top = 16.dp)
)
}
item { Text("Disabled buttons with leading icons", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
NiaFilledButton(
NiaButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
NiaOutlinedButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
leadingIcon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
}
},
)
NiaTextButton(
onClick = {},
enabled = false,
small = true,
text = { Text(text = "Disabled") },
leadingIcon = {
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 {
NiaDropdownMenuButton(
text = { Text("Newest first") },
items = listOf("Item 1", "Item 2", "Item 3"),
onItemClick = {},
itemText = { item -> Text(item) }
)
}
item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) }
item { Text("Chips", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
@ -418,81 +173,98 @@ fun NiaCatalog() {
NiaFilterChip(
selected = firstChecked,
onSelectedChange = { checked -> firstChecked = checked },
label = { Text(text = "Enabled".uppercase()) }
label = { Text(text = "Enabled") },
)
var secondChecked by remember { mutableStateOf(true) }
NiaFilterChip(
selected = secondChecked,
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(
selected = thirdChecked,
onSelectedChange = { checked -> thirdChecked = checked },
selected = true,
onSelectedChange = {},
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 {
FlowRow(mainAxisSpacing = 16.dp) {
var firstChecked by remember { mutableStateOf(false) }
NiaToggleButton(
NiaIconToggleButton(
checked = firstChecked,
onCheckedChange = { checked -> firstChecked = checked },
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null
contentDescription = null,
)
}
},
)
var secondChecked by remember { mutableStateOf(true) }
NiaToggleButton(
NiaIconToggleButton(
checked = secondChecked,
onCheckedChange = { checked -> secondChecked = checked },
icon = {
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null
contentDescription = null,
)
},
checkedIcon = {
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null
contentDescription = null,
)
}
},
)
var thirdChecked by remember { mutableStateOf(false) }
NiaToggleButton(
checked = thirdChecked,
onCheckedChange = { checked -> thirdChecked = checked },
NiaIconToggleButton(
checked = false,
onCheckedChange = {},
icon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null,
)
},
checkedIcon = {
Icon(imageVector = NiaIcons.Check, contentDescription = null)
}
Icon(
painter = painterResource(id = NiaIcons.Bookmark),
contentDescription = null,
)
},
enabled = false,
)
var fourthChecked by remember { mutableStateOf(true) }
NiaToggleButton(
checked = fourthChecked,
onCheckedChange = { checked -> fourthChecked = checked },
NiaIconToggleButton(
checked = true,
onCheckedChange = {},
icon = {
Icon(imageVector = NiaIcons.Add, contentDescription = null)
Icon(
painter = painterResource(id = NiaIcons.BookmarkBorder),
contentDescription = null,
)
},
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,
onExpandedChange = { expanded -> firstExpanded = expanded },
compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") }
expandedText = { Text(text = "Expanded view") },
)
var secondExpanded by remember { mutableStateOf(true) }
NiaViewToggleButton(
expanded = secondExpanded,
onExpandedChange = { expanded -> secondExpanded = expanded },
compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") }
expandedText = { Text(text = "Expanded view") },
)
NiaViewToggleButton(
expanded = false,
onExpandedChange = {},
compactText = { Text(text = "Disabled") },
expandedText = { Text(text = "Disabled") },
enabled = false,
)
}
}
item { Text("Tags", Modifier.padding(top = 16.dp)) }
item {
FlowRow(mainAxisSpacing = 16.dp) {
var expandedTopicId by remember { mutableStateOf<String?>(null) }
var firstFollowed by remember { mutableStateOf(false) }
NiaTopicTag(
expanded = expandedTopicId == "Topic 1",
followed = firstFollowed,
onDropMenuToggle = { show ->
expandedTopicId = if (show) "Topic 1" else null
},
onFollowClick = { firstFollowed = true },
onUnfollowClick = { firstFollowed = false },
onBrowseClick = {},
followed = true,
onClick = {},
text = { Text(text = "Topic 1".uppercase()) },
followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") }
)
var secondFollowed by remember { mutableStateOf(true) }
NiaTopicTag(
expanded = expandedTopicId == "Topic 2",
followed = secondFollowed,
onDropMenuToggle = { show ->
expandedTopicId = if (show) "Topic 2" else null
},
onFollowClick = { secondFollowed = true },
onUnfollowClick = { secondFollowed = false },
onBrowseClick = {},
followed = false,
onClick = {},
text = { Text(text = "Topic 2".uppercase()) },
followText = { Text(text = "Follow") },
unFollowText = { Text(text = "Unfollow") },
browseText = { Text(text = "Browse topic") }
)
NiaTopicTag(
followed = false,
onClick = {},
text = { Text(text = "Disabled".uppercase()) },
enabled = false,
)
}
}
@ -561,7 +324,7 @@ fun NiaCatalog() {
NiaTab(
selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index },
text = { Text(text = title) }
text = { Text(text = title) },
)
}
}
@ -573,12 +336,12 @@ fun NiaCatalog() {
val icons = listOf(
NiaIcons.UpcomingBorder,
NiaIcons.MenuBookBorder,
NiaIcons.BookmarksBorder
NiaIcons.BookmarksBorder,
)
val selectedIcons = listOf(
NiaIcons.Upcoming,
NiaIcons.MenuBook,
NiaIcons.Bookmarks
NiaIcons.Bookmarks,
)
val tagIcon = NiaIcons.Tag
NiaNavigationBar {
@ -590,7 +353,7 @@ fun NiaCatalog() {
} else {
Icon(
painter = painterResource(id = icons[index]),
contentDescription = item
contentDescription = item,
)
}
},
@ -600,13 +363,13 @@ fun NiaCatalog() {
} else {
Icon(
painter = painterResource(id = selectedIcons[index]),
contentDescription = item
contentDescription = item,
)
}
},
label = { Text(item) },
selected = selectedItem == index,
onClick = { selectedItem = index }
onClick = { selectedItem = index },
)
}
}

@ -3,4 +3,16 @@
# 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
# 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");
* 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
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.NiaBuildType
plugins {
id("nowinandroid.android.application")
id("nowinandroid.android.application.compose")
id("nowinandroid.android.application.flavors")
id("nowinandroid.android.application.jacoco")
id("nowinandroid.android.hilt")
id("jacoco")
id("nowinandroid.firebase-perf")
id("nowinandroid.android.application.firebase")
}
android {
defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 3
versionName = "0.0.3" // X.Y.Z; X = Major, Y = minor, Z = Patch level
versionCode = 5
versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
@ -38,10 +40,11 @@ android {
buildTypes {
val debug by getting {
applicationIdSuffix = ".debug"
applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix
}
val release by getting {
isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix
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
@ -57,9 +60,8 @@ android {
signingConfig = signingConfigs.getByName("debug")
// Only use benchmark proguard rules
proguardFiles("benchmark-rules.pro")
// FIXME enabling minification breaks access to demo backend.
isMinifyEnabled = false
applicationIdSuffix = ".benchmark"
isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.BENCHMARK.applicationIdSuffix
}
}
@ -77,7 +79,6 @@ android {
}
dependencies {
implementation(project(":feature:author"))
implementation(project(":feature:interests"))
implementation(project(":feature:foryou"))
implementation(project(":feature:bookmarks"))
@ -89,6 +90,7 @@ dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:data"))
implementation(project(":core:model"))
implementation(project(":core:analytics"))
implementation(project(":sync:work"))
@ -97,8 +99,10 @@ dependencies {
androidTestImplementation(project(":core:data-test"))
androidTestImplementation(project(":core:network"))
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness)
androidTestImplementation(kotlin("test"))
debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(project(":ui-test-hilt-manifest"))
implementation(libs.accompanist.systemuicontroller)
implementation(libs.androidx.activity.compose)
@ -115,7 +119,6 @@ dependencies {
implementation(libs.androidx.profileinstaller)
implementation(libs.coil.kt)
implementation(libs.coil.kt.svg)
}
// androidx.test is forcing JUnit, 4.12. This forces it to use 4.13

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

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

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

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

File diff suppressed because it is too large Load Diff

@ -24,6 +24,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -37,16 +38,18 @@ import androidx.metrics.performance.JankStats
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
@ -61,6 +64,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var analyticsHelper: AnalyticsHelper
val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
@ -104,14 +110,17 @@ class MainActivity : ComponentActivity() {
onDispose {}
}
NiaTheme(
darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState)
) {
NiaApp(
networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this),
)
CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
NiaTheme(
darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
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].
*/
@Composable
fun shouldUseAndroidTheme(
private fun shouldUseAndroidTheme(
uiState: MainActivityUiState,
): Boolean = when (uiState) {
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
* current system context.
*/
@Composable
fun shouldUseDarkTheme(
private fun shouldUseDarkTheme(
uiState: MainActivityUiState,
): Boolean = when (uiState) {
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.model.data.UserData
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class MainActivityViewModel @Inject constructor(
userDataRepository: UserDataRepository
userDataRepository: UserDataRepository,
) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userDataStream.map {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
Success(it)
}.stateIn(
scope = viewModelScope,
initialValue = Loading,
started = SharingStarted.WhileSubscribed(5_000)
started = SharingStarted.WhileSubscribed(5_000),
)
}

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

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

@ -20,8 +20,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
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.foryou.navigation.forYouNavigationRoute
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
fun NiaNavHost(
navController: NavHostController,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute
startDestination: String = forYouNavigationRoute,
) {
NavHost(
navController = navController,
startDestination = startDestination,
modifier = modifier,
) {
forYouScreen()
bookmarksScreen()
// TODO: handle topic clicks from each top level destination
forYouScreen(onTopicClick = {})
bookmarksScreen(onTopicClick = {})
interestsGraph(
navigateToTopic = { topicId ->
onTopicClick = { topicId ->
navController.navigateToTopic(topicId)
},
navigateToAuthor = { authorId ->
navController.navigateToAuthor(authorId)
},
nestedGraphs = {
topicScreen(onBackClick)
authorScreen(onBackClick)
}
topicScreen(
onBackClick = navController::popBackStack,
onTopicClick = {},
)
},
)
}
}

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

@ -16,11 +16,12 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumedWindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
@ -44,12 +45,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
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.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class,
ExperimentalLifecycleComposeApi::class
)
@Composable
fun NiaApp(
@ -82,109 +83,105 @@ fun NiaApp(
networkMonitor: NetworkMonitor,
appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass
windowSizeClass = windowSizeClass,
),
) {
val background: @Composable (@Composable () -> Unit) -> Unit =
when (appState.currentTopLevelDestination) {
TopLevelDestination.FOR_YOU -> {
content ->
NiaGradientBackground(content = content)
}
else -> { content -> NiaBackground(content = content) }
}
background {
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
NiaBackground {
NiaGradientBackground(
gradientColors = if (shouldShowGradientBackground) {
LocalGradientColors.current
} else {
GradientColors()
},
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
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 snackbarHostState = remember { SnackbarHostState() }
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
// 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) {
if (isOffline) snackbarHostState.showSnackbar(
message = notConnected,
duration = Indefinite
)
if (isOffline) {
snackbarHostState.showSnackbar(
message = notConnectedMessage,
duration = Indefinite,
)
}
}
if (appState.shouldShowSettingsDialog) {
SettingsDialog(
onDismiss = { appState.setShowSettingsDialog(false) }
onDismiss = { appState.setShowSettingsDialog(false) },
)
}
Row(
Modifier
.fillMaxSize()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"),
)
)
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.safeDrawingPadding()
)
}
}
},
) { padding ->
Row(
Modifier
.fillMaxSize()
.padding(padding)
.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(
navController = appState.navController,
onBackClick = appState::onBackClick,
Column(Modifier.fillMaxSize()) {
// Show the top app bar on top level destinations.
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
.padding(padding)
.consumedWindowInsets(padding)
)
NiaNavHost(appState.navController)
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
}
}
}
}
@ -212,15 +209,15 @@ private fun NiaNavRail(
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
contentDescription = null,
)
is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id),
contentDescription = null
contentDescription = null,
)
}
},
label = { Text(stringResource(destination.iconTextId)) }
label = { Text(stringResource(destination.iconTextId)) },
)
}
}
@ -230,9 +227,12 @@ private fun NiaNavRail(
private fun NiaBottomBar(
destinations: List<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?
currentDestination: NavDestination?,
modifier: Modifier = Modifier,
) {
NiaNavigationBar {
NiaNavigationBar(
modifier = modifier,
) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
NiaNavigationBarItem(
@ -247,16 +247,16 @@ private fun NiaBottomBar(
when (icon) {
is ImageVectorIcon -> Icon(
imageVector = icon.imageVector,
contentDescription = null
contentDescription = null,
)
is DrawableResourceIcon -> Icon(
painter = painterResource(id = icon.id),
contentDescription = null
contentDescription = null,
)
}
},
label = { Text(stringResource(destination.iconTextId)) }
label = { Text(stringResource(destination.iconTextId)) },
)
}
}

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

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

@ -14,6 +14,7 @@
* limitations under the License.
*/
import com.android.build.api.dsl.ManagedVirtualDevice
import com.google.samples.apps.nowinandroid.NiaBuildType
import com.google.samples.apps.nowinandroid.configureFlavors
plugins {
@ -26,6 +27,8 @@ android {
defaultConfig {
minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "APP_BUILD_TYPE_SUFFIX", "\"\"")
}
buildFeatures {
@ -41,39 +44,37 @@ android {
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
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,
// 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.
configureFlavors(this)
configureFlavors(this) { flavor ->
buildConfigField(
"String",
"APP_FLAVOR_SUFFIX",
"\"${flavor.applicationIdSuffix ?: ""}\""
)
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
testOptions {
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel6Api31") {
device = "Pixel 6"
apiLevel = 31
systemImageSource = "aosp"
}
}
}
}
}
dependencies {
implementation(libs.androidx.benchmark.macro)
implementation(libs.androidx.test.core)
implementation(libs.androidx.test.espresso.core)
implementation(libs.androidx.test.ext)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.rules)
implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.uiautomator)
implementation(libs.androidx.benchmark.macro)
implementation(libs.androidx.profileinstaller)
}
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
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import com.google.samples.apps.nowinandroid.benchmarks.BuildConfig
/**
* 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 {
if (BuildConfig.FLAVOR != "prod") {
append(".${BuildConfig.FLAVOR}")
}
if (BuildConfig.BUILD_TYPE != "release") {
append(".${BuildConfig.BUILD_TYPE}")
}
}.toString()
val PACKAGE_NAME = buildString {
append("com.google.samples.apps.nowinandroid")
append(BuildConfig.APP_FLAVOR_SUFFIX)
append(BuildConfig.APP_BUILD_TYPE_SUFFIX)
}
fun UiDevice.flingElementDownUp(element: UiObject2) {
// 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.junit4.BaselineProfileRule
import androidx.test.uiautomator.By
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.forYouSelectAuthors
import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.interests.interestsScrollPeopleDownUp
import com.google.samples.apps.nowinandroid.interests.goToInterestsScreen
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
import org.junit.Rule
import org.junit.Test
@ -48,25 +47,16 @@ class BaselineProfileGenerator {
// Scroll the feed critical user journey
forYouWaitForContent()
forYouSelectAuthors()
forYouSelectTopics(true)
forYouScrollFeedDownUp()
// Navigate to saved screen
device.findObject(By.text("Saved")).click()
device.waitForIdle()
bookmarksScrollFeedDownUp()
goToBookmarksScreen()
// TODO: we need to implement adding stuff to bookmarks before able to scroll it
// bookmarksScrollFeedDownUp()
// Navigate to interests screen
device.findObject(By.text("Interests")).click()
device.waitForIdle()
goToInterestsScreen()
interestsScrollTopicsDownUp()
// Navigate to people tab
device.findObject(By.text("People")).click()
device.waitForIdle()
interestsScrollPeopleDownUp()
}
}

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

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

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

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

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

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

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

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

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

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

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

@ -14,21 +14,20 @@
* limitations under the License.
*/
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureJacoco
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.kotlin
class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.kapt")
apply("dagger.hilt.android.plugin")
// 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")

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

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

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

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

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

@ -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.ApplicationProductFlavor
import com.android.build.api.dsl.ApplicationVariantDimension
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.ProductFlavor
import org.gradle.api.Project
@Suppress("EnumEntryName")
enum class FlavorDimension {
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
// purposes, or from a production backend server which supplies up-to-date, real content.
// These two product flavors reflect this behaviour.
enum class Flavor (val dimension : FlavorDimension, val applicationIdSuffix : String? = null) {
demo(FlavorDimension.contentType),
prod(FlavorDimension.contentType, ".prod")
@Suppress("EnumEntryName")
enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) {
demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"),
prod(FlavorDimension.contentType, )
}
fun Project.configureFlavors(
commonExtension: CommonExtension<*, *, *, *>
commonExtension: CommonExtension<*, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {}
) {
commonExtension.apply {
flavorDimensions += FlavorDimension.contentType.name
productFlavors {
Flavor.values().forEach{
NiaFlavor.values().forEach {
create(it.name) {
dimension = it.dimension.name
flavorConfigurationBlock(this, it)
if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) {
if (it.applicationIdSuffix != null) {
this.applicationIdSuffix = it.applicationIdSuffix
@ -36,4 +40,4 @@ fun Project.configureFlavors(
}
}
}
}
}

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

@ -25,44 +25,26 @@ export JAVA_HOME="$(cd $DIR/../../../prebuilts/studio/jdk/jdk11/linux && pwd )"
echo "JAVA_HOME=$JAVA_HOME"
export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )"
echo "ANDROID_HOME=$ANDROID_HOME"
cd $DIR
# Build
GRADLE_PARAMS=" --stacktrace"
$DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS}
BUILD_RESULT=$?
echo "Copying google-services.json"
cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app
# Demo debug
cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR
echo "Copying local.properties"
cp $DIR/../nowinandroid-prebuilts/local.properties $DIR
# Demo release
cp $APP_OUT/apk/demo/release/app-demo-release.apk $DIST_DIR
cd $DIR
# Prod debug
cp $APP_OUT/apk/prod/debug/app-prod-debug.apk $DIST_DIR/app-prod-debug.apk
# Build the prodRelease variant
GRADLE_PARAMS=" --stacktrace -Puse-google-services"
$DIR/gradlew :app:clean :app:assembleProdRelease :app:bundleProdRelease ${GRADLE_PARAMS}
BUILD_RESULT=$?
# Prod release
# Prod release apk
cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk
#cp $APP_OUT/mapping/release/mapping.txt $DIST_DIR/mobile-release-apk-mapping.txt
# Build App Bundles
# Don't clean here, otherwise all apks are gone.
$DIR/gradlew :app:bundle ${GRADLE_PARAMS}
# Demo debug
cp $APP_OUT/bundle/demoDebug/app-demo-debug.aab $DIST_DIR/app-demo-debug.aab
# Demo release
cp $APP_OUT/bundle/demoRelease/app-demo-release.aab $DIST_DIR/app-demo-release.aab
# Prod debug
cp $APP_OUT/bundle/prodDebug/app-prod-debug.aab $DIST_DIR/app-prod-debug.aab
# Prod release
# Prod release bundle
cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab
#cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt
BUILD_RESULT=$?
# Prod release bundle mapping
cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt
exit $BUILD_RESULT
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");
* you may not use this file except in compliance with the License.
@ -14,10 +14,11 @@
* 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"
const val DATA = "data.json"
const val TOPICS = "topics.json"
/**
* Implementation of AnalyticsHelper which does nothing. Useful for tests and previews.
*/
class NoOpAnalyticsHelper : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) = Unit
}

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

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

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

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

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

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

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

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

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

@ -16,9 +16,7 @@
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.OfflineFirstAuthorsRepository
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.OfflineFirstUserDataRepository
@ -37,26 +35,21 @@ interface DataModule {
@Binds
fun bindsTopicRepository(
topicsRepository: OfflineFirstTopicsRepository
topicsRepository: OfflineFirstTopicsRepository,
): TopicsRepository
@Binds
fun bindsAuthorsRepository(
authorsRepository: OfflineFirstAuthorsRepository
): AuthorsRepository
@Binds
fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository
newsRepository: OfflineFirstNewsRepository,
): NewsRepository
@Binds
fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository
userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor
networkMonitor: ConnectivityManagerNetworkMonitor,
): 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
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.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -44,22 +42,6 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
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 [NewsResourceEntity] into the DB
@ -80,14 +62,6 @@ fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef>
topics.map { topicId ->
NewsResourceTopicCrossRef(
newsResourceId = id,
topicId = topicId
)
}
fun NetworkNewsResource.authorCrossReferences(): List<NewsResourceAuthorCrossRef> =
authors.map { authorId ->
NewsResourceAuthorCrossRef(
newsResourceId = id,
authorId = authorId
topicId = topicId,
)
}

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

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

@ -21,19 +21,30 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import kotlinx.coroutines.flow.Flow
/**
* Data layer implementation for [NewsResource]
* Encapsulation class for query parameters for [NewsResource]
*/
interface NewsRepository : Syncable {
data class NewsResourceQuery(
/**
* Topic ids to filter for. Null means any topic id will match.
*/
val filterTopicIds: Set<String>? = null,
/**
* Returns available news resources as a stream.
* News ids to filter for. Null means any news id will match.
*/
fun 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(
filterAuthorIds: Set<String> = emptySet(),
filterTopicIds: Set<String> = emptySet(),
fun getNewsResources(
query: NewsResourceQuery = NewsResourceQuery(
filterTopicIds = null,
filterNewsIds = null,
),
): 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.changeListSync
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.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.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.TopicEntity
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.network.NiaNetworkDataSource
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.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
// Heuristic value to optimize for serialization and deserialization cost on client and server
// for each news resource batch.
private const val SYNC_BATCH_SIZE = 40
/**
* Disk storage backed implementation of the [NewsRepository].
@ -44,21 +46,18 @@ import kotlinx.coroutines.flow.map
*/
class OfflineFirstNewsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
private val authorDao: AuthorDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
private val notifier: Notifier,
) : NewsRepository {
override fun getNewsResourcesStream(): Flow<List<NewsResource>> =
newsResourceDao.getNewsResourcesStream()
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResourcesStream(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>
): Flow<List<NewsResource>> = newsResourceDao.getNewsResourcesStream(
filterAuthorIds = filterAuthorIds,
filterTopicIds = filterTopicIds
override fun getNewsResources(
query: NewsResourceQuery,
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
useFilterTopicIds = query.filterTopicIds != null,
filterTopicIds = query.filterTopicIds ?: emptySet(),
useFilterNewsIds = query.filterNewsIds != null,
filterNewsIds = query.filterNewsIds ?: emptySet(),
)
.map { it.map(PopulatedNewsResource::asExternalModel) }
@ -73,38 +72,53 @@ class OfflineFirstNewsRepository @Inject constructor(
},
modelDeleter = newsResourceDao::deleteNewsResources,
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(
topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id)
)
authorDao.insertOrIgnoreAuthors(
authorEntities = networkNewsResources
.map(NetworkNewsResource::authorEntityShells)
.flatten()
.distinctBy(AuthorEntity::id)
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources
.map(NetworkNewsResource::asEntity)
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.flatten()
)
newsResourceDao.insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences = networkNewsResources
.map(NetworkNewsResource::authorCrossReferences)
.distinct()
.flatten()
// Order of invocation matters to satisfy id and foreign key constraints!
topicDao.insertOrIgnoreTopics(
topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id),
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources.map(
NetworkNewsResource::asEntity,
),
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.flatten(),
)
}
val addedNewsResources = newsResourceDao.getNewsResources(
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet(),
)
}
.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.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Disk storage backed implementation of the [TopicsRepository].
@ -39,8 +39,8 @@ class OfflineFirstTopicsRepository @Inject constructor(
private val network: NiaNetworkDataSource,
) : TopicsRepository {
override fun getTopicsStream(): Flow<List<Topic>> =
topicDao.getTopicEntitiesStream()
override fun getTopics(): Flow<List<Topic>> =
topicDao.getTopicEntities()
.map { it.map(TopicEntity::asExternalModel) }
override fun getTopic(id: String): Flow<Topic> =
@ -59,8 +59,8 @@ class OfflineFirstTopicsRepository @Inject constructor(
modelUpdater = { changedIds ->
val networkTopics = network.getTopics(ids = changedIds)
topicDao.upsertTopics(
entities = networkTopics.map(NetworkTopic::asEntity)
entities = networkTopics.map(NetworkTopic::asEntity),
)
}
},
)
}

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

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

@ -26,7 +26,7 @@ interface UserDataRepository {
/**
* Stream of [UserData]
*/
val userDataStream: Flow<UserData>
val userData: Flow<UserData>
/**
* Sets the user's currently followed topics
@ -38,16 +38,6 @@ interface UserDataRepository {
*/
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
*/
@ -63,6 +53,11 @@ interface UserDataRepository {
*/
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
/**
* Sets the preferred dynamic color config.
*/
suspend fun setDynamicColorPreference(useDynamicColor: Boolean)
/**
* Sets whether the user has completed the onboarding process.
*/

@ -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.model.asEntity
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
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.fake.FakeNiaNetworkDataSource
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.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import javax.inject.Inject
/**
* 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(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json,
private val assets: FakeAssetManager,
private val datasource: FakeNiaNetworkDataSource,
) : NewsRepository {
override fun getNewsResourcesStream(): Flow<List<NewsResource>> =
flow {
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>,
override fun getNewsResources(
query: NewsResourceQuery,
): Flow<List<NewsResource>> =
flow {
emit(
assets.open(FakeDataSource.DATA).use { stream ->
networkJson.decodeFromStream<List<NetworkNewsResource>>(stream)
.filter {
it.authors.intersect(filterAuthorIds).isNotEmpty() ||
it.topics.intersect(filterTopicIds).isNotEmpty()
}
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
}
datasource
.getNewsResources()
.filter { networkNewsResource ->
// Filter out any news resources which don't match the current query.
// If no query parameters (filterTopicIds or filterNewsIds) are specified
// then the news resource is returned.
listOfNotNull(
true,
query.filterNewsIds?.contains(networkNewsResource.id),
query.filterTopicIds?.let { filterTopicIds ->
networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty()
},
)
.all(true::equals)
}
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel),
)
}
.flowOn(ioDispatcher)
}.flowOn(ioDispatcher)
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.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.NetworkTopic
import java.io.InputStream
import javax.inject.Inject
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
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
import javax.inject.Inject
/**
* 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(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json,
private val assets: FakeAssetManager,
private val datasource: FakeNiaNetworkDataSource,
) : TopicsRepository {
override fun getTopicsStream(): Flow<List<Topic>> = flow {
override fun getTopics(): Flow<List<Topic>> = flow {
emit(
assets.open(FakeDataSource.TOPICS)
.use<InputStream, List<NetworkTopic>>(networkJson::decodeFromStream)
.map {
Topic(
id = it.id,
name = it.name,
shortDescription = it.shortDescription,
longDescription = it.longDescription,
url = it.url,
imageUrl = it.imageUrl
)
}
datasource.getTopics().map {
Topic(
id = it.id,
name = it.name,
shortDescription = it.shortDescription,
longDescription = it.longDescription,
url = it.url,
imageUrl = it.imageUrl,
)
},
)
}
.flowOn(ioDispatcher)
}.flowOn(ioDispatcher)
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

@ -16,17 +16,16 @@
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.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* 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
* backend.
@ -35,8 +34,8 @@ class FakeUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
) : UserDataRepository {
override val userDataStream: Flow<UserData> =
niaPreferencesDataSource.userDataStream
override val userData: Flow<UserData> =
niaPreferencesDataSource.userData
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
@ -44,14 +43,6 @@ class FakeUserDataRepository @Inject constructor(
override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
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) {
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
}
@ -64,6 +55,10 @@ class FakeUserDataRepository @Inject constructor(
niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig)
}
override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor)
}
override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding)
}

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

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

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

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

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

@ -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
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.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.datetime.Instant
val filteredInterestsIds = setOf("1")
val nonPresentInterestsIds = setOf("2")
@ -39,44 +35,45 @@ val nonPresentInterestsIds = setOf("2")
class TestNewsResourceDao : NewsResourceDao {
private var entitiesStateFlow = MutableStateFlow(
listOf(
NewsResourceEntity(
id = "1",
title = "news",
content = "Hilt",
url = "url",
headerImageUrl = "headerImageUrl",
type = Video,
publishDate = Instant.fromEpochMilliseconds(1),
)
)
emptyList<NewsResourceEntity>(),
)
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
internal var authorCrossReferences: List<NewsResourceAuthorCrossRef> = listOf()
override fun getNewsResourcesStream(): Flow<List<PopulatedNewsResource>> =
entitiesStateFlow.map {
it.map(NewsResourceEntity::asPopulatedNewsResource)
}
override fun getNewsResourcesStream(
filterAuthorIds: Set<String>,
filterTopicIds: Set<String>
override fun getNewsResources(
useFilterTopicIds: Boolean,
filterTopicIds: Set<String>,
useFilterNewsIds: Boolean,
filterNewsIds: Set<String>,
): Flow<List<PopulatedNewsResource>> =
getNewsResourcesStream()
entitiesStateFlow
.map { it.map(NewsResourceEntity::asPopulatedNewsResource) }
.map { resources ->
resources.filter { resource ->
resource.topics.any { it.id in filterTopicIds } ||
resource.authors.any { it.id in filterAuthorIds }
var result = resources
if (useFilterTopicIds) {
result = result.filter { resource ->
resource.topics.any { it.id in filterTopicIds }
}
}
if (useFilterNewsIds) {
result = result.filter { resource ->
resource.entity.id in filterNewsIds
}
}
result
}
override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>
entities: List<NewsResourceEntity>,
): List<Long> {
entitiesStateFlow.value = entities
entitiesStateFlow.update { oldValues ->
// Old values come first so new values don't overwrite them
(oldValues + entities)
.distinctBy(NewsResourceEntity::id)
.sortedWith(
compareBy(NewsResourceEntity::publishDate).reversed(),
)
}
// Assume no conflicts on insert
return entities.map { it.id.toLong() }
}
@ -86,19 +83,22 @@ class TestNewsResourceDao : NewsResourceDao {
}
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
entitiesStateFlow.value = newsResourceEntities
entitiesStateFlow.update { oldValues ->
// New values come first so they overwrite old values
(newsResourceEntities + oldValues)
.distinctBy(NewsResourceEntity::id)
.sortedWith(
compareBy(NewsResourceEntity::publishDate).reversed(),
)
}
}
override suspend fun insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
) {
topicCrossReferences = newsResourceTopicCrossReferences
}
override suspend fun insertOrIgnoreAuthorCrossRefEntities(
newsResourceAuthorCrossReferences: List<NewsResourceAuthorCrossRef>
) {
authorCrossReferences = newsResourceAuthorCrossReferences
// Keep old values over new ones
topicCrossReferences = (topicCrossReferences + newsResourceTopicCrossReferences)
.distinctBy { it.newsResourceId to it.topicId }
}
override suspend fun deleteNewsResources(ids: List<String>) {
@ -111,16 +111,6 @@ class TestNewsResourceDao : NewsResourceDao {
private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource(
entity = this,
authors = listOf(
AuthorEntity(
id = "id",
name = "name",
imageUrl = "imageUrl",
twitter = "twitter",
mediumPage = "mediumPage",
bio = "bio",
)
),
topics = listOf(
TopicEntity(
id = filteredInterestsIds.random(),
@ -129,6 +119,6 @@ private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource
longDescription = "long description",
url = "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.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.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
@ -28,8 +27,7 @@ import kotlinx.serialization.json.Json
enum class CollectionType {
Topics,
Authors,
NewsResources
NewsResources,
}
/**
@ -37,19 +35,18 @@ enum class CollectionType {
*/
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 allAuthors = runBlocking { source.getAuthors() }
private val allNewsResources = runBlocking { source.getNewsResources() }
private val changeLists: MutableMap<CollectionType, List<NetworkChangeList>> = mutableMapOf(
CollectionType.Topics to allTopics
.mapToChangeList(idGetter = NetworkTopic::id),
CollectionType.Authors to allAuthors
.mapToChangeList(idGetter = NetworkAuthor::id),
CollectionType.NewsResources to allNewsResources
.mapToChangeList(idGetter = NetworkNewsResource::id),
)
@ -57,27 +54,18 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
allTopics.matchIds(
ids = ids,
idGetter = NetworkTopic::id
)
override suspend fun getAuthors(ids: List<String>?): List<NetworkAuthor> =
allAuthors.matchIds(
ids = ids,
idGetter = NetworkAuthor::id
idGetter = NetworkTopic::id,
)
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds(
ids = ids,
idGetter = NetworkNewsResource::id
idGetter = NetworkNewsResource::id,
)
override suspend fun getTopicChangeList(after: Int?): List<NetworkChangeList> =
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> =
changeLists.getValue(CollectionType.NewsResources).after(after)
@ -114,7 +102,7 @@ fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> =
*/
private fun <T> List<T>.matchIds(
ids: List<String>?,
idGetter: (T) -> String
idGetter: (T) -> String,
) = when (ids) {
null -> this
else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } }
@ -125,7 +113,7 @@ private fun <T> List<T>.matchIds(
* [after] simulates which models have changed by excluding items before it
*/
private fun <T> List<T>.mapToChangeList(
idGetter: (T) -> String
idGetter: (T) -> String,
) = mapIndexed { index, item ->
NetworkChangeList(
id = idGetter(item),

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

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

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

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