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