Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b5980e5
Add ProductTelemetry interface and consent state for E6
rainxchzed Apr 27, 2026
d55d505
Add ProductTelemetryImpl with bounded queue, exponential backoff, con…
rainxchzed Apr 27, 2026
2098161
Surface E6 event names and prop keys as Kotlin constants
rainxchzed Apr 27, 2026
4e2a56b
Add product telemetry consent state and toggle action to Tweaks
rainxchzed Apr 27, 2026
2c02650
Fire app_launched on Android Application.onCreate and Desktop main()
rainxchzed Apr 27, 2026
7334492
Add E6 client-side handoff doc with E1 overlap resolution
rainxchzed Apr 27, 2026
27acef0
Merge branch 'main' into feature/e6-telemetry
rainxchzed Apr 28, 2026
22427c4
Render product telemetry consent toggle in privacy settings
rainxchzed Apr 28, 2026
0437cf9
Show first-launch consent sheet on home screen
rainxchzed Apr 28, 2026
e6fa4e9
Add TelemetryBuckets helper for duration, count, and confidence bucke…
rainxchzed Apr 28, 2026
4ea0dcb
Wire CRASH and OPERATION_FAILED telemetry events
rainxchzed Apr 28, 2026
3d45e56
Wire COLD_START_MS and FIRST_PAINT_MS telemetry events
rainxchzed Apr 28, 2026
675d631
Wire CACHE_HIT and CACHE_MISS telemetry events in CacheManager
rainxchzed Apr 28, 2026
0283e72
Wire SEARCH_EXECUTED, DETAILS_VIEWED, and UPDATE_INSTALLED telemetry …
rainxchzed Apr 28, 2026
a11534b
Migrate import telemetry events from TelemetryRepository to ProductTe…
rainxchzed Apr 28, 2026
a8691a2
Wire PROXY_CONFIGURED and PROXY_USED telemetry events
rainxchzed Apr 28, 2026
4d37ad5
Wire SESSION_DURATION telemetry event on app stop
rainxchzed Apr 28, 2026
c69a3c9
Add PrivacyAuditTest for telemetry allowlist
rainxchzed Apr 28, 2026
42d0319
Flush product telemetry buffer on app shutdown
rainxchzed Apr 28, 2026
0c2ebd7
Stabilize ProductTelemetryImpl: break Koin cycle, rethrow Cancellatio…
rainxchzed Apr 28, 2026
e3f535f
Stop telemetry POST from re-entering the buffer (recursion fix)
rainxchzed Apr 28, 2026
736a23b
GithubStoreApp: per-foreground SESSION_DURATION (off main thread), UE…
rainxchzed Apr 28, 2026
8d92687
DesktopApp: crash-reporter installs first, deep-link forwarders skip …
rainxchzed Apr 28, 2026
07f83e6
UpdateCheckWorker: rethrow CancellationException instead of firing OP…
rainxchzed Apr 28, 2026
87f8210
Replace nav 'from' string with closed DetailsFrom enum
rainxchzed Apr 28, 2026
a89d2d5
Fire DETAILS_VIEWED only once per ViewModel instance, not on every Retry
rainxchzed Apr 28, 2026
36a10a8
Hide consent sheet during initial load instead of treating it as 'not…
rainxchzed Apr 28, 2026
3058076
Pin TELEMETRY_SCHEMA_URL to a release commit so the consent sheet mat…
rainxchzed Apr 28, 2026
cb22d98
Fire IMPORT_AUTO_LINKED, switch IMPORT_MATCH_ATTEMPTED to TelemetryBu…
rainxchzed Apr 28, 2026
de12647
Bucket OPERATION_FAILED error codes by category instead of class simp…
rainxchzed Apr 28, 2026
fc99e64
Make UPDATE_INSTALLED and PROXY_CONFIGURED telemetry best-effort so t…
rainxchzed Apr 28, 2026
4fb5e87
Soften privacy copy: 'personal or account identifiers' vs 'any identi…
rainxchzed Apr 28, 2026
762de69
TelemetryBuckets.resultCount: bucket negative input as 'invalid' inst…
rainxchzed Apr 28, 2026
c707604
Add language tags to E6 handoff fenced code blocks
rainxchzed Apr 28, 2026
e00426c
Consent sheet: tighter button spacing, swap to inline schema view via…
rainxchzed Apr 28, 2026
38e7476
Add inline 'What we collect' view enumerating every actually-fired event
rainxchzed Apr 28, 2026
ea71b5e
Stop sending hashed search queries to the legacy /v1/events pipeline
rainxchzed Apr 28, 2026
757e68e
Drop redundant app_launched props (backend strips them; platform/vers…
rainxchzed Apr 28, 2026
7a09872
Acknowledge server-side processing in privacy view (search-misses, HT…
rainxchzed Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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<ProductTelemetry>()
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() }
}
Comment on lines +72 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

fire() races the flush in both lifecycle-sensitive paths.

ProductTelemetryImpl.fire() appends asynchronously, while both of these paths flush right away. If the enqueue hasn't run yet, SESSION_DURATION/CRASH is dropped even though the flush completes normally. This needs a synchronous/suspend enqueue path for termination-sensitive events before flushing.

Also applies to: 99-110

}

else -> {
Unit
}
}
},
)
}

private fun installCrashTelemetryHandler() {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
val telemetry = get<ProductTelemetry>()
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<ProductTelemetry>().fire(name = ProductTelemetryEvents.APP_LAUNCHED)
}

private fun scheduleInitialExternalScan() {
appScope.launch {
runCatching {
Expand Down
30 changes: 30 additions & 0 deletions composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ fun AppNavigation(
navController.navigate(
GithubStoreGraph.DetailsScreen(
repositoryId = repoId,
from = DetailsFrom.Category,
),
)
},
Expand All @@ -112,6 +113,7 @@ fun AppNavigation(
navController.navigate(
GithubStoreGraph.DetailsScreen(
repositoryId = repoId,
from = DetailsFrom.Search,
),
)
},
Expand All @@ -120,6 +122,7 @@ fun AppNavigation(
GithubStoreGraph.DetailsScreen(
owner = owner,
repo = repo,
from = DetailsFrom.Link,
),
)
},
Expand All @@ -143,6 +146,7 @@ fun AppNavigation(
navController.navigate(
GithubStoreGraph.DetailsScreen(
repositoryId = repoId,
from = DetailsFrom.Link,
),
)
},
Expand All @@ -160,6 +164,7 @@ fun AppNavigation(
args.owner,
args.repo,
args.isComingFromUpdate,
args.from.slug,
)
},
)
Expand All @@ -175,6 +180,7 @@ fun AppNavigation(
navController.navigate(
GithubStoreGraph.DetailsScreen(
repositoryId = repoId,
from = DetailsFrom.Link,
),
)
},
Expand Down Expand Up @@ -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(
Expand All @@ -224,6 +235,7 @@ fun AppNavigation(
navController.navigate(
GithubStoreGraph.DetailsScreen(
repositoryId = repoId,
from = DetailsFrom.Library,
),
)
},
Expand Down Expand Up @@ -277,6 +289,7 @@ fun AppNavigation(
navController.navigate(
GithubStoreGraph.DetailsScreen(
repositoryId = repoId,
from = DetailsFrom.Library,
),
)
},
Expand Down Expand Up @@ -323,6 +336,7 @@ fun AppNavigation(
GithubStoreGraph.DetailsScreen(
repositoryId = repoId,
isComingFromUpdate = true,
from = DetailsFrom.Library,
),
)
},
Expand All @@ -344,6 +358,7 @@ fun AppNavigation(
GithubStoreGraph.DetailsScreen(
repositoryId = repoId,
isComingFromUpdate = true,
from = DetailsFrom.Library,
),
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading