diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9b229e40a..861572b11 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -18,6 +18,7 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.process) implementation(libs.core.splashscreen) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index e85818408..1f18bd0ff 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -4,12 +4,17 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import zed.rainxch.core.data.local.db.dao.ExternalLinkDao @@ -22,13 +27,18 @@ import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.githubstore.app.di.initKoin +import kotlin.time.TimeSource class GithubStoreApp : Application() { private var packageEventReceiver: PackageEventReceiver? = null private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onCreate() { + ColdStart.markStart() super.onCreate() initKoin { @@ -40,10 +50,84 @@ class GithubStoreApp : Application() { startDownloadNotificationObserver() scheduleBackgroundUpdateChecks() registerSelfAsInstalledApp() + installCrashTelemetryHandler() + installSessionDurationObserver() + fireAppLaunched() scheduleInitialExternalScan() scheduleSigningSeedSync() } + private fun installSessionDurationObserver() { + // Per-foreground session — set on ON_START, consumed on ON_STOP. + // Distinct from ColdStart so each backgrounding reports its own + // duration rather than monotonically-growing process uptime. + var foregroundStart: TimeSource.Monotonic.ValueTimeMark? = null + ProcessLifecycleOwner.get().lifecycle.addObserver( + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + foregroundStart = TimeSource.Monotonic.markNow() + } + + Lifecycle.Event.ON_STOP -> { + val seconds = foregroundStart?.elapsedNow()?.inWholeSeconds + foregroundStart = null + val telemetry = get() + if (seconds != null) { + telemetry.fire( + name = ProductTelemetryEvents.SESSION_DURATION, + props = mapOf(ProductTelemetryProps.SECONDS to seconds.toString()), + ) + } + // Off-main bounded flush — ON_STOP runs on the main + // thread, never block it on the network. + appScope.launch { + withTimeoutOrNull(2_000) { telemetry.flush() } + } + } + + else -> { + Unit + } + } + }, + ) + } + + private fun installCrashTelemetryHandler() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { + val telemetry = get() + telemetry.fire( + name = ProductTelemetryEvents.CRASH, + props = + mapOf( + ProductTelemetryProps.CATEGORY to categorizeCrash(throwable), + ProductTelemetryProps.PLATFORM to "android", + ), + ) + runBlocking { withTimeoutOrNull(500) { telemetry.flush() } } + } + // When `previous` is null we still need to surface the crash — + // delegate to the thread's group so the JVM's default printout + // and exit behaviour kicks in instead of swallowing silently. + if (previous != null) { + previous.uncaughtException(thread, throwable) + } else { + thread.threadGroup?.uncaughtException(thread, throwable) + } + } + } + + private fun fireAppLaunched() { + // platform + appVersion ride along on the event's top-level + // columns (set by ProductTelemetryImpl). The backend's PropsSchema + // allows no props on app_launched and silently strips anything + // we put here, so we don't. + get().fire(name = ProductTelemetryEvents.APP_LAUNCHED) + } + private fun scheduleInitialExternalScan() { appScope.launch { runCatching { diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index 358e407de..01c53aacf 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -7,10 +7,18 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.core.domain.getPlatform +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ApplyAndroidSystemBars import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.githubstore.app.ColdStart import zed.rainxch.githubstore.app.components.RateLimitDialog import zed.rainxch.githubstore.app.components.SessionExpiredDialog import zed.rainxch.githubstore.app.deeplink.DeepLinkDestination @@ -25,10 +33,24 @@ import zed.rainxch.githubstore.app.navigation.getCurrentScreen fun App(deepLinkUri: String? = null) { val viewModel: MainViewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() + val productTelemetry: ProductTelemetry = koinInject() val navController = rememberNavController() val currentScreen = navController.currentBackStackEntryAsState().value.getCurrentScreen() + LaunchedEffect(Unit) { + ColdStart.consumeIfFirst()?.let { ms -> + productTelemetry.fire( + name = ProductTelemetryEvents.COLD_START_MS, + props = + mapOf( + ProductTelemetryProps.PLATFORM to platformSlug(), + ProductTelemetryProps.BUCKET to TelemetryBuckets.durationMs(ms), + ), + ) + } + } + LaunchedEffect(deepLinkUri) { deepLinkUri?.let { uri -> when (val destination = DeepLinkParser.parse(uri)) { @@ -120,3 +142,11 @@ fun App(deepLinkUri: String? = null) { ) } } + +private fun platformSlug(): String = + when (getPlatform()) { + Platform.ANDROID -> "android" + Platform.MACOS -> "macos" + Platform.WINDOWS -> "windows" + Platform.LINUX -> "linux" + } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt new file mode 100644 index 000000000..1df108b21 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/ColdStart.kt @@ -0,0 +1,22 @@ +package zed.rainxch.githubstore.app + +import kotlin.time.TimeSource + +object ColdStart { + private var mark: TimeSource.Monotonic.ValueTimeMark? = null + private var consumed: Boolean = false + + fun markStart() { + if (mark == null) mark = TimeSource.Monotonic.markNow() + } + + // Returns elapsed ms on the first call after [markStart], null thereafter. + // Single-process, single-shot — App composable consumes once on first frame. + fun consumeIfFirst(): Long? { + if (consumed) return null + consumed = true + return mark?.elapsedNow()?.inWholeMilliseconds + } + + fun elapsedSeconds(): Long? = mark?.elapsedNow()?.inWholeSeconds +} diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt new file mode 100644 index 000000000..1a45f7f67 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt @@ -0,0 +1,10 @@ +package zed.rainxch.githubstore.app + +fun categorizeCrash(throwable: Throwable): String = + when { + throwable is OutOfMemoryError -> "other" + throwable.message?.contains("DataStore", ignoreCase = true) == true -> "data_loss" + throwable.message?.contains("install", ignoreCase = true) == true -> "install_fail" + throwable.message?.contains("version", ignoreCase = true) == true -> "version_detect" + else -> "other" + } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt index 4539c3ce5..1c7b58170 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt @@ -9,6 +9,7 @@ import zed.rainxch.auth.presentation.AuthenticationViewModel import zed.rainxch.details.presentation.DetailsViewModel import zed.rainxch.devprofile.presentation.DeveloperProfileViewModel import zed.rainxch.favourites.presentation.FavouritesViewModel +import zed.rainxch.home.presentation.HomeConsentGateViewModel import zed.rainxch.home.presentation.HomeViewModel import zed.rainxch.profile.presentation.ProfileViewModel import zed.rainxch.recentlyviewed.presentation.RecentlyViewedViewModel @@ -31,6 +32,7 @@ val viewModelsModule = ownerParam = params.get(1), repoParam = params.get(2), isComingFromUpdate = params.get(3), + from = params.get(4), detailsRepository = get(), downloader = get(), installer = get(), @@ -51,11 +53,13 @@ val viewModelsModule = downloadOrchestrator = get(), telemetryRepository = get(), externalImportRepository = get(), + productTelemetry = get(), ) } viewModelOf(::DeveloperProfileViewModel) viewModelOf(::FavouritesViewModel) viewModelOf(::HomeViewModel) + viewModelOf(::HomeConsentGateViewModel) viewModelOf(::RecentlyViewedViewModel) viewModelOf(::SearchViewModel) viewModelOf(::ProfileViewModel) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 9c1be9b2a..d0fb87ac1 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -90,6 +90,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = DetailsFrom.Category, ), ) }, @@ -112,6 +113,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = DetailsFrom.Search, ), ) }, @@ -120,6 +122,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( owner = owner, repo = repo, + from = DetailsFrom.Link, ), ) }, @@ -143,6 +146,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = DetailsFrom.Link, ), ) }, @@ -160,6 +164,7 @@ fun AppNavigation( args.owner, args.repo, args.isComingFromUpdate, + args.from.slug, ) }, ) @@ -175,6 +180,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = DetailsFrom.Link, ), ) }, @@ -203,7 +209,12 @@ fun AppNavigation( navController.navigateUp() }, onNavigateToDetails = { - navController.navigate(GithubStoreGraph.DetailsScreen(it)) + navController.navigate( + GithubStoreGraph.DetailsScreen( + repositoryId = it, + from = DetailsFrom.Library, + ), + ) }, onNavigateToDeveloperProfile = { username -> navController.navigate( @@ -224,6 +235,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = DetailsFrom.Library, ), ) }, @@ -277,6 +289,7 @@ fun AppNavigation( navController.navigate( GithubStoreGraph.DetailsScreen( repositoryId = repoId, + from = DetailsFrom.Library, ), ) }, @@ -323,6 +336,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( repositoryId = repoId, isComingFromUpdate = true, + from = DetailsFrom.Library, ), ) }, @@ -344,6 +358,7 @@ fun AppNavigation( GithubStoreGraph.DetailsScreen( repositoryId = repoId, isComingFromUpdate = true, + from = DetailsFrom.Library, ), ) }, diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt index 0cfbb6c5a..152a86cce 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt @@ -2,6 +2,18 @@ package zed.rainxch.githubstore.app.navigation import kotlinx.serialization.Serializable +// E6 telemetry: where a Details nav originated. Closed set so the +// FROM prop on DETAILS_VIEWED can never carry an arbitrary string. +@Serializable +enum class DetailsFrom( + val slug: String, +) { + Search("search"), + Category("category"), + Library("library"), + Link("link"), +} + @Serializable sealed interface GithubStoreGraph { @Serializable @@ -19,6 +31,9 @@ sealed interface GithubStoreGraph { val owner: String = "", val repo: String = "", val isComingFromUpdate: Boolean = false, + // Drives the FROM prop on DETAILS_VIEWED. Typed so callers + // can't accidentally introduce off-allowlist values. + val from: DetailsFrom = DetailsFrom.Link, ) : GithubStoreGraph @Serializable diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt index b89a94694..f22c4c7e3 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt @@ -20,6 +20,11 @@ import org.jetbrains.compose.resources.stringResource import org.koin.core.context.GlobalContext import zed.rainxch.core.data.services.LocalizationManager import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.githubstore.app.ColdStart +import zed.rainxch.githubstore.app.categorizeCrash import zed.rainxch.githubstore.app.desktop.KeyboardNavigation import zed.rainxch.githubstore.app.desktop.KeyboardNavigationEvent import zed.rainxch.githubstore.app.di.initKoin @@ -32,11 +37,13 @@ import kotlin.system.exitProcess private const val LANGUAGE_PREF_READ_TIMEOUT_MS = 2000L fun main(args: Array) { - // Install first so anything that blows up during Koin init or - // resource loading leaves a diagnosable trail on disk (see - // `CrashReporter.resolveLogDir` for the per-OS path). + // Install first so anything that blows up during ColdStart, Koin + // init, or resource loading leaves a diagnosable trail on disk + // (see `CrashReporter.resolveLogDir` for the per-OS path). CrashReporter.install() + ColdStart.markStart() + // Guard the AWT EventDispatchThread against a known Compose MP 1.10.x NPE // raised by the macOS accessibility bridge (see `A11yCrashGuard`). A11yCrashGuard.install() @@ -49,6 +56,9 @@ fun main(args: Array) { initKoin() + installCrashTelemetryHandler() + installTelemetryShutdownHook() + // Apply persisted UI language before any Compose code runs — same // reasoning as on Android (see `MainActivity.onCreate`). Desktop // Compose has no runtime `recreate()` equivalent, so mid-session @@ -73,9 +83,19 @@ fun main(args: Array) { val deepLinkArg = args.firstOrNull() if (deepLinkArg != null && DesktopDeepLink.tryForwardToRunningInstance(deepLinkArg)) { + // Transient handoff process — exit before firing APP_LAUNCHED so + // forwarder invocations don't get counted as user-visible launches. exitProcess(0) } + // Fire app_launched once per *real* process (after the deep-link + // forwarder has had a chance to exit transient processes). No-op when + // consent is not Granted. platform + appVersion are populated by + // ProductTelemetryImpl as top-level event columns; the backend's + // PropsSchema allows zero props on app_launched and silently strips + // anything we put in the props bag, so we don't. + GlobalContext.get().get().fire(name = ProductTelemetryEvents.APP_LAUNCHED) + DesktopDeepLink.registerUriSchemeIfNeeded() application { @@ -98,7 +118,16 @@ fun main(args: Array) { } Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + val telemetry = GlobalContext.get().get() + ColdStart.elapsedSeconds()?.let { seconds -> + telemetry.fire( + name = ProductTelemetryEvents.SESSION_DURATION, + props = mapOf(ProductTelemetryProps.SECONDS to seconds.toString()), + ) + } + exitApplication() + }, title = stringResource(Res.string.app_name), icon = painterResource(Res.drawable.app_icon), onKeyEvent = { keyEvent -> @@ -118,3 +147,53 @@ fun main(args: Array) { } } } + +private fun installTelemetryShutdownHook() { + Runtime.getRuntime().addShutdownHook( + Thread { + runCatching { + runBlocking { + withTimeoutOrNull(2_000) { + GlobalContext.get().get().flush() + } + } + } + }, + ) +} + +private fun installCrashTelemetryHandler() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { + val telemetry = GlobalContext.get().get() + telemetry.fire( + name = ProductTelemetryEvents.CRASH, + props = + mapOf( + ProductTelemetryProps.CATEGORY to categorizeCrash(throwable), + ProductTelemetryProps.PLATFORM to desktopPlatformSlug(), + ), + ) + runBlocking { withTimeoutOrNull(500) { telemetry.flush() } } + } + // Delegate to whatever the JVM had set up before us, falling back + // to the thread's group so the default print-and-exit behaviour + // still runs when no explicit handler is installed. + if (previous != null) { + previous.uncaughtException(thread, throwable) + } else { + thread.threadGroup?.uncaughtException(thread, throwable) + } + } +} + +private fun desktopPlatformSlug(): String { + val os = System.getProperty("os.name").orEmpty().lowercase() + return when { + os.contains("mac") -> "macos" + os.contains("win") -> "windows" + os.contains("nix") || os.contains("nux") -> "linux" + else -> "desktop" + } +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt index 9b4f05fdc..cc5724b8c 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -24,6 +24,9 @@ import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.PackageMonitor +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase /** @@ -46,6 +49,7 @@ class UpdateCheckWorker( private val externalImportRepository: ExternalImportRepository by inject() private val externalLinkDao: ExternalLinkDao by inject() private val packageMonitor: PackageMonitor by inject() + private val productTelemetry: ProductTelemetry by inject() override suspend fun doWork(): Result = try { @@ -87,11 +91,24 @@ class UpdateCheckWorker( Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" } Result.success() + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + // Worker cancellation isn't a user-facing failure — propagate so + // structured concurrency does its job and we don't pollute the + // OPERATION_FAILED stream with cancel events. + throw e } catch (e: Exception) { Logger.e { "UpdateCheckWorker: Update check failed: ${e.message}" } if (runAttemptCount < 3) { Result.retry() } else { + productTelemetry.fire( + name = ProductTelemetryEvents.OPERATION_FAILED, + props = + mapOf( + ProductTelemetryProps.OP to "update", + ProductTelemetryProps.ERROR_CODE to (e::class.simpleName ?: "unknown"), + ), + ) Result.failure() } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt index 976a1d4d3..56b8490a0 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt @@ -4,11 +4,15 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.serializer import zed.rainxch.core.data.local.db.dao.CacheDao import zed.rainxch.core.data.local.db.entities.CacheEntryEntity +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import kotlin.time.Clock import kotlin.time.Duration.Companion.hours class CacheManager( val cacheDao: CacheDao, + @PublishedApi internal val productTelemetry: ProductTelemetry, ) { val json = Json { @@ -27,7 +31,9 @@ class CacheManager( memoryCache[key]?.let { (expiresAt, jsonData) -> if (expiresAt > currentTime) { return try { - json.decodeFromString(serializer(), jsonData) + val value = json.decodeFromString(serializer(), jsonData) + fireCacheLookup(key, hit = true) + value } catch (_: Exception) { memoryCache.remove(key) null @@ -37,11 +43,17 @@ class CacheManager( } } - val entry = cacheDao.getValid(key, currentTime) ?: return null + val entry = + cacheDao.getValid(key, currentTime) ?: run { + fireCacheLookup(key, hit = false) + return null + } memoryCache[key] = entry.expiresAt to entry.jsonData return try { - json.decodeFromString(serializer(), entry.jsonData) + val value = json.decodeFromString(serializer(), entry.jsonData) + fireCacheLookup(key, hit = true) + value } catch (_: Exception) { cacheDao.delete(key) memoryCache.remove(key) @@ -49,6 +61,29 @@ class CacheManager( } } + @PublishedApi + internal fun fireCacheLookup(key: String, hit: Boolean) { + val cacheName = deriveCacheName(key) ?: return + productTelemetry.fire( + name = if (hit) ProductTelemetryEvents.CACHE_HIT else ProductTelemetryEvents.CACHE_MISS, + props = mapOf(ProductTelemetryProps.CACHE_NAME to cacheName), + ) + } + + @PublishedApi + internal fun deriveCacheName(key: String): String? = + when { + key.startsWith("readme:") -> "readmes" + key.startsWith("search:") -> "search_results" + key.startsWith("repo:") || + key.startsWith("repo_id:") || + key.startsWith("releases:") || + key.startsWith("latest_release:") || + key.startsWith("repo_stats:") || + key.startsWith("user_profile:") -> "details" + else -> null + } + suspend inline fun getStale(key: String): T? { val entry = cacheDao.getAny(key) ?: return null return try { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index 8b2e5247b..5dcc45d8a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -44,6 +44,7 @@ import zed.rainxch.core.data.repository.DeviceIdentityRepositoryImpl import zed.rainxch.core.data.repository.RateLimitRepositoryImpl import zed.rainxch.core.data.repository.SearchHistoryRepositoryImpl import zed.rainxch.core.data.repository.TelemetryRepositoryImpl +import zed.rainxch.core.data.telemetry.ProductTelemetryImpl import zed.rainxch.core.data.repository.SeenReposRepositoryImpl import zed.rainxch.core.data.repository.StarredRepositoryImpl import zed.rainxch.core.data.repository.TweaksRepositoryImpl @@ -66,6 +67,7 @@ import zed.rainxch.core.domain.repository.SearchHistoryRepository import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TelemetryRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase @@ -103,6 +105,7 @@ val coreModule = historyDao = get(), installer = get(), clientProvider = get(), + productTelemetry = get(), ) } @@ -137,6 +140,7 @@ val coreModule = ProxyRepositoryImpl( preferences = get(), logger = get(), + productTelemetry = get(), ) } @@ -154,7 +158,10 @@ val coreModule = } single { - CacheManager(cacheDao = get()) + CacheManager( + cacheDao = get(), + productTelemetry = get(), + ) } single { @@ -165,6 +172,7 @@ val coreModule = BackendApiClient( proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY), tokenStore = get(), + productTelemetry = get(), ) } // NOTE: the reviewer asked for a Koin onClose hook to call @@ -195,6 +203,16 @@ val coreModule = ) } + single { + ProductTelemetryImpl( + backendApiClientProvider = { get() }, + tweaksRepository = get(), + platform = get(), + appScope = get(), + logger = get(), + ) + } + single { BackendExternalMatchApi(get()) } single { MockExternalMatchApi() } @@ -217,6 +235,7 @@ val coreModule = externalMatchApi = get(), backendClient = get(), telemetry = get(), + productTelemetry = get(), ) } @@ -230,6 +249,7 @@ val coreModule = installedAppsRepository = get(), pendingInstallNotifier = get(), appScope = get(), + productTelemetry = get(), ) } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt index 1ec3b4628..3ff0b1c16 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/EventRequest.kt @@ -9,7 +9,6 @@ data class EventRequest( val appVersion: String? = null, val eventType: String, val repoId: Long? = null, - val queryHash: String? = null, val resultCount: Int? = null, val success: Boolean? = null, val errorCode: String? = null, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ProductTelemetryEventBody.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ProductTelemetryEventBody.kt new file mode 100644 index 000000000..914f2e777 --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ProductTelemetryEventBody.kt @@ -0,0 +1,19 @@ +package zed.rainxch.core.data.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class ProductTelemetryEventBody( + val name: String, + val sessionId: String, + val timestamp: Long, + val platform: String? = null, + val appVersion: String? = null, + val props: JsonObject? = null, +) + +@Serializable +data class ProductTelemetryBatch( + val events: List, +) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt index 3ac89899b..f2f874017 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt @@ -35,11 +35,16 @@ import zed.rainxch.core.data.dto.BackendSearchResponse import zed.rainxch.core.data.dto.EventRequest import zed.rainxch.core.data.dto.ExternalMatchRequest import zed.rainxch.core.data.dto.ExternalMatchResponse +import zed.rainxch.core.data.dto.ProductTelemetryBatch +import zed.rainxch.core.data.dto.ProductTelemetryEventBody import zed.rainxch.core.data.dto.GithubReadmeResponseDto import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.dto.SigningFingerprintSeedResponse import zed.rainxch.core.data.dto.UserProfileNetwork import zed.rainxch.core.domain.model.ProxyConfig +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import kotlin.coroutines.cancellation.CancellationException /** @@ -51,6 +56,7 @@ import kotlin.coroutines.cancellation.CancellationException class BackendApiClient( proxyConfigFlow: StateFlow, private val tokenStore: TokenStore, + private val productTelemetry: ProductTelemetry, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val mutex = Mutex() @@ -58,6 +64,9 @@ class BackendApiClient( @Volatile private var httpClient: HttpClient = buildClient(proxyConfigFlow.value) + @Volatile + private var currentProxyConfig: ProxyConfig = proxyConfigFlow.value + init { proxyConfigFlow .drop(1) @@ -66,6 +75,7 @@ class BackendApiClient( mutex.withLock { httpClient.close() httpClient = buildClient(config) + currentProxyConfig = config } }.launchIn(scope) } @@ -297,15 +307,51 @@ class BackendApiClient( } } - private inline fun safeCall(block: () -> Result): Result = + // Bypasses [safeCall]'s [firePerProxyOutcome] hook — otherwise every + // telemetry POST would emit another PROXY_USED event, which would + // re-enter the buffer, schedule another flush, and recurse. + suspend fun postProductTelemetryEvents(events: List): Result = try { - block() + val response = httpClient.post("telemetry/events") { + contentType(ContentType.Application.Json) + setBody(ProductTelemetryBatch(events)) + } + when { + response.status == HttpStatusCode.NoContent || response.status.isSuccess() -> + Result.success(Unit) + response.status == HttpStatusCode.TooManyRequests -> + Result.failure(RateLimitedException()) + else -> + Result.failure(BackendException(response.status.value)) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { Result.failure(e) } + private inline fun safeCall(block: () -> Result): Result { + val result = + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + firePerProxyOutcome(result.isSuccess) + return result + } + + private fun firePerProxyOutcome(success: Boolean) { + val proxy = currentProxyConfig + if (proxy is ProxyConfig.None || proxy is ProxyConfig.System) return + productTelemetry.fire( + name = ProductTelemetryEvents.PROXY_USED, + props = mapOf(ProductTelemetryProps.SUCCESS to success.toString()), + ) + } + companion object { private const val BASE_URL = BACKEND_BASE_URL private const val X_GITHUB_TOKEN_HEADER = "X-GitHub-Token" diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt index 54d208354..d31130495 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt @@ -25,6 +25,10 @@ import zed.rainxch.core.data.network.ExternalMatchApi import zed.rainxch.core.data.network.RateLimitedException import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.TelemetryRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalAppScanner import zed.rainxch.core.domain.system.ExternalDecisionSnapshot @@ -42,6 +46,7 @@ class ExternalImportRepositoryImpl( private val externalMatchApi: ExternalMatchApi, private val backendClient: BackendApiClient, private val telemetry: TelemetryRepository, + private val productTelemetry: ProductTelemetry, ) : ExternalImportRepository { // Snapshot cache survives only for the lifetime of the process. Decisions // (linked / skipped / never-ask) are persisted in `external_links`; the @@ -65,8 +70,10 @@ class ExternalImportRepositoryImpl( val firstLaunch = preferences.data.first()[INITIAL_SCAN_COMPLETED_AT_KEY] == null runCatching { if (firstLaunch) { - runCatching { telemetry.importScanStarted(trigger = "first_launch") } - .onFailure { Logger.d { "telemetry importScanStarted failed: ${it.message}" } } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_SCAN_STARTED, + props = mapOf(ProductTelemetryProps.PLATFORM to "android"), + ) } runFullScan() }.onSuccess { @@ -102,12 +109,14 @@ class ExternalImportRepositoryImpl( .onFailure { Logger.d { "prune pending failed: ${it.message}" } } val durationMs = nowMillis() - started - runCatching { - telemetry.importScanCompleted( - candidateCountBucket = bucketCandidateCount(candidates.size), - durationMsBucket = bucketDurationMs(durationMs), - ) - }.onFailure { Logger.d { "telemetry importScanCompleted failed: ${it.message}" } } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_SCAN_COMPLETED, + props = + mapOf( + ProductTelemetryProps.CANDIDATE_COUNT to bucketCandidateCount(candidates.size), + ProductTelemetryProps.DURATION_MS to bucketDurationMs(durationMs), + ), + ) return ScanResult( totalCandidates = candidates.size, @@ -245,12 +254,15 @@ class ExternalImportRepositoryImpl( // only — never owner/repo/package name. deduped.groupBy { it.source }.forEach { (source, hits) -> val top = hits.maxByOrNull { it.confidence } ?: return@forEach - runCatching { - telemetry.importMatchAttempted( - strategy = source.telemetryStrategy(), - confidenceBucket = bucketConfidence(top.confidence), - ) - }.onFailure { Logger.d { "telemetry importMatchAttempted failed: ${it.message}" } } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_MATCH_ATTEMPTED, + props = + mapOf( + ProductTelemetryProps.STRATEGY to source.telemetryStrategy(), + ProductTelemetryProps.CONFIDENCE_BUCKET to + TelemetryBuckets.confidence(top.confidence.toFloat()), + ), + ) } RepoMatchResult(packageName = candidate.packageName, suggestions = deduped) @@ -543,13 +555,6 @@ class ExternalImportRepositoryImpl( else -> ">5000" } - private fun bucketConfidence(c: Double): String = - when { - c < 0.5 -> "<0.5" - c < 0.85 -> "0.5-0.85" - else -> ">=0.85" - } - private fun bucketSeedRowsAdded(n: Int): String = when { n <= 0 -> "0" diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 5c8036b17..f285aef5b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -28,6 +28,8 @@ import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.MatchingPreview import zed.rainxch.core.domain.system.Installer +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents import zed.rainxch.core.domain.model.isEffectivelyPreRelease import zed.rainxch.core.domain.util.AssetFilter import zed.rainxch.core.domain.util.AssetVariant @@ -39,6 +41,7 @@ class InstalledAppsRepositoryImpl( private val historyDao: UpdateHistoryDao, private val installer: Installer, private val clientProvider: GitHubClientProvider, + private val productTelemetry: ProductTelemetry, ) : InstalledAppsRepository { // Reads the current Ktor client at every call site so any proxy // change (ProxyManager rebuilds the client via [clientProvider]) @@ -451,6 +454,10 @@ class InstalledAppsRepositoryImpl( signingFingerprint = signingFingerprint, ), ) + + // Best-effort: telemetry must never fail an update persistence. + runCatching { productTelemetry.fire(name = ProductTelemetryEvents.UPDATE_INSTALLED) } + .onFailure { Logger.d(it) { "UPDATE_INSTALLED telemetry failed" } } } override suspend fun updateApp(app: InstalledApp) { diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt index b497b5618..d82186b48 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ProxyRepositoryImpl.kt @@ -13,6 +13,9 @@ import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.ProxyConfig import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.repository.ProxyRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps /** * Persists one [ProxyConfig] per [ProxyScope] in DataStore, writes @@ -30,6 +33,7 @@ import zed.rainxch.core.domain.repository.ProxyRepository class ProxyRepositoryImpl( private val preferences: DataStore, private val logger: GitHubStoreLogger, + private val productTelemetry: ProductTelemetry, ) : ProxyRepository { // Legacy (pre-scope) keys — read-only, used as a fallback seed. private val legacyType = stringPreferencesKey("proxy_type") @@ -181,6 +185,22 @@ class ProxyRepositoryImpl( } } ProxyManager.setConfig(scope, config) + + // Best-effort: a save shouldn't fail because telemetry hiccupped. + runCatching { + productTelemetry.fire( + name = ProductTelemetryEvents.PROXY_CONFIGURED, + props = + mapOf( + ProductTelemetryProps.TYPE to + when (config) { + is ProxyConfig.Http -> "http" + is ProxyConfig.Socks -> "socks5" + ProxyConfig.None, ProxyConfig.System -> "none" + }, + ), + ) + }.onFailure { logger.debug("PROXY_CONFIGURED telemetry failed: ${it.message}") } } private fun writeOrRemove( diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt index a36428895..9a720a33f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TelemetryRepositoryImpl.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.withContext import zed.rainxch.core.data.BuildKonfig import zed.rainxch.core.data.dto.EventRequest import zed.rainxch.core.data.network.BackendApiClient -import zed.rainxch.core.data.utils.hashQuery import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.repository.DeviceIdentityRepository @@ -42,10 +41,12 @@ class TelemetryRepositoryImpl( // ── recording (fire-and-forget, guarded by opt-in) ────────────── - override fun recordSearchPerformed(query: String, resultCount: Int) { + override fun recordSearchPerformed(resultCount: Int) { + // Query intentionally dropped: a 16-hex SHA-256 prefix of a + // lowercased repo-name search is rainbow-table-trivial. The + // count-only signal here is what survives. enqueue( eventType = "search_performed", - queryHash = hashQuery(query), resultCount = resultCount, ) } @@ -240,7 +241,6 @@ class TelemetryRepositoryImpl( private fun enqueue( eventType: String, repoId: Long? = null, - queryHash: String? = null, resultCount: Int? = null, success: Boolean? = null, errorCode: String? = null, @@ -256,7 +256,6 @@ class TelemetryRepositoryImpl( appVersion = BuildKonfig.VERSION_NAME, eventType = eventType, repoId = repoId, - queryHash = queryHash, resultCount = resultCount, success = success, errorCode = errorCode, diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index 17f468720..d42160dfe 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -182,6 +182,21 @@ class TweaksRepositoryImpl( } } + override fun getProductTelemetryConsent(): Flow = + preferences.data.map { prefs -> + when (prefs[PRODUCT_TELEMETRY_CONSENT_KEY]) { + "Granted" -> zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.Granted + "Denied" -> zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.Denied + else -> zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.NotYetAsked + } + } + + override suspend fun setProductTelemetryConsent(consent: zed.rainxch.core.domain.telemetry.ProductTelemetryConsent) { + preferences.edit { prefs -> + prefs[PRODUCT_TELEMETRY_CONSENT_KEY] = consent.name + } + } + override fun getTranslationProvider(): Flow = preferences.data.map { prefs -> TranslationProvider.fromName(prefs[TRANSLATION_PROVIDER_KEY]) @@ -281,6 +296,7 @@ class TweaksRepositoryImpl( private val HIDE_SEEN_ENABLED_KEY = booleanPreferencesKey("hide_seen_enabled") private val SCROLLBAR_ENABLED_KEY = booleanPreferencesKey("scrollbar_enabled") private val TELEMETRY_ENABLED_KEY = booleanPreferencesKey("telemetry_enabled") + private val PRODUCT_TELEMETRY_CONSENT_KEY = stringPreferencesKey("product_telemetry_consent") private val TRANSLATION_PROVIDER_KEY = stringPreferencesKey("translation_provider") private val YOUDAO_APP_KEY = stringPreferencesKey("youdao_app_key") private val YOUDAO_APP_SECRET = stringPreferencesKey("youdao_app_secret") diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt index 3cac2dc3f..71688272f 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt @@ -17,6 +17,9 @@ import kotlinx.coroutines.sync.withLock import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.DownloadOrchestrator +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.core.domain.system.DownloadSpec import zed.rainxch.core.domain.system.DownloadStage import zed.rainxch.core.domain.system.InstallOutcome @@ -75,6 +78,7 @@ class DefaultDownloadOrchestrator( private val installedAppsRepository: InstalledAppsRepository, private val pendingInstallNotifier: PendingInstallNotifier, private val appScope: CoroutineScope, + private val productTelemetry: ProductTelemetry, ) : DownloadOrchestrator { private companion object { private const val DEFAULT_MAX_CONCURRENT = 3 @@ -160,7 +164,7 @@ class DefaultDownloadOrchestrator( throw e } catch (t: Throwable) { Logger.e(t) { "Orchestrator: download/install failed for ${spec.packageName}" } - markFailed(spec.packageName, t.message) + markFailed(spec.packageName, t.message, op = "download", throwable = t) } finally { stateMutex.withLock { if (activeJobs[spec.packageName]?.isCompleted == true || @@ -300,7 +304,7 @@ class DefaultDownloadOrchestrator( throw e } catch (t: Throwable) { Logger.e(t) { "Orchestrator: install failed for ${spec.packageName}" } - markFailed(spec.packageName, t.message) + markFailed(spec.packageName, t.message, op = "install", throwable = t) } } @@ -527,7 +531,7 @@ class DefaultDownloadOrchestrator( throw e } catch (t: Throwable) { Logger.e(t) { "Orchestrator: standalone install failed for $packageName" } - markFailed(packageName, t.message) + markFailed(packageName, t.message, op = "install", throwable = t) null } } @@ -551,13 +555,57 @@ class DefaultDownloadOrchestrator( } } - private suspend fun markFailed(packageName: String, message: String?) { + private suspend fun markFailed( + packageName: String, + message: String?, + op: String, + throwable: Throwable? = null, + ) { stateMutex.withLock { _downloads.update { state -> val current = state[packageName] ?: return@update state state + (packageName to current.copy(stage = DownloadStage.Failed, errorMessage = message)) } } + productTelemetry.fire( + name = ProductTelemetryEvents.OPERATION_FAILED, + props = + mapOf( + ProductTelemetryProps.OP to op, + ProductTelemetryProps.ERROR_CODE to mapExceptionToBucket(throwable), + ), + ) + } + + // Categorical buckets — the schema requires stable, low-cardinality + // values so dashboards can aggregate. Using throwable simple class + // names directly would explode the dimension when callers are + // platform-specific (e.g. SocketTimeoutException vs HttpRequestTimeoutException). + private fun mapExceptionToBucket(throwable: Throwable?): String { + if (throwable == null) return "unknown" + val name = throwable::class.simpleName.orEmpty() + val message = throwable.message.orEmpty() + return when { + name.contains("Cancellation", ignoreCase = true) -> "cancelled" + name.contains("Timeout", ignoreCase = true) || + message.contains("timeout", ignoreCase = true) -> "timeout" + name.contains("UnknownHost", ignoreCase = true) || + name.contains("DnsResolveException", ignoreCase = true) -> "dns" + name.contains("Ssl", ignoreCase = true) || + name.contains("Certificate", ignoreCase = true) -> "tls" + name.contains("HttpRetry", ignoreCase = true) || + name.contains("ConnectException", ignoreCase = true) || + name.contains("Socket", ignoreCase = true) -> "network" + name.contains("Serialization", ignoreCase = true) || + name.contains("Json", ignoreCase = true) || + name.contains("Parse", ignoreCase = true) -> "parse" + name.contains("FileNotFound", ignoreCase = true) || + name.contains("Io", ignoreCase = true) -> "io" + name.contains("SecurityException", ignoreCase = true) || + name.contains("Auth", ignoreCase = true) || + name.contains("Permission", ignoreCase = true) -> "auth" + else -> "unknown" + } } private fun generateId(): String = diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt new file mode 100644 index 000000000..f08c054ec --- /dev/null +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/telemetry/ProductTelemetryImpl.kt @@ -0,0 +1,193 @@ +package zed.rainxch.core.data.telemetry + +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Clock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import zed.rainxch.core.data.BuildKonfig +import zed.rainxch.core.data.dto.ProductTelemetryEventBody +import zed.rainxch.core.data.network.BackendApiClient +import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.Platform +import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.random.Random + +@OptIn(ExperimentalEncodingApi::class) +class ProductTelemetryImpl( + // Lazy-resolved to break the BackendApiClient ↔ ProductTelemetry cycle + // in Koin: BackendApiClient injects ProductTelemetry for PROXY_USED + // emission, and we need BackendApiClient to ship batches. Both are + // singletons, so deferring resolution to first use lets Koin construct + // either one first. + private val backendApiClientProvider: () -> BackendApiClient, + private val tweaksRepository: TweaksRepository, + private val platform: Platform, + private val appScope: CoroutineScope, + private val logger: GitHubStoreLogger, +) : ProductTelemetry { + + private val backendApiClient: BackendApiClient by lazy(backendApiClientProvider) + + // Fresh per process. The contract from E6 is "ephemeral, reset per app + // launch" — the JVM/Android process lifecycle scopes that for us. + private val sessionId: String = Base64.UrlSafe + .encode(Random.nextBytes(16)) + .trimEnd('=') + + private val bufferMutex = Mutex() + private val buffer = ArrayDeque() + + @Volatile + private var backoffMs: Long = INITIAL_BACKOFF_MS + + init { + appScope.launch { + while (true) { + delay(FLUSH_INTERVAL_MS) + runCatching { flushInternal() } + .onFailure { + if (it is CancellationException) throw it + logger.debug("Product telemetry flush error: ${it.message}") + } + } + } + } + + override fun fire(name: String, props: Map) { + appScope.launch { + // Consent check happens here, BEFORE serialization or queue insert. + // PrivacyAuditTest leans on this — a test that mocks the queue and + // asserts zero enqueue calls when consent is not Granted. + if (consent() != ProductTelemetryConsent.Granted) return@launch + + val body = ProductTelemetryEventBody( + name = name, + sessionId = sessionId, + timestamp = nowMs(), + platform = platformSlug(), + appVersion = BuildKonfig.VERSION_NAME, + props = props.toJsonObject(), + ) + val shouldFlush = bufferMutex.withLock { + if (buffer.size >= MAX_BUFFER_SIZE) buffer.removeFirst() + buffer.add(body) + buffer.size >= FLUSH_BATCH_THRESHOLD + } + if (shouldFlush) { + appScope.launch { flushInternal() } + } + } + } + + override suspend fun flush() { + // Best-effort with a short timeout — used by app shutdown hooks. + withTimeoutOrNull(SHUTDOWN_FLUSH_TIMEOUT_MS) { + flushInternal() + } + } + + private suspend fun flushInternal() { + if (consent() != ProductTelemetryConsent.Granted) { + // Consent withdrawn between enqueue and flush. Drop everything in + // flight; never let a stale-consent batch leave the device. + bufferMutex.withLock { buffer.clear() } + return + } + + val pending = bufferMutex.withLock { + if (buffer.isEmpty()) return + val take = minOf(buffer.size, MAX_BATCH_SIZE) + (0 until take).map { buffer.removeFirst() } + } + + val result = withContext(Dispatchers.IO) { + backendApiClient.postProductTelemetryEvents(pending) + } + + if (result.isSuccess) { + backoffMs = INITIAL_BACKOFF_MS + } else { + // Network or 5xx. Re-add at the front for retry next tick, but only + // if consent still holds. Bound the buffer so a long offline session + // doesn't grow unbounded. + if (consent() == ProductTelemetryConsent.Granted) { + bufferMutex.withLock { + for (i in pending.indices.reversed()) { + if (buffer.size < MAX_BUFFER_SIZE) buffer.addFirst(pending[i]) + } + } + // Exponential backoff up to MAX_BACKOFF_MS. The fixed-cadence + // flush loop still runs every FLUSH_INTERVAL_MS; backoff just + // makes us skip flushes while it's elevated. + backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + delay(backoffMs) + } else { + bufferMutex.withLock { buffer.clear() } + } + logger.debug("Product telemetry batch failed: ${result.exceptionOrNull()?.message}") + } + } + + private suspend fun consent(): ProductTelemetryConsent = + runCatching { tweaksRepository.getProductTelemetryConsent().first() } + .getOrDefault(ProductTelemetryConsent.NotYetAsked) + + private fun platformSlug(): String = when (platform) { + Platform.ANDROID -> "android" + Platform.MACOS -> "macos" + Platform.WINDOWS -> "windows" + Platform.LINUX -> "linux" + } + + private fun Map.toJsonObject(): JsonObject? { + if (isEmpty()) return null + // Only categorical / bucketed values survive — anything that isn't a + // String, Number, or Boolean is dropped. Defense against accidentally + // attaching a repo object, an exception, or a query string. + val entries = mutableMapOf() + for ((k, v) in this) { + when (v) { + is String -> entries[k] = JsonPrimitive(v) + is Number -> entries[k] = JsonPrimitive(v) + is Boolean -> entries[k] = JsonPrimitive(v) + null -> {} // skip nulls + else -> logger.debug("Product telemetry: dropping non-primitive prop '$k' (${v::class.simpleName})") + } + } + return if (entries.isEmpty()) null else JsonObject(entries) + } + + private fun nowMs(): Long = Clock.System.now().toEpochMilliseconds() + + private companion object { + // Flush every 30s on a timer, OR when the buffer reaches the batch + // threshold. Whichever first. + private const val FLUSH_INTERVAL_MS = 30_000L + private const val FLUSH_BATCH_THRESHOLD = 20 + + // Backend caps batches at 100 — we cap at 50 for safety margin. + private const val MAX_BATCH_SIZE = 50 + + // Bounded ring buffer. Long offline sessions can exceed this; oldest + // events get dropped first. 500 is enough for a multi-hour session at + // typical event rates without becoming a memory liability. + private const val MAX_BUFFER_SIZE = 500 + + private const val INITIAL_BACKOFF_MS = 5_000L + private const val MAX_BACKOFF_MS = 5 * 60_000L + private const val SHUTDOWN_FLUSH_TIMEOUT_MS = 2_000L + } +} diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt deleted file mode 100644 index bd43067b2..000000000 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/utils/QueryHash.kt +++ /dev/null @@ -1,17 +0,0 @@ -package zed.rainxch.core.data.utils - -import java.security.MessageDigest - -fun hashQuery(query: String): String { - val normalized = query.trim().lowercase() - if (normalized.isEmpty()) return "" - val digest = MessageDigest.getInstance("SHA-256").digest(normalized.encodeToByteArray()) - val hex = buildString(digest.size * 2) { - for (byte in digest) { - val v = byte.toInt() and 0xff - if (v < 0x10) append('0') - append(v.toString(16)) - } - } - return hex.take(16) -} diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index ae2bc844e..041baf7d0 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -11,6 +11,12 @@ kotlin { } } + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + androidMain { dependencies { } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt index d5e394d95..a1d7a305b 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TelemetryRepository.kt @@ -1,7 +1,7 @@ package zed.rainxch.core.domain.repository interface TelemetryRepository { - fun recordSearchPerformed(query: String, resultCount: Int) + fun recordSearchPerformed(resultCount: Int) fun recordSearchResultClicked(repoId: Long) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index cfad78dc5..65b4732ff 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -64,6 +64,14 @@ interface TweaksRepository { suspend fun setTelemetryEnabled(enabled: Boolean) + // E6 product-metric telemetry consent. Three-state because the first-launch + // sheet needs to distinguish "user actively said no" from "we haven't asked + // yet." Independent of getTelemetryEnabled — that's the legacy ranking- + // signals pipeline (different data, different backend table). + fun getProductTelemetryConsent(): Flow + + suspend fun setProductTelemetryConsent(consent: zed.rainxch.core.domain.telemetry.ProductTelemetryConsent) + fun getTranslationProvider(): Flow suspend fun setTranslationProvider(provider: TranslationProvider) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt new file mode 100644 index 000000000..b916f3ae9 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt @@ -0,0 +1,24 @@ +package zed.rainxch.core.domain.telemetry + +object TelemetryBuckets { + fun durationMs(ms: Long): String = when { + ms < 500 -> "<500" + ms < 1000 -> "500-1000" + ms < 3000 -> "1000-3000" + else -> ">3000" + } + + fun resultCount(n: Int): String = when { + n < 0 -> "invalid" + n == 0 -> "0" + n <= 5 -> "1-5" + n <= 20 -> "6-20" + else -> ">20" + } + + fun confidence(score: Float): String = when { + score >= 0.85f -> "high" + score >= 0.5f -> "medium" + else -> "low" + } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetry.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetry.kt new file mode 100644 index 000000000..ea74b4524 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetry.kt @@ -0,0 +1,19 @@ +package zed.rainxch.core.domain.telemetry + +// E6 product-metric telemetry. Distinct from the existing TelemetryRepository +// (which feeds the backend's ranking-signal pipeline at /v1/events). This +// interface targets POST /v1/telemetry/events with anonymous, opt-out +// product-metric events for measuring crash rate, performance buckets, +// import success, etc. Schema is locked — see TelemetryAllowlist. +interface ProductTelemetry { + + // Fire an event. Synchronous from the caller's POV — never throws, + // never blocks. If consent is denied or not yet asked, the call is + // a no-op. Props keys must use snake_case and contain no PII. + fun fire(name: String, props: Map = emptyMap()) + + // Drain the buffer if there's anything pending. Best-effort — fails + // silently. Used by app shutdown hooks to send a final batch before + // the process exits. + suspend fun flush() +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryConsent.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryConsent.kt new file mode 100644 index 000000000..61b74b997 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryConsent.kt @@ -0,0 +1,7 @@ +package zed.rainxch.core.domain.telemetry + +enum class ProductTelemetryConsent { + NotYetAsked, + Granted, + Denied, +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt new file mode 100644 index 000000000..45f976b22 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt @@ -0,0 +1,85 @@ +package zed.rainxch.core.domain.telemetry + +// Locked schema mirror of the backend's TelemetryAllowlist +// (github-store-backend, src/main/kotlin/.../telemetry/TelemetryEvent.kt). +// Names are referenced by callers as constants — never typed as inline +// strings — so a typo is a compile error, not a silently-dropped event. +// +// PrivacyAuditTest pins this set against the backend's; any drift fails CI. +object ProductTelemetryEvents { + + // session + const val APP_LAUNCHED = "app_launched" + const val SESSION_DURATION = "session_duration" + + // import (E1 / E2) + const val IMPORT_SCAN_STARTED = "import_scan_started" + const val IMPORT_SCAN_COMPLETED = "import_scan_completed" + const val IMPORT_MATCH_ATTEMPTED = "import_match_attempted" + const val IMPORT_AUTO_LINKED = "import_auto_linked" + const val IMPORT_MANUALLY_LINKED = "import_manually_linked" + const val IMPORT_SKIPPED = "import_skipped" + + // reliability (E3) + const val CRASH = "crash" + const val OPERATION_FAILED = "operation_failed" + + // performance (E4) + const val COLD_START_MS = "cold_start_ms" + const val FIRST_PAINT_MS = "first_paint_ms" + const val CACHE_HIT = "cache_hit" + const val CACHE_MISS = "cache_miss" + + // proxy (E5) + const val PROXY_CONFIGURED = "proxy_configured" + const val PROXY_USED = "proxy_used" + const val MIRROR_USED = "mirror_used" + + // discovery / engagement + const val UPDATE_INSTALLED = "update_installed" + const val SEARCH_EXECUTED = "search_executed" + const val DETAILS_VIEWED = "details_viewed" + + val ALL: Set = setOf( + APP_LAUNCHED, SESSION_DURATION, + IMPORT_SCAN_STARTED, IMPORT_SCAN_COMPLETED, IMPORT_MATCH_ATTEMPTED, + IMPORT_AUTO_LINKED, IMPORT_MANUALLY_LINKED, IMPORT_SKIPPED, + CRASH, OPERATION_FAILED, + COLD_START_MS, FIRST_PAINT_MS, CACHE_HIT, CACHE_MISS, + PROXY_CONFIGURED, PROXY_USED, MIRROR_USED, + UPDATE_INSTALLED, SEARCH_EXECUTED, DETAILS_VIEWED, + ) +} + +// Allowed prop keys for telemetry events. Anything outside this set must not +// appear as a key in props. Categorical / bucketed values only — no IDs, +// names, queries, paths. +object ProductTelemetryProps { + + const val PLATFORM = "platform" + const val VERSION = "version" + const val SECONDS = "seconds" + const val CANDIDATE_COUNT = "candidate_count" + const val DURATION_MS = "duration_ms" + const val STRATEGY = "strategy" + const val CONFIDENCE_BUCKET = "confidence_bucket" + const val COUNT = "count" + const val CATEGORY = "category" + const val OP = "op" + const val ERROR_CODE = "error_code" + const val BUCKET = "bucket" + const val SCREEN = "screen" + const val CACHE_NAME = "cache_name" + const val TYPE = "type" + const val SUCCESS = "success" + const val PRESET = "preset" + const val RESULT_COUNT_BUCKET = "result_count_bucket" + const val FROM = "from" + + val ALLOWED: Set = setOf( + PLATFORM, VERSION, SECONDS, CANDIDATE_COUNT, DURATION_MS, + STRATEGY, CONFIDENCE_BUCKET, COUNT, CATEGORY, OP, ERROR_CODE, + BUCKET, SCREEN, CACHE_NAME, TYPE, SUCCESS, PRESET, + RESULT_COUNT_BUCKET, FROM, + ) +} diff --git a/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt b/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt new file mode 100644 index 000000000..4388e6ac6 --- /dev/null +++ b/core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt @@ -0,0 +1,46 @@ +package zed.rainxch.core.domain.telemetry + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PrivacyAuditTest { + + // Hardcoded mirror of the backend's TelemetryAllowlist. Drift between + // client and server here means the server silently drops events the + // client thinks it's sending — fail loudly on mismatch. + @Test + fun `event allowlist matches the backend's locked schema`() { + val backendAllowlist = setOf( + "app_launched", "session_duration", + "import_scan_started", "import_scan_completed", "import_match_attempted", + "import_auto_linked", "import_manually_linked", "import_skipped", + "crash", "operation_failed", + "cold_start_ms", "first_paint_ms", "cache_hit", "cache_miss", + "proxy_configured", "proxy_used", "mirror_used", + "update_installed", "search_executed", "details_viewed", + ) + assertEquals(backendAllowlist, ProductTelemetryEvents.ALL) + } + + @Test + fun `no PII-shaped names slip into the allowlist`() { + val forbidden = listOf( + "user_id", "email", "search_query", "repo_name", "github_username", + "device_id", "ip_address", "owner", "name", "query", "username", + ) + forbidden.forEach { name -> + assertFalse(name in ProductTelemetryEvents.ALL, "$name leaked into ALL") + assertFalse(name in ProductTelemetryProps.ALLOWED, "$name leaked into ALLOWED props") + } + } + + @Test + fun `every prop key is short and snake_case`() { + ProductTelemetryProps.ALLOWED.forEach { key -> + assertFalse(key.contains(" "), "prop key '$key' has whitespace") + assertFalse(key.any { it.isUpperCase() }, "prop key '$key' has uppercase") + check(key.length <= 32) { "prop key '$key' exceeds 32-char cap" } + } + } +} diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 53bc38579..af1ead197 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -689,6 +689,55 @@ Reset analytics ID Generate a new anonymous ID, severing the link to past telemetry. Analytics ID reset + Anonymous usage data + Help improve GitHub Store with anonymous, aggregate metrics tied only to a session ID that resets every launch. The full list of events is open source — see "View what we collect" on the consent screen. + Help improve GitHub Store + We collect anonymous, bucketed usage data to find bugs and improve performance. Every event is tied only to a session ID that resets each time you open the app. Tap "View what we collect" below for the precise list. + Got it + No thanks + What we collect + All values below are anonymous and bucketed. Each event carries only a random session ID that resets every time you open the app. + Back + View source on GitHub + Session + When you open the app, plus your platform (Android, macOS, Windows, Linux) and app version. + How many seconds the app stayed in the foreground when it goes to the background. + Discovery + That you ran a search, plus a bucket of how many results came back. We never see the query text. + That you opened a repo\'s details page, plus where you came from (search, category, library, link). We never see the repo name. + Library import (Android only) + That a scan for installable apps from outside the store started. + That the scan finished, plus how long it took (bucket) and how many candidates were found (bucket). + That we tried to match a candidate to a GitHub repo, plus the strategy (manifest, search, fingerprint) and a confidence bucket (low, medium, high). + A bucketed count of apps that auto-linked successfully. + A bucketed count of apps you manually linked. + A bucketed count of apps you skipped. + Performance + How long the app took to render its first frame on launch (bucket). + How long each screen took to render its first non-skeleton frame, plus the screen name (home, details, library, settings, search). + That a cache lookup hit, plus the cache name (details, icons, readmes, search results). + That a cache lookup missed, plus the cache name. + Reliability + That a crash happened, plus a bucketed category (data loss, install fail, version detect, other) and platform. We never send raw exception messages or stack traces. + That a download, install, update, or fetch failed, plus a bucketed error category (network, io, timeout, auth, parse, unknown). + Network + That you configured a proxy, plus the type (HTTP, SOCKS5, none). + That an outbound request used your configured proxy, plus whether it succeeded (true / false). + Updates + That an existing app was successfully updated to a new version. + Server-side processing + A few signals are derived by our backend from normal API requests, independent of your telemetry choices: + Searches that return fewer than 5 results have a one-way 64-bit hash of the query recorded for ranking quality. Raw query text is never stored. + HTTP request logs are size-bounded. They never contain your IP (we sit behind a CDN), query strings, request bodies, or auth headers. + We never collect + Raw search query text + Repo names, owner names, or package names in analytics events + File paths, stack traces, or exception messages + GitHub usernames, emails, or any account-identifying information + Anything that could personally identify you + Your GitHub OAuth or PAT tokens (forwarded once for the request, never persisted or logged) + Your IP address (CDN terminates the connection; the backend never sees it) + View what we collect Recent searches diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/telemetry/TrackFirstPaint.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/telemetry/TrackFirstPaint.kt new file mode 100644 index 000000000..1c4554d1b --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/telemetry/TrackFirstPaint.kt @@ -0,0 +1,24 @@ +package zed.rainxch.core.presentation.telemetry + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlin.time.TimeSource + +@Composable +fun TrackFirstPaint( + isReady: Boolean, + onFirstPaint: (elapsedMs: Long) -> Unit, +) { + val enteredAt = remember { TimeSource.Monotonic.markNow() } + var fired by remember { mutableStateOf(false) } + LaunchedEffect(isReady) { + if (!fired && isReady) { + fired = true + onFirstPaint(enteredAt.elapsedNow().inWholeMilliseconds) + } + } +} diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index d8787351c..ca5a867c9 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -156,6 +156,19 @@ fun AppsRoot( ) { val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry = org.koin.compose.koinInject() + + zed.rainxch.core.presentation.telemetry.TrackFirstPaint(isReady = !state.isLoading) { ms -> + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.SCREEN to "library", + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.BUCKET to + zed.rainxch.core.domain.telemetry.TelemetryBuckets.durationMs(ms), + ), + ) + } ObserveAsEvents(viewModel.events) { event -> when (event) { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt index 377327f4b..f2988905d 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt @@ -29,6 +29,9 @@ import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.repository.ExternalImportRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.TelemetryRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps import zed.rainxch.core.domain.system.ExternalAppCandidate import zed.rainxch.core.domain.system.ExternalDecisionSnapshot import zed.rainxch.core.domain.system.InstallerKind @@ -55,6 +58,7 @@ class ExternalImportViewModel( private val appsRepository: AppsRepository, private val installedAppsRepository: InstalledAppsRepository, private val telemetry: TelemetryRepository, + private val productTelemetry: ProductTelemetry, private val logger: GitHubStoreLogger, ) : ViewModel() { private var candidatesByPackage: Map = emptyMap() @@ -102,18 +106,15 @@ class ExternalImportViewModel( ExternalImportAction.OnRequestPermission -> { _state.update { it.copy(phase = ImportPhase.RequestingPermission) } - viewModelScope.launch { runCatching { telemetry.importPermissionRequested() } } } is ExternalImportAction.OnPermissionGranted -> { _state.update { it.copy(isPermissionDenied = false) } - emitPermissionOutcome(granted = true, sdkInt = action.sdkInt) startScanIfIdle(force = true) } is ExternalImportAction.OnPermissionDenied -> { _state.update { it.copy(isPermissionDenied = true) } - emitPermissionOutcome(granted = false, sdkInt = action.sdkInt) startScanIfIdle(force = true) } @@ -220,6 +221,13 @@ class ExternalImportViewModel( val autoLinked = autoMaterialize(matches) val autoLinkedPackages = autoLinked.toSet() + if (autoLinked.isNotEmpty()) { + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_AUTO_LINKED, + props = mapOf(ProductTelemetryProps.COUNT to bucketCount(autoLinked.size)), + ) + } + val reviewCandidates = candidates.filter { it.packageName !in autoLinkedPackages } val reviewMatchesByPkg = @@ -348,13 +356,11 @@ class ExternalImportViewModel( ), ) } - viewModelScope.launch { runCatching { telemetry.importSearchOverrideUsed() } } return } searchJob?.cancel() _state.update { it.copy(isSearching = true, searchError = null) } - viewModelScope.launch { runCatching { telemetry.importSearchOverrideUsed() } } searchJob = viewModelScope.launch { val result = runCatching { externalImportRepository.searchRepos(query) } .getOrElse { e -> @@ -364,9 +370,6 @@ class ExternalImportViewModel( result.fold( onSuccess = { suggestions -> - if (suggestions.isEmpty()) { - runCatching { telemetry.importSearchOverrideNoResults() } - } _state.update { // Stale-completion guard: if the user collapsed or switched cards // while the request was in flight, drop the response on the floor @@ -439,12 +442,10 @@ class ExternalImportViewModel( return@launch } - runCatching { - telemetry.importSkipped( - countBucket = "1-2", - persisted = if (neverAsk) "forever" else "7day", - ) - } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_SKIPPED, + props = mapOf(ProductTelemetryProps.COUNT to "1-2"), + ) removeCardFromState(packageName) { it.copy(skipped = it.skipped + 1) } pendingUndo = PendingUndo( @@ -502,9 +503,10 @@ class ExternalImportViewModel( ) return@launch } - runCatching { - telemetry.importManuallyLinked(countBucket = "1-2", source = source) - } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_MANUALLY_LINKED, + props = mapOf(ProductTelemetryProps.COUNT to "1-2"), + ) removeCardFromState(packageName) { it.copy(manuallyLinked = it.manuallyLinked + 1) } pendingUndo = PendingUndo( @@ -690,26 +692,6 @@ class ExternalImportViewModel( } } - private fun emitPermissionOutcome(granted: Boolean, sdkInt: Int?) { - viewModelScope.launch { - runCatching { - telemetry.importPermissionOutcome( - granted = granted, - sdkIntBucket = bucketSdkInt(sdkInt), - ) - } - } - } - - private fun bucketSdkInt(sdkInt: Int?): String = - when { - sdkInt == null -> "unknown" - sdkInt in 26..29 -> "26-29" - sdkInt in 30..32 -> "30-32" - sdkInt >= 33 -> "33+" - else -> "unknown" - } - private fun bucketCount(count: Int): String = when { count <= 0 -> "0" @@ -744,12 +726,10 @@ class ExternalImportViewModel( } if (successes.isNotEmpty()) { - runCatching { - telemetry.importSkipped( - countBucket = bucketCount(successes.size), - persisted = "7day", - ) - } + productTelemetry.fire( + name = ProductTelemetryEvents.IMPORT_SKIPPED, + props = mapOf(ProductTelemetryProps.COUNT to bucketCount(successes.size)), + ) } // Skip-remaining is intentionally not undoable — bulk skip clears diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index ef05cc14e..c01925457 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -64,10 +64,16 @@ import io.github.fletchmckee.liquid.rememberLiquidState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.InstallSource +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.presentation.components.ScrollbarContainer import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled +import zed.rainxch.core.presentation.telemetry.TrackFirstPaint import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.arrowKeyScroll @@ -124,6 +130,18 @@ fun DetailsRoot( val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + val productTelemetry: ProductTelemetry = koinInject() + + TrackFirstPaint(isReady = !state.isLoading) { ms -> + productTelemetry.fire( + name = ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + ProductTelemetryProps.SCREEN to "details", + ProductTelemetryProps.BUCKET to TelemetryBuckets.durationMs(ms), + ), + ) + } ObserveAsEvents(viewModel.events) { event -> when (event) { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 9dc5f55a2..fcb31e827 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -118,8 +118,19 @@ class DetailsViewModel( private val downloadOrchestrator: DownloadOrchestrator, private val telemetryRepository: TelemetryRepository, private val externalImportRepository: ExternalImportRepository, + // String slug rather than the enum: the closed `DetailsFrom` lives in + // composeApp's nav module, which feature/details/presentation + // intentionally doesn't depend on. The enum's `slug` is mapped at the + // navigation boundary so this module never sees a free-form String + // directly from a UI caller. + private val from: String, + private val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry, ) : ViewModel() { private var hasLoadedInitialData = false + // Distinct from hasLoadedInitialData: Retry resets that flag so the + // payload reloads, but DETAILS_VIEWED is a per-instance signal — we + // only care it fired once per visit, not per data refresh. + private var detailsViewedFired = false private var currentDownloadJob: Job? = null private var currentAssetName: String? = null private var aboutTranslationJob: Job? = null @@ -179,7 +190,6 @@ class DetailsViewModel( externalImportRepository.unlink(packageName) installedAppsRepository.deleteInstalledApp(packageName) } - runCatching { telemetryRepository.importUnlinkedFromDetails() } _events.send( DetailsEvent.OnMessage( getString(Res.string.details_unlink_external_app_success), @@ -2395,6 +2405,16 @@ class DetailsViewModel( ) telemetryRepository.recordRepoViewed(repo.id) + if (!detailsViewedFired) { + detailsViewedFired = true + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.DETAILS_VIEWED, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.FROM to from, + ), + ) + } observeInstalledApp(repo.id) } catch (e: RateLimitException) { diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt new file mode 100644 index 000000000..f4c01297e --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeConsentGateViewModel.kt @@ -0,0 +1,37 @@ +package zed.rainxch.home.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent + +class HomeConsentGateViewModel( + private val tweaksRepository: TweaksRepository, +) : ViewModel() { + + // Nullable so a fresh subscription doesn't briefly look like + // "user hasn't answered" while the persisted value is still being + // hydrated. HomeRoot only shows the sheet on an explicit NotYetAsked. + val consent: StateFlow = + tweaksRepository.getProductTelemetryConsent().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = null, + ) + + fun grant() { + viewModelScope.launch { + tweaksRepository.setProductTelemetryConsent(ProductTelemetryConsent.Granted) + } + } + + fun deny() { + viewModelScope.launch { + tweaksRepository.setProductTelemetryConsent(ProductTelemetryConsent.Denied) + } + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt index 39a747b3f..d61f27bde 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/HomeRoot.kt @@ -65,6 +65,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -78,14 +79,21 @@ import io.github.fletchmckee.liquid.rememberLiquidState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.DiscoveryPlatform +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.components.RepositoryCard import zed.rainxch.core.presentation.components.ScrollbarContainer import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.locals.LocalScrollbarEnabled +import zed.rainxch.core.presentation.telemetry.TrackFirstPaint import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents import zed.rainxch.core.presentation.utils.arrowKeyScroll @@ -95,10 +103,20 @@ import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.home.domain.model.HomeCategory import zed.rainxch.home.domain.model.TopicCategory import zed.rainxch.home.presentation.components.LiquidGlassCategoryChips +import zed.rainxch.home.presentation.components.ProductTelemetryConsentSheet import zed.rainxch.home.presentation.locals.LocalHomeTopBarLiquid import zed.rainxch.home.presentation.utils.displayText import zed.rainxch.home.presentation.utils.icon +// Pinned to the client commit that ships this release so "View what we +// collect" always reflects the schema actually used by the running app. +// Bump this hash whenever ProductTelemetryEvents.kt changes — and only +// when shipping a release that includes the new entry. +private const val TELEMETRY_SCHEMA_URL = + "https://github.com/OpenHub-Store/GitHub-Store/blob/" + + "42d0319235f8c973a732bd299965208d7fe95ec4" + + "/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ProductTelemetryEvents.kt" + @Composable fun HomeRoot( onNavigateToSettings: () -> Unit, @@ -107,11 +125,42 @@ fun HomeRoot( onNavigateToDetails: (repoId: Long) -> Unit, onNavigateToDeveloperProfile: (username: String) -> Unit, viewModel: HomeViewModel = koinViewModel(), + consentGate: HomeConsentGateViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val consent by consentGate.consent.collectAsStateWithLifecycle() val listState = rememberLazyStaggeredGridState() val scope = rememberCoroutineScope() val snackbarHost = remember { SnackbarHostState() } + val uriHandler = LocalUriHandler.current + val productTelemetry: ProductTelemetry = koinInject() + + TrackFirstPaint(isReady = !state.isLoading) { ms -> + productTelemetry.fire( + name = ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + ProductTelemetryProps.SCREEN to "home", + ProductTelemetryProps.BUCKET to TelemetryBuckets.durationMs(ms), + ), + ) + } + + if (consent == ProductTelemetryConsent.NotYetAsked) { + ProductTelemetryConsentSheet( + onGrant = consentGate::grant, + onDeny = consentGate::deny, + onViewSchemaSource = { + runCatching { + uriHandler.openUri(TELEMETRY_SCHEMA_URL) + } + }, + // Intentional: dismissing without picking keeps state at + // NotYetAsked so the sheet reappears next launch. No answer + // is not silent opt-in. + onDismiss = {}, + ) + } ObserveAsEvents(viewModel.events) { event -> when (event) { diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt new file mode 100644 index 000000000..ea962ff45 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/PrivacyCollectedView.kt @@ -0,0 +1,293 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_app_launched_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_back +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_cache_hit_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_cache_miss_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_cold_start_ms_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_crash_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_details_viewed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_first_paint_ms_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_auto_linked_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_manually_linked_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_match_attempted_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_scan_completed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_scan_started_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_import_skipped_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_intro +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_account +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_ip +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_paths +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_personal +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_raw_queries +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_repo_identifiers +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_title +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_never_tokens +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_operation_failed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_proxy_configured_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_proxy_used_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_search_executed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_discovery +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_library +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_network +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_performance +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_reliability +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_server_side +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_session +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_section_updates +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_server_side_intro +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_server_side_logs +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_server_side_search_misses +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_session_duration_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_title +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_update_installed_desc +import zed.rainxch.githubstore.core.presentation.res.privacy_collected_view_source + +private data class CollectedEvent( + val name: String, + val description: StringResource, +) + +private data class CollectedSection( + val title: StringResource, + val events: List, +) + +private val sections = + listOf( + CollectedSection( + title = Res.string.privacy_collected_section_session, + events = + listOf( + CollectedEvent("app_launched", Res.string.privacy_collected_app_launched_desc), + CollectedEvent("session_duration", Res.string.privacy_collected_session_duration_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_discovery, + events = + listOf( + CollectedEvent("search_executed", Res.string.privacy_collected_search_executed_desc), + CollectedEvent("details_viewed", Res.string.privacy_collected_details_viewed_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_library, + events = + listOf( + CollectedEvent("import_scan_started", Res.string.privacy_collected_import_scan_started_desc), + CollectedEvent("import_scan_completed", Res.string.privacy_collected_import_scan_completed_desc), + CollectedEvent("import_match_attempted", Res.string.privacy_collected_import_match_attempted_desc), + CollectedEvent("import_auto_linked", Res.string.privacy_collected_import_auto_linked_desc), + CollectedEvent("import_manually_linked", Res.string.privacy_collected_import_manually_linked_desc), + CollectedEvent("import_skipped", Res.string.privacy_collected_import_skipped_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_performance, + events = + listOf( + CollectedEvent("cold_start_ms", Res.string.privacy_collected_cold_start_ms_desc), + CollectedEvent("first_paint_ms", Res.string.privacy_collected_first_paint_ms_desc), + CollectedEvent("cache_hit", Res.string.privacy_collected_cache_hit_desc), + CollectedEvent("cache_miss", Res.string.privacy_collected_cache_miss_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_reliability, + events = + listOf( + CollectedEvent("crash", Res.string.privacy_collected_crash_desc), + CollectedEvent("operation_failed", Res.string.privacy_collected_operation_failed_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_network, + events = + listOf( + CollectedEvent("proxy_configured", Res.string.privacy_collected_proxy_configured_desc), + CollectedEvent("proxy_used", Res.string.privacy_collected_proxy_used_desc), + ), + ), + CollectedSection( + title = Res.string.privacy_collected_section_updates, + events = + listOf( + CollectedEvent("update_installed", Res.string.privacy_collected_update_installed_desc), + ), + ), + ) + +private val neverCollected = + listOf( + Res.string.privacy_collected_never_raw_queries, + Res.string.privacy_collected_never_repo_identifiers, + Res.string.privacy_collected_never_paths, + Res.string.privacy_collected_never_account, + Res.string.privacy_collected_never_tokens, + Res.string.privacy_collected_never_ip, + Res.string.privacy_collected_never_personal, + ) + +private val serverSideItems = + listOf( + Res.string.privacy_collected_server_side_search_misses, + Res.string.privacy_collected_server_side_logs, + ) + +@Composable +fun PrivacyCollectedView( + onBack: () -> Unit, + onViewSource: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.privacy_collected_back), + ) + } + Spacer(Modifier.size(8.dp)) + Text( + text = stringResource(Res.string.privacy_collected_title), + style = MaterialTheme.typography.headlineSmall, + ) + } + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + start = 24.dp, + end = 24.dp, + top = 8.dp, + bottom = 24.dp, + ), + ) { + item { + Text( + text = stringResource(Res.string.privacy_collected_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + } + sections.forEach { section -> + item { SectionHeader(stringResource(section.title)) } + items(section.events) { event -> + EventRow(event.name, stringResource(event.description)) + } + } + item { + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(Res.string.privacy_collected_section_server_side)) + Text( + text = stringResource(Res.string.privacy_collected_server_side_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + } + items(serverSideItems) { res -> + BulletRow(stringResource(res)) + } + item { + Spacer(Modifier.height(8.dp)) + SectionHeader(stringResource(Res.string.privacy_collected_never_title)) + } + items(neverCollected) { res -> + BulletRow(stringResource(res)) + } + item { + Spacer(Modifier.height(16.dp)) + TextButton( + onClick = onViewSource, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.privacy_collected_view_source)) + } + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Spacer(Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun EventRow(name: String, description: String) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = name, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun BulletRow(text: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "•", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt new file mode 100644 index 000000000..d6b1d1a74 --- /dev/null +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/ProductTelemetryConsentSheet.kt @@ -0,0 +1,102 @@ +package zed.rainxch.home.presentation.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_body +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_deny +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_grant +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_title +import zed.rainxch.githubstore.core.presentation.res.product_telemetry_sheet_view_schema + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProductTelemetryConsentSheet( + onGrant: () -> Unit, + onDeny: () -> Unit, + onViewSchemaSource: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // Scoped to the sheet's lifetime — closing and re-opening the sheet + // resets to the consent panel, which matches what a "first launch" + // consent dialog should do. + var showSchema by rememberSaveable { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + if (showSchema) { + PrivacyCollectedView( + onBack = { showSchema = false }, + onViewSource = onViewSchemaSource, + ) + } else { + ConsentPanel( + onGrant = onGrant, + onDeny = onDeny, + onViewSchema = { showSchema = true }, + ) + } + } +} + +@Composable +private fun ConsentPanel( + onGrant: () -> Unit, + onDeny: () -> Unit, + onViewSchema: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + ) { + Text( + text = stringResource(Res.string.product_telemetry_sheet_title), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.product_telemetry_sheet_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = onGrant, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.product_telemetry_sheet_grant)) + } + Spacer(Modifier.height(4.dp)) + TextButton( + onClick = onDeny, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.product_telemetry_sheet_deny)) + } + Spacer(Modifier.height(4.dp)) + TextButton( + onClick = onViewSchema, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(Res.string.product_telemetry_sheet_view_schema)) + } + Spacer(Modifier.height(8.dp)) + } +} diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt index 6d2f03003..bf9a4253f 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt @@ -116,6 +116,19 @@ fun SearchRoot( val state by viewModel.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() val snackbarHost = remember { SnackbarHostState() } + val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry = org.koin.compose.koinInject() + + zed.rainxch.core.presentation.telemetry.TrackFirstPaint(isReady = !state.isLoading) { ms -> + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.SCREEN to "search", + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.BUCKET to + zed.rainxch.core.domain.telemetry.TelemetryBuckets.durationMs(ms), + ), + ) + } ObserveAsEvents(viewModel.events) { event -> when (event) { diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index 17f9530af..f8082e065 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -30,6 +30,10 @@ import zed.rainxch.core.domain.repository.SeenReposRepository import zed.rainxch.core.domain.repository.StarredRepository import zed.rainxch.core.domain.repository.TelemetryRepository import zed.rainxch.core.domain.repository.TweaksRepository +import zed.rainxch.core.domain.telemetry.ProductTelemetry +import zed.rainxch.core.domain.telemetry.ProductTelemetryEvents +import zed.rainxch.core.domain.telemetry.ProductTelemetryProps +import zed.rainxch.core.domain.telemetry.TelemetryBuckets import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.ClipboardHelper import zed.rainxch.core.domain.utils.ShareManager @@ -60,6 +64,7 @@ class SearchViewModel( private val seenReposRepository: SeenReposRepository, private val searchHistoryRepository: SearchHistoryRepository, private val telemetryRepository: TelemetryRepository, + private val productTelemetry: ProductTelemetry, ) : ViewModel() { private var hasLoadedInitialData = false private var currentSearchJob: Job? = null @@ -411,9 +416,17 @@ class SearchViewModel( } if (isInitial) { + val resultCount = _state.value.repositories.size telemetryRepository.recordSearchPerformed( - query = query, - resultCount = _state.value.repositories.size, + resultCount = resultCount, + ) + productTelemetry.fire( + name = ProductTelemetryEvents.SEARCH_EXECUTED, + props = + mapOf( + ProductTelemetryProps.RESULT_COUNT_BUCKET to + TelemetryBuckets.resultCount(resultCount), + ), ) } } catch (e: RateLimitException) { diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt index b40e0830c..92a830962 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt @@ -113,6 +113,13 @@ sealed interface TweaksAction { val enabled: Boolean, ) : TweaksAction + // E6 product-metric telemetry consent toggle. Maps directly to + // ProductTelemetryConsent.Granted / Denied — kept separate from + // OnTelemetryToggled so accepting one doesn't auto-grant the other. + data class OnProductTelemetryToggled( + val enabled: Boolean, + ) : TweaksAction + data object OnResetAnalyticsId : TweaksAction data class OnTranslationProviderSelected( diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt index 488d8d775..f82d73839 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksRoot.kt @@ -47,6 +47,21 @@ fun TweaksRoot(viewModel: TweaksViewModel = koinViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + val productTelemetry: zed.rainxch.core.domain.telemetry.ProductTelemetry = org.koin.compose.koinInject() + + // versionName is hydrated asynchronously in TweaksViewModel.onStart; + // its presence is the cheapest "first non-skeleton render" signal here. + zed.rainxch.core.presentation.telemetry.TrackFirstPaint(isReady = state.versionName.isNotEmpty()) { ms -> + productTelemetry.fire( + name = zed.rainxch.core.domain.telemetry.ProductTelemetryEvents.FIRST_PAINT_MS, + props = + mapOf( + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.SCREEN to "settings", + zed.rainxch.core.domain.telemetry.ProductTelemetryProps.BUCKET to + zed.rainxch.core.domain.telemetry.TelemetryBuckets.durationMs(ms), + ), + ) + } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt index 7d01f66b8..6edc4a835 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt @@ -28,6 +28,10 @@ data class TweaksState( val isHideSeenEnabled: Boolean = false, val isScrollbarEnabled: Boolean = false, val isTelemetryEnabled: Boolean = false, + // E6 product-metric telemetry consent. Three-state so the UI can show + // a first-launch prompt when NotYetAsked vs a normal toggle when set. + val productTelemetryConsent: zed.rainxch.core.domain.telemetry.ProductTelemetryConsent = + zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.NotYetAsked, val translationProvider: TranslationProvider = TranslationProvider.Default, /** * Transient UI-only selection used when the user picks a provider diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index 2d625542a..33c84b326 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -73,6 +73,7 @@ class TweaksViewModel( loadHideSeenEnabled() loadScrollbarEnabled() loadTelemetryEnabled() + loadProductTelemetryConsent() loadTranslationSettings() loadAppLanguage() @@ -348,6 +349,16 @@ class TweaksViewModel( } } + private fun loadProductTelemetryConsent() { + viewModelScope.launch { + tweaksRepository.getProductTelemetryConsent().collect { consent -> + _state.update { + it.copy(productTelemetryConsent = consent) + } + } + } + } + private fun loadTranslationSettings() { viewModelScope.launch { tweaksRepository.getTranslationProvider().collect { provider -> @@ -657,6 +668,15 @@ class TweaksViewModel( } } + is TweaksAction.OnProductTelemetryToggled -> { + viewModelScope.launch { + tweaksRepository.setProductTelemetryConsent( + if (action.enabled) zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.Granted + else zed.rainxch.core.domain.telemetry.ProductTelemetryConsent.Denied, + ) + } + } + TweaksAction.OnResetAnalyticsId -> { viewModelScope.launch { // Clear the telemetry buffer *before* resetting the ID. diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt index e67ccfb63..cc92c2f1f 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Others.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.telemetry.ProductTelemetryConsent import zed.rainxch.core.presentation.components.ExpressiveCard import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.tweaks.presentation.TweaksAction @@ -161,6 +162,17 @@ fun LazyListScope.othersSection( Spacer(Modifier.height(8.dp)) + ToggleSettingCard( + title = stringResource(Res.string.settings_product_telemetry_title), + description = stringResource(Res.string.settings_product_telemetry_description), + checked = state.productTelemetryConsent == ProductTelemetryConsent.Granted, + onCheckedChange = { enabled -> + onAction(TweaksAction.OnProductTelemetryToggled(enabled)) + }, + ) + + Spacer(Modifier.height(8.dp)) + ResetAnalyticsIdCard( onClick = { onAction(TweaksAction.OnResetAnalyticsId) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4e37a459..bf19f4c0f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ material-icons = "1.7.3" # AndroidX androidx-activity = "1.12.4" +androidx-lifecycle = "2.9.4" core-splashscreen = "1.2.0" # Kotlinx @@ -61,9 +62,11 @@ projectVersionCode = "14" # Kotlin androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } # AndroidX Core androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } jetbrains-compose-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "compose-lifecycle" } koin-core-viewmodel = { group = "io.insert-koin", name = "koin-core-viewmodel", version.ref = "koin" } core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "core-splashscreen" } diff --git a/roadmap/E6_CLIENT_HANDOFF.md b/roadmap/E6_CLIENT_HANDOFF.md new file mode 100644 index 000000000..d1bbe4c5b --- /dev/null +++ b/roadmap/E6_CLIENT_HANDOFF.md @@ -0,0 +1,440 @@ +# E6 — client handoff + +Status: foundation in place on `feature/e6-telemetry`. Backend endpoint live (`POST /v1/telemetry/events`). Roughly 30% of the user-visible work shipped: one event type fires (`app_launched`), no consent UI exists yet, 19 events unwired. + +This document is the punch list to take the client to GA. Read top to bottom. + +--- + +## 0. What already exists on this branch (do NOT redo) + +```text +core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/ + ProductTelemetry.kt // interface: fire(name, props) + flush() + ProductTelemetryConsent.kt // enum: NotYetAsked / Granted / Denied + ProductTelemetryEvents.kt // 20 event-name constants + ALL set + // 19 prop-key constants + ALLOWED set + +core/data/src/commonMain/kotlin/zed/rainxch/core/data/ + telemetry/ProductTelemetryImpl.kt + // Bounded ring buffer (500), 30s timer + 20-event threshold flush, + // exponential backoff 5s→5min, ephemeral session_id (16 random bytes, + // base64url, fresh per process), consent re-check at fire+flush+post-net + dto/ProductTelemetryEventBody.kt + network/BackendApiClient.kt // postProductTelemetryEvents added + repository/TweaksRepositoryImpl.kt // get/setProductTelemetryConsent (DataStore) + di/SharedModule.kt // ProductTelemetry registered as Koin single + +core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ + TweaksRepository.kt // get/setProductTelemetryConsent declared + +feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/ + TweaksState.kt // productTelemetryConsent: ProductTelemetryConsent + TweaksAction.kt // OnProductTelemetryToggled(enabled: Boolean) + TweaksViewModel.kt // observes consent flow, handles toggle action + +composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt + // fires ProductTelemetryEvents.APP_LAUNCHED from onCreate + +composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt + // fires ProductTelemetryEvents.APP_LAUNCHED from main() +``` + +`fire()` is a no-op when consent is not `Granted`. Currently nothing can grant consent because there's no UI. Wire that first, then events. + +--- + +## 1. Compose UI — Switch row in privacy section + +**Goal:** user can manually toggle product telemetry from the existing tweaks/privacy screen. + +**Find the existing legacy-telemetry Switch:** +```bash +grep -rn 'OnTelemetryToggled\|isTelemetryEnabled' feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/ +``` + +**Add a sibling `ListItem` directly below it:** +```kotlin +ListItem( + headlineContent = { Text(stringResource(Res.string.settings_product_telemetry_title)) }, + supportingContent = { + Text(stringResource(Res.string.settings_product_telemetry_description)) + }, + trailingContent = { + Switch( + checked = state.productTelemetryConsent == ProductTelemetryConsent.Granted, + onCheckedChange = { onAction(TweaksAction.OnProductTelemetryToggled(it)) }, + ) + }, +) +``` + +**Strings** (find the existing `settings_telemetry_*` keys and add neighbors): +```xml +Anonymous usage data +Help improve GitHub Store with anonymous, aggregate metrics. We never collect search queries, repo names, or any identifying information. Schema is open source. +``` + +**Acceptance:** toggling the Switch persists across app restart. + +--- + +## 2. First-launch consent bottom sheet + +Show when `productTelemetryConsent == NotYetAsked` and the user has reached the home screen. One screen, three buttons. + +### 2a. New file + +`feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/privacy/ProductTelemetryConsentSheet.kt` + +```kotlin +package zed.rainxch.profile.presentation.privacy + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProductTelemetryConsentSheet( + onGrant: () -> Unit, + onDeny: () -> Unit, + onViewSchema: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Help improve GitHub Store", style = MaterialTheme.typography.headlineSmall) + Text( + "We collect anonymous, aggregate usage data to find bugs and improve performance. We never collect your repos, search queries, or any identifying information. The full schema and collection code are open source.", + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onGrant, modifier = Modifier.fillMaxWidth()) { Text("Got it") } + TextButton(onClick = onDeny, modifier = Modifier.fillMaxWidth()) { Text("No thanks") } + TextButton(onClick = onViewSchema, modifier = Modifier.fillMaxWidth()) { Text("View what we collect") } + Spacer(Modifier.height(8.dp)) + } + } +} +``` + +### 2b. Wire from home + +Add a small `HomeConsentGateViewModel` that exposes `consent: StateFlow` and `grant() / deny()` (writes to `TweaksRepository`). In `HomeRoot`, observe and render the sheet conditionally. `onViewSchema` opens `https://github.com/OpenHub-Store/backend/blob/main/src/main/kotlin/zed/rainxch/githubstore/telemetry/TelemetryEvent.kt` (or, when the schema page exists, `https://github-store.org/telemetry-schema`) via `LocalUriHandler.current`. + +Dismissing without choosing keeps state at `NotYetAsked` so the sheet reappears next launch — intentional, no answer ≠ silent opt-in. + +**Acceptance:** fresh install → sheet appears once. "Got it"/"No thanks" persists, sheet doesn't reappear. Dismiss → sheet reappears next launch. + +--- + +## 3. Wire the 19 remaining events + +**Hard rules:** +- ALL event names from `ProductTelemetryEvents.*` constants. +- ALL prop keys from `ProductTelemetryProps.*` constants. +- NO inline strings on either side. A typo at a `fire()` call should be a compile error. +- NEVER `repo_name`, `query`, `username`, file paths, stack traces, exception messages, IDs, or anything user-identifying as prop values. Categorical / bucketed only. + +### 3.1 Reliability + +| Event | Where | Props | +|---|---|---| +| `CRASH` | `Thread.setDefaultUncaughtExceptionHandler` in `GithubStoreApp.onCreate` (Android) AND `CrashReporter.install` (Desktop) | `CATEGORY` ∈ `"data_loss" / "version_detect" / "install_fail" / "other"`, `PLATFORM` | +| `OPERATION_FAILED` | every `Result.failure(...)` branch in `core/data/.../services/` and `core/data/.../repository/` | `OP` ∈ `"download" / "install" / "update" / "fetch"`, `ERROR_CODE` (existing error enum's `name` — never raw exception messages) | + +Helper file `composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/CrashCategory.kt`: +```kotlin +fun categorizeCrash(throwable: Throwable): String = when { + throwable is OutOfMemoryError -> "other" + throwable.message?.contains("DataStore", ignoreCase = true) == true -> "data_loss" + throwable.message?.contains("install", ignoreCase = true) == true -> "install_fail" + throwable.message?.contains("version", ignoreCase = true) == true -> "version_detect" + else -> "other" +} +``` + +Crash handler: chain to the existing `Thread.getDefaultUncaughtExceptionHandler()` after firing + 500ms flush. Don't suppress the original handler. + +### 3.2 Performance + +| Event | Where | Props | +|---|---|---| +| `COLD_START_MS` | `Application.onCreate` start → first frame of `App` Composable; once per launch | `PLATFORM`, `BUCKET` | +| `FIRST_PAINT_MS` | per-screen first non-skeleton render | `SCREEN` ∈ `"home" / "details" / "library" / "settings" / "search"`, `BUCKET` | +| `CACHE_HIT` / `CACHE_MISS` | each repo / readme / icon cache lookup | `CACHE_NAME` ∈ `"details" / "icons" / "readmes" / "search_results"` | + +Bucketing helper `core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/telemetry/Buckets.kt`: +```kotlin +package zed.rainxch.core.domain.telemetry + +object TelemetryBuckets { + fun durationMs(ms: Long): String = when { + ms < 500 -> "<500" + ms < 1000 -> "500-1000" + ms < 3000 -> "1000-3000" + else -> ">3000" + } + + fun resultCount(n: Int): String = when { + n == 0 -> "0" + n <= 5 -> "1-5" + n <= 20 -> "6-20" + else -> ">20" + } + + fun confidence(score: Float): String = when { + score >= 0.85f -> "high" + score >= 0.5f -> "medium" + else -> "low" + } +} +``` + +### 3.3 Discovery / engagement + +| Event | Where | Props | +|---|---|---| +| `SEARCH_EXECUTED` | `SearchViewModel` on submit completion | `RESULT_COUNT_BUCKET` (via `TelemetryBuckets.resultCount`). **NEVER the query string.** | +| `DETAILS_VIEWED` | `DetailsViewModel` first-render | `FROM` ∈ `"search" / "category" / "library" / "link"` (derive from nav arg). **NEVER the repo name.** | +| `UPDATE_INSTALLED` | `DefaultDownloadOrchestrator` on install completion when `lastUpdatedAt` was bumped (i.e., update path, not first install) | (no required props) | + +> **Coexist, not replace, for `SEARCH_EXECUTED`.** The legacy `TelemetryRepository.recordSearchPerformed` carries `query_hash + result_count + repo_id` per click for ranking-miss tracking. The new `SEARCH_EXECUTED` is a count-only aggregate. Both fire. Different purposes, different backends. Same is true for the rest of §3.6 below. + +### 3.4 Import (E1) — REPLACE the legacy calls + +**The six import events currently fire via `TelemetryRepository.recordImport*` calls. Move them to `ProductTelemetry`. Delete the legacy calls in the same commit so the migration is atomic — no double-emission on the wire.** + +Sites (per E1's handoff doc): +- `core/data/.../repository/ExternalImportRepositoryImpl.kt` — `runFullScan` / `runDeltaScan` for `IMPORT_SCAN_STARTED`, `IMPORT_SCAN_COMPLETED`, `IMPORT_MATCH_ATTEMPTED`, `IMPORT_AUTO_LINKED` +- `feature/apps/.../ExternalImportViewModel.kt` — `skipPackage` / `pickSuggestion` / `submitSearchOverride` for `IMPORT_SKIPPED`, `IMPORT_MANUALLY_LINKED` + +| Event | Props | +|---|---| +| `IMPORT_SCAN_STARTED` | `PLATFORM` | +| `IMPORT_SCAN_COMPLETED` | `CANDIDATE_COUNT`, `DURATION_MS` | +| `IMPORT_MATCH_ATTEMPTED` | `STRATEGY` ∈ `"manifest" / "search" / "fingerprint"`, `CONFIDENCE_BUCKET` (via `TelemetryBuckets.confidence`) | +| `IMPORT_AUTO_LINKED` | `COUNT` | +| `IMPORT_MANUALLY_LINKED` | `COUNT` | +| `IMPORT_SKIPPED` | `COUNT` | + +**Out of scope — the following legacy import-related calls are NOT in the E6 allowlist and should be deleted in the same migration commit:** + +- `recordImportSearchOverrideUsed` +- `recordImportSearchOverrideNoResults` +- `recordImportPermissionRequested` +- `recordImportPermissionOutcome` +- `recordImportUnlinkedFromDetails` (in `DetailsViewModel.confirmUnlinkExternalApp`) + +These were finer-grained extensions the legacy pipeline took on. Either propose adding them to the E6 schema (requires backend allowlist update + migration coordination) OR delete them. **Recommended: delete.** They're ranking-signal-shaped but never used for ranking. + +### 3.5 Proxy (E5) + +| Event | Where | Props | +|---|---|---| +| `PROXY_CONFIGURED` | proxy ViewModel save handler | `TYPE` ∈ `"http" / "socks5" / "none"` | +| `PROXY_USED` | every outbound HTTP through the configured proxy (`BackendApiClient` request-completion site) | `SUCCESS: Bool` (true if 2xx) | +| `MIRROR_USED` | when a request used a mirror endpoint (search for `ghproxy`) | `PRESET` ∈ `"ghproxy" / "custom" / "none"`, `SUCCESS` | + +### 3.6 Session + +| Event | Where | Props | +|---|---|---| +| `SESSION_DURATION` | `ProcessLifecycleOwner.lifecycle` `ON_STOP` (Android) / `Window.onCloseRequest` (Desktop) | `SECONDS: Int` (compute from `coldStartAt` to now) | + +--- + +## 4. Pipeline boundary — what stays in `TelemetryRepository` + +**Do not migrate these. They have `repo_id` and feed `SignalAggregationWorker`'s ranking computation:** + +- `recordSearchPerformed` — drives ranking-miss tracking and result-freshness signals +- `recordSearchResultClicked` — drives CTR +- `recordRepoViewed` — drives view-count ranking signal +- `recordReleaseDownloaded`, `recordInstallStarted`, `recordInstallSucceeded`, `recordInstallFailed` — drive install-success-rate signal +- `recordAppOpenedAfterInstall` — drives engagement signal +- `recordUninstalled`, `recordFavorited`, `recordUnfavorited` — drive long-term retention signals + +These continue to flow through `/v1/events` and the `events` table. The two pipelines coexist by design — different jobs, different identity (hashed `device_id` vs ephemeral `session_id`), different consent toggles. + +--- + +## 5. PrivacyAuditTest + +### 5.1 Add `commonTest` to `core/domain` + +Most KMP modules in this repo don't have `commonTest`. If `core/data` or any other module already has it, copy that pattern into `core/domain/build.gradle.kts`. Otherwise: + +```kotlin +sourceSets { + val commonTest by getting { + dependencies { + implementation(libs.kotlin.test) + } + } +} +``` + +Ensure `libs.versions.toml` has: +```toml +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +``` + +### 5.2 Test file + +`core/domain/src/commonTest/kotlin/zed/rainxch/core/domain/telemetry/PrivacyAuditTest.kt`: + +```kotlin +package zed.rainxch.core.domain.telemetry + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PrivacyAuditTest { + + @Test + fun `event allowlist matches the backend's locked schema`() { + // Hardcoded copy of the backend's TelemetryAllowlist. Drift between + // client and server here means the server silently drops events the + // client thinks it's sending — fail loudly on mismatch. + val backendAllowlist = setOf( + "app_launched", "session_duration", + "import_scan_started", "import_scan_completed", "import_match_attempted", + "import_auto_linked", "import_manually_linked", "import_skipped", + "crash", "operation_failed", + "cold_start_ms", "first_paint_ms", "cache_hit", "cache_miss", + "proxy_configured", "proxy_used", "mirror_used", + "update_installed", "search_executed", "details_viewed", + ) + assertEquals(backendAllowlist, ProductTelemetryEvents.ALL) + } + + @Test + fun `no PII-shaped names slip into the allowlist`() { + val forbidden = listOf( + "user_id", "email", "search_query", "repo_name", "github_username", + "device_id", "ip_address", "owner", "name", "query", "username", + ) + forbidden.forEach { name -> + assertFalse(name in ProductTelemetryEvents.ALL, "$name leaked into ALL") + assertFalse(name in ProductTelemetryProps.ALLOWED, "$name leaked into ALLOWED props") + } + } + + @Test + fun `every prop key is short and snake_case`() { + ProductTelemetryProps.ALLOWED.forEach { key -> + assertFalse(key.contains(" "), "prop key '$key' has whitespace") + assertFalse(key.any { it.isUpperCase() }, "prop key '$key' has uppercase") + check(key.length <= 32) { "prop key '$key' exceeds 32-char cap" } + } + } +} +``` + +A source-tree static scan that asserts every `productTelemetry.fire(...)` call uses only `ProductTelemetryProps.*` keys is doable but requires a build task — skip unless you want CI-level enforcement. + +**Acceptance:** `./gradlew :core:domain:jvmTest` passes. + +--- + +## 6. Flush on app shutdown + +### Android +`GithubStoreApp.onCreate` — register a `ProcessLifecycleOwner` observer on `Lifecycle.Event.ON_STOP`: +```kotlin +ProcessLifecycleOwner.get().lifecycle.addObserver(LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + runBlocking { withTimeoutOrNull(2_000) { get().flush() } } + } +}) +``` +(`onTerminate` only fires in emulators — don't rely on it.) + +### Desktop +Before `application { … }` in `DesktopApp.kt`: +```kotlin +Runtime.getRuntime().addShutdownHook( + Thread { + runBlocking { + withTimeoutOrNull(2_000) { GlobalContext.get().get().flush() } + } + }, +) +``` + +--- + +## 7. Verification + +```bash +# Both targets compile +./gradlew :composeApp:compileKotlinJvm :composeApp:compileDebugKotlinAndroid + +# Tests pass (after §5.1 commonTest infra) +./gradlew :core:domain:jvmTest + +# Manual verification against staging backend +# 1. Fresh-install device, open app → consent sheet appears +# 2. "Got it" → use the app for 30s → kill the app +# 3. SSH to VPS: +# docker exec github-store-backend-postgres-1 psql -U githubstore -d githubstore \ +# -c "SELECT name, count(*) FROM telemetry_events GROUP BY name ORDER BY count DESC;" +# Should show app_launched + cold_start_ms + first_paint_ms + (whatever else got wired) +# 4. Toggle off in Settings → use the app → re-query → no new rows +``` + +--- + +## 8. Commit conventions + +One logical change per commit. Suggested order: + +1. Render product telemetry consent toggle in privacy settings +2. Show first-launch consent sheet on home screen +3. Add TelemetryBuckets helper for duration / count / confidence bucketing +4. Wire CRASH and OPERATION_FAILED events +5. Wire COLD_START_MS and FIRST_PAINT_MS events +6. Wire CACHE_HIT and CACHE_MISS events +7. Wire SEARCH_EXECUTED and DETAILS_VIEWED events (coexist with legacy) +8. Migrate import events from TelemetryRepository to ProductTelemetry (E1 handoff §) +9. Wire PROXY_CONFIGURED / PROXY_USED / MIRROR_USED events +10. Wire SESSION_DURATION event on app stop +11. Add PrivacyAuditTest for telemetry allowlist +12. Flush telemetry buffer on app shutdown + +After all merge: open PR `feature/e6-telemetry → main`, ship a new app release. Schema URL (`github-store.org/telemetry-schema`) can land separately — the consent sheet handles the missing URL gracefully (link goes to the GitHub source of the backend's `TelemetryEvent.kt`). + +--- + +## 9. Backend contract recap + +```text +POST https://api.github-store.org/v1/telemetry/events +Body: +{ + "events": [ + { + "name": "...", // must be in ProductTelemetryEvents.ALL + "sessionId": "...", // ≤128 chars, ephemeral, reset per launch + "timestamp": 1745678901234, + "platform": "android" | "macos" | "windows" | "linux", + "appVersion": "1.7.0", + "props": { ... } // optional; keys from ProductTelemetryProps.ALLOWED + } + ] +} +Returns: 204 always (drops non-allowlisted server-side; partial success counts as success) +Rate limit: 600 / min / IP +Batch cap: 100 events; over → 400 +Field caps: name 64, sessionId 128, platform 32, appVersion 32; over → 400 +``` + +Status codes other than 204 / 400 are transient — the impl already retries with exponential backoff.