Merge pull request #591 from android/feb13-merge

All changes from 0.0.5 release (merge from internal repo)
pull/596/head
Don Turner 2 years ago committed by GitHub
commit 0a02c066ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

@ -19,17 +19,18 @@ import com.android.build.api.dsl.ManagedVirtualDevice
plugins { plugins {
id("nowinandroid.android.application") id("nowinandroid.android.application")
id("nowinandroid.android.application.compose") id("nowinandroid.android.application.compose")
id("nowinandroid.android.application.flavors")
id("nowinandroid.android.application.jacoco") id("nowinandroid.android.application.jacoco")
id("nowinandroid.android.hilt") id("nowinandroid.android.hilt")
id("jacoco") id("jacoco")
id("nowinandroid.firebase-perf") id("nowinandroid.android.application.firebase")
} }
android { android {
defaultConfig { defaultConfig {
applicationId = "com.google.samples.apps.nowinandroid" applicationId = "com.google.samples.apps.nowinandroid"
versionCode = 4 versionCode = 5
versionName = "0.0.4" // X.Y.Z; X = Major, Y = minor, Z = Patch level versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level
// Custom test runner to set up Hilt dependency graph // Custom test runner to set up Hilt dependency graph
testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
@ -101,6 +102,7 @@ dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:analytics"))
implementation(project(":sync:work")) implementation(project(":sync:work"))

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

@ -24,6 +24,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -37,6 +38,8 @@ import androidx.metrics.performance.JankStats
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
@ -61,6 +64,9 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var networkMonitor: NetworkMonitor lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var analyticsHelper: AnalyticsHelper
val viewModel: MainActivityViewModel by viewModels() val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -104,15 +110,17 @@ class MainActivity : ComponentActivity() {
onDispose {} onDispose {}
} }
NiaTheme( CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
darkTheme = darkTheme, NiaTheme(
androidTheme = shouldUseAndroidTheme(uiState), darkTheme = darkTheme,
disableDynamicTheming = shouldDisableDynamicTheming(uiState), androidTheme = shouldUseAndroidTheme(uiState),
) { disableDynamicTheming = shouldDisableDynamicTheming(uiState),
NiaApp( ) {
networkMonitor = networkMonitor, NiaApp(
windowSizeClass = calculateWindowSizeClass(this), networkMonitor = networkMonitor,
) windowSizeClass = calculateWindowSizeClass(this),
)
}
} }
} }
} }

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

@ -28,6 +28,8 @@ java {
dependencies { dependencies {
compileOnly(libs.android.gradlePlugin) compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.firebase.performance.gradle)
compileOnly(libs.firebase.crashlytics.gradle)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
} }
@ -73,9 +75,13 @@ gradlePlugin {
id = "nowinandroid.android.room" id = "nowinandroid.android.room"
implementationClass = "AndroidRoomConventionPlugin" implementationClass = "AndroidRoomConventionPlugin"
} }
register("firebase-perf") { register("androidFirebase") {
id = "nowinandroid.firebase-perf" id = "nowinandroid.android.application.firebase"
implementationClass = "FirebasePerfConventionPlugin" implementationClass = "AndroidApplicationFirebaseConventionPlugin"
}
register("androidFlavors") {
id = "nowinandroid.android.application.flavors"
implementationClass = "AndroidApplicationFlavorsConventionPlugin"
} }
} }
} }

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

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

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

@ -49,6 +49,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", project(":core:data")) add("implementation", project(":core:data"))
add("implementation", project(":core:common")) add("implementation", project(":core:common"))
add("implementation", project(":core:domain")) add("implementation", project(":core:domain"))
add("implementation", project(":core:analytics"))
add("testImplementation", kotlin("test")) add("testImplementation", kotlin("test"))
add("testImplementation", project(":core:testing")) add("testImplementation", project(":core:testing"))

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

@ -14,7 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureFlavors

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

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

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

@ -0,0 +1 @@
/build

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

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

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

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

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

@ -0,0 +1,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: FirebaseAnalyticsHelper): AnalyticsHelper
}

@ -0,0 +1,44 @@
/*
* 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.ktx.analytics
import com.google.firebase.analytics.ktx.logEvent
import com.google.firebase.ktx.Firebase
import javax.inject.Inject
import javax.inject.Singleton
/**
* Implementation of `AnalyticsHelper` which logs events to a Firebase backend.
*/
@Singleton
class FirebaseAnalyticsHelper @Inject constructor() : AnalyticsHelper {
private val firebaseAnalytics = Firebase.analytics
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),
)
}
}
}
}

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

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

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

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.core.data.repository package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
@ -37,6 +38,8 @@ class OfflineFirstUserDataRepositoryTest {
private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource
private val analyticsHelper = NoOpAnalyticsHelper()
@get:Rule @get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@ -48,6 +51,7 @@ class OfflineFirstUserDataRepositoryTest {
subject = OfflineFirstUserDataRepository( subject = OfflineFirstUserDataRepository(
niaPreferencesDataSource = niaPreferencesDataSource, niaPreferencesDataSource = niaPreferencesDataSource,
analyticsHelper,
) )
} }

@ -30,6 +30,7 @@ dependencies {
implementation(project(":core:designsystem")) implementation(project(":core:designsystem"))
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:domain")) implementation(project(":core:domain"))
implementation(project(":core:analytics"))
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)

@ -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.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.AnalyticsEvent.ParamKeys
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Types
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
/**
* Classes and functions associated with analytics events for the UI.
*/
fun AnalyticsHelper.logScreenView(screenName: String) {
logEvent(
AnalyticsEvent(
type = Types.SCREEN_VIEW,
extras = listOf(
Param(ParamKeys.SCREEN_NAME, screenName),
),
),
)
}
fun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String, newsResourceTitle: String) {
logEvent(
event = AnalyticsEvent(
type = "news_resource_opened",
extras = listOf(
Param("opened_news_resource", newsResourceId),
),
),
)
}
/**
* A side-effect which records a screen view event.
*/
@Composable
fun TrackScreenViewEvent(
screenName: String,
analyticsHelper: AnalyticsHelper = LocalAnalyticsHelper.current,
) = DisposableEffect(Unit) {
analyticsHelper.logScreenView(screenName)
onDispose {}
}

@ -37,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
@ -57,12 +58,19 @@ fun LazyGridScope.newsFeed(
mutableStateOf(Uri.parse(userNewsResource.url)) mutableStateOf(Uri.parse(userNewsResource.url))
} }
val context = LocalContext.current val context = LocalContext.current
val analyticsHelper = LocalAnalyticsHelper.current
val backgroundColor = MaterialTheme.colorScheme.background.toArgb() val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = userNewsResource, userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved, isBookmarked = userNewsResource.isSaved,
onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) }, onClick = {
analyticsHelper.logNewsResourceOpened(
newsResourceId = userNewsResource.id,
newsResourceTitle = userNewsResource.title,
)
launchCustomChromeTab(context, resourceUrl, backgroundColor)
},
onToggleBookmark = { onToggleBookmark = {
onNewsResourcesCheckedChanged( onNewsResourcesCheckedChanged(
userNewsResource.id, userNewsResource.id,

@ -23,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
/** /**
@ -46,12 +47,17 @@ fun LazyListScope.userNewsResourceCardItems(
val resourceUrl = Uri.parse(userNewsResource.url) val resourceUrl = Uri.parse(userNewsResource.url)
val backgroundColor = MaterialTheme.colorScheme.background.toArgb() val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
val context = LocalContext.current val context = LocalContext.current
val analyticsHelper = LocalAnalyticsHelper.current
NewsResourceCardExpanded( NewsResourceCardExpanded(
userNewsResource = userNewsResource, userNewsResource = userNewsResource,
isBookmarked = userNewsResource.isSaved, isBookmarked = userNewsResource.isSaved,
onToggleBookmark = { onToggleBookmark(userNewsResource) }, onToggleBookmark = { onToggleBookmark(userNewsResource) },
onClick = { onClick = {
analyticsHelper.logNewsResourceOpened(
newsResourceId = userNewsResource.id,
newsResourceTitle = userNewsResource.title,
)
when (onItemClick) { when (onItemClick) {
null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) null -> launchCustomChromeTab(context, resourceUrl, backgroundColor)
else -> onItemClick(userNewsResource) else -> onItemClick(userNewsResource)

@ -59,6 +59,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@ -98,6 +99,7 @@ internal fun BookmarksScreen(
EmptyState(modifier) EmptyState(modifier)
} }
} }
TrackScreenViewEvent(screenName = "Saved")
} }
@Composable @Composable

@ -85,6 +85,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@ -211,6 +212,7 @@ internal fun ForYouScreen(
) )
} }
} }
TrackScreenViewEvent(screenName = "ForYou")
} }
/** /**

@ -33,6 +33,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
@OptIn(ExperimentalLifecycleComposeApi::class) @OptIn(ExperimentalLifecycleComposeApi::class)
@Composable @Composable
@ -78,6 +79,7 @@ internal fun InterestsScreen(
is InterestsUiState.Empty -> InterestsEmptyScreen() is InterestsUiState.Empty -> InterestsEmptyScreen()
} }
} }
TrackScreenViewEvent(screenName = "Interests")
} }
@Composable @Composable

@ -61,6 +61,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.LIGH
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.feature.settings.R.string import com.google.samples.apps.nowinandroid.feature.settings.R.string
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
@ -134,6 +135,7 @@ fun SettingsDialog(
Divider(Modifier.padding(top = 8.dp)) Divider(Modifier.padding(top = 8.dp))
LinksPanel() LinksPanel()
} }
TrackScreenViewEvent(screenName = "Settings")
}, },
confirmButton = { confirmButton = {
Text( Text(

@ -55,6 +55,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews
import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider
import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
@ -72,6 +73,7 @@ internal fun TopicRoute(
val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle() val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle()
val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle() val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle()
TrackScreenViewEvent(screenName = "Topic: ${viewModel.topicId}")
TopicScreen( TopicScreen(
topicUiState = topicUiState, topicUiState = topicUiState,
newsUiState = newsUiState, newsUiState = newsUiState,

@ -50,6 +50,8 @@ class TopicViewModel @Inject constructor(
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder)
val topicId = topicArgs.topicId
val topicUiState: StateFlow<TopicUiState> = topicUiState( val topicUiState: StateFlow<TopicUiState> = topicUiState(
topicId = topicArgs.topicId, topicId = topicArgs.topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,

@ -70,6 +70,10 @@ class TopicViewModelTest {
) )
} }
@Test
fun topicId_matchesTopicIdFromSavedStateHandle() =
assertEquals(testInputTopics[0].topic.id, viewModel.topicId)
@Test @Test
fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest { fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }

@ -28,6 +28,10 @@ androidxUiAutomator = "2.2.0"
androidxWindowManager = "1.0.0" androidxWindowManager = "1.0.0"
androidxWork = "2.7.1" androidxWork = "2.7.1"
coil = "2.2.2" coil = "2.2.2"
firebaseBom = "31.2.0"
firebaseCrashlyticsPlugin = "2.9.2"
firebasePerfPlugin = "1.4.2"
gmsPlugin = "4.3.14"
hilt = "2.44.2" hilt = "2.44.2"
hiltExt = "1.0.0" hiltExt = "1.0.0"
jacoco = "0.8.7" jacoco = "0.8.7"
@ -96,6 +100,12 @@ androidx-work-testing = { group = "androidx.work", name = "work-testing", versio
coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref="firebaseBom"}
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx"}
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx"}
firebase-crashlytics-gradle = { group = "com.google.firebase", name="firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin"}
firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx"}
firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin"}
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
@ -127,6 +137,9 @@ ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devto
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin"}
firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin"}
gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin"}
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

@ -20,7 +20,7 @@ set -e
set -x set -x
deviceIds=${1:-'Nexus5,Pixel2,Pixel3,Nexus9'} deviceIds=${1:-'Nexus5,Pixel2,Pixel3,Nexus9'}
osVersionIds=${2:-'23,27,30'} osVersionIds=${2:-'27,30'}
GRADLE_FLAGS=() GRADLE_FLAGS=()
if [[ -n "$GRADLE_DEBUG" ]]; then if [[ -n "$GRADLE_DEBUG" ]]; then

@ -46,6 +46,8 @@ include(":core:model")
include(":core:network") include(":core:network")
include(":core:ui") include(":core:ui")
include(":core:testing") include(":core:testing")
include(":core:analytics")
include(":feature:foryou") include(":feature:foryou")
include(":feature:interests") include(":feature:interests")
include(":feature:bookmarks") include(":feature:bookmarks")

@ -31,6 +31,7 @@ dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:datastore")) implementation(project(":core:datastore"))
implementation(project(":core:analytics"))
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)

@ -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.
*/
package com.google.samples.apps.nowinandroid.sync.workers
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
fun AnalyticsHelper.logSyncStarted() =
logEvent(
AnalyticsEvent(type = "network_sync_started"),
)
fun AnalyticsHelper.logSyncFinished(syncedSuccessfully: Boolean) {
val eventType = if (syncedSuccessfully) "network_sync_successful" else "network_sync_failed"
logEvent(
AnalyticsEvent(type = eventType),
)
}

@ -24,6 +24,7 @@ import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
@ -52,6 +53,7 @@ class SyncWorker @AssistedInject constructor(
private val topicRepository: TopicsRepository, private val topicRepository: TopicsRepository,
private val newsRepository: NewsRepository, private val newsRepository: NewsRepository,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val analyticsHelper: AnalyticsHelper,
) : CoroutineWorker(appContext, workerParams), Synchronizer { ) : CoroutineWorker(appContext, workerParams), Synchronizer {
override suspend fun getForegroundInfo(): ForegroundInfo = override suspend fun getForegroundInfo(): ForegroundInfo =
@ -59,12 +61,16 @@ class SyncWorker @AssistedInject constructor(
override suspend fun doWork(): Result = withContext(ioDispatcher) { override suspend fun doWork(): Result = withContext(ioDispatcher) {
traceAsync("Sync", 0) { traceAsync("Sync", 0) {
analyticsHelper.logSyncStarted()
// First sync the repositories in parallel // First sync the repositories in parallel
val syncedSuccessfully = awaitAll( val syncedSuccessfully = awaitAll(
async { topicRepository.sync() }, async { topicRepository.sync() },
async { newsRepository.sync() }, async { newsRepository.sync() },
).all { it } ).all { it }
analyticsHelper.logSyncFinished(syncedSuccessfully)
if (syncedSuccessfully) { if (syncedSuccessfully) {
Result.success() Result.success()
} else { } else {

Loading…
Cancel
Save