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/analytics/src/main/java/net/opendasharchive/openarchive/analytics/core/AnalyticsProvider.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/core/AnalyticsProvider.kt new file mode 100644 index 000000000..99ac2ef55 --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/core/AnalyticsProvider.kt @@ -0,0 +1,41 @@ +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 + */ + suspend fun initialize() + + /** + * Track an analytics event + * @param event The event to track + */ + suspend fun trackEvent(event: AnalyticsEvent) + + /** + * Set user properties (GDPR-compliant, aggregated only) + * Examples: app_version, device_type, install_date + */ + suspend fun setUserProperty(key: String, value: Any) + + /** + * Persist/flush analytics data + */ + 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/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt new file mode 100644 index 000000000..1e9b6b5da --- /dev/null +++ b/analytics/src/main/java/net/opendasharchive/openarchive/analytics/providers/mixpanel/MixpanelProvider.kt @@ -0,0 +1,106 @@ +package net.opendasharchive.openarchive.analytics.providers.mixpanel + +import android.content.Context +import com.mixpanel.android.mpmetrics.MixpanelAPI +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 and coroutine support + */ +class MixpanelProvider( + private val context: Context, + private val token: String +) : AnalyticsProvider { + + private var mixpanel: MixpanelAPI? = null + + override suspend fun initialize() { + withContext(Dispatchers.IO) { + mixpanel = MixpanelAPI.getInstance(context, token, false) + } + } + + override suspend fun trackEvent(event: AnalyticsEvent) { + withContext(Dispatchers.IO) { + 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 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) + } + } + + override suspend fun flush() { + withContext(Dispatchers.IO) { + 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/build.gradle.kts b/app/build.gradle.kts index c9ec4289f..74b7d6f9e 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) @@ -296,11 +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 + // Firebase (Crashlytics only - Analytics is in :analytics module) 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..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,14 +27,18 @@ 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 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 -class SaveApp : SugarApp(), SingletonImageLoader.Factory { +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) @@ -40,10 +52,16 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory { } override fun onCreate() { - super.onCreate() - Analytics.init(this) + super.onCreate() + + // Initialize logging first AppLogger.init(applicationContext, initDebugger = true) + registerActivityLifecycleCallbacks(PasscodeManager()) + + Prefs.load(this) + + // Initialize Koin DI startKoin { androidLogger(Level.DEBUG) androidContext(this@SaveApp) @@ -52,20 +70,63 @@ class SaveApp : SugarApp(), SingletonImageLoader.Factory { 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 (kept for backwards compatibility) CleanInsightsManager.init(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") + + // Register app lifecycle observer AFTER analytics is initialized + ProcessLifecycleOwner.get().lifecycle.addObserver(this@SaveApp) + } + createSnowbirdNotificationChannel() } + override fun onStart(owner: LifecycleOwner) { + super.onStart(owner) + // App came to foreground + ProcessLifecycleOwner.get().lifecycleScope.launch { + sessionTracker.startSession() + sessionTracker.onForeground() + } + } + + override fun onStop(owner: LifecycleOwner) { + super.onStop(owner) + // App went to background + ProcessLifecycleOwner.get().lifecycleScope.launch { + sessionTracker.onBackground() + sessionTracker.endSession() + + // Persist analytics data + analyticsManager.flush() + } + } + private fun initNetCipher() { AppLogger.d("Initializing NetCipher client") val oh = OrbotHelper.get(this) 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 6058e6219..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 @@ -1,44 +1,81 @@ package net.opendasharchive.openarchive.core.logger import android.content.Context +import com.google.firebase.crashlytics.FirebaseCrashlytics +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 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" + private var analyticsManager: AnalyticsManager? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + /** - * 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 analytics manager for error tracking + */ + fun setAnalyticsManager(manager: AnalyticsManager) { + analyticsManager = manager + } + + /** + * 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 +88,82 @@ 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?.let { manager -> + scope.launch { + manager.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?.let { manager -> + scope.launch { + manager.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..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,18 +6,36 @@ 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 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 = "" + + protected open fun getScreenName(): String { + return this::class.simpleName ?: "UnknownActivity" + } + companion object { const val EXTRA_DATA_SPACE = "space" } @@ -72,6 +90,40 @@ 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) + + lifecycleScope.launch { + analyticsManager.trackScreenView(screenName, null, previousScreen) + } + sessionTracker.setCurrentScreen(screenName) + + // Track navigation if coming from another screen + if (previousScreen.isNotEmpty() && previousScreen != screenName) { + lifecycleScope.launch { + analyticsManager.trackNavigation(previousScreen, screenName) + } + } + } + + override fun onPause() { + super.onPause() + + // Track time spent on screen + val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 + val screenName = getScreenName() + + lifecycleScope.launch { + 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..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,6 +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 org.koin.android.ext.android.inject abstract class BaseFragment : Fragment(), ToolbarConfigurable { @@ -23,6 +29,18 @@ 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 = "" + + protected open fun getScreenName(): String { + return this::class.simpleName ?: "UnknownFragment" + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ensureComposeDialogHost() @@ -58,5 +76,39 @@ 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) + + lifecycleScope.launch { + analyticsManager.trackScreenView(screenName, null, previousScreen) + } + sessionTracker.setCurrentScreen(screenName) + + // Track navigation if coming from another screen + if (previousScreen.isNotEmpty() && previousScreen != screenName) { + lifecycleScope.launch { + analyticsManager.trackNavigation(previousScreen, screenName) + } + } + } + + override fun onPause() { + super.onPause() + + // Track time spent on screen + val timeSpent = (System.currentTimeMillis() - screenStartTime) / 1000 + val screenName = getScreenName() + + lifecycleScope.launch { + 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/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 84ea461ac..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,11 +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.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 = @@ -22,10 +24,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/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 e4f796802..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,7 +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.util.Prefs import net.opendasharchive.openarchive.util.Theme import net.opendasharchive.openarchive.util.extensions.getVersionName @@ -28,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( @@ -104,7 +106,18 @@ 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 + lifecycleScope.launch { + 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 +148,17 @@ 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 + lifecycleScope.launch { + analyticsManager.trackFeatureToggled("tor", enabled) + } + //torViewModel.updateTorServiceState() true } @@ -189,12 +212,32 @@ 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 + lifecycleScope.launch { + 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 + lifecycleScope.launch { + 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..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 @@ -15,6 +22,8 @@ import net.opendasharchive.openarchive.services.webdav.WebDavConduit import net.opendasharchive.openarchive.upload.BroadcastManager import net.opendasharchive.openarchive.util.Prefs 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 @@ -26,13 +35,57 @@ 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) 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)") + + scope.launch { + 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 +125,32 @@ 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)") + + scope.launch { + analyticsManager.trackUploadCompleted( + backendType = backendType, + fileType = fileType, + fileSizeKB = fileSizeKB, + durationSeconds = uploadDuration, + uploadSpeedKBps = uploadSpeedKBps + ) + } + + // Track in session + sessionTracker.trackUploadCompleted() + BroadcastManager.postSuccess( context = mContext, collectionId = mMedia.collectionId, @@ -80,9 +159,26 @@ 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 + scope.launch { + analyticsManager.trackEvent( + AnalyticsEvent.UploadCancelled( + backendType = backendType, + fileType = fileType, + reason = "user_cancelled" + ) + ) + } + return } @@ -93,6 +189,40 @@ 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" + } + + scope.launch { + analyticsManager.trackUploadFailed( + backendType = backendType, + fileType = fileType, + errorCategory = errorCategory, + fileSizeKB = fileSizeKB + ) + } + + // Track in session + sessionTracker.trackUploadFailed() + + // Track error for drop-off analysis + scope.launch { + 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..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,11 +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.analytics.api.AnalyticsManager import java.io.IOException class WebDavViewModel( private val repository: WebDavRepository, - savedStateHandle: SavedStateHandle + savedStateHandle: SavedStateHandle, + private val analyticsManager: AnalyticsManager ) : ViewModel() { companion object { @@ -290,9 +292,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..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,16 +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.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" @@ -94,16 +100,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 upload session started (1+ files) + val sessionSize = initialBatch.size + val totalSizeMB = initialBatch.sumOf { it.contentLength } / (1024 * 1024) + analyticsManager.trackEvent( + AnalyticsEvent.UploadSessionStarted( + count = sessionSize, + totalSizeMB = totalSizeMB + ) + ) + } while (mKeepUploading && Media.getByStatus( @@ -116,6 +150,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 +169,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 +190,19 @@ class UploadService : JobService() { AppLogger.i("Uploads completed") + // Track upload session completed (if any uploads were attempted) + if (totalCount > 0) { + val sessionDuration = (System.currentTimeMillis() - batchStartTime) / 1000 + analyticsManager.trackEvent( + AnalyticsEvent.UploadSessionCompleted( + count = totalCount, + successCount = successCount, + failedCount = failedCount, + durationSeconds = sessionDuration + ) + ) + } + 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 deleted file mode 100644 index 1347d9feb..000000000 --- a/app/src/main/java/net/opendasharchive/openarchive/util/Analytics.kt +++ /dev/null @@ -1,29 +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 jsonObject = props?.let { strongProps -> - JSONObject(strongProps) - } - - mixpanel?.track(eventName, jsonObject) - } -} \ 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..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,6 +6,12 @@ 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 object InAppReviewHelper { @@ -22,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. */ @@ -32,16 +41,24 @@ 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 -> if (task.isSuccessful) { reviewInfo = task.result AppLogger.d("InAppReview", "ReviewInfo obtained successfully.") + // Track review prompt shown + 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 + scope.launch { + analyticsManager.trackEvent(AnalyticsEvent.ReviewPromptError(ex.errorCode)) + } } reviewInfo = null } @@ -70,14 +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 + scope.launch { + analyticsManager.trackEvent(AnalyticsEvent.ReviewPromptCompleted) + } reviewInfo = null } } ?: run { 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 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")