feat(analytics): Firebase Analytics, Crashlytics, ObservabilityModule, Firestore rules

This commit is contained in:
null 2026-06-16 01:13:20 -05:00
parent 11a81cb826
commit 1a33d4f2b9
9 changed files with 280 additions and 1 deletions

View File

@ -73,6 +73,9 @@ dependencies {
implementation("com.google.firebase:firebase-config-ktx") implementation("com.google.firebase:firebase-config-ktx")
implementation("com.google.firebase:firebase-analytics-ktx") implementation("com.google.firebase:firebase-analytics-ktx")
implementation("com.google.firebase:firebase-crashlytics-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 // Hilt
implementation("com.google.dagger:hilt-android:2.53.1") implementation("com.google.dagger:hilt-android:2.53.1")

View File

@ -1,12 +1,17 @@
package com.couplesconnect.app package com.couplesconnect.app
import android.app.Application import android.app.Application
import com.couplesconnect.app.core.firebase.FirebaseInitializer
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class CouplesConnectApp : Application() { class CouplesConnectApp : Application() {
@Inject lateinit var firebaseInitializer: FirebaseInitializer
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize app-wide components here firebaseInitializer.initialize()
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

69
firestore.rules Normal file
View File

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