feat(android): add telemetry client, feature flags client, wire into DI + ViewModel

This commit is contained in:
saravanakumardb1 2026-02-28 19:12:15 -08:00
parent 180c98160b
commit 672acadba6
4 changed files with 334 additions and 6 deletions

View File

@ -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)
}
}

View File

@ -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<String, Boolean>)
@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<Map<String, Boolean>>(emptyMap())
val flags: StateFlow<Map<String, Boolean>> = _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<FlagsResponse>(body)
_flags.value = parsed.flags
}
conn.disconnect()
} catch (_: Exception) {
// Keep existing flags on error
}
}
}

View File

@ -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<String, String>? = null,
val metrics: Map<String, Double>? = null,
val occurredAt: String,
)
@Serializable
private data class EventBatch(val events: List<TelemetryEvent>)
// ── 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<TelemetryEvent>()
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<String, String>? = null,
metrics: Map<String, Double>? = 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<String, String>? = null, metrics: Map<String, Double>? = 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<TelemetryEvent>
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
}
}
}
}

View File

@ -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<List<CMTimer>>(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