diff --git a/.google/BUILDME b/.google/BUILDME new file mode 100644 index 000000000..5295ed188 --- /dev/null +++ b/.google/BUILDME @@ -0,0 +1,2 @@ +# This file can be used to trigger an internal build by changing the number below +3 diff --git a/app/benchmark-rules.pro b/app/benchmark-rules.pro index ddecd591b..96b67f2d1 100644 --- a/app/benchmark-rules.pro +++ b/app/benchmark-rules.pro @@ -3,4 +3,16 @@ # Obsfuscation must be disabled for the build variant that generates Baseline Profile, otherwise # wrong symbols would be generated. The generated Baseline Profile will be properly applied when generated # without obfuscation and your app is being obfuscated. --dontobfuscate \ No newline at end of file +-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 \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4163090ce..b7cb7d1d7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,17 +19,18 @@ import com.android.build.api.dsl.ManagedVirtualDevice plugins { id("nowinandroid.android.application") id("nowinandroid.android.application.compose") + id("nowinandroid.android.application.flavors") id("nowinandroid.android.application.jacoco") id("nowinandroid.android.hilt") id("jacoco") - id("nowinandroid.firebase-perf") + id("nowinandroid.android.application.firebase") } android { defaultConfig { applicationId = "com.google.samples.apps.nowinandroid" - versionCode = 4 - versionName = "0.0.4" // X.Y.Z; X = Major, Y = minor, Z = Patch level + versionCode = 5 + versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level // Custom test runner to set up Hilt dependency graph testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" @@ -101,6 +102,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:data")) implementation(project(":core:model")) + implementation(project(":core:analytics")) implementation(project(":sync:work")) diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..69f0b5da3 --- /dev/null +++ b/app/google-services.json @@ -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" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5c3b889d2..99c233910 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,13 @@ + + + + + + + + diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt index e46d2156a..5fc9d0525 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +38,8 @@ import androidx.metrics.performance.JankStats import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Success +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper +import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig @@ -61,6 +64,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var analyticsHelper: AnalyticsHelper + val viewModel: MainActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -104,15 +110,17 @@ class MainActivity : ComponentActivity() { onDispose {} } - NiaTheme( - darkTheme = darkTheme, - androidTheme = shouldUseAndroidTheme(uiState), - disableDynamicTheming = shouldDisableDynamicTheming(uiState), - ) { - NiaApp( - networkMonitor = networkMonitor, - windowSizeClass = calculateWindowSizeClass(this), - ) + CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) { + NiaTheme( + darkTheme = darkTheme, + androidTheme = shouldUseAndroidTheme(uiState), + disableDynamicTheming = shouldDisableDynamicTheming(uiState), + ) { + NiaApp( + networkMonitor = networkMonitor, + windowSizeClass = calculateWindowSizeClass(this), + ) + } } } } diff --git a/app/src/prod/AndroidManifest.xml b/app/src/prod/AndroidManifest.xml new file mode 100644 index 000000000..2f8a8592a --- /dev/null +++ b/app/src/prod/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 0152a902b..0b929d4f7 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -28,6 +28,8 @@ java { dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.firebase.performance.gradle) + compileOnly(libs.firebase.crashlytics.gradle) compileOnly(libs.ksp.gradlePlugin) } @@ -73,9 +75,13 @@ gradlePlugin { id = "nowinandroid.android.room" implementationClass = "AndroidRoomConventionPlugin" } - register("firebase-perf") { - id = "nowinandroid.firebase-perf" - implementationClass = "FirebasePerfConventionPlugin" + register("androidFirebase") { + id = "nowinandroid.android.application.firebase" + implementationClass = "AndroidApplicationFirebaseConventionPlugin" + } + register("androidFlavors") { + id = "nowinandroid.android.application.flavors" + implementationClass = "AndroidApplicationFlavorsConventionPlugin" } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 6bc5f9f9e..26b6951d3 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -14,10 +14,9 @@ * limitations under the License. */ -import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.dsl.ApplicationExtension -import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureGradleManagedDevices +import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configurePrintApksTask import org.gradle.api.Plugin @@ -35,7 +34,6 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 33 - configureFlavors(this) configureGradleManagedDevices(this) } extensions.configure { diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt new file mode 100644 index 000000000..598da727d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt @@ -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 { + 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().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 { + 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 { + mappingFileUploadEnabled = false + } + } + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt similarity index 66% rename from build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt rename to build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt index 48f750678..46b019d7a 100644 --- a/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,16 +14,18 @@ * limitations under the License. */ +import com.android.build.api.dsl.ApplicationExtension +import com.google.samples.apps.nowinandroid.configureFlavors import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure -class FirebasePerfConventionPlugin : Plugin { +class AndroidApplicationFlavorsConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - pluginManager.findPlugin("com.google.firebase.firebase-perf").apply { - version = "1.4.1" + extensions.configure { + configureFlavors(this) } } } - } \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index 0c71d9d4c..1b567ae2d 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -49,6 +49,7 @@ class AndroidFeatureConventionPlugin : Plugin { add("implementation", project(":core:data")) add("implementation", project(":core:common")) add("implementation", project(":core:domain")) + add("implementation", project(":core:analytics")) add("testImplementation", kotlin("test")) add("testImplementation", project(":core:testing")) diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt index 772064942..29cb748c2 100644 --- a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -14,14 +14,11 @@ * limitations under the License. */ -import com.android.build.api.variant.LibraryAndroidComponentsExtension -import com.google.samples.apps.nowinandroid.configureJacoco import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType -import org.gradle.kotlin.dsl.kotlin class AndroidHiltConventionPlugin : Plugin { override fun apply(target: Project) { diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 842873bae..5b2f76edb 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -14,7 +14,6 @@ * limitations under the License. */ -import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.gradle.LibraryExtension import com.google.samples.apps.nowinandroid.configureFlavors diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt index 5cafdf7ce..dec592542 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt @@ -16,8 +16,8 @@ enum class FlavorDimension { // These two product flavors reflect this behaviour. @Suppress("EnumEntryName") enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) { - demo(FlavorDimension.contentType), - prod(FlavorDimension.contentType, ".prod") + demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"), + prod(FlavorDimension.contentType, ) } fun Project.configureFlavors( diff --git a/build.gradle.kts b/build.gradle.kts index 30640d41c..17690f83e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,13 +22,16 @@ buildscript { // Android Build Server maven { url = uri("../nowinandroid-prebuilts/m2repository") } } - } +// Lists all plugins used throughout the project without applying them. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.firebase.crashlytics) apply false + alias(libs.plugins.firebase.perf) apply false + alias(libs.plugins.gms) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.secrets) apply false diff --git a/build_android_release.sh b/build_android_release.sh index dfdf37500..c7e5fc835 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -25,44 +25,26 @@ export JAVA_HOME="$(cd $DIR/../../../prebuilts/studio/jdk/jdk11/linux && pwd )" echo "JAVA_HOME=$JAVA_HOME" export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )" - echo "ANDROID_HOME=$ANDROID_HOME" -cd $DIR -# Build -GRADLE_PARAMS=" --stacktrace" -$DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS} -BUILD_RESULT=$? +echo "Copying google-services.json" +cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app -# Demo debug -cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR +echo "Copying local.properties" +cp $DIR/../nowinandroid-prebuilts/local.properties $DIR -# Demo release -cp $APP_OUT/apk/demo/release/app-demo-release.apk $DIST_DIR +cd $DIR -# Prod debug -cp $APP_OUT/apk/prod/debug/app-prod-debug.apk $DIST_DIR/app-prod-debug.apk +# Build the prodRelease variant +GRADLE_PARAMS=" --stacktrace -Puse-google-services" +$DIR/gradlew :app:clean :app:assembleProdRelease :app:bundleProdRelease ${GRADLE_PARAMS} +BUILD_RESULT=$? -# Prod release +# Prod release apk cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk -#cp $APP_OUT/mapping/release/mapping.txt $DIST_DIR/mobile-release-apk-mapping.txt - -# Build App Bundles -# Don't clean here, otherwise all apks are gone. -$DIR/gradlew :app:bundle ${GRADLE_PARAMS} - -# Demo debug -cp $APP_OUT/bundle/demoDebug/app-demo-debug.aab $DIST_DIR/app-demo-debug.aab - -# Demo release -cp $APP_OUT/bundle/demoRelease/app-demo-release.aab $DIST_DIR/app-demo-release.aab - -# Prod debug -cp $APP_OUT/bundle/prodDebug/app-prod-debug.aab $DIST_DIR/app-prod-debug.aab - -# Prod release +# Prod release bundle cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab -#cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt -BUILD_RESULT=$? +# Prod release bundle mapping +cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt -exit $BUILD_RESULT \ No newline at end of file +exit $BUILD_RESULT diff --git a/core/analytics/.gitignore b/core/analytics/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/analytics/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts new file mode 100644 index 000000000..e42499769 --- /dev/null +++ b/core/analytics/build.gradle.kts @@ -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) +} diff --git a/core/analytics/src/demo/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt b/core/analytics/src/demo/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt new file mode 100644 index 000000000..78ebec9e5 --- /dev/null +++ b/core/analytics/src/demo/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -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 +} diff --git a/core/analytics/src/main/AndroidManifest.xml b/core/analytics/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a522a4c23 --- /dev/null +++ b/core/analytics/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt new file mode 100644 index 000000000..97ae76b56 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt @@ -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 = 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" + } + } +} diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsHelper.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsHelper.kt new file mode 100644 index 000000000..f9e6dad44 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsHelper.kt @@ -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) +} diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/NoOpAnalyticsHelper.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/NoOpAnalyticsHelper.kt new file mode 100644 index 000000000..16a193439 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/NoOpAnalyticsHelper.kt @@ -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 +} diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt new file mode 100644 index 000000000..2ff022287 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt @@ -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") + } +} diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/UiHelpers.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/UiHelpers.kt new file mode 100644 index 000000000..b0e5d29d8 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/UiHelpers.kt @@ -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 { + // 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() +} diff --git a/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt new file mode 100644 index 000000000..e947d036b --- /dev/null +++ b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -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 +} diff --git a/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt new file mode 100644 index 000000000..96b9ce67d --- /dev/null +++ b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt @@ -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), + ) + } + } + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 717082bfe..5b468c43e 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(project(":core:database")) implementation(project(":core:datastore")) implementation(project(":core:network")) + implementation(project(":core:analytics")) testImplementation(project(":core:testing")) testImplementation(project(":core:datastore-test")) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt new file mode 100644 index 000000000..d36f509d9 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt @@ -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), + ) +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt index 200ca4a3d..334209538 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt @@ -16,6 +16,8 @@ package com.google.samples.apps.nowinandroid.core.data.repository +import androidx.annotation.VisibleForTesting +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand @@ -25,29 +27,46 @@ import javax.inject.Inject class OfflineFirstUserDataRepository @Inject constructor( private val niaPreferencesDataSource: NiaPreferencesDataSource, + private val analyticsHelper: AnalyticsHelper, ) : UserDataRepository { override val userData: Flow = niaPreferencesDataSource.userData + @VisibleForTesting override suspend fun setFollowedTopicIds(followedTopicIds: Set) = niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds) - override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) = + override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) { niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed) + analyticsHelper.logTopicFollowToggled(followedTopicId, followed) + } - override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) = + override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) { niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) + analyticsHelper.logNewsResourceBookmarkToggled( + newsResourceId = newsResourceId, + isBookmarked = bookmarked, + ) + } - override suspend fun setThemeBrand(themeBrand: ThemeBrand) = + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { niaPreferencesDataSource.setThemeBrand(themeBrand) + analyticsHelper.logThemeChanged(themeBrand.name) + } - override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) + analyticsHelper.logDarkThemeConfigChanged(darkThemeConfig.name) + } - override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) = + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor) + analyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor) + } - override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) = + override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) + analyticsHelper.logOnboardingStateChanged(shouldHideOnboarding) + } } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index 055d8e074..61569d650 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.data.repository +import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig @@ -37,6 +38,8 @@ class OfflineFirstUserDataRepositoryTest { private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource + private val analyticsHelper = NoOpAnalyticsHelper() + @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() @@ -48,6 +51,7 @@ class OfflineFirstUserDataRepositoryTest { subject = OfflineFirstUserDataRepository( niaPreferencesDataSource = niaPreferencesDataSource, + analyticsHelper, ) } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 39f9bcff1..0438b8f36 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:model")) implementation(project(":core:domain")) + implementation(project(":core:analytics")) implementation(libs.androidx.browser) implementation(libs.androidx.core.ktx) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt new file mode 100644 index 000000000..38bce838a --- /dev/null +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt @@ -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 {} +} diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 2ad38a26e..3b0015bab 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter 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.domain.model.UserNewsResource @@ -57,12 +58,19 @@ fun LazyGridScope.newsFeed( mutableStateOf(Uri.parse(userNewsResource.url)) } val context = LocalContext.current + val analyticsHelper = LocalAnalyticsHelper.current val backgroundColor = MaterialTheme.colorScheme.background.toArgb() NewsResourceCardExpanded( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, - onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) }, + onClick = { + analyticsHelper.logNewsResourceOpened( + newsResourceId = userNewsResource.id, + newsResourceTitle = userNewsResource.title, + ) + launchCustomChromeTab(context, resourceUrl, backgroundColor) + }, onToggleBookmark = { onNewsResourcesCheckedChanged( userNewsResource.id, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt index a2c02f84f..0f6861fbc 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb 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 /** @@ -46,12 +47,17 @@ fun LazyListScope.userNewsResourceCardItems( val resourceUrl = Uri.parse(userNewsResource.url) val backgroundColor = MaterialTheme.colorScheme.background.toArgb() val context = LocalContext.current + val analyticsHelper = LocalAnalyticsHelper.current NewsResourceCardExpanded( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, onToggleBookmark = { onToggleBookmark(userNewsResource) }, onClick = { + analyticsHelper.logNewsResourceOpened( + newsResourceId = userNewsResource.id, + newsResourceTitle = userNewsResource.title, + ) when (onItemClick) { null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) else -> onItemClick(userNewsResource) diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 5f43fd235..2db5a7280 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -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.Loading 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.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.newsFeed @@ -98,6 +99,7 @@ internal fun BookmarksScreen( EmptyState(modifier) } } + TrackScreenViewEvent(screenName = "Saved") } @Composable diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index cb0b0ecd6..fce7f8fed 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -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.ui.DevicePreviews 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.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.newsFeed @@ -211,6 +212,7 @@ internal fun ForYouScreen( ) } } + TrackScreenViewEvent(screenName = "ForYou") } /** diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index ec0179139..cde929917 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -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.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider +import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent @OptIn(ExperimentalLifecycleComposeApi::class) @Composable @@ -78,6 +79,7 @@ internal fun InterestsScreen( is InterestsUiState.Empty -> InterestsEmptyScreen() } } + TrackScreenViewEvent(screenName = "Interests") } @Composable diff --git a/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt index 3d9fd27d3..2cec43f7c 100644 --- a/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt @@ -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.ANDROID 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.SettingsUiState.Loading import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success @@ -134,6 +135,7 @@ fun SettingsDialog( Divider(Modifier.padding(top = 8.dp)) LinksPanel() } + TrackScreenViewEvent(screenName = "Settings") }, confirmButton = { Text( diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index 9d2c3e817..08bacd01d 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -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.UserNewsResource 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.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems @@ -72,6 +73,7 @@ internal fun TopicRoute( val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle() val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle() + TrackScreenViewEvent(screenName = "Topic: ${viewModel.topicId}") TopicScreen( topicUiState = topicUiState, newsUiState = newsUiState, diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index c0c6bbafd..c58d40b5e 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -50,6 +50,8 @@ class TopicViewModel @Inject constructor( private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) + val topicId = topicArgs.topicId + val topicUiState: StateFlow = topicUiState( topicId = topicArgs.topicId, userDataRepository = userDataRepository, diff --git a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt index a8f1b0a88..dfed60385 100644 --- a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt @@ -70,6 +70,10 @@ class TopicViewModelTest { ) } + @Test + fun topicId_matchesTopicIdFromSavedStateHandle() = + assertEquals(testInputTopics[0].topic.id, viewModel.topicId) + @Test fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38c1f7d8e..a6636c98e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,10 @@ androidxUiAutomator = "2.2.0" androidxWindowManager = "1.0.0" androidxWork = "2.7.1" coil = "2.2.2" +firebaseBom = "31.2.0" +firebaseCrashlyticsPlugin = "2.9.2" +firebasePerfPlugin = "1.4.2" +gmsPlugin = "4.3.14" hilt = "2.44.2" hiltExt = "1.0.0" 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-compose = { group = "io.coil-kt", name = "coil-compose", 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-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" } @@ -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-library = { id = "com.android.library", 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" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/kokoro/build.sh b/kokoro/build.sh index c283382d7..5c50a814f 100755 --- a/kokoro/build.sh +++ b/kokoro/build.sh @@ -20,7 +20,7 @@ set -e set -x deviceIds=${1:-'Nexus5,Pixel2,Pixel3,Nexus9'} -osVersionIds=${2:-'23,27,30'} +osVersionIds=${2:-'27,30'} GRADLE_FLAGS=() if [[ -n "$GRADLE_DEBUG" ]]; then diff --git a/settings.gradle.kts b/settings.gradle.kts index 809efd27d..857f9d56c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,8 @@ include(":core:model") include(":core:network") include(":core:ui") include(":core:testing") +include(":core:analytics") + include(":feature:foryou") include(":feature:interests") include(":feature:bookmarks") diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index 70f6b2e89..a3b589db3 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(project(":core:model")) implementation(project(":core:data")) implementation(project(":core:datastore")) + implementation(project(":core:analytics")) implementation(libs.kotlinx.coroutines.android) diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt new file mode 100644 index 000000000..d5250b330 --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt @@ -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), + ) +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index c6ac6fb65..211940ddb 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -24,6 +24,7 @@ import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy 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.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository @@ -52,6 +53,7 @@ class SyncWorker @AssistedInject constructor( private val topicRepository: TopicsRepository, private val newsRepository: NewsRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + private val analyticsHelper: AnalyticsHelper, ) : CoroutineWorker(appContext, workerParams), Synchronizer { override suspend fun getForegroundInfo(): ForegroundInfo = @@ -59,12 +61,16 @@ class SyncWorker @AssistedInject constructor( override suspend fun doWork(): Result = withContext(ioDispatcher) { traceAsync("Sync", 0) { + analyticsHelper.logSyncStarted() + // First sync the repositories in parallel val syncedSuccessfully = awaitAll( async { topicRepository.sync() }, async { newsRepository.sync() }, ).all { it } + analyticsHelper.logSyncFinished(syncedSuccessfully) + if (syncedSuccessfully) { Result.success() } else {