diff --git a/android/ShieldAI/app/build.gradle.kts b/android/ShieldAI/app/build.gradle.kts
index 9a655de..b0d8cb3 100644
--- a/android/ShieldAI/app/build.gradle.kts
+++ b/android/ShieldAI/app/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
}
android {
@@ -19,15 +20,23 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
+ buildConfigField("String", "API_STAGING_URL", "\"https://staging.api.shieldai.com\"")
+ buildConfigField("String", "API_PRODUCTION_URL", "\"https://api.shieldai.com\"")
}
buildTypes {
+ debug {
+ buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
+ }
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ buildConfigField("String", "API_BASE_URL", "\"https://api.shieldai.com\"")
}
}
compileOptions {
@@ -36,6 +45,10 @@ android {
}
buildFeatures {
compose = true
+ buildConfig = true
+ }
+ lint {
+ baseline = file("lint-baseline.xml")
}
}
@@ -52,14 +65,24 @@ dependencies {
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation("androidx.compose.material:material-icons-core")
implementation(libs.coil.compose)
+ implementation(libs.lottie.compose)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.biometric)
- implementation(libs.play.services.auth)
implementation(libs.okhttp)
+ implementation(libs.okhttp.logging.interceptor)
implementation(libs.gson)
- implementation(libs.lottie.compose)
+ implementation(libs.play.services.auth)
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.kotlinx.serialization.converter)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.work.runtime.ktx)
+
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.truth)
+ testImplementation(libs.okhttp.mockwebserver)
+ testImplementation(libs.work.testing)
+
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
diff --git a/android/ShieldAI/app/lint-baseline.xml b/android/ShieldAI/app/lint-baseline.xml
new file mode 100644
index 0000000..637d5df
--- /dev/null
+++ b/android/ShieldAI/app/lint-baseline.xml
@@ -0,0 +1,521 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/local/CacheManager.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/local/CacheManager.kt
new file mode 100644
index 0000000..8b8726f
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/local/CacheManager.kt
@@ -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(
+ 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()
+ 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 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 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>>(text)
+ if (entry.isExpired()) {
+ file.delete()
+ null
+ } else {
+ json.decodeFromString>(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()
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Alert.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Alert.kt
new file mode 100644
index 0000000..5fccb69
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Alert.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/BrokerListing.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/BrokerListing.kt
new file mode 100644
index 0000000..9d4132a
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/BrokerListing.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Exposure.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Exposure.kt
new file mode 100644
index 0000000..97d127b
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Exposure.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Property.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Property.kt
new file mode 100644
index 0000000..38a52a8
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Property.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/RemovalRequest.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/RemovalRequest.kt
new file mode 100644
index 0000000..90767e1
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/RemovalRequest.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/SpamRule.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/SpamRule.kt
new file mode 100644
index 0000000..eee767e
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/SpamRule.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Subscription.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Subscription.kt
new file mode 100644
index 0000000..714d6a2
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Subscription.kt
@@ -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 = emptyList(),
+ @SerialName("auto_renew") val autoRenew: Boolean = true,
+ @SerialName("created_at") val createdAt: String? = null,
+ @SerialName("updated_at") val updatedAt: String? = null,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/User.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/User.kt
new file mode 100644
index 0000000..3a530aa
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/User.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/VoiceAnalysis.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/VoiceAnalysis.kt
new file mode 100644
index 0000000..f5d3cd2
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/VoiceAnalysis.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/VoiceEnrollment.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/VoiceEnrollment.kt
new file mode 100644
index 0000000..c8601ec
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/VoiceEnrollment.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/WatchlistItem.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/WatchlistItem.kt
new file mode 100644
index 0000000..104213f
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/WatchlistItem.kt
@@ -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,
+)
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/AuthInterceptor.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/AuthInterceptor.kt
new file mode 100644
index 0000000..481711b
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/AuthInterceptor.kt
@@ -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)
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/ErrorHandler.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/ErrorHandler.kt
new file mode 100644
index 0000000..53b9ced
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/ErrorHandler.kt
@@ -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 {
+ data class Success(val data: T) : ApiResult()
+ data class Error(val message: String, val code: Int = -1) : ApiResult()
+}
+
+object ErrorHandler {
+ private const val MAX_RETRIES = 3
+ private const val BASE_DELAY_MS = 1000L
+ private const val MAX_DELAY_MS = 10000L
+
+ suspend fun executeWithRetry(
+ maxRetries: Int = MAX_RETRIES,
+ block: suspend () -> T,
+ ): ApiResult {
+ 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"
+ }
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/TRPCApiService.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/TRPCApiService.kt
new file mode 100644
index 0000000..eb5a1db
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/TRPCApiService.kt
@@ -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
+
+ @POST("api/trpc/user.updateProfile")
+ suspend fun userUpdateProfile(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/subscription.get")
+ suspend fun subscriptionGet(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/subscription.update")
+ suspend fun subscriptionUpdate(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/darkwatch.getWatchlist")
+ suspend fun darkWatchGetWatchlist(@Body body: JsonObject): TRPCResponse>
+
+ @POST("api/trpc/darkwatch.addWatchlistItem")
+ suspend fun darkWatchAddWatchlistItem(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/darkwatch.removeWatchlistItem")
+ suspend fun darkWatchRemoveWatchlistItem(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/darkwatch.getExposures")
+ suspend fun darkWatchGetExposures(@Body body: JsonObject): TRPCResponse>
+
+ @POST("api/trpc/alerts.list")
+ suspend fun alertsList(@Body body: JsonObject): TRPCResponse>
+
+ @POST("api/trpc/alerts.markRead")
+ suspend fun alertsMarkRead(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/voice.enrollments")
+ suspend fun voiceEnrollments(@Body body: JsonObject): TRPCResponse>
+
+ @POST("api/trpc/voice.createEnrollment")
+ suspend fun voiceCreateEnrollment(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/voice.analyze")
+ suspend fun voiceAnalyze(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/spam.listRules")
+ suspend fun spamListRules(@Body body: JsonObject): TRPCResponse>
+
+ @POST("api/trpc/spam.createRule")
+ suspend fun spamCreateRule(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/property.list")
+ suspend fun propertyList(@Body body: JsonObject): TRPCResponse>
+
+ @POST("api/trpc/property.add")
+ suspend fun propertyAdd(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/removal.list")
+ suspend fun removalList(@Body body: JsonObject): TRPCResponse>
+
+ @POST("api/trpc/removal.create")
+ suspend fun removalCreate(@Body body: JsonObject): TRPCResponse
+
+ @POST("api/trpc/broker.listListings")
+ suspend fun brokerListListings(@Body body: JsonObject): TRPCResponse>
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/TRPCResponse.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/TRPCResponse.kt
new file mode 100644
index 0000000..976ff59
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/TRPCResponse.kt
@@ -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(
+ val result: TRPCResult,
+)
+
+@Serializable
+data class TRPCResult(
+ 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)
+ })
+ }
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/AlertRepository.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/AlertRepository.kt
new file mode 100644
index 0000000..e28ebc4
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/AlertRepository.kt
@@ -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>(emptyList())
+
+ suspend fun getAlerts(): ApiResult> {
+ val cached: List? = 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 {
+ 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> = _alerts
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/DarkWatchRepository.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/DarkWatchRepository.kt
new file mode 100644
index 0000000..102ae78
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/DarkWatchRepository.kt
@@ -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>(emptyList())
+
+ suspend fun getWatchlist(forceRefresh: Boolean = false): ApiResult> {
+ if (!forceRefresh) {
+ val cached: List? = 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 {
+ 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 {
+ return ErrorHandler.executeWithRetry {
+ val body = buildJsonObject { put("id", id) }
+ api.darkWatchRemoveWatchlistItem(TRPCRequest.body(body))
+ refreshCache()
+ }
+ }
+
+ suspend fun getExposures(forceRefresh: Boolean = false): ApiResult> {
+ if (!forceRefresh) {
+ val cached: List? = 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> = _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
+ }
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/SubscriptionRepository.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/SubscriptionRepository.kt
new file mode 100644
index 0000000..dbaed4a
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/SubscriptionRepository.kt
@@ -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 {
+ 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 {
+ 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
+ }
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/UserRepository.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/UserRepository.kt
new file mode 100644
index 0000000..1e421b6
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/UserRepository.kt
@@ -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(null)
+
+ suspend fun getMe(forceRefresh: Boolean = false): ApiResult {
+ 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 {
+ 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 = _currentUser
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/VoicePrintRepository.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/VoicePrintRepository.kt
new file mode 100644
index 0000000..976659c
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/VoicePrintRepository.kt
@@ -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>(emptyList())
+
+ suspend fun getEnrollments(): ApiResult> {
+ val cached: List? = 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 {
+ 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 {
+ 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> = _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
+ }
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/OfflineWorker.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/OfflineWorker.kt
new file mode 100644
index 0000000..330d4fd
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/OfflineWorker.kt
@@ -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()
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/PendingRequestQueue.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/PendingRequestQueue.kt
new file mode 100644
index 0000000..95b75fd
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/PendingRequestQueue.kt
@@ -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 {
+ if (!file.exists()) return emptyList()
+ return try {
+ json.decodeFromString>(file.readText())
+ } catch (_: Exception) {
+ file.delete()
+ emptyList()
+ }
+ }
+
+ private fun saveAll(requests: List) {
+ 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
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/SyncManager.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/SyncManager.kt
new file mode 100644
index 0000000..e383294
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/SyncManager.kt
@@ -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()
+ .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)
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/di/DatabaseModule.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/di/DatabaseModule.kt
new file mode 100644
index 0000000..c5bc91c
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/di/DatabaseModule.kt
@@ -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)
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/di/NetworkModule.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/di/NetworkModule.kt
new file mode 100644
index 0000000..cfb44b9
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/di/NetworkModule.kt
@@ -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 }
+ }
+ }
+}
diff --git a/android/ShieldAI/app/src/main/java/com/shieldai/android/di/RepositoryModule.kt b/android/ShieldAI/app/src/main/java/com/shieldai/android/di/RepositoryModule.kt
new file mode 100644
index 0000000..9ded37f
--- /dev/null
+++ b/android/ShieldAI/app/src/main/java/com/shieldai/android/di/RepositoryModule.kt
@@ -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 }
+ }
+ }
+}
diff --git a/android/ShieldAI/app/src/test/java/com/shieldai/android/data/local/CacheManagerTest.kt b/android/ShieldAI/app/src/test/java/com/shieldai/android/data/local/CacheManagerTest.kt
new file mode 100644
index 0000000..bd05c89
--- /dev/null
+++ b/android/ShieldAI/app/src/test/java/com/shieldai/android/data/local/CacheManagerTest.kt
@@ -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"))
+ }
+}
diff --git a/android/ShieldAI/app/src/test/java/com/shieldai/android/data/remote/ErrorHandlerTest.kt b/android/ShieldAI/app/src/test/java/com/shieldai/android/data/remote/ErrorHandlerTest.kt
new file mode 100644
index 0000000..9b3fec8
--- /dev/null
+++ b/android/ShieldAI/app/src/test/java/com/shieldai/android/data/remote/ErrorHandlerTest.kt
@@ -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")))
+ }
+}
diff --git a/android/ShieldAI/app/src/test/java/com/shieldai/android/data/remote/TRPCResponseTest.kt b/android/ShieldAI/app/src/test/java/com/shieldai/android/data/remote/TRPCResponseTest.kt
new file mode 100644
index 0000000..0c0b871
--- /dev/null
+++ b/android/ShieldAI/app/src/test/java/com/shieldai/android/data/remote/TRPCResponseTest.kt
@@ -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)
+ }
+}
diff --git a/android/ShieldAI/app/src/test/java/com/shieldai/android/data/sync/SyncManagerTest.kt b/android/ShieldAI/app/src/test/java/com/shieldai/android/data/sync/SyncManagerTest.kt
new file mode 100644
index 0000000..0dcec70
--- /dev/null
+++ b/android/ShieldAI/app/src/test/java/com/shieldai/android/data/sync/SyncManagerTest.kt
@@ -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()
+ private var nextId = 1L
+
+ fun getAll(): List = 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()
+ }
+}
diff --git a/android/ShieldAI/build.gradle.kts b/android/ShieldAI/build.gradle.kts
index 18318be..86acfea 100644
--- a/android/ShieldAI/build.gradle.kts
+++ b/android/ShieldAI/build.gradle.kts
@@ -1,5 +1,5 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
-}
\ No newline at end of file
+ alias(libs.plugins.kotlin.serialization) apply false
+}
diff --git a/android/ShieldAI/gradle.properties b/android/ShieldAI/gradle.properties
index 34c5e9e..2ddb726 100644
--- a/android/ShieldAI/gradle.properties
+++ b/android/ShieldAI/gradle.properties
@@ -1,15 +1,2 @@
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
-# Specifies the JVM arguments used for the daemon process.
-# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. For more details, visit
-# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
-# org.gradle.parallel=true
-# Kotlin code style for this project: "official" or "obsolete":
-kotlin.code.style=official
\ No newline at end of file
+kotlin.code.style=official
diff --git a/android/ShieldAI/gradle/libs.versions.toml b/android/ShieldAI/gradle/libs.versions.toml
index b4b5180..a6e2b49 100644
--- a/android/ShieldAI/gradle/libs.versions.toml
+++ b/android/ShieldAI/gradle/libs.versions.toml
@@ -17,6 +17,12 @@ okhttp = "4.12.0"
gson = "2.10.1"
lottieCompose = "6.4.0"
coroutinesTest = "1.7.3"
+retrofit = "2.11.0"
+retrofitKotlinxSerializationConverter = "1.0.0"
+kotlinxSerializationJson = "1.7.3"
+work = "2.9.1"
+truth = "1.4.4"
+mockwebserver = "4.12.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -43,8 +49,16 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" }
+okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
+work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
+truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
-
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }