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:
parent
f7356706cd
commit
2060a831bf
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user