From 2060a831bf898fcf6314ce8121e7ff080095de66 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 20 Mar 2026 22:19:11 -0700 Subject: [PATCH] =?UTF-8?q?refactor(android):=20Phase=201=20=E2=80=94=20de?= =?UTF-8?q?lete=20dead=20services,=20migrate=20sync=20to=20OkHttp,=20wire?= =?UTF-8?q?=20kill=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../java/com/chronomind/app/MainActivity.kt | 63 +++- .../com/chronomind/app/auth/AuthService.kt | 327 ------------------ .../java/com/chronomind/app/di/AppModule.kt | 8 +- .../chronomind/app/sync/PlatformApiClient.kt | 105 +++--- .../com/chronomind/app/sync/SyncRepository.kt | 23 +- .../app/telemetry/FeatureFlagService.kt | 88 ----- .../app/telemetry/TelemetryService.kt | 177 ---------- 7 files changed, 112 insertions(+), 679 deletions(-) delete mode 100644 android/app/src/main/java/com/chronomind/app/auth/AuthService.kt delete mode 100644 android/app/src/main/java/com/chronomind/app/telemetry/FeatureFlagService.kt delete mode 100644 android/app/src/main/java/com/chronomind/app/telemetry/TelemetryService.kt diff --git a/android/app/src/main/java/com/chronomind/app/MainActivity.kt b/android/app/src/main/java/com/chronomind/app/MainActivity.kt index 6856bf6..6729c72 100644 --- a/android/app/src/main/java/com/chronomind/app/MainActivity.kt +++ b/android/app/src/main/java/com/chronomind/app/MainActivity.kt @@ -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) } } } diff --git a/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt b/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt deleted file mode 100644 index 176c9d9..0000000 --- a/android/app/src/main/java/com/chronomind/app/auth/AuthService.kt +++ /dev/null @@ -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.Loading) - val state: StateFlow = _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(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(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 - } - } -} 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 54b4e13..4061a0d 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,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) } } diff --git a/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt b/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt index 574d323..4da6229 100644 --- a/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt +++ b/android/app/src/main/java/com/chronomind/app/sync/PlatformApiClient.kt @@ -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= fun pullDelta(since: String?): List { 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): 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= 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 } } diff --git a/android/app/src/main/java/com/chronomind/app/sync/SyncRepository.kt b/android/app/src/main/java/com/chronomind/app/sync/SyncRepository.kt index 05e815e..7351658 100644 --- a/android/app/src/main/java/com/chronomind/app/sync/SyncRepository.kt +++ b/android/app/src/main/java/com/chronomind/app/sync/SyncRepository.kt @@ -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 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 deleted file mode 100644 index e07ca87..0000000 --- a/android/app/src/main/java/com/chronomind/app/telemetry/FeatureFlagService.kt +++ /dev/null @@ -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) - -@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 deleted file mode 100644 index f0c97e0..0000000 --- a/android/app/src/main/java/com/chronomind/app/telemetry/TelemetryService.kt +++ /dev/null @@ -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? = 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 - } - } - } -}