From 3ccaeaa2e385e857f2166f381cb6774fe54ea20f Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 20:41:53 -0400 Subject: [PATCH] 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 --- android/ShieldAI/app/build.gradle.kts | 27 +- android/ShieldAI/app/lint-baseline.xml | 521 ++++++++++++++++++ .../android/data/local/CacheManager.kt | 77 +++ .../com/shieldai/android/data/model/Alert.kt | 17 + .../android/data/model/BrokerListing.kt | 17 + .../shieldai/android/data/model/Exposure.kt | 18 + .../shieldai/android/data/model/Property.kt | 17 + .../android/data/model/RemovalRequest.kt | 16 + .../shieldai/android/data/model/SpamRule.kt | 16 + .../android/data/model/Subscription.kt | 17 + .../com/shieldai/android/data/model/User.kt | 19 + .../android/data/model/VoiceAnalysis.kt | 14 + .../android/data/model/VoiceEnrollment.kt | 14 + .../android/data/model/WatchlistItem.kt | 16 + .../android/data/remote/AuthInterceptor.kt | 31 ++ .../android/data/remote/ErrorHandler.kt | 63 +++ .../android/data/remote/TRPCApiService.kt | 78 +++ .../android/data/remote/TRPCResponse.kt | 47 ++ .../data/repository/AlertRepository.kt | 47 ++ .../data/repository/DarkWatchRepository.kt | 84 +++ .../data/repository/SubscriptionRepository.kt | 38 ++ .../android/data/repository/UserRepository.kt | 52 ++ .../data/repository/VoicePrintRepository.kt | 68 +++ .../android/data/sync/OfflineWorker.kt | 52 ++ .../android/data/sync/PendingRequestQueue.kt | 71 +++ .../shieldai/android/data/sync/SyncManager.kt | 65 +++ .../com/shieldai/android/di/DatabaseModule.kt | 16 + .../com/shieldai/android/di/NetworkModule.kt | 61 ++ .../shieldai/android/di/RepositoryModule.kt | 61 ++ .../android/data/local/CacheManagerTest.kt | 48 ++ .../android/data/remote/ErrorHandlerTest.kt | 85 +++ .../android/data/remote/TRPCResponseTest.kt | 47 ++ .../android/data/sync/SyncManagerTest.kt | 106 ++++ android/ShieldAI/build.gradle.kts | 4 +- android/ShieldAI/gradle.properties | 15 +- android/ShieldAI/gradle/libs.versions.toml | 16 +- 36 files changed, 1942 insertions(+), 19 deletions(-) create mode 100644 android/ShieldAI/app/lint-baseline.xml create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/local/CacheManager.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Alert.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/BrokerListing.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Exposure.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Property.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/RemovalRequest.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/SpamRule.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/Subscription.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/User.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/VoiceAnalysis.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/VoiceEnrollment.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/model/WatchlistItem.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/AuthInterceptor.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/ErrorHandler.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/TRPCApiService.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/remote/TRPCResponse.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/AlertRepository.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/DarkWatchRepository.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/SubscriptionRepository.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/UserRepository.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/repository/VoicePrintRepository.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/OfflineWorker.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/PendingRequestQueue.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/data/sync/SyncManager.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/di/DatabaseModule.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/di/NetworkModule.kt create mode 100644 android/ShieldAI/app/src/main/java/com/shieldai/android/di/RepositoryModule.kt create mode 100644 android/ShieldAI/app/src/test/java/com/shieldai/android/data/local/CacheManagerTest.kt create mode 100644 android/ShieldAI/app/src/test/java/com/shieldai/android/data/remote/ErrorHandlerTest.kt create mode 100644 android/ShieldAI/app/src/test/java/com/shieldai/android/data/remote/TRPCResponseTest.kt create mode 100644 android/ShieldAI/app/src/test/java/com/shieldai/android/data/sync/SyncManagerTest.kt 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" }