diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml new file mode 100644 index 0000000..02b915b --- /dev/null +++ b/.idea/git_toolbox_prj.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/README.MD b/README.MD index 4ad2c25..4b4f9b6 100644 --- a/README.MD +++ b/README.MD @@ -42,6 +42,10 @@ Using Bitrise instead? Then you can delete: 1. Run `python ./scripts/rename-project.py` or `python3 ./scripts/rename-project.py` from the project root, to change the project name and the package names. The script will ask for your new project name and update all references. +1. Replace google-services.json with your own Firebase config file. + The template Firebase project can be found here: [Firebase project](https://console.firebase.google.com/u/0/project/template-android-799ab/overview) + Google Services currently in use: + - Crashlytics ## Contributing @@ -238,6 +242,12 @@ It also has multiplatform support, so we can use it in our KMP projects as well. We use Napier because it's usage is close to Timber/Tolbaaken, but Napier supports KMM. +We use Crashlytics for crash reporting. Note that Google Analytics is not added. Google [recommends] +(https://firebase.google.com/docs/crashlytics/get-started?platform=android#before-you-begin) +to enable it for more insight such as breadcrumbs and crash-free percentage. +If you don't want to use Google Analytics, you can remove it by simply removing the dependency. + + ### Image loading We did not include an image loading library is this template, because not every app might need it, diff --git a/app/build.gradle b/app/build.gradle index 59f7e2e..0007773 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,5 +1,7 @@ plugins { id "com.android.application" + alias libs.plugins.googleServices + alias libs.plugins.firebaseCrashlyticsPlugin } apply from: "$rootDir/build.module.feature-and-app.gradle" @@ -63,4 +65,7 @@ dependencies { implementation(project(":data:user")) // needed for di implementation(project(":core:network")) // needed for di implementation(libs.composeDestinations) + + api platform(libs.firebaseBoM) + implementation(libs.firebaseCrashlytics) } diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..30130e7 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "1076113435843", + "project_id": "template-android-799ab", + "storage_bucket": "template-android-799ab.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1076113435843:android:e2916720b9c98936dc8676", + "android_client_info": { + "package_name": "nl.q42.template" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDzfsJ_ycKoyci--w_M2hHwvsjk8-zKmzA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1076113435843:android:72065788817f0133dc8676", + "android_client_info": { + "package_name": "nl.q42.template.dev" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDzfsJ_ycKoyci--w_M2hHwvsjk8-zKmzA" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/main/kotlin/nl/q42/template/MainApplication.kt b/app/src/main/kotlin/nl/q42/template/MainApplication.kt index 6eb3a90..c2005c8 100644 --- a/app/src/main/kotlin/nl/q42/template/MainApplication.kt +++ b/app/src/main/kotlin/nl/q42/template/MainApplication.kt @@ -2,9 +2,11 @@ package nl.q42.template import android.app.Application import android.os.StrictMode +import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.HiltAndroidApp import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier +import nl.q42.template.logging.CrashlyticsLogger @HiltAndroidApp class MainApplication : Application() { @@ -13,6 +15,7 @@ class MainApplication : Application() { super.onCreate() if (BuildConfig.DEBUG) { + FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(false) Napier.base(DebugAntilog()) StrictMode.setThreadPolicy( @@ -23,6 +26,9 @@ class MainApplication : Application() { .penaltyLog() .build() ) + } else { + FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(true) + Napier.base(CrashlyticsLogger()) } } } diff --git a/app/src/main/kotlin/nl/q42/template/logging/CrashlyticsLogger.kt b/app/src/main/kotlin/nl/q42/template/logging/CrashlyticsLogger.kt new file mode 100644 index 0000000..4e24de5 --- /dev/null +++ b/app/src/main/kotlin/nl/q42/template/logging/CrashlyticsLogger.kt @@ -0,0 +1,49 @@ +package nl.q42.template.logging + +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import io.github.aakira.napier.Antilog +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.LogLevel +import nl.q42.template.BuildConfig + +/** A value suitable for Crashlytics + * This value is used to truncate the user-defined message and the exception message + * In theory, the message sent to Crashlytics could therefore be 2x this value + */ +private const val MAX_CHARS_IN_LOG = 1200 + +/** A Crashlytics logger. The name Antilog might be an unfortunate choice by the Napier library; + * this is not a stub + */ +class CrashlyticsLogger : Antilog() { + + private val logcatLogger = DebugAntilog() + + override fun performLog( + priority: LogLevel, + tag: String?, + throwable: Throwable?, + message: String? + ) { + if (message == null && throwable == null) return + + if (BuildConfig.DEBUG || priority > LogLevel.DEBUG) { + // also send to logcat + logcatLogger.log(priority, tag, throwable, message) + } + + val limitedMessage = message?.take(MAX_CHARS_IN_LOG) ?: "(no message)" // to avoid OutOfMemoryError's + + if (priority < LogLevel.ERROR) { + // at least one of message or throwable is not null + val errorMessage = throwable?.let { + " with error: ${throwable}: ${throwable.message}".take(MAX_CHARS_IN_LOG) + } ?: "" + Firebase.crashlytics.log(limitedMessage + errorMessage) + } else { + Firebase.crashlytics.log("recordException with message: $limitedMessage") + Firebase.crashlytics.recordException(throwable ?: Exception(message)) + } + } +} diff --git a/build.gradle b/build.gradle index f809ee9..3619fd4 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,8 @@ plugins { // sets class paths only (because of 'apply false') alias libs.plugins.kotlinSerialization apply false alias libs.plugins.hilt apply false alias libs.plugins.ksp apply false + alias libs.plugins.googleServices apply false + alias libs.plugins.firebaseCrashlyticsPlugin apply false } allprojects { diff --git a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/RouteNavigator.kt b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/RouteNavigator.kt index 57dc419..6f44bc5 100644 --- a/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/RouteNavigator.kt +++ b/core/navigation/src/main/kotlin/nl/q42/template/navigation/viewmodel/RouteNavigator.kt @@ -2,6 +2,7 @@ package nl.q42.template.navigation.viewmodel import androidx.annotation.VisibleForTesting import com.ramcosta.composedestinations.spec.Direction +import io.github.aakira.napier.Napier import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -43,6 +44,7 @@ class MyRouteNavigator : RouteNavigator { @VisibleForTesting fun navigate(state: NavigationState) { + Napier.i { state.toString() } navigationState.value = state } -} \ No newline at end of file +} diff --git a/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewModel.kt b/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewModel.kt index a29a9c9..329fde2 100644 --- a/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewModel.kt +++ b/feature/home/src/main/kotlin/nl/q42/template/presentation/home/HomeViewModel.kt @@ -3,6 +3,8 @@ package nl.q42.template.presentation.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,6 +17,7 @@ import nl.q42.template.navigation.AppGraphRoutes import nl.q42.template.navigation.viewmodel.RouteNavigator import nl.q42.template.ui.home.destinations.HomeSecondScreenDestination import nl.q42.template.ui.presentation.ViewStateString +import java.lang.RuntimeException import javax.inject.Inject @HiltViewModel @@ -57,6 +60,9 @@ class HomeViewModel @Inject constructor( } fun onOpenSecondScreenClicked() { + Napier.e(RuntimeException("Open Second Screen tapped. This will be shown as the non-fatal title")) { + "Open Second Screen Tapped. This will be shown in the Crashlytics breadcrumbs" + } navigateTo(HomeSecondScreenDestination(title = "Hello world!")) } diff --git a/feature/home/src/main/kotlin/nl/q42/template/ui/home/HomeContent.kt b/feature/home/src/main/kotlin/nl/q42/template/ui/home/HomeContent.kt index adf38cf..2def132 100644 --- a/feature/home/src/main/kotlin/nl/q42/template/ui/home/HomeContent.kt +++ b/feature/home/src/main/kotlin/nl/q42/template/ui/home/HomeContent.kt @@ -2,12 +2,15 @@ package nl.q42.template.ui.home import androidx.compose.foundation.layout.Arrangement.Center import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import nl.q42.template.presentation.home.HomeViewState import nl.q42.template.ui.compose.get import nl.q42.template.ui.presentation.toViewStateString @@ -52,6 +55,10 @@ internal fun HomeContent( Button(onClick = onOpenOnboardingClicked) { Text("Open onboarding") } + + Spacer(modifier = Modifier.height(32.dp)) + + Text(text = "NOTE: when cloning this template, set up your own Firebase project and replace google-services.json") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e91586c..dea10d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,9 @@ jvmTarget = "17" kotlin = "1.9.22" ksp = "1.9.22-1.0.18" gradlePlugin = "8.2.1" +googleServices = "4.4.0" +crashlyticsPlugin = "2.9.9" +firebaseBOM = "32.7.0" manesVersions = "0.44.0" littleRobotsCatalogUpdates = "0.8.1" hilt = "2.50" @@ -49,6 +52,8 @@ composeUITooling = { module = "androidx.compose.ui:ui-tooling" } composeUIToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview" } composeMaterial3 = { module = "androidx.compose.material3:material3" } composePlatform = { module = "androidx.compose:compose-bom", version.ref = "composePlatform" } +firebaseBoM = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBOM" } +firebaseCrashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" } activityCompose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } hiltNavigationCompose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } composeLifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "composeLifecycle" } @@ -64,3 +69,5 @@ manesVersions = { id = "com-github-ben-manes-versions", version.ref = "manesVers littleRobotsCatalogUpdates = { id = "nl.littlerobots.version-catalog-update", version.ref = "littleRobotsCatalogUpdates" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" } +firebaseCrashlyticsPlugin = { id = "com.google.firebase.crashlytics", version.ref = "crashlyticsPlugin" }