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:
2026-05-25 20:41:53 -04:00
parent a90534e164
commit 3ccaeaa2e3
36 changed files with 1942 additions and 19 deletions

View File

@@ -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))

View File

@@ -0,0 +1,521 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 9.1.1" type="baseline" client="gradle" dependencies="false" name="AGP (9.1.1)" variant="all" version="9.1.1">
<issue
id="RedundantLabel"
message="Redundant label can be removed"
errorLine1=" android:label=&quot;@string/app_name&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="20"
column="13"/>
</issue>
<issue
id="AndroidGradlePluginVersion"
message="A newer version of Gradle than 9.3.1 is available: 9.5.1"
errorLine1="distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/wrapper/gradle-wrapper.properties"
line="5"
column="17"/>
</issue>
<issue
id="AndroidGradlePluginVersion"
message="A newer version of com.android.application than 9.1.1 is available: 9.2.1"
errorLine1="agp = &quot;9.1.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="2"
column="7"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.core:core-ktx than 1.10.1 is available: 1.18.0"
errorLine1="coreKtx = &quot;1.10.1&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="3"
column="11"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.test.ext:junit than 1.1.5 is available: 1.3.0"
errorLine1="junitVersion = &quot;1.1.5&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="5"
column="16"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.test.espresso:espresso-core than 3.5.1 is available: 3.7.0"
errorLine1="espressoCore = &quot;3.5.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="6"
column="16"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.6.1 is available: 2.10.0"
errorLine1="lifecycleRuntimeKtx = &quot;2.6.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="7"
column="23"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.activity:activity-compose than 1.8.0 is available: 1.13.0"
errorLine1="activityCompose = &quot;1.8.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="8"
column="19"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.navigation:navigation-compose than 2.7.7 is available: 2.9.8"
errorLine1="navigationCompose = &quot;2.7.7&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="9"
column="21"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.compose:compose-bom than 2025.12.00 is available: 2026.05.01"
errorLine1="composeBom = &quot;2025.12.00&quot;"
errorLine2=" ~~~~~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="11"
column="14"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.security:security-crypto than 1.1.0-alpha06 is available: 1.1.0"
errorLine1="securityCrypto = &quot;1.1.0-alpha06&quot;"
errorLine2=" ~~~~~~~~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="13"
column="18"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of com.google.android.gms:play-services-auth than 21.0.0 is available: 21.5.1"
errorLine1="playServicesAuth = &quot;21.0.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="15"
column="20"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-runtime-ktx than 2.9.1 is available: 2.11.2"
errorLine1="work = &quot;2.9.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="23"
column="8"/>
</issue>
<issue
id="GradleDependency"
message="A newer version of androidx.work:work-testing than 2.9.1 is available: 2.11.2"
errorLine1="work = &quot;2.9.1&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="23"
column="8"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.10 is available: 2.3.21"
errorLine1="kotlin = &quot;2.2.10&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="10"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlin.plugin.serialization than 2.2.10 is available: 2.3.21"
errorLine1="kotlin = &quot;2.2.10&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="10"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:logging-interceptor than 4.12.0 is available: 5.3.2"
errorLine1="okhttp = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="16"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:okhttp than 4.12.0 is available: 5.3.2"
errorLine1="okhttp = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="16"
column="10"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.code.gson:gson than 2.10.1 is available: 2.14.0"
errorLine1="gson = &quot;2.10.1&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="17"
column="8"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.airbnb.android:lottie-compose than 6.4.0 is available: 6.7.1"
errorLine1="lottieCompose = &quot;6.4.0&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="18"
column="17"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlinx:kotlinx-coroutines-test than 1.7.3 is available: 1.11.0"
errorLine1="coroutinesTest = &quot;1.7.3&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="19"
column="18"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.retrofit2:retrofit than 2.11.0 is available: 3.0.0"
errorLine1="retrofit = &quot;2.11.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="20"
column="12"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of org.jetbrains.kotlinx:kotlinx-serialization-json than 1.7.3 is available: 1.11.0"
errorLine1="kotlinxSerializationJson = &quot;1.7.3&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="22"
column="28"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.google.truth:truth than 1.4.4 is available: 1.4.5"
errorLine1="truth = &quot;1.4.4&quot;"
errorLine2=" ~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="24"
column="9"/>
</issue>
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.okhttp3:mockwebserver than 4.12.0 is available: 5.3.2"
errorLine1="mockwebserver = &quot;4.12.0&quot;"
errorLine2=" ~~~~~~~~">
<location
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
line="25"
column="17"/>
</issue>
<issue
id="LocalContextGetResourceValueCall"
message="Querying resource values using LocalContext.current"
errorLine1=" .requestIdToken(context.getString(com.shieldai.android.R.string.default_web_client_id))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/ui/screens/auth/LoginScreen.kt"
line="56"
column="29"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `UserRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var userRepository: UserRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="11"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `DarkWatchRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var darkWatchRepository: DarkWatchRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="12"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `VoicePrintRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var voicePrintRepository: VoicePrintRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="13"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `AlertRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var alertRepository: AlertRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="14"
column="5"/>
</issue>
<issue
id="StaticFieldLeak"
message="Do not place Android context classes in static fields (static reference to `SubscriptionRepository` which has field `context` pointing to `Context`); this is a memory leak"
errorLine1=" private var subscriptionRepository: SubscriptionRepository? = null"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
line="15"
column="5"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_primary&quot;>#FF4F46E5&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="3"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_primary_light` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_primary_light&quot;>#FF818CF8&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="4"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.brand_accent` appears to be unused"
errorLine1=" &lt;color name=&quot;brand_accent&quot;>#FF06B6D4&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="5"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.bg_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;bg_primary&quot;>#FFFFFFFF&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="6"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.bg_primary_dark` appears to be unused"
errorLine1=" &lt;color name=&quot;bg_primary_dark&quot;>#FF0F172A&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="7"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.text_primary` appears to be unused"
errorLine1=" &lt;color name=&quot;text_primary&quot;>#FF0F172A&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="8"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.text_primary_dark` appears to be unused"
errorLine1=" &lt;color name=&quot;text_primary_dark&quot;>#FFF1F5F9&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="9"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.success` appears to be unused"
errorLine1=" &lt;color name=&quot;success&quot;>#FF22C55E&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="10"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.warning` appears to be unused"
errorLine1=" &lt;color name=&quot;warning&quot;>#FFF59E0B&lt;/color>"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="11"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.error` appears to be unused"
errorLine1=" &lt;color name=&quot;error&quot;>#FFEF4444&lt;/color>"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="12"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.color.info` appears to be unused"
errorLine1=" &lt;color name=&quot;info&quot;>#FF3B82F6&lt;/color>"
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/res/values/colors.xml"
line="13"
column="12"/>
</issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_home` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_home.xml"
line="1"
column="1"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" securePrefs.edit()"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/data/repository/AuthRepository.kt"
line="144"
column="9"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" securePrefs.edit()"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/data/repository/AuthRepository.kt"
line="155"
column="9"/>
</issue>
<issue
id="UseKtx"
message="Use the KTX extension function `SharedPreferences.edit` instead?"
errorLine1=" prefs.edit().putBoolean(&quot;biometric_enabled&quot;, enabled).apply()"
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/java/com/shieldai/android/ui/screens/auth/BiometricAuthScreen.kt"
line="88"
column="5"/>
</issue>
<issue
id="UseTomlInstead"
message="Use version catalog instead"
errorLine1=" implementation(&quot;androidx.compose.material:material-icons-core&quot;)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle.kts"
line="66"
column="20"/>
</issue>
</issues>

View File

@@ -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()
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -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"
}
}
}

View File

@@ -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>>
}

View File

@@ -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)
})
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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"))
}
}

View File

@@ -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")))
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
alias(libs.plugins.kotlin.serialization) apply false
}

View File

@@ -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
kotlin.code.style=official

View File

@@ -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" }