From e5f4ad201efcf6cc84e49df8cc456e0742bd7824 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Thu, 4 Dec 2025 06:06:49 +0530 Subject: [PATCH 1/2] feat: add unified analytics and session tracking Introduced a unified `AnalyticsManager` to handle multiple analytics providers (Firebase, Mixpanel, CleanInsights) using a facade pattern. This refactor ensures GDPR compliance through automatic PII sanitization. Key changes include: - A new `SessionManager` for tracking app lifecycle events like session start/end and foreground/background states. - Automatic screen view and navigation tracking in `BaseActivity` and `BaseFragment`. - Added analytics for key user actions: - Feature toggles in Settings (Dark Mode, Tor, etc.). - Upload lifecycle events (start, complete, fail, cancel). - Backend configuration events. - Enhanced `AppLogger` to integrate with Crashlytics for improved error reporting with breadcrumbs. - Defined a structured set of `AnalyticsEvent` types for consistency. --- app/build.gradle.kts | 2 + .../openarchive/CleanInsightsManager.kt | 136 ++++++ .../opendasharchive/openarchive/SaveApp.kt | 44 +- .../core/analytics/AnalyticsEvent.kt | 407 ++++++++++++++++++ .../core/analytics/AnalyticsManager.kt | 285 ++++++++++++ .../core/analytics/AnalyticsProvider.kt | 35 ++ .../providers/CleanInsightsProvider.kt | 47 ++ .../analytics/providers/FirebaseProvider.kt | 128 ++++++ .../analytics/providers/MixpanelProvider.kt | 97 +++++ .../openarchive/core/logger/AppLogger.kt | 117 ++++- .../openarchive/features/core/BaseActivity.kt | 39 ++ .../openarchive/features/core/BaseFragment.kt | 39 ++ .../usecase/InternetArchiveLoginUseCase.kt | 11 + .../features/settings/SettingsFragment.kt | 40 +- .../openarchive/services/Conduit.kt | 112 ++++- .../services/webdav/WebDavViewModel.kt | 11 + .../openarchive/upload/UploadService.kt | 52 ++- .../openarchive/util/Analytics.kt | 40 +- .../openarchive/util/InAppReviewHelper.kt | 8 + .../openarchive/util/SessionManager.kt | 163 +++++++ gradle/libs.versions.toml | 3 + 21 files changed, 1788 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsEvent.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsManager.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsProvider.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/CleanInsightsProvider.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/FirebaseProvider.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/MixpanelProvider.kt create mode 100644 app/src/main/java/net/opendasharchive/openarchive/util/SessionManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c9ec4289f..45f3ae60a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -187,6 +187,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.livedata) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.process) // AndroidX Navigation implementation(libs.androidx.navigation.fragment) @@ -301,6 +302,7 @@ dependencies { implementation(libs.clean.insights) // Firebase + implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) // Testing diff --git a/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt b/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt index 06231c529..9bd961b6e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/CleanInsightsManager.kt @@ -73,4 +73,140 @@ object CleanInsightsManager { fun persist() { mCi?.persist() } + + // ========== Enhanced Tracking Methods ========== + + /** + * Track screen views with optional time spent + * @param screenName Name of the screen (e.g., "MainActivity", "SettingsFragment") + * @param timeSpentSeconds Optional time spent on screen in seconds + */ + fun trackScreenView(screenName: String, timeSpentSeconds: Long? = null) { + measureView(screenName) + timeSpentSeconds?.let { + measureEvent("screen_time", "view_duration", screenName, it.toDouble()) + } + } + + /** + * Track navigation between screens + * @param fromScreen Source screen name + * @param toScreen Destination screen name + * @param trigger What triggered the navigation (e.g., "button_click", "back_press") + */ + fun trackNavigation(fromScreen: String, toScreen: String, trigger: String? = null) { + measureEvent("navigation", "screen_change", "$fromScreen->$toScreen") + trigger?.let { + measureEvent("navigation", "trigger", it) + } + } + + /** + * Track backend/server usage + * @param action Action performed (e.g., "configured", "upload_started", "upload_completed") + * @param backendType Type of backend (e.g., "Internet Archive", "Private Server", "DWeb Service", "Storacha") + * @param value Optional numeric value (e.g., duration, file size) + */ + fun trackBackendAction(action: String, backendType: String, value: Double? = null) { + measureEvent("backend", action, backendType, value) + } + + /** + * Track upload events + * @param backendType Type of backend + * @param success Whether upload succeeded + * @param durationSeconds Optional duration in seconds + * @param fileSizeKB Optional file size in KB + */ + fun trackUpload( + backendType: String, + success: Boolean, + durationSeconds: Long? = null, + fileSizeKB: Long? = null + ) { + val action = if (success) "upload_completed" else "upload_failed" + measureEvent("upload", action, backendType, durationSeconds?.toDouble()) + + fileSizeKB?.let { + measureEvent("upload", "file_size", backendType, it.toDouble()) + } + } + + /** + * Track download events + * @param backendType Type of backend + * @param success Whether download succeeded + * @param durationSeconds Optional duration in seconds + */ + fun trackDownload(backendType: String, success: Boolean, durationSeconds: Long? = null) { + val action = if (success) "download_completed" else "download_failed" + measureEvent("download", action, backendType, durationSeconds?.toDouble()) + } + + /** + * Track media capture/selection + * @param action Action performed (e.g., "captured", "selected", "deleted") + * @param mediaType Type of media (e.g., "photo", "video", "document") + * @param source Source of media (e.g., "camera", "gallery", "files") + * @param count Number of items + */ + fun trackMediaAction(action: String, mediaType: String? = null, source: String? = null, count: Int? = null) { + measureEvent("media", action, mediaType ?: "unknown", count?.toDouble()) + source?.let { + measureEvent("media", "source", it) + } + } + + /** + * Track app lifecycle events + * @param event Event type (e.g., "app_opened", "app_closed", "app_backgrounded") + * @param sessionDurationSeconds Optional session duration in seconds + * @param isFirstLaunch Whether this is the first app launch + */ + fun trackAppLifecycle(event: String, sessionDurationSeconds: Long? = null, isFirstLaunch: Boolean? = null) { + measureEvent("app", event, null, sessionDurationSeconds?.toDouble()) + isFirstLaunch?.let { + if (it) measureEvent("app", "first_launch", null) + } + } + + /** + * Track feature usage + * @param feature Feature name (e.g., "proofmode", "tor", "dark_mode") + * @param enabled Whether the feature was enabled or disabled + */ + fun trackFeatureToggle(feature: String, enabled: Boolean) { + val action = if (enabled) "enabled" else "disabled" + measureEvent("feature", action, feature) + } + + /** + * Track errors (GDPR-compliant - no PII) + * @param errorCategory Category of error (e.g., "network", "permission", "upload", "auth") + * @param screenName Screen where error occurred + * @param backendType Optional backend type if relevant + */ + fun trackError(errorCategory: String, screenName: String, backendType: String? = null) { + measureEvent("error", errorCategory, screenName) + backendType?.let { + measureEvent("error", "backend", it) + } + } + + /** + * Track session start + * @param isFirstSession Whether this is the user's first session + */ + fun trackSessionStart(isFirstSession: Boolean = false) { + measureEvent("session", "started", if (isFirstSession) "first" else "returning") + } + + /** + * Track session end + * @param lastScreen Last screen user was on + * @param durationSeconds Session duration in seconds + */ + fun trackSessionEnd(lastScreen: String, durationSeconds: Long) { + measureEvent("session", "ended", lastScreen, durationSeconds.toDouble()) + } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt index 21a9e30a0..8aa61dea5 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -21,12 +21,17 @@ import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.features.settings.passcode.PasscodeManager import net.opendasharchive.openarchive.util.Analytics import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.util.SessionManager +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin import org.koin.core.logger.Level +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner -class SaveApp : SugarApp(), SingletonImageLoader.Factory { +class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserver { override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) @@ -40,9 +45,17 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory { } override fun onCreate() { - super.onCreate() - Analytics.init(this) + super.onCreate() + + // Initialize logging first AppLogger.init(applicationContext, initDebugger = true) + + // Initialize legacy Analytics (kept for backwards compatibility) + Analytics.init(this) + + // Initialize new unified Analytics Manager (CleanInsights + Mixpanel + Firebase) + AnalyticsManager.initialize(this) + registerActivityLifecycleCallbacks(PasscodeManager()) startKoin { androidLogger(Level.DEBUG) @@ -61,11 +74,36 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory { if (Prefs.useTor) initNetCipher() + // Legacy CleanInsightsManager (will be replaced by AnalyticsManager) CleanInsightsManager.init(this) + // Register app lifecycle observer for session tracking + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + // Set user properties (GDPR-compliant) + AnalyticsManager.setUserProperty("app_version", BuildConfig.VERSION_NAME) + AnalyticsManager.setUserProperty("device_type", "android") + createSnowbirdNotificationChannel() } + override fun onStart(owner: LifecycleOwner) { + super.onStart(owner) + // App came to foreground + SessionManager.startSession(this) + SessionManager.onForeground() + } + + override fun onStop(owner: LifecycleOwner) { + super.onStop(owner) + // App went to background + SessionManager.onBackground() + SessionManager.endSession() + + // Persist analytics data + AnalyticsManager.persist() + } + private fun initNetCipher() { AppLogger.d("Initializing NetCipher client") val oh = OrbotHelper.get(this) diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsEvent.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsEvent.kt new file mode 100644 index 000000000..94d8398c9 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsEvent.kt @@ -0,0 +1,407 @@ +package net.opendasharchive.openarchive.core.analytics + +/** + * Sealed class representing all analytics events in the application + * GDPR-Compliant: Contains NO PII (personally identifiable information) + */ +sealed class AnalyticsEvent( + val category: String, + val action: String, + val label: String? = null, + val value: Double? = null, + val properties: Map = emptyMap(), +) { + // ==================== APP LIFECYCLE ==================== + + data class AppOpened( + val isFirstLaunch: Boolean = false, + val appVersion: String, + ) : AnalyticsEvent( + category = "app", + action = "opened", + properties = + mapOf( + "is_first_launch" to isFirstLaunch, + "app_version" to appVersion, + ), + ) + + data class AppClosed( + val sessionDurationSeconds: Long, + ) : AnalyticsEvent( + category = "app", + action = "closed", + value = sessionDurationSeconds.toDouble(), + ) + + class AppBackgrounded : + AnalyticsEvent( + category = "app", + action = "backgrounded", + ) + + class AppForegrounded : + AnalyticsEvent( + category = "app", + action = "foregrounded", + ) + + // ==================== SCREEN TRACKING ==================== + + data class ScreenViewed( + val screenName: String, + val timeSpentSeconds: Long? = null, + val previousScreen: String? = null, + ) : AnalyticsEvent( + category = "screen", + action = "viewed", + label = screenName, + value = timeSpentSeconds?.toDouble(), + properties = + mapOf( + "screen_name" to screenName, + "previous_screen" to (previousScreen ?: "none"), + ), + ) + + data class NavigationAction( + val fromScreen: String, + val toScreen: String, + val trigger: String? = null, + ) : AnalyticsEvent( + category = "navigation", + action = "screen_change", + label = "$fromScreen -> $toScreen", + properties = + mapOf( + "from_screen" to fromScreen, + "to_screen" to toScreen, + "trigger" to (trigger ?: "unknown"), + ), + ) + + // ==================== BACKEND USAGE ==================== + + data class BackendConfigured( + val backendType: String, // "Internet Archive", "Private Server", "DWeb Service", "Storacha" + val isNew: Boolean = true, + ) : AnalyticsEvent( + category = "backend", + action = if (isNew) "configured" else "updated", + label = backendType, + properties = + mapOf( + "backend_type" to backendType, + "is_new" to isNew, + ), + ) + + data class BackendRemoved( + val backendType: String, + val reason: String? = null, + ) : AnalyticsEvent( + category = "backend", + action = "removed", + label = backendType, + properties = + mapOf( + "backend_type" to backendType, + "reason" to (reason ?: "unknown"), + ), + ) + + // ==================== UPLOAD METRICS ==================== + + data class UploadStarted( + val backendType: String, + val fileType: String, // "image", "video", "document", "other" + val fileSizeKB: Long, + ) : AnalyticsEvent( + category = "upload", + action = "started", + label = backendType, + properties = + mapOf( + "backend_type" to backendType, + "file_type" to fileType, + "file_size_kb" to fileSizeKB, + "file_size_category" to getFileSizeCategory(fileSizeKB), + ), + ) { + companion object { + internal fun getFileSizeCategory(sizeKB: Long): String = + when { + sizeKB < 100 -> "tiny" + + // < 100KB + sizeKB < 1024 -> "small" + + // < 1MB + sizeKB < 10240 -> "medium" + + // < 10MB + sizeKB < 102400 -> "large" + + // < 100MB + else -> "very_large" // >= 100MB + } + } + } + + data class UploadCompleted( + val backendType: String, + val fileType: String, + val fileSizeKB: Long, + val durationSeconds: Long, + val uploadSpeedKBps: Long? = null, + ) : AnalyticsEvent( + category = "upload", + action = "completed", + label = backendType, + value = durationSeconds.toDouble(), + properties = + mapOf( + "backend_type" to backendType, + "file_type" to fileType, + "file_size_kb" to fileSizeKB, + "duration_seconds" to durationSeconds, + "upload_speed_kbps" to (uploadSpeedKBps ?: 0), + "file_size_category" to UploadStarted.getFileSizeCategory(fileSizeKB), + ), + ) + + data class UploadFailed( + val backendType: String, + val fileType: String, + val errorCategory: String, // "network", "permission", "file_not_found", "storage", "unknown" + val fileSizeKB: Long? = null, + ) : AnalyticsEvent( + category = "upload", + action = "failed", + label = backendType, + properties = + mapOf( + "backend_type" to backendType, + "file_type" to fileType, + "error_category" to errorCategory, + "file_size_kb" to (fileSizeKB ?: 0), + ), + ) + + // ==================== MEDIA ACTIONS ==================== + + data class MediaCaptured( + val mediaType: String, // "photo", "video" + val source: String = "camera", + ) : AnalyticsEvent( + category = "media", + action = "captured", + label = mediaType, + properties = + mapOf( + "media_type" to mediaType, + "source" to source, + ), + ) + + data class MediaSelected( + val count: Int, + val source: String, // "gallery", "camera", "files" + val mediaTypes: List = emptyList(), + ) : AnalyticsEvent( + category = "media", + action = "selected", + label = source, + value = count.toDouble(), + properties = + mapOf( + "count" to count, + "source" to source, + "has_images" to mediaTypes.contains("image"), + "has_videos" to mediaTypes.contains("video"), + "has_documents" to mediaTypes.contains("document"), + ), + ) + + data class MediaDeleted( + val count: Int, + ) : AnalyticsEvent( + category = "media", + action = "deleted", + value = count.toDouble(), + properties = mapOf("count" to count), + ) + + // ==================== FEATURE USAGE ==================== + + data class FeatureToggled( + val featureName: String, // "proofmode", "tor", "dark_mode", "wifi_only_upload" + val enabled: Boolean, + ) : AnalyticsEvent( + category = "feature", + action = if (enabled) "enabled" else "disabled", + label = featureName, + properties = + mapOf( + "feature_name" to featureName, + "enabled" to enabled, + ), + ) + + // ==================== ERROR TRACKING ==================== + + data class ErrorOccurred( + val errorCategory: String, // "network", "permission", "upload", "auth", "storage", "unknown" + val screenName: String, + val backendType: String? = null, + ) : AnalyticsEvent( + category = "error", + action = errorCategory, + label = screenName, + properties = + mapOf( + "error_category" to errorCategory, + "screen_name" to screenName, + "backend_type" to (backendType ?: "none"), + ), + ) + + // ==================== SESSION TRACKING ==================== + + data class SessionStarted( + val isFirstSession: Boolean = false, + val sessionNumber: Int = 1, + ) : AnalyticsEvent( + category = "session", + action = "started", + value = sessionNumber.toDouble(), + properties = + mapOf( + "is_first_session" to isFirstSession, + "session_number" to sessionNumber, + ), + ) + + data class SessionEnded( + val lastScreen: String, + val durationSeconds: Long, + val uploadsCompleted: Int = 0, + val uploadsFailed: Int = 0, + ) : AnalyticsEvent( + category = "session", + action = "ended", + label = lastScreen, + value = durationSeconds.toDouble(), + properties = + mapOf( + "last_screen" to lastScreen, + "duration_seconds" to durationSeconds, + "uploads_completed" to uploadsCompleted, + "uploads_failed" to uploadsFailed, + ), + ) + + // ==================== USAGE STATISTICS ==================== + + data class DailyUsageStats( + val totalUploads: Int, + val totalUploadSizeMB: Long, + val successRate: Float, + val averageUploadTimeSec: Long, + val mostUsedBackend: String, + ) : AnalyticsEvent( + category = "usage", + action = "daily_stats", + properties = + mapOf( + "total_uploads" to totalUploads, + "total_upload_size_mb" to totalUploadSizeMB, + "success_rate" to successRate, + "average_upload_time_sec" to averageUploadTimeSec, + "most_used_backend" to mostUsedBackend, + ), + ) + + // ==================== UPLOAD BATCH TRACKING ==================== + + data class UploadBatchStarted( + val count: Int, + val totalSizeMB: Long, + ) : AnalyticsEvent( + category = "upload", + action = "batch_started", + value = count.toDouble(), + properties = + mapOf( + "count" to count, + "total_size_mb" to totalSizeMB, + ), + ) + + data class UploadBatchCompleted( + val count: Int, + val successCount: Int, + val failedCount: Int, + val durationSeconds: Long, + ) : AnalyticsEvent( + category = "upload", + action = "batch_completed", + value = durationSeconds.toDouble(), + properties = + mapOf( + "count" to count, + "success_count" to successCount, + "failed_count" to failedCount, + "duration_seconds" to durationSeconds, + "success_rate" to if (count > 0) (successCount.toFloat() / count * 100).toInt() else 0, + ), + ) + + data class UploadCancelled( + val backendType: String, + val fileType: String, + val reason: String, + ) : AnalyticsEvent( + category = "upload", + action = "cancelled", + label = backendType, + properties = + mapOf( + "backend_type" to backendType, + "file_type" to fileType, + "reason" to reason, + ), + ) + + data class UploadNetworkError( + val reason: String, // "no_network", "wifi_required", "connection_lost" + ) : AnalyticsEvent( + category = "upload", + action = "network_error", + label = reason, + properties = mapOf("reason" to reason), + ) + + // ==================== ENGAGEMENT TRACKING ==================== + + class ReviewPromptShown : + AnalyticsEvent( + category = "engagement", + action = "review_prompt_shown", + ) + + class ReviewPromptCompleted : + AnalyticsEvent( + category = "engagement", + action = "review_prompt_completed", + ) + + data class ReviewPromptError( + val errorCode: Int, + ) : AnalyticsEvent( + category = "engagement", + action = "review_prompt_error", + value = errorCode.toDouble(), + properties = mapOf("error_code" to errorCode), + ) +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsManager.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsManager.kt new file mode 100644 index 000000000..d374033c8 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsManager.kt @@ -0,0 +1,285 @@ +package net.opendasharchive.openarchive.core.analytics + +import android.content.Context +import net.opendasharchive.openarchive.BuildConfig +import net.opendasharchive.openarchive.core.analytics.providers.CleanInsightsProvider +import net.opendasharchive.openarchive.core.analytics.providers.FirebaseProvider +import net.opendasharchive.openarchive.core.analytics.providers.MixpanelProvider +import net.opendasharchive.openarchive.core.logger.AppLogger + +/** + * Unified Analytics Manager - Facade Pattern + * + * Dispatches analytics events to multiple providers: + * - CleanInsights (privacy-focused, GDPR-compliant) + * - Mixpanel (detailed analytics) + * - Firebase (Google Analytics) + * + * GDPR-Compliant: All events are sanitized and contain NO PII + * + * Usage: + * ``` + * AnalyticsManager.initialize(context) + * AnalyticsManager.trackEvent(AnalyticsEvent.AppOpened(isFirstLaunch = true, appVersion = "1.0")) + * ``` + */ +object AnalyticsManager { + + private val providers = mutableListOf() + private var isInitialized = false + + /** + * Initialize all analytics providers + * Call this once in Application.onCreate() + * + * NOTE: Analytics is disabled in DEBUG builds to keep production data clean + */ + fun initialize(context: Context) { + if (isInitialized) return + + // Skip analytics in debug builds to avoid polluting production data + if (BuildConfig.DEBUG) { + AppLogger.d("AnalyticsManager: Analytics DISABLED in DEBUG mode") + isInitialized = false + return + } + + try { + // Add all providers + providers.add(CleanInsightsProvider(context.applicationContext)) + providers.add(MixpanelProvider(context.applicationContext)) + providers.add(FirebaseProvider(context.applicationContext)) + + // Initialize each provider + providers.forEach { provider -> + try { + provider.initialize() + AppLogger.d("Analytics: ${provider.getProviderName()} initialized") + } catch (e: Exception) { + AppLogger.e("Failed to initialize ${provider.getProviderName()}", e) + } + } + + isInitialized = true + AppLogger.d("AnalyticsManager initialized with ${providers.size} providers") + } catch (e: Exception) { + AppLogger.e("Failed to initialize AnalyticsManager", e) + } + } + + /** + * Track an analytics event across all providers + * @param event The event to track + */ + fun trackEvent(event: AnalyticsEvent) { + // Skip if not initialized (includes DEBUG builds) + if (!isInitialized) return + + providers.forEach { provider -> + try { + provider.trackEvent(event) + } catch (e: Exception) { + AppLogger.e("Failed to track event in ${provider.getProviderName()}", e) + } + } + } + + /** + * Set user properties across all providers + * GDPR-Compliant: Only use aggregated, non-identifying properties + * Examples: app_version, device_type, install_date + */ + fun setUserProperty(key: String, value: Any) { + if (!isInitialized) return + + providers.forEach { provider -> + try { + provider.setUserProperty(key, value) + } catch (e: Exception) { + AppLogger.e("Failed to set user property in ${provider.getProviderName()}", e) + } + } + } + + /** + * Persist/flush analytics data to servers + * Call this when app goes to background + */ + fun persist() { + if (!isInitialized) return + + providers.forEach { provider -> + try { + provider.persist() + } catch (e: Exception) { + AppLogger.e("Failed to persist in ${provider.getProviderName()}", e) + } + } + } + + // ==================== CONVENIENCE METHODS ==================== + + /** + * Track screen view with time spent + */ + fun trackScreenView(screenName: String, timeSpentSeconds: Long? = null, previousScreen: String? = null) { + trackEvent( + AnalyticsEvent.ScreenViewed( + screenName = screenName, + timeSpentSeconds = timeSpentSeconds, + previousScreen = previousScreen + ) + ) + } + + /** + * Track navigation between screens + */ + fun trackNavigation(fromScreen: String, toScreen: String, trigger: String? = null) { + trackEvent( + AnalyticsEvent.NavigationAction( + fromScreen = fromScreen, + toScreen = toScreen, + trigger = trigger + ) + ) + } + + /** + * Track backend configuration + */ + fun trackBackendConfigured(backendType: String, isNew: Boolean = true) { + trackEvent( + AnalyticsEvent.BackendConfigured( + backendType = backendType, + isNew = isNew + ) + ) + } + + /** + * Track upload started + */ + fun trackUploadStarted(backendType: String, fileType: String, fileSizeKB: Long) { + trackEvent( + AnalyticsEvent.UploadStarted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB + ) + ) + } + + /** + * Track upload completed + */ + fun trackUploadCompleted( + backendType: String, + fileType: String, + fileSizeKB: Long, + durationSeconds: Long, + uploadSpeedKBps: Long? = null + ) { + trackEvent( + AnalyticsEvent.UploadCompleted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB, + durationSeconds = durationSeconds, + uploadSpeedKBps = uploadSpeedKBps + ) + ) + } + + /** + * Track upload failed + */ + fun trackUploadFailed( + backendType: String, + fileType: String, + errorCategory: String, + fileSizeKB: Long? = null + ) { + trackEvent( + AnalyticsEvent.UploadFailed( + backendType = backendType, + fileType = fileType, + errorCategory = errorCategory, + fileSizeKB = fileSizeKB + ) + ) + } + + /** + * Track feature toggle + */ + fun trackFeatureToggled(featureName: String, enabled: Boolean) { + trackEvent( + AnalyticsEvent.FeatureToggled( + featureName = featureName, + enabled = enabled + ) + ) + } + + /** + * Track error + */ + fun trackError(errorCategory: String, screenName: String, backendType: String? = null) { + trackEvent( + AnalyticsEvent.ErrorOccurred( + errorCategory = errorCategory, + screenName = screenName, + backendType = backendType + ) + ) + } + + /** + * Track app lifecycle events + */ + fun trackAppOpened(isFirstLaunch: Boolean, appVersion: String) { + trackEvent( + AnalyticsEvent.AppOpened( + isFirstLaunch = isFirstLaunch, + appVersion = appVersion + ) + ) + } + + fun trackAppClosed(sessionDurationSeconds: Long) { + trackEvent( + AnalyticsEvent.AppClosed( + sessionDurationSeconds = sessionDurationSeconds + ) + ) + } + + /** + * Track session events + */ + fun trackSessionStarted(isFirstSession: Boolean, sessionNumber: Int) { + trackEvent( + AnalyticsEvent.SessionStarted( + isFirstSession = isFirstSession, + sessionNumber = sessionNumber + ) + ) + } + + fun trackSessionEnded( + lastScreen: String, + durationSeconds: Long, + uploadsCompleted: Int = 0, + uploadsFailed: Int = 0 + ) { + trackEvent( + AnalyticsEvent.SessionEnded( + lastScreen = lastScreen, + durationSeconds = durationSeconds, + uploadsCompleted = uploadsCompleted, + uploadsFailed = uploadsFailed + ) + ) + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsProvider.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsProvider.kt new file mode 100644 index 000000000..4d5314c3e --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsProvider.kt @@ -0,0 +1,35 @@ +package net.opendasharchive.openarchive.core.analytics + +/** + * Interface for analytics providers + * Implements Strategy Pattern for multiple analytics backends + */ +interface AnalyticsProvider { + + /** + * Initialize the analytics provider + */ + fun initialize() + + /** + * Track an analytics event + * @param event The event to track + */ + fun trackEvent(event: AnalyticsEvent) + + /** + * Set user properties (GDPR-compliant, aggregated only) + * Examples: app_version, device_type, install_date + */ + fun setUserProperty(key: String, value: Any) + + /** + * Persist/flush analytics data + */ + fun persist() + + /** + * Get provider name for debugging + */ + fun getProviderName(): String +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/CleanInsightsProvider.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/CleanInsightsProvider.kt new file mode 100644 index 000000000..a60277483 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/CleanInsightsProvider.kt @@ -0,0 +1,47 @@ +package net.opendasharchive.openarchive.core.analytics.providers + +import android.content.Context +import net.opendasharchive.openarchive.CleanInsightsManager +import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent +import net.opendasharchive.openarchive.core.analytics.AnalyticsProvider + +/** + * CleanInsights implementation of AnalyticsProvider + * Privacy-focused, GDPR-compliant by design + */ +class CleanInsightsProvider( + private val context: Context +) : AnalyticsProvider { + + override fun initialize() { + CleanInsightsManager.init(context) + } + + override fun trackEvent(event: AnalyticsEvent) { + // Only track if user has consented + if (!CleanInsightsManager.hasConsent()) return + + CleanInsightsManager.measureEvent( + category = event.category, + action = event.action, + name = event.label, + value = event.value + ) + + // Track screen views separately for visit tracking + if (event is AnalyticsEvent.ScreenViewed) { + CleanInsightsManager.measureView(event.screenName) + } + } + + override fun setUserProperty(key: String, value: Any) { + // CleanInsights doesn't support user properties (privacy-focused) + // Aggregate data only + } + + override fun persist() { + CleanInsightsManager.persist() + } + + override fun getProviderName(): String = "CleanInsights" +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/FirebaseProvider.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/FirebaseProvider.kt new file mode 100644 index 000000000..2e8b346f7 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/FirebaseProvider.kt @@ -0,0 +1,128 @@ +package net.opendasharchive.openarchive.core.analytics.providers + +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent +import net.opendasharchive.openarchive.core.analytics.AnalyticsProvider + +/** + * Firebase Analytics implementation of AnalyticsProvider + * With automatic PII sanitization + */ +class FirebaseProvider( + private val context: Context +) : AnalyticsProvider { + + private var firebaseAnalytics: FirebaseAnalytics? = null + + override fun initialize() { + firebaseAnalytics = FirebaseAnalytics.getInstance(context) + } + + override fun trackEvent(event: AnalyticsEvent) { + val eventName = sanitizeFirebaseEventName("${event.category}_${event.action}") + + // Convert properties to Bundle with PII sanitization + val bundle = Bundle().apply { + event.properties.forEach { (key, value) -> + val sanitizedKey = sanitizeFirebaseParameterName(key) + when (value) { + is String -> putString(sanitizedKey, sanitizePII(value)) + is Int -> putInt(sanitizedKey, value) + is Long -> putLong(sanitizedKey, value) + is Double -> putDouble(sanitizedKey, value) + is Float -> putDouble(sanitizedKey, value.toDouble()) + is Boolean -> putBoolean(sanitizedKey, value) + else -> putString(sanitizedKey, value.toString()) + } + } + + // Add event label if present + event.label?.let { + putString("label", sanitizePII(it)) + } + + // Add event value if present + event.value?.let { + putDouble("value", it) + } + } + + firebaseAnalytics?.logEvent(eventName, bundle) + } + + override fun setUserProperty(key: String, value: Any) { + val sanitizedKey = sanitizeFirebaseParameterName(key) + val sanitizedValue = when (value) { + is String -> sanitizePII(value) + else -> value.toString() + } + + firebaseAnalytics?.setUserProperty(sanitizedKey, sanitizedValue) + } + + override fun persist() { + // Firebase automatically persists events + } + + override fun getProviderName(): String = "Firebase" + + /** + * Sanitize event name to conform to Firebase requirements + * Max 40 characters, alphanumeric + underscore only + */ + private fun sanitizeFirebaseEventName(name: String): String { + return name + .replace(Regex("[^a-zA-Z0-9_]"), "_") + .take(40) + .lowercase() + } + + /** + * Sanitize parameter name to conform to Firebase requirements + * Max 40 characters, alphanumeric + underscore only + */ + private fun sanitizeFirebaseParameterName(name: String): String { + return name + .replace(Regex("[^a-zA-Z0-9_]"), "_") + .take(40) + .lowercase() + } + + /** + * Sanitizes personally identifiable information (PII) from strings + * GDPR-compliant: removes file paths, URLs, emails, usernames, IP addresses + */ + private fun sanitizePII(input: String): String { + var sanitized = input + + // Firebase has a 100-character limit for parameter values + if (sanitized.length > 100) { + sanitized = sanitized.take(97) + "..." + } + + // Remove file paths + sanitized = sanitized.replace(Regex("/[\\w/.-]+"), "[FILE_PATH]") + + // Remove URLs + sanitized = sanitized.replace(Regex("https?://[\\w.-]+(/[\\w.-]*)*"), "[URL]") + + // Remove email addresses + sanitized = sanitized.replace(Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"), "[EMAIL]") + + // Remove IP addresses + sanitized = sanitized.replace(Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"), "[IP]") + + // Remove potential usernames + sanitized = sanitized.replace(Regex("(?i)(user|username|login|account)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + // Remove potential passwords + sanitized = sanitized.replace(Regex("(?i)(password|passwd|pwd|pass)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + // Remove potential tokens/keys + sanitized = sanitized.replace(Regex("(?i)(token|key|secret|api[-_]?key)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + return sanitized + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/MixpanelProvider.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/MixpanelProvider.kt new file mode 100644 index 000000000..738e82d4a --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/MixpanelProvider.kt @@ -0,0 +1,97 @@ +package net.opendasharchive.openarchive.core.analytics.providers + +import android.content.Context +import com.mixpanel.android.mpmetrics.MixpanelAPI +import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent +import net.opendasharchive.openarchive.core.analytics.AnalyticsProvider +import org.json.JSONObject + +/** + * Mixpanel implementation of AnalyticsProvider + * With automatic PII sanitization + */ +class MixpanelProvider( + private val context: Context +) : AnalyticsProvider { + + private var mixpanel: MixpanelAPI? = null + + override fun initialize() { + val token = context.getString(R.string.mixpanel_key) + mixpanel = MixpanelAPI.getInstance(context, token, false) + } + + override fun trackEvent(event: AnalyticsEvent) { + val eventName = "${event.category}_${event.action}" + + // Convert properties to JSONObject with PII sanitization + val properties = JSONObject() + + event.properties.forEach { (key, value) -> + val sanitizedValue = when (value) { + is String -> sanitizePII(value) + else -> value + } + properties.put(key, sanitizedValue) + } + + // Add event label if present + event.label?.let { + properties.put("label", sanitizePII(it)) + } + + // Add event value if present + event.value?.let { + properties.put("value", it) + } + + mixpanel?.track(eventName, properties) + } + + override fun setUserProperty(key: String, value: Any) { + val sanitizedValue = when (value) { + is String -> sanitizePII(value) + else -> value + } + + mixpanel?.people?.set(key, sanitizedValue) + } + + override fun persist() { + mixpanel?.flush() + } + + override fun getProviderName(): String = "Mixpanel" + + /** + * Sanitizes personally identifiable information (PII) from strings + * GDPR-compliant: removes file paths, URLs, emails, usernames, IP addresses + */ + private fun sanitizePII(input: String): String { + var sanitized = input + + // Remove file paths (e.g., /storage/emulated/0/..., /data/user/...) + sanitized = sanitized.replace(Regex("/[\\w/.-]+"), "[FILE_PATH]") + + // Remove URLs (http://, https://) + sanitized = sanitized.replace(Regex("https?://[\\w.-]+(/[\\w.-]*)*"), "[URL]") + + // Remove email addresses + sanitized = sanitized.replace(Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"), "[EMAIL]") + + // Remove IP addresses + sanitized = sanitized.replace(Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"), "[IP_ADDRESS]") + + // Remove potential usernames + sanitized = sanitized.replace(Regex("(?i)(user|username|login|account)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + // Remove potential passwords + sanitized = sanitized.replace(Regex("(?i)(password|passwd|pwd|pass)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + // Remove potential tokens/keys + sanitized = sanitized.replace(Regex("(?i)(token|key|secret|api[-_]?key)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + return sanitized + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt b/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt index 6058e6219..7b0fd61d4 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt @@ -1,44 +1,68 @@ package net.opendasharchive.openarchive.core.logger import android.content.Context +import com.google.firebase.crashlytics.FirebaseCrashlytics +import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager import net.opendasharchive.openarchive.core.logger.AppLogger.init -import net.opendasharchive.openarchive.util.Analytics import timber.log.Timber /** * A utility object for centralized logging in Android applications. - * This object simplifies the logging process by integrating with the Timber and - * AndroidRemoteDebugger libraries. + * Integrates with Timber, Firebase Crashlytics, and Analytics for comprehensive error tracking. * - * Logs will only be generated if the [init] method is called. The class supports - * different log levels and allows for conditional remote debugging. - * The name of the class from which AppLogger was called will automatically be set as a tag + * Features: + * - Logs to Logcat via Timber + * - Sends errors to Firebase Crashlytics with breadcrumbs + * - Tracks critical errors in Analytics (GDPR-compliant) + * - User journey breadcrumbs for crash analysis */ object AppLogger { + private var crashlytics: FirebaseCrashlytics? = null + private var currentScreen: String = "Unknown" + /** - * Initializes the logger by planting a Timber DebugTree and optionally - * initializing the AndroidRemoteDebugger. - * - * @param context The context used to initialize the AndroidRemoteDebugger. - * @param initDebugger A boolean flag to determine whether AndroidRemoteDebugger - * should be initialized. + * Initializes the logger + * @param context The context used to initialize services + * @param initDebugger Legacy parameter (unused) */ fun init(context: Context, initDebugger: Boolean) { - Timber.plant(DebugTreeWithTag()) + + try { + crashlytics = FirebaseCrashlytics.getInstance() + } catch (e: Exception) { + Timber.e(e, "Failed to initialize Firebase Crashlytics") + } + } + + /** + * Set current screen for breadcrumb context + */ + fun setCurrentScreen(screenName: String) { + currentScreen = screenName + crashlytics?.log("Screen: $screenName") + } + + /** + * Add breadcrumb for user journey tracking + * This helps understand what user was doing before a crash + */ + fun breadcrumb(action: String, details: String? = null) { + val breadcrumb = if (details != null) "$action: $details" else action + crashlytics?.log("[$currentScreen] $breadcrumb") } // Info Level Logging + // REMOVED Analytics.log() - info logs are for debugging, not analytics fun i(message: String, vararg args: Any?) { Timber.i(message + args.joinToString(" ")) - Analytics.log(Analytics.APP_LOG, mapOf("info" to message + args.joinToString(" "))) } fun i(message: String, throwable: Throwable) { Timber.i(throwable, message) - Analytics.log(Analytics.APP_LOG, mapOf("info" to message)) } // Debug Level Logging @@ -51,19 +75,74 @@ object AppLogger { } // Error Level Logging + /** + * Log error message only (no exception) + * This is for minor errors that don't require stack traces + */ fun e(message: String, vararg args: Any?) { - Timber.e(message + args.joinToString(" ")) - Analytics.log(Analytics.APP_ERROR, mapOf("error" to message + args.joinToString(" "))) + val fullMessage = message + args.joinToString(" ") + Timber.e(fullMessage) + + // Add breadcrumb for context + crashlytics?.log("ERROR: $fullMessage") } + /** + * Log error with exception + * Sends to Firebase Crashlytics + Analytics + */ fun e(message: String, throwable: Throwable) { Timber.e(throwable, message) - Analytics.log(Analytics.APP_ERROR, mapOf("error" to message)) + + // Send to Firebase Crashlytics (non-fatal exception) + crashlytics?.let { + it.log("[$currentScreen] ERROR: $message") + it.recordException(throwable) + } + + // Track in Analytics (GDPR-safe - only error category, no PII) + val errorCategory = categorizeError(throwable) + AnalyticsManager.trackError( + errorCategory = errorCategory, + screenName = currentScreen + ) } + /** + * Log exception only + * Sends to Firebase Crashlytics + Analytics + */ fun e(throwable: Throwable) { Timber.e(throwable) - Analytics.log(Analytics.APP_ERROR, mapOf("error" to throwable.message)) + + // Send to Firebase Crashlytics (non-fatal exception) + crashlytics?.let { + it.log("[$currentScreen] EXCEPTION: ${throwable.message}") + it.recordException(throwable) + } + + // Track in Analytics (GDPR-safe) + val errorCategory = categorizeError(throwable) + AnalyticsManager.trackError( + errorCategory = errorCategory, + screenName = currentScreen + ) + } + + /** + * Categorize error for analytics (GDPR-safe) + */ + private fun categorizeError(throwable: Throwable): String { + return when (throwable) { + is java.io.IOException -> "network" + is java.io.FileNotFoundException -> "file_not_found" + is SecurityException -> "permission" + is IllegalStateException -> "illegal_state" + is IllegalArgumentException -> "illegal_argument" + is NullPointerException -> "null_pointer" + is OutOfMemoryError -> "out_of_memory" + else -> throwable::class.simpleName ?: "unknown" + } } // Warning Level Logging diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt index d36f872ed..286a46b58 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt @@ -13,11 +13,22 @@ import net.opendasharchive.openarchive.features.core.dialog.DialogHost import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager import net.opendasharchive.openarchive.util.Prefs import org.koin.androidx.viewmodel.ext.android.viewModel +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.util.SessionManager abstract class BaseActivity : AppCompatActivity() { val dialogManager: DialogStateManager by viewModel() + // Screen tracking variables + private var screenStartTime: Long = 0 + private var previousScreen: String = "" + + protected open fun getScreenName(): String { + return this::class.simpleName ?: "UnknownActivity" + } + companion object { const val EXTRA_DATA_SPACE = "space" } @@ -72,6 +83,34 @@ abstract class BaseActivity : AppCompatActivity() { // updating this in onResume (previously was in onCreate) to make sure setting changes get // applied instantly instead after the next app restart updateScreenshotPrevention() + + // Track screen view + screenStartTime = System.currentTimeMillis() + val screenName = getScreenName() + + // Set current screen for error tracking breadcrumbs + AppLogger.setCurrentScreen(screenName) + + AnalyticsManager.trackScreenView(screenName, null, previousScreen) + SessionManager.setCurrentScreen(screenName) + + // Track navigation if coming from another screen + if (previousScreen.isNotEmpty() && previousScreen != screenName) { + AnalyticsManager.trackNavigation(previousScreen, screenName) + } + } + + override fun onPause() { + super.onPause() + + // Track time spent on screen + val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 + val screenName = getScreenName() + + AnalyticsManager.trackScreenView(screenName, timeSpent, previousScreen) + + // Store as previous screen for navigation tracking + previousScreen = screenName } fun updateScreenshotPrevention() { diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt index fa4f865ec..a60315483 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt @@ -15,6 +15,9 @@ import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupViewModel import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoViewModel import net.opendasharchive.openarchive.util.FullScreenOverlayManager +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager +import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.util.SessionManager abstract class BaseFragment : Fragment(), ToolbarConfigurable { @@ -23,6 +26,14 @@ abstract class BaseFragment : Fragment(), ToolbarConfigurable { val snowbirdGroupViewModel: SnowbirdGroupViewModel by androidViewModel() val snowbirdRepoViewModel: SnowbirdRepoViewModel by androidViewModel() + // Screen tracking variables + private var screenStartTime: Long = 0 + private var previousScreen: String = "" + + protected open fun getScreenName(): String { + return this::class.simpleName ?: "UnknownFragment" + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ensureComposeDialogHost() @@ -58,5 +69,33 @@ abstract class BaseFragment : Fragment(), ToolbarConfigurable { override fun onResume() { super.onResume() (activity as? SpaceSetupActivity)?.updateToolbarFromFragment(this) + + // Track screen view + screenStartTime = System.currentTimeMillis() + val screenName = getScreenName() + + // Set current screen for error tracking breadcrumbs + AppLogger.setCurrentScreen(screenName) + + AnalyticsManager.trackScreenView(screenName, null, previousScreen) + SessionManager.setCurrentScreen(screenName) + + // Track navigation if coming from another screen + if (previousScreen.isNotEmpty() && previousScreen != screenName) { + AnalyticsManager.trackNavigation(previousScreen, screenName) + } + } + + override fun onPause() { + super.onPause() + + // Track time spent on screen + val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 + val screenName = getScreenName() + + AnalyticsManager.trackScreenView(screenName, timeSpent, previousScreen) + + // Store as previous screen for navigation tracking + previousScreen = screenName } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt index 84ea461ac..69f0ebd4d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt @@ -4,6 +4,7 @@ import com.google.gson.Gson import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive import net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository.InternetArchiveRepository +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager class InternetArchiveLoginUseCase( private val repository: InternetArchiveRepository, @@ -22,10 +23,20 @@ class InternetArchiveLoginUseCase( // TODO: use local data source for database space.metaData = gson.toJson(response.meta) + + // Check if this is a new backend or existing one + val isNewBackend = space.id == null || space.id == 0L + space.save() Space.current = space + // Track backend configuration + AnalyticsManager.trackBackendConfigured( + backendType = Space.Type.INTERNET_ARCHIVE.friendlyName, + isNew = isNewBackend + ) + response } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt index e4f796802..7ea17220d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt @@ -19,6 +19,7 @@ import net.opendasharchive.openarchive.features.onboarding.StartDestination import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository import net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupActivity import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme import net.opendasharchive.openarchive.util.extensions.getVersionName @@ -104,7 +105,16 @@ class SettingsFragment : PreferenceFragmentCompat() { false } - findPreference(Prefs.PROHIBIT_SCREENSHOTS)?.setOnPreferenceClickListener { _ -> + findPreference(Prefs.PROHIBIT_SCREENSHOTS)?.setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + Prefs.prohibitScreenshots = enabled + + // Add breadcrumb for crash analysis + AppLogger.breadcrumb("Feature Toggled", "screenshot_prevention: $enabled") + + // Track feature toggle + AnalyticsManager.trackFeatureToggled("screenshot_prevention", enabled) + if (activity is BaseActivity) { // make sure this gets settings change gets applied instantly // (all other activities rely on the hook in BaseActivity.onResume()) @@ -135,7 +145,15 @@ class SettingsFragment : PreferenceFragmentCompat() { } findPreference(Prefs.USE_TOR)?.setOnPreferenceChangeListener { _, newValue -> - Prefs.useTor = (newValue as Boolean) + val enabled = newValue as Boolean + Prefs.useTor = enabled + + // Add breadcrumb for crash analysis + AppLogger.breadcrumb("Feature Toggled", "tor: $enabled") + + // Track feature toggle + AnalyticsManager.trackFeatureToggled("tor", enabled) + //torViewModel.updateTorServiceState() true } @@ -189,12 +207,28 @@ class SettingsFragment : PreferenceFragmentCompat() { Theme.set(theme) // Save the preference Prefs.putBoolean(getString(R.string.pref_key_use_dark_mode), useDarkMode) + + // Add breadcrumb for crash analysis + AppLogger.breadcrumb("Feature Toggled", "dark_mode: $useDarkMode") + + // Track feature toggle + AnalyticsManager.trackFeatureToggled("dark_mode", useDarkMode) + true } findPreference(Prefs.UPLOAD_WIFI_ONLY)?.setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + Prefs.uploadWifiOnly = enabled + + // Add breadcrumb for crash analysis + AppLogger.breadcrumb("Feature Toggled", "wifi_only_upload: $enabled") + + // Track feature toggle + AnalyticsManager.trackFeatureToggled("wifi_only_upload", enabled) + val intent = - Intent(Prefs.UPLOAD_WIFI_ONLY).apply { putExtra("value", newValue as Boolean) } + Intent(Prefs.UPLOAD_WIFI_ONLY).apply { putExtra("value", enabled) } // Replace with shared ViewModel + LiveData // LocalBroadcastManager.getInstance(requireContext()).sendBroadcast(intent) true diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt index 98ad07900..f51015bdb 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -14,6 +14,9 @@ import net.opendasharchive.openarchive.services.internetarchive.IaConduit import net.opendasharchive.openarchive.services.webdav.WebDavConduit import net.opendasharchive.openarchive.upload.BroadcastManager import net.opendasharchive.openarchive.util.Prefs +import net.opendasharchive.openarchive.util.SessionManager +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager +import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent import okhttp3.HttpUrl import org.witness.proofmode.storage.DefaultStorageProvider import java.io.File @@ -33,6 +36,44 @@ abstract class Conduit( protected var mCancelled = false + // Track upload start time for analytics + private var uploadStartTime: Long = System.currentTimeMillis() + + init { + // Track upload started + trackUploadStarted() + } + + private fun trackUploadStarted() { + uploadStartTime = System.currentTimeMillis() + + val backendType = mMedia.space?.tType?.friendlyName ?: "Unknown" + val fileSizeKB = mMedia.contentLength / 1024 + val fileType = getFileType(mMedia.mimeType) + + // Add breadcrumb for crash analysis + AppLogger.breadcrumb("Upload Started", "$fileType to $backendType (${fileSizeKB}KB)") + + AnalyticsManager.trackUploadStarted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB + ) + } + + private fun getFileType(mimeType: String?): String { + return when { + mimeType == null -> "unknown" + mimeType.startsWith("image/") -> "image" + mimeType.startsWith("video/") -> "video" + mimeType.startsWith("audio/") -> "audio" + mimeType.startsWith("application/pdf") -> "document" + mimeType.startsWith("application/") -> "document" + mimeType.startsWith("text/") -> "text" + else -> "other" + } + } + /** * Gives a SiteController a chance to add metadata to the intent resulting from the ChooseAccounts process * that gets passed to each SiteController during publishing @@ -72,6 +113,30 @@ abstract class Conduit( mMedia.sStatus = Media.Status.Uploaded mMedia.save() AppLogger.i("media item ${mMedia.id} is uploaded and saved") + + // Track successful upload analytics + val uploadDuration = (System.currentTimeMillis() - uploadStartTime) / 1000 + val fileSizeKB = mMedia.contentLength / 1024 + val backendType = mMedia.space?.tType?.friendlyName ?: "Unknown" + val fileType = getFileType(mMedia.mimeType) + + // Calculate upload speed + val uploadSpeedKBps = if (uploadDuration > 0) fileSizeKB / uploadDuration else 0 + + // Add breadcrumb for crash analysis + AppLogger.breadcrumb("Upload Completed", "$fileType (${uploadDuration}s, ${uploadSpeedKBps}KB/s)") + + AnalyticsManager.trackUploadCompleted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB, + durationSeconds = uploadDuration, + uploadSpeedKBps = uploadSpeedKBps + ) + + // Track in session + SessionManager.trackUploadCompleted() + BroadcastManager.postSuccess( context = mContext, collectionId = mMedia.collectionId, @@ -80,9 +145,24 @@ abstract class Conduit( } fun jobFailed(exception: Throwable) { - // If an upload was cancelled, ignore the error. + // If an upload was cancelled, track and return. if (mCancelled) { AppLogger.i("Upload cancelled", exception) + + // Add breadcrumb + val backendType = mMedia.space?.tType?.friendlyName ?: "Unknown" + val fileType = getFileType(mMedia.mimeType) + AppLogger.breadcrumb("Upload Cancelled", "$fileType to $backendType") + + // Track upload cancellation + AnalyticsManager.trackEvent( + AnalyticsEvent.UploadCancelled( + backendType = backendType, + fileType = fileType, + reason = "user_cancelled" + ) + ) + return } @@ -93,6 +173,36 @@ abstract class Conduit( AppLogger.e(exception) + // Track failed upload analytics (GDPR-compliant - no PII) + val backendType = mMedia.space?.tType?.friendlyName ?: "Unknown" + val fileType = getFileType(mMedia.mimeType) + val fileSizeKB = mMedia.contentLength / 1024 + + // Categorize error + val errorCategory = when (exception) { + is IOException -> "network" + is FileNotFoundException -> "file_not_found" + is SecurityException -> "permission" + else -> "unknown" + } + + AnalyticsManager.trackUploadFailed( + backendType = backendType, + fileType = fileType, + errorCategory = errorCategory, + fileSizeKB = fileSizeKB + ) + + // Track in session + SessionManager.trackUploadFailed() + + // Track error for drop-off analysis + AnalyticsManager.trackError( + errorCategory = errorCategory, + screenName = "Upload", + backendType = backendType + ) + BroadcastManager.postChange( context = mContext, collectionId = mMedia.collectionId, diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt index 1b340c239..43f340a92 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt @@ -15,6 +15,7 @@ import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager import java.io.IOException class WebDavViewModel( @@ -290,9 +291,19 @@ class WebDavViewModel( viewModelScope.launch { try { repository.testConnection(space) + + // Check if this is a new backend or existing one + val isNewBackend = space.id == null || space.id == 0L + space.save() Space.current = space + // Track backend configuration + AnalyticsManager.trackBackendConfigured( + backendType = Space.Type.WEBDAV.friendlyName, + isNew = isNewBackend + ) + _uiState.update { it.copy(isLoading = false) } _events.send(WebDavEvent.NavigateToLicenseSetup(space.id)) } catch (e: IOException) { diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt index 020544974..eea5d4379 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.launch import net.opendasharchive.openarchive.CleanInsightsManager import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager +import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.features.main.MainActivity import net.opendasharchive.openarchive.services.Conduit @@ -94,16 +96,44 @@ class UploadService : JobService() { if (mRunning) return completed() mRunning = true + val batchStartTime = System.currentTimeMillis() AppLogger.i("upload started") if (!shouldUpload()) { mRunning = false AppLogger.i("no network, upload stopped") + // Track network error + AnalyticsManager.trackEvent( + AnalyticsEvent.UploadNetworkError( + reason = if (Prefs.uploadWifiOnly) "wifi_required" else "no_network" + ) + ) return completed() } // Get all media items that are set into queued state. var results = emptyList() + var successCount = 0 + var failedCount = 0 + var totalCount = 0 + + // Get initial batch + val initialBatch = Media.getByStatus( + listOf(Media.Status.Queued, Media.Status.Uploading), + Media.ORDER_PRIORITY + ) + + if (initialBatch.isNotEmpty()) { + // Track batch started + val batchSize = initialBatch.size + val totalSizeMB = initialBatch.sumOf { it.contentLength } / (1024 * 1024) + AnalyticsManager.trackEvent( + AnalyticsEvent.UploadBatchStarted( + count = batchSize, + totalSizeMB = totalSizeMB + ) + ) + } while (mKeepUploading && Media.getByStatus( @@ -116,6 +146,7 @@ class UploadService : JobService() { val datePublish = Date() for (media in results) { + totalCount++ if (media.sStatus != Media.Status.Uploading) { media.uploadDate = datePublish media.progress = 0 // Should we reset this? @@ -134,13 +165,19 @@ class UploadService : JobService() { try { AppLogger.i("Started uploading", media) - upload(media) + val uploadSuccess = upload(media) + if (uploadSuccess) { + successCount++ + } else { + failedCount++ + } } catch (ioe: IOException) { AppLogger.e(ioe) media.statusMessage = "error in uploading media: " + ioe.message media.sStatus = Media.Status.Error media.save() + failedCount++ } if (!mKeepUploading) break // Time to end this. @@ -149,6 +186,19 @@ class UploadService : JobService() { AppLogger.i("Uploads completed") + // Track batch completed (if any uploads were attempted) + if (totalCount > 0) { + val batchDuration = (System.currentTimeMillis() - batchStartTime) / 1000 + AnalyticsManager.trackEvent( + AnalyticsEvent.UploadBatchCompleted( + count = totalCount, + successCount = successCount, + failedCount = failedCount, + durationSeconds = batchDuration + ) + ) + } + mRunning = false completed() } diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt b/app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt index 1347d9feb..3035e1f8f 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt @@ -20,10 +20,48 @@ object Analytics { } fun log(eventName: String, props: Map? = null) { - val jsonObject = props?.let { strongProps -> + val sanitizedProps = props?.mapValues { (_, value) -> + when (value) { + is String -> sanitizePII(value) + else -> value + } + } + + val jsonObject = sanitizedProps?.let { strongProps -> JSONObject(strongProps) } mixpanel?.track(eventName, jsonObject) } + + /** + * Sanitizes personally identifiable information (PII) from strings + * GDPR-compliant: removes file paths, URLs, emails, usernames, IP addresses + */ + private fun sanitizePII(input: String): String { + var sanitized = input + + // Remove file paths (e.g., /storage/emulated/0/..., /data/user/...) + sanitized = sanitized.replace(Regex("/[\\w/.-]+"), "[FILE_PATH]") + + // Remove URLs (http://, https://) + sanitized = sanitized.replace(Regex("https?://[\\w.-]+(/[\\w.-]*)*"), "[URL]") + + // Remove email addresses + sanitized = sanitized.replace(Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"), "[EMAIL]") + + // Remove IP addresses + sanitized = sanitized.replace(Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"), "[IP_ADDRESS]") + + // Remove potential usernames (strings after common patterns like user=, username=, login=) + sanitized = sanitized.replace(Regex("(?i)(user|username|login|account)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + // Remove potential passwords + sanitized = sanitized.replace(Regex("(?i)(password|passwd|pwd|pass)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + // Remove potential tokens/keys + sanitized = sanitized.replace(Regex("(?i)(token|key|secret|api[-_]?key)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + return sanitized + } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt index fd32a6db3..3f508aeb1 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt @@ -7,6 +7,8 @@ import com.google.android.play.core.review.ReviewInfo import com.google.android.play.core.review.ReviewManager import com.google.android.play.core.review.ReviewManagerFactory import net.opendasharchive.openarchive.core.logger.AppLogger +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager +import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent object InAppReviewHelper { // Keys for our Prefs helper: @@ -39,9 +41,13 @@ object InAppReviewHelper { if (task.isSuccessful) { reviewInfo = task.result AppLogger.d("InAppReview", "ReviewInfo obtained successfully.") + // Track review prompt shown + AnalyticsManager.trackEvent(AnalyticsEvent.ReviewPromptShown()) } else { (task.exception as? ReviewException)?.let { ex -> AppLogger.e("InAppReview", "Error requesting review flow: ${ex.errorCode}", ex) + // Track review error + AnalyticsManager.trackEvent(AnalyticsEvent.ReviewPromptError(ex.errorCode)) } reviewInfo = null } @@ -78,6 +84,8 @@ object InAppReviewHelper { reviewManager.launchReviewFlow(activity, info) .addOnCompleteListener { AppLogger.d("InAppReview", "Review flow finished.") + // Track review flow completed + AnalyticsManager.trackEvent(AnalyticsEvent.ReviewPromptCompleted()) reviewInfo = null } } ?: run { diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/SessionManager.kt b/app/src/main/java/net/opendasharchive/openarchive/util/SessionManager.kt new file mode 100644 index 000000000..66f364179 --- /dev/null +++ b/app/src/main/java/net/opendasharchive/openarchive/util/SessionManager.kt @@ -0,0 +1,163 @@ +package net.opendasharchive.openarchive.util + +import android.content.Context +import net.opendasharchive.openarchive.BuildConfig +import net.opendasharchive.openarchive.core.analytics.AnalyticsManager + +/** + * Manages user sessions and tracks session analytics + * Provides GDPR-compliant session tracking for user journey analysis + */ +object SessionManager { + + private const val PREF_FIRST_LAUNCH = "first_launch_completed" + private const val PREF_LAST_SCREEN = "last_active_screen" + private const val PREF_SESSION_COUNT = "session_count" + private const val PREF_SESSION_UPLOADS_COMPLETED = "session_uploads_completed" + private const val PREF_SESSION_UPLOADS_FAILED = "session_uploads_failed" + + private var sessionStartTime: Long = 0 + private var currentScreen: String = "" + private var isSessionActive: Boolean = false + private var sessionUploadsCompleted: Int = 0 + private var sessionUploadsFailed: Int = 0 + + /** + * Start a new session + * Called when app is opened or comes to foreground + */ + fun startSession(context: Context) { + if (isSessionActive) return + + sessionStartTime = System.currentTimeMillis() + isSessionActive = true + + // Reset session counters + sessionUploadsCompleted = 0 + sessionUploadsFailed = 0 + + val isFirstLaunch = Prefs.getBoolean(PREF_FIRST_LAUNCH, true) + val sessionCount = Prefs.getInt(PREF_SESSION_COUNT, 0) + 1 + + // Track session start with new AnalyticsManager + AnalyticsManager.trackSessionStarted(isFirstLaunch, sessionCount) + + // Track app opened + AnalyticsManager.trackAppOpened(isFirstLaunch, BuildConfig.VERSION_NAME) + + if (isFirstLaunch) { + Prefs.putBoolean(PREF_FIRST_LAUNCH, false) + } + + // Increment and save session count + Prefs.putInt(PREF_SESSION_COUNT, sessionCount) + } + + /** + * End the current session + * Called when app is closed or goes to background + */ + fun endSession() { + if (!isSessionActive) return + + val sessionDuration = (System.currentTimeMillis() - sessionStartTime) / 1000 + + // Track session end with upload stats + AnalyticsManager.trackSessionEnded( + lastScreen = currentScreen, + durationSeconds = sessionDuration, + uploadsCompleted = sessionUploadsCompleted, + uploadsFailed = sessionUploadsFailed + ) + + // Track app closed + AnalyticsManager.trackAppClosed(sessionDuration) + + // Persist analytics data + AnalyticsManager.persist() + + // Store last screen and upload stats for analysis + Prefs.putString(PREF_LAST_SCREEN, currentScreen) + Prefs.putInt(PREF_SESSION_UPLOADS_COMPLETED, sessionUploadsCompleted) + Prefs.putInt(PREF_SESSION_UPLOADS_FAILED, sessionUploadsFailed) + + isSessionActive = false + } + + /** + * Update the current screen + * @param screenName Name of the current screen + */ + fun setCurrentScreen(screenName: String) { + currentScreen = screenName + } + + /** + * Get the last active screen (useful for crash/uninstall analysis) + */ + fun getLastScreen(): String { + return Prefs.getString(PREF_LAST_SCREEN, "Unknown") ?: "Unknown" + } + + /** + * Get total session count + */ + fun getSessionCount(): Int { + return Prefs.getInt(PREF_SESSION_COUNT, 0) + } + + /** + * Check if this is the first launch + */ + fun isFirstLaunch(): Boolean { + return Prefs.getBoolean(PREF_FIRST_LAUNCH, true) + } + + /** + * Get current session duration in seconds + */ + fun getCurrentSessionDuration(): Long { + if (!isSessionActive) return 0 + return (System.currentTimeMillis() - sessionStartTime) / 1000 + } + + /** + * Track app going to background + */ + fun onBackground() { + if (isSessionActive) { + AnalyticsManager.trackEvent(net.opendasharchive.openarchive.core.analytics.AnalyticsEvent.AppBackgrounded()) + } + } + + /** + * Track app coming to foreground + */ + fun onForeground() { + if (isSessionActive) { + AnalyticsManager.trackEvent(net.opendasharchive.openarchive.core.analytics.AnalyticsEvent.AppForegrounded()) + } + } + + /** + * Track successful upload + */ + fun trackUploadCompleted() { + sessionUploadsCompleted++ + } + + /** + * Track failed upload + */ + fun trackUploadFailed() { + sessionUploadsFailed++ + } + + /** + * Get upload success rate for current session + */ + fun getUploadSuccessRate(): Float { + val total = sessionUploadsCompleted + sessionUploadsFailed + return if (total > 0) sessionUploadsCompleted.toFloat() / total else 0f + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 49ce31653..24449b9c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ detekt-compose = "0.4.28" detekt-rules-compose = "1.4.0" dotsindicator = "5.1.0" espresso-core = "3.5.1" +firebase-analytics = "22.1.2" firebase-crashlytics = "20.0.3" fragment = "1.8.9" google-api-client-android = "1.26.0" @@ -120,6 +121,7 @@ androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifec androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } # AndroidX - Media @@ -157,6 +159,7 @@ detekt-compose = { group = "io.nlopez.compose.rules", name = "detekt", version.r detekt-rules-compose = { group = "ru.kode", name = "detekt-rules-compose", version.ref = "detekt-rules-compose" } # Firebase +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref = "firebase-analytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebase-crashlytics" } # Google - APIs From 97d50e2dd34dba0970e507a9591846efe7222590 Mon Sep 17 00:00:00 2001 From: Prathieshna Vekneswaran Date: Fri, 5 Dec 2025 13:03:06 +0530 Subject: [PATCH 2/2] Refactored analytics into a dedicated, reusable module Extracted all analytics logic, including Mixpanel, Firebase, and CleanInsights providers, into a new `:analytics` module. This modernizes the implementation by using coroutines, dependency injection with Koin, and reactive session tracking with StateFlow. Key changes: - Created a unified `AnalyticsManager` interface and `AnalyticsEvent` sealed interface for type-safe, asynchronous event tracking. - Introduced a `SessionTracker` to manage session lifecycle events reactively. - Replaced the old static `AnalyticsManager` and `SessionManager` with injected, testable components. - Renamed `UploadBatchStarted`/`Completed` events to `UploadSessionStarted`/`Completed` for clarity. - Removed direct analytics dependencies from the `:app` module. --- analytics/.gitignore | 1 + analytics/README.md | 374 ++++++++++++++++ analytics/build.gradle.kts | 69 +++ analytics/consumer-rules.pro | 5 + analytics/proguard-rules.pro | 18 + analytics/src/main/AndroidManifest.xml | 7 + .../src/main/assets/cleaninsights.json | 0 .../analytics/api/AnalyticsEvent.kt | 412 ++++++++++++++++++ .../analytics/api/AnalyticsManager.kt | 145 ++++++ .../analytics/api/AnalyticsManagerImpl.kt | 268 ++++++++++++ .../analytics/api/session/SessionTracker.kt | 97 +++++ .../api/session/SessionTrackerImpl.kt | 166 +++++++ .../analytics/core}/AnalyticsProvider.kt | 16 +- .../analytics/di/AnalyticsModule.kt | 81 ++++ .../cleaninsights/CleanInsightsProvider.kt | 61 +++ .../providers/firebase/FirebaseProvider.kt | 147 +++++++ .../providers/mixpanel}/MixpanelProvider.kt | 53 ++- app/build.gradle.kts | 8 +- .../opendasharchive/openarchive/SaveApp.kt | 73 ++-- .../core/analytics/AnalyticsEvent.kt | 407 ----------------- .../core/analytics/AnalyticsManager.kt | 285 ------------ .../providers/CleanInsightsProvider.kt | 47 -- .../analytics/providers/FirebaseProvider.kt | 128 ------ .../openarchive/core/di/FeaturesModule.kt | 2 +- .../openarchive/core/logger/AppLogger.kt | 41 +- .../openarchive/features/core/BaseActivity.kt | 27 +- .../openarchive/features/core/BaseFragment.kt | 27 +- .../features/internetarchive/Module.kt | 2 +- .../usecase/InternetArchiveLoginUseCase.kt | 5 +- .../openarchive/features/main/MainActivity.kt | 4 +- .../features/settings/SettingsFragment.kt | 25 +- .../openarchive/services/Conduit.kt | 90 ++-- .../services/webdav/WebDavViewModel.kt | 7 +- .../openarchive/upload/UploadService.kt | 30 +- .../openarchive/util/Analytics.kt | 67 --- .../openarchive/util/InAppReviewHelper.kt | 31 +- .../openarchive/util/SessionManager.kt | 163 ------- settings.gradle.kts | 1 + 38 files changed, 2138 insertions(+), 1252 deletions(-) create mode 100644 analytics/.gitignore create mode 100644 analytics/README.md create mode 100644 analytics/build.gradle.kts create mode 100644 analytics/consumer-rules.pro create mode 100644 analytics/proguard-rules.pro create mode 100644 analytics/src/main/AndroidManifest.xml rename {app => analytics}/src/main/assets/cleaninsights.json (100%) create mode 100644 analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsEvent.kt create mode 100644 analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsManager.kt create mode 100644 analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsManagerImpl.kt create mode 100644 analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/session/SessionTracker.kt create mode 100644 analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/session/SessionTrackerImpl.kt rename {app/src/main/java/net/opendasharchive/openarchive/core/analytics => analytics/src/main/java/net/opendasharchive/openarchive/analytics/core}/AnalyticsProvider.kt (55%) create mode 100644 analytics/src/main/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt create mode 100644 analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/cleaninsights/CleanInsightsProvider.kt create mode 100644 analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/firebase/FirebaseProvider.kt rename {app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers => analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel}/MixpanelProvider.kt (65%) delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsEvent.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsManager.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/CleanInsightsProvider.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/FirebaseProvider.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt delete mode 100644 app/src/main/java/net/opendasharchive/openarchive/util/SessionManager.kt diff --git a/analytics/.gitignore b/analytics/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/analytics/.gitignore @@ -0,0 +1 @@ +/build diff --git a/analytics/README.md b/analytics/README.md new file mode 100644 index 000000000..0e5b41e3c --- /dev/null +++ b/analytics/README.md @@ -0,0 +1,374 @@ +# Analytics Module + +Unified analytics tracking module for the Save Android app with GDPR-compliant multi-provider support. + +## Features + +- **Multi-Provider Architecture**: CleanInsights (privacy-focused), Mixpanel (detailed analytics), and Firebase Analytics +- **Automatic PII Sanitization**: GDPR-compliant - automatically removes file paths, URLs, emails, IP addresses, tokens +- **Modern Kotlin**: Coroutines, StateFlow, sealed interfaces +- **Dependency Injection**: Koin-based DI for easy testing and modularity +- **Type-Safe Events**: 30+ predefined event types with compile-time safety +- **Session Tracking**: Reactive session management with upload statistics +- **Debug Mode Support**: Configurable debug flag for Firebase DebugView testing + +## Installation + +### 1. Add Module Dependency + +In your `app/build.gradle.kts`: + +```kotlin +dependencies { + implementation(project(":analytics")) +} +``` + +### 2. Initialize Koin Module + +In your `Application` class: + +```kotlin +class SaveApp : Application() { + override fun onCreate() { + super.onCreate() + + startKoin { + androidContext(this@SaveApp) + modules( + // ... other modules + analyticsModule( + mixpanelToken = getString(R.string.mixpanel_key), + cleanInsightsConsentChecker = { CleanInsightsManager.hasConsent() } + ) + ) + } + + // Initialize analytics asynchronously + val analyticsManager: AnalyticsManager by inject() + lifecycleScope.launch { + analyticsManager.initialize(this@SaveApp) + analyticsManager.setUserProperty("app_version", BuildConfig.VERSION_NAME) + analyticsManager.setUserProperty("device_type", "android") + } + } +} +``` + +### 3. Add CleanInsights Configuration + +Place your `cleaninsights.json` file in `/analytics/src/main/assets/cleaninsights.json`. + +## Usage + +### Basic Event Tracking + +```kotlin +class MainActivity : AppCompatActivity() { + private val analyticsManager: AnalyticsManager by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + analyticsManager.trackEvent( + AnalyticsEvent.ScreenViewed( + screenName = "MainActivity", + previousScreen = "LaunchScreen" + ) + ) + } + } +} +``` + +### Session Tracking + +```kotlin +class SaveApp : Application(), DefaultLifecycleObserver { + private val sessionTracker: SessionTracker by inject() + + override fun onStart(owner: LifecycleOwner) { + lifecycleScope.launch { + sessionTracker.startSession() + } + } + + override fun onStop(owner: LifecycleOwner) { + lifecycleScope.launch { + sessionTracker.endSession() + } + } +} +``` + +### Convenience Methods + +```kotlin +lifecycleScope.launch { + // Screen tracking + analyticsManager.trackScreenView("SettingsScreen") + + // Upload tracking + analyticsManager.trackUploadStarted("Internet Archive", "image", 2048) + analyticsManager.trackUploadCompleted( + backendType = "Internet Archive", + fileType = "image", + fileSizeKB = 2048, + durationSeconds = 12, + uploadSpeedKBps = 170 + ) + + // Feature toggles + analyticsManager.trackFeatureToggled("dark_mode", enabled = true) + + // Error tracking + analyticsManager.trackError("network", "UploadScreen", "Internet Archive") +} +``` + +## Available Events + +### App Lifecycle +- `AppOpened` - App launch with first-launch detection +- `AppClosed` - App closure with session duration +- `AppBackgrounded` - App moved to background +- `AppForegrounded` - App moved to foreground + +### Screen Tracking +- `ScreenViewed` - Screen display with time spent +- `NavigationAction` - Navigation between screens + +### Backend Usage +- `BackendConfigured` - Backend setup/update +- `BackendRemoved` - Backend removal + +### Upload Metrics +- `UploadStarted` - Upload initiated +- `UploadCompleted` - Upload successful +- `UploadFailed` - Upload failed +- `UploadCancelled` - User cancelled upload +- `UploadSessionStarted` - Upload session started (1+ files) +- `UploadSessionCompleted` - Upload session finished +- `UploadNetworkError` - Network-specific errors + +### Media Actions +- `MediaCaptured` - Photo/video captured +- `MediaSelected` - Media selected from gallery +- `MediaDeleted` - Media deleted + +### Feature Usage +- `FeatureToggled` - Feature enabled/disabled + +### Error Tracking +- `ErrorOccurred` - Error with category and screen context + +### Session Tracking +- `SessionStarted` - Session begin +- `SessionEnded` - Session end with upload stats + +### Engagement +- `ReviewPromptShown` - In-app review prompt displayed +- `ReviewPromptCompleted` - Review submitted +- `ReviewPromptError` - Review error occurred + +## Architecture + +``` +AnalyticsManager (Interface) + ↓ +AnalyticsManagerImpl + ↓ + ┌───┼───┐ + ↓ ↓ ↓ + CleanInsights Mixpanel Firebase + Provider Provider Provider + ↓ ↓ ↓ + SDKs (runs in parallel) +``` + +**Key Design Patterns:** +- **Facade Pattern**: `AnalyticsManager` provides unified interface +- **Strategy Pattern**: `AnalyticsProvider` interface for pluggable providers +- **Repository Pattern**: Provider isolation with error handling +- **Observer Pattern**: StateFlow for reactive session state + +## Firebase Analytics Verification + +### 1. Enable Debug Mode + +```bash +adb shell setprop debug.firebase.analytics.app net.opendasharchive.openarchive.debug +``` + +### 2. Open Firebase Console + +Navigate to: Firebase Console → DebugView + +### 3. Verify Events + +Check that events appear with: +- Event names <= 40 characters +- Parameter names <= 40 characters +- Parameter values <= 100 characters +- No PII (file paths, URLs, emails removed) + +### 4. Debug Build Configuration + +The module includes a `ENABLE_ANALYTICS_IN_DEBUG` flag: +- **DEBUG builds**: Analytics enabled by default for testing +- **RELEASE builds**: Analytics enabled + +To disable analytics in DEBUG builds, modify `/analytics/build.gradle.kts`: + +```kotlin +buildTypes { + debug { + buildConfigField("boolean", "ENABLE_ANALYTICS_IN_DEBUG", "false") + } +} +``` + +## GDPR Compliance + +All events are automatically sanitized before being sent to providers: + +**What gets redacted:** +- File paths → `[FILE_PATH]` +- URLs → `[URL]` +- Emails → `[EMAIL]` +- IP addresses → `[IP_ADDRESS]` +- Usernames → `user=[REDACTED]` +- Passwords → `password=[REDACTED]` +- Tokens/Keys → `token=[REDACTED]` + +**Example:** +```kotlin +// Input: "Upload failed: /storage/emulated/0/DCIM/photo.jpg to https://example.com" +// Output: "Upload failed: [FILE_PATH] to [URL]" +``` + +## Testing + +### Unit Testing + +```kotlin +class AnalyticsTest { + private lateinit var manager: AnalyticsManager + private lateinit var fakeProviders: List + + @Before + fun setup() { + fakeProviders = listOf(FakeAnalyticsProvider()) + manager = AnalyticsManagerImpl(fakeProviders) + } + + @Test + fun `trackEvent dispatches to all providers`() = runTest { + val event = AnalyticsEvent.AppOpened( + isFirstLaunch = true, + appVersion = "1.0.0" + ) + + manager.trackEvent(event) + + assert(fakeProviders[0].trackedEvents.contains(event)) + } +} +``` + +## Module Structure + +``` +:analytics/ +├── api/ # Public API +│ ├── AnalyticsManager.kt +│ ├── AnalyticsEvent.kt +│ └── session/ +│ ├── SessionTracker.kt +│ └── SessionTrackerImpl.kt +├── core/ # Core abstractions +│ └── AnalyticsProvider.kt +├── providers/ # Provider implementations +│ ├── cleaninsights/ +│ ├── mixpanel/ +│ └── firebase/ +├── util/ # Utilities +│ └── PiiSanitizer.kt +└── di/ # Dependency injection + └── AnalyticsModule.kt +``` + +## Dependencies + +```kotlin +// Kotlin Coroutines +implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + +// AndroidX Lifecycle +implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0") +implementation("androidx.lifecycle:lifecycle-process:2.10.0") + +// Analytics SDKs +api("com.mixpanel.android:mixpanel-android:8.2.4") +api("org.cleaninsights.sdk:cleaninsights:2.8.0") +api("com.google.firebase:firebase-analytics:22.1.2") + +// Dependency Injection +implementation("io.insert-koin:koin-core:4.2.0-alpha3") +implementation("io.insert-koin:koin-android:4.2.0-alpha3") +``` + +## Migration from Old Analytics + +If migrating from the old object-based `AnalyticsManager`: + +**Old Code:** +```kotlin +AnalyticsManager.trackScreenView("MainActivity") +SessionManager.setCurrentScreen("MainActivity") +``` + +**New Code:** +```kotlin +class MainActivity : AppCompatActivity() { + private val analyticsManager: AnalyticsManager by inject() + private val sessionTracker: SessionTracker by inject() + + override fun onResume() { + lifecycleScope.launch { + analyticsManager.trackScreenView("MainActivity") + } + sessionTracker.setCurrentScreen("MainActivity") + } +} +``` + +## Troubleshooting + +### Events not appearing in Firebase + +1. Check DEBUG flag is enabled in `build.gradle.kts` +2. Verify Firebase DebugView is enabled: `adb shell setprop debug.firebase.analytics.app ` +3. Check `google-services.json` is in the app module +4. Ensure app is in foreground (Firebase batches events in background) + +### CleanInsights not tracking + +1. Verify user has granted consent: `CleanInsightsManager.hasConsent()` +2. Check `cleaninsights.json` exists in `/analytics/src/main/assets/` +3. Ensure campaign ID matches configuration + +### Build errors + +1. Ensure all old analytics imports are removed from app module +2. Clean build: `./gradlew clean :analytics:build` +3. Invalidate caches in Android Studio + +## License + +This module is part of the Save Android app. + +## Support + +For issues or questions, please file an issue on the project's GitHub repository. diff --git a/analytics/build.gradle.kts b/analytics/build.gradle.kts new file mode 100644 index 000000000..e11b231f9 --- /dev/null +++ b/analytics/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) + } +} + +android { + namespace = "net.opendasharchive.openarchive.analytics" + compileSdk = 36 + + defaultConfig { + minSdk = 29 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildFeatures { + buildConfig = true + } + + buildTypes { + debug { + buildConfigField("boolean", "ENABLE_ANALYTICS_IN_DEBUG", "true") + } + release { + isMinifyEnabled = false + buildConfigField("boolean", "ENABLE_ANALYTICS_IN_DEBUG", "false") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + // Kotlin + implementation(libs.kotlinx.coroutines.android) + + // AndroidX + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.process) + + // Analytics SDKs + api(libs.mixpanel) + api(libs.clean.insights) + api(libs.firebase.analytics) + + // Dependency Injection + implementation(libs.koin.core) + implementation(libs.koin.android) + + // Testing (optional) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.runner) +} diff --git a/analytics/consumer-rules.pro b/analytics/consumer-rules.pro new file mode 100644 index 000000000..971c7cded --- /dev/null +++ b/analytics/consumer-rules.pro @@ -0,0 +1,5 @@ +# Consumer ProGuard rules for analytics module + +# Keep analytics API +-keep public class net.opendasharchive.openarchive.analytics.api.** { *; } +-keep public interface net.opendasharchive.openarchive.analytics.api.** { *; } diff --git a/analytics/proguard-rules.pro b/analytics/proguard-rules.pro new file mode 100644 index 000000000..a49fb927c --- /dev/null +++ b/analytics/proguard-rules.pro @@ -0,0 +1,18 @@ +# Add project specific ProGuard rules here. + +# Keep analytics API +-keep public class net.opendasharchive.openarchive.analytics.api.** { *; } +-keep public interface net.opendasharchive.openarchive.analytics.api.** { *; } + +# Keep event classes (used via reflection by providers) +-keep class net.opendasharchive.openarchive.analytics.api.AnalyticsEvent$** { *; } + +# Mixpanel +-dontwarn com.mixpanel.** +-keep class com.mixpanel.android.** { *; } + +# CleanInsights +-keep class org.cleaninsights.sdk.** { *; } + +# Firebase +-keep class com.google.firebase.analytics.** { *; } diff --git a/analytics/src/main/AndroidManifest.xml b/analytics/src/main/AndroidManifest.xml new file mode 100644 index 000000000..4062c3c18 --- /dev/null +++ b/analytics/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/assets/cleaninsights.json b/analytics/src/main/assets/cleaninsights.json similarity index 100% rename from app/src/main/assets/cleaninsights.json rename to analytics/src/main/assets/cleaninsights.json diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsEvent.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsEvent.kt new file mode 100644 index 000000000..c19fb23d0 --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsEvent.kt @@ -0,0 +1,412 @@ +package net.opendasharchive.openarchive.analytics.api + +/** + * Sealed interface representing all analytics events in the application + * GDPR-Compliant: Contains NO PII (personally identifiable information) + * + * Modern implementation using sealed interface for better composition + */ +sealed interface AnalyticsEvent { + val category: String + val action: String + val label: String? + val value: Double? + val properties: Map + + // ==================== APP LIFECYCLE ==================== + + data class AppOpened( + val isFirstLaunch: Boolean = false, + val appVersion: String, + ) : AnalyticsEvent { + override val category = "app" + override val action = "opened" + override val label: String? = null + override val value: Double? = null + override val properties = mapOf( + "is_first_launch" to isFirstLaunch, + "app_version" to appVersion, + ) + } + + data class AppClosed( + val sessionDurationSeconds: Long, + ) : AnalyticsEvent { + override val category = "app" + override val action = "closed" + override val label: String? = null + override val value = sessionDurationSeconds.toDouble() + override val properties: Map = emptyMap() + } + + data object AppBackgrounded : AnalyticsEvent { + override val category = "app" + override val action = "backgrounded" + override val label: String? = null + override val value: Double? = null + override val properties: Map = emptyMap() + } + + data object AppForegrounded : AnalyticsEvent { + override val category = "app" + override val action = "foregrounded" + override val label: String? = null + override val value: Double? = null + override val properties: Map = emptyMap() + } + + // ==================== SCREEN TRACKING ==================== + + data class ScreenViewed( + val screenName: String, + val timeSpentSeconds: Long? = null, + val previousScreen: String? = null, + ) : AnalyticsEvent { + override val category = "screen" + override val action = "viewed" + override val label = screenName + override val value = timeSpentSeconds?.toDouble() + override val properties = mapOf( + "screen_name" to screenName, + "previous_screen" to (previousScreen ?: "none"), + ) + } + + data class NavigationAction( + val fromScreen: String, + val toScreen: String, + val trigger: String? = null, + ) : AnalyticsEvent { + override val category = "navigation" + override val action = "screen_change" + override val label = "$fromScreen -> $toScreen" + override val value: Double? = null + override val properties = mapOf( + "from_screen" to fromScreen, + "to_screen" to toScreen, + "trigger" to (trigger ?: "unknown"), + ) + } + + // ==================== BACKEND USAGE ==================== + + data class BackendConfigured( + val backendType: String, // "Internet Archive", "Private Server", "DWeb Service", "Storacha" + val isNew: Boolean = true, + ) : AnalyticsEvent { + override val category = "backend" + override val action = if (isNew) "configured" else "updated" + override val label = backendType + override val value: Double? = null + override val properties = mapOf( + "backend_type" to backendType, + "is_new" to isNew, + ) + } + + data class BackendRemoved( + val backendType: String, + val reason: String? = null, + ) : AnalyticsEvent { + override val category = "backend" + override val action = "removed" + override val label = backendType + override val value: Double? = null + override val properties = mapOf( + "backend_type" to backendType, + "reason" to (reason ?: "unknown"), + ) + } + + // ==================== UPLOAD METRICS ==================== + + data class UploadStarted( + val backendType: String, + val fileType: String, // "image", "video", "document", "other" + val fileSizeKB: Long, + ) : AnalyticsEvent { + override val category = "upload" + override val action = "started" + override val label = backendType + override val value: Double? = null + override val properties = mapOf( + "backend_type" to backendType, + "file_type" to fileType, + "file_size_kb" to fileSizeKB, + "file_size_category" to getFileSizeCategory(fileSizeKB), + ) + + companion object { + internal fun getFileSizeCategory(sizeKB: Long): String = + when { + sizeKB < 100 -> "tiny" // < 100KB + sizeKB < 1024 -> "small" // < 1MB + sizeKB < 10240 -> "medium" // < 10MB + sizeKB < 102400 -> "large" // < 100MB + else -> "very_large" // >= 100MB + } + } + } + + data class UploadCompleted( + val backendType: String, + val fileType: String, + val fileSizeKB: Long, + val durationSeconds: Long, + val uploadSpeedKBps: Long? = null, + ) : AnalyticsEvent { + override val category = "upload" + override val action = "completed" + override val label = backendType + override val value = durationSeconds.toDouble() + override val properties = mapOf( + "backend_type" to backendType, + "file_type" to fileType, + "file_size_kb" to fileSizeKB, + "duration_seconds" to durationSeconds, + "upload_speed_kbps" to (uploadSpeedKBps ?: 0), + "file_size_category" to UploadStarted.getFileSizeCategory(fileSizeKB), + ) + } + + data class UploadFailed( + val backendType: String, + val fileType: String, + val errorCategory: String, // "network", "permission", "file_not_found", "storage", "unknown" + val fileSizeKB: Long? = null, + ) : AnalyticsEvent { + override val category = "upload" + override val action = "failed" + override val label = backendType + override val value: Double? = null + override val properties = mapOf( + "backend_type" to backendType, + "file_type" to fileType, + "error_category" to errorCategory, + "file_size_kb" to (fileSizeKB ?: 0), + ) + } + + // ==================== MEDIA ACTIONS ==================== + + data class MediaCaptured( + val mediaType: String, // "photo", "video" + val source: String = "camera", + ) : AnalyticsEvent { + override val category = "media" + override val action = "captured" + override val label = mediaType + override val value: Double? = null + override val properties = mapOf( + "media_type" to mediaType, + "source" to source, + ) + } + + data class MediaSelected( + val count: Int, + val source: String, // "gallery", "camera", "files" + val mediaTypes: List = emptyList(), + ) : AnalyticsEvent { + override val category = "media" + override val action = "selected" + override val label = source + override val value = count.toDouble() + override val properties = mapOf( + "count" to count, + "source" to source, + "has_images" to mediaTypes.contains("image"), + "has_videos" to mediaTypes.contains("video"), + "has_documents" to mediaTypes.contains("document"), + ) + } + + data class MediaDeleted( + val count: Int, + ) : AnalyticsEvent { + override val category = "media" + override val action = "deleted" + override val label: String? = null + override val value = count.toDouble() + override val properties = mapOf("count" to count) + } + + // ==================== FEATURE USAGE ==================== + + data class FeatureToggled( + val featureName: String, // "proofmode", "tor", "dark_mode", "wifi_only_upload" + val enabled: Boolean, + ) : AnalyticsEvent { + override val category = "feature" + override val action = if (enabled) "enabled" else "disabled" + override val label = featureName + override val value: Double? = null + override val properties = mapOf( + "feature_name" to featureName, + "enabled" to enabled, + ) + } + + // ==================== ERROR TRACKING ==================== + + data class ErrorOccurred( + val errorCategory: String, // "network", "permission", "upload", "auth", "storage", "unknown" + val screenName: String, + val backendType: String? = null, + ) : AnalyticsEvent { + override val category = "error" + override val action = errorCategory + override val label = screenName + override val value: Double? = null + override val properties = mapOf( + "error_category" to errorCategory, + "screen_name" to screenName, + "backend_type" to (backendType ?: "none"), + ) + } + + // ==================== SESSION TRACKING ==================== + + data class SessionStarted( + val isFirstSession: Boolean = false, + val sessionNumber: Int = 1, + ) : AnalyticsEvent { + override val category = "session" + override val action = "started" + override val label: String? = null + override val value = sessionNumber.toDouble() + override val properties = mapOf( + "is_first_session" to isFirstSession, + "session_number" to sessionNumber, + ) + } + + data class SessionEnded( + val lastScreen: String, + val durationSeconds: Long, + val uploadsCompleted: Int = 0, + val uploadsFailed: Int = 0, + ) : AnalyticsEvent { + override val category = "session" + override val action = "ended" + override val label = lastScreen + override val value = durationSeconds.toDouble() + override val properties = mapOf( + "last_screen" to lastScreen, + "duration_seconds" to durationSeconds, + "uploads_completed" to uploadsCompleted, + "uploads_failed" to uploadsFailed, + ) + } + + // ==================== USAGE STATISTICS ==================== + + data class DailyUsageStats( + val totalUploads: Int, + val totalUploadSizeMB: Long, + val successRate: Float, + val averageUploadTimeSec: Long, + val mostUsedBackend: String, + ) : AnalyticsEvent { + override val category = "usage" + override val action = "daily_stats" + override val label: String? = null + override val value: Double? = null + override val properties = mapOf( + "total_uploads" to totalUploads, + "total_upload_size_mb" to totalUploadSizeMB, + "success_rate" to successRate, + "average_upload_time_sec" to averageUploadTimeSec, + "most_used_backend" to mostUsedBackend, + ) + } + + // ==================== UPLOAD SESSION TRACKING ==================== + + data class UploadSessionStarted( + val count: Int, + val totalSizeMB: Long, + ) : AnalyticsEvent { + override val category = "upload" + override val action = "session_started" + override val label: String? = null + override val value = count.toDouble() + override val properties = mapOf( + "count" to count, + "total_size_mb" to totalSizeMB, + ) + } + + data class UploadSessionCompleted( + val count: Int, + val successCount: Int, + val failedCount: Int, + val durationSeconds: Long, + ) : AnalyticsEvent { + override val category = "upload" + override val action = "session_completed" + override val label: String? = null + override val value = durationSeconds.toDouble() + override val properties = mapOf( + "count" to count, + "success_count" to successCount, + "failed_count" to failedCount, + "duration_seconds" to durationSeconds, + "success_rate" to if (count > 0) (successCount.toFloat() / count * 100).toInt() else 0, + ) + } + + data class UploadCancelled( + val backendType: String, + val fileType: String, + val reason: String, + ) : AnalyticsEvent { + override val category = "upload" + override val action = "cancelled" + override val label = backendType + override val value: Double? = null + override val properties = mapOf( + "backend_type" to backendType, + "file_type" to fileType, + "reason" to reason, + ) + } + + data class UploadNetworkError( + val reason: String, // "no_network", "wifi_required", "connection_lost" + ) : AnalyticsEvent { + override val category = "upload" + override val action = "network_error" + override val label = reason + override val value: Double? = null + override val properties = mapOf("reason" to reason) + } + + // ==================== ENGAGEMENT TRACKING ==================== + + data object ReviewPromptShown : AnalyticsEvent { + override val category = "engagement" + override val action = "review_prompt_shown" + override val label: String? = null + override val value: Double? = null + override val properties: Map = emptyMap() + } + + data object ReviewPromptCompleted : AnalyticsEvent { + override val category = "engagement" + override val action = "review_prompt_completed" + override val label: String? = null + override val value: Double? = null + override val properties: Map = emptyMap() + } + + data class ReviewPromptError( + val errorCode: Int, + ) : AnalyticsEvent { + override val category = "engagement" + override val action = "review_prompt_error" + override val label: String? = null + override val value = errorCode.toDouble() + override val properties = mapOf("error_code" to errorCode) + } +} diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsManager.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsManager.kt new file mode 100644 index 000000000..b36fecab6 --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsManager.kt @@ -0,0 +1,145 @@ +package net.opendasharchive.openarchive.analytics.api + +import android.content.Context + +/** + * Unified Analytics Manager Interface + * + * Dispatches analytics events to multiple providers: + * - CleanInsights (privacy-focused, GDPR-compliant) + * - Mixpanel (detailed analytics) + * - Firebase (Google Analytics) + * + * GDPR-Compliant: All events are sanitized and contain NO PII + * + * Modern implementation using coroutines for async operations + */ +interface AnalyticsManager { + + /** + * Initialize all analytics providers + * Call this once in Application.onCreate() + */ + suspend fun initialize(context: Context) + + /** + * Track an analytics event across all providers + * @param event The event to track + */ + suspend fun trackEvent(event: AnalyticsEvent) + + /** + * Set user properties across all providers + * GDPR-Compliant: Only use aggregated, non-identifying properties + * Examples: app_version, device_type, install_date + */ + suspend fun setUserProperty(key: String, value: Any) + + /** + * Persist/flush analytics data to servers + * Call this when app goes to background + */ + suspend fun flush() + + // ==================== CONVENIENCE METHODS ==================== + + /** + * Track screen view with time spent + */ + suspend fun trackScreenView( + screenName: String, + timeSpentSeconds: Long? = null, + previousScreen: String? = null + ) + + /** + * Track navigation between screens + */ + suspend fun trackNavigation( + fromScreen: String, + toScreen: String, + trigger: String? = null + ) + + /** + * Track backend configuration + */ + suspend fun trackBackendConfigured( + backendType: String, + isNew: Boolean = true + ) + + /** + * Track upload started + */ + suspend fun trackUploadStarted( + backendType: String, + fileType: String, + fileSizeKB: Long + ) + + /** + * Track upload completed + */ + suspend fun trackUploadCompleted( + backendType: String, + fileType: String, + fileSizeKB: Long, + durationSeconds: Long, + uploadSpeedKBps: Long? = null + ) + + /** + * Track upload failed + */ + suspend fun trackUploadFailed( + backendType: String, + fileType: String, + errorCategory: String, + fileSizeKB: Long? = null + ) + + /** + * Track feature toggle + */ + suspend fun trackFeatureToggled( + featureName: String, + enabled: Boolean + ) + + /** + * Track error + */ + suspend fun trackError( + errorCategory: String, + screenName: String, + backendType: String? = null + ) + + /** + * Track app lifecycle events + */ + suspend fun trackAppOpened( + isFirstLaunch: Boolean, + appVersion: String + ) + + suspend fun trackAppClosed( + sessionDurationSeconds: Long + ) + + /** + * Track session events + */ + suspend fun trackSessionStarted( + isFirstSession: Boolean, + sessionNumber: Int + ) + + suspend fun trackSessionEnded( + lastScreen: String, + durationSeconds: Long, + uploadsCompleted: Int = 0, + uploadsFailed: Int = 0 + ) +} diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsManagerImpl.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsManagerImpl.kt new file mode 100644 index 000000000..1d55bf34c --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/AnalyticsManagerImpl.kt @@ -0,0 +1,268 @@ +package net.opendasharchive.openarchive.analytics.api + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.analytics.BuildConfig +import net.opendasharchive.openarchive.analytics.core.AnalyticsProvider + +/** + * Implementation of AnalyticsManager + * Dispatches events to all providers with proper error isolation + */ +class AnalyticsManagerImpl( + private val providers: List, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) +) : AnalyticsManager { + + private var isInitialized = false + + override suspend fun initialize(context: Context) { + // Log build configuration + Log.d("AnalyticsManager", "BuildConfig.DEBUG = ${BuildConfig.DEBUG}") + Log.d("AnalyticsManager", "BuildConfig.ENABLE_ANALYTICS_IN_DEBUG = ${BuildConfig.ENABLE_ANALYTICS_IN_DEBUG}") + + // Skip analytics if: DEBUG build AND debug analytics not enabled + if (BuildConfig.DEBUG && !BuildConfig.ENABLE_ANALYTICS_IN_DEBUG) { + Log.w("AnalyticsManager", "Analytics DISABLED in DEBUG mode") + isInitialized = false + return + } + + Log.i("AnalyticsManager", "Starting analytics initialization with ${providers.size} providers") + + try { + // Initialize each provider + providers.forEach { provider -> + try { + Log.d("Analytics", "Initializing ${provider.getProviderName()}...") + provider.initialize() + Log.i("Analytics", "✓ ${provider.getProviderName()} initialized successfully") + } catch (e: Exception) { + Log.e("Analytics", "✗ Failed to initialize ${provider.getProviderName()}", e) + } + } + + isInitialized = true + Log.i("AnalyticsManager", "✓ Analytics ENABLED - Initialized with ${providers.size} providers") + } catch (e: Exception) { + Log.e("AnalyticsManager", "Failed to initialize AnalyticsManager", e) + } + } + + override suspend fun trackEvent(event: AnalyticsEvent) { + // Skip if not initialized (includes DEBUG builds) + if (!isInitialized) { + Log.w("AnalyticsManager", "Event NOT tracked (not initialized): ${event.category}_${event.action}") + return + } + + Log.d("AnalyticsManager", "Tracking event: ${event.category}_${event.action}") + + providers.forEach { provider -> + scope.launch { + try { + provider.trackEvent(event) + Log.d("Analytics", "Event sent to ${provider.getProviderName()}: ${event.category}_${event.action}") + } catch (e: Exception) { + Log.e("Analytics", "Failed to track event in ${provider.getProviderName()}", e) + } + } + } + } + + override suspend fun setUserProperty(key: String, value: Any) { + if (!isInitialized) return + + providers.forEach { provider -> + scope.launch { + try { + provider.setUserProperty(key, value) + } catch (e: Exception) { + Log.e("Analytics", "Failed to set user property in ${provider.getProviderName()}", e) + } + } + } + } + + override suspend fun flush() { + if (!isInitialized) return + + providers.forEach { provider -> + try { + provider.flush() + } catch (e: Exception) { + Log.e("Analytics", "Failed to flush in ${provider.getProviderName()}", e) + } + } + } + + // ==================== CONVENIENCE METHODS ==================== + + override suspend fun trackScreenView( + screenName: String, + timeSpentSeconds: Long?, + previousScreen: String? + ) { + trackEvent( + AnalyticsEvent.ScreenViewed( + screenName = screenName, + timeSpentSeconds = timeSpentSeconds, + previousScreen = previousScreen + ) + ) + } + + override suspend fun trackNavigation( + fromScreen: String, + toScreen: String, + trigger: String? + ) { + trackEvent( + AnalyticsEvent.NavigationAction( + fromScreen = fromScreen, + toScreen = toScreen, + trigger = trigger + ) + ) + } + + override suspend fun trackBackendConfigured( + backendType: String, + isNew: Boolean + ) { + trackEvent( + AnalyticsEvent.BackendConfigured( + backendType = backendType, + isNew = isNew + ) + ) + } + + override suspend fun trackUploadStarted( + backendType: String, + fileType: String, + fileSizeKB: Long + ) { + trackEvent( + AnalyticsEvent.UploadStarted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB + ) + ) + } + + override suspend fun trackUploadCompleted( + backendType: String, + fileType: String, + fileSizeKB: Long, + durationSeconds: Long, + uploadSpeedKBps: Long? + ) { + trackEvent( + AnalyticsEvent.UploadCompleted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB, + durationSeconds = durationSeconds, + uploadSpeedKBps = uploadSpeedKBps + ) + ) + } + + override suspend fun trackUploadFailed( + backendType: String, + fileType: String, + errorCategory: String, + fileSizeKB: Long? + ) { + trackEvent( + AnalyticsEvent.UploadFailed( + backendType = backendType, + fileType = fileType, + errorCategory = errorCategory, + fileSizeKB = fileSizeKB + ) + ) + } + + override suspend fun trackFeatureToggled( + featureName: String, + enabled: Boolean + ) { + trackEvent( + AnalyticsEvent.FeatureToggled( + featureName = featureName, + enabled = enabled + ) + ) + } + + override suspend fun trackError( + errorCategory: String, + screenName: String, + backendType: String? + ) { + trackEvent( + AnalyticsEvent.ErrorOccurred( + errorCategory = errorCategory, + screenName = screenName, + backendType = backendType + ) + ) + } + + override suspend fun trackAppOpened( + isFirstLaunch: Boolean, + appVersion: String + ) { + trackEvent( + AnalyticsEvent.AppOpened( + isFirstLaunch = isFirstLaunch, + appVersion = appVersion + ) + ) + } + + override suspend fun trackAppClosed( + sessionDurationSeconds: Long + ) { + trackEvent( + AnalyticsEvent.AppClosed( + sessionDurationSeconds = sessionDurationSeconds + ) + ) + } + + override suspend fun trackSessionStarted( + isFirstSession: Boolean, + sessionNumber: Int + ) { + trackEvent( + AnalyticsEvent.SessionStarted( + isFirstSession = isFirstSession, + sessionNumber = sessionNumber + ) + ) + } + + override suspend fun trackSessionEnded( + lastScreen: String, + durationSeconds: Long, + uploadsCompleted: Int, + uploadsFailed: Int + ) { + trackEvent( + AnalyticsEvent.SessionEnded( + lastScreen = lastScreen, + durationSeconds = durationSeconds, + uploadsCompleted = uploadsCompleted, + uploadsFailed = uploadsFailed + ) + ) + } +} diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/session/SessionTracker.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/session/SessionTracker.kt new file mode 100644 index 000000000..dbf36a280 --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/session/SessionTracker.kt @@ -0,0 +1,97 @@ +package net.opendasharchive.openarchive.analytics.api.session + +import kotlinx.coroutines.flow.StateFlow + +/** + * Session tracking interface with reactive StateFlow support + * Modern implementation using StateFlow for lifecycle-aware session management + */ +interface SessionTracker { + + /** + * Reactive session state + */ + val sessionState: StateFlow + + /** + * Reactive current screen tracking + */ + val currentScreen: StateFlow + + /** + * Start a new session + * Called when app is opened or comes to foreground + */ + suspend fun startSession() + + /** + * End the current session + * Called when app is closed or goes to background + */ + suspend fun endSession() + + /** + * Update the current screen + */ + fun setCurrentScreen(screenName: String) + + /** + * Track successful upload + */ + fun trackUploadCompleted() + + /** + * Track failed upload + */ + fun trackUploadFailed() + + /** + * Get upload success rate for current session + */ + fun getUploadSuccessRate(): Float + + /** + * Check if this is the first launch + */ + fun isFirstLaunch(): Boolean + + /** + * Get total session count + */ + fun getSessionCount(): Int + + /** + * Get current session duration in seconds + */ + fun getCurrentSessionDuration(): Long + + /** + * Track app going to background + */ + suspend fun onBackground() + + /** + * Track app coming to foreground + */ + suspend fun onForeground() +} + +/** + * Sealed interface representing session states + */ +sealed interface SessionState { + /** + * Session is not active + */ + data object Idle : SessionState + + /** + * Session is active + */ + data class Active( + val sessionNumber: Int, + val startTime: Long, + val uploadsCompleted: Int = 0, + val uploadsFailed: Int = 0 + ) : SessionState +} diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/session/SessionTrackerImpl.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/session/SessionTrackerImpl.kt new file mode 100644 index 000000000..1aa450180 --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/api/session/SessionTrackerImpl.kt @@ -0,0 +1,166 @@ +package net.opendasharchive.openarchive.analytics.api.session + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager + +/** + * Implementation of SessionTracker using StateFlow for reactive session management + */ +class SessionTrackerImpl( + private val analyticsManager: AnalyticsManager, + context: Context +) : SessionTracker { + + private val prefs: SharedPreferences = context.getSharedPreferences("analytics_session", Context.MODE_PRIVATE) + + private val _sessionState = MutableStateFlow(SessionState.Idle) + override val sessionState: StateFlow = _sessionState.asStateFlow() + + private val _currentScreen = MutableStateFlow("") + override val currentScreen: StateFlow = _currentScreen.asStateFlow() + + private var sessionStartTime: Long = 0 + private var sessionUploadsCompleted: Int = 0 + private var sessionUploadsFailed: Int = 0 + + override suspend fun startSession() { + val currentState = _sessionState.value + if (currentState is SessionState.Active) return + + sessionStartTime = System.currentTimeMillis() + sessionUploadsCompleted = 0 + sessionUploadsFailed = 0 + + val isFirstLaunch = prefs.getBoolean(PREF_FIRST_LAUNCH, true) + val sessionCount = prefs.getInt(PREF_SESSION_COUNT, 0) + 1 + + _sessionState.value = SessionState.Active( + sessionNumber = sessionCount, + startTime = sessionStartTime, + uploadsCompleted = 0, + uploadsFailed = 0 + ) + + // Track session start with new AnalyticsManager + analyticsManager.trackSessionStarted(isFirstLaunch, sessionCount) + + // Track app opened + val appVersion = prefs.getString(PREF_APP_VERSION, "unknown") ?: "unknown" + analyticsManager.trackAppOpened(isFirstLaunch, appVersion) + + if (isFirstLaunch) { + prefs.edit().putBoolean(PREF_FIRST_LAUNCH, false).apply() + } + + // Increment and save session count + prefs.edit().putInt(PREF_SESSION_COUNT, sessionCount).apply() + } + + override suspend fun endSession() { + val currentState = _sessionState.value + if (currentState !is SessionState.Active) return + + val sessionDuration = (System.currentTimeMillis() - sessionStartTime) / 1000 + + // Track session end with upload stats + analyticsManager.trackSessionEnded( + lastScreen = _currentScreen.value, + durationSeconds = sessionDuration, + uploadsCompleted = sessionUploadsCompleted, + uploadsFailed = sessionUploadsFailed + ) + + // Track app closed + analyticsManager.trackAppClosed(sessionDuration) + + // Persist analytics data + analyticsManager.flush() + + // Store last screen and upload stats for analysis + prefs.edit() + .putString(PREF_LAST_SCREEN, _currentScreen.value) + .putInt(PREF_SESSION_UPLOADS_COMPLETED, sessionUploadsCompleted) + .putInt(PREF_SESSION_UPLOADS_FAILED, sessionUploadsFailed) + .apply() + + _sessionState.value = SessionState.Idle + } + + override fun setCurrentScreen(screenName: String) { + _currentScreen.value = screenName + } + + override fun trackUploadCompleted() { + sessionUploadsCompleted++ + val currentState = _sessionState.value + if (currentState is SessionState.Active) { + _sessionState.value = currentState.copy(uploadsCompleted = sessionUploadsCompleted) + } + } + + override fun trackUploadFailed() { + sessionUploadsFailed++ + val currentState = _sessionState.value + if (currentState is SessionState.Active) { + _sessionState.value = currentState.copy(uploadsFailed = sessionUploadsFailed) + } + } + + override fun getUploadSuccessRate(): Float { + val total = sessionUploadsCompleted + sessionUploadsFailed + return if (total > 0) sessionUploadsCompleted.toFloat() / total else 0f + } + + override fun isFirstLaunch(): Boolean { + return prefs.getBoolean(PREF_FIRST_LAUNCH, true) + } + + override fun getSessionCount(): Int { + return prefs.getInt(PREF_SESSION_COUNT, 0) + } + + override fun getCurrentSessionDuration(): Long { + val currentState = _sessionState.value + return if (currentState is SessionState.Active) { + (System.currentTimeMillis() - sessionStartTime) / 1000 + } else { + 0 + } + } + + override suspend fun onBackground() { + val currentState = _sessionState.value + if (currentState is SessionState.Active) { + analyticsManager.trackEvent(AnalyticsEvent.AppBackgrounded) + } + } + + override suspend fun onForeground() { + val currentState = _sessionState.value + if (currentState is SessionState.Active) { + analyticsManager.trackEvent(AnalyticsEvent.AppForegrounded) + } + } + + /** + * Set app version for tracking + * Should be called during initialization + */ + fun setAppVersion(version: String) { + prefs.edit().putString(PREF_APP_VERSION, version).apply() + } + + companion object { + private const val PREF_FIRST_LAUNCH = "first_launch_completed" + private const val PREF_LAST_SCREEN = "last_active_screen" + private const val PREF_SESSION_COUNT = "session_count" + private const val PREF_SESSION_UPLOADS_COMPLETED = "session_uploads_completed" + private const val PREF_SESSION_UPLOADS_FAILED = "session_uploads_failed" + private const val PREF_APP_VERSION = "app_version" + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsProvider.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/core/AnalyticsProvider.kt similarity index 55% rename from app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsProvider.kt rename to analytics/src/main/java/net/opendasharchive/openarchive/analytics/core/AnalyticsProvider.kt index 4d5314c3e..99ac2ef55 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsProvider.kt +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/core/AnalyticsProvider.kt @@ -1,35 +1,41 @@ -package net.opendasharchive.openarchive.core.analytics +package net.opendasharchive.openarchive.analytics.core + +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent /** * Interface for analytics providers * Implements Strategy Pattern for multiple analytics backends + * + * Modern implementation using coroutines for async operations */ interface AnalyticsProvider { /** * Initialize the analytics provider + * Suspends to allow async initialization */ - fun initialize() + suspend fun initialize() /** * Track an analytics event * @param event The event to track */ - fun trackEvent(event: AnalyticsEvent) + suspend fun trackEvent(event: AnalyticsEvent) /** * Set user properties (GDPR-compliant, aggregated only) * Examples: app_version, device_type, install_date */ - fun setUserProperty(key: String, value: Any) + suspend fun setUserProperty(key: String, value: Any) /** * Persist/flush analytics data */ - fun persist() + suspend fun flush() /** * Get provider name for debugging + * Not a suspend function as it's synchronous */ fun getProviderName(): String } diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt new file mode 100644 index 000000000..9f7eac48c --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/di/AnalyticsModule.kt @@ -0,0 +1,81 @@ +package net.opendasharchive.openarchive.analytics.di + +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.AnalyticsManagerImpl +import net.opendasharchive.openarchive.analytics.api.session.SessionTracker +import net.opendasharchive.openarchive.analytics.api.session.SessionTrackerImpl +import net.opendasharchive.openarchive.analytics.core.AnalyticsProvider +import net.opendasharchive.openarchive.analytics.providers.cleaninsights.CleanInsightsProvider +import net.opendasharchive.openarchive.analytics.providers.firebase.FirebaseProvider +import net.opendasharchive.openarchive.analytics.providers.mixpanel.MixpanelProvider +import org.koin.android.ext.koin.androidContext +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +/** + * Koin dependency injection module for analytics + * + * Provides: + * - Analytics providers (Mixpanel, Firebase, CleanInsights) + * - AnalyticsManager (unified interface) + * - SessionTracker (reactive session management) + * + * Usage in app module: + * ```kotlin + * startKoin { + * modules( + * analyticsModule( + * mixpanelToken = getString(R.string.mixpanel_key), + * cleanInsightsConsentChecker = { CleanInsightsManager.hasConsent() } + * ) + * ) + * } + * ``` + */ +fun analyticsModule( + mixpanelToken: String, + cleanInsightsConsentChecker: () -> Boolean +) = module { + + // Providers - Each provider is a singleton + single(qualifier = org.koin.core.qualifier.named("mixpanel")) { + MixpanelProvider( + context = androidContext(), + token = mixpanelToken + ) + } + + single(qualifier = org.koin.core.qualifier.named("firebase")) { + FirebaseProvider( + context = androidContext() + ) + } + + single(qualifier = org.koin.core.qualifier.named("cleaninsights")) { + CleanInsightsProvider( + context = androidContext(), + campaignId = "main", + consentChecker = cleanInsightsConsentChecker + ) + } + + // AnalyticsManager - Unified interface for all providers + single { + AnalyticsManagerImpl( + providers = listOf( + get(qualifier = org.koin.core.qualifier.named("mixpanel")), + get(qualifier = org.koin.core.qualifier.named("firebase")), + get(qualifier = org.koin.core.qualifier.named("cleaninsights")) + ) + ) + } + + // SessionTracker - Reactive session management + single { + SessionTrackerImpl( + analyticsManager = get(), + context = androidContext() + ) + } +} diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/cleaninsights/CleanInsightsProvider.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/cleaninsights/CleanInsightsProvider.kt new file mode 100644 index 000000000..ab777cdfa --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/cleaninsights/CleanInsightsProvider.kt @@ -0,0 +1,61 @@ +package net.opendasharchive.openarchive.analytics.providers.cleaninsights + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.core.AnalyticsProvider +import org.cleaninsights.sdk.CleanInsights + +/** + * CleanInsights implementation of AnalyticsProvider + * Privacy-focused, GDPR-compliant by design + */ +class CleanInsightsProvider( + private val context: Context, + private val campaignId: String = "main", + private val consentChecker: () -> Boolean +) : AnalyticsProvider { + + private var cleanInsights: CleanInsights? = null + + override suspend fun initialize() { + withContext(Dispatchers.IO) { + val config = context.assets.open("cleaninsights.json").reader().use { it.readText() } + cleanInsights = CleanInsights(config, context.filesDir) + } + } + + override suspend fun trackEvent(event: AnalyticsEvent) { + withContext(Dispatchers.IO) { + // Only track if user has consented + if (!consentChecker()) return@withContext + + cleanInsights?.measureEvent( + category = event.category, + action = event.action, + campaignId = campaignId, + name = event.label, + value = event.value + ) + + // Track screen views separately for visit tracking + if (event is AnalyticsEvent.ScreenViewed) { + cleanInsights?.measureVisit(listOf(event.screenName), campaignId) + } + } + } + + override suspend fun setUserProperty(key: String, value: Any) { + // CleanInsights doesn't support user properties (privacy-focused) + // Aggregate data only + } + + override suspend fun flush() { + withContext(Dispatchers.IO) { + cleanInsights?.persist() + } + } + + override fun getProviderName(): String = "CleanInsights" +} diff --git a/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/firebase/FirebaseProvider.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/firebase/FirebaseProvider.kt new file mode 100644 index 000000000..999d9e2bd --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/firebase/FirebaseProvider.kt @@ -0,0 +1,147 @@ +package net.opendasharchive.openarchive.analytics.providers.firebase + +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.core.AnalyticsProvider + +/** + * Firebase Analytics implementation of AnalyticsProvider + * With automatic PII sanitization and coroutine support + */ +class FirebaseProvider( + private val context: Context +) : AnalyticsProvider { + + private var firebaseAnalytics: FirebaseAnalytics? = null + + override suspend fun initialize() { + withContext(Dispatchers.IO) { + try { + firebaseAnalytics = FirebaseAnalytics.getInstance(context) + android.util.Log.i("FirebaseProvider", "Firebase Analytics instance created successfully") + } catch (e: Exception) { + android.util.Log.e("FirebaseProvider", "Failed to get Firebase Analytics instance", e) + throw e + } + } + } + + override suspend fun trackEvent(event: AnalyticsEvent) { + withContext(Dispatchers.IO) { + val eventName = sanitizeFirebaseEventName("${event.category}_${event.action}") + + // Convert properties to Bundle with PII sanitization + val bundle = Bundle().apply { + event.properties.forEach { (key, value) -> + val sanitizedKey = sanitizeFirebaseParameterName(key) + when (value) { + is String -> putString(sanitizedKey, sanitizePII(value)) + is Int -> putInt(sanitizedKey, value) + is Long -> putLong(sanitizedKey, value) + is Double -> putDouble(sanitizedKey, value) + is Float -> putDouble(sanitizedKey, value.toDouble()) + is Boolean -> putBoolean(sanitizedKey, value) + else -> putString(sanitizedKey, value.toString()) + } + } + + // Add event label if present + event.label?.let { + putString("label", sanitizePII(it)) + } + + // Add event value if present + event.value?.let { + putDouble("value", it) + } + } + + if (firebaseAnalytics == null) { + android.util.Log.e("FirebaseProvider", "Cannot log event '$eventName' - Firebase Analytics not initialized") + } else { + firebaseAnalytics?.logEvent(eventName, bundle) + android.util.Log.d("FirebaseProvider", "Firebase event logged: $eventName with ${bundle.size()} parameters") + } + } + } + + override suspend fun setUserProperty(key: String, value: Any) { + withContext(Dispatchers.IO) { + val sanitizedKey = sanitizeFirebaseParameterName(key) + val sanitizedValue = when (value) { + is String -> sanitizePII(value) + else -> value.toString() + } + + firebaseAnalytics?.setUserProperty(sanitizedKey, sanitizedValue) + } + } + + override suspend fun flush() { + // Firebase automatically persists events + } + + override fun getProviderName(): String = "Firebase" + + /** + * Sanitize event name to conform to Firebase requirements + * Max 40 characters, alphanumeric + underscore only + */ + private fun sanitizeFirebaseEventName(name: String): String { + return name + .replace(Regex("[^a-zA-Z0-9_]"), "_") + .take(40) + .lowercase() + } + + /** + * Sanitize parameter name to conform to Firebase requirements + * Max 40 characters, alphanumeric + underscore only + */ + private fun sanitizeFirebaseParameterName(name: String): String { + return name + .replace(Regex("[^a-zA-Z0-9_]"), "_") + .take(40) + .lowercase() + } + + /** + * Sanitizes personally identifiable information (PII) from strings + * GDPR-compliant: removes file paths, URLs, emails, usernames, IP addresses + */ + private fun sanitizePII(input: String): String { + var sanitized = input + + // Firebase has a 100-character limit for parameter values + if (sanitized.length > 100) { + sanitized = sanitized.take(97) + "..." + } + + // Remove file paths + sanitized = sanitized.replace(Regex("/[\\w/.-]+"), "[FILE_PATH]") + + // Remove URLs + sanitized = sanitized.replace(Regex("https?://[\\w.-]+(/[\\w.-]*)*"), "[URL]") + + // Remove email addresses + sanitized = sanitized.replace(Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"), "[EMAIL]") + + // Remove IP addresses + sanitized = sanitized.replace(Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"), "[IP]") + + // Remove potential usernames + sanitized = sanitized.replace(Regex("(?i)(user|username|login|account)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + // Remove potential passwords + sanitized = sanitized.replace(Regex("(?i)(password|passwd|pwd|pass)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + // Remove potential tokens/keys + sanitized = sanitized.replace(Regex("(?i)(token|key|secret|api[-_]?key)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") + + return sanitized + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/MixpanelProvider.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt similarity index 65% rename from app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/MixpanelProvider.kt rename to analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt index 738e82d4a..1e9b6b5da 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/MixpanelProvider.kt +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt @@ -1,28 +1,32 @@ -package net.opendasharchive.openarchive.core.analytics.providers +package net.opendasharchive.openarchive.analytics.providers.mixpanel import android.content.Context import com.mixpanel.android.mpmetrics.MixpanelAPI -import net.opendasharchive.openarchive.R -import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent -import net.opendasharchive.openarchive.core.analytics.AnalyticsProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.core.AnalyticsProvider import org.json.JSONObject /** * Mixpanel implementation of AnalyticsProvider - * With automatic PII sanitization + * With automatic PII sanitization and coroutine support */ class MixpanelProvider( - private val context: Context + private val context: Context, + private val token: String ) : AnalyticsProvider { private var mixpanel: MixpanelAPI? = null - override fun initialize() { - val token = context.getString(R.string.mixpanel_key) - mixpanel = MixpanelAPI.getInstance(context, token, false) + override suspend fun initialize() { + withContext(Dispatchers.IO) { + mixpanel = MixpanelAPI.getInstance(context, token, false) + } } - override fun trackEvent(event: AnalyticsEvent) { + override suspend fun trackEvent(event: AnalyticsEvent) { + withContext(Dispatchers.IO) { val eventName = "${event.category}_${event.action}" // Convert properties to JSONObject with PII sanitization @@ -42,24 +46,29 @@ class MixpanelProvider( } // Add event value if present - event.value?.let { - properties.put("value", it) - } + event.value?.let { + properties.put("value", it) + } - mixpanel?.track(eventName, properties) + mixpanel?.track(eventName, properties) + } } - override fun setUserProperty(key: String, value: Any) { - val sanitizedValue = when (value) { - is String -> sanitizePII(value) - else -> value - } + override suspend fun setUserProperty(key: String, value: Any) { + withContext(Dispatchers.IO) { + val sanitizedValue = when (value) { + is String -> sanitizePII(value) + else -> value + } - mixpanel?.people?.set(key, sanitizedValue) + mixpanel?.people?.set(key, sanitizedValue) + } } - override fun persist() { - mixpanel?.flush() + override suspend fun flush() { + withContext(Dispatchers.IO) { + mixpanel?.flush() + } } override fun getProviderName(): String = "Mixpanel" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45f3ae60a..74b7d6f9e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -297,12 +297,10 @@ dependencies { implementation(libs.permissionx) implementation(libs.satyan.sugar) - // Analytics & Tracking - implementation(libs.mixpanel) - implementation(libs.clean.insights) + // Analytics Module + implementation(project(":analytics")) - // Firebase - implementation(libs.firebase.analytics) + // Firebase (Crashlytics only - Analytics is in :analytics module) implementation(libs.firebase.crashlytics) // Testing diff --git a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt index 8aa61dea5..1e5feea49 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/SaveApp.kt @@ -12,6 +12,14 @@ import coil3.SingletonImageLoader import coil3.video.VideoFrameDecoder import com.orm.SugarApp import info.guardianproject.netcipher.proxy.OrbotHelper +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.session.SessionTracker +import net.opendasharchive.openarchive.analytics.di.analyticsModule import net.opendasharchive.openarchive.core.di.coreModule import net.opendasharchive.openarchive.core.di.featuresModule import net.opendasharchive.openarchive.core.di.passcodeModule @@ -19,20 +27,19 @@ import net.opendasharchive.openarchive.core.di.retrofitModule import net.opendasharchive.openarchive.core.di.unixSocketModule import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.features.settings.passcode.PasscodeManager -import net.opendasharchive.openarchive.util.Analytics import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.SessionManager -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger +import org.koin.android.ext.android.inject import org.koin.core.context.startKoin import org.koin.core.logger.Level -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserver { + // Inject analytics dependencies + private val analyticsManager: AnalyticsManager by inject() + private val sessionTracker: SessionTracker by inject() + override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) } @@ -50,13 +57,11 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv // Initialize logging first AppLogger.init(applicationContext, initDebugger = true) - // Initialize legacy Analytics (kept for backwards compatibility) - Analytics.init(this) + registerActivityLifecycleCallbacks(PasscodeManager()) - // Initialize new unified Analytics Manager (CleanInsights + Mixpanel + Firebase) - AnalyticsManager.initialize(this) + Prefs.load(this) - registerActivityLifecycleCallbacks(PasscodeManager()) + // Initialize Koin DI startKoin { androidLogger(Level.DEBUG) androidContext(this@SaveApp) @@ -65,24 +70,38 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv featuresModule, retrofitModule, unixSocketModule, - passcodeModule + passcodeModule, + analyticsModule( + mixpanelToken = getString(R.string.mixpanel_key), + cleanInsightsConsentChecker = { CleanInsightsManager.hasConsent() } + ) ) } - Prefs.load(this) applyTheme() if (Prefs.useTor) initNetCipher() - // Legacy CleanInsightsManager (will be replaced by AnalyticsManager) + // Legacy CleanInsightsManager (kept for backwards compatibility) CleanInsightsManager.init(this) - // Register app lifecycle observer for session tracking - ProcessLifecycleOwner.get().lifecycle.addObserver(this) + // Initialize analytics asynchronously BEFORE registering lifecycle observer + ProcessLifecycleOwner.get().lifecycleScope.launch { + analyticsManager.initialize(this@SaveApp) + + // Set analytics manager for AppLogger + AppLogger.setAnalyticsManager(analyticsManager) + + // Set app version for session tracker + (sessionTracker as? net.opendasharchive.openarchive.analytics.api.session.SessionTrackerImpl)?.setAppVersion(BuildConfig.VERSION_NAME) - // Set user properties (GDPR-compliant) - AnalyticsManager.setUserProperty("app_version", BuildConfig.VERSION_NAME) - AnalyticsManager.setUserProperty("device_type", "android") + // Set user properties (GDPR-compliant) + analyticsManager.setUserProperty("app_version", BuildConfig.VERSION_NAME) + analyticsManager.setUserProperty("device_type", "android") + + // Register app lifecycle observer AFTER analytics is initialized + ProcessLifecycleOwner.get().lifecycle.addObserver(this@SaveApp) + } createSnowbirdNotificationChannel() } @@ -90,18 +109,22 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory, DefaultLifecycleObserv override fun onStart(owner: LifecycleOwner) { super.onStart(owner) // App came to foreground - SessionManager.startSession(this) - SessionManager.onForeground() + ProcessLifecycleOwner.get().lifecycleScope.launch { + sessionTracker.startSession() + sessionTracker.onForeground() + } } override fun onStop(owner: LifecycleOwner) { super.onStop(owner) // App went to background - SessionManager.onBackground() - SessionManager.endSession() + ProcessLifecycleOwner.get().lifecycleScope.launch { + sessionTracker.onBackground() + sessionTracker.endSession() - // Persist analytics data - AnalyticsManager.persist() + // Persist analytics data + analyticsManager.flush() + } } private fun initNetCipher() { diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsEvent.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsEvent.kt deleted file mode 100644 index 94d8398c9..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsEvent.kt +++ /dev/null @@ -1,407 +0,0 @@ -package net.opendasharchive.openarchive.core.analytics - -/** - * Sealed class representing all analytics events in the application - * GDPR-Compliant: Contains NO PII (personally identifiable information) - */ -sealed class AnalyticsEvent( - val category: String, - val action: String, - val label: String? = null, - val value: Double? = null, - val properties: Map = emptyMap(), -) { - // ==================== APP LIFECYCLE ==================== - - data class AppOpened( - val isFirstLaunch: Boolean = false, - val appVersion: String, - ) : AnalyticsEvent( - category = "app", - action = "opened", - properties = - mapOf( - "is_first_launch" to isFirstLaunch, - "app_version" to appVersion, - ), - ) - - data class AppClosed( - val sessionDurationSeconds: Long, - ) : AnalyticsEvent( - category = "app", - action = "closed", - value = sessionDurationSeconds.toDouble(), - ) - - class AppBackgrounded : - AnalyticsEvent( - category = "app", - action = "backgrounded", - ) - - class AppForegrounded : - AnalyticsEvent( - category = "app", - action = "foregrounded", - ) - - // ==================== SCREEN TRACKING ==================== - - data class ScreenViewed( - val screenName: String, - val timeSpentSeconds: Long? = null, - val previousScreen: String? = null, - ) : AnalyticsEvent( - category = "screen", - action = "viewed", - label = screenName, - value = timeSpentSeconds?.toDouble(), - properties = - mapOf( - "screen_name" to screenName, - "previous_screen" to (previousScreen ?: "none"), - ), - ) - - data class NavigationAction( - val fromScreen: String, - val toScreen: String, - val trigger: String? = null, - ) : AnalyticsEvent( - category = "navigation", - action = "screen_change", - label = "$fromScreen -> $toScreen", - properties = - mapOf( - "from_screen" to fromScreen, - "to_screen" to toScreen, - "trigger" to (trigger ?: "unknown"), - ), - ) - - // ==================== BACKEND USAGE ==================== - - data class BackendConfigured( - val backendType: String, // "Internet Archive", "Private Server", "DWeb Service", "Storacha" - val isNew: Boolean = true, - ) : AnalyticsEvent( - category = "backend", - action = if (isNew) "configured" else "updated", - label = backendType, - properties = - mapOf( - "backend_type" to backendType, - "is_new" to isNew, - ), - ) - - data class BackendRemoved( - val backendType: String, - val reason: String? = null, - ) : AnalyticsEvent( - category = "backend", - action = "removed", - label = backendType, - properties = - mapOf( - "backend_type" to backendType, - "reason" to (reason ?: "unknown"), - ), - ) - - // ==================== UPLOAD METRICS ==================== - - data class UploadStarted( - val backendType: String, - val fileType: String, // "image", "video", "document", "other" - val fileSizeKB: Long, - ) : AnalyticsEvent( - category = "upload", - action = "started", - label = backendType, - properties = - mapOf( - "backend_type" to backendType, - "file_type" to fileType, - "file_size_kb" to fileSizeKB, - "file_size_category" to getFileSizeCategory(fileSizeKB), - ), - ) { - companion object { - internal fun getFileSizeCategory(sizeKB: Long): String = - when { - sizeKB < 100 -> "tiny" - - // < 100KB - sizeKB < 1024 -> "small" - - // < 1MB - sizeKB < 10240 -> "medium" - - // < 10MB - sizeKB < 102400 -> "large" - - // < 100MB - else -> "very_large" // >= 100MB - } - } - } - - data class UploadCompleted( - val backendType: String, - val fileType: String, - val fileSizeKB: Long, - val durationSeconds: Long, - val uploadSpeedKBps: Long? = null, - ) : AnalyticsEvent( - category = "upload", - action = "completed", - label = backendType, - value = durationSeconds.toDouble(), - properties = - mapOf( - "backend_type" to backendType, - "file_type" to fileType, - "file_size_kb" to fileSizeKB, - "duration_seconds" to durationSeconds, - "upload_speed_kbps" to (uploadSpeedKBps ?: 0), - "file_size_category" to UploadStarted.getFileSizeCategory(fileSizeKB), - ), - ) - - data class UploadFailed( - val backendType: String, - val fileType: String, - val errorCategory: String, // "network", "permission", "file_not_found", "storage", "unknown" - val fileSizeKB: Long? = null, - ) : AnalyticsEvent( - category = "upload", - action = "failed", - label = backendType, - properties = - mapOf( - "backend_type" to backendType, - "file_type" to fileType, - "error_category" to errorCategory, - "file_size_kb" to (fileSizeKB ?: 0), - ), - ) - - // ==================== MEDIA ACTIONS ==================== - - data class MediaCaptured( - val mediaType: String, // "photo", "video" - val source: String = "camera", - ) : AnalyticsEvent( - category = "media", - action = "captured", - label = mediaType, - properties = - mapOf( - "media_type" to mediaType, - "source" to source, - ), - ) - - data class MediaSelected( - val count: Int, - val source: String, // "gallery", "camera", "files" - val mediaTypes: List = emptyList(), - ) : AnalyticsEvent( - category = "media", - action = "selected", - label = source, - value = count.toDouble(), - properties = - mapOf( - "count" to count, - "source" to source, - "has_images" to mediaTypes.contains("image"), - "has_videos" to mediaTypes.contains("video"), - "has_documents" to mediaTypes.contains("document"), - ), - ) - - data class MediaDeleted( - val count: Int, - ) : AnalyticsEvent( - category = "media", - action = "deleted", - value = count.toDouble(), - properties = mapOf("count" to count), - ) - - // ==================== FEATURE USAGE ==================== - - data class FeatureToggled( - val featureName: String, // "proofmode", "tor", "dark_mode", "wifi_only_upload" - val enabled: Boolean, - ) : AnalyticsEvent( - category = "feature", - action = if (enabled) "enabled" else "disabled", - label = featureName, - properties = - mapOf( - "feature_name" to featureName, - "enabled" to enabled, - ), - ) - - // ==================== ERROR TRACKING ==================== - - data class ErrorOccurred( - val errorCategory: String, // "network", "permission", "upload", "auth", "storage", "unknown" - val screenName: String, - val backendType: String? = null, - ) : AnalyticsEvent( - category = "error", - action = errorCategory, - label = screenName, - properties = - mapOf( - "error_category" to errorCategory, - "screen_name" to screenName, - "backend_type" to (backendType ?: "none"), - ), - ) - - // ==================== SESSION TRACKING ==================== - - data class SessionStarted( - val isFirstSession: Boolean = false, - val sessionNumber: Int = 1, - ) : AnalyticsEvent( - category = "session", - action = "started", - value = sessionNumber.toDouble(), - properties = - mapOf( - "is_first_session" to isFirstSession, - "session_number" to sessionNumber, - ), - ) - - data class SessionEnded( - val lastScreen: String, - val durationSeconds: Long, - val uploadsCompleted: Int = 0, - val uploadsFailed: Int = 0, - ) : AnalyticsEvent( - category = "session", - action = "ended", - label = lastScreen, - value = durationSeconds.toDouble(), - properties = - mapOf( - "last_screen" to lastScreen, - "duration_seconds" to durationSeconds, - "uploads_completed" to uploadsCompleted, - "uploads_failed" to uploadsFailed, - ), - ) - - // ==================== USAGE STATISTICS ==================== - - data class DailyUsageStats( - val totalUploads: Int, - val totalUploadSizeMB: Long, - val successRate: Float, - val averageUploadTimeSec: Long, - val mostUsedBackend: String, - ) : AnalyticsEvent( - category = "usage", - action = "daily_stats", - properties = - mapOf( - "total_uploads" to totalUploads, - "total_upload_size_mb" to totalUploadSizeMB, - "success_rate" to successRate, - "average_upload_time_sec" to averageUploadTimeSec, - "most_used_backend" to mostUsedBackend, - ), - ) - - // ==================== UPLOAD BATCH TRACKING ==================== - - data class UploadBatchStarted( - val count: Int, - val totalSizeMB: Long, - ) : AnalyticsEvent( - category = "upload", - action = "batch_started", - value = count.toDouble(), - properties = - mapOf( - "count" to count, - "total_size_mb" to totalSizeMB, - ), - ) - - data class UploadBatchCompleted( - val count: Int, - val successCount: Int, - val failedCount: Int, - val durationSeconds: Long, - ) : AnalyticsEvent( - category = "upload", - action = "batch_completed", - value = durationSeconds.toDouble(), - properties = - mapOf( - "count" to count, - "success_count" to successCount, - "failed_count" to failedCount, - "duration_seconds" to durationSeconds, - "success_rate" to if (count > 0) (successCount.toFloat() / count * 100).toInt() else 0, - ), - ) - - data class UploadCancelled( - val backendType: String, - val fileType: String, - val reason: String, - ) : AnalyticsEvent( - category = "upload", - action = "cancelled", - label = backendType, - properties = - mapOf( - "backend_type" to backendType, - "file_type" to fileType, - "reason" to reason, - ), - ) - - data class UploadNetworkError( - val reason: String, // "no_network", "wifi_required", "connection_lost" - ) : AnalyticsEvent( - category = "upload", - action = "network_error", - label = reason, - properties = mapOf("reason" to reason), - ) - - // ==================== ENGAGEMENT TRACKING ==================== - - class ReviewPromptShown : - AnalyticsEvent( - category = "engagement", - action = "review_prompt_shown", - ) - - class ReviewPromptCompleted : - AnalyticsEvent( - category = "engagement", - action = "review_prompt_completed", - ) - - data class ReviewPromptError( - val errorCode: Int, - ) : AnalyticsEvent( - category = "engagement", - action = "review_prompt_error", - value = errorCode.toDouble(), - properties = mapOf("error_code" to errorCode), - ) -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsManager.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsManager.kt deleted file mode 100644 index d374033c8..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/AnalyticsManager.kt +++ /dev/null @@ -1,285 +0,0 @@ -package net.opendasharchive.openarchive.core.analytics - -import android.content.Context -import net.opendasharchive.openarchive.BuildConfig -import net.opendasharchive.openarchive.core.analytics.providers.CleanInsightsProvider -import net.opendasharchive.openarchive.core.analytics.providers.FirebaseProvider -import net.opendasharchive.openarchive.core.analytics.providers.MixpanelProvider -import net.opendasharchive.openarchive.core.logger.AppLogger - -/** - * Unified Analytics Manager - Facade Pattern - * - * Dispatches analytics events to multiple providers: - * - CleanInsights (privacy-focused, GDPR-compliant) - * - Mixpanel (detailed analytics) - * - Firebase (Google Analytics) - * - * GDPR-Compliant: All events are sanitized and contain NO PII - * - * Usage: - * ``` - * AnalyticsManager.initialize(context) - * AnalyticsManager.trackEvent(AnalyticsEvent.AppOpened(isFirstLaunch = true, appVersion = "1.0")) - * ``` - */ -object AnalyticsManager { - - private val providers = mutableListOf() - private var isInitialized = false - - /** - * Initialize all analytics providers - * Call this once in Application.onCreate() - * - * NOTE: Analytics is disabled in DEBUG builds to keep production data clean - */ - fun initialize(context: Context) { - if (isInitialized) return - - // Skip analytics in debug builds to avoid polluting production data - if (BuildConfig.DEBUG) { - AppLogger.d("AnalyticsManager: Analytics DISABLED in DEBUG mode") - isInitialized = false - return - } - - try { - // Add all providers - providers.add(CleanInsightsProvider(context.applicationContext)) - providers.add(MixpanelProvider(context.applicationContext)) - providers.add(FirebaseProvider(context.applicationContext)) - - // Initialize each provider - providers.forEach { provider -> - try { - provider.initialize() - AppLogger.d("Analytics: ${provider.getProviderName()} initialized") - } catch (e: Exception) { - AppLogger.e("Failed to initialize ${provider.getProviderName()}", e) - } - } - - isInitialized = true - AppLogger.d("AnalyticsManager initialized with ${providers.size} providers") - } catch (e: Exception) { - AppLogger.e("Failed to initialize AnalyticsManager", e) - } - } - - /** - * Track an analytics event across all providers - * @param event The event to track - */ - fun trackEvent(event: AnalyticsEvent) { - // Skip if not initialized (includes DEBUG builds) - if (!isInitialized) return - - providers.forEach { provider -> - try { - provider.trackEvent(event) - } catch (e: Exception) { - AppLogger.e("Failed to track event in ${provider.getProviderName()}", e) - } - } - } - - /** - * Set user properties across all providers - * GDPR-Compliant: Only use aggregated, non-identifying properties - * Examples: app_version, device_type, install_date - */ - fun setUserProperty(key: String, value: Any) { - if (!isInitialized) return - - providers.forEach { provider -> - try { - provider.setUserProperty(key, value) - } catch (e: Exception) { - AppLogger.e("Failed to set user property in ${provider.getProviderName()}", e) - } - } - } - - /** - * Persist/flush analytics data to servers - * Call this when app goes to background - */ - fun persist() { - if (!isInitialized) return - - providers.forEach { provider -> - try { - provider.persist() - } catch (e: Exception) { - AppLogger.e("Failed to persist in ${provider.getProviderName()}", e) - } - } - } - - // ==================== CONVENIENCE METHODS ==================== - - /** - * Track screen view with time spent - */ - fun trackScreenView(screenName: String, timeSpentSeconds: Long? = null, previousScreen: String? = null) { - trackEvent( - AnalyticsEvent.ScreenViewed( - screenName = screenName, - timeSpentSeconds = timeSpentSeconds, - previousScreen = previousScreen - ) - ) - } - - /** - * Track navigation between screens - */ - fun trackNavigation(fromScreen: String, toScreen: String, trigger: String? = null) { - trackEvent( - AnalyticsEvent.NavigationAction( - fromScreen = fromScreen, - toScreen = toScreen, - trigger = trigger - ) - ) - } - - /** - * Track backend configuration - */ - fun trackBackendConfigured(backendType: String, isNew: Boolean = true) { - trackEvent( - AnalyticsEvent.BackendConfigured( - backendType = backendType, - isNew = isNew - ) - ) - } - - /** - * Track upload started - */ - fun trackUploadStarted(backendType: String, fileType: String, fileSizeKB: Long) { - trackEvent( - AnalyticsEvent.UploadStarted( - backendType = backendType, - fileType = fileType, - fileSizeKB = fileSizeKB - ) - ) - } - - /** - * Track upload completed - */ - fun trackUploadCompleted( - backendType: String, - fileType: String, - fileSizeKB: Long, - durationSeconds: Long, - uploadSpeedKBps: Long? = null - ) { - trackEvent( - AnalyticsEvent.UploadCompleted( - backendType = backendType, - fileType = fileType, - fileSizeKB = fileSizeKB, - durationSeconds = durationSeconds, - uploadSpeedKBps = uploadSpeedKBps - ) - ) - } - - /** - * Track upload failed - */ - fun trackUploadFailed( - backendType: String, - fileType: String, - errorCategory: String, - fileSizeKB: Long? = null - ) { - trackEvent( - AnalyticsEvent.UploadFailed( - backendType = backendType, - fileType = fileType, - errorCategory = errorCategory, - fileSizeKB = fileSizeKB - ) - ) - } - - /** - * Track feature toggle - */ - fun trackFeatureToggled(featureName: String, enabled: Boolean) { - trackEvent( - AnalyticsEvent.FeatureToggled( - featureName = featureName, - enabled = enabled - ) - ) - } - - /** - * Track error - */ - fun trackError(errorCategory: String, screenName: String, backendType: String? = null) { - trackEvent( - AnalyticsEvent.ErrorOccurred( - errorCategory = errorCategory, - screenName = screenName, - backendType = backendType - ) - ) - } - - /** - * Track app lifecycle events - */ - fun trackAppOpened(isFirstLaunch: Boolean, appVersion: String) { - trackEvent( - AnalyticsEvent.AppOpened( - isFirstLaunch = isFirstLaunch, - appVersion = appVersion - ) - ) - } - - fun trackAppClosed(sessionDurationSeconds: Long) { - trackEvent( - AnalyticsEvent.AppClosed( - sessionDurationSeconds = sessionDurationSeconds - ) - ) - } - - /** - * Track session events - */ - fun trackSessionStarted(isFirstSession: Boolean, sessionNumber: Int) { - trackEvent( - AnalyticsEvent.SessionStarted( - isFirstSession = isFirstSession, - sessionNumber = sessionNumber - ) - ) - } - - fun trackSessionEnded( - lastScreen: String, - durationSeconds: Long, - uploadsCompleted: Int = 0, - uploadsFailed: Int = 0 - ) { - trackEvent( - AnalyticsEvent.SessionEnded( - lastScreen = lastScreen, - durationSeconds = durationSeconds, - uploadsCompleted = uploadsCompleted, - uploadsFailed = uploadsFailed - ) - ) - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/CleanInsightsProvider.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/CleanInsightsProvider.kt deleted file mode 100644 index a60277483..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/CleanInsightsProvider.kt +++ /dev/null @@ -1,47 +0,0 @@ -package net.opendasharchive.openarchive.core.analytics.providers - -import android.content.Context -import net.opendasharchive.openarchive.CleanInsightsManager -import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent -import net.opendasharchive.openarchive.core.analytics.AnalyticsProvider - -/** - * CleanInsights implementation of AnalyticsProvider - * Privacy-focused, GDPR-compliant by design - */ -class CleanInsightsProvider( - private val context: Context -) : AnalyticsProvider { - - override fun initialize() { - CleanInsightsManager.init(context) - } - - override fun trackEvent(event: AnalyticsEvent) { - // Only track if user has consented - if (!CleanInsightsManager.hasConsent()) return - - CleanInsightsManager.measureEvent( - category = event.category, - action = event.action, - name = event.label, - value = event.value - ) - - // Track screen views separately for visit tracking - if (event is AnalyticsEvent.ScreenViewed) { - CleanInsightsManager.measureView(event.screenName) - } - } - - override fun setUserProperty(key: String, value: Any) { - // CleanInsights doesn't support user properties (privacy-focused) - // Aggregate data only - } - - override fun persist() { - CleanInsightsManager.persist() - } - - override fun getProviderName(): String = "CleanInsights" -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/FirebaseProvider.kt b/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/FirebaseProvider.kt deleted file mode 100644 index 2e8b346f7..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/core/analytics/providers/FirebaseProvider.kt +++ /dev/null @@ -1,128 +0,0 @@ -package net.opendasharchive.openarchive.core.analytics.providers - -import android.content.Context -import android.os.Bundle -import com.google.firebase.analytics.FirebaseAnalytics -import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent -import net.opendasharchive.openarchive.core.analytics.AnalyticsProvider - -/** - * Firebase Analytics implementation of AnalyticsProvider - * With automatic PII sanitization - */ -class FirebaseProvider( - private val context: Context -) : AnalyticsProvider { - - private var firebaseAnalytics: FirebaseAnalytics? = null - - override fun initialize() { - firebaseAnalytics = FirebaseAnalytics.getInstance(context) - } - - override fun trackEvent(event: AnalyticsEvent) { - val eventName = sanitizeFirebaseEventName("${event.category}_${event.action}") - - // Convert properties to Bundle with PII sanitization - val bundle = Bundle().apply { - event.properties.forEach { (key, value) -> - val sanitizedKey = sanitizeFirebaseParameterName(key) - when (value) { - is String -> putString(sanitizedKey, sanitizePII(value)) - is Int -> putInt(sanitizedKey, value) - is Long -> putLong(sanitizedKey, value) - is Double -> putDouble(sanitizedKey, value) - is Float -> putDouble(sanitizedKey, value.toDouble()) - is Boolean -> putBoolean(sanitizedKey, value) - else -> putString(sanitizedKey, value.toString()) - } - } - - // Add event label if present - event.label?.let { - putString("label", sanitizePII(it)) - } - - // Add event value if present - event.value?.let { - putDouble("value", it) - } - } - - firebaseAnalytics?.logEvent(eventName, bundle) - } - - override fun setUserProperty(key: String, value: Any) { - val sanitizedKey = sanitizeFirebaseParameterName(key) - val sanitizedValue = when (value) { - is String -> sanitizePII(value) - else -> value.toString() - } - - firebaseAnalytics?.setUserProperty(sanitizedKey, sanitizedValue) - } - - override fun persist() { - // Firebase automatically persists events - } - - override fun getProviderName(): String = "Firebase" - - /** - * Sanitize event name to conform to Firebase requirements - * Max 40 characters, alphanumeric + underscore only - */ - private fun sanitizeFirebaseEventName(name: String): String { - return name - .replace(Regex("[^a-zA-Z0-9_]"), "_") - .take(40) - .lowercase() - } - - /** - * Sanitize parameter name to conform to Firebase requirements - * Max 40 characters, alphanumeric + underscore only - */ - private fun sanitizeFirebaseParameterName(name: String): String { - return name - .replace(Regex("[^a-zA-Z0-9_]"), "_") - .take(40) - .lowercase() - } - - /** - * Sanitizes personally identifiable information (PII) from strings - * GDPR-compliant: removes file paths, URLs, emails, usernames, IP addresses - */ - private fun sanitizePII(input: String): String { - var sanitized = input - - // Firebase has a 100-character limit for parameter values - if (sanitized.length > 100) { - sanitized = sanitized.take(97) + "..." - } - - // Remove file paths - sanitized = sanitized.replace(Regex("/[\\w/.-]+"), "[FILE_PATH]") - - // Remove URLs - sanitized = sanitized.replace(Regex("https?://[\\w.-]+(/[\\w.-]*)*"), "[URL]") - - // Remove email addresses - sanitized = sanitized.replace(Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"), "[EMAIL]") - - // Remove IP addresses - sanitized = sanitized.replace(Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"), "[IP]") - - // Remove potential usernames - sanitized = sanitized.replace(Regex("(?i)(user|username|login|account)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") - - // Remove potential passwords - sanitized = sanitized.replace(Regex("(?i)(password|passwd|pwd|pass)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") - - // Remove potential tokens/keys - sanitized = sanitized.replace(Regex("(?i)(token|key|secret|api[-_]?key)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") - - return sanitized - } -} diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/di/FeaturesModule.kt b/app/src/main/java/net/opendasharchive/openarchive/core/di/FeaturesModule.kt index d72463e1e..a5812f93e 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/di/FeaturesModule.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/di/FeaturesModule.kt @@ -45,5 +45,5 @@ val featuresModule = module { // WebDAV single { SaveClientFactoryImpl(get()) } single { WebDavRepository(get()) } - viewModel { WebDavViewModel(get(), get()) } + viewModel { WebDavViewModel(get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt b/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt index 7b0fd61d4..9a9e13eb3 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/core/logger/AppLogger.kt @@ -2,8 +2,12 @@ package net.opendasharchive.openarchive.core.logger import android.content.Context import com.google.firebase.crashlytics.FirebaseCrashlytics -import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager import net.opendasharchive.openarchive.core.logger.AppLogger.init import timber.log.Timber @@ -22,6 +26,8 @@ object AppLogger { private var crashlytics: FirebaseCrashlytics? = null private var currentScreen: String = "Unknown" + private var analyticsManager: AnalyticsManager? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) /** * Initializes the logger @@ -38,6 +44,13 @@ object AppLogger { } } + /** + * Set analytics manager for error tracking + */ + fun setAnalyticsManager(manager: AnalyticsManager) { + analyticsManager = manager + } + /** * Set current screen for breadcrumb context */ @@ -102,10 +115,14 @@ object AppLogger { // Track in Analytics (GDPR-safe - only error category, no PII) val errorCategory = categorizeError(throwable) - AnalyticsManager.trackError( - errorCategory = errorCategory, - screenName = currentScreen - ) + analyticsManager?.let { manager -> + scope.launch { + manager.trackError( + errorCategory = errorCategory, + screenName = currentScreen + ) + } + } } /** @@ -123,10 +140,14 @@ object AppLogger { // Track in Analytics (GDPR-safe) val errorCategory = categorizeError(throwable) - AnalyticsManager.trackError( - errorCategory = errorCategory, - screenName = currentScreen - ) + analyticsManager?.let { manager -> + scope.launch { + manager.trackError( + errorCategory = errorCategory, + screenName = currentScreen + ) + } + } } /** diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt index 286a46b58..2859dc800 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseActivity.kt @@ -6,21 +6,28 @@ import android.view.ViewGroup import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.MaterialToolbar +import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.session.SessionTracker +import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.core.presentation.theme.SaveAppTheme import net.opendasharchive.openarchive.features.core.dialog.DialogHost import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager import net.opendasharchive.openarchive.util.Prefs +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.util.SessionManager abstract class BaseActivity : AppCompatActivity() { val dialogManager: DialogStateManager by viewModel() + // Inject analytics dependencies + protected val analyticsManager: AnalyticsManager by inject() + protected val sessionTracker: SessionTracker by inject() + // Screen tracking variables private var screenStartTime: Long = 0 private var previousScreen: String = "" @@ -91,12 +98,16 @@ abstract class BaseActivity : AppCompatActivity() { // Set current screen for error tracking breadcrumbs AppLogger.setCurrentScreen(screenName) - AnalyticsManager.trackScreenView(screenName, null, previousScreen) - SessionManager.setCurrentScreen(screenName) + lifecycleScope.launch { + analyticsManager.trackScreenView(screenName, null, previousScreen) + } + sessionTracker.setCurrentScreen(screenName) // Track navigation if coming from another screen if (previousScreen.isNotEmpty() && previousScreen != screenName) { - AnalyticsManager.trackNavigation(previousScreen, screenName) + lifecycleScope.launch { + analyticsManager.trackNavigation(previousScreen, screenName) + } } } @@ -107,7 +118,9 @@ abstract class BaseActivity : AppCompatActivity() { val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 val screenName = getScreenName() - AnalyticsManager.trackScreenView(screenName, timeSpent, previousScreen) + lifecycleScope.launch { + analyticsManager.trackScreenView(screenName, timeSpent, previousScreen) + } // Store as previous screen for navigation tracking previousScreen = screenName diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt index a60315483..1b81988b0 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/core/BaseFragment.kt @@ -6,7 +6,12 @@ import android.view.View import android.view.inputmethod.InputMethodManager import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.session.SessionTracker +import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.db.SnowbirdError import net.opendasharchive.openarchive.extensions.androidViewModel import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager @@ -15,9 +20,7 @@ import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity import net.opendasharchive.openarchive.services.snowbird.SnowbirdGroupViewModel import net.opendasharchive.openarchive.services.snowbird.SnowbirdRepoViewModel import net.opendasharchive.openarchive.util.FullScreenOverlayManager -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.util.SessionManager +import org.koin.android.ext.android.inject abstract class BaseFragment : Fragment(), ToolbarConfigurable { @@ -26,6 +29,10 @@ abstract class BaseFragment : Fragment(), ToolbarConfigurable { val snowbirdGroupViewModel: SnowbirdGroupViewModel by androidViewModel() val snowbirdRepoViewModel: SnowbirdRepoViewModel by androidViewModel() + // Inject analytics dependencies + protected val analyticsManager: AnalyticsManager by inject() + protected val sessionTracker: SessionTracker by inject() + // Screen tracking variables private var screenStartTime: Long = 0 private var previousScreen: String = "" @@ -77,12 +84,16 @@ abstract class BaseFragment : Fragment(), ToolbarConfigurable { // Set current screen for error tracking breadcrumbs AppLogger.setCurrentScreen(screenName) - AnalyticsManager.trackScreenView(screenName, null, previousScreen) - SessionManager.setCurrentScreen(screenName) + lifecycleScope.launch { + analyticsManager.trackScreenView(screenName, null, previousScreen) + } + sessionTracker.setCurrentScreen(screenName) // Track navigation if coming from another screen if (previousScreen.isNotEmpty() && previousScreen != screenName) { - AnalyticsManager.trackNavigation(previousScreen, screenName) + lifecycleScope.launch { + analyticsManager.trackNavigation(previousScreen, screenName) + } } } @@ -93,7 +104,9 @@ abstract class BaseFragment : Fragment(), ToolbarConfigurable { val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 val screenName = getScreenName() - AnalyticsManager.trackScreenView(screenName, timeSpent, previousScreen) + lifecycleScope.launch { + analyticsManager.trackScreenView(screenName, timeSpent, previousScreen) + } // Store as previous screen for navigation tracking previousScreen = screenName diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt index f958fad9d..3b21cb7fe 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/Module.kt @@ -26,7 +26,7 @@ val internetArchiveModule = module { single { InternetArchiveLocalSource() } factory { InternetArchiveMapper() } factory { InternetArchiveRepository(get(), get(), get()) } - factory { args -> InternetArchiveLoginUseCase(get(), get(), args.get()) } + factory { args -> InternetArchiveLoginUseCase(get(), get(), args.get(), get()) } viewModel { InternetArchiveDetailsViewModel(get(), get()) } viewModel { InternetArchiveLoginViewModel(get()) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt index 69f0ebd4d..1f92c979b 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/internetarchive/domain/usecase/InternetArchiveLoginUseCase.kt @@ -4,12 +4,13 @@ import com.google.gson.Gson import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.internetarchive.domain.model.InternetArchive import net.opendasharchive.openarchive.features.internetarchive.infrastructure.repository.InternetArchiveRepository -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager class InternetArchiveLoginUseCase( private val repository: InternetArchiveRepository, private val gson: Gson, private val space: Space, + private val analyticsManager: AnalyticsManager, ) { suspend operator fun invoke(email: String, password: String): Result = @@ -32,7 +33,7 @@ class InternetArchiveLoginUseCase( Space.current = space // Track backend configuration - AnalyticsManager.trackBackendConfigured( + analyticsManager.trackBackendConfigured( backendType = Space.Type.INTERNET_ARCHIVE.friendlyName, isNew = isNewBackend ) diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt index c2228c3f3..b3ef7ed4c 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/main/MainActivity.kt @@ -281,7 +281,7 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda } reviewManager = ReviewManagerFactory.create(this) - InAppReviewHelper.requestReviewInfo(this) + InAppReviewHelper.requestReviewInfo(this, analyticsManager) shouldPromptReview = InAppReviewHelper.onAppLaunched() // Set flag to check for app updates on first onResume @@ -372,7 +372,7 @@ class MainActivity : BaseActivity(), SpaceDrawerAdapterListener, FolderDrawerAda lifecycleScope.launch(Dispatchers.Main) { // Wait a small delay so we don't interrupt initial load (e.g. 2 seconds). delay(2_000) - InAppReviewHelper.showReviewIfPossible(this@MainActivity, reviewManager) + InAppReviewHelper.showReviewIfPossible(this@MainActivity, reviewManager, analyticsManager) InAppReviewHelper.markReviewDone() shouldPromptReview = false } diff --git a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt index 7ea17220d..e1f5e5bcc 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/features/settings/SettingsFragment.kt @@ -5,10 +5,14 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat +import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.features.core.BaseActivity import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.core.dialog.DialogStateManager @@ -18,8 +22,6 @@ import net.opendasharchive.openarchive.features.onboarding.SpaceSetupActivity import net.opendasharchive.openarchive.features.onboarding.StartDestination import net.opendasharchive.openarchive.features.settings.passcode.PasscodeRepository import net.opendasharchive.openarchive.features.settings.passcode.passcode_setup.PasscodeSetupActivity -import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager import net.opendasharchive.openarchive.util.Prefs import net.opendasharchive.openarchive.util.Theme import net.opendasharchive.openarchive.util.extensions.getVersionName @@ -29,10 +31,9 @@ import org.koin.androidx.viewmodel.ext.android.activityViewModel class SettingsFragment : PreferenceFragmentCompat() { private val passcodeRepository by inject() - + private val analyticsManager: AnalyticsManager by inject() private val dialogManager: DialogStateManager by activityViewModel() - private var passcodePreference: SwitchPreferenceCompat? = null private val activityResultLauncher = registerForActivityResult( @@ -113,7 +114,9 @@ class SettingsFragment : PreferenceFragmentCompat() { AppLogger.breadcrumb("Feature Toggled", "screenshot_prevention: $enabled") // Track feature toggle - AnalyticsManager.trackFeatureToggled("screenshot_prevention", enabled) + lifecycleScope.launch { + analyticsManager.trackFeatureToggled("screenshot_prevention", enabled) + } if (activity is BaseActivity) { // make sure this gets settings change gets applied instantly @@ -152,7 +155,9 @@ class SettingsFragment : PreferenceFragmentCompat() { AppLogger.breadcrumb("Feature Toggled", "tor: $enabled") // Track feature toggle - AnalyticsManager.trackFeatureToggled("tor", enabled) + lifecycleScope.launch { + analyticsManager.trackFeatureToggled("tor", enabled) + } //torViewModel.updateTorServiceState() true @@ -212,7 +217,9 @@ class SettingsFragment : PreferenceFragmentCompat() { AppLogger.breadcrumb("Feature Toggled", "dark_mode: $useDarkMode") // Track feature toggle - AnalyticsManager.trackFeatureToggled("dark_mode", useDarkMode) + lifecycleScope.launch { + analyticsManager.trackFeatureToggled("dark_mode", useDarkMode) + } true } @@ -225,7 +232,9 @@ class SettingsFragment : PreferenceFragmentCompat() { AppLogger.breadcrumb("Feature Toggled", "wifi_only_upload: $enabled") // Track feature toggle - AnalyticsManager.trackFeatureToggled("wifi_only_upload", enabled) + lifecycleScope.launch { + analyticsManager.trackFeatureToggled("wifi_only_upload", enabled) + } val intent = Intent(Prefs.UPLOAD_WIFI_ONLY).apply { putExtra("value", enabled) } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt index f51015bdb..e00a59cad 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/Conduit.kt @@ -6,7 +6,14 @@ import android.content.res.Configuration import android.webkit.MimeTypeMap import com.google.common.net.UrlEscapers import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.session.SessionTracker import net.opendasharchive.openarchive.core.logger.AppLogger import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.db.Space @@ -14,10 +21,9 @@ import net.opendasharchive.openarchive.services.internetarchive.IaConduit import net.opendasharchive.openarchive.services.webdav.WebDavConduit import net.opendasharchive.openarchive.upload.BroadcastManager import net.opendasharchive.openarchive.util.Prefs -import net.opendasharchive.openarchive.util.SessionManager -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager -import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent import okhttp3.HttpUrl +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.witness.proofmode.storage.DefaultStorageProvider import java.io.File import java.io.FileNotFoundException @@ -29,7 +35,11 @@ import java.util.Locale abstract class Conduit( protected val mMedia: Media, protected val mContext: Context -) { +) : KoinComponent { + + protected val analyticsManager: AnalyticsManager by inject() + protected val sessionTracker: SessionTracker by inject() + protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @SuppressLint("SimpleDateFormat") protected val mDateFormat = SimpleDateFormat(FOLDER_DATETIME_FORMAT) @@ -54,11 +64,13 @@ abstract class Conduit( // Add breadcrumb for crash analysis AppLogger.breadcrumb("Upload Started", "$fileType to $backendType (${fileSizeKB}KB)") - AnalyticsManager.trackUploadStarted( - backendType = backendType, - fileType = fileType, - fileSizeKB = fileSizeKB - ) + scope.launch { + analyticsManager.trackUploadStarted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB + ) + } } private fun getFileType(mimeType: String?): String { @@ -126,16 +138,18 @@ abstract class Conduit( // Add breadcrumb for crash analysis AppLogger.breadcrumb("Upload Completed", "$fileType (${uploadDuration}s, ${uploadSpeedKBps}KB/s)") - AnalyticsManager.trackUploadCompleted( - backendType = backendType, - fileType = fileType, - fileSizeKB = fileSizeKB, - durationSeconds = uploadDuration, - uploadSpeedKBps = uploadSpeedKBps - ) + scope.launch { + analyticsManager.trackUploadCompleted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB, + durationSeconds = uploadDuration, + uploadSpeedKBps = uploadSpeedKBps + ) + } // Track in session - SessionManager.trackUploadCompleted() + sessionTracker.trackUploadCompleted() BroadcastManager.postSuccess( context = mContext, @@ -155,13 +169,15 @@ abstract class Conduit( AppLogger.breadcrumb("Upload Cancelled", "$fileType to $backendType") // Track upload cancellation - AnalyticsManager.trackEvent( - AnalyticsEvent.UploadCancelled( - backendType = backendType, - fileType = fileType, - reason = "user_cancelled" + scope.launch { + analyticsManager.trackEvent( + AnalyticsEvent.UploadCancelled( + backendType = backendType, + fileType = fileType, + reason = "user_cancelled" + ) ) - ) + } return } @@ -186,22 +202,26 @@ abstract class Conduit( else -> "unknown" } - AnalyticsManager.trackUploadFailed( - backendType = backendType, - fileType = fileType, - errorCategory = errorCategory, - fileSizeKB = fileSizeKB - ) + scope.launch { + analyticsManager.trackUploadFailed( + backendType = backendType, + fileType = fileType, + errorCategory = errorCategory, + fileSizeKB = fileSizeKB + ) + } // Track in session - SessionManager.trackUploadFailed() + sessionTracker.trackUploadFailed() // Track error for drop-off analysis - AnalyticsManager.trackError( - errorCategory = errorCategory, - screenName = "Upload", - backendType = backendType - ) + scope.launch { + analyticsManager.trackError( + errorCategory = errorCategory, + screenName = "Upload", + backendType = backendType + ) + } BroadcastManager.postChange( context = mContext, diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt index 43f340a92..a065ad333 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/webdav/WebDavViewModel.kt @@ -15,12 +15,13 @@ import net.opendasharchive.openarchive.R import net.opendasharchive.openarchive.db.Space import net.opendasharchive.openarchive.features.core.UiText import net.opendasharchive.openarchive.features.settings.CreativeCommonsLicenseManager -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager import java.io.IOException class WebDavViewModel( private val repository: WebDavRepository, - savedStateHandle: SavedStateHandle + savedStateHandle: SavedStateHandle, + private val analyticsManager: AnalyticsManager ) : ViewModel() { companion object { @@ -299,7 +300,7 @@ class WebDavViewModel( Space.current = space // Track backend configuration - AnalyticsManager.trackBackendConfigured( + analyticsManager.trackBackendConfigured( backendType = Space.Type.WEBDAV.friendlyName, isNew = isNewBackend ) diff --git a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt index eea5d4379..a6ee85b56 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/upload/UploadService.kt @@ -19,18 +19,22 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.opendasharchive.openarchive.CleanInsightsManager import net.opendasharchive.openarchive.R +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager -import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent import net.opendasharchive.openarchive.db.Media import net.opendasharchive.openarchive.features.main.MainActivity import net.opendasharchive.openarchive.services.Conduit import net.opendasharchive.openarchive.util.Prefs +import org.koin.android.ext.android.inject import java.io.IOException import java.util.* class UploadService : JobService() { + // Inject analytics manager + private val analyticsManager: AnalyticsManager by inject() + companion object { private const val MY_BACKGROUND_JOB = 0 private const val NOTIFICATION_CHANNEL_ID = "oasave_channel_1" @@ -103,7 +107,7 @@ class UploadService : JobService() { mRunning = false AppLogger.i("no network, upload stopped") // Track network error - AnalyticsManager.trackEvent( + analyticsManager.trackEvent( AnalyticsEvent.UploadNetworkError( reason = if (Prefs.uploadWifiOnly) "wifi_required" else "no_network" ) @@ -124,12 +128,12 @@ class UploadService : JobService() { ) if (initialBatch.isNotEmpty()) { - // Track batch started - val batchSize = initialBatch.size + // Track upload session started (1+ files) + val sessionSize = initialBatch.size val totalSizeMB = initialBatch.sumOf { it.contentLength } / (1024 * 1024) - AnalyticsManager.trackEvent( - AnalyticsEvent.UploadBatchStarted( - count = batchSize, + analyticsManager.trackEvent( + AnalyticsEvent.UploadSessionStarted( + count = sessionSize, totalSizeMB = totalSizeMB ) ) @@ -186,15 +190,15 @@ class UploadService : JobService() { AppLogger.i("Uploads completed") - // Track batch completed (if any uploads were attempted) + // Track upload session completed (if any uploads were attempted) if (totalCount > 0) { - val batchDuration = (System.currentTimeMillis() - batchStartTime) / 1000 - AnalyticsManager.trackEvent( - AnalyticsEvent.UploadBatchCompleted( + val sessionDuration = (System.currentTimeMillis() - batchStartTime) / 1000 + analyticsManager.trackEvent( + AnalyticsEvent.UploadSessionCompleted( count = totalCount, successCount = successCount, failedCount = failedCount, - durationSeconds = batchDuration + durationSeconds = sessionDuration ) ) } diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt b/app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt deleted file mode 100644 index 3035e1f8f..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt +++ /dev/null @@ -1,67 +0,0 @@ -package net.opendasharchive.openarchive.util - -import android.annotation.SuppressLint -import android.content.Context -import com.mixpanel.android.mpmetrics.MixpanelAPI -import net.opendasharchive.openarchive.R -import org.json.JSONObject - -@SuppressLint("StaticFieldLeak") -object Analytics { - - const val APP_LOG = "app_log" - const val APP_ERROR = "app_error" - - private var mixpanel: MixpanelAPI? = null - - fun init(context: Context) { - val token = context.getString(R.string.mixpanel_key) - mixpanel = MixpanelAPI.getInstance(context, token, false) - } - - fun log(eventName: String, props: Map? = null) { - val sanitizedProps = props?.mapValues { (_, value) -> - when (value) { - is String -> sanitizePII(value) - else -> value - } - } - - val jsonObject = sanitizedProps?.let { strongProps -> - JSONObject(strongProps) - } - - mixpanel?.track(eventName, jsonObject) - } - - /** - * Sanitizes personally identifiable information (PII) from strings - * GDPR-compliant: removes file paths, URLs, emails, usernames, IP addresses - */ - private fun sanitizePII(input: String): String { - var sanitized = input - - // Remove file paths (e.g., /storage/emulated/0/..., /data/user/...) - sanitized = sanitized.replace(Regex("/[\\w/.-]+"), "[FILE_PATH]") - - // Remove URLs (http://, https://) - sanitized = sanitized.replace(Regex("https?://[\\w.-]+(/[\\w.-]*)*"), "[URL]") - - // Remove email addresses - sanitized = sanitized.replace(Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"), "[EMAIL]") - - // Remove IP addresses - sanitized = sanitized.replace(Regex("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"), "[IP_ADDRESS]") - - // Remove potential usernames (strings after common patterns like user=, username=, login=) - sanitized = sanitized.replace(Regex("(?i)(user|username|login|account)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") - - // Remove potential passwords - sanitized = sanitized.replace(Regex("(?i)(password|passwd|pwd|pass)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") - - // Remove potential tokens/keys - sanitized = sanitized.replace(Regex("(?i)(token|key|secret|api[-_]?key)\\s*[=:]\\s*\\S+"), "$1=[REDACTED]") - - return sanitized - } -} \ No newline at end of file diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt b/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt index 3f508aeb1..e3aa23204 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/util/InAppReviewHelper.kt @@ -6,9 +6,13 @@ import com.google.android.play.core.review.ReviewException import com.google.android.play.core.review.ReviewInfo import com.google.android.play.core.review.ReviewManager import com.google.android.play.core.review.ReviewManagerFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import net.opendasharchive.openarchive.analytics.api.AnalyticsEvent +import net.opendasharchive.openarchive.analytics.api.AnalyticsManager import net.opendasharchive.openarchive.core.logger.AppLogger -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager -import net.opendasharchive.openarchive.core.analytics.AnalyticsEvent object InAppReviewHelper { // Keys for our Prefs helper: @@ -24,6 +28,9 @@ object InAppReviewHelper { // Once requestReviewFlow() succeeds, we cache this: private var reviewInfo: ReviewInfo? = null + // Coroutine scope for analytics + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + /** * Call once (e.g. in Application.onCreate or first Activity) so that Prefs.load(...) runs. */ @@ -34,7 +41,7 @@ object InAppReviewHelper { /** * Call early (e.g. in onCreate of MainActivity) to asynchronously fetch ReviewInfo. */ - fun requestReviewInfo(context: Context) { + fun requestReviewInfo(context: Context, analyticsManager: AnalyticsManager) { val manager: ReviewManager = ReviewManagerFactory.create(context) manager.requestReviewFlow() .addOnCompleteListener { task -> @@ -42,12 +49,16 @@ object InAppReviewHelper { reviewInfo = task.result AppLogger.d("InAppReview", "ReviewInfo obtained successfully.") // Track review prompt shown - AnalyticsManager.trackEvent(AnalyticsEvent.ReviewPromptShown()) + scope.launch { + analyticsManager.trackEvent(AnalyticsEvent.ReviewPromptShown) + } } else { (task.exception as? ReviewException)?.let { ex -> AppLogger.e("InAppReview", "Error requesting review flow: ${ex.errorCode}", ex) // Track review error - AnalyticsManager.trackEvent(AnalyticsEvent.ReviewPromptError(ex.errorCode)) + scope.launch { + analyticsManager.trackEvent(AnalyticsEvent.ReviewPromptError(ex.errorCode)) + } } reviewInfo = null } @@ -76,16 +87,18 @@ object InAppReviewHelper { } /** - * Once you decide it’s time to actually show the prompt (e.g. in onResume, after UI ready), - * call this. If reviewInfo is non-null it will launch; otherwise it just logs “no Info.” + * Once you decide it's time to actually show the prompt (e.g. in onResume, after UI ready), + * call this. If reviewInfo is non-null it will launch; otherwise it just logs "no Info." */ - fun showReviewIfPossible(activity: Activity, reviewManager: ReviewManager) { + fun showReviewIfPossible(activity: Activity, reviewManager: ReviewManager, analyticsManager: AnalyticsManager) { reviewInfo?.let { info -> reviewManager.launchReviewFlow(activity, info) .addOnCompleteListener { AppLogger.d("InAppReview", "Review flow finished.") // Track review flow completed - AnalyticsManager.trackEvent(AnalyticsEvent.ReviewPromptCompleted()) + scope.launch { + analyticsManager.trackEvent(AnalyticsEvent.ReviewPromptCompleted) + } reviewInfo = null } } ?: run { diff --git a/app/src/main/java/net/opendasharchive/openarchive/util/SessionManager.kt b/app/src/main/java/net/opendasharchive/openarchive/util/SessionManager.kt deleted file mode 100644 index 66f364179..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/util/SessionManager.kt +++ /dev/null @@ -1,163 +0,0 @@ -package net.opendasharchive.openarchive.util - -import android.content.Context -import net.opendasharchive.openarchive.BuildConfig -import net.opendasharchive.openarchive.core.analytics.AnalyticsManager - -/** - * Manages user sessions and tracks session analytics - * Provides GDPR-compliant session tracking for user journey analysis - */ -object SessionManager { - - private const val PREF_FIRST_LAUNCH = "first_launch_completed" - private const val PREF_LAST_SCREEN = "last_active_screen" - private const val PREF_SESSION_COUNT = "session_count" - private const val PREF_SESSION_UPLOADS_COMPLETED = "session_uploads_completed" - private const val PREF_SESSION_UPLOADS_FAILED = "session_uploads_failed" - - private var sessionStartTime: Long = 0 - private var currentScreen: String = "" - private var isSessionActive: Boolean = false - private var sessionUploadsCompleted: Int = 0 - private var sessionUploadsFailed: Int = 0 - - /** - * Start a new session - * Called when app is opened or comes to foreground - */ - fun startSession(context: Context) { - if (isSessionActive) return - - sessionStartTime = System.currentTimeMillis() - isSessionActive = true - - // Reset session counters - sessionUploadsCompleted = 0 - sessionUploadsFailed = 0 - - val isFirstLaunch = Prefs.getBoolean(PREF_FIRST_LAUNCH, true) - val sessionCount = Prefs.getInt(PREF_SESSION_COUNT, 0) + 1 - - // Track session start with new AnalyticsManager - AnalyticsManager.trackSessionStarted(isFirstLaunch, sessionCount) - - // Track app opened - AnalyticsManager.trackAppOpened(isFirstLaunch, BuildConfig.VERSION_NAME) - - if (isFirstLaunch) { - Prefs.putBoolean(PREF_FIRST_LAUNCH, false) - } - - // Increment and save session count - Prefs.putInt(PREF_SESSION_COUNT, sessionCount) - } - - /** - * End the current session - * Called when app is closed or goes to background - */ - fun endSession() { - if (!isSessionActive) return - - val sessionDuration = (System.currentTimeMillis() - sessionStartTime) / 1000 - - // Track session end with upload stats - AnalyticsManager.trackSessionEnded( - lastScreen = currentScreen, - durationSeconds = sessionDuration, - uploadsCompleted = sessionUploadsCompleted, - uploadsFailed = sessionUploadsFailed - ) - - // Track app closed - AnalyticsManager.trackAppClosed(sessionDuration) - - // Persist analytics data - AnalyticsManager.persist() - - // Store last screen and upload stats for analysis - Prefs.putString(PREF_LAST_SCREEN, currentScreen) - Prefs.putInt(PREF_SESSION_UPLOADS_COMPLETED, sessionUploadsCompleted) - Prefs.putInt(PREF_SESSION_UPLOADS_FAILED, sessionUploadsFailed) - - isSessionActive = false - } - - /** - * Update the current screen - * @param screenName Name of the current screen - */ - fun setCurrentScreen(screenName: String) { - currentScreen = screenName - } - - /** - * Get the last active screen (useful for crash/uninstall analysis) - */ - fun getLastScreen(): String { - return Prefs.getString(PREF_LAST_SCREEN, "Unknown") ?: "Unknown" - } - - /** - * Get total session count - */ - fun getSessionCount(): Int { - return Prefs.getInt(PREF_SESSION_COUNT, 0) - } - - /** - * Check if this is the first launch - */ - fun isFirstLaunch(): Boolean { - return Prefs.getBoolean(PREF_FIRST_LAUNCH, true) - } - - /** - * Get current session duration in seconds - */ - fun getCurrentSessionDuration(): Long { - if (!isSessionActive) return 0 - return (System.currentTimeMillis() - sessionStartTime) / 1000 - } - - /** - * Track app going to background - */ - fun onBackground() { - if (isSessionActive) { - AnalyticsManager.trackEvent(net.opendasharchive.openarchive.core.analytics.AnalyticsEvent.AppBackgrounded()) - } - } - - /** - * Track app coming to foreground - */ - fun onForeground() { - if (isSessionActive) { - AnalyticsManager.trackEvent(net.opendasharchive.openarchive.core.analytics.AnalyticsEvent.AppForegrounded()) - } - } - - /** - * Track successful upload - */ - fun trackUploadCompleted() { - sessionUploadsCompleted++ - } - - /** - * Track failed upload - */ - fun trackUploadFailed() { - sessionUploadsFailed++ - } - - /** - * Get upload success rate for current session - */ - fun getUploadSuccessRate(): Float { - val total = sessionUploadsCompleted + sessionUploadsFailed - return if (total > 0) sessionUploadsCompleted.toFloat() / total else 0f - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index e657fe635..0931f7b82 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -95,3 +95,4 @@ dependencyResolutionManagement { rootProject.name = "save-android-old" include(":app") +include(":analytics")