From 1a33d4f2b97a783330cbdd5250807c2671499541 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 01:13:20 -0500 Subject: [PATCH] feat(analytics): Firebase Analytics, Crashlytics, ObservabilityModule, Firestore rules --- app/build.gradle.kts | 3 + .../main/java/com/couplesconnect/app/App.kt | 7 +- .../app/core/analytics/AnalyticsTracker.kt | 16 ++++ .../analytics/FirebaseAnalyticsTracker.kt | 84 +++++++++++++++++++ .../app/core/crash/CrashReporter.kt | 8 ++ .../app/core/crash/FirebaseCrashReporter.kt | 27 ++++++ .../app/core/firebase/FirebaseInitializer.kt | 35 ++++++++ .../app/di/ObservabilityModule.kt | 32 +++++++ firestore.rules | 69 +++++++++++++++ 9 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/couplesconnect/app/core/analytics/AnalyticsTracker.kt create mode 100644 app/src/main/java/com/couplesconnect/app/core/analytics/FirebaseAnalyticsTracker.kt create mode 100644 app/src/main/java/com/couplesconnect/app/core/crash/CrashReporter.kt create mode 100644 app/src/main/java/com/couplesconnect/app/core/crash/FirebaseCrashReporter.kt create mode 100644 app/src/main/java/com/couplesconnect/app/core/firebase/FirebaseInitializer.kt create mode 100644 app/src/main/java/com/couplesconnect/app/di/ObservabilityModule.kt create mode 100644 firestore.rules diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 314ac64a..273d6aea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,6 +73,9 @@ dependencies { implementation("com.google.firebase:firebase-config-ktx") implementation("com.google.firebase:firebase-analytics-ktx") implementation("com.google.firebase:firebase-crashlytics-ktx") + implementation("com.google.firebase:firebase-appcheck-ktx") + implementation("com.google.firebase:firebase-appcheck-playintegrity") + debugImplementation("com.google.firebase:firebase-appcheck-debug") // Hilt implementation("com.google.dagger:hilt-android:2.53.1") diff --git a/app/src/main/java/com/couplesconnect/app/App.kt b/app/src/main/java/com/couplesconnect/app/App.kt index 6a81fbe4..a42f08d3 100644 --- a/app/src/main/java/com/couplesconnect/app/App.kt +++ b/app/src/main/java/com/couplesconnect/app/App.kt @@ -1,12 +1,17 @@ package com.couplesconnect.app import android.app.Application +import com.couplesconnect.app.core.firebase.FirebaseInitializer import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp class CouplesConnectApp : Application() { + + @Inject lateinit var firebaseInitializer: FirebaseInitializer + override fun onCreate() { super.onCreate() - // Initialize app-wide components here + firebaseInitializer.initialize() } } diff --git a/app/src/main/java/com/couplesconnect/app/core/analytics/AnalyticsTracker.kt b/app/src/main/java/com/couplesconnect/app/core/analytics/AnalyticsTracker.kt new file mode 100644 index 00000000..a04d5945 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/core/analytics/AnalyticsTracker.kt @@ -0,0 +1,16 @@ +package com.couplesconnect.app.core.analytics + +interface AnalyticsTracker { + fun trackScreenViewed(screenName: String) + fun trackAppOpened() + fun trackQuestionViewed(questionId: String, category: String) + fun trackAnswerSubmitted(questionId: String, category: String, answerType: String) + fun trackAnswerRevealed(questionId: String, category: String) + fun trackPaywallViewed(source: String) + fun trackCouplePaired() + fun trackCoupleLeft() + fun trackSpinWheelStarted(categoryId: String) + fun trackSpinWheelCompleted(categoryId: String, answeredCount: Int) + fun trackSignIn(method: String) + fun trackSignUp(method: String) +} diff --git a/app/src/main/java/com/couplesconnect/app/core/analytics/FirebaseAnalyticsTracker.kt b/app/src/main/java/com/couplesconnect/app/core/analytics/FirebaseAnalyticsTracker.kt new file mode 100644 index 00000000..833fdfe5 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/core/analytics/FirebaseAnalyticsTracker.kt @@ -0,0 +1,84 @@ +package com.couplesconnect.app.core.analytics + +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.logEvent +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FirebaseAnalyticsTracker @Inject constructor( + private val analytics: FirebaseAnalytics +) : AnalyticsTracker { + + override fun trackScreenViewed(screenName: String) { + analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { + param(FirebaseAnalytics.Param.SCREEN_NAME, screenName) + } + } + + override fun trackAppOpened() { + analytics.logEvent("app_opened", Bundle.EMPTY) + } + + override fun trackQuestionViewed(questionId: String, category: String) { + analytics.logEvent("question_viewed") { + param("question_id", questionId) + param("category", category) + } + } + + override fun trackAnswerSubmitted(questionId: String, category: String, answerType: String) { + analytics.logEvent("answer_submitted") { + param("question_id", questionId) + param("category", category) + param("answer_type", answerType) + } + } + + override fun trackAnswerRevealed(questionId: String, category: String) { + analytics.logEvent("answer_revealed") { + param("question_id", questionId) + param("category", category) + } + } + + override fun trackPaywallViewed(source: String) { + analytics.logEvent("paywall_viewed") { + param("source", source) + } + } + + override fun trackCouplePaired() { + analytics.logEvent("couple_paired", Bundle.EMPTY) + } + + override fun trackCoupleLeft() { + analytics.logEvent("couple_left", Bundle.EMPTY) + } + + override fun trackSpinWheelStarted(categoryId: String) { + analytics.logEvent("spin_wheel_started") { + param("category_id", categoryId) + } + } + + override fun trackSpinWheelCompleted(categoryId: String, answeredCount: Int) { + analytics.logEvent("spin_wheel_completed") { + param("category_id", categoryId) + param("answered_count", answeredCount.toLong()) + } + } + + override fun trackSignIn(method: String) { + analytics.logEvent(FirebaseAnalytics.Event.LOGIN) { + param(FirebaseAnalytics.Param.METHOD, method) + } + } + + override fun trackSignUp(method: String) { + analytics.logEvent(FirebaseAnalytics.Event.SIGN_UP) { + param(FirebaseAnalytics.Param.METHOD, method) + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/core/crash/CrashReporter.kt b/app/src/main/java/com/couplesconnect/app/core/crash/CrashReporter.kt new file mode 100644 index 00000000..eee72489 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/core/crash/CrashReporter.kt @@ -0,0 +1,8 @@ +package com.couplesconnect.app.core.crash + +interface CrashReporter { + fun setUserId(uid: String) + fun clearUserId() + fun log(message: String) + fun recordException(throwable: Throwable) +} diff --git a/app/src/main/java/com/couplesconnect/app/core/crash/FirebaseCrashReporter.kt b/app/src/main/java/com/couplesconnect/app/core/crash/FirebaseCrashReporter.kt new file mode 100644 index 00000000..8d14149d --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/core/crash/FirebaseCrashReporter.kt @@ -0,0 +1,27 @@ +package com.couplesconnect.app.core.crash + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FirebaseCrashReporter @Inject constructor() : CrashReporter { + + private val crashlytics = FirebaseCrashlytics.getInstance() + + override fun setUserId(uid: String) { + crashlytics.setUserId(uid) + } + + override fun clearUserId() { + crashlytics.setUserId("") + } + + override fun log(message: String) { + crashlytics.log(message) + } + + override fun recordException(throwable: Throwable) { + crashlytics.recordException(throwable) + } +} diff --git a/app/src/main/java/com/couplesconnect/app/core/firebase/FirebaseInitializer.kt b/app/src/main/java/com/couplesconnect/app/core/firebase/FirebaseInitializer.kt new file mode 100644 index 00000000..8875c3a0 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/core/firebase/FirebaseInitializer.kt @@ -0,0 +1,35 @@ +package com.couplesconnect.app.core.firebase + +import com.couplesconnect.app.BuildConfig +import com.google.firebase.appcheck.FirebaseAppCheck +import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FirebaseInitializer @Inject constructor() { + + fun initialize() { + val appCheck = FirebaseAppCheck.getInstance() + if (BuildConfig.DEBUG) { + // DebugAppCheckProviderFactory is in the debug artifact only; + // referenced by name to avoid a compile-time dep in release. + try { + val cls = Class.forName( + "com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory" + ) + val factory = cls.getMethod("getInstance").invoke(null) + as com.google.firebase.appcheck.AppCheckProviderFactory + appCheck.installAppCheckProviderFactory(factory) + } catch (_: Exception) { + appCheck.installAppCheckProviderFactory( + PlayIntegrityAppCheckProviderFactory.getInstance() + ) + } + } else { + appCheck.installAppCheckProviderFactory( + PlayIntegrityAppCheckProviderFactory.getInstance() + ) + } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/di/ObservabilityModule.kt b/app/src/main/java/com/couplesconnect/app/di/ObservabilityModule.kt new file mode 100644 index 00000000..a06a98d4 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/di/ObservabilityModule.kt @@ -0,0 +1,32 @@ +package com.couplesconnect.app.di + +import android.content.Context +import com.couplesconnect.app.core.analytics.AnalyticsTracker +import com.couplesconnect.app.core.analytics.FirebaseAnalyticsTracker +import com.couplesconnect.app.core.crash.CrashReporter +import com.couplesconnect.app.core.crash.FirebaseCrashReporter +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 + +@Module +@InstallIn(SingletonComponent::class) +abstract class ObservabilityModule { + + @Binds @Singleton + abstract fun bindAnalyticsTracker(impl: FirebaseAnalyticsTracker): AnalyticsTracker + + @Binds @Singleton + abstract fun bindCrashReporter(impl: FirebaseCrashReporter): CrashReporter + + companion object { + @Provides @Singleton + fun provideFirebaseAnalytics(@ApplicationContext context: Context): FirebaseAnalytics = + FirebaseAnalytics.getInstance(context) + } +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 00000000..089f020d --- /dev/null +++ b/firestore.rules @@ -0,0 +1,69 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + + // ── Helpers ────────────────────────────────────────────────────────────── + + function isSignedIn() { + return request.auth != null; + } + + function isOwner(uid) { + return isSignedIn() && request.auth.uid == uid; + } + + function isCouplesMember(coupleId) { + return isSignedIn() + && request.auth.uid in + get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds; + } + + // ── Users ───────────────────────────────────────────────────────────────── + // Each user owns exactly their own document. + + match /users/{uid} { + allow read, write: if isOwner(uid); + } + + // ── Invite codes ────────────────────────────────────────────────────────── + // Any authenticated user can create or read an invite code. + // Only status + acceptor fields may be updated (no re-writing the code). + + match /invites/{code} { + allow read: if isSignedIn(); + allow create: if isSignedIn(); + allow update: if isSignedIn() + && resource.data.status == 'pending' + && request.resource.data.keys().hasOnly( + ['status', 'acceptorUserId', 'acceptedAt', 'coupleId']); + } + + // ── Couples ─────────────────────────────────────────────────────────────── + // Only the two members of a couple may read or write couple data. + + match /couples/{coupleId} { + allow read, write: if isCouplesMember(coupleId); + + // Question threads live under the couple document. + match /question_threads/{threadId} { + allow read, write: if isCouplesMember(coupleId); + + // Answers: each user writes their own; both members can read all answers. + match /answers/{userId} { + allow write: if isOwner(userId); + allow read: if isCouplesMember(coupleId); + } + + // Discussion messages: any couple member can write and read. + match /messages/{messageId} { + allow read, write: if isCouplesMember(coupleId); + } + + // Reactions: any couple member can write and read. + match /reactions/{reactionId} { + allow read, write: if isCouplesMember(coupleId); + } + } + } + } +}