feat(android): add API client, tRPC bridge, and offline support
- Add Retrofit with kotlinx-serialization converter for tRPC endpoints - Create TRPCApiService with type-safe wrappers for all procedures - Implement AuthInterceptor for JWT injection from EncryptedSharedPreferences - Add ErrorHandler with exponential backoff retry logic and ApiResult sealed class - Create 11 serializable data models matching backend enums - Add JSON file-based cache with TTL invalidation (CacheManager) - Implement repositories: User, DarkWatch, VoicePrint, Alert, Subscription - Add offline sync: PendingRequestQueue, OfflineWorker, SyncManager - Create manual DI modules: NetworkModule, DatabaseModule, RepositoryModule - Add WorkManager for background offline request processing - Add ConnectivityManager-based network monitoring for auto-sync - Configure build system with KSP for Room, kotlinx-serialization plugin - Update build config with environment-specific API URLs - Write 19 new unit tests for ErrorHandler, CacheManager, TRPCResponse, SyncManager
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package com.shieldai.android.data.local
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class CacheEntry<T>(
|
||||
val data: T,
|
||||
val cachedAt: Long = System.currentTimeMillis(),
|
||||
val ttlMs: Long = CacheManager.DEFAULT_TTL_MS,
|
||||
) {
|
||||
fun isExpired(): Boolean = System.currentTimeMillis() - cachedAt > ttlMs
|
||||
}
|
||||
|
||||
object CacheManager {
|
||||
const val DEFAULT_TTL_MS = 5 * 60 * 1000L
|
||||
private val ttlOverrides = mutableMapOf<String, Long>()
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
fun setTtl(tableName: String, ttlMs: Long) {
|
||||
ttlOverrides[tableName] = ttlMs
|
||||
}
|
||||
|
||||
fun getTtl(tableName: String): Long = ttlOverrides[tableName] ?: DEFAULT_TTL_MS
|
||||
|
||||
fun <T> save(context: Context, key: String, data: T) {
|
||||
val entry = CacheEntry(
|
||||
data = data,
|
||||
cachedAt = System.currentTimeMillis(),
|
||||
ttlMs = getTtl(key),
|
||||
)
|
||||
val file = File(context.cacheDir, "$key.cache")
|
||||
file.writeText(json.encodeToString(entry))
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> load(context: Context, key: String): T? {
|
||||
val file = File(context.cacheDir, "$key.cache")
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val text = file.readText()
|
||||
val entry = json.decodeFromString<CacheEntry<Map<String, Any>>>(text)
|
||||
if (entry.isExpired()) {
|
||||
file.delete()
|
||||
null
|
||||
} else {
|
||||
json.decodeFromString<CacheEntry<T>>(text).data
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(context: Context, key: String) {
|
||||
File(context.cacheDir, "$key.cache").delete()
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
context.cacheDir.listFiles { _, name -> name.endsWith(".cache") }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
fun isExpired(cachedAt: Long, tableName: String): Boolean {
|
||||
val ttl = getTtl(tableName)
|
||||
return System.currentTimeMillis() - cachedAt > ttl
|
||||
}
|
||||
|
||||
fun isFresh(cachedAt: Long, tableName: String): Boolean = !isExpired(cachedAt, tableName)
|
||||
|
||||
fun clearOverrides() = ttlOverrides.clear()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Alert(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val severity: String,
|
||||
val read: Boolean = false,
|
||||
val date: String? = null,
|
||||
@SerialName("action_url") val actionUrl: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BrokerListing(
|
||||
val id: String,
|
||||
@SerialName("broker_name") val brokerName: String,
|
||||
@SerialName("property_address") val propertyAddress: String? = null,
|
||||
val url: String? = null,
|
||||
val status: String = "active",
|
||||
@SerialName("date_found") val dateFound: String? = null,
|
||||
@SerialName("removal_request_id") val removalRequestId: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Exposure(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val source: String,
|
||||
val severity: String,
|
||||
val details: String? = null,
|
||||
val date: String? = null,
|
||||
@SerialName("watchlist_item_id") val watchlistItemId: String? = null,
|
||||
val resolved: Boolean = false,
|
||||
@SerialName("resolved_at") val resolvedAt: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Property(
|
||||
val id: String,
|
||||
val address: String,
|
||||
val type: String,
|
||||
@SerialName("owner_name") val ownerName: String? = null,
|
||||
val county: String? = null,
|
||||
@SerialName("document_id") val documentId: String? = null,
|
||||
val status: String = "monitored",
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RemovalRequest(
|
||||
val id: String,
|
||||
@SerialName("listing_id") val listingId: String,
|
||||
val status: String,
|
||||
@SerialName("submitted_date") val submittedDate: String? = null,
|
||||
@SerialName("resolved_date") val resolvedDate: String? = null,
|
||||
val notes: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SpamRule(
|
||||
val id: String,
|
||||
val pattern: String,
|
||||
val action: String,
|
||||
val enabled: Boolean = true,
|
||||
val description: String? = null,
|
||||
val priority: Int = 0,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Subscription(
|
||||
val id: String,
|
||||
val plan: String,
|
||||
val status: String,
|
||||
@SerialName("start_date") val startDate: String? = null,
|
||||
@SerialName("end_date") val endDate: String? = null,
|
||||
val features: List<String> = emptyList(),
|
||||
@SerialName("auto_renew") val autoRenew: Boolean = true,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||
@SerialName("subscription_tier") val subscriptionTier: String? = null,
|
||||
@SerialName("email_verified") val emailVerified: Boolean = false,
|
||||
@SerialName("phone_verified") val phoneVerified: Boolean = false,
|
||||
@SerialName("is_new_user") val isNewUser: Boolean = false,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class VoiceAnalysis(
|
||||
val id: String,
|
||||
@SerialName("enrollment_id") val enrollmentId: String,
|
||||
val confidence: Double = 0.0,
|
||||
val result: String? = null,
|
||||
val status: String = "pending",
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class VoiceEnrollment(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@SerialName("sample_count") val sampleCount: Int = 0,
|
||||
val status: String = "pending",
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WatchlistItem(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val value: String,
|
||||
val label: String? = null,
|
||||
val status: String = "active",
|
||||
@SerialName("date_added") val dateAdded: String? = null,
|
||||
@SerialName("last_checked") val lastChecked: String? = null,
|
||||
@SerialName("alerts_enabled") val alertsEnabled: Boolean = true,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class AuthInterceptor(context: Context) : Interceptor {
|
||||
|
||||
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"shieldai_auth_prefs",
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = securePrefs.getString("access_token", null)
|
||||
val request = if (token != null) {
|
||||
chain.request().newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
sealed class ApiResult<out T> {
|
||||
data class Success<T>(val data: T) : ApiResult<T>()
|
||||
data class Error(val message: String, val code: Int = -1) : ApiResult<Nothing>()
|
||||
}
|
||||
|
||||
object ErrorHandler {
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val BASE_DELAY_MS = 1000L
|
||||
private const val MAX_DELAY_MS = 10000L
|
||||
|
||||
suspend fun <T> executeWithRetry(
|
||||
maxRetries: Int = MAX_RETRIES,
|
||||
block: suspend () -> T,
|
||||
): ApiResult<T> {
|
||||
var lastError: Exception? = null
|
||||
for (attempt in 0..maxRetries) {
|
||||
try {
|
||||
val result = block()
|
||||
return ApiResult.Success(result)
|
||||
} catch (e: Exception) {
|
||||
lastError = e
|
||||
if (attempt < maxRetries && shouldRetry(e)) {
|
||||
val delayMs = calculateBackoff(attempt)
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ApiResult.Error(lastError?.message ?: "Unknown error")
|
||||
}
|
||||
|
||||
private fun shouldRetry(e: Exception): Boolean {
|
||||
return when {
|
||||
e is java.net.SocketTimeoutException -> true
|
||||
e is java.net.ConnectException -> true
|
||||
e is java.net.UnknownHostException -> true
|
||||
e is java.io.IOException -> true
|
||||
e.message?.contains("503") == true -> true
|
||||
e.message?.contains("429") == true -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateBackoff(attempt: Int): Long {
|
||||
val exponential = BASE_DELAY_MS * 2.0.pow(attempt.toDouble())
|
||||
return min(exponential.toLong(), MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
fun parseError(throwable: Throwable): String {
|
||||
return when (throwable) {
|
||||
is java.net.UnknownHostException -> "No internet connection"
|
||||
is java.net.SocketTimeoutException -> "Request timed out"
|
||||
is java.net.ConnectException -> "Connection refused"
|
||||
is java.io.IOException -> "Network error: ${throwable.message}"
|
||||
else -> throwable.message ?: "Unknown error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import com.shieldai.android.data.model.Alert
|
||||
import com.shieldai.android.data.model.BrokerListing
|
||||
import com.shieldai.android.data.model.Exposure
|
||||
import com.shieldai.android.data.model.Property
|
||||
import com.shieldai.android.data.model.RemovalRequest
|
||||
import com.shieldai.android.data.model.SpamRule
|
||||
import com.shieldai.android.data.model.Subscription
|
||||
import com.shieldai.android.data.model.User
|
||||
import com.shieldai.android.data.model.VoiceAnalysis
|
||||
import com.shieldai.android.data.model.VoiceEnrollment
|
||||
import com.shieldai.android.data.model.WatchlistItem
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface TRPCApiService {
|
||||
@POST("api/trpc/user.me")
|
||||
suspend fun userMe(@Body body: JsonObject): TRPCResponse<User>
|
||||
|
||||
@POST("api/trpc/user.updateProfile")
|
||||
suspend fun userUpdateProfile(@Body body: JsonObject): TRPCResponse<User>
|
||||
|
||||
@POST("api/trpc/subscription.get")
|
||||
suspend fun subscriptionGet(@Body body: JsonObject): TRPCResponse<Subscription>
|
||||
|
||||
@POST("api/trpc/subscription.update")
|
||||
suspend fun subscriptionUpdate(@Body body: JsonObject): TRPCResponse<Subscription>
|
||||
|
||||
@POST("api/trpc/darkwatch.getWatchlist")
|
||||
suspend fun darkWatchGetWatchlist(@Body body: JsonObject): TRPCResponse<List<WatchlistItem>>
|
||||
|
||||
@POST("api/trpc/darkwatch.addWatchlistItem")
|
||||
suspend fun darkWatchAddWatchlistItem(@Body body: JsonObject): TRPCResponse<WatchlistItem>
|
||||
|
||||
@POST("api/trpc/darkwatch.removeWatchlistItem")
|
||||
suspend fun darkWatchRemoveWatchlistItem(@Body body: JsonObject): TRPCResponse<Unit>
|
||||
|
||||
@POST("api/trpc/darkwatch.getExposures")
|
||||
suspend fun darkWatchGetExposures(@Body body: JsonObject): TRPCResponse<List<Exposure>>
|
||||
|
||||
@POST("api/trpc/alerts.list")
|
||||
suspend fun alertsList(@Body body: JsonObject): TRPCResponse<List<Alert>>
|
||||
|
||||
@POST("api/trpc/alerts.markRead")
|
||||
suspend fun alertsMarkRead(@Body body: JsonObject): TRPCResponse<Alert>
|
||||
|
||||
@POST("api/trpc/voice.enrollments")
|
||||
suspend fun voiceEnrollments(@Body body: JsonObject): TRPCResponse<List<VoiceEnrollment>>
|
||||
|
||||
@POST("api/trpc/voice.createEnrollment")
|
||||
suspend fun voiceCreateEnrollment(@Body body: JsonObject): TRPCResponse<VoiceEnrollment>
|
||||
|
||||
@POST("api/trpc/voice.analyze")
|
||||
suspend fun voiceAnalyze(@Body body: JsonObject): TRPCResponse<VoiceAnalysis>
|
||||
|
||||
@POST("api/trpc/spam.listRules")
|
||||
suspend fun spamListRules(@Body body: JsonObject): TRPCResponse<List<SpamRule>>
|
||||
|
||||
@POST("api/trpc/spam.createRule")
|
||||
suspend fun spamCreateRule(@Body body: JsonObject): TRPCResponse<SpamRule>
|
||||
|
||||
@POST("api/trpc/property.list")
|
||||
suspend fun propertyList(@Body body: JsonObject): TRPCResponse<List<Property>>
|
||||
|
||||
@POST("api/trpc/property.add")
|
||||
suspend fun propertyAdd(@Body body: JsonObject): TRPCResponse<Property>
|
||||
|
||||
@POST("api/trpc/removal.list")
|
||||
suspend fun removalList(@Body body: JsonObject): TRPCResponse<List<RemovalRequest>>
|
||||
|
||||
@POST("api/trpc/removal.create")
|
||||
suspend fun removalCreate(@Body body: JsonObject): TRPCResponse<RemovalRequest>
|
||||
|
||||
@POST("api/trpc/broker.listListings")
|
||||
suspend fun brokerListListings(@Body body: JsonObject): TRPCResponse<List<BrokerListing>>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
@Serializable
|
||||
data class TRPCResponse<T>(
|
||||
val result: TRPCResult<T>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TRPCResult<T>(
|
||||
val data: T,
|
||||
)
|
||||
|
||||
data class TRPCErrorResponse(
|
||||
val error: TRPCError,
|
||||
)
|
||||
|
||||
data class TRPCError(
|
||||
val message: String,
|
||||
val code: Int = -1,
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(json: JsonObject): TRPCError {
|
||||
val errorObj = json["error"]?.jsonObject
|
||||
val message = errorObj?.get("message")?.jsonPrimitive?.content ?: "Unknown error"
|
||||
val code = errorObj?.get("code")?.jsonPrimitive?.content?.toIntOrNull() ?: -1
|
||||
return TRPCError(message = message, code = code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object TRPCRequest {
|
||||
fun body(json: JsonObject): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("0", buildJsonObject {
|
||||
put("json", json)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.Alert
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class AlertRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
private val _alerts = MutableStateFlow<List<Alert>>(emptyList())
|
||||
|
||||
suspend fun getAlerts(): ApiResult<List<Alert>> {
|
||||
val cached: List<Alert>? = CacheManager.load(context, "alerts")
|
||||
if (cached != null) {
|
||||
_alerts.value = cached
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.alertsList(TRPCRequest.body(buildJsonObject {}))
|
||||
val alerts = response.result.data
|
||||
CacheManager.save(context, "alerts", alerts)
|
||||
_alerts.value = alerts
|
||||
alerts
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markRead(id: String): ApiResult<Alert> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject { put("id", id) }
|
||||
val response = api.alertsMarkRead(TRPCRequest.body(body))
|
||||
val alert = response.result.data
|
||||
_alerts.value = _alerts.value.map { if (it.id == id) alert else it }
|
||||
alert
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAlerts(): Flow<List<Alert>> = _alerts
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.Exposure
|
||||
import com.shieldai.android.data.model.WatchlistItem
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class DarkWatchRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
private val _watchlist = MutableStateFlow<List<WatchlistItem>>(emptyList())
|
||||
|
||||
suspend fun getWatchlist(forceRefresh: Boolean = false): ApiResult<List<WatchlistItem>> {
|
||||
if (!forceRefresh) {
|
||||
val cached: List<WatchlistItem>? = CacheManager.load(context, "watchlist")
|
||||
if (cached != null) {
|
||||
_watchlist.value = cached
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.darkWatchGetWatchlist(TRPCRequest.body(buildJsonObject {}))
|
||||
val items = response.result.data
|
||||
CacheManager.save(context, "watchlist", items)
|
||||
_watchlist.value = items
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addWatchlistItem(type: String, value: String, label: String? = null): ApiResult<WatchlistItem> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject {
|
||||
put("type", type)
|
||||
put("value", value)
|
||||
label?.let { put("label", it) }
|
||||
}
|
||||
val response = api.darkWatchAddWatchlistItem(TRPCRequest.body(body))
|
||||
val item = response.result.data
|
||||
refreshCache()
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeWatchlistItem(id: String): ApiResult<Unit> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject { put("id", id) }
|
||||
api.darkWatchRemoveWatchlistItem(TRPCRequest.body(body))
|
||||
refreshCache()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getExposures(forceRefresh: Boolean = false): ApiResult<List<Exposure>> {
|
||||
if (!forceRefresh) {
|
||||
val cached: List<Exposure>? = CacheManager.load(context, "exposures")
|
||||
if (cached != null) return ApiResult.Success(cached)
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.darkWatchGetExposures(TRPCRequest.body(buildJsonObject {}))
|
||||
val exposures = response.result.data
|
||||
CacheManager.save(context, "exposures", exposures)
|
||||
exposures
|
||||
}
|
||||
}
|
||||
|
||||
fun observeWatchlist(): Flow<List<WatchlistItem>> = _watchlist
|
||||
|
||||
private suspend fun refreshCache() {
|
||||
ErrorHandler.executeWithRetry {
|
||||
val response = api.darkWatchGetWatchlist(TRPCRequest.body(buildJsonObject {}))
|
||||
val items = response.result.data
|
||||
CacheManager.save(context, "watchlist", items)
|
||||
_watchlist.value = items
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.Subscription
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class SubscriptionRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
suspend fun getSubscription(): ApiResult<Subscription> {
|
||||
val cached: Subscription? = CacheManager.load(context, "subscription")
|
||||
if (cached != null) return ApiResult.Success(cached)
|
||||
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.subscriptionGet(TRPCRequest.body(buildJsonObject {}))
|
||||
val subscription = response.result.data
|
||||
CacheManager.save(context, "subscription", subscription)
|
||||
subscription
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateSubscription(plan: String): ApiResult<Subscription> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject { put("plan", plan) }
|
||||
val response = api.subscriptionUpdate(TRPCRequest.body(body))
|
||||
val subscription = response.result.data
|
||||
CacheManager.save(context, "subscription", subscription)
|
||||
subscription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.User
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
class UserRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
private val _currentUser = MutableStateFlow<User?>(null)
|
||||
|
||||
suspend fun getMe(forceRefresh: Boolean = false): ApiResult<User> {
|
||||
if (!forceRefresh) {
|
||||
val cached: User? = CacheManager.load(context, "current_user")
|
||||
if (cached != null) {
|
||||
_currentUser.value = cached
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.userMe(TRPCRequest.body(buildJsonObject {}))
|
||||
val user = response.result.data
|
||||
CacheManager.save(context, "current_user", user)
|
||||
_currentUser.value = user
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProfile(name: String? = null, phone: String? = null): ApiResult<User> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject {
|
||||
name?.let { put("name", kotlinx.serialization.json.JsonPrimitive(it)) }
|
||||
phone?.let { put("phone", kotlinx.serialization.json.JsonPrimitive(it)) }
|
||||
}
|
||||
val response = api.userUpdateProfile(TRPCRequest.body(body))
|
||||
val user = response.result.data
|
||||
CacheManager.save(context, "current_user", user)
|
||||
_currentUser.value = user
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
fun observeCurrentUser(): Flow<User?> = _currentUser
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.VoiceAnalysis
|
||||
import com.shieldai.android.data.model.VoiceEnrollment
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class VoicePrintRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
private val _enrollments = MutableStateFlow<List<VoiceEnrollment>>(emptyList())
|
||||
|
||||
suspend fun getEnrollments(): ApiResult<List<VoiceEnrollment>> {
|
||||
val cached: List<VoiceEnrollment>? = CacheManager.load(context, "voice_enrollments")
|
||||
if (cached != null) {
|
||||
_enrollments.value = cached
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.voiceEnrollments(TRPCRequest.body(buildJsonObject {}))
|
||||
val enrollments = response.result.data
|
||||
CacheManager.save(context, "voice_enrollments", enrollments)
|
||||
_enrollments.value = enrollments
|
||||
enrollments
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createEnrollment(name: String): ApiResult<VoiceEnrollment> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject { put("name", name) }
|
||||
val response = api.voiceCreateEnrollment(TRPCRequest.body(body))
|
||||
val enrollment = response.result.data
|
||||
refreshEnrollmentsCache()
|
||||
enrollment
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun analyze(enrollmentId: String, audioData: String): ApiResult<VoiceAnalysis> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject {
|
||||
put("enrollmentId", enrollmentId)
|
||||
put("audioData", audioData)
|
||||
}
|
||||
val response = api.voiceAnalyze(TRPCRequest.body(body))
|
||||
response.result.data
|
||||
}
|
||||
}
|
||||
|
||||
fun observeEnrollments(): Flow<List<VoiceEnrollment>> = _enrollments
|
||||
|
||||
private suspend fun refreshEnrollmentsCache() {
|
||||
ErrorHandler.executeWithRetry {
|
||||
val response = api.voiceEnrollments(TRPCRequest.body(buildJsonObject {}))
|
||||
val enrollments = response.result.data
|
||||
CacheManager.save(context, "voice_enrollments", enrollments)
|
||||
_enrollments.value = enrollments
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.shieldai.android.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class OfflineWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val queue = PendingRequestQueue(applicationContext)
|
||||
val pendingRequests = queue.getAll()
|
||||
if (pendingRequests.isEmpty()) return Result.success()
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
for (request in pendingRequests) {
|
||||
if (request.retryCount >= request.maxRetries) {
|
||||
queue.deleteById(request.id)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val body = request.body.toRequestBody(jsonMediaType)
|
||||
val httpRequest = Request.Builder()
|
||||
.url("https://api.shieldai.com/${request.endpoint}")
|
||||
.method(request.method, body)
|
||||
.build()
|
||||
val response = client.newCall(httpRequest).execute()
|
||||
if (response.isSuccessful) {
|
||||
queue.deleteById(request.id)
|
||||
} else {
|
||||
queue.incrementRetry(request.id)
|
||||
if (response.code == 422 || response.code == 400) {
|
||||
queue.deleteById(request.id)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
queue.incrementRetry(request.id)
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
queue.deleteExpired()
|
||||
return if (queue.count() == 0) Result.success() else Result.retry()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.shieldai.android.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class PendingRequest(
|
||||
val id: Long = 0,
|
||||
val endpoint: String,
|
||||
val method: String = "POST",
|
||||
val body: String,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val retryCount: Int = 0,
|
||||
val maxRetries: Int = 5,
|
||||
)
|
||||
|
||||
class PendingRequestQueue(private val context: Context) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
private val file: File get() = File(context.cacheDir, "pending_requests.json")
|
||||
|
||||
fun getAll(): List<PendingRequest> {
|
||||
if (!file.exists()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<PendingRequest>>(file.readText())
|
||||
} catch (_: Exception) {
|
||||
file.delete()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAll(requests: List<PendingRequest>) {
|
||||
file.writeText(json.encodeToString(requests))
|
||||
}
|
||||
|
||||
fun insert(request: PendingRequest) {
|
||||
val requests = getAll().toMutableList()
|
||||
val newId = (requests.maxOfOrNull { it.id } ?: 0) + 1
|
||||
requests.add(request.copy(id = newId))
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
fun incrementRetry(id: Long) {
|
||||
val requests = getAll().map {
|
||||
if (it.id == id) it.copy(retryCount = it.retryCount + 1) else it
|
||||
}
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
fun deleteById(id: Long) {
|
||||
val requests = getAll().filter { it.id != id }
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
fun deleteExpired() {
|
||||
val requests = getAll().filter { it.retryCount < it.maxRetries }
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
fun deleteAll() {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
fun count(): Int = getAll().size
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.shieldai.android.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SyncManager(private val context: Context) {
|
||||
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
private val queue = PendingRequestQueue(context)
|
||||
|
||||
fun enqueueRequest(endpoint: String, body: String, method: String = "POST") {
|
||||
val request = PendingRequest(
|
||||
endpoint = endpoint,
|
||||
method = method,
|
||||
body = body,
|
||||
)
|
||||
queue.insert(request)
|
||||
scheduleSync()
|
||||
}
|
||||
|
||||
fun scheduleSync(delayMinutes: Long = 0) {
|
||||
val workRequest = OneTimeWorkRequestBuilder<OfflineWorker>()
|
||||
.setInitialDelay(delayMinutes, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(
|
||||
"offline_sync",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest,
|
||||
)
|
||||
}
|
||||
|
||||
fun queueSize(): Int = queue.count()
|
||||
|
||||
fun startMonitoring() {
|
||||
val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
if (queueSize() > 0) {
|
||||
scheduleSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, networkCallback)
|
||||
}
|
||||
|
||||
fun isOnline(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android.di
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
|
||||
object DatabaseModule {
|
||||
fun initializeCache(context: Context) {
|
||||
CacheManager.setTtl("users", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("current_user", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("watchlist", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("exposures", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("alerts", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("subscription", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("voice_enrollments", 10 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.shieldai.android.di
|
||||
|
||||
import android.content.Context
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.shieldai.android.data.remote.AuthInterceptor
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NetworkModule {
|
||||
private var baseUrl: String = "http://10.0.2.2:3000/"
|
||||
private var retrofit: Retrofit? = null
|
||||
private var apiService: TRPCApiService? = null
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
fun setBaseUrl(url: String) {
|
||||
baseUrl = if (url.endsWith("/")) url else "$url/"
|
||||
retrofit = null
|
||||
apiService = null
|
||||
}
|
||||
|
||||
fun getBaseUrl(): String = baseUrl
|
||||
|
||||
fun provideOkHttpClient(context: Context): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(AuthInterceptor(context))
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun provideRetrofit(context: Context): Retrofit {
|
||||
return retrofit ?: synchronized(this) {
|
||||
retrofit ?: Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(provideOkHttpClient(context))
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.also { retrofit = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideApiService(context: Context): TRPCApiService {
|
||||
return apiService ?: synchronized(this) {
|
||||
apiService ?: provideRetrofit(context).create(TRPCApiService::class.java)
|
||||
.also { apiService = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.shieldai.android.di
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.repository.AlertRepository
|
||||
import com.shieldai.android.data.repository.DarkWatchRepository
|
||||
import com.shieldai.android.data.repository.SubscriptionRepository
|
||||
import com.shieldai.android.data.repository.UserRepository
|
||||
import com.shieldai.android.data.repository.VoicePrintRepository
|
||||
|
||||
object RepositoryModule {
|
||||
private var userRepository: UserRepository? = null
|
||||
private var darkWatchRepository: DarkWatchRepository? = null
|
||||
private var voicePrintRepository: VoicePrintRepository? = null
|
||||
private var alertRepository: AlertRepository? = null
|
||||
private var subscriptionRepository: SubscriptionRepository? = null
|
||||
|
||||
fun provideUserRepository(context: Context): UserRepository {
|
||||
return userRepository ?: synchronized(this) {
|
||||
UserRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { userRepository = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideDarkWatchRepository(context: Context): DarkWatchRepository {
|
||||
return darkWatchRepository ?: synchronized(this) {
|
||||
DarkWatchRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { darkWatchRepository = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideVoicePrintRepository(context: Context): VoicePrintRepository {
|
||||
return voicePrintRepository ?: synchronized(this) {
|
||||
VoicePrintRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { voicePrintRepository = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideAlertRepository(context: Context): AlertRepository {
|
||||
return alertRepository ?: synchronized(this) {
|
||||
AlertRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { alertRepository = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideSubscriptionRepository(context: Context): SubscriptionRepository {
|
||||
return subscriptionRepository ?: synchronized(this) {
|
||||
SubscriptionRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { subscriptionRepository = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.shieldai.android.data.local
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CacheManagerTest {
|
||||
|
||||
@Test
|
||||
fun isFresh_returnsTrue_whenWithinTtl() {
|
||||
val fresh = CacheManager.isFresh(System.currentTimeMillis(), "users")
|
||||
assertTrue(fresh)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isExpired_returnsTrue_whenPastTtl() {
|
||||
val expired = CacheManager.isFresh(System.currentTimeMillis() - 10 * 60 * 1000, "users")
|
||||
assertFalse(expired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customTtl_overridesDefault() {
|
||||
CacheManager.setTtl("fast_cache", 1000L)
|
||||
val fresh = CacheManager.isFresh(System.currentTimeMillis(), "fast_cache")
|
||||
assertTrue(fresh)
|
||||
|
||||
val expired = CacheManager.isFresh(System.currentTimeMillis() - 2000L, "fast_cache")
|
||||
assertFalse(expired)
|
||||
|
||||
CacheManager.clearOverrides()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getTtl_returnsDefault_whenNoOverride() {
|
||||
val ttl = CacheManager.getTtl("unknown_table")
|
||||
assertEquals(5 * 60 * 1000L, ttl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearOverrides_removesCustomTtls() {
|
||||
CacheManager.setTtl("test", 999L)
|
||||
assertEquals(999L, CacheManager.getTtl("test"))
|
||||
|
||||
CacheManager.clearOverrides()
|
||||
assertEquals(5 * 60 * 1000L, CacheManager.getTtl("test"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class ErrorHandlerTest {
|
||||
|
||||
@Test
|
||||
fun executeWithRetry_returnsSuccess_whenBlockSucceeds() = runTest {
|
||||
val result = ErrorHandler.executeWithRetry(maxRetries = 2) {
|
||||
"success"
|
||||
}
|
||||
assertTrue(result is ApiResult.Success)
|
||||
assertEquals("success", (result as ApiResult.Success).data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun executeWithRetry_retriesOnIOException_andReturnsError() = runTest {
|
||||
var attempts = 0
|
||||
val result = ErrorHandler.executeWithRetry(maxRetries = 2) {
|
||||
attempts++
|
||||
if (attempts <= 3) throw java.io.IOException("Network error")
|
||||
"success"
|
||||
}
|
||||
assertTrue(result is ApiResult.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun executeWithRetry_succeedsAfterRetry() = runTest {
|
||||
var attempts = 0
|
||||
val result = ErrorHandler.executeWithRetry(maxRetries = 3) {
|
||||
attempts++
|
||||
if (attempts < 3) throw java.io.IOException("Transient error")
|
||||
"success"
|
||||
}
|
||||
assertTrue("Should succeed after retry", result is ApiResult.Success)
|
||||
assertEquals("success", (result as ApiResult.Success).data)
|
||||
assertEquals(3, attempts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun executeWithRetry_retriesOnSocketTimeout() = runTest {
|
||||
var attempts = 0
|
||||
ErrorHandler.executeWithRetry(maxRetries = 2) {
|
||||
attempts++
|
||||
if (attempts <= 2) throw SocketTimeoutException("timeout")
|
||||
"success"
|
||||
}
|
||||
assertTrue(attempts >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun executeWithRetry_retriesOnConnectException() = runTest {
|
||||
var attempts = 0
|
||||
ErrorHandler.executeWithRetry(maxRetries = 2) {
|
||||
attempts++
|
||||
throw ConnectException("connection refused")
|
||||
}
|
||||
assertTrue(attempts >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun executeWithRetry_retriesOnUnknownHost() = runTest {
|
||||
var attempts = 0
|
||||
ErrorHandler.executeWithRetry(maxRetries = 2) {
|
||||
attempts++
|
||||
throw UnknownHostException()
|
||||
}
|
||||
assertTrue(attempts >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseError_returnsFriendlyMessages() {
|
||||
assertEquals("No internet connection", ErrorHandler.parseError(UnknownHostException()))
|
||||
assertEquals("Request timed out", ErrorHandler.parseError(SocketTimeoutException()))
|
||||
assertEquals("Connection refused", ErrorHandler.parseError(ConnectException()))
|
||||
assertEquals("Network error: boom", ErrorHandler.parseError(java.io.IOException("boom")))
|
||||
assertEquals("custom error", ErrorHandler.parseError(Exception("custom error")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TRPCResponseTest {
|
||||
|
||||
@Test
|
||||
fun tRPCRequest_createsCorrectBody() {
|
||||
val json = buildJsonObject {
|
||||
put("email", kotlinx.serialization.json.JsonPrimitive("test@example.com"))
|
||||
}
|
||||
val body = TRPCRequest.body(json)
|
||||
val jsonStr = body.toString()
|
||||
assertTrue(jsonStr.contains("test@example.com"))
|
||||
assertTrue(jsonStr.contains("\"0\""))
|
||||
assertTrue(jsonStr.contains("\"json\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tRPCRequest_handlesEmptyObject() {
|
||||
val json = buildJsonObject {}
|
||||
val body = TRPCRequest.body(json)
|
||||
val jsonStr = body.toString()
|
||||
assertTrue(jsonStr.contains("{}"))
|
||||
assertTrue(jsonStr.contains("\"0\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tRPCRequest_handlesNestedObject() {
|
||||
val json = buildJsonObject {
|
||||
put("profile", buildJsonObject {
|
||||
put("name", kotlinx.serialization.json.JsonPrimitive("Test"))
|
||||
put("age", kotlinx.serialization.json.JsonPrimitive(30))
|
||||
})
|
||||
}
|
||||
val body = TRPCRequest.body(json)
|
||||
val jsonStr = body.toString()
|
||||
assertTrue(jsonStr.contains("\"profile\""))
|
||||
assertTrue(jsonStr.contains("\"name\""))
|
||||
assertTrue(jsonStr.contains("\"age\""))
|
||||
assertNotNull(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.shieldai.android.data.sync
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SyncManagerTest {
|
||||
|
||||
private lateinit var fakeQueue: FakePendingRequestQueue
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fakeQueue = FakePendingRequestQueue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_insertsAndCounts() = runBlocking {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = """{"0":{"json":{"type":"email","value":"test@test.com"}}}""",
|
||||
))
|
||||
|
||||
assertEquals(1, fakeQueue.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_tracksRetryCount() = runBlocking {
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/user.updateProfile",
|
||||
body = """{"0":{"json":{"name":"New"}}}""",
|
||||
)
|
||||
fakeQueue.insert(request)
|
||||
val inserted = fakeQueue.getAll().first()
|
||||
fakeQueue.incrementRetry(inserted.id)
|
||||
|
||||
assertEquals(1, fakeQueue.getAll().first().retryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_deletesById() = runBlocking {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test",
|
||||
body = "{}",
|
||||
))
|
||||
val id = fakeQueue.getAll().first().id
|
||||
fakeQueue.deleteById(id)
|
||||
|
||||
assertEquals(0, fakeQueue.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_deletesExpiredRequests() = runBlocking {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test",
|
||||
body = "{}",
|
||||
retryCount = 5,
|
||||
maxRetries = 5,
|
||||
))
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test2",
|
||||
body = "{}",
|
||||
retryCount = 2,
|
||||
maxRetries = 5,
|
||||
))
|
||||
|
||||
fakeQueue.deleteExpired()
|
||||
|
||||
assertEquals(1, fakeQueue.count())
|
||||
assertEquals("test2", fakeQueue.getAll().first().endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
class FakePendingRequestQueue {
|
||||
private val store = mutableListOf<PendingRequest>()
|
||||
private var nextId = 1L
|
||||
|
||||
fun getAll(): List<PendingRequest> = store.toList()
|
||||
|
||||
fun count(): Int = store.size
|
||||
|
||||
fun insert(request: PendingRequest) {
|
||||
val toInsert = if (request.id == 0L) request.copy(id = nextId++) else request
|
||||
store.add(toInsert)
|
||||
}
|
||||
|
||||
fun incrementRetry(id: Long) {
|
||||
val idx = store.indexOfFirst { it.id == id }
|
||||
if (idx >= 0) {
|
||||
store[idx] = store[idx].copy(retryCount = store[idx].retryCount + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteById(id: Long) {
|
||||
store.removeAll { it.id == id }
|
||||
}
|
||||
|
||||
fun deleteExpired() {
|
||||
store.removeAll { it.retryCount >= it.maxRetries }
|
||||
}
|
||||
|
||||
fun deleteAll() {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user