From 672acadba6ff76eb2d69ed4706793eeab6d01987 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 19:12:15 -0800 Subject: [PATCH] feat(android): add telemetry client, feature flags client, wire into DI + ViewModel --- .../java/com/chronomind/app/di/AppModule.kt | 32 ++++ .../app/telemetry/FeatureFlagService.kt | 88 +++++++++ .../app/telemetry/TelemetryService.kt | 177 ++++++++++++++++++ .../app/viewmodel/TimerViewModel.kt | 43 ++++- 4 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/com/chronomind/app/telemetry/FeatureFlagService.kt create mode 100644 android/app/src/main/java/com/chronomind/app/telemetry/TelemetryService.kt diff --git a/android/app/src/main/java/com/chronomind/app/di/AppModule.kt b/android/app/src/main/java/com/chronomind/app/di/AppModule.kt index b39e264..e2adcfb 100644 --- a/android/app/src/main/java/com/chronomind/app/di/AppModule.kt +++ b/android/app/src/main/java/com/chronomind/app/di/AppModule.kt @@ -5,6 +5,9 @@ import androidx.room.Room import com.chronomind.app.data.TimerDao import com.chronomind.app.data.TimerDatabase import com.chronomind.app.notifications.TimerNotificationManager +import com.chronomind.app.sync.SyncRepository +import com.chronomind.app.telemetry.FeatureFlagService +import com.chronomind.app.telemetry.TelemetryService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -39,4 +42,33 @@ object AppModule { it.createNotificationChannels() } } + + @Provides + @Singleton + fun provideSyncRepository( + @ApplicationContext context: Context, + timerDao: TimerDao + ): SyncRepository { + return SyncRepository(context, timerDao).also { + it.restoreAuthToken() + } + } + + @Provides + @Singleton + fun provideTelemetryService( + @ApplicationContext context: Context + ): TelemetryService { + return TelemetryService(context).also { + it.start() + } + } + + @Provides + @Singleton + fun provideFeatureFlagService( + @ApplicationContext context: Context + ): FeatureFlagService { + return FeatureFlagService(context) + } } diff --git a/android/app/src/main/java/com/chronomind/app/telemetry/FeatureFlagService.kt b/android/app/src/main/java/com/chronomind/app/telemetry/FeatureFlagService.kt new file mode 100644 index 0000000..e07ca87 --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/telemetry/FeatureFlagService.kt @@ -0,0 +1,88 @@ +package com.chronomind.app.telemetry + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import javax.inject.Inject +import javax.inject.Singleton + +// ── Feature Flag Service ───────────────────────────────────── +// Polls platform-service /flags/poll for feature flags. +// Flags cached in memory, re-polled every 5 minutes. + +@Serializable +private data class FlagsResponse(val flags: Map) + +@Singleton +class FeatureFlagService @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val json = Json { ignoreUnknownKeys = true } + private val pollIntervalMs = 5 * 60 * 1000L // 5 minutes + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var pollJob: Job? = null + + private val _flags = MutableStateFlow>(emptyMap()) + val flags: StateFlow> = _flags.asStateFlow() + + private val baseUrl: String + get() = context.applicationInfo.metaData?.getString("PLATFORM_SERVICE_URL") + ?: "https://api.chronomind.app" + + // MARK: - Public API + + fun start(userId: String? = null) { + if (pollJob != null) return + // Initial fetch + scope.launch { fetchFlags(userId) } + // Periodic poll + pollJob = scope.launch { + while (isActive) { + delay(pollIntervalMs) + fetchFlags(userId) + } + } + } + + fun stop() { + pollJob?.cancel() + pollJob = null + } + + fun isEnabled(key: String): Boolean = _flags.value[key] == true + + // MARK: - Fetch + + private suspend fun fetchFlags(userId: String? = null) { + try { + val params = mutableListOf("platform=android") + if (!userId.isNullOrEmpty()) { + params.add("userId=${URLEncoder.encode(userId, "UTF-8")}") + } + val queryString = params.joinToString("&") + val url = URL("$baseUrl/api/flags/poll?$queryString") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.setRequestProperty("X-Product-Id", "chronomind") + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + + if (conn.responseCode == 200) { + val body = conn.inputStream.bufferedReader().readText() + val parsed = json.decodeFromString(body) + _flags.value = parsed.flags + } + conn.disconnect() + } catch (_: Exception) { + // Keep existing flags on error + } + } +} diff --git a/android/app/src/main/java/com/chronomind/app/telemetry/TelemetryService.kt b/android/app/src/main/java/com/chronomind/app/telemetry/TelemetryService.kt new file mode 100644 index 0000000..87476f1 --- /dev/null +++ b/android/app/src/main/java/com/chronomind/app/telemetry/TelemetryService.kt @@ -0,0 +1,177 @@ +package com.chronomind.app.telemetry + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject +import javax.inject.Singleton + +// ── Telemetry Event ────────────────────────────────────────── + +@Serializable +data class TelemetryEvent( + val id: String, + val productId: String, + val anonymousInstallId: String, + val sessionId: String, + val platform: String, + val channel: String, + val osFamily: String, + val osVersion: String, + val appVersion: String, + val buildNumber: String, + val releaseChannel: String, + val eventType: String, + val module: String, + val eventName: String, + val feature: String? = null, + val message: String? = null, + val tags: Map? = null, + val metrics: Map? = null, + val occurredAt: String, +) + +@Serializable +private data class EventBatch(val events: List) + +// ── Telemetry Service ──────────────────────────────────────── + +@Singleton +class TelemetryService @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val prefs: SharedPreferences = + context.getSharedPreferences("chronomind_telemetry", Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val queue = mutableListOf() + private val maxQueue = 50 + private val flushIntervalMs = 30_000L + private var flushJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val sessionId = UUID.randomUUID().toString() + + private val installId: String + get() { + val key = "install_id" + var id = prefs.getString(key, null) + if (id == null) { + id = UUID.randomUUID().toString() + prefs.edit().putString(key, id).apply() + } + return id + } + + private val baseUrl: String + get() = context.applicationInfo.metaData?.getString("PLATFORM_SERVICE_URL") + ?: "https://api.chronomind.app" + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + // MARK: - Public API + + fun start() { + if (flushJob != null) return + flushJob = scope.launch { + while (isActive) { + delay(flushIntervalMs) + flush() + } + } + } + + fun stop() { + flush() + flushJob?.cancel() + flushJob = null + } + + fun trackEvent( + eventType: String, + module: String, + name: String, + feature: String? = null, + message: String? = null, + tags: Map? = null, + metrics: Map? = null, + ) { + val event = TelemetryEvent( + id = UUID.randomUUID().toString(), + productId = "chronomind", + anonymousInstallId = installId, + sessionId = sessionId, + platform = "android", + channel = "native", + osFamily = "Android", + osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + appVersion = "0.1.0", + buildNumber = "1", + releaseChannel = "beta", + eventType = eventType, + module = module, + eventName = name, + feature = feature, + message = message, + tags = tags, + metrics = metrics, + occurredAt = isoFormat.format(Date()), + ) + + synchronized(queue) { + queue.add(event) + if (queue.size >= maxQueue) { + flush() + } + } + } + + fun trackTimer(name: String, tags: Map? = null, metrics: Map? = null) { + trackEvent("info", "timers", name, tags = tags, metrics = metrics) + } + + fun trackScreen(screen: String) { + trackEvent("info", "navigation", "screen_view", tags = mapOf("screen" to screen)) + } + + fun flush() { + val batch: List + synchronized(queue) { + if (queue.isEmpty()) return + batch = queue.toList() + queue.clear() + } + + scope.launch { + try { + val url = URL("$baseUrl/telemetry/events") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("X-Product-Id", "chronomind") + conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString()) + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + conn.doOutput = true + + val body = json.encodeToString(EventBatch(batch)) + conn.outputStream.bufferedWriter().use { it.write(body) } + + conn.responseCode // trigger the request + conn.disconnect() + } catch (_: Exception) { + // Fire-and-forget — errors never surface to the user + } + } + } +} diff --git a/android/app/src/main/java/com/chronomind/app/viewmodel/TimerViewModel.kt b/android/app/src/main/java/com/chronomind/app/viewmodel/TimerViewModel.kt index 06137db..9bbf898 100644 --- a/android/app/src/main/java/com/chronomind/app/viewmodel/TimerViewModel.kt +++ b/android/app/src/main/java/com/chronomind/app/viewmodel/TimerViewModel.kt @@ -6,6 +6,8 @@ import com.chronomind.app.data.TimerDao import com.chronomind.app.data.toEntity import com.chronomind.app.data.toTimer import com.chronomind.app.engine.* +import com.chronomind.app.sync.SyncRepository +import com.chronomind.app.telemetry.TelemetryService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +21,9 @@ import javax.inject.Inject @HiltViewModel class TimerViewModel @Inject constructor( - private val timerDao: TimerDao + private val timerDao: TimerDao, + private val syncRepository: SyncRepository, + private val telemetryService: TelemetryService, ) : ViewModel() { private val _timers = MutableStateFlow>(emptyList()) @@ -65,29 +69,56 @@ class TimerViewModel @Inject constructor( val timer = createCountdown(label, durationSeconds) _timers.value = _timers.value + timer persist(timer) + if (syncRepository.syncEnabled) syncRepository.enqueueCreate(timer) + telemetryService.trackTimer("timer_created", tags = mapOf("type" to "countdown")) } fun addAlarm(label: String, targetTime: Date, urgency: UrgencyLevel = UrgencyLevel.STANDARD) { val timer = createAlarm(label, targetTime, urgency) _timers.value = _timers.value + timer persist(timer) + if (syncRepository.syncEnabled) syncRepository.enqueueCreate(timer) + telemetryService.trackTimer("timer_created", tags = mapOf("type" to "alarm", "urgency" to urgency.name)) } fun addPomodoro(label: String = "Focus Session", config: PomodoroConfig = PomodoroConfig()) { val timer = createPomodoro(label, config) _timers.value = _timers.value + timer persist(timer) + if (syncRepository.syncEnabled) syncRepository.enqueueCreate(timer) + telemetryService.trackTimer("timer_created", tags = mapOf("type" to "pomodoro")) } - fun pause(id: String) = updateTimer(id) { pauseTimer(it) } - fun resume(id: String) = updateTimer(id) { resumeTimer(it) } - fun dismiss(id: String) = updateTimer(id) { dismissTimer(it) } - fun complete(id: String) = updateTimer(id) { completeTimer(it) } - fun snooze(id: String, minutes: Int) = updateTimer(id) { snoozeTimer(it, minutes) } + fun pause(id: String) { + updateTimer(id) { pauseTimer(it) } + if (syncRepository.syncEnabled) _timers.value.find { it.id == id }?.let { syncRepository.enqueueUpdate(it) } + } + + fun resume(id: String) { + updateTimer(id) { resumeTimer(it) } + if (syncRepository.syncEnabled) _timers.value.find { it.id == id }?.let { syncRepository.enqueueUpdate(it) } + } + + fun dismiss(id: String) { + updateTimer(id) { dismissTimer(it) } + if (syncRepository.syncEnabled) _timers.value.find { it.id == id }?.let { syncRepository.enqueueUpdate(it) } + } + + fun complete(id: String) { + updateTimer(id) { completeTimer(it) } + if (syncRepository.syncEnabled) _timers.value.find { it.id == id }?.let { syncRepository.enqueueUpdate(it) } + } + + fun snooze(id: String, minutes: Int) { + updateTimer(id) { snoozeTimer(it, minutes) } + if (syncRepository.syncEnabled) _timers.value.find { it.id == id }?.let { syncRepository.enqueueUpdate(it) } + } fun removeTimer(id: String) { _timers.value = _timers.value.filter { it.id != id } viewModelScope.launch { timerDao.deleteById(id) } + if (syncRepository.syncEnabled) syncRepository.enqueueDelete(id) + telemetryService.trackTimer("timer_deleted") } // MARK: - Tick