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.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.bytelyst.platform.BLAuthClient
|
import com.bytelyst.platform.BLAuthClient
|
||||||
|
import com.bytelyst.platform.BLKillSwitchClient
|
||||||
import com.chronomind.app.auth.LoginScreen
|
import com.chronomind.app.auth.LoginScreen
|
||||||
import com.chronomind.app.ui.navigation.ChronoMindNavHost
|
import com.chronomind.app.ui.navigation.ChronoMindNavHost
|
||||||
import com.chronomind.app.ui.theme.CMColors
|
import com.chronomind.app.ui.theme.CMColors
|
||||||
@ -20,6 +33,7 @@ import javax.inject.Inject
|
|||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@Inject lateinit var authClient: BLAuthClient
|
@Inject lateinit var authClient: BLAuthClient
|
||||||
|
@Inject lateinit var killSwitchClient: BLKillSwitchClient
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@ -27,16 +41,47 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ChronoMindTheme {
|
ChronoMindTheme {
|
||||||
val authState by authClient.state.collectAsState()
|
var killed by remember { mutableStateOf(false) }
|
||||||
if (authState is BLAuthClient.AuthState.LoggedIn) {
|
var killMessage by remember { mutableStateOf("") }
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
LaunchedEffect(Unit) {
|
||||||
color = CMColors.bg
|
val result = killSwitchClient.check()
|
||||||
) {
|
if (result.killed) {
|
||||||
ChronoMindNavHost()
|
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.TimerDao
|
||||||
import com.chronomind.app.data.TimerDatabase
|
import com.chronomind.app.data.TimerDatabase
|
||||||
import com.chronomind.app.notifications.TimerNotificationManager
|
import com.chronomind.app.notifications.TimerNotificationManager
|
||||||
|
import com.bytelyst.platform.BLSecureStore
|
||||||
import com.chronomind.app.sync.SyncRepository
|
import com.chronomind.app.sync.SyncRepository
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@ -45,11 +46,10 @@ object AppModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideSyncRepository(
|
fun provideSyncRepository(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
timerDao: TimerDao
|
timerDao: TimerDao,
|
||||||
|
secureStore: BLSecureStore,
|
||||||
): SyncRepository {
|
): SyncRepository {
|
||||||
return SyncRepository(context, timerDao).also {
|
return SyncRepository(context, timerDao, secureStore)
|
||||||
it.restoreAuthToken()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,12 @@ package com.chronomind.app.sync
|
|||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.OutputStreamWriter
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import java.net.HttpURLConnection
|
import okhttp3.OkHttpClient
|
||||||
import java.net.URL
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
// ── DTOs ──────────────────────────────────────────────────────
|
// ── DTOs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -69,111 +72,93 @@ data class BatchResult(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class SyncConflict(val id: String, val serverVersion: Int)
|
data class SyncConflict(val id: String, val serverVersion: Int)
|
||||||
|
|
||||||
// ── API Client ────────────────────────────────────────────────
|
// ── API Client (OkHttp — via kotlin-platform-sdk dependency) ─
|
||||||
|
|
||||||
class PlatformApiClient(
|
class PlatformApiClient(
|
||||||
private val baseUrl: String = "https://api.chronomind.app",
|
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 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>
|
// GET /timers/sync?since=<ISO>
|
||||||
fun pullDelta(since: String?): List<SyncTimerDTO> {
|
fun pullDelta(since: String?): List<SyncTimerDTO> {
|
||||||
val url = if (since != null) "$baseUrl/timers/sync?since=$since" else "$baseUrl/timers/sync"
|
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)
|
return json.decodeFromString(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /timers
|
// POST /timers
|
||||||
fun createTimer(dto: SyncTimerDTO): SyncTimerDTO {
|
fun createTimer(dto: SyncTimerDTO): SyncTimerDTO {
|
||||||
val body = json.encodeToString(SyncTimerDTO.serializer(), dto)
|
val body = json.encodeToString(SyncTimerDTO.serializer(), dto)
|
||||||
val response = post("$baseUrl/timers", body)
|
val response = executeRequest("POST", "$baseUrl/timers", body)
|
||||||
return json.decodeFromString(response)
|
return json.decodeFromString(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /timers/:id
|
// PUT /timers/:id
|
||||||
fun updateTimer(id: String, dto: UpdateTimerDTO): SyncTimerDTO {
|
fun updateTimer(id: String, dto: UpdateTimerDTO): SyncTimerDTO {
|
||||||
val body = json.encodeToString(UpdateTimerDTO.serializer(), dto)
|
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)
|
return json.decodeFromString(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /timers/:id
|
// DELETE /timers/:id
|
||||||
fun deleteTimer(id: String) {
|
fun deleteTimer(id: String) {
|
||||||
delete("$baseUrl/timers/$id")
|
executeRequest("DELETE", "$baseUrl/timers/$id")
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /timers/batch
|
// POST /timers/batch
|
||||||
fun batchUpsert(timers: List<SyncTimerDTO>): BatchResult {
|
fun batchUpsert(timers: List<SyncTimerDTO>): BatchResult {
|
||||||
val body = json.encodeToString(BatchRequest.serializer(), BatchRequest(timers))
|
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)
|
return json.decodeFromString(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /routines/sync?since=<ISO>
|
// GET /routines/sync?since=<ISO>
|
||||||
fun pullRoutinesDelta(since: String?): String {
|
fun pullRoutinesDelta(since: String?): String {
|
||||||
val url = if (since != null) "$baseUrl/routines/sync?since=$since" else "$baseUrl/routines/sync"
|
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 {
|
private fun executeRequest(method: String, url: String, body: String? = null): String {
|
||||||
val conn = openConnection(url, "GET")
|
val builder = Request.Builder()
|
||||||
return readResponse(conn)
|
.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 {
|
tokenProvider()?.let { token ->
|
||||||
val conn = openConnection(url, "POST")
|
builder.header("Authorization", "Bearer $token")
|
||||||
writeBody(conn, body)
|
}
|
||||||
return readResponse(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun put(url: String, body: String): String {
|
val requestBody = body?.toRequestBody(jsonMediaType)
|
||||||
val conn = openConnection(url, "PUT")
|
when (method.uppercase()) {
|
||||||
writeBody(conn, body)
|
"GET" -> builder.get()
|
||||||
return readResponse(conn)
|
"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 response = httpClient.newCall(builder.build()).execute()
|
||||||
val conn = openConnection(url, "DELETE")
|
val responseBody = response.body?.string() ?: ""
|
||||||
val code = conn.responseCode
|
|
||||||
conn.disconnect()
|
|
||||||
if (code !in 200..299) throw SyncException("DELETE failed: $code")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openConnection(url: String, method: String): HttpURLConnection {
|
if (response.code == 409) {
|
||||||
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()
|
|
||||||
throw SyncConflictException("Conflict: server has newer version")
|
throw SyncConflictException("Conflict: server has newer version")
|
||||||
}
|
}
|
||||||
if (code !in 200..299) {
|
if (!response.isSuccessful) {
|
||||||
conn.disconnect()
|
throw SyncException("HTTP ${response.code}: $responseBody")
|
||||||
throw SyncException("HTTP $code")
|
|
||||||
}
|
}
|
||||||
val response = conn.inputStream.bufferedReader().use { it.readText() }
|
|
||||||
conn.disconnect()
|
return responseBody
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.chronomind.app.sync
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import com.bytelyst.platform.BLSecureStore
|
||||||
import com.chronomind.app.data.TimerDao
|
import com.chronomind.app.data.TimerDao
|
||||||
import com.chronomind.app.data.TimerEntity
|
import com.chronomind.app.data.TimerEntity
|
||||||
import com.chronomind.app.data.toEntity
|
import com.chronomind.app.data.toEntity
|
||||||
@ -35,10 +36,14 @@ data class OfflineQueueItem(
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SyncRepository @Inject constructor(
|
class SyncRepository @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@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 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 json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
|
||||||
timeZone = TimeZone.getTimeZone("UTC")
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
@ -65,19 +70,9 @@ class SyncRepository @Inject constructor(
|
|||||||
_pendingChanges.value = loadOfflineQueue().size
|
_pendingChanges.value = loadOfflineQueue().size
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auth
|
// MARK: - Auth (token read from BLSecureStore via tokenProvider)
|
||||||
|
|
||||||
fun setAuthToken(token: String?) {
|
val isAuthenticated: Boolean get() = secureStore.read("access_token") != null
|
||||||
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
|
|
||||||
|
|
||||||
// MARK: - Full Sync
|
// 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