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/'
|
@ -0,0 +1,2 @@
|
||||
# This file can be used to trigger an internal build by changing the number below
|
||||
3
|
@ -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"
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -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>
|
@ -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,
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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}")
|
||||
}
|
||||
}
|
@ -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("-", ""))
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
plugins {
|
||||
id("nowinandroid.android.library")
|
||||
id("nowinandroid.android.library.compose")
|
||||
id("nowinandroid.android.hilt")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.google.samples.apps.nowinandroid.core.analytics"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.firebase.analytics)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.analytics
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AnalyticsModule {
|
||||
@Binds
|
||||
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: StubAnalyticsHelper): AnalyticsHelper
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2023 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<manifest />
|
@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.analytics
|
||||
|
||||
/**
|
||||
* Represents an analytics event.
|
||||
*
|
||||
* @param type - the event type. Wherever possible use one of the standard
|
||||
* event `Types`, however, if there is no suitable event type already defined, a custom event can be
|
||||
* defined as long as it is configured in your backend analytics system (for example, by creating a
|
||||
* Firebase Analytics custom event).
|
||||
*
|
||||
* @param extras - list of parameters which supply additional context to the event. See `Param`.
|
||||
*/
|
||||
data class AnalyticsEvent(
|
||||
val type: String,
|
||||
val extras: List<Param> = emptyList(),
|
||||
) {
|
||||
// Standard analytics types.
|
||||
class Types {
|
||||
companion object {
|
||||
const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key-value pair used to supply extra context to an analytics event.
|
||||
*
|
||||
* @param key - the parameter key. Wherever possible use one of the standard `ParamKeys`,
|
||||
* however, if no suitable key is available you can define your own as long as it is configured
|
||||
* in your backend analytics system (for example, by creating a Firebase Analytics custom
|
||||
* parameter).
|
||||
*
|
||||
* @param value - the parameter value.
|
||||
*/
|
||||
data class Param(val key: String, val value: String)
|
||||
|
||||
// Standard parameter keys.
|
||||
class ParamKeys {
|
||||
companion object {
|
||||
const val SCREEN_NAME = "screen_name"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.samples.apps.nowinandroid.core.analytics
|
||||
|
||||
/**
|
||||
* Interface for logging analytics events. See `FirebaseAnalyticsHelper` and
|
||||
* `StubAnalyticsHelper` for implementations.
|
||||
*/
|
||||
interface AnalyticsHelper {
|
||||
fun logEvent(event: AnalyticsEvent)
|
||||
}
|
@ -0,0 +1,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
@ -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),
|
||||
)
|
||||
}
|
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
@ -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…
Reference in new issue