feat(android): add telemetry client, feature flags client, wire into DI + ViewModel
This commit is contained in:
parent
180c98160b
commit
672acadba6
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user