refactor(android): Phase 1 — delete dead services, migrate sync to OkHttp, wire kill switch

- Delete auth/AuthService.kt (328 LOC dead code — LoginScreen already uses BLAuthClient)
- Delete telemetry/TelemetryService.kt (178 LOC dead code — TimerViewModel already uses BLTelemetryClient)
- Delete telemetry/FeatureFlagService.kt (89 LOC dead code — PlatformModule provides BLFeatureFlagClient)
- Migrate sync/PlatformApiClient.kt from raw HttpURLConnection to OkHttp (consistent with kotlin-platform-sdk)
- Migrate sync/SyncRepository.kt to use BLSecureStore for auth token instead of manual SharedPreferences
- Wire BLKillSwitchClient in MainActivity.kt with kill switch check on app start
- Update AppModule.kt to inject BLSecureStore into SyncRepository

Part of Mobile DRY Refactoring Roadmap Phase 1.
This commit is contained in:
saravanakumardb1 2026-03-20 22:19:11 -07:00
parent f7356706cd
commit 2060a831bf
7 changed files with 112 additions and 679 deletions

View File

@ -4,12 +4,25 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.bytelyst.platform.BLAuthClient
import com.bytelyst.platform.BLKillSwitchClient
import com.chronomind.app.auth.LoginScreen
import com.chronomind.app.ui.navigation.ChronoMindNavHost
import com.chronomind.app.ui.theme.CMColors
@ -20,6 +33,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var authClient: BLAuthClient
@Inject lateinit var killSwitchClient: BLKillSwitchClient
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -27,16 +41,47 @@ class MainActivity : ComponentActivity() {
setContent {
ChronoMindTheme {
val authState by authClient.state.collectAsState()
if (authState is BLAuthClient.AuthState.LoggedIn) {
Surface(
modifier = Modifier.fillMaxSize(),
color = CMColors.bg
) {
ChronoMindNavHost()
var killed by remember { mutableStateOf(false) }
var killMessage by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
val result = killSwitchClient.check()
if (result.killed) {
killed = true
killMessage = result.message ?: "ChronoMind is temporarily unavailable."
}
}
when {
killed -> {
Surface(modifier = Modifier.fillMaxSize(), color = CMColors.bg) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = killMessage,
color = CMColors.text,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}
}
else -> {
val authState by authClient.state.collectAsState()
if (authState is BLAuthClient.AuthState.LoggedIn) {
Surface(
modifier = Modifier.fillMaxSize(),
color = CMColors.bg
) {
ChronoMindNavHost()
}
} else {
LoginScreen(authClient = authClient)
}
}
} else {
LoginScreen(authClient = authClient)
}
}
}

View File

@ -1,327 +0,0 @@
package com.chronomind.app.auth
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private val json = Json { ignoreUnknownKeys = true }
private const val PREFS_NAME = "chronomind_auth"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_USER_EMAIL = "user_email"
private const val KEY_USER_NAME = "user_name"
private const val KEY_USER_PLAN = "user_plan"
private const val KEY_USER_ID = "user_id"
private const val PRODUCT_ID = "chronomind"
@Serializable
data class AuthUser(
val id: String = "",
val email: String = "",
@SerialName("displayName")
val name: String = "",
val plan: String = "free",
val role: String = "user",
)
@Serializable
data class TokenResponse(
val accessToken: String,
val refreshToken: String,
val user: AuthUser,
)
@Serializable
data class RefreshResponse(
val accessToken: String,
val refreshToken: String,
)
sealed class AuthState {
data object Loading : AuthState()
data object LoggedOut : AuthState()
data class LoggedIn(val user: AuthUser) : AuthState()
data class Error(val message: String) : AuthState()
}
@Singleton
class AuthService @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
val state: StateFlow<AuthState> = _state.asStateFlow()
val isLoggedIn: Boolean
get() = _state.value is AuthState.LoggedIn
val currentUser: AuthUser?
get() = (_state.value as? AuthState.LoggedIn)?.user
fun getAccessToken(): String? {
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
return if (token.isNullOrBlank()) null else token
}
private fun getBaseUrl(): String {
return context.getSharedPreferences("chronomind_sync", Context.MODE_PRIVATE)
.getString("base_url", null) ?: "https://api.chronomind.app"
}
fun checkExistingSession() {
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
val email = prefs.getString(KEY_USER_EMAIL, null)
if (!token.isNullOrBlank() && !email.isNullOrBlank()) {
val name = prefs.getString(KEY_USER_NAME, "") ?: ""
val plan = prefs.getString(KEY_USER_PLAN, "free") ?: "free"
val id = prefs.getString(KEY_USER_ID, "") ?: ""
_state.value = AuthState.LoggedIn(AuthUser(id = id, email = email, name = name, plan = plan))
wireSyncToken()
} else {
_state.value = AuthState.LoggedOut
}
}
suspend fun login(email: String, password: String) {
_state.value = AuthState.Loading
val body = json.encodeToString(
MapSerializer(String.serializer(), String.serializer()),
mapOf("email" to email, "password" to password, "productId" to PRODUCT_ID),
)
val result = postAuth("/auth/login", body)
if (result == null) {
_state.value = AuthState.Error("Invalid email or password")
return
}
handleAuthResult(result)
}
suspend fun register(name: String, email: String, password: String) {
_state.value = AuthState.Loading
val body = json.encodeToString(
MapSerializer(String.serializer(), String.serializer()),
mapOf(
"email" to email,
"displayName" to name,
"password" to password,
"productId" to PRODUCT_ID,
),
)
val result = postAuth("/auth/register", body)
if (result == null) {
_state.value = AuthState.Error("Registration failed")
return
}
handleAuthResult(result)
}
fun logout() {
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.remove(KEY_REFRESH_TOKEN)
.remove(KEY_USER_EMAIL)
.remove(KEY_USER_NAME)
.remove(KEY_USER_PLAN)
.remove(KEY_USER_ID)
.apply()
_state.value = AuthState.LoggedOut
// Clear sync token
context.getSharedPreferences("chronomind_sync", Context.MODE_PRIVATE)
.edit().remove("auth_token").apply()
}
suspend fun refreshAccessToken(): Boolean = withContext(Dispatchers.IO) {
val rt = prefs.getString(KEY_REFRESH_TOKEN, null) ?: return@withContext false
val baseUrl = getBaseUrl()
val body = json.encodeToString(
MapSerializer(String.serializer(), String.serializer()),
mapOf("refreshToken" to rt),
)
try {
val url = URL("$baseUrl/auth/refresh")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("X-Product-Id", PRODUCT_ID)
conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString())
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
conn.doOutput = true
conn.outputStream.use { it.write(body.toByteArray()) }
if (conn.responseCode == 200) {
val responseBody = conn.inputStream.bufferedReader().readText()
val refreshResp = json.decodeFromString<RefreshResponse>(responseBody)
prefs.edit()
.putString(KEY_ACCESS_TOKEN, refreshResp.accessToken)
.putString(KEY_REFRESH_TOKEN, refreshResp.refreshToken)
.apply()
wireSyncToken()
true
} else if (conn.responseCode == 401) {
withContext(Dispatchers.Main) { logout() }
false
} else {
false
}
} catch (_: Exception) {
false
}
}
suspend fun forgotPassword(email: String): String? = withContext(Dispatchers.IO) {
val baseUrl = getBaseUrl()
val body = json.encodeToString(
MapSerializer(String.serializer(), String.serializer()),
mapOf("email" to email, "productId" to PRODUCT_ID),
)
try {
val url = URL("$baseUrl/auth/forgot-password")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("X-Product-Id", PRODUCT_ID)
conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString())
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.doOutput = true
conn.outputStream.use { it.write(body.toByteArray()) }
if (conn.responseCode == 200) null
else "Failed to send reset email"
} catch (e: Exception) {
e.message ?: "Network error"
}
}
suspend fun changePassword(currentPassword: String, newPassword: String): String? = withContext(Dispatchers.IO) {
val token = prefs.getString(KEY_ACCESS_TOKEN, null) ?: return@withContext "Not authenticated"
val baseUrl = getBaseUrl()
val body = json.encodeToString(
MapSerializer(String.serializer(), String.serializer()),
mapOf("currentPassword" to currentPassword, "newPassword" to newPassword),
)
try {
val url = URL("$baseUrl/auth/change-password")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("X-Product-Id", PRODUCT_ID)
conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString())
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.doOutput = true
conn.outputStream.use { it.write(body.toByteArray()) }
if (conn.responseCode == 200) null
else {
val errBody = try { conn.errorStream?.bufferedReader()?.readText() } catch (_: Exception) { null }
errBody ?: "Failed to change password"
}
} catch (e: Exception) {
e.message ?: "Network error"
}
}
suspend fun deleteAccount(password: String): String? = withContext(Dispatchers.IO) {
val token = prefs.getString(KEY_ACCESS_TOKEN, null) ?: return@withContext "Not authenticated"
val baseUrl = getBaseUrl()
val body = json.encodeToString(
MapSerializer(String.serializer(), String.serializer()),
mapOf("password" to password),
)
try {
val url = URL("$baseUrl/auth/account")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "DELETE"
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Authorization", "Bearer $token")
conn.setRequestProperty("X-Product-Id", PRODUCT_ID)
conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString())
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.doOutput = true
conn.outputStream.use { it.write(body.toByteArray()) }
if (conn.responseCode == 200) {
withContext(Dispatchers.Main) { logout() }
null
} else {
val errBody = try { conn.errorStream?.bufferedReader()?.readText() } catch (_: Exception) { null }
errBody ?: "Failed to delete account"
}
} catch (e: Exception) {
e.message ?: "Network error"
}
}
private fun handleAuthResult(responseBody: String) {
try {
val tokenResp = json.decodeFromString<TokenResponse>(responseBody)
prefs.edit()
.putString(KEY_ACCESS_TOKEN, tokenResp.accessToken)
.putString(KEY_REFRESH_TOKEN, tokenResp.refreshToken)
.putString(KEY_USER_EMAIL, tokenResp.user.email)
.putString(KEY_USER_NAME, tokenResp.user.name)
.putString(KEY_USER_PLAN, tokenResp.user.plan)
.putString(KEY_USER_ID, tokenResp.user.id)
.apply()
_state.value = AuthState.LoggedIn(tokenResp.user)
wireSyncToken()
} catch (e: Exception) {
_state.value = AuthState.Error(e.message ?: "Parse error")
}
}
private fun wireSyncToken() {
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
context.getSharedPreferences("chronomind_sync", Context.MODE_PRIVATE)
.edit().putString("auth_token", token).apply()
}
private suspend fun postAuth(path: String, body: String): String? = withContext(Dispatchers.IO) {
val baseUrl = getBaseUrl()
try {
val url = URL("$baseUrl$path")
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("X-Product-Id", PRODUCT_ID)
conn.setRequestProperty("X-Request-Id", UUID.randomUUID().toString())
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
conn.doOutput = true
conn.outputStream.use { it.write(body.toByteArray()) }
if (conn.responseCode in 200..201) {
conn.inputStream.bufferedReader().readText()
} else {
null
}
} catch (_: Exception) {
null
}
}
}

View File

@ -5,6 +5,7 @@ import androidx.room.Room
import com.chronomind.app.data.TimerDao
import com.chronomind.app.data.TimerDatabase
import com.chronomind.app.notifications.TimerNotificationManager
import com.bytelyst.platform.BLSecureStore
import com.chronomind.app.sync.SyncRepository
import dagger.Module
import dagger.Provides
@ -45,11 +46,10 @@ object AppModule {
@Singleton
fun provideSyncRepository(
@ApplicationContext context: Context,
timerDao: TimerDao
timerDao: TimerDao,
secureStore: BLSecureStore,
): SyncRepository {
return SyncRepository(context, timerDao).also {
it.restoreAuthToken()
}
return SyncRepository(context, timerDao, secureStore)
}
}

View File

@ -2,9 +2,12 @@ package com.chronomind.app.sync
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.UUID
import java.util.concurrent.TimeUnit
// ── DTOs ──────────────────────────────────────────────────────
@ -69,111 +72,93 @@ data class BatchResult(
@Serializable
data class SyncConflict(val id: String, val serverVersion: Int)
// ── API Client ───────────────────────────────────────────────
// ── API Client (OkHttp — via kotlin-platform-sdk dependency)
class PlatformApiClient(
private val baseUrl: String = "https://api.chronomind.app",
private var authToken: String? = null
private val tokenProvider: () -> String? = { null },
) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val jsonMediaType = "application/json".toMediaType()
fun setAuthToken(token: String?) { authToken = token }
private val httpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
// GET /timers/sync?since=<ISO>
fun pullDelta(since: String?): List<SyncTimerDTO> {
val url = if (since != null) "$baseUrl/timers/sync?since=$since" else "$baseUrl/timers/sync"
val response = get(url)
val response = executeRequest("GET", url)
return json.decodeFromString(response)
}
// POST /timers
fun createTimer(dto: SyncTimerDTO): SyncTimerDTO {
val body = json.encodeToString(SyncTimerDTO.serializer(), dto)
val response = post("$baseUrl/timers", body)
val response = executeRequest("POST", "$baseUrl/timers", body)
return json.decodeFromString(response)
}
// PUT /timers/:id
fun updateTimer(id: String, dto: UpdateTimerDTO): SyncTimerDTO {
val body = json.encodeToString(UpdateTimerDTO.serializer(), dto)
val response = put("$baseUrl/timers/$id", body)
val response = executeRequest("PUT", "$baseUrl/timers/$id", body)
return json.decodeFromString(response)
}
// DELETE /timers/:id
fun deleteTimer(id: String) {
delete("$baseUrl/timers/$id")
executeRequest("DELETE", "$baseUrl/timers/$id")
}
// POST /timers/batch
fun batchUpsert(timers: List<SyncTimerDTO>): BatchResult {
val body = json.encodeToString(BatchRequest.serializer(), BatchRequest(timers))
val response = post("$baseUrl/timers/batch", body)
val response = executeRequest("POST", "$baseUrl/timers/batch", body)
return json.decodeFromString(response)
}
// GET /routines/sync?since=<ISO>
fun pullRoutinesDelta(since: String?): String {
val url = if (since != null) "$baseUrl/routines/sync?since=$since" else "$baseUrl/routines/sync"
return get(url)
return executeRequest("GET", url)
}
// ── HTTP helpers ──────────────────────────────────────────
// ── HTTP helper (OkHttp) ──────────────────────────────────
private fun get(url: String): String {
val conn = openConnection(url, "GET")
return readResponse(conn)
}
private fun executeRequest(method: String, url: String, body: String? = null): String {
val builder = Request.Builder()
.url(url)
.header("Content-Type", "application/json")
.header("X-Request-Id", UUID.randomUUID().toString())
.header("X-Product-Id", "chronomind")
private fun post(url: String, body: String): String {
val conn = openConnection(url, "POST")
writeBody(conn, body)
return readResponse(conn)
}
tokenProvider()?.let { token ->
builder.header("Authorization", "Bearer $token")
}
private fun put(url: String, body: String): String {
val conn = openConnection(url, "PUT")
writeBody(conn, body)
return readResponse(conn)
}
val requestBody = body?.toRequestBody(jsonMediaType)
when (method.uppercase()) {
"GET" -> builder.get()
"POST" -> builder.post(requestBody ?: "".toRequestBody(null))
"PUT" -> builder.put(requestBody ?: "".toRequestBody(null))
"DELETE" -> if (requestBody != null) builder.delete(requestBody) else builder.delete()
else -> builder.method(method, requestBody)
}
private fun delete(url: String) {
val conn = openConnection(url, "DELETE")
val code = conn.responseCode
conn.disconnect()
if (code !in 200..299) throw SyncException("DELETE failed: $code")
}
val response = httpClient.newCall(builder.build()).execute()
val responseBody = response.body?.string() ?: ""
private fun openConnection(url: String, method: String): HttpURLConnection {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = method
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("x-request-id", java.util.UUID.randomUUID().toString())
conn.setRequestProperty("x-product-id", "chronomind")
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
authToken?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
return conn
}
private fun writeBody(conn: HttpURLConnection, body: String) {
conn.doOutput = true
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(body) }
}
private fun readResponse(conn: HttpURLConnection): String {
val code = conn.responseCode
if (code == 409) {
conn.disconnect()
if (response.code == 409) {
throw SyncConflictException("Conflict: server has newer version")
}
if (code !in 200..299) {
conn.disconnect()
throw SyncException("HTTP $code")
if (!response.isSuccessful) {
throw SyncException("HTTP ${response.code}: $responseBody")
}
val response = conn.inputStream.bufferedReader().use { it.readText() }
conn.disconnect()
return response
return responseBody
}
}

View File

@ -2,6 +2,7 @@ package com.chronomind.app.sync
import android.content.Context
import android.content.SharedPreferences
import com.bytelyst.platform.BLSecureStore
import com.chronomind.app.data.TimerDao
import com.chronomind.app.data.TimerEntity
import com.chronomind.app.data.toEntity
@ -35,10 +36,14 @@ data class OfflineQueueItem(
@Singleton
class SyncRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val timerDao: TimerDao
private val timerDao: TimerDao,
private val secureStore: BLSecureStore,
) {
private val prefs: SharedPreferences = context.getSharedPreferences("chronomind_sync", Context.MODE_PRIVATE)
private val api = PlatformApiClient()
private val api = PlatformApiClient(
baseUrl = prefs.getString("base_url", null) ?: "https://api.chronomind.app",
tokenProvider = { secureStore.read("access_token") },
)
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
@ -65,19 +70,9 @@ class SyncRepository @Inject constructor(
_pendingChanges.value = loadOfflineQueue().size
}
// MARK: - Auth
// MARK: - Auth (token read from BLSecureStore via tokenProvider)
fun setAuthToken(token: String?) {
api.setAuthToken(token)
prefs.edit().putString("auth_token", token).apply()
}
fun restoreAuthToken() {
val token = prefs.getString("auth_token", null)
if (token != null) api.setAuthToken(token)
}
val isAuthenticated: Boolean get() = prefs.getString("auth_token", null) != null
val isAuthenticated: Boolean get() = secureStore.read("access_token") != null
// MARK: - Full Sync

View File

@ -1,88 +0,0 @@
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

@ -1,177 +0,0 @@
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
}
}
}
}