diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f874b181..56a50a7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.hilt) alias(libs.plugins.compose.compiler) + alias(libs.plugins.google.services) + alias(libs.plugins.firebase.crashlytics) id("kotlin-parcelize") id("jacoco") } @@ -48,9 +50,6 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) -// firebaseCrashlytics { -// mappingFileUploadEnabled true -// } } getByName("debug") { isMinifyEnabled = false @@ -281,6 +280,11 @@ dependencies { implementation(libs.mp4parser.muxer) implementation(libs.androidx.media) + // Firebase + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics) + implementation(libs.firebase.analytics) + // Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f7521e35..3bd831b5 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ - + = Log.WARN + + override fun log(priority: Int, tag: String?, message: String, throwable: Throwable?) { + val fullMessage = if (tag != null) "[$tag] $message" else message + crashlytics.log(fullMessage) + if (throwable != null) { + crashlytics.recordException(throwable) + } + } +} + diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/FirebaseAnalyticsTracker.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/FirebaseAnalyticsTracker.kt new file mode 100644 index 00000000..d29b62c6 --- /dev/null +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/analytics/FirebaseAnalyticsTracker.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Dmytro Ponomarenko + * + * 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. + */ + +package com.dimowner.audiorecorder.v2.analytics + +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.logEvent +import timber.log.Timber +import javax.inject.Inject + +/** + * [AnalyticsTracker] implementation that forwards events to Firebase Analytics. + * + * Each method maps to a dedicated custom event with snake_case parameter names + * that conform to the Firebase 40-character name limit. + * + * Note: Firebase Analytics is only active in release builds (i.e. builds that + * include a valid `google-services.json`). In debug builds the [NoOpAnalyticsTracker] + * is bound instead via [com.dimowner.audiorecorder.v2.di.AnalyticsModule]. + */ +class FirebaseAnalyticsTracker @Inject constructor( + private val firebaseAnalytics: FirebaseAnalytics, +) : AnalyticsTracker { + + // ── Database migration ─────────────────────────────────────────────────── + + override fun trackDbMigrationStarted() { + log(EVENT_DB_MIGRATION_STARTED) + firebaseAnalytics.logEvent(EVENT_DB_MIGRATION_STARTED) {} + } + + override fun trackDbMigrationSuccess(migratedRecordCount: Int, durationMs: Long) { + log(EVENT_DB_MIGRATION_SUCCESS, "migrated_record_count=$migratedRecordCount, duration_ms=$durationMs") + firebaseAnalytics.logEvent(EVENT_DB_MIGRATION_SUCCESS) { + param(PARAM_MIGRATED_RECORD_COUNT, migratedRecordCount.toLong()) + param(PARAM_DURATION_MS, durationMs) + } + } + + override fun trackDbMigrationFailed(error: Throwable) { + log(EVENT_DB_MIGRATION_FAILED, "error=${error.message}") + firebaseAnalytics.logEvent(EVENT_DB_MIGRATION_FAILED) { + param(PARAM_ERROR_MESSAGE, error.message?.take(100) ?: "unknown") + } + } + + // ── App version switching ──────────────────────────────────────────────── + + override fun trackSwitchToAppV2() { + log(EVENT_SWITCH_TO_APP_V2) + firebaseAnalytics.logEvent(EVENT_SWITCH_TO_APP_V2) {} + } + + override fun trackSwitchToLegacyApp() { + log(EVENT_SWITCH_TO_LEGACY_APP) + firebaseAnalytics.logEvent(EVENT_SWITCH_TO_LEGACY_APP) {} + } + + // ── Broken record recovery ─────────────────────────────────────────────── + + override fun trackBrokenRecordDetected(format: String, count: Int) { + log(EVENT_BROKEN_RECORD_DETECTED, "format=$format, count=$count") + firebaseAnalytics.logEvent(EVENT_BROKEN_RECORD_DETECTED) { + param(PARAM_FORMAT, format) + param(PARAM_COUNT, count.toLong()) + } + } + + override fun trackBrokenRecordRestoreSuccess(format: String) { + log(EVENT_BROKEN_RECORD_RESTORE_SUCCESS, "format=$format") + firebaseAnalytics.logEvent(EVENT_BROKEN_RECORD_RESTORE_SUCCESS) { + param(PARAM_FORMAT, format) + } + } + + override fun trackBrokenRecordRestoreFailed(format: String) { + log(EVENT_BROKEN_RECORD_RESTORE_FAILED, "format=$format") + firebaseAnalytics.logEvent(EVENT_BROKEN_RECORD_RESTORE_FAILED) { + param(PARAM_FORMAT, format) + } + } + + // ── Lost records ───────────────────────────────────────────────────────── + + override fun trackLostRecordsDetected(count: Int) { + log(EVENT_LOST_RECORDS_DETECTED, "count=$count") + firebaseAnalytics.logEvent(EVENT_LOST_RECORDS_DETECTED) { + param(PARAM_COUNT, count.toLong()) + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private fun log(event: String, params: String = "") { + if (params.isEmpty()) { + Timber.d("FirebaseAnalyticsTracker: $event") + } else { + Timber.d("FirebaseAnalyticsTracker: $event [$params]") + } + } + + // ── Event / parameter name constants ───────────────────────────────────── + + companion object { + const val EVENT_DB_MIGRATION_STARTED = "db_migration_started" + const val EVENT_DB_MIGRATION_SUCCESS = "db_migration_success" + const val EVENT_DB_MIGRATION_FAILED = "db_migration_failed" + const val EVENT_SWITCH_TO_APP_V2 = "switch_to_app_v2" + const val EVENT_SWITCH_TO_LEGACY_APP = "switch_to_legacy_app" + const val EVENT_BROKEN_RECORD_DETECTED = "broken_record_detected" + const val EVENT_BROKEN_RECORD_RESTORE_SUCCESS = "broken_record_restore_ok" + const val EVENT_BROKEN_RECORD_RESTORE_FAILED = "broken_record_restore_fail" + const val EVENT_LOST_RECORDS_DETECTED = "lost_records_detected" + + const val PARAM_MIGRATED_RECORD_COUNT = "migrated_record_count" + const val PARAM_DURATION_MS = "duration_ms" + const val PARAM_ERROR_MESSAGE = "error_message" + const val PARAM_FORMAT = "format" + const val PARAM_COUNT = "count" + } +} + diff --git a/app/src/main/java/com/dimowner/audiorecorder/v2/di/AnalyticsModule.kt b/app/src/main/java/com/dimowner/audiorecorder/v2/di/AnalyticsModule.kt index 6c723ec7..849564d2 100644 --- a/app/src/main/java/com/dimowner/audiorecorder/v2/di/AnalyticsModule.kt +++ b/app/src/main/java/com/dimowner/audiorecorder/v2/di/AnalyticsModule.kt @@ -1,10 +1,14 @@ package com.dimowner.audiorecorder.v2.di +import android.content.Context import com.dimowner.audiorecorder.v2.analytics.AnalyticsTracker -import com.dimowner.audiorecorder.v2.analytics.NoOpAnalyticsTracker +import com.dimowner.audiorecorder.v2.analytics.FirebaseAnalyticsTracker +import com.google.firebase.analytics.FirebaseAnalytics import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @@ -14,5 +18,13 @@ abstract class AnalyticsModule { @Singleton @Binds - abstract fun bindAnalyticsTracker(impl: NoOpAnalyticsTracker): AnalyticsTracker + abstract fun bindAnalyticsTracker(impl: FirebaseAnalyticsTracker): AnalyticsTracker + + companion object { + + @Singleton + @Provides + fun provideFirebaseAnalytics(@ApplicationContext context: Context): FirebaseAnalytics = + FirebaseAnalytics.getInstance(context) + } } diff --git a/build.gradle.kts b/build.gradle.kts index 3a4a0aa9..b2dee819 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,4 +13,6 @@ plugins { alias(libs.plugins.hilt) apply false alias(libs.plugins.android.test) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32e2c9d3..502a0483 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,8 @@ [versions] androidGradlePlugin = "9.2.1" +firebaseBom = "34.14.0" +googleServices = "4.4.4" +firebaseCrashlyticsGradle = "3.0.7" # @keep compose-compiler = "1.5.8" composeBom = "2026.05.01" @@ -89,6 +92,9 @@ mp4parser-muxer = { module = "org.mp4parser:muxer", version.ref = "mp4parser" } androidx-media = { module = "androidx.media:media", version.ref = "media" } #androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } #androidx-fragment = { group = "androidx.fragment:fragment-ktx:", version.ref = "fragment" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -96,4 +102,6 @@ android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } \ No newline at end of file +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsGradle" }