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.TimerDao
|
||||||
import com.chronomind.app.data.TimerDatabase
|
import com.chronomind.app.data.TimerDatabase
|
||||||
import com.chronomind.app.notifications.TimerNotificationManager
|
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.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@ -39,4 +42,33 @@ object AppModule {
|
|||||||
it.createNotificationChannels()
|
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.toEntity
|
||||||
import com.chronomind.app.data.toTimer
|
import com.chronomind.app.data.toTimer
|
||||||
import com.chronomind.app.engine.*
|
import com.chronomind.app.engine.*
|
||||||
|
import com.chronomind.app.sync.SyncRepository
|
||||||
|
import com.chronomind.app.telemetry.TelemetryService
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@ -19,7 +21,9 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TimerViewModel @Inject constructor(
|
class TimerViewModel @Inject constructor(
|
||||||
private val timerDao: TimerDao
|
private val timerDao: TimerDao,
|
||||||
|
private val syncRepository: SyncRepository,
|
||||||
|
private val telemetryService: TelemetryService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _timers = MutableStateFlow<List<CMTimer>>(emptyList())
|
private val _timers = MutableStateFlow<List<CMTimer>>(emptyList())
|
||||||
@ -65,29 +69,56 @@ class TimerViewModel @Inject constructor(
|
|||||||
val timer = createCountdown(label, durationSeconds)
|
val timer = createCountdown(label, durationSeconds)
|
||||||
_timers.value = _timers.value + timer
|
_timers.value = _timers.value + timer
|
||||||
persist(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) {
|
fun addAlarm(label: String, targetTime: Date, urgency: UrgencyLevel = UrgencyLevel.STANDARD) {
|
||||||
val timer = createAlarm(label, targetTime, urgency)
|
val timer = createAlarm(label, targetTime, urgency)
|
||||||
_timers.value = _timers.value + timer
|
_timers.value = _timers.value + timer
|
||||||
persist(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()) {
|
fun addPomodoro(label: String = "Focus Session", config: PomodoroConfig = PomodoroConfig()) {
|
||||||
val timer = createPomodoro(label, config)
|
val timer = createPomodoro(label, config)
|
||||||
_timers.value = _timers.value + timer
|
_timers.value = _timers.value + timer
|
||||||
persist(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 pause(id: String) {
|
||||||
fun resume(id: String) = updateTimer(id) { resumeTimer(it) }
|
updateTimer(id) { pauseTimer(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) }
|
}
|
||||||
fun snooze(id: String, minutes: Int) = updateTimer(id) { snoozeTimer(it, minutes) }
|
|
||||||
|
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) {
|
fun removeTimer(id: String) {
|
||||||
_timers.value = _timers.value.filter { it.id != id }
|
_timers.value = _timers.value.filter { it.id != id }
|
||||||
viewModelScope.launch { timerDao.deleteById(id) }
|
viewModelScope.launch { timerDao.deleteById(id) }
|
||||||
|
if (syncRepository.syncEnabled) syncRepository.enqueueDelete(id)
|
||||||
|
telemetryService.trackTimer("timer_deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tick
|
// MARK: - Tick
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user