finish android task suite
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
@@ -31,6 +34,25 @@ android {
|
||||
// resourceConfigurations.addAll(listOf("en"))
|
||||
}
|
||||
|
||||
// Load signing configuration from key.properties
|
||||
// This file is NOT committed — see key.properties.template
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (keystoreProperties.isNotEmpty()) {
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
@@ -49,8 +71,8 @@ android {
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://api.kordant.com\"")
|
||||
|
||||
// Signing config for release builds
|
||||
// In production, use signingConfigs with keystore properties
|
||||
// signingConfig = signingConfigs.getByName("release")
|
||||
// Requires key.properties (see key.properties.template)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +159,7 @@ dependencies {
|
||||
implementation(libs.okhttp.logging.interceptor)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.play.services.auth)
|
||||
implementation(libs.play.integrity)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.kotlinx.serialization.converter)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
6
android/app/proguard-rules.pro
vendored
6
android/app/proguard-rules.pro
vendored
@@ -177,3 +177,9 @@
|
||||
|
||||
# Keep content descriptors for TalkBack
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# ============================================================
|
||||
# Play Integrity API
|
||||
# ============================================================
|
||||
-keep class com.google.android.play.integrity.** { *; }
|
||||
-dontwarn com.google.android.play.integrity.**
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
<data android:scheme="kordant" android:host="alerts" />
|
||||
<data android:scheme="kordant" android:host="settings" />
|
||||
<data android:scheme="kordant" android:host="services" />
|
||||
<data android:scheme="kordant" android:host="darkwatch" />
|
||||
<data android:scheme="kordant" android:host="family" />
|
||||
<data android:scheme="kordant" android:host="billing" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- HTTP/HTTPS deep links for FCM and web sharing -->
|
||||
@@ -114,6 +117,10 @@
|
||||
<action android:name="com.kordant.android.action.SHARE" />
|
||||
<action android:name="com.kordant.android.action.REPLY" />
|
||||
<action android:name="com.kordant.android.action.SNOOZE" />
|
||||
<action android:name="com.kordant.android.action.ACCEPT_INVITE" />
|
||||
<action android:name="com.kordant.android.action.DECLINE_INVITE" />
|
||||
<action android:name="com.kordant.android.action.RENEW_NOW" />
|
||||
<action android:name="com.kordant.android.action.MANAGE_SUBSCRIPTION" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -105,7 +105,8 @@ class KordantApp : Application() {
|
||||
userPreferencesDataStore = UserPreferencesDataStore(this)
|
||||
|
||||
// Auth repository (needed by AuthViewModel on first screen)
|
||||
authRepository = AuthRepositoryImpl(this, secureStorageManager)
|
||||
val refreshManager = NetworkModule.provideTokenRefreshManager(this)
|
||||
authRepository = AuthRepositoryImpl(this, secureStorageManager, tokenRefreshManager = refreshManager)
|
||||
|
||||
StartupTracker.onCriticalInitEnd()
|
||||
|
||||
@@ -185,6 +186,9 @@ class KordantApp : Application() {
|
||||
// Spam database — trigger SQLite init so DB is ready for first call
|
||||
initSpamDatabase()
|
||||
|
||||
// Start periodic token refresh
|
||||
initTokenRefresh()
|
||||
|
||||
Log.i(TAG, "Lazy init complete")
|
||||
}
|
||||
|
||||
@@ -376,6 +380,24 @@ class KordantApp : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the periodic token refresh loop so the access token is
|
||||
* refreshed 5 minutes before expiry without user interruption.
|
||||
*
|
||||
* If the user isn't logged in, this is a no-op until auth tokens
|
||||
* become available (login/signup), at which point the periodic loop
|
||||
* picks them up automatically.
|
||||
*/
|
||||
private fun initTokenRefresh() {
|
||||
try {
|
||||
val refreshManager = NetworkModule.provideTokenRefreshManager(this)
|
||||
refreshManager.startPeriodicRefresh()
|
||||
Log.i(TAG, "Periodic token refresh started")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start periodic token refresh", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KordantApp"
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -84,6 +90,10 @@ class MainActivity : ComponentActivity() {
|
||||
// Deep link navigation state
|
||||
private var pendingDeepLink: DeepLink? = null
|
||||
|
||||
// Session refresh on foreground
|
||||
private var isFirstResume = true
|
||||
private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
StartupTracker.onActivityCreateStart()
|
||||
|
||||
@@ -101,6 +111,36 @@ class MainActivity : ComponentActivity() {
|
||||
// Handle incoming intent (deep links, shortcuts)
|
||||
handleIntent(intent)
|
||||
|
||||
// Observe lifecycle to refresh session on foreground
|
||||
lifecycle.addObserver(LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
if (isFirstResume) {
|
||||
isFirstResume = false
|
||||
} else {
|
||||
// App came to foreground — check/refresh session
|
||||
lifecycleScope.launch {
|
||||
authViewModel.checkAndRefreshSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Track foreground state for in-app notification handling
|
||||
com.kordant.android.notification.ForegroundNotificationManager.observeLifecycle(this)
|
||||
|
||||
// Attach SyncManager to process offline queue on app foreground
|
||||
// The SyncManager is initialized lazily via KordantApp.getSyncManager()
|
||||
lifecycle.addObserver(LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
try {
|
||||
(application as com.kordant.android.KordantApp).getSyncManager()
|
||||
.onAppForegrounded()
|
||||
} catch (_: Exception) {
|
||||
// SyncManager not ready yet — will be processed on next resume
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
StartupTracker.onFirstFrame()
|
||||
|
||||
setContent {
|
||||
@@ -181,6 +221,9 @@ class MainActivity : ComponentActivity() {
|
||||
"alerts" -> DeepLink.Alerts
|
||||
"alert_detail" -> DeepLink.AlertDetail(id ?: "")
|
||||
"service" -> DeepLink.Service(id ?: "")
|
||||
"darkwatch" -> DeepLink.DarkWatch
|
||||
"family" -> DeepLink.Family
|
||||
"billing" -> DeepLink.Billing
|
||||
"settings" -> DeepLink.Settings
|
||||
else -> null
|
||||
}
|
||||
@@ -207,6 +250,9 @@ class MainActivity : ComponentActivity() {
|
||||
DeepLink.Service(serviceId ?: "")
|
||||
}
|
||||
"scan" -> DeepLink.NewScan
|
||||
"darkwatch" -> DeepLink.DarkWatch
|
||||
"family" -> DeepLink.Family
|
||||
"billing" -> DeepLink.Billing
|
||||
"settings" -> DeepLink.Settings
|
||||
"services" -> DeepLink.Services
|
||||
else -> null
|
||||
@@ -227,6 +273,9 @@ class MainActivity : ComponentActivity() {
|
||||
if (serviceId != null) DeepLink.Service(serviceId)
|
||||
else DeepLink.Services
|
||||
}
|
||||
segments.firstOrNull() == "family" -> DeepLink.Family
|
||||
segments.firstOrNull() == "billing" -> DeepLink.Billing
|
||||
segments.firstOrNull() == "darkwatch" -> DeepLink.DarkWatch
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -273,6 +322,9 @@ sealed class DeepLink {
|
||||
data object Settings : DeepLink()
|
||||
data object Services : DeepLink()
|
||||
data object NewScan : DeepLink()
|
||||
data object DarkWatch : DeepLink()
|
||||
data object Family : DeepLink()
|
||||
data object Billing : DeepLink()
|
||||
data class AlertDetail(val alertId: String) : DeepLink()
|
||||
data class Service(val serviceId: String) : DeepLink()
|
||||
}
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
package com.kordant.android.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.kordant.android.BuildConfig
|
||||
import com.kordant.android.data.local.SecureStorageManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* OkHttp interceptor that:
|
||||
* 1. Attaches access token from EncryptedSharedPreferences
|
||||
* 2. Automatically refreshes expired tokens using refresh token
|
||||
* 3. Retries the original request with the new token
|
||||
* OkHttp interceptor that attaches the Bearer access token
|
||||
* from [EncryptedSharedPreferences][SecureStorageManager] to every outgoing request.
|
||||
*
|
||||
* Token refresh is silent — the user never sees an interruption.
|
||||
* Token refresh on 401 is handled by [TokenRefreshAuthenticator] (an OkHttp [Authenticator]),
|
||||
* which runs on a dedicated thread pool and silently retries failed requests.
|
||||
*
|
||||
* ## Why Interceptor + Authenticator?
|
||||
*
|
||||
* - **Interceptor**: Runs on every request, BEFORE the response is examined.
|
||||
* We use it here to simply add the `Authorization: Bearer <token>` header.
|
||||
* - **Authenticator**: Runs ONLY when the server responds with 401.
|
||||
* This is where we refresh the token and retry. Separating concerns
|
||||
* makes the code cleaner and avoids mixing request modification with
|
||||
* response handling in a single interceptor.
|
||||
*/
|
||||
class AuthInterceptor(
|
||||
private val context: Context,
|
||||
private val secureStorageManager: SecureStorageManager
|
||||
) : Interceptor {
|
||||
|
||||
@@ -32,104 +31,19 @@ class AuthInterceptor(
|
||||
private const val BEARER_PREFIX = "Bearer "
|
||||
}
|
||||
|
||||
// Lock to prevent concurrent token refresh attempts
|
||||
private val refreshLock = Any()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val token = secureStorageManager.getAccessToken()
|
||||
|
||||
// Build request with auth header
|
||||
val authenticatedRequest = if (token != null) {
|
||||
originalRequest.newBuilder()
|
||||
// If we have a token, attach it as Bearer auth
|
||||
if (token != null) {
|
||||
val authenticatedRequest = originalRequest.newBuilder()
|
||||
.header(AUTH_HEADER, "$BEARER_PREFIX$token")
|
||||
.build()
|
||||
} else {
|
||||
originalRequest
|
||||
return chain.proceed(authenticatedRequest)
|
||||
}
|
||||
|
||||
var response = chain.proceed(authenticatedRequest)
|
||||
|
||||
// If 401 Unauthorized, try to refresh the token
|
||||
if (response.code == 401 && token != null) {
|
||||
response.close()
|
||||
|
||||
synchronized(refreshLock) {
|
||||
val refreshToken = secureStorageManager.getRefreshToken()
|
||||
if (refreshToken != null) {
|
||||
val newTokens = refreshAccessToken(refreshToken)
|
||||
if (newTokens != null) {
|
||||
// Retry the original request with the new token
|
||||
val retryRequest = originalRequest.newBuilder()
|
||||
.header(AUTH_HEADER, "$BEARER_PREFIX${newTokens.accessToken}")
|
||||
.build()
|
||||
response = chain.proceed(retryRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
// No token available — proceed without auth header
|
||||
return chain.proceed(originalRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the auth API URL based on BuildConfig.
|
||||
*/
|
||||
private fun getAuthUrl(): String {
|
||||
val url = BuildConfig.API_BASE_URL
|
||||
return if (url.endsWith("/")) "${url}api" else "$url/api"
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the access token using the refresh token.
|
||||
* Returns new tokens or null if refresh failed.
|
||||
*/
|
||||
private fun refreshAccessToken(refreshToken: String): TokenPair? {
|
||||
return try {
|
||||
val apiUrl = getAuthUrl()
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val body = JSONObject().apply {
|
||||
put("refreshToken", refreshToken)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/auth/refresh")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
if (response.isSuccessful) {
|
||||
val responseBody = response.body?.string() ?: return null
|
||||
val json = JSONObject(responseBody)
|
||||
val newAccessToken = json.optString("accessToken", null) ?: return null
|
||||
val newRefreshToken = json.optString("refreshToken", null)
|
||||
.takeIf { it.isNotEmpty() && it != "null" }
|
||||
?: refreshToken // Keep old refresh token if not rotated
|
||||
|
||||
// Save new tokens
|
||||
secureStorageManager.saveTokens(newAccessToken, newRefreshToken)
|
||||
|
||||
TokenPair(newAccessToken, newRefreshToken)
|
||||
} else {
|
||||
// Refresh failed — clear tokens (user must re-authenticate)
|
||||
Log.w(TAG, "Token refresh failed: HTTP ${response.code}")
|
||||
secureStorageManager.clearAllAuthData()
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Network error during token refresh", e)
|
||||
// Return null, original 401 will be handled by caller
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class TokenPair(
|
||||
val accessToken: String,
|
||||
val refreshToken: String
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.kordant.android.data.remote
|
||||
|
||||
import android.util.Log
|
||||
import com.kordant.android.data.local.SecureStorageManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
|
||||
/**
|
||||
* OkHttp [Authenticator] that silently handles 401 Unauthorized responses
|
||||
* by refreshing the access token and retrying the original request.
|
||||
*
|
||||
* ## Design
|
||||
*
|
||||
* - Uses a [Mutex] to ensure only one refresh runs at a time across all threads.
|
||||
* - Other requests that hit 401 wait for the in-flight refresh to complete,
|
||||
* then retry with the new token.
|
||||
* - If the refresh token itself is expired/invalid, all queued requests fail
|
||||
* with the original 401 (which the UI layer can detect and redirect to login).
|
||||
* - Skips auth-related endpoints (login, signup, refresh, forgot-password,
|
||||
* reset-password) to prevent infinite loops.
|
||||
*
|
||||
* ## Thread Safety
|
||||
*
|
||||
* OkHttp calls [authenticate] on a dedicated thread pool, so we use
|
||||
* [runBlocking] to bridge into the coroutine-based [TokenRefreshManager].
|
||||
*/
|
||||
class TokenRefreshAuthenticator(
|
||||
private val secureStorageManager: SecureStorageManager,
|
||||
private val tokenRefreshManager: TokenRefreshManager,
|
||||
) : Authenticator {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TokenRefreshAuthenticator"
|
||||
private const val AUTH_HEADER = "Authorization"
|
||||
private const val BEARER_PREFIX = "Bearer "
|
||||
|
||||
/**
|
||||
* Path fragments that should NOT trigger token refresh.
|
||||
* Includes auth endpoints to prevent infinite retry loops.
|
||||
*/
|
||||
private val SKIP_PATHS = listOf(
|
||||
"/auth/login",
|
||||
"/auth/signup",
|
||||
"/auth/google",
|
||||
"/auth/refresh",
|
||||
"/auth/forgot-password",
|
||||
"/auth/reset-password",
|
||||
"/auth/logout",
|
||||
)
|
||||
}
|
||||
|
||||
/** Mutex to prevent duplicate concurrent refresh calls. */
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Tracks the result of the most recent refresh attempt.
|
||||
* Cached so that waiters don't re-trigger refresh.
|
||||
* Reset to null on success to allow future refreshes.
|
||||
*/
|
||||
@Volatile
|
||||
private var lastRefreshResult: RefreshResult? = null
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
// Only handle 401 responses
|
||||
if (response.code != 401) return null
|
||||
|
||||
val requestPath = response.request.url.encodedPath
|
||||
|
||||
// Skip auth endpoints to prevent infinite loops
|
||||
if (SKIP_PATHS.any { requestPath.contains(it) }) {
|
||||
Log.d(TAG, "Skipping auth endpoint: $requestPath")
|
||||
return null
|
||||
}
|
||||
|
||||
// If we already have a valid token from a previous retry on this connection,
|
||||
// use it directly without refreshing again
|
||||
val existingToken = secureStorageManager.getAccessToken()
|
||||
if (existingToken != null) {
|
||||
val currentAuthHeader = response.request.header(AUTH_HEADER)
|
||||
val currentToken = currentAuthHeader?.removePrefix(BEARER_PREFIX)
|
||||
if (currentToken != null && currentToken != existingToken) {
|
||||
// Token has changed since this request was made — retry with new token
|
||||
Log.d(TAG, "Token changed since request — retrying with new token")
|
||||
return response.request.newBuilder()
|
||||
.header(AUTH_HEADER, "$BEARER_PREFIX$existingToken")
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
return runBlocking(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
// Check if another thread already refreshed successfully
|
||||
val cached = lastRefreshResult
|
||||
if (cached != null) {
|
||||
if (cached is RefreshResult.Success) {
|
||||
return@withLock buildRetryRequest(response, cached.accessToken)
|
||||
} else {
|
||||
return@withLock null // Refresh already failed — don't retry
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the token refresh
|
||||
val success = tokenRefreshManager.refreshToken()
|
||||
val newToken = secureStorageManager.getAccessToken()
|
||||
|
||||
if (success && newToken != null) {
|
||||
Log.d(TAG, "Token refreshed successfully, retrying original request")
|
||||
lastRefreshResult = RefreshResult.Success(newToken)
|
||||
return@withLock buildRetryRequest(response, newToken)
|
||||
} else {
|
||||
Log.w(TAG, "Token refresh failed — returning null to propagate 401")
|
||||
lastRefreshResult = RefreshResult.Failure
|
||||
return@withLock null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a retry request with the new access token.
|
||||
* Preserves all original headers and body.
|
||||
*/
|
||||
private fun buildRetryRequest(originalResponse: Response, newToken: String): Request {
|
||||
return originalResponse.request.newBuilder()
|
||||
.header(AUTH_HEADER, "$BEARER_PREFIX$newToken")
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the cached refresh result.
|
||||
* Called when the user logs in again or manually triggers refresh.
|
||||
*/
|
||||
fun reset() {
|
||||
lastRefreshResult = null
|
||||
}
|
||||
|
||||
/** Internal sealed class for caching refresh results. */
|
||||
private sealed class RefreshResult {
|
||||
data class Success(val accessToken: String) : RefreshResult()
|
||||
data object Failure : RefreshResult()
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,31 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
/**
|
||||
* Manages silent token refresh with rotation.
|
||||
*
|
||||
* Handles:
|
||||
* - Automatic refresh before expiry (grace period)
|
||||
* - Token rotation (old refresh token is invalidated per rotation)
|
||||
* - Refresh failure handling (clears auth state, triggers re-authentication)
|
||||
* - Concurrent request deduplication (only one refresh at a time)
|
||||
* - Exponential backoff on refresh failures
|
||||
* ## Responsibilities
|
||||
*
|
||||
* Uses BuildConfig.API_BASE_URL for the API URL so it automatically
|
||||
* picks up debug/staging/production configuration.
|
||||
* - **Automatic refresh before expiry** — Parses JWT `exp` claim and refreshes
|
||||
* 5 minutes before expiry ([REFRESH_GRACE_PERIOD_MS]).
|
||||
* - **Token rotation** — Stores the new refresh token if the backend rotates it.
|
||||
* - **Concurrent deduplication** — Only one refresh runs at a time.
|
||||
* - **Exponential backoff** — On transient failures, retries with jitter.
|
||||
* - **Permanent failure** — After 3 failed attempts, clears auth state so the
|
||||
* UI layer can show the login screen.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```kotlin
|
||||
* // Start periodic refresh on app startup
|
||||
* tokenRefreshManager.startPeriodicRefresh()
|
||||
*
|
||||
* // Proactive refresh when app comes to foreground
|
||||
* tokenRefreshManager.refreshIfNeeded()
|
||||
* ```
|
||||
*
|
||||
* ## Thread Safety
|
||||
*
|
||||
* This class is designed to be called from both coroutine and blocking contexts.
|
||||
* The core [refreshToken] is a suspend function. For OkHttp's [Authenticator],
|
||||
* use [refreshTokenBlocking] which bridges via [runBlocking].
|
||||
*/
|
||||
class TokenRefreshManager(
|
||||
private val context: Context,
|
||||
@@ -46,69 +62,99 @@ class TokenRefreshManager(
|
||||
/** Refresh the token 5 minutes before expiry */
|
||||
private const val REFRESH_GRACE_PERIOD_MS = 5 * 60 * 1000L
|
||||
|
||||
/** Default token expiry (7 days in ms) */
|
||||
/** Default token expiry when JWT parsing fails (7 days) */
|
||||
private const val DEFAULT_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000L
|
||||
|
||||
/** Maximum backoff for refresh retries */
|
||||
/** Maximum exponential backoff for retries */
|
||||
private const val MAX_BACKOFF_MS = 60 * 1000L
|
||||
|
||||
/** Base backoff for exponential retry */
|
||||
/** Base backoff duration */
|
||||
private const val BASE_BACKOFF_MS = 1000L
|
||||
|
||||
/** Maximum consecutive refresh failures before clearing auth */
|
||||
private const val MAX_CONSECUTIVE_FAILURES = 3
|
||||
|
||||
/** Check interval for periodic refresh loop when no token is available */
|
||||
private const val NO_TOKEN_CHECK_INTERVAL_MS = 60_000L
|
||||
}
|
||||
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
/** Dedicated scope for periodic refresh and backoff retries. */
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* A standalone OkHttp client (no auth interceptor/authenticator) for the refresh
|
||||
* endpoint. We intentionally avoid the shared client to prevent infinite loops
|
||||
* (refreshing via a client that has [TokenRefreshAuthenticator] could trigger
|
||||
* another refresh on 401).
|
||||
*/
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/** Whether a refresh is currently in progress. */
|
||||
private val isRefreshing = AtomicBoolean(false)
|
||||
|
||||
/** Consecutive failure count for backoff calculation. */
|
||||
private val refreshAttempts = AtomicInteger(0)
|
||||
|
||||
/** Time of the last successful refresh. */
|
||||
private val lastRefreshTime = AtomicLong(0)
|
||||
|
||||
private val _refreshState = MutableStateFlow(RefreshState.IDLE)
|
||||
val refreshState: StateFlow<RefreshState> = _refreshState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Token refresh state exposed to the UI layer.
|
||||
*/
|
||||
enum class RefreshState {
|
||||
/** No refresh in progress. */
|
||||
IDLE,
|
||||
|
||||
/** Token is being refreshed. */
|
||||
REFRESHING,
|
||||
|
||||
/** Refresh failed permanently — user must re-authenticate. */
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the auth API URL based on BuildConfig.
|
||||
*/
|
||||
private fun getAuthUrl(): String {
|
||||
val url = BuildConfig.API_BASE_URL
|
||||
return if (url.endsWith("/")) "${url}api" else "$url/api"
|
||||
}
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Attempts to refresh the access token using the stored refresh token.
|
||||
* Only one refresh can happen at a time — concurrent calls are coalesced.
|
||||
* Refreshes the access token using the stored refresh token.
|
||||
*
|
||||
* @return true if the refresh succeeded, false otherwise
|
||||
* **Concurrent calls:** Only one refresh happens at a time. If another
|
||||
* refresh is already in progress, this method waits for it to complete
|
||||
* and returns its result.
|
||||
*
|
||||
* @return `true` if the token was refreshed successfully, `false` otherwise.
|
||||
*/
|
||||
suspend fun refreshToken(): Boolean {
|
||||
val refreshToken = secureStorageManager.getRefreshToken()
|
||||
if (refreshToken == null) {
|
||||
Log.w(TAG, "No refresh token available")
|
||||
Log.w(TAG, "No refresh token available — cannot refresh")
|
||||
_refreshState.value = RefreshState.FAILED
|
||||
return false
|
||||
}
|
||||
|
||||
// Deduplicate concurrent refresh attempts
|
||||
if (!isRefreshing.compareAndSet(false, true)) {
|
||||
// Another refresh is in progress — wait for it
|
||||
// Another refresh is in progress — wait for it with timeout
|
||||
Log.d(TAG, "Refresh already in progress — waiting for result")
|
||||
var waited = 0L
|
||||
while (isRefreshing.get() && waited < 10_000L) {
|
||||
delay(100)
|
||||
waited += 100
|
||||
}
|
||||
return secureStorageManager.getAccessToken() != null
|
||||
// Check if the concurrent refresh succeeded
|
||||
val hasToken = secureStorageManager.getAccessToken() != null
|
||||
Log.d(TAG, "Concurrent refresh finished — token present: $hasToken")
|
||||
return hasToken
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -129,70 +175,204 @@ class TokenRefreshManager(
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val json = JSONObject(responseBody)
|
||||
val newAccessToken = json.optString("accessToken", "")
|
||||
if (newAccessToken.isEmpty()) {
|
||||
Log.w(TAG, "Refresh response missing accessToken")
|
||||
handleRefreshFailure()
|
||||
return false
|
||||
}
|
||||
|
||||
// Token rotation: new refresh token may be provided
|
||||
val newRefreshToken = json.optString("refreshToken", null)
|
||||
.takeIf { it.isNotEmpty() && it != "null" }
|
||||
?: refreshToken // Keep existing if not rotated
|
||||
|
||||
secureStorageManager.saveTokens(newAccessToken, newRefreshToken)
|
||||
refreshAttempts.set(0)
|
||||
lastRefreshTime.set(System.currentTimeMillis())
|
||||
_refreshState.value = RefreshState.IDLE
|
||||
Log.d(TAG, "Token refreshed successfully")
|
||||
return true
|
||||
return handleSuccessfulRefresh(responseBody, refreshToken)
|
||||
} else {
|
||||
Log.w(TAG, "Token refresh failed: HTTP ${response.code}")
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
// Refresh token is invalid or expired — force re-authentication
|
||||
handleRefreshFailure()
|
||||
} else {
|
||||
// Server error — retry with backoff
|
||||
val attempts = refreshAttempts.incrementAndGet()
|
||||
if (attempts >= 3) {
|
||||
handleRefreshFailure()
|
||||
} else {
|
||||
val backoffMs = calculateBackoff(attempts)
|
||||
Log.d(TAG, "Scheduling refresh retry in ${backoffMs}ms (attempt $attempts)")
|
||||
scope.launch {
|
||||
delay(backoffMs)
|
||||
refreshToken()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return handleFailedRefresh(response.code, responseBody)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Token refresh exception", e)
|
||||
val attempts = refreshAttempts.incrementAndGet()
|
||||
if (attempts >= 3) {
|
||||
handleRefreshFailure()
|
||||
} else {
|
||||
val backoffMs = calculateBackoff(attempts)
|
||||
Log.d(TAG, "Scheduling refresh retry in ${backoffMs}ms (attempt $attempts)")
|
||||
scope.launch {
|
||||
delay(backoffMs)
|
||||
refreshToken()
|
||||
}
|
||||
}
|
||||
return false
|
||||
return handleRefreshException(e)
|
||||
} finally {
|
||||
isRefreshing.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when refresh fails permanently. Clears all auth state
|
||||
* so the UI can show the login screen.
|
||||
* Proactive token refresh.
|
||||
*
|
||||
* Checks if the current access token is close to expiry (within
|
||||
* [REFRESH_GRACE_PERIOD_MS]) and refreshes it silently if needed.
|
||||
*
|
||||
* Call this when:
|
||||
* - App comes to foreground
|
||||
* - User performs a sensitive action
|
||||
* - On a periodic timer
|
||||
*
|
||||
* @return `true` if token was refreshed or was still valid, `false` on failure.
|
||||
*/
|
||||
private fun handleRefreshFailure() {
|
||||
suspend fun refreshIfNeeded(): Boolean {
|
||||
val accessToken = secureStorageManager.getAccessToken() ?: return false
|
||||
val refreshToken = secureStorageManager.getRefreshToken() ?: return false
|
||||
|
||||
val expiryMs = estimateTokenExpiry(accessToken)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeUntilExpiry = expiryMs - now
|
||||
|
||||
if (timeUntilExpiry <= REFRESH_GRACE_PERIOD_MS) {
|
||||
Log.d(TAG, "Token expires in ${timeUntilExpiry / 1000}s — refreshing proactively")
|
||||
return refreshToken()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Token valid for ${timeUntilExpiry / 1000}s — no refresh needed")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current access token, or `null` if not authenticated.
|
||||
*/
|
||||
fun getAccessToken(): String? = secureStorageManager.getAccessToken()
|
||||
|
||||
/**
|
||||
* Returns the current refresh token, or `null` if not authenticated.
|
||||
*/
|
||||
fun getRefreshToken(): String? = secureStorageManager.getRefreshToken()
|
||||
|
||||
/**
|
||||
* Whether the user has valid auth tokens stored.
|
||||
*/
|
||||
fun isAuthenticated(): Boolean = secureStorageManager.hasAuthTokens()
|
||||
|
||||
/**
|
||||
* Starts periodic token refresh loop.
|
||||
*
|
||||
* Runs in a background coroutine and checks token expiry periodically.
|
||||
* Refreshes the token [REFRESH_GRACE_PERIOD_MS] before it expires.
|
||||
*
|
||||
* **Must be called once during app initialization.**
|
||||
*/
|
||||
fun startPeriodicRefresh() {
|
||||
scope.launch {
|
||||
Log.d(TAG, "Periodic refresh loop started")
|
||||
while (true) {
|
||||
val accessToken = secureStorageManager.getAccessToken()
|
||||
if (accessToken != null) {
|
||||
val expiryMs = estimateTokenExpiry(accessToken)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeUntilExpiry = expiryMs - now
|
||||
val timeUntilRefresh = (timeUntilExpiry - REFRESH_GRACE_PERIOD_MS)
|
||||
.coerceAtMost(DEFAULT_TOKEN_EXPIRY_MS)
|
||||
.coerceAtLeast(NO_TOKEN_CHECK_INTERVAL_MS)
|
||||
|
||||
Log.d(TAG, "Token expires in ${timeUntilExpiry / 1000}s, " +
|
||||
"next refresh check in ${timeUntilRefresh / 1000}s")
|
||||
delay(timeUntilRefresh)
|
||||
|
||||
// Don't refresh if already refreshing
|
||||
if (!isRefreshing.get()) {
|
||||
refreshToken()
|
||||
}
|
||||
} else {
|
||||
delay(NO_TOKEN_CHECK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the internal state after a successful login.
|
||||
* Clears failure count and state.
|
||||
*/
|
||||
fun resetState() {
|
||||
refreshAttempts.set(0)
|
||||
_refreshState.value = RefreshState.IDLE
|
||||
Log.d(TAG, "Refresh state reset")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private Helpers
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Builds the REST auth API URL from the injected [baseUrl] parameter.
|
||||
* Uses [baseUrl] (not BuildConfig) so it's testable via MockWebServer.
|
||||
* In production, [baseUrl] defaults to BuildConfig.API_BASE_URL.
|
||||
*/
|
||||
private fun getAuthUrl(): String {
|
||||
val normalized = baseUrl.removeSuffix("/api").removeSuffix("/")
|
||||
return "$normalized/api"
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a successful refresh response.
|
||||
* Supports token rotation (server may issue a new refresh token).
|
||||
*/
|
||||
private fun handleSuccessfulRefresh(responseBody: String, oldRefreshToken: String): Boolean {
|
||||
return try {
|
||||
val json = JSONObject(responseBody)
|
||||
val newAccessToken = json.optString("accessToken", "")
|
||||
if (newAccessToken.isEmpty()) {
|
||||
Log.w(TAG, "Refresh response missing accessToken — treating as failure")
|
||||
scheduleRetry()
|
||||
return false
|
||||
}
|
||||
|
||||
// Token rotation: server may provide a new refresh token
|
||||
val newRefreshToken = json.optString("refreshToken", null)
|
||||
.takeIf { it.isNotEmpty() && it != "null" }
|
||||
?: oldRefreshToken // Keep existing if not rotated
|
||||
|
||||
secureStorageManager.saveTokens(newAccessToken, newRefreshToken)
|
||||
refreshAttempts.set(0)
|
||||
lastRefreshTime.set(System.currentTimeMillis())
|
||||
_refreshState.value = RefreshState.IDLE
|
||||
Log.d(TAG, "Token refreshed successfully${if (newRefreshToken != oldRefreshToken) " (rotated)" else ""}")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse refresh response", e)
|
||||
scheduleRetry()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a non-2xx refresh response.
|
||||
* 401/403 → refresh token invalid, clear auth (permanent failure)
|
||||
* Other → transient failure, retry with backoff
|
||||
*/
|
||||
private fun handleFailedRefresh(httpCode: Int, responseBody: String): Boolean {
|
||||
if (httpCode == 401 || httpCode == 403) {
|
||||
Log.w(TAG, "Refresh token rejected (HTTP $httpCode) — permanent failure")
|
||||
handlePermanentFailure()
|
||||
return false
|
||||
}
|
||||
|
||||
Log.w(TAG, "Token refresh failed: HTTP $httpCode")
|
||||
return scheduleRetry()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an exception during the refresh HTTP call.
|
||||
*/
|
||||
private fun handleRefreshException(e: Exception): Boolean {
|
||||
Log.e(TAG, "Network error during token refresh", e)
|
||||
return scheduleRetry()
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a retry with exponential backoff, or fails permanently
|
||||
* after [MAX_CONSECUTIVE_FAILURES] attempts.
|
||||
*/
|
||||
private fun scheduleRetry(): Boolean {
|
||||
val attempts = refreshAttempts.incrementAndGet()
|
||||
if (attempts >= MAX_CONSECUTIVE_FAILURES) {
|
||||
Log.w(TAG, "Token refresh failed $attempts times — permanent failure")
|
||||
handlePermanentFailure()
|
||||
return false
|
||||
}
|
||||
|
||||
val backoffMs = calculateBackoff(attempts)
|
||||
Log.d(TAG, "Scheduling refresh retry in ${backoffMs}ms (attempt $attempts/$MAX_CONSECUTIVE_FAILURES)")
|
||||
scope.launch {
|
||||
delay(backoffMs)
|
||||
refreshToken()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanent failure — clears all auth state so the UI can
|
||||
* redirect to the login screen.
|
||||
*/
|
||||
private fun handlePermanentFailure() {
|
||||
Log.w(TAG, "Token refresh failed permanently — clearing auth state")
|
||||
_refreshState.value = RefreshState.FAILED
|
||||
secureStorageManager.clearAllAuthData()
|
||||
@@ -208,33 +388,9 @@ class TokenRefreshManager(
|
||||
return (exponential + jitter).coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules periodic token refresh before expiry.
|
||||
* Should be called once at app startup.
|
||||
*/
|
||||
fun startPeriodicRefresh() {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val accessToken = secureStorageManager.getAccessToken()
|
||||
if (accessToken != null) {
|
||||
val expiryMs = estimateTokenExpiry(accessToken)
|
||||
val timeUntilRefresh = (expiryMs - REFRESH_GRACE_PERIOD_MS)
|
||||
.coerceAtMost(DEFAULT_TOKEN_EXPIRY_MS - REFRESH_GRACE_PERIOD_MS)
|
||||
.coerceAtLeast(60_000L) // Don't check more than once per minute
|
||||
|
||||
Log.d(TAG, "Scheduled refresh in ${timeUntilRefresh / 1000}s")
|
||||
delay(timeUntilRefresh)
|
||||
refreshToken()
|
||||
} else {
|
||||
delay(60_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token expiry by decoding the JWT payload (without verification).
|
||||
* Falls back to default expiry if parsing fails.
|
||||
* Falls back to [DEFAULT_TOKEN_EXPIRY_MS] if parsing fails.
|
||||
*/
|
||||
private fun estimateTokenExpiry(token: String): Long {
|
||||
return try {
|
||||
|
||||
@@ -46,6 +46,7 @@ class AuthRepositoryImpl(
|
||||
context: Context,
|
||||
private val secureStorageManager: SecureStorageManager,
|
||||
private val baseUrl: String = "${BuildConfig.API_BASE_URL}api",
|
||||
private val tokenRefreshManager: TokenRefreshManager? = null,
|
||||
) : AuthRepository {
|
||||
|
||||
companion object {
|
||||
@@ -59,14 +60,15 @@ class AuthRepositoryImpl(
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val tokenRefreshManager = TokenRefreshManager(context, secureStorageManager, baseUrl)
|
||||
private val sharedRefreshManager = tokenRefreshManager
|
||||
?: TokenRefreshManager(context, secureStorageManager, baseUrl)
|
||||
|
||||
/**
|
||||
* Normalizes the base URL to include a trailing slash if needed.
|
||||
* Returns the REST auth API URL from the injected [baseUrl] parameter.
|
||||
*/
|
||||
private fun getAuthUrl(): String {
|
||||
val url = BuildConfig.API_BASE_URL
|
||||
return if (url.endsWith("/")) "${url}api" else "$url/api"
|
||||
val normalized = baseUrl.removeSuffix("/api").removeSuffix("/")
|
||||
return "$normalized/api"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,7 +246,7 @@ class AuthRepositoryImpl(
|
||||
}.mapError()
|
||||
|
||||
override suspend fun refreshAccessToken(): Boolean {
|
||||
return tokenRefreshManager.refreshToken()
|
||||
return sharedRefreshManager.refreshToken()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
package com.kordant.android.data.sync
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Resolves sync conflicts between local pending requests and the server state.
|
||||
*
|
||||
* Each [EntityType] has a [ConflictStrategy] defined in [ConflictStrategyMap].
|
||||
* The resolver applies the strategy to determine the appropriate action:
|
||||
*
|
||||
* - [ConflictStrategy.SERVER_WINS]: Local change is discarded, UI is refreshed from server.
|
||||
* - [ConflictStrategy.LAST_WRITE_WINS]: Compares versions; the newer change wins.
|
||||
* - [ConflictStrategy.MERGE]: Merges local additions with server state.
|
||||
* - [ConflictStrategy.MANUAL]: Returns a [ConflictResolution] with [ConflictAction.MANUAL]
|
||||
* for the UI to present to the user.
|
||||
*
|
||||
* ## Version Detection
|
||||
*
|
||||
* Versions are extracted from request bodies and server responses:
|
||||
* - For tRPC endpoints, version might be in "version", "updatedAt", or "_version" fields
|
||||
* - For REST endpoints, version comes from ETag or Last-Modified headers
|
||||
* - If no version is found, LAST_WRITE_WINS defaults to local (we have newer intent)
|
||||
*/
|
||||
class ConflictResolver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConflictResolver"
|
||||
|
||||
/**
|
||||
* JSON field names to check for version information.
|
||||
* Ordered by likelihood of containing a meaningful version.
|
||||
*/
|
||||
private val VERSION_FIELDS = listOf(
|
||||
"_version", "__v", "version", "updatedAt",
|
||||
"updated_at", "modifiedAt", "modified_at",
|
||||
"etag", "revision",
|
||||
)
|
||||
|
||||
/**
|
||||
* Timestamp fields used as fallback version indicators.
|
||||
*/
|
||||
private val TIMESTAMP_FIELDS = listOf(
|
||||
"timestamp", "createdAt", "created_at",
|
||||
)
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Resolves a conflict between a local pending request and server state.
|
||||
*
|
||||
* @param conflict The detected conflict with strategy and versions.
|
||||
* @param serverResponseBody The server's response body (if available) for merge strategies.
|
||||
* @return A [ConflictResolution] with the action to take.
|
||||
*/
|
||||
fun resolve(
|
||||
conflict: SyncConflict,
|
||||
serverResponseBody: String? = null,
|
||||
): ConflictResolution {
|
||||
Log.d(TAG, "Resolving conflict for ${conflict.entityType} using ${conflict.strategy}")
|
||||
|
||||
return when (conflict.strategy) {
|
||||
ConflictStrategy.SERVER_WINS -> resolveServerWins(conflict, serverResponseBody)
|
||||
ConflictStrategy.LAST_WRITE_WINS -> resolveLastWriteWins(conflict, serverResponseBody)
|
||||
ConflictStrategy.MERGE -> resolveMerge(conflict, serverResponseBody)
|
||||
ConflictStrategy.MANUAL -> resolveManual(conflict)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects a conflict by comparing version information.
|
||||
*
|
||||
* @param pendingRequest The local pending request.
|
||||
* @param serverResponseCode The HTTP response code from the server.
|
||||
* @param serverResponseBody The server response body (for version extraction).
|
||||
* @param serverEtag The ETag header from the response.
|
||||
* @return A [SyncConflict] if a conflict is detected, null otherwise.
|
||||
*/
|
||||
fun detectConflict(
|
||||
pendingRequest: PendingRequest,
|
||||
serverResponseCode: Int,
|
||||
serverResponseBody: String? = null,
|
||||
serverEtag: String? = null,
|
||||
): SyncConflict? {
|
||||
if (serverResponseCode != 409) return null
|
||||
|
||||
val strategy = ConflictStrategyMap.forEntityType(pendingRequest.entityType)
|
||||
val serverVersion = extractVersion(serverResponseBody)
|
||||
?: serverEtag
|
||||
?: (serverResponseBody?.let { extractTimestamp(it) })
|
||||
|
||||
return SyncConflict(
|
||||
pendingRequest = pendingRequest,
|
||||
entityType = pendingRequest.entityType,
|
||||
localVersion = pendingRequest.version,
|
||||
serverVersion = serverVersion,
|
||||
strategy = strategy,
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Strategy Implementations
|
||||
// ============================================================
|
||||
|
||||
private fun resolveServerWins(
|
||||
conflict: SyncConflict,
|
||||
serverResponseBody: String?,
|
||||
): ConflictResolution {
|
||||
// Server wins — discard local change, server is source of truth
|
||||
if (conflict.pendingRequest.mutationType == MutationType.DELETE) {
|
||||
// Delete operations: server might say "already deleted" which is fine
|
||||
return ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_SERVER,
|
||||
message = "Server already reflects this deletion",
|
||||
localVersion = conflict.localVersion,
|
||||
serverVersion = conflict.serverVersion,
|
||||
)
|
||||
}
|
||||
|
||||
return ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_SERVER,
|
||||
message = "Server state is authoritative for ${conflict.entityType}. " +
|
||||
"Local change discarded.",
|
||||
localVersion = conflict.localVersion,
|
||||
serverVersion = conflict.serverVersion,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveLastWriteWins(
|
||||
conflict: SyncConflict,
|
||||
serverResponseBody: String?,
|
||||
): ConflictResolution {
|
||||
val local = conflict.localVersion
|
||||
val server = conflict.serverVersion
|
||||
|
||||
if (local == null && server == null) {
|
||||
// No version info — local wins (we made the most recent change)
|
||||
return ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_LOCAL,
|
||||
message = "No version info available; using local changes",
|
||||
)
|
||||
}
|
||||
|
||||
if (local == null) {
|
||||
// Server has version, local doesn't — server wins
|
||||
return ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_SERVER,
|
||||
message = "Local change has no version info; using server state",
|
||||
serverVersion = server,
|
||||
)
|
||||
}
|
||||
|
||||
if (server == null) {
|
||||
// Local has version, server doesn't — local wins
|
||||
return ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_LOCAL,
|
||||
message = "Server has no version info; using local changes",
|
||||
localVersion = local,
|
||||
)
|
||||
}
|
||||
|
||||
// Compare versions — try numeric comparison first, then string comparison
|
||||
val localNum = local.toLongOrNull()
|
||||
val serverNum = server.toLongOrNull()
|
||||
|
||||
return if (localNum != null && serverNum != null) {
|
||||
if (localNum >= serverNum) {
|
||||
ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_LOCAL,
|
||||
message = "Local version ($localNum) >= server version ($serverNum); using local",
|
||||
localVersion = local,
|
||||
serverVersion = server,
|
||||
)
|
||||
} else {
|
||||
ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_SERVER,
|
||||
message = "Server version ($serverNum) > local version ($localNum); using server",
|
||||
localVersion = local,
|
||||
serverVersion = server,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// String comparison (ISO dates, UUIDs, etc.)
|
||||
if (local >= server) {
|
||||
ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_LOCAL,
|
||||
message = "Local version >= server version; using local",
|
||||
localVersion = local,
|
||||
serverVersion = server,
|
||||
)
|
||||
} else {
|
||||
ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_SERVER,
|
||||
message = "Server version > local version; using server",
|
||||
localVersion = local,
|
||||
serverVersion = server,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveMerge(
|
||||
conflict: SyncConflict,
|
||||
serverResponseBody: String?,
|
||||
): ConflictResolution {
|
||||
return when (conflict.pendingRequest.mutationType) {
|
||||
MutationType.ADD -> {
|
||||
// ADD operations are generally safe to retry — server handles duplicates
|
||||
// via idempotency keys or dedup on the backend.
|
||||
tryMergeAdd(conflict, serverResponseBody)
|
||||
}
|
||||
MutationType.UPDATE -> {
|
||||
// For UPDATE, try to merge fields from both sides
|
||||
tryMergeUpdate(conflict, serverResponseBody)
|
||||
}
|
||||
MutationType.DELETE -> {
|
||||
// For DELETE, if server returned 409, someone else modified it.
|
||||
// Server-wins for deletions: the item may have been updated, but
|
||||
// we still want to delete it. Re-send the delete.
|
||||
ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_LOCAL,
|
||||
message = "Delete conflict — retrying deletion with current version",
|
||||
localVersion = conflict.localVersion,
|
||||
serverVersion = conflict.serverVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveManual(
|
||||
conflict: SyncConflict,
|
||||
): ConflictResolution {
|
||||
return ConflictResolution(
|
||||
resolved = false,
|
||||
action = ConflictAction.MANUAL,
|
||||
message = "Manual resolution required for ${conflict.entityType} conflict. " +
|
||||
"Please choose which version to keep.",
|
||||
localVersion = conflict.localVersion,
|
||||
serverVersion = conflict.serverVersion,
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Merge Helpers
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Attempts to merge an ADD mutation.
|
||||
* ADDs are typically idempotent — resend with the original body.
|
||||
*/
|
||||
private fun tryMergeAdd(
|
||||
conflict: SyncConflict,
|
||||
serverResponseBody: String?,
|
||||
): ConflictResolution {
|
||||
val serverId = serverResponseBody?.let { extractField(it, "id") }
|
||||
|
||||
return if (serverId != null) {
|
||||
// Server already has the item, but we can add the local fields
|
||||
val mergedBody = mergeBodies(
|
||||
localBody = conflict.pendingRequest.body,
|
||||
serverBody = serverResponseBody,
|
||||
)
|
||||
ConflictResolution(
|
||||
resolved = true,
|
||||
action = if (mergedBody != null) ConflictAction.MERGED else ConflictAction.USE_SERVER,
|
||||
mergedBody = mergedBody,
|
||||
message = if (mergedBody != null) "Fields merged with server version"
|
||||
else "Item already exists on server",
|
||||
localVersion = conflict.localVersion,
|
||||
serverVersion = conflict.serverVersion,
|
||||
)
|
||||
} else {
|
||||
// Server doesn't have it — resend
|
||||
ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_LOCAL,
|
||||
message = "Re-attempting add operation",
|
||||
localVersion = conflict.localVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to merge an UPDATE mutation by combining fields.
|
||||
*/
|
||||
private fun tryMergeUpdate(
|
||||
conflict: SyncConflict,
|
||||
serverResponseBody: String?,
|
||||
): ConflictResolution {
|
||||
if (serverResponseBody == null) {
|
||||
return ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_LOCAL,
|
||||
message = "No server response body; using local changes",
|
||||
localVersion = conflict.localVersion,
|
||||
)
|
||||
}
|
||||
|
||||
val mergedBody = mergeBodies(
|
||||
localBody = conflict.pendingRequest.body,
|
||||
serverBody = serverResponseBody,
|
||||
)
|
||||
|
||||
if (mergedBody != null) {
|
||||
return ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.MERGED,
|
||||
mergedBody = mergedBody,
|
||||
message = "Fields merged with server version",
|
||||
localVersion = conflict.localVersion,
|
||||
serverVersion = conflict.serverVersion,
|
||||
)
|
||||
}
|
||||
|
||||
// If merge produces no changes, server wins
|
||||
return ConflictResolution(
|
||||
resolved = true,
|
||||
action = ConflictAction.USE_SERVER,
|
||||
message = "Local changes already reflected on server",
|
||||
localVersion = conflict.localVersion,
|
||||
serverVersion = conflict.serverVersion,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two JSON bodies by taking the union of fields.
|
||||
* Local fields take precedence for non-version, non-timestamp fields.
|
||||
* Server fields fill in any gaps.
|
||||
*/
|
||||
private fun mergeBodies(localBody: String?, serverBody: String?): String? {
|
||||
if (localBody == null) return serverBody
|
||||
if (serverBody == null) return localBody
|
||||
|
||||
return try {
|
||||
val localJson = json.parseToJsonElement(localBody).jsonObject
|
||||
val serverJson = json.parseToJsonElement(serverBody).jsonObject
|
||||
|
||||
val merged = mergeJsonObjects(localJson, serverJson)
|
||||
json.encodeToString(kotlinx.serialization.serializer(), merged)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to merge JSON bodies", e)
|
||||
localBody // Fall back to local body if merge fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively merges two JSON objects. Local fields take precedence
|
||||
* except for server-only fields like "id", "createdAt", "status"
|
||||
* which should always come from the server.
|
||||
*/
|
||||
private fun mergeJsonObjects(
|
||||
local: JsonObject,
|
||||
server: JsonObject,
|
||||
): JsonObject {
|
||||
// Fields that should always come from the server
|
||||
val serverOnlyFields = setOf("id", "_id", "createdAt", "created_at")
|
||||
|
||||
val merged = mutableMapOf<String, JsonElement>()
|
||||
// Add all server fields
|
||||
merged.putAll(server)
|
||||
// Override with local fields, except server-only ones
|
||||
for ((key, value) in local) {
|
||||
if (key !in serverOnlyFields) {
|
||||
// For nested objects, recurse
|
||||
if (value is JsonObject && server[key] is JsonObject) {
|
||||
merged[key] = mergeJsonObjects(value, server[key]!!.jsonObject)
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return JsonObject(merged)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Version Extraction
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Extracts a version identifier from a JSON response body.
|
||||
* Checks known version fields in order of likelihood.
|
||||
*/
|
||||
fun extractVersion(body: String?): String? {
|
||||
if (body == null) return null
|
||||
return try {
|
||||
val jsonElement = json.parseToJsonElement(body)
|
||||
val obj = when {
|
||||
jsonElement is JsonObject -> jsonElement
|
||||
// Handle tRPC wrapper: { "result": { "data": { ... } } }
|
||||
jsonElement.jsonObject.containsKey("result") -> {
|
||||
jsonElement.jsonObject["result"]?.jsonObject?.get("data")?.jsonObject
|
||||
?: jsonElement.jsonObject["result"]?.jsonObject
|
||||
}
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
for (field in VERSION_FIELDS) {
|
||||
obj[field]?.jsonPrimitive?.content?.let { return it }
|
||||
}
|
||||
null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a timestamp from a JSON response body as a fallback version.
|
||||
*/
|
||||
private fun extractTimestamp(body: String): String? {
|
||||
return try {
|
||||
val jsonElement = json.parseToJsonElement(body)
|
||||
val obj = when {
|
||||
jsonElement is JsonObject -> jsonElement
|
||||
jsonElement.jsonObject.containsKey("result") -> {
|
||||
jsonElement.jsonObject["result"]?.jsonObject?.get("data")?.jsonObject
|
||||
?: jsonElement.jsonObject["result"]?.jsonObject
|
||||
}
|
||||
else -> return null
|
||||
} ?: return null
|
||||
|
||||
for (field in TIMESTAMP_FIELDS) {
|
||||
obj[field]?.jsonPrimitive?.content?.let { return it }
|
||||
}
|
||||
null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a specific field from a JSON body.
|
||||
*/
|
||||
private fun extractField(body: String?, field: String): String? {
|
||||
if (body == null) return null
|
||||
return try {
|
||||
val jsonElement = json.parseToJsonElement(body)
|
||||
val obj = when {
|
||||
jsonElement is JsonObject -> jsonElement
|
||||
jsonElement.jsonObject.containsKey("result") -> {
|
||||
jsonElement.jsonObject["result"]?.jsonObject?.get("data")?.jsonObject
|
||||
}
|
||||
else -> null
|
||||
} ?: return null
|
||||
obj[field]?.jsonPrimitive?.content
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.kordant.android.data.sync
|
||||
|
||||
/**
|
||||
* Defines the conflict resolution strategy for a specific entity type.
|
||||
*/
|
||||
enum class ConflictStrategy {
|
||||
/**
|
||||
* Server state always wins. Local changes are discarded on conflict.
|
||||
* Used for: alerts, exposures, spam rules (source of truth is server).
|
||||
*/
|
||||
SERVER_WINS,
|
||||
|
||||
/**
|
||||
* The last write (most recent modification) wins.
|
||||
* Used for: user preferences, settings (rarely conflicting).
|
||||
*/
|
||||
LAST_WRITE_WINS,
|
||||
|
||||
/**
|
||||
* Merge strategy — attempts to combine local and server changes.
|
||||
* Used for: watchlist items (additions from both sides should merge).
|
||||
*/
|
||||
MERGE,
|
||||
|
||||
/**
|
||||
* Manual resolution required — shows UI for user to decide.
|
||||
* Used for: user profile edits, subscription changes.
|
||||
*/
|
||||
MANUAL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps entity types to their conflict resolution strategies.
|
||||
*/
|
||||
object ConflictStrategyMap {
|
||||
private val strategies = mapOf(
|
||||
// Server-wins data (source of truth is always the server)
|
||||
EntityType.ALERT to ConflictStrategy.SERVER_WINS,
|
||||
EntityType.EXPOSURE to ConflictStrategy.SERVER_WINS,
|
||||
EntityType.SPAM_RULE to ConflictStrategy.SERVER_WINS,
|
||||
EntityType.VOICE_ENROLLMENT to ConflictStrategy.SERVER_WINS,
|
||||
|
||||
// Last-write-wins for preferences and settings
|
||||
EntityType.SETTINGS to ConflictStrategy.LAST_WRITE_WINS,
|
||||
EntityType.USER_PROFILE to ConflictStrategy.LAST_WRITE_WINS,
|
||||
EntityType.SUBSCRIPTION to ConflictStrategy.LAST_WRITE_WINS,
|
||||
|
||||
// Merge for entities that can accumulate additions from both sides
|
||||
EntityType.WATCHLIST_ITEM to ConflictStrategy.MERGE,
|
||||
EntityType.BROKER_LISTING to ConflictStrategy.MERGE,
|
||||
EntityType.REMOVAL_REQUEST to ConflictStrategy.MERGE,
|
||||
|
||||
// Default fallback
|
||||
EntityType.UNKNOWN to ConflictStrategy.SERVER_WINS,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the conflict resolution strategy for the given entity type.
|
||||
*/
|
||||
fun forEntityType(entityType: EntityType): ConflictStrategy {
|
||||
return strategies[entityType] ?: ConflictStrategy.SERVER_WINS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the result of a conflict resolution.
|
||||
*
|
||||
* @param resolved Whether the conflict was successfully resolved.
|
||||
* @param action The action to take:
|
||||
* - USE_SERVER: Use server version, discard local
|
||||
* - USE_LOCAL: Use local version, send to server
|
||||
* - MERGED: Both versions were merged into a combined result
|
||||
* - MANUAL: User intervention required
|
||||
* @param mergedBody If MERGED, the combined request body to send.
|
||||
* @param message Human-readable resolution explanation.
|
||||
* @param localVersion The local version string/timestamp, for tracking.
|
||||
* @param serverVersion The server version string/timestamp, for tracking.
|
||||
*/
|
||||
data class ConflictResolution(
|
||||
val resolved: Boolean,
|
||||
val action: ConflictAction,
|
||||
val mergedBody: String? = null,
|
||||
val message: String = "",
|
||||
val localVersion: String? = null,
|
||||
val serverVersion: String? = null,
|
||||
)
|
||||
|
||||
enum class ConflictAction {
|
||||
USE_SERVER,
|
||||
USE_LOCAL,
|
||||
MERGED,
|
||||
MANUAL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a detected conflict between a local pending request and
|
||||
* the server's current state.
|
||||
*
|
||||
* @property pendingRequest The local queued request that triggered the conflict.
|
||||
* @property entityType The type of entity involved.
|
||||
* @property localVersion The version/timestamp of the local change.
|
||||
* @property serverVersion The version/timestamp of the server's current state.
|
||||
* @property strategy The strategy to use for resolution.
|
||||
*/
|
||||
data class SyncConflict(
|
||||
val pendingRequest: PendingRequest,
|
||||
val entityType: EntityType,
|
||||
val localVersion: String?,
|
||||
val serverVersion: String?,
|
||||
val strategy: ConflictStrategy,
|
||||
)
|
||||
@@ -4,21 +4,30 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.kordant.android.data.local.SecureStorageManager
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
/**
|
||||
* Background worker that processes the offline request queue.
|
||||
* Legacy offline request processor.
|
||||
*
|
||||
* Runs periodically via WorkManager (every 15 minutes) or on-demand
|
||||
* when network connectivity is restored.
|
||||
* **Deprecated**: Use [OfflineQueueWorker] instead, which provides:
|
||||
* - Dependency-ordered processing
|
||||
* - Request deduplication
|
||||
* - Conflict resolution per entity type
|
||||
* - Exponential per-request backoff
|
||||
* - Partial sync handling
|
||||
* - Sync state reporting to [SyncManager]
|
||||
*
|
||||
* Uses server-wins conflict resolution: if the server returns a conflict,
|
||||
* the local request is discarded and the server's version is used.
|
||||
* This worker is retained for backward compatibility with existing
|
||||
* WorkManager schedules. New schedules should use [OfflineQueueWorker].
|
||||
* Both workers share the same [PendingRequestQueue] storage.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Use OfflineQueueWorker instead for enhanced conflict resolution and dedup",
|
||||
replaceWith = ReplaceWith("OfflineQueueWorker"),
|
||||
)
|
||||
class OfflineWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
@@ -30,9 +39,9 @@ class OfflineWorker(
|
||||
}
|
||||
|
||||
private val queue = PendingRequestQueue(applicationContext)
|
||||
private val secureStorage = SecureStorageManager(applicationContext)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
Log.w(TAG, "Legacy OfflineWorker invoked — delegating to OfflineQueueWorker logic")
|
||||
val pendingRequests = queue.getAll()
|
||||
if (pendingRequests.isEmpty()) {
|
||||
Log.d(TAG, "No pending requests to sync")
|
||||
@@ -59,13 +68,6 @@ class OfflineWorker(
|
||||
val httpRequest = Request.Builder()
|
||||
.url("$apiBaseUrl/${request.endpoint}")
|
||||
.method(request.method, body)
|
||||
.apply {
|
||||
// Attach auth token if available
|
||||
val token = secureStorage.getAccessToken()
|
||||
if (token != null) {
|
||||
header("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
val response = client.newCall(httpRequest).execute()
|
||||
@@ -76,22 +78,22 @@ class OfflineWorker(
|
||||
queue.deleteById(request.id)
|
||||
}
|
||||
response.code == 401 -> {
|
||||
// Token expired — skip this request, it will be retried with new token
|
||||
Log.w(TAG, "Request ${request.id} unauthorized, will retry with new token")
|
||||
// Token expired — will retry with new token
|
||||
Log.w(TAG, "Request ${request.id} unauthorized, will retry")
|
||||
queue.incrementRetry(request.id)
|
||||
}
|
||||
response.code == 409 -> {
|
||||
// Conflict — server-wins: discard local request
|
||||
// Conflict — server-wins
|
||||
Log.w(TAG, "Request ${request.id} conflict, server-wins: discarding")
|
||||
queue.deleteById(request.id)
|
||||
}
|
||||
response.code == 422 || response.code == 400 -> {
|
||||
// Validation error — discard (data is no longer valid)
|
||||
// Validation error — discard
|
||||
Log.w(TAG, "Request ${request.id} validation error, discarding")
|
||||
queue.deleteById(request.id)
|
||||
}
|
||||
response.code in 500..599 -> {
|
||||
// Server error — retry later
|
||||
// Server error — retry
|
||||
Log.w(TAG, "Request ${request.id} server error ${response.code}")
|
||||
queue.incrementRetry(request.id)
|
||||
return Result.retry()
|
||||
|
||||
@@ -1,23 +1,79 @@
|
||||
package com.kordant.android.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.FileLock
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The type of mutation that this pending request represents.
|
||||
* Used for deduplication and conflict resolution.
|
||||
*/
|
||||
@Serializable
|
||||
enum class MutationType {
|
||||
ADD,
|
||||
UPDATE,
|
||||
DELETE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity type that this request targets.
|
||||
* Used for group-level conflict resolution and UI badge display.
|
||||
*/
|
||||
@Serializable
|
||||
enum class EntityType {
|
||||
WATCHLIST_ITEM,
|
||||
EXPOSURE,
|
||||
ALERT,
|
||||
SETTINGS,
|
||||
SUBSCRIPTION,
|
||||
SPAM_RULE,
|
||||
VOICE_ENROLLMENT,
|
||||
BROKER_LISTING,
|
||||
REMOVAL_REQUEST,
|
||||
USER_PROFILE,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending API request that failed due to network unavailability
|
||||
* and is queued for later retry.
|
||||
*
|
||||
* Enhanced with:
|
||||
* - [mutationType] — ADD, UPDATE, or DELETE for deduplication and conflict handling
|
||||
* - [entityType] — which domain entity this request targets
|
||||
* - [entityId] — the specific entity ID (for dedup: same entityId + mutationType replaces)
|
||||
* - [dedupKey] — custom deduplication key (if different from entityType+entityId), defaults to auto-generated
|
||||
* - [dependencyIds] — IDs of requests that must complete before this one
|
||||
* - [version] — entity version/timestamp for conflict detection
|
||||
* - [priority] — higher priority = processed first in queue
|
||||
* - [createdAt] — epoch millis of original creation
|
||||
* - [lastAttemptAt] — epoch millis of last retry attempt
|
||||
* - [exponentialBaseMs] — base delay for exponential backoff calculation
|
||||
*
|
||||
* @property id Unique identifier (auto-incremented).
|
||||
* @property endpoint API endpoint path (e.g., "api/trpc/darkwatch.addWatchlistItem").
|
||||
* @property method HTTP method (default: "POST").
|
||||
* @property body JSON request body as a string.
|
||||
* @property timestamp When the request was originally created.
|
||||
* @property mutationType The type of mutation being performed.
|
||||
* @property entityType The domain entity this request affects.
|
||||
* @property entityId The ID of the specific entity (for deduplication).
|
||||
* @property dedupKey Custom deduplication key. Auto-generated from entityType+entityId+mutationType if null.
|
||||
* @property dependencyIds List of request IDs that must complete before this one.
|
||||
* @property version Entity version number or timestamp for conflict detection.
|
||||
* @property priority Processing priority (higher = processed first).
|
||||
* @property timestamp When the request was originally created (epoch millis).
|
||||
* @property lastAttemptAt When the last retry attempt was made.
|
||||
* @property retryCount Number of failed retry attempts so far.
|
||||
* @property maxRetries Maximum retries before the request is dropped (default: 5).
|
||||
* @property maxRetries Maximum retries before the request is dropped.
|
||||
* @property lastError Human-readable error from the last failed attempt.
|
||||
* @property exponentialBaseMs Base delay milliseconds for exponential backoff.
|
||||
*/
|
||||
@Serializable
|
||||
data class PendingRequest(
|
||||
@@ -25,99 +81,249 @@ data class PendingRequest(
|
||||
val endpoint: String,
|
||||
val method: String = "POST",
|
||||
val body: String,
|
||||
val mutationType: MutationType = MutationType.ADD,
|
||||
val entityType: EntityType = EntityType.UNKNOWN,
|
||||
val entityId: String? = null,
|
||||
val dedupKey: String? = null,
|
||||
val dependencyIds: List<Long> = emptyList(),
|
||||
val version: String? = null,
|
||||
val priority: Int = 0,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val lastAttemptAt: Long = 0L,
|
||||
val retryCount: Int = 0,
|
||||
val maxRetries: Int = 5,
|
||||
val maxRetries: Int = 10,
|
||||
val lastError: String? = null,
|
||||
)
|
||||
val exponentialBaseMs: Long = 30_000L, // 30 seconds base
|
||||
) {
|
||||
/**
|
||||
* Returns the effective deduplication key.
|
||||
* Prefers custom dedupKey, otherwise auto-generates from entity context.
|
||||
*/
|
||||
fun effectiveDedupKey(): String {
|
||||
return dedupKey ?: if (entityId != null && entityType != EntityType.UNKNOWN) {
|
||||
"${entityType.name}_${entityId}_${mutationType.name}"
|
||||
} else {
|
||||
// Fall back to a key based on endpoint and body for non-entity requests
|
||||
"${endpoint}_${body.hashCode()}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the backoff delay for the next retry attempt.
|
||||
* Uses exponential backoff: base * 2^retryCount, capped at 1 hour.
|
||||
*/
|
||||
fun nextBackoffDelayMs(): Long {
|
||||
val exponential = exponentialBaseMs * (1L shl retryCount.coerceAtMost(7))
|
||||
return exponential.coerceAtMost(3_600_000L) // Max 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists pending API requests to a JSON file in the app cache directory.
|
||||
* Persists pending API requests to a JSON file in the app's internal storage
|
||||
* with atomic writes and file-level locking for thread safety.
|
||||
*
|
||||
* The queue is used by [OfflineWorker] and [OfflineQueueWorker] to retry
|
||||
* failed requests when network connectivity is restored.
|
||||
* Features:
|
||||
* - Atomic write: writes to a .tmp file, then renames atomically
|
||||
* - File locking: prevents concurrent read/write corruption
|
||||
* - Deduplication: same dedupKey replaces existing entry
|
||||
* - Dependency ordering: requests with dependencies sorted after their dependents
|
||||
* - Versioned format: supports future migration via format version field
|
||||
* - Corruption recovery: corrupt files are backed up, not silently deleted
|
||||
*
|
||||
* Thread safety: This class is NOT thread-safe. Access should be serialized
|
||||
* via WorkManager (only one worker runs at a time per unique work name).
|
||||
* Thread safety: File-level locking via [FileChannel.lock] ensures safe
|
||||
* concurrent access from WorkManager (which guarantees serial execution
|
||||
* per unique work name).
|
||||
*/
|
||||
class PendingRequestQueue(private val context: Context) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val file: File get() = File(context.cacheDir, "pending_requests.json")
|
||||
/**
|
||||
* Format version for forward compatibility.
|
||||
* Increment when the [PendingRequest] schema changes.
|
||||
*/
|
||||
private val FORMAT_VERSION = 2
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PendingRequestQueue"
|
||||
private const val FILE_NAME = "pending_requests_v2.json"
|
||||
private const val TMP_FILE_NAME = "pending_requests_v2.tmp"
|
||||
private const val BACKUP_FILE_NAME = "pending_requests_v2.bak"
|
||||
}
|
||||
|
||||
private val file: File get() = File(context.filesDir, FILE_NAME)
|
||||
private val tmpFile: File get() = File(context.filesDir, TMP_FILE_NAME)
|
||||
private val backupFile: File get() = File(context.filesDir, BACKUP_FILE_NAME)
|
||||
|
||||
/**
|
||||
* Returns all pending requests from the persisted queue.
|
||||
* If the file is corrupt, it is deleted and an empty list is returned.
|
||||
* Wrapper for serialized data with format version for migration support.
|
||||
*/
|
||||
@Serializable
|
||||
private data class QueueData(
|
||||
val formatVersion: Int = 2, // FORMAT_VERSION — inline to avoid companion access issue
|
||||
val requests: List<PendingRequest> = emptyList(),
|
||||
val nextId: Long = 1L,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reads and returns all pending requests from the persisted queue.
|
||||
* Uses file locking and atomic reads. Handles corruption gracefully.
|
||||
*/
|
||||
fun getAll(): List<PendingRequest> {
|
||||
if (!file.exists()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<PendingRequest>>(file.readText())
|
||||
val data = readWithLock()
|
||||
data.requests
|
||||
} catch (e: Exception) {
|
||||
// File corruption — delete and start fresh
|
||||
file.delete()
|
||||
Log.e(TAG, "Failed to read queue, attempting recovery", e)
|
||||
recoverFromCorruption()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAll(requests: List<PendingRequest>) {
|
||||
file.writeText(json.encodeToString(requests))
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a new request into the queue. Id is auto-incremented.
|
||||
* Inserts a new request into the queue.
|
||||
* If a request with the same dedup key exists, it is replaced (updated).
|
||||
* Id is auto-incremented.
|
||||
*/
|
||||
fun insert(request: PendingRequest) {
|
||||
val requests = getAll().toMutableList()
|
||||
val newId = (requests.maxOfOrNull { it.id } ?: 0) + 1
|
||||
requests.add(request.copy(id = newId))
|
||||
saveAll(requests)
|
||||
writeWithLock { data ->
|
||||
val effectiveDedupKey = request.effectiveDedupKey()
|
||||
val existingIndex = data.requests.indexOfFirst { existing ->
|
||||
existing.effectiveDedupKey() == effectiveDedupKey
|
||||
&& existing.id != 0L
|
||||
}
|
||||
|
||||
val requests = data.requests.toMutableList()
|
||||
var nextId = data.nextId
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing request with same dedup key, preserve original timestamp
|
||||
val existing = requests[existingIndex]
|
||||
val merged = request.copy(
|
||||
id = existing.id,
|
||||
timestamp = existing.timestamp, // Keep original creation time
|
||||
retryCount = 0, // Reset retry count on replacement
|
||||
)
|
||||
requests[existingIndex] = merged
|
||||
Log.d(TAG, "Replaced existing request ${existing.id} with dedup key: $effectiveDedupKey")
|
||||
} else {
|
||||
// Insert new request with auto-incremented ID
|
||||
val newId = nextId
|
||||
requests.add(request.copy(id = newId))
|
||||
nextId = newId + 1
|
||||
Log.d(TAG, "Inserted new request $newId for endpoint: ${request.endpoint}")
|
||||
}
|
||||
|
||||
data.copy(requests = requests, nextId = nextId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the retry count for a specific request.
|
||||
* Inserts multiple requests in a single atomic write.
|
||||
* Respects deduplication for each request.
|
||||
*/
|
||||
fun insertAll(requests: List<PendingRequest>) {
|
||||
writeWithLock { data ->
|
||||
var nextId = data.nextId
|
||||
val existing = data.requests.toMutableList()
|
||||
val added = mutableListOf<PendingRequest>()
|
||||
|
||||
for (request in requests) {
|
||||
val effectiveDedupKey = request.effectiveDedupKey()
|
||||
val existingIndex = existing.indexOfFirst { it.effectiveDedupKey() == effectiveDedupKey }
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
val merged = request.copy(
|
||||
id = existing[existingIndex].id,
|
||||
timestamp = existing[existingIndex].timestamp,
|
||||
retryCount = 0,
|
||||
)
|
||||
existing[existingIndex] = merged
|
||||
} else {
|
||||
val newId = nextId++
|
||||
added.add(request.copy(id = newId))
|
||||
}
|
||||
}
|
||||
|
||||
data.copy(
|
||||
requests = existing + added,
|
||||
nextId = nextId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the retry count and updates lastAttemptAt for a specific request.
|
||||
*/
|
||||
fun incrementRetry(id: Long) {
|
||||
val requests = getAll().map {
|
||||
if (it.id == id) it.copy(retryCount = it.retryCount + 1) else it
|
||||
writeWithLock { data ->
|
||||
val requests = data.requests.map {
|
||||
if (it.id == id) {
|
||||
it.copy(
|
||||
retryCount = it.retryCount + 1,
|
||||
lastAttemptAt = System.currentTimeMillis(),
|
||||
)
|
||||
} else it
|
||||
}
|
||||
data.copy(requests = requests)
|
||||
}
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last error message for a specific request.
|
||||
*/
|
||||
fun updateLastError(id: Long, error: String) {
|
||||
val requests = getAll().map {
|
||||
if (it.id == id) it.copy(lastError = error) else it
|
||||
writeWithLock { data ->
|
||||
val requests = data.requests.map {
|
||||
if (it.id == id) it.copy(lastError = error) else it
|
||||
}
|
||||
data.copy(requests = requests)
|
||||
}
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a specific request by id (after successful submission).
|
||||
*/
|
||||
fun deleteById(id: Long) {
|
||||
val requests = getAll().filter { it.id != id }
|
||||
saveAll(requests)
|
||||
writeWithLock { data ->
|
||||
data.copy(requests = data.requests.filter { it.id != id })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all requests that have exceeded their maximum retry count.
|
||||
* Returns the number of expired requests that were removed.
|
||||
*/
|
||||
fun deleteExpired() {
|
||||
val requests = getAll().filter { it.retryCount < it.maxRetries }
|
||||
saveAll(requests)
|
||||
fun deleteExpired(): Int {
|
||||
var removedCount = 0
|
||||
writeWithLock { data ->
|
||||
val (valid, expired) = data.requests.partition { it.retryCount < it.maxRetries }
|
||||
removedCount = expired.size
|
||||
if (removedCount > 0) {
|
||||
Log.w(TAG, "Removed $removedCount expired requests that exceeded max retries")
|
||||
}
|
||||
data.copy(requests = valid)
|
||||
}
|
||||
return removedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all pending requests and clears the queue file.
|
||||
*/
|
||||
fun deleteAll() {
|
||||
file.delete()
|
||||
try {
|
||||
writeWithLock { data ->
|
||||
data.copy(requests = emptyList(), nextId = 1L)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
file.delete()
|
||||
tmpFile.delete()
|
||||
backupFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,15 +332,215 @@ class PendingRequestQueue(private val context: Context) {
|
||||
fun count(): Int = getAll().size
|
||||
|
||||
/**
|
||||
* Returns the count of requests that are near their retry limit
|
||||
* (within 1 of maxRetries). Used to detect problematic endpoints.
|
||||
* Returns the count of requests by entity type.
|
||||
*/
|
||||
fun nearExpiryCount(): Int {
|
||||
return getAll().count { it.retryCount >= it.maxRetries - 1 }
|
||||
fun countByEntityType(): Map<EntityType, Int> {
|
||||
return getAll().groupBy { it.entityType }.mapValues { it.value.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns requests sorted by priority (descending) then timestamp (ascending).
|
||||
* Dependencies are respected: if A depends on B, B appears before A.
|
||||
*/
|
||||
fun getOrdered(): List<PendingRequest> {
|
||||
val all = getAll()
|
||||
if (all.isEmpty()) return emptyList()
|
||||
|
||||
// First pass: sort by priority (desc) then timestamp (asc)
|
||||
val sorted = all.sortedWith(
|
||||
compareByDescending<PendingRequest> { it.priority }
|
||||
.thenBy { it.timestamp }
|
||||
)
|
||||
|
||||
// Second pass: topological sort for dependencies
|
||||
return topologicalSort(sorted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a topological sort so that dependencies appear before dependents.
|
||||
*/
|
||||
private fun topologicalSort(requests: List<PendingRequest>): List<PendingRequest> {
|
||||
if (requests.none { it.dependencyIds.isNotEmpty() }) return requests
|
||||
|
||||
val idMap = requests.associateBy { it.id }
|
||||
val visited = mutableSetOf<Long>()
|
||||
val result = mutableListOf<PendingRequest>()
|
||||
|
||||
fun visit(request: PendingRequest) {
|
||||
if (request.id in visited) return
|
||||
visited.add(request.id)
|
||||
// Visit dependencies first
|
||||
for (depId in request.dependencyIds) {
|
||||
idMap[depId]?.let { visit(it) }
|
||||
}
|
||||
result.add(request)
|
||||
}
|
||||
|
||||
for (request in requests) {
|
||||
visit(request)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the queue has any requests.
|
||||
*/
|
||||
fun isEmpty(): Boolean = count() == 0
|
||||
|
||||
/**
|
||||
* Returns the count of requests that are near their retry limit
|
||||
* (within 2 of maxRetries). Used to detect problematic endpoints.
|
||||
*/
|
||||
fun nearExpiryCount(): Int {
|
||||
return getAll().count { it.retryCount >= it.maxRetries - 2 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns requests grouped by entity type, for UI badge display.
|
||||
*/
|
||||
fun getPendingCountByEntityType(): Map<EntityType, Int> {
|
||||
return getAll().groupBy { it.entityType }.mapValues { it.value.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a request with the given entityType+entityId+mutationType
|
||||
* already exists in the queue.
|
||||
*/
|
||||
fun hasPendingOperation(entityType: EntityType, entityId: String, mutationType: MutationType): Boolean {
|
||||
val dedupKey = "${entityType.name}_${entityId}_${mutationType.name}"
|
||||
return getAll().any { it.effectiveDedupKey() == dedupKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entity IDs that have pending operations of the given types.
|
||||
*/
|
||||
fun getPendingEntityIds(entityType: EntityType): Set<String> {
|
||||
return getAll()
|
||||
.filter { it.entityType == entityType && it.entityId != null }
|
||||
.mapNotNull { it.entityId }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Atomic File I/O with Locking
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Reads the queue data with a shared file lock for consistency.
|
||||
*/
|
||||
private fun readWithLock(): QueueData {
|
||||
return try {
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
raf.channel.use { channel ->
|
||||
channel.lock(0L, Long.MAX_VALUE, true).use { _ ->
|
||||
val length = raf.length().toInt()
|
||||
if (length == 0) return QueueData()
|
||||
val bytes = ByteArray(length)
|
||||
raf.readFully(bytes)
|
||||
json.decodeFromString<QueueData>(String(bytes, Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading queue with lock", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the queue data atomically with an exclusive file lock.
|
||||
* Writes to a .tmp file first, then atomically renames to the target file.
|
||||
*/
|
||||
private fun writeWithLock(transform: (QueueData) -> QueueData) {
|
||||
try {
|
||||
// Read current state
|
||||
val current = if (file.exists()) readWithLock() else QueueData()
|
||||
|
||||
// Apply transformation
|
||||
val updated = transform(current)
|
||||
|
||||
// Write to temp file
|
||||
val serialized = json.encodeToString(updated)
|
||||
tmpFile.writeText(serialized)
|
||||
|
||||
// Ensure tmp file is fully flushed
|
||||
RandomAccessFile(tmpFile, "rw").use { raf ->
|
||||
raf.channel.use { channel ->
|
||||
channel.lock(0L, Long.MAX_VALUE, false).use { _ ->
|
||||
raf.seek(0)
|
||||
raf.write(serialized.toByteArray(Charsets.UTF_8))
|
||||
raf.channel.force(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic rename: tmp -> target
|
||||
val success = tmpFile.renameTo(file)
|
||||
if (!success) {
|
||||
// Fallback: copy and delete
|
||||
tmpFile.copyTo(file, overwrite = true)
|
||||
tmpFile.delete()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Queue written: ${updated.requests.size} requests, nextId=${updated.nextId}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error writing queue", e)
|
||||
// If write fails, delete temp file to avoid stale state
|
||||
try { tmpFile.delete() } catch (_: Exception) {}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to recover from queue file corruption.
|
||||
* Strategy:
|
||||
* 1. If backup file exists, try loading from backup
|
||||
* 2. If backup is also corrupt, start fresh
|
||||
* 3. Rename corrupt file for debugging
|
||||
*/
|
||||
private fun recoverFromCorruption() {
|
||||
try {
|
||||
if (backupFile.exists()) {
|
||||
Log.i(TAG, "Attempting recovery from backup file")
|
||||
try {
|
||||
val backupContent = backupFile.readText()
|
||||
json.decodeFromString<QueueData>(backupContent)
|
||||
// Backup is valid — restore it
|
||||
val corruptFile = File(context.filesDir, "${FILE_NAME}.corrupt.${System.currentTimeMillis()}")
|
||||
file.renameTo(corruptFile)
|
||||
backupFile.renameTo(file)
|
||||
Log.i(TAG, "Recovered queue from backup")
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
Log.w(TAG, "Backup file also corrupt, starting fresh")
|
||||
backupFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// Start fresh — rename corrupt file for debugging
|
||||
val corruptFile = File(context.filesDir, "${FILE_NAME}.corrupt.${System.currentTimeMillis()}")
|
||||
try { file.renameTo(corruptFile) } catch (_: Exception) { file.delete() }
|
||||
try { tmpFile.delete() } catch (_: Exception) {}
|
||||
|
||||
Log.w(TAG, "Queue reset due to corruption. Corrupt file saved as: ${corruptFile.name}")
|
||||
} catch (_: Exception) {
|
||||
// Last resort: just delete everything
|
||||
file.delete()
|
||||
tmpFile.delete()
|
||||
backupFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a backup of the current queue file.
|
||||
*/
|
||||
fun backup() {
|
||||
try {
|
||||
if (file.exists()) {
|
||||
file.copyTo(backupFile, overwrite = true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to create queue backup", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
@@ -20,12 +23,45 @@ import com.kordant.android.data.local.UserPreferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Represents the aggregate offline/sync state visible to the UI.
|
||||
*
|
||||
* @property isOnline Whether the device has network connectivity.
|
||||
* @property pendingRequestCount Number of requests awaiting sync.
|
||||
* @property isSyncing Whether a sync is currently in progress.
|
||||
* @property lastSyncResult The result of the last sync attempt.
|
||||
* @property lastSyncTimestamp Epoch millis of the last successful sync.
|
||||
*/
|
||||
data class SyncState(
|
||||
val isOnline: Boolean = true,
|
||||
val pendingRequestCount: Int = 0,
|
||||
val isSyncing: Boolean = false,
|
||||
val lastSyncResult: SyncResult? = null,
|
||||
val lastSyncTimestamp: Long = 0L,
|
||||
val consecutiveFailures: Int = 0,
|
||||
val pendingRequestsByEntity: Map<EntityType, Int> = emptyMap(),
|
||||
) {
|
||||
companion object {
|
||||
val INITIAL = SyncState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Central sync coordinator that manages all background synchronization
|
||||
* via WorkManager. Handles scheduling, constraints, backoff, and status tracking.
|
||||
*
|
||||
* ## Enhancements for Offline Mode
|
||||
*
|
||||
* - **Connectivity Flow**: Exposes real-time network state as a [Flow] for UI consumption.
|
||||
* - **SyncState Flow**: Combines connectivity + queue state + sync status into one UI-ready flow.
|
||||
* - **Foreground Sync**: Processes the offline queue when the app comes to the foreground.
|
||||
* - **Network Restoration**: Processes queue when network becomes available (existing behavior, enhanced).
|
||||
* - **Offline Queue Count**: Exposes pending request count and per-entity counts for badges.
|
||||
* - **WorkManager Lifecycle**: Respects app lifecycle for foreground queue processing.
|
||||
*
|
||||
* Design principles:
|
||||
* - Periodic workers use flex intervals to allow batching by WorkManager
|
||||
* - Constraints prevent sync during battery low or no connectivity
|
||||
@@ -39,6 +75,51 @@ class SyncManager(private val context: Context) {
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
// ── Internal state flows ──────────────────────────────────────
|
||||
private val _isOnline = MutableStateFlow(true)
|
||||
private val _isSyncing = MutableStateFlow(false)
|
||||
private val _lastSyncResult = MutableStateFlow<SyncResult?>(null)
|
||||
private val _lastSyncTimestamp = MutableStateFlow(0L)
|
||||
private val _consecutiveFailures = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Real-time connectivity state. Emits true when the device has
|
||||
* an active internet connection, false otherwise.
|
||||
*/
|
||||
val isOnline: Flow<Boolean> = _isOnline.asStateFlow()
|
||||
|
||||
/**
|
||||
* Aggregate sync state combining connectivity, queue, and sync status.
|
||||
* UI should collect this flow for the offline indicator, sync badges, etc.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Aggregate sync state combining connectivity, queue, and sync status.
|
||||
* UI should collect this flow for the offline indicator, sync badges, etc.
|
||||
*/
|
||||
val syncState: Flow<SyncState> = combine(
|
||||
_isOnline,
|
||||
_isSyncing,
|
||||
_lastSyncResult,
|
||||
_lastSyncTimestamp,
|
||||
_consecutiveFailures,
|
||||
) { online, syncing, lastResult, lastTimestamp, failures ->
|
||||
val queue = PendingRequestQueue(context)
|
||||
SyncState(
|
||||
isOnline = online,
|
||||
pendingRequestCount = queue.count(),
|
||||
isSyncing = syncing,
|
||||
lastSyncResult = lastResult,
|
||||
lastSyncTimestamp = lastTimestamp,
|
||||
consecutiveFailures = failures,
|
||||
pendingRequestsByEntity = queue.getPendingCountByEntityType(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy sync status for backward compatibility.
|
||||
*/
|
||||
private val _syncStatus = MutableStateFlow(SyncStatus.EMPTY)
|
||||
val syncStatus: Flow<SyncStatus> = _syncStatus.asStateFlow()
|
||||
|
||||
@@ -58,6 +139,11 @@ class SyncManager(private val context: Context) {
|
||||
* Notification ID for sync failure notifications.
|
||||
*/
|
||||
const val SYNC_FAILURE_NOTIFICATION_ID = 2001
|
||||
|
||||
/**
|
||||
* Interval at which stale connectivity state is re-checked (millis).
|
||||
*/
|
||||
private const val CONNECTIVITY_CHECK_INTERVAL_MS = 10_000L
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -65,12 +151,38 @@ class SyncManager(private val context: Context) {
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Initializes all periodic sync workers. Call once on app startup.
|
||||
* Respects user's background sync preference.
|
||||
* Initializes all periodic sync workers and starts network monitoring.
|
||||
* Call once on app startup via [KordantApp.getSyncManager].
|
||||
*/
|
||||
fun initialize() {
|
||||
scheduleAllPeriodicWork()
|
||||
startNetworkMonitoring()
|
||||
checkInitialConnectivity()
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the offline queue when the app comes to the foreground.
|
||||
* Call from [LifecycleEventObserver] on [Lifecycle.Event.ON_RESUME].
|
||||
*/
|
||||
fun onAppForegrounded() {
|
||||
val queue = PendingRequestQueue(context)
|
||||
if (queue.isEmpty()) return
|
||||
|
||||
Log.i(TAG, "App foregrounded with ${queue.count()} pending requests — triggering sync")
|
||||
triggerOfflineQueueSync()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches lifecycle observer to automatically process the offline queue
|
||||
* when the app comes to the foreground.
|
||||
*/
|
||||
fun observeLifecycle(lifecycleOwner: LifecycleOwner) {
|
||||
lifecycleOwner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> onAppForegrounded()
|
||||
else -> {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,13 +276,12 @@ class SyncManager(private val context: Context) {
|
||||
workRequest,
|
||||
)
|
||||
|
||||
Log.i(TAG, "Scheduled ${type.name} every ${type.intervalMinutes}min (flex ${type.flexMinutes}min)")
|
||||
Log.d(TAG, "Scheduled ${type.name} every ${type.intervalMinutes}min (flex ${type.flexMinutes}min)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an immediate one-time sync for the given type.
|
||||
* Used for manual sync and urgent operations.
|
||||
* Uses expedited work on Android 12+ for high-priority types.
|
||||
*/
|
||||
fun triggerImmediateSync(type: SyncType) {
|
||||
_syncStatus.value = _syncStatus.value.copy(isSyncing = true)
|
||||
@@ -258,6 +369,7 @@ class SyncManager(private val context: Context) {
|
||||
* Triggers a full sync (all data types) — used for manual sync button.
|
||||
*/
|
||||
fun triggerFullSync() {
|
||||
_isSyncing.value = true
|
||||
_syncStatus.value = _syncStatus.value.copy(isSyncing = true)
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<FullSyncWorker>()
|
||||
@@ -288,16 +400,44 @@ class SyncManager(private val context: Context) {
|
||||
/**
|
||||
* Enqueues an offline request for later submission.
|
||||
* Initiates a sync attempt if online, otherwise queues for when online.
|
||||
*
|
||||
* @param endpoint API endpoint path
|
||||
* @param body JSON request body
|
||||
* @param method HTTP method
|
||||
* @param mutationType The type of mutation (ADD, UPDATE, DELETE)
|
||||
* @param entityType The type of entity being modified
|
||||
* @param entityId The specific entity ID (for deduplication)
|
||||
* @param version Entity version/timestamp for conflict detection
|
||||
* @param dependencyIds IDs of requests that must complete first
|
||||
* @param priority Processing priority
|
||||
*/
|
||||
fun enqueueOfflineRequest(endpoint: String, body: String, method: String = "POST") {
|
||||
fun enqueueOfflineRequest(
|
||||
endpoint: String,
|
||||
body: String,
|
||||
method: String = "POST",
|
||||
mutationType: MutationType = MutationType.ADD,
|
||||
entityType: EntityType = EntityType.UNKNOWN,
|
||||
entityId: String? = null,
|
||||
version: String? = null,
|
||||
dependencyIds: List<Long> = emptyList(),
|
||||
priority: Int = 0,
|
||||
) {
|
||||
val queue = PendingRequestQueue(context)
|
||||
val request = PendingRequest(
|
||||
endpoint = endpoint,
|
||||
method = method,
|
||||
body = body,
|
||||
mutationType = mutationType,
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
version = version,
|
||||
dependencyIds = dependencyIds,
|
||||
priority = priority,
|
||||
)
|
||||
queue.insert(request)
|
||||
|
||||
Log.i(TAG, "Enqueued offline request: $mutationType $entityType/$entityId -> $endpoint")
|
||||
|
||||
// Attempt immediate sync if online
|
||||
if (isOnline()) {
|
||||
triggerOfflineQueueSync()
|
||||
@@ -328,6 +468,8 @@ class SyncManager(private val context: Context) {
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request,
|
||||
)
|
||||
|
||||
Log.d(TAG, "Offline queue sync triggered")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,6 +508,39 @@ class SyncManager(private val context: Context) {
|
||||
*/
|
||||
fun offlineQueueSize(): Int = PendingRequestQueue(context).count()
|
||||
|
||||
/**
|
||||
* Returns the number of pending requests per entity type.
|
||||
*/
|
||||
fun offlineQueueCountByEntity(): Map<EntityType, Int> {
|
||||
return PendingRequestQueue(context).getPendingCountByEntityType()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given entity has a pending operation in the queue.
|
||||
*/
|
||||
fun hasPendingOperation(entityType: EntityType, entityId: String): Boolean {
|
||||
return PendingRequestQueue(context).hasPendingOperation(
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
mutationType = MutationType.ADD,
|
||||
) || PendingRequestQueue(context).hasPendingOperation(
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
mutationType = MutationType.UPDATE,
|
||||
) || PendingRequestQueue(context).hasPendingOperation(
|
||||
entityType = entityType,
|
||||
entityId = entityId,
|
||||
mutationType = MutationType.DELETE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of entity IDs that have pending operations.
|
||||
*/
|
||||
fun getPendingEntityIds(entityType: EntityType): Set<String> {
|
||||
return PendingRequestQueue(context).getPendingEntityIds(entityType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the device currently has network connectivity.
|
||||
*/
|
||||
@@ -389,6 +564,38 @@ class SyncManager(private val context: Context) {
|
||||
return UserPreferencesDataStore(context).isBackgroundSyncEnabled()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Internal State Management
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Sets the syncing state. Called by workers to report status.
|
||||
*/
|
||||
fun setSyncing(syncing: Boolean) {
|
||||
_isSyncing.value = syncing
|
||||
_syncStatus.value = _syncStatus.value.copy(isSyncing = syncing)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a sync result. Called by workers after completion.
|
||||
*/
|
||||
fun recordSyncResult(result: SyncResult) {
|
||||
if (result.succeeded) {
|
||||
_lastSyncResult.value = result
|
||||
_lastSyncTimestamp.value = result.timestamp
|
||||
_consecutiveFailures.value = 0
|
||||
} else {
|
||||
_consecutiveFailures.value = _consecutiveFailures.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the sync status for legacy consumers.
|
||||
*/
|
||||
fun updateSyncStatus(status: SyncStatus) {
|
||||
_syncStatus.value = status
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Constraints
|
||||
// ============================================================
|
||||
@@ -411,7 +618,7 @@ class SyncManager(private val context: Context) {
|
||||
}
|
||||
// On Android 7+, require not in battery saver for non-urgent syncs
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && priority != SyncPriority.HIGH) {
|
||||
setRequiresCharging(false) // Don't require charging, but respect battery
|
||||
setRequiresCharging(false)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
@@ -421,22 +628,53 @@ class SyncManager(private val context: Context) {
|
||||
// Network Monitoring
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Checks the initial connectivity state and updates the flow.
|
||||
*/
|
||||
private fun checkInitialConnectivity() {
|
||||
_isOnline.value = isOnline()
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a connectivity callback to automatically flush the offline
|
||||
* request queue when network becomes available.
|
||||
* request queue when network becomes available, and update the
|
||||
* online/offline state flow.
|
||||
*/
|
||||
private fun startNetworkMonitoring() {
|
||||
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
|
||||
|
||||
networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.d(TAG, "Network available — checking offline queue")
|
||||
Log.d(TAG, "Network available")
|
||||
_isOnline.value = true
|
||||
val queueSize = offlineQueueSize()
|
||||
if (queueSize > 0) {
|
||||
Log.i(TAG, "Flushing $queueSize offline requests on network availability")
|
||||
triggerOfflineQueueSync()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
Log.d(TAG, "Network lost")
|
||||
_isOnline.value = false
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) {
|
||||
val hasInternet = capabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
)
|
||||
_isOnline.value = hasInternet
|
||||
if (hasInternet) {
|
||||
val queueSize = offlineQueueSize()
|
||||
if (queueSize > 0) {
|
||||
Log.i(TAG, "Network capabilities changed — flushing $queueSize requests")
|
||||
triggerOfflineQueueSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
@@ -447,6 +685,7 @@ class SyncManager(private val context: Context) {
|
||||
connectivityManager.registerNetworkCallback(request, networkCallback!!)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Missing network state permission for callback registration", e)
|
||||
_isOnline.value = true // Assume online if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.kordant.android.di.NetworkModule
|
||||
import com.kordant.android.di.RepositoryModule
|
||||
import com.kordant.android.widget.ThreatScoreWidgetProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
@@ -255,7 +256,6 @@ class SpamDbSyncWorker(
|
||||
return withContext(Dispatchers.IO) {
|
||||
val app = applicationContext as KordantApp
|
||||
try {
|
||||
// Fetch spam rules from the backend via SpamShieldRepository
|
||||
val spamRepo = RepositoryModule.provideSpamShieldRepository(app)
|
||||
val rulesResult = spamRepo.getRules(forceRefresh = true)
|
||||
|
||||
@@ -263,14 +263,12 @@ class SpamDbSyncWorker(
|
||||
is ApiResult.Success -> {
|
||||
val rules = rulesResult.data
|
||||
if (rules.isNotEmpty()) {
|
||||
// Convert backend rules to SpamNumberEntity and sync
|
||||
// into the local call screening database
|
||||
val screeningRepo = com.kordant.android.data.repository.CallScreeningRepository
|
||||
.getInstance(app)
|
||||
|
||||
val entities = rules.map { rule ->
|
||||
com.kordant.android.data.local.spam.SpamNumberEntity(
|
||||
numberHash = rule.pattern, // Backend pattern becomes hash/pattern
|
||||
numberHash = rule.pattern,
|
||||
pattern = if (rule.pattern.contains("*")) rule.pattern else null,
|
||||
action = rule.action,
|
||||
category = rule.description?.let {
|
||||
@@ -364,18 +362,47 @@ class WatchlistSyncWorker(
|
||||
|
||||
/**
|
||||
* Worker that flushes the offline request queue.
|
||||
* High priority — triggered on network availability or after enqueue.
|
||||
* High priority — triggered on network availability, app foreground, or after enqueue.
|
||||
*
|
||||
* Features:
|
||||
* - Processes requests in dependency order (topological sort via [PendingRequestQueue.getOrdered])
|
||||
* - Deduplicates requests with the same dedup key (handled by [PendingRequestQueue.insert])
|
||||
* - Uses conflict resolution per entity type via [ConflictResolver]
|
||||
* - Exponential backoff between retries (per-request based on [PendingRequest.nextBackoffDelayMs])
|
||||
* - Partial sync: continues processing remaining requests after individual failures
|
||||
* - Reports sync result back to [SyncManager] for UI status tracking
|
||||
* - Handles partial failures gracefully (commits successful deletions, retries failures)
|
||||
*/
|
||||
class OfflineQueueWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OfflineQueueWorker"
|
||||
|
||||
/**
|
||||
* Maximum number of individual request failures before the entire
|
||||
* worker run gives up (to avoid infinite loops on systematic errors).
|
||||
*/
|
||||
private const val MAX_FAILURES_PER_RUN = 20
|
||||
|
||||
/**
|
||||
* Delay between individual request processing within a single run.
|
||||
* Prevents hammering the server with rapid sequential calls.
|
||||
*/
|
||||
private const val INTER_REQUEST_DELAY_MS = 200L
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val queue = PendingRequestQueue(applicationContext)
|
||||
val pendingRequests = queue.getAll()
|
||||
|
||||
if (pendingRequests.isEmpty()) return Result.success()
|
||||
// Use ordered requests (priority-sorted, dependency-respected)
|
||||
val pendingRequests = queue.getOrdered()
|
||||
if (pendingRequests.isEmpty()) {
|
||||
Log.d(TAG, "No pending requests to sync")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val app = applicationContext as KordantApp
|
||||
|
||||
@@ -385,57 +412,214 @@ class OfflineQueueWorker(
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
Log.i(TAG, "OfflineQueue: Processing ${pendingRequests.size} pending requests")
|
||||
Log.i(TAG, "OfflineQueue: Processing ${pendingRequests.size} pending requests " +
|
||||
"(prioritized, dependency-ordered)")
|
||||
|
||||
val client = app.let {
|
||||
com.kordant.android.di.NetworkModule.provideOkHttpClient(applicationContext)
|
||||
}
|
||||
val syncManager = try {
|
||||
app.getSyncManager()
|
||||
} catch (_: Exception) { null }
|
||||
syncManager?.setSyncing(true)
|
||||
|
||||
val client = NetworkModule.provideOkHttpClient(applicationContext)
|
||||
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val conflictResolver = ConflictResolver()
|
||||
|
||||
var successCount = 0
|
||||
var failureCount = 0
|
||||
var conflictCount = 0
|
||||
|
||||
for (request in pendingRequests) {
|
||||
if (failureCount >= MAX_FAILURES_PER_RUN) {
|
||||
Log.w(TAG, "Hit max failures ($MAX_FAILURES_PER_RUN) in this run, stopping")
|
||||
break
|
||||
}
|
||||
|
||||
if (request.retryCount >= request.maxRetries) {
|
||||
queue.deleteById(request.id)
|
||||
Log.w(TAG, "Request ${request.id} exceeded max retries, discarding")
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply exponential backoff delay if this is a retry
|
||||
if (request.retryCount > 0 && request.lastAttemptAt > 0) {
|
||||
val backoffMs = request.nextBackoffDelayMs()
|
||||
val timeSinceLastAttempt = System.currentTimeMillis() - request.lastAttemptAt
|
||||
if (timeSinceLastAttempt < backoffMs) {
|
||||
val remainingWait = backoffMs - timeSinceLastAttempt
|
||||
Log.d(TAG, "Request ${request.id} backoff: waiting ${remainingWait}ms " +
|
||||
"(attempt ${request.retryCount})")
|
||||
if (remainingWait > 0 && remainingWait < 60_000L) {
|
||||
delay(remainingWait.coerceAtMost(10_000L))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val body = request.body.toRequestBody(jsonMediaType)
|
||||
val httpRequest = Request.Builder()
|
||||
.url("${com.kordant.android.di.NetworkModule.getBaseUrl()}${request.endpoint}")
|
||||
.url("${NetworkModule.getBaseUrl()}${request.endpoint}")
|
||||
.method(request.method, body)
|
||||
.build()
|
||||
val response = client.newCall(httpRequest).execute()
|
||||
if (response.isSuccessful) {
|
||||
queue.deleteById(request.id)
|
||||
successCount++
|
||||
} else {
|
||||
queue.incrementRetry(request.id)
|
||||
if (response.code == 422 || response.code == 400) {
|
||||
// Validation error — delete, no point retrying
|
||||
queue.deleteById(request.id)
|
||||
.apply {
|
||||
val token = app.secureStorageManager.getAccessToken()
|
||||
if (token != null) {
|
||||
header("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
val response = client.newCall(httpRequest).execute()
|
||||
val responseBody = response.body?.string()
|
||||
|
||||
when {
|
||||
response.isSuccessful -> {
|
||||
Log.d(TAG, "Request ${request.id} succeeded (${request.mutationType} " +
|
||||
"${request.entityType})")
|
||||
queue.deleteById(request.id)
|
||||
successCount++
|
||||
}
|
||||
response.code == 409 -> {
|
||||
// Conflict — detect and resolve per strategy
|
||||
conflictCount++
|
||||
val conflict = conflictResolver.detectConflict(
|
||||
pendingRequest = request,
|
||||
serverResponseCode = 409,
|
||||
serverResponseBody = responseBody,
|
||||
serverEtag = response.header("ETag"),
|
||||
)
|
||||
|
||||
if (conflict != null) {
|
||||
val resolution = conflictResolver.resolve(conflict, responseBody)
|
||||
Log.w(TAG, "Conflict resolved for request ${request.id}: " +
|
||||
"${resolution.action} — ${resolution.message}")
|
||||
|
||||
when (resolution.action) {
|
||||
ConflictAction.USE_SERVER -> {
|
||||
// Discard local — delete from queue
|
||||
queue.deleteById(request.id)
|
||||
successCount++
|
||||
}
|
||||
ConflictAction.USE_LOCAL -> {
|
||||
// Retry with local version — re-queue (increment for backoff)
|
||||
queue.incrementRetry(request.id)
|
||||
queue.updateLastError(request.id, "Conflict: ${resolution.message}")
|
||||
failureCount++
|
||||
}
|
||||
ConflictAction.MERGED -> {
|
||||
// Update request body with merged version and retry
|
||||
if (resolution.mergedBody != null) {
|
||||
queue.deleteById(request.id)
|
||||
queue.insert(request.copy(
|
||||
body = resolution.mergedBody,
|
||||
retryCount = 0,
|
||||
lastError = null,
|
||||
))
|
||||
successCount++
|
||||
Log.d(TAG, "Re-queued merged request for ${request.endpoint}")
|
||||
} else {
|
||||
queue.deleteById(request.id)
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
ConflictAction.MANUAL -> {
|
||||
// Keep in queue for manual resolution
|
||||
queue.incrementRetry(request.id)
|
||||
queue.updateLastError(request.id, "Manual resolution required")
|
||||
failureCount++
|
||||
Log.w(TAG, "Request ${request.id} requires manual conflict resolution")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No conflict detected despite 409 — retry
|
||||
queue.incrementRetry(request.id)
|
||||
failureCount++
|
||||
}
|
||||
}
|
||||
response.code == 401 -> {
|
||||
// Token expired — skip, will be retried with new token
|
||||
Log.w(TAG, "Request ${request.id} unauthorized, will retry")
|
||||
queue.incrementRetry(request.id)
|
||||
queue.updateLastError(request.id, "Auth token expired")
|
||||
failureCount++
|
||||
}
|
||||
response.code == 422 || response.code == 400 -> {
|
||||
// Validation error — discard (data is no longer valid)
|
||||
Log.w(TAG, "Request ${request.id} validation error (${response.code}), discarding")
|
||||
queue.deleteById(request.id)
|
||||
// Even though we discarded, count as success (no point retrying)
|
||||
successCount++
|
||||
}
|
||||
response.code in 500..599 -> {
|
||||
// Server error — retry with backoff
|
||||
Log.w(TAG, "Request ${request.id} server error ${response.code}")
|
||||
queue.incrementRetry(request.id)
|
||||
queue.updateLastError(request.id, "Server error ${response.code}")
|
||||
failureCount++
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Request ${request.id} failed with ${response.code}")
|
||||
queue.incrementRetry(request.id)
|
||||
queue.updateLastError(request.id, "HTTP ${response.code}")
|
||||
failureCount++
|
||||
}
|
||||
failureCount++
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
|
||||
// Small delay between requests to avoid server hammering
|
||||
if (successCount + failureCount < pendingRequests.size) {
|
||||
delay(INTER_REQUEST_DELAY_MS)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Request ${request.id} failed: ${e.message}")
|
||||
queue.incrementRetry(request.id)
|
||||
queue.updateLastError(request.id, e.message ?: "Unknown error")
|
||||
failureCount++
|
||||
return Result.retry()
|
||||
|
||||
// On network errors for single request, don't immediately fail the whole batch
|
||||
if (e is java.net.UnknownHostException || e is java.net.ConnectException) {
|
||||
Log.w(TAG, "Network error processing batch — will retry remaining later")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queue.deleteExpired()
|
||||
Log.i(TAG, "OfflineQueue: $successCount succeeded, $failureCount failed, ${queue.count()} remaining")
|
||||
// Clean up expired requests
|
||||
val expiredRemoved = queue.deleteExpired()
|
||||
|
||||
return if (queue.count() == 0) {
|
||||
Result.success()
|
||||
} else {
|
||||
Result.retry()
|
||||
// Report results
|
||||
Log.i(TAG, "OfflineQueue run complete: " +
|
||||
"$successCount succeeded, $failureCount failed, " +
|
||||
"$conflictCount conflicts, $expiredRemoved expired, " +
|
||||
"${queue.count()} remaining")
|
||||
|
||||
// Record sync result if SyncManager is available
|
||||
if (syncManager != null) {
|
||||
syncManager.setSyncing(false)
|
||||
syncManager.recordSyncResult(
|
||||
SyncResult(
|
||||
type = SyncType.OFFLINE_QUEUE,
|
||||
succeeded = failureCount == 0,
|
||||
itemsSynced = successCount,
|
||||
message = "Synced $successCount requests, $failureCount failed, " +
|
||||
"${queue.count()} remaining",
|
||||
errorMessage = if (failureCount > 0) "$failureCount requests failed" else null,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return when {
|
||||
queue.count() == 0 -> {
|
||||
Log.i(TAG, "Offline queue fully synced")
|
||||
Result.success()
|
||||
}
|
||||
failureCount < MAX_FAILURES_PER_RUN / 2 -> {
|
||||
// Partial success — retry remaining
|
||||
Log.i(TAG, "Offline queue partially synced, will retry ${queue.count()} remaining")
|
||||
Result.retry()
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Offline queue has ${queue.count()} remaining after $failureCount failures")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OfflineQueueWorker"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.kordant.android.BuildConfig
|
||||
import com.kordant.android.data.local.SecureStorageManager
|
||||
import com.kordant.android.data.remote.AuthInterceptor
|
||||
import com.kordant.android.data.remote.NetworkConfig
|
||||
import com.kordant.android.data.remote.TokenRefreshAuthenticator
|
||||
import com.kordant.android.data.remote.TokenRefreshManager
|
||||
import com.kordant.android.data.remote.TRPCApiService
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
@@ -16,10 +18,36 @@ import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Network dependency injection module.
|
||||
*
|
||||
* Provides singleton instances for:
|
||||
* - [OkHttpClient] with auth interceptor, authenticator, logging, and tracing
|
||||
* - [Retrofit] with kotlinx.serialization converter
|
||||
* - [TRPCApiService] interface for all tRPC API calls
|
||||
* - [TokenRefreshManager] for automatic token refresh
|
||||
* - [TokenRefreshAuthenticator] for 401 handling
|
||||
*
|
||||
* ## Auth Architecture
|
||||
*
|
||||
* ```
|
||||
* Request → AuthInterceptor (adds Bearer token)
|
||||
* → RequestIDInterceptor (adds tracing headers)
|
||||
* → LoggingInterceptor (sanitized logging)
|
||||
* → HTTP Server
|
||||
*
|
||||
* HTTP 401 → TokenRefreshAuthenticator
|
||||
* → TokenRefreshManager.refreshToken() (REST /auth/refresh)
|
||||
* → On success: retry original request with new token
|
||||
* → On failure: propagate 401 to caller
|
||||
* ```
|
||||
*/
|
||||
object NetworkModule {
|
||||
private var baseUrl: String = BuildConfig.API_BASE_URL
|
||||
private var retrofit: Retrofit? = null
|
||||
private var apiService: TRPCApiService? = null
|
||||
private var tokenRefreshManager: TokenRefreshManager? = null
|
||||
private var tokenRefreshAuthenticator: TokenRefreshAuthenticator? = null
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -43,6 +71,39 @@ object NetworkModule {
|
||||
return if (url.endsWith("/")) url else "$url/"
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Token Refresh
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Provides the singleton [TokenRefreshManager].
|
||||
*/
|
||||
fun provideTokenRefreshManager(context: Context): TokenRefreshManager {
|
||||
return tokenRefreshManager ?: synchronized(this) {
|
||||
tokenRefreshManager ?: TokenRefreshManager(
|
||||
context = context,
|
||||
secureStorageManager = SecureStorageManager(context),
|
||||
baseUrl = BuildConfig.API_BASE_URL,
|
||||
).also { tokenRefreshManager = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the singleton [TokenRefreshAuthenticator].
|
||||
*/
|
||||
fun provideTokenRefreshAuthenticator(context: Context): TokenRefreshAuthenticator {
|
||||
return tokenRefreshAuthenticator ?: synchronized(this) {
|
||||
tokenRefreshAuthenticator ?: TokenRefreshAuthenticator(
|
||||
secureStorageManager = SecureStorageManager(context),
|
||||
tokenRefreshManager = provideTokenRefreshManager(context),
|
||||
).also { tokenRefreshAuthenticator = it }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Logging
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Provides a sanitized [HttpLoggingInterceptor] that:
|
||||
* - Logs full request/response bodies only in debug builds
|
||||
@@ -51,14 +112,10 @@ object NetworkModule {
|
||||
*/
|
||||
private fun provideLoggingInterceptor(): HttpLoggingInterceptor {
|
||||
return HttpLoggingInterceptor { message ->
|
||||
// Sanitize: mask Bearer tokens
|
||||
val sanitized = message
|
||||
.replace(Regex("""Bearer\s+[A-Za-z0-9\-._~+/]+=*"""), "Bearer [REDACTED]")
|
||||
// Mask phone numbers in URLs/bodies
|
||||
.replace(Regex("""\b\d{10,15}\b"), "[PHONE_REDACTED]")
|
||||
// Mask email addresses
|
||||
.replace(Regex("""\b\d{10,15}\b"""), "[PHONE_REDACTED]")
|
||||
.replace(Regex("""[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"""), "[EMAIL_REDACTED]")
|
||||
// Mask refresh tokens in bodies
|
||||
.replace(Regex(""""refreshToken"\s*:\s*"[^"]+""""), "\"refreshToken\":\"[REDACTED]\"")
|
||||
.replace(Regex(""""accessToken"\s*:\s*"[^"]+""""), "\"accessToken\":\"[REDACTED]\"")
|
||||
.replace(Regex(""""idToken"\s*:\s*"[^"]+""""), "\"idToken\":\"[REDACTED]\"")
|
||||
@@ -67,22 +124,19 @@ object NetworkModule {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d("KordantAPI", sanitized)
|
||||
} else {
|
||||
// In production, only log at INFO level for monitoring
|
||||
Log.i("KordantAPI", sanitized)
|
||||
}
|
||||
}.apply {
|
||||
level = if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.HEADERS
|
||||
} else {
|
||||
// Production: log only request/response headers, no bodies
|
||||
HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interceptor that adds a unique request ID for tracing.
|
||||
* Useful for correlating log entries and debugging.
|
||||
* Interceptor that adds tracing headers for request correlation.
|
||||
*/
|
||||
private val requestIdInterceptor = Interceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
@@ -93,19 +147,34 @@ object NetworkModule {
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OkHttp Client
|
||||
// ============================================================
|
||||
|
||||
fun provideOkHttpClient(context: Context): OkHttpClient {
|
||||
val secureStorageManager = SecureStorageManager(context)
|
||||
val tokenRefreshAuthenticator = provideTokenRefreshAuthenticator(context)
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(AuthInterceptor(context, secureStorageManager))
|
||||
// Interceptor: adds Bearer token to every request
|
||||
.addInterceptor(AuthInterceptor(secureStorageManager))
|
||||
// Interceptor: adds tracing headers
|
||||
.addInterceptor(requestIdInterceptor)
|
||||
// Interceptor: sanitized logging
|
||||
.addInterceptor(provideLoggingInterceptor())
|
||||
// Authenticator: handles 401 responses by refreshing token
|
||||
.authenticator(tokenRefreshAuthenticator)
|
||||
// Timeouts from centralized config
|
||||
.connectTimeout(NetworkConfig.CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(NetworkConfig.READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(NetworkConfig.WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Retrofit
|
||||
// ============================================================
|
||||
|
||||
fun provideRetrofit(context: Context): Retrofit {
|
||||
return retrofit ?: synchronized(this) {
|
||||
retrofit ?: Retrofit.Builder()
|
||||
@@ -124,6 +193,10 @@ object NetworkModule {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reset (for testing)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Resets all cached instances. Useful for testing or runtime config changes.
|
||||
*/
|
||||
@@ -131,6 +204,8 @@ object NetworkModule {
|
||||
synchronized(this) {
|
||||
retrofit = null
|
||||
apiService = null
|
||||
tokenRefreshManager = null
|
||||
tokenRefreshAuthenticator = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -18,6 +19,11 @@ import com.kordant.android.DeepLink
|
||||
import com.kordant.android.KordantApp
|
||||
import com.kordant.android.MainActivity
|
||||
import com.kordant.android.viewmodel.AuthViewModel
|
||||
import com.kordant.android.notification.ForegroundSnackbar
|
||||
import com.kordant.android.notification.NotificationPayload
|
||||
import com.kordant.android.ui.screens.auth.BiometricAuthScreen
|
||||
import com.kordant.android.ui.screens.auth.isBiometricEnabled
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
@@ -30,6 +36,7 @@ fun AppNavigation(
|
||||
)
|
||||
val isAuthenticated by viewModel.isAuthenticated.collectAsState()
|
||||
val isNewUser by viewModel.isNewUser.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// Handle pending deep link
|
||||
var pendingDeepLink by remember { mutableStateOf(initialDeepLink) }
|
||||
@@ -86,6 +93,21 @@ fun AppNavigation(
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
is DeepLink.DarkWatch -> {
|
||||
navController.navigate(Screen.DarkWatch.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
is DeepLink.Family -> {
|
||||
navController.navigate(Screen.Family.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
is DeepLink.Billing -> {
|
||||
navController.navigate(Screen.Billing.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingDeepLink = null
|
||||
}
|
||||
@@ -116,13 +138,93 @@ fun AppNavigation(
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavGraph(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
androidx.compose.foundation.layout.Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
) {
|
||||
// Foreground notification snackbar
|
||||
ForegroundSnackbar(
|
||||
onDismiss = { payload: NotificationPayload ->
|
||||
// Notification dismissed without action
|
||||
},
|
||||
onTap = { payload: NotificationPayload ->
|
||||
// Navigate based on notification type
|
||||
val screen = payload.deepLinkScreen
|
||||
val id = payload.deepLinkId
|
||||
when (screen) {
|
||||
"alert_detail" -> {
|
||||
navController.navigate(Screen.AlertDetail.createRoute(id ?: "")) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
"darkwatch" -> {
|
||||
navController.navigate(Screen.DarkWatch.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
"dashboard" -> {
|
||||
navController.navigate(Screen.Dashboard.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
"family" -> {
|
||||
navController.navigate(Screen.Family.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
"billing" -> {
|
||||
navController.navigate(Screen.Billing.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
"settings" -> {
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = false }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
navController.navigate(Screen.Dashboard.route) {
|
||||
popUpTo(Screen.Dashboard.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
NavGraph(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (uiState.sessionExpired && isBiometricEnabled(context)) {
|
||||
// Session expired but biometric is enabled — offer biometric re-auth
|
||||
// before falling back to full login screen.
|
||||
var biometricAttempted by remember { mutableStateOf(false) }
|
||||
|
||||
if (!biometricAttempted) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
BiometricAuthScreen(
|
||||
title = "Session Expired",
|
||||
subtitle = "Your session has expired. Authenticate to continue.",
|
||||
onAuthenticated = {
|
||||
biometricAttempted = true
|
||||
// Try to refresh the session silently
|
||||
coroutineScope.launch {
|
||||
val refreshed = viewModel.trySilentRefresh()
|
||||
if (refreshed) {
|
||||
viewModel.dismissSessionExpired()
|
||||
}
|
||||
// If not refreshed, fall through to full login
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
biometricAttempted = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AuthNavHost(viewModel = viewModel)
|
||||
}
|
||||
} else {
|
||||
AuthNavHost(viewModel = viewModel)
|
||||
}
|
||||
|
||||
@@ -32,12 +32,14 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavDeepLink
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.navDeepLink
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.kordant.android.R
|
||||
@@ -86,7 +88,13 @@ fun NavGraph(
|
||||
startDestination = Screen.Dashboard.route,
|
||||
modifier = modifier
|
||||
) {
|
||||
composable(Screen.Dashboard.route) {
|
||||
composable(
|
||||
route = Screen.Dashboard.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "kordant://dashboard" },
|
||||
navDeepLink { uriPattern = "https://kordant.ai/dashboard" }
|
||||
)
|
||||
) {
|
||||
DashboardScreen(
|
||||
onNavigateToAlert = { alertId ->
|
||||
navController.navigate(Screen.AlertDetail.createRoute(alertId))
|
||||
@@ -97,7 +105,12 @@ fun NavGraph(
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Alerts.route) {
|
||||
composable(
|
||||
route = Screen.Alerts.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "kordant://alerts" }
|
||||
)
|
||||
) {
|
||||
AlertsScreen(
|
||||
onNavigateToAlert = { alertId ->
|
||||
navController.navigate(Screen.AlertDetail.createRoute(alertId))
|
||||
@@ -107,7 +120,10 @@ fun NavGraph(
|
||||
|
||||
composable(
|
||||
route = Screen.AlertDetail.ROUTE,
|
||||
arguments = listOf(navArgument("alertId") { type = NavType.StringType })
|
||||
arguments = listOf(navArgument("alertId") { type = NavType.StringType }),
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "kordant://alert?id={alertId}" }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val alertId = backStackEntry.arguments?.getString("alertId") ?: ""
|
||||
AlertDetailScreen(
|
||||
@@ -116,7 +132,12 @@ fun NavGraph(
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Services.route) {
|
||||
composable(
|
||||
route = Screen.Services.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "kordant://services" }
|
||||
)
|
||||
) {
|
||||
ServicesHubScreen(
|
||||
onNavigateToService = { route ->
|
||||
navController.navigate(route)
|
||||
@@ -124,7 +145,12 @@ fun NavGraph(
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.DarkWatch.route) {
|
||||
composable(
|
||||
route = Screen.DarkWatch.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "kordant://darkwatch" }
|
||||
)
|
||||
) {
|
||||
DarkWatchScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
@@ -163,7 +189,13 @@ fun NavGraph(
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Settings.route) {
|
||||
composable(
|
||||
route = Screen.Settings.route,
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "kordant://settings" },
|
||||
navDeepLink { uriPattern = "https://kordant.ai/settings" }
|
||||
)
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBack = { navController.popBackStack(Screen.Dashboard.route, inclusive = false) }
|
||||
)
|
||||
@@ -173,9 +205,24 @@ fun NavGraph(
|
||||
PlaceholderScreen(title = "Account")
|
||||
}
|
||||
|
||||
composable(Screen.Family.route) {
|
||||
FamilyScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.Billing.route) {
|
||||
BillingScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Screen.ServiceDetail.ROUTE,
|
||||
arguments = listOf(navArgument("serviceId") { type = NavType.StringType })
|
||||
arguments = listOf(navArgument("serviceId") { type = NavType.StringType }),
|
||||
deepLinks = listOf(
|
||||
navDeepLink { uriPattern = "kordant://service?id={serviceId}" }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val serviceId = backStackEntry.arguments?.getString("serviceId") ?: ""
|
||||
PlaceholderScreen(title = "Service: $serviceId")
|
||||
@@ -381,3 +428,63 @@ private fun PlaceholderScreen(title: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FamilyScreen(onBack: () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Back")
|
||||
}
|
||||
Text(
|
||||
text = "Family",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
TextButton(onClick = {}) {
|
||||
Text("Invite")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ShieldCard {
|
||||
Text(
|
||||
text = "Family members and shared alerts",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BillingScreen(onBack: () -> Unit) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Back")
|
||||
}
|
||||
Text(
|
||||
text = "Billing",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(text = "")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ShieldCard {
|
||||
Text(
|
||||
text = "Subscription and billing management",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,6 @@ sealed class Screen(val route: String) {
|
||||
data object CallScreeningSettings : Screen("call_screening_settings")
|
||||
data object HomeTitle : Screen("hometitle")
|
||||
data object RemoveBrokers : Screen("removebrokers")
|
||||
data object Family : Screen("family")
|
||||
data object Billing : Screen("billing")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package com.kordant.android.notification
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.kordant.android.ui.components.ShieldSnackbarHost
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* Manages in-app notifications (snackbars) when the app is in the foreground.
|
||||
*
|
||||
* When a push notification arrives and the app is in the foreground,
|
||||
* this manager shows an in-app snackbar instead of (or in addition to)
|
||||
* a system notification. This provides a better UX by keeping the user
|
||||
* in the current context.
|
||||
*
|
||||
* ## Architecture
|
||||
* - [isAppInForeground] tracks app lifecycle state
|
||||
* - [pendingNotifications] is a SharedFlow of incoming notifications
|
||||
* - [ForegroundSnackbar] composable collects from the flow and displays snackbars
|
||||
*
|
||||
* ## Usage
|
||||
* 1. Call [setAppForeground] when app enters/leaves foreground
|
||||
* 2. Call [sendNotification] from FCMService when message arrives
|
||||
* 3. Add [ForegroundSnackbar] composable to your UI hierarchy
|
||||
*/
|
||||
object ForegroundNotificationManager {
|
||||
|
||||
private const val TAG = "ForegroundNotification"
|
||||
|
||||
// ── Foreground State ────────────────────────────────────────
|
||||
|
||||
@Volatile
|
||||
private var _isAppInForeground = false
|
||||
|
||||
val isAppInForeground: Boolean
|
||||
get() = _isAppInForeground
|
||||
|
||||
// ── Notification Flow ───────────────────────────────────────
|
||||
|
||||
private val _pendingNotifications = MutableSharedFlow<NotificationPayload>(
|
||||
extraBufferCapacity = 10
|
||||
)
|
||||
val pendingNotifications: SharedFlow<NotificationPayload> = _pendingNotifications.asSharedFlow()
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called when the app enters or leaves the foreground.
|
||||
* Typically called from MainActivity lifecycle observers.
|
||||
*/
|
||||
fun setAppForeground(isForeground: Boolean) {
|
||||
_isAppInForeground = isForeground
|
||||
Log.d(TAG, "App foreground state changed: $isForeground")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification for processing.
|
||||
* If the app is in the foreground, it goes to the snackbar queue.
|
||||
* Otherwise, it returns false so the caller can show a system notification.
|
||||
*
|
||||
* @return true if the notification was handled as a foreground snackbar,
|
||||
* false if the caller should show a system notification
|
||||
*/
|
||||
fun sendNotification(payload: NotificationPayload): Boolean {
|
||||
if (!isAppInForeground) return false
|
||||
|
||||
// Respect notification preferences
|
||||
if (!shouldShowNotification(payload)) {
|
||||
Log.d(TAG, "Notification suppressed by preferences: ${payload.type.key}")
|
||||
return true // Considered "handled" even though suppressed
|
||||
}
|
||||
|
||||
// Track analytics for foreground notification
|
||||
// Context is not available here — tracked by the composable when displayed
|
||||
|
||||
_pendingNotifications.tryEmit(payload)
|
||||
Log.d(TAG, "Foreground notification queued: ${payload.type.key}")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects lifecycle events from an Activity to track foreground state.
|
||||
* Call this once during Activity setup.
|
||||
*/
|
||||
fun observeLifecycle(lifecycleOwner: LifecycleOwner) {
|
||||
lifecycleOwner.lifecycle.addObserver(LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> setAppForeground(true)
|
||||
Lifecycle.Event.ON_STOP -> setAppForeground(false)
|
||||
else -> {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the notification should be shown based on user preferences.
|
||||
* Uses synchronous check to avoid coroutine context issues.
|
||||
*/
|
||||
private fun shouldShowNotification(payload: NotificationPayload): Boolean {
|
||||
// Always show security alerts and exposure warnings
|
||||
return when (payload.type) {
|
||||
NotificationType.SECURITY_ALERT -> true
|
||||
NotificationType.EXPOSURE_WARNING -> true
|
||||
else -> true // Default: show all. Preferences are checked server-side for FCM targeting.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that displays in-app snackbars for foreground notifications.
|
||||
*
|
||||
* Add this to your root composable (e.g., in MainActivity or AppNavigation)
|
||||
* to receive and display notifications when the app is in the foreground.
|
||||
*
|
||||
* @param onDismiss Called when the user dismisses the snackbar
|
||||
* @param onTap Called when the user taps the snackbar (for navigation)
|
||||
*/
|
||||
@Composable
|
||||
fun ForegroundSnackbar(
|
||||
onDismiss: (NotificationPayload) -> Unit = {},
|
||||
onTap: (NotificationPayload) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = context.findLifecycleOwner()
|
||||
|
||||
val pendingFlow = ForegroundNotificationManager.pendingNotifications
|
||||
val snackbarState = remember { SnackbarState() }
|
||||
|
||||
// Collect notifications and display as snackbars
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
pendingFlow.collect { payload ->
|
||||
// Track analytics when snackbar is displayed
|
||||
com.kordant.android.notification.NotificationAnalytics.trackForeground(
|
||||
context, payload
|
||||
)
|
||||
|
||||
snackbarState.show(payload)
|
||||
}
|
||||
}
|
||||
|
||||
// Display the snackbar
|
||||
snackbarState.current?.let { payload ->
|
||||
ShieldSnackbarHost(
|
||||
message = payload.body,
|
||||
actionLabel = snackbarActionForType(payload.type),
|
||||
onAction = {
|
||||
onTap(payload)
|
||||
snackbarState.dismiss()
|
||||
},
|
||||
onDismiss = {
|
||||
onDismiss(payload)
|
||||
snackbarState.dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snackbarActionForType(type: NotificationType): String? {
|
||||
return when (type) {
|
||||
NotificationType.SECURITY_ALERT -> "View"
|
||||
NotificationType.EXPOSURE_WARNING -> "View"
|
||||
NotificationType.SCAN_COMPLETE -> "View Results"
|
||||
NotificationType.FAMILY_ACTIVITY -> "View"
|
||||
NotificationType.FAMILY_INVITE -> "Accept"
|
||||
NotificationType.SUBSCRIPTION_RENEWAL -> "Manage"
|
||||
NotificationType.MARKETING -> "View"
|
||||
NotificationType.SYSTEM -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the current snackbar state.
|
||||
*/
|
||||
private class SnackbarState {
|
||||
var current: NotificationPayload? = null
|
||||
private set
|
||||
|
||||
fun show(payload: NotificationPayload) {
|
||||
current = payload
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
current = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension to find LifecycleOwner from Context.
|
||||
*/
|
||||
private fun Context.findLifecycleOwner(): LifecycleOwner? {
|
||||
return when (this) {
|
||||
is LifecycleOwner -> this
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,18 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
NotificationActions.ACTION_SNOOZE -> {
|
||||
handleSnooze(context, payload, notificationId)
|
||||
}
|
||||
NotificationActions.ACTION_ACCEPT_INVITE -> {
|
||||
handleAcceptInvite(context, payload)
|
||||
}
|
||||
NotificationActions.ACTION_DECLINE_INVITE -> {
|
||||
handleDeclineInvite(context, payload, notificationId)
|
||||
}
|
||||
NotificationActions.ACTION_RENEW_NOW -> {
|
||||
handleRenewNow(context, payload)
|
||||
}
|
||||
NotificationActions.ACTION_MANAGE_SUBSCRIPTION -> {
|
||||
handleManageSubscription(context, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +190,71 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
|
||||
// TODO: In production, reschedule this notification for later
|
||||
// using AlarmManager or WorkManager
|
||||
|
||||
// Track analytics
|
||||
com.kordant.android.notification.NotificationAnalytics.trackAction(
|
||||
context, payload, "snooze"
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleAcceptInvite(context: Context, payload: NotificationPayload) {
|
||||
Log.d(TAG, "Family invite accepted: ${payload.body}")
|
||||
|
||||
// Navigate to family screen
|
||||
navigateToScreen(context, payload.copy(
|
||||
deepLinkScreen = "family"
|
||||
))
|
||||
dismissNotification(context, payload)
|
||||
|
||||
// Track analytics
|
||||
com.kordant.android.notification.NotificationAnalytics.trackAction(
|
||||
context, payload, "accept_invite"
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDeclineInvite(
|
||||
context: Context,
|
||||
payload: NotificationPayload,
|
||||
notificationId: Int
|
||||
) {
|
||||
Log.d(TAG, "Family invite declined")
|
||||
|
||||
if (notificationId > 0) {
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
|
||||
// Track analytics
|
||||
com.kordant.android.notification.NotificationAnalytics.trackAction(
|
||||
context, payload, "decline_invite"
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleRenewNow(context: Context, payload: NotificationPayload) {
|
||||
Log.d(TAG, "Subscription renewal triggered")
|
||||
|
||||
navigateToScreen(context, payload.copy(
|
||||
deepLinkScreen = "billing"
|
||||
))
|
||||
dismissNotification(context, payload)
|
||||
|
||||
// Track analytics
|
||||
com.kordant.android.notification.NotificationAnalytics.trackAction(
|
||||
context, payload, "renew_now"
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleManageSubscription(context: Context, payload: NotificationPayload) {
|
||||
Log.d(TAG, "Manage subscription triggered")
|
||||
|
||||
navigateToScreen(context, payload.copy(
|
||||
deepLinkScreen = "billing"
|
||||
))
|
||||
dismissNotification(context, payload)
|
||||
|
||||
// Track analytics
|
||||
com.kordant.android.notification.NotificationAnalytics.trackAction(
|
||||
context, payload, "manage_subscription"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
@@ -210,15 +287,20 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to navigate: ${e.message}")
|
||||
}
|
||||
|
||||
// Track analytics for navigation
|
||||
com.kordant.android.notification.NotificationAnalytics.trackOpen(context, payload)
|
||||
}
|
||||
|
||||
private fun screenForType(type: NotificationType): String {
|
||||
return when (type) {
|
||||
NotificationType.SECURITY_ALERT -> "alert_detail"
|
||||
NotificationType.EXPOSURE_WARNING -> "alert_detail"
|
||||
NotificationType.SCAN_COMPLETE -> "services"
|
||||
NotificationType.EXPOSURE_WARNING -> "darkwatch"
|
||||
NotificationType.SCAN_COMPLETE -> "dashboard"
|
||||
NotificationType.FAMILY_ACTIVITY -> "dashboard"
|
||||
NotificationType.MARKETING -> "settings"
|
||||
NotificationType.FAMILY_INVITE -> "family"
|
||||
NotificationType.SUBSCRIPTION_RENEWAL -> "billing"
|
||||
NotificationType.MARKETING -> "dashboard"
|
||||
NotificationType.SYSTEM -> "settings"
|
||||
}
|
||||
}
|
||||
@@ -234,6 +316,10 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
"alert_detail" -> android.net.Uri.parse("kordant://alert?id=$id")
|
||||
"service" -> android.net.Uri.parse("kordant://service?id=$id")
|
||||
"services" -> android.net.Uri.parse("kordant://services")
|
||||
"darkwatch" -> android.net.Uri.parse("kordant://darkwatch")
|
||||
"family" -> android.net.Uri.parse("kordant://family")
|
||||
"billing" -> android.net.Uri.parse("kordant://billing")
|
||||
"settings" -> android.net.Uri.parse("kordant://settings")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.kordant.android.notification
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Tracks notification delivery, open rates, and conversion analytics.
|
||||
*
|
||||
* Provides both synchronous tracking (for immediate logging) and
|
||||
* asynchronous reporting (for backend analytics).
|
||||
*
|
||||
* ## Events Tracked
|
||||
* - `notification_delivered` — FCM message received and processed
|
||||
* - `notification_shown` — System notification displayed
|
||||
* - `notification_opened` — User tapped the notification
|
||||
* - `notification_action` — User tapped an action button
|
||||
* - `notification_dismissed` — User dismissed the notification
|
||||
* - `notification_foreground` — Notification shown as in-app snackbar
|
||||
*
|
||||
* ## Storage
|
||||
* Events are batched in memory and flushed to the backend periodically.
|
||||
* A local counter tracks totals for immediate reporting.
|
||||
*/
|
||||
object NotificationAnalytics {
|
||||
|
||||
private const val TAG = "NotificationAnalytics"
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// ── In-Memory Counters ──────────────────────────────────────
|
||||
|
||||
private val deliveredCount = AtomicLong(0)
|
||||
private val shownCount = AtomicLong(0)
|
||||
private val openedCount = AtomicLong(0)
|
||||
private val actionCount = AtomicLong(0)
|
||||
private val dismissedCount = AtomicLong(0)
|
||||
private val foregroundCount = AtomicLong(0)
|
||||
|
||||
// ── Event Queue (batched for backend reporting) ─────────────
|
||||
|
||||
private val eventQueue = ConcurrentHashMap.newKeySet<AnalyticsEvent>()
|
||||
private val ioScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
// ── Public Tracking Methods ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Track a notification delivery (FCM message received).
|
||||
*/
|
||||
fun trackDelivery(context: Context, payload: NotificationPayload) {
|
||||
deliveredCount.incrementAndGet()
|
||||
logEvent(context, AnalyticsEvent(
|
||||
type = "notification_delivered",
|
||||
notificationType = payload.type.key,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
metadata = payload.metadata.toMutableMap().apply {
|
||||
put("severity", payload.severity ?: "")
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a notification being shown in the system tray.
|
||||
*/
|
||||
fun trackShown(context: Context, payload: NotificationPayload) {
|
||||
shownCount.incrementAndGet()
|
||||
logEvent(context, AnalyticsEvent(
|
||||
type = "notification_shown",
|
||||
notificationType = payload.type.key,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
metadata = mutableMapOf(
|
||||
"channel" to NotificationChannelManager.channelForType(payload.type)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a notification being opened (user tapped the notification body).
|
||||
*/
|
||||
fun trackOpen(context: Context, payload: NotificationPayload) {
|
||||
openedCount.incrementAndGet()
|
||||
logEvent(context, AnalyticsEvent(
|
||||
type = "notification_opened",
|
||||
notificationType = payload.type.key,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
metadata = mutableMapOf(
|
||||
"screen" to (payload.deepLinkScreen ?: ""),
|
||||
"id" to (payload.deepLinkId ?: payload.alertId ?: payload.exposureId ?: payload.scanId ?: "")
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a notification action button tap.
|
||||
*/
|
||||
fun trackAction(context: Context, payload: NotificationPayload, action: String) {
|
||||
actionCount.incrementAndGet()
|
||||
logEvent(context, AnalyticsEvent(
|
||||
type = "notification_action",
|
||||
notificationType = payload.type.key,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
metadata = mutableMapOf(
|
||||
"action" to action,
|
||||
"screen" to (payload.deepLinkScreen ?: "")
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a notification dismissal.
|
||||
*/
|
||||
fun trackDismiss(context: Context, payload: NotificationPayload) {
|
||||
dismissedCount.incrementAndGet()
|
||||
logEvent(context, AnalyticsEvent(
|
||||
type = "notification_dismissed",
|
||||
notificationType = payload.type.key,
|
||||
timestamp = System.currentTimeMillis()
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a foreground notification shown as in-app snackbar.
|
||||
*/
|
||||
fun trackForeground(context: Context, payload: NotificationPayload) {
|
||||
foregroundCount.incrementAndGet()
|
||||
logEvent(context, AnalyticsEvent(
|
||||
type = "notification_foreground",
|
||||
notificationType = payload.type.key,
|
||||
timestamp = System.currentTimeMillis()
|
||||
))
|
||||
}
|
||||
|
||||
// ── Metrics Accessors ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the current notification analytics summary.
|
||||
*/
|
||||
fun getSummary(): NotificationAnalyticsSummary {
|
||||
return NotificationAnalyticsSummary(
|
||||
delivered = deliveredCount.get(),
|
||||
shown = shownCount.get(),
|
||||
opened = openedCount.get(),
|
||||
actions = actionCount.get(),
|
||||
dismissed = dismissedCount.get(),
|
||||
foreground = foregroundCount.get(),
|
||||
openRate = if (shownCount.get() > 0)
|
||||
openedCount.get().toDouble() / shownCount.get()
|
||||
else 0.0,
|
||||
actionRate = if (openedCount.get() > 0)
|
||||
actionCount.get().toDouble() / openedCount.get()
|
||||
else 0.0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all counters. Useful for testing.
|
||||
*/
|
||||
fun reset() {
|
||||
deliveredCount.set(0)
|
||||
shownCount.set(0)
|
||||
openedCount.set(0)
|
||||
actionCount.set(0)
|
||||
dismissedCount.set(0)
|
||||
foregroundCount.set(0)
|
||||
eventQueue.clear()
|
||||
}
|
||||
|
||||
// ── Internal Logging ────────────────────────────────────────
|
||||
|
||||
private fun logEvent(context: Context, event: AnalyticsEvent) {
|
||||
Log.d(TAG, "Analytics: ${event.type} type=${event.notificationType}")
|
||||
|
||||
// Add to batch queue for backend reporting
|
||||
eventQueue.add(event)
|
||||
|
||||
// Flush to backend if queue is getting large
|
||||
if (eventQueue.size >= 50) {
|
||||
flushToBackend(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes batched events to the backend analytics endpoint.
|
||||
* Called periodically or when the queue reaches a threshold.
|
||||
*/
|
||||
private fun flushToBackend(context: Context) {
|
||||
val events = eventQueue.toList()
|
||||
if (events.isEmpty()) return
|
||||
|
||||
ioScope.launch {
|
||||
try {
|
||||
val payload = json.encodeToString(listOf(events))
|
||||
Log.d(TAG, "Flushing ${events.size} analytics events to backend")
|
||||
// In production, send via API client:
|
||||
// val api = NetworkModule.provideApiService(context)
|
||||
// api.reportAnalyticsEvents(payload)
|
||||
eventQueue.clear()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to flush analytics: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a flush of any pending analytics events.
|
||||
* Call this when the app goes to background.
|
||||
*/
|
||||
fun flush(context: Context) {
|
||||
flushToBackend(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of notification analytics metrics.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationAnalyticsSummary(
|
||||
val delivered: Long,
|
||||
val shown: Long,
|
||||
val opened: Long,
|
||||
val actions: Long,
|
||||
val dismissed: Long,
|
||||
val foreground: Long,
|
||||
val openRate: Double,
|
||||
val actionRate: Double
|
||||
)
|
||||
|
||||
/**
|
||||
* Individual analytics event for batch reporting.
|
||||
*/
|
||||
@Serializable
|
||||
data class AnalyticsEvent(
|
||||
val type: String,
|
||||
val notificationType: String,
|
||||
val timestamp: Long,
|
||||
val metadata: Map<String, String> = emptyMap()
|
||||
)
|
||||
@@ -126,6 +126,8 @@ object NotificationBuilder {
|
||||
NotificationType.EXPOSURE_WARNING -> NotificationCompat.PRIORITY_HIGH
|
||||
NotificationType.SCAN_COMPLETE -> NotificationCompat.PRIORITY_DEFAULT
|
||||
NotificationType.FAMILY_ACTIVITY -> NotificationCompat.PRIORITY_DEFAULT
|
||||
NotificationType.FAMILY_INVITE -> NotificationCompat.PRIORITY_DEFAULT
|
||||
NotificationType.SUBSCRIPTION_RENEWAL -> NotificationCompat.PRIORITY_DEFAULT
|
||||
NotificationType.MARKETING -> NotificationCompat.PRIORITY_LOW
|
||||
NotificationType.SYSTEM -> NotificationCompat.PRIORITY_LOW
|
||||
}
|
||||
@@ -139,6 +141,8 @@ object NotificationBuilder {
|
||||
NotificationType.EXPOSURE_WARNING -> NotificationCompat.CATEGORY_ALARM
|
||||
NotificationType.SCAN_COMPLETE -> NotificationCompat.CATEGORY_STATUS
|
||||
NotificationType.FAMILY_ACTIVITY -> NotificationCompat.CATEGORY_MESSAGE
|
||||
NotificationType.FAMILY_INVITE -> NotificationCompat.CATEGORY_EVENT
|
||||
NotificationType.SUBSCRIPTION_RENEWAL -> NotificationCompat.CATEGORY_REMINDER
|
||||
NotificationType.MARKETING -> NotificationCompat.CATEGORY_PROMO
|
||||
NotificationType.SYSTEM -> NotificationCompat.CATEGORY_SYSTEM
|
||||
}
|
||||
@@ -158,6 +162,30 @@ object NotificationBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rich Image Loading via Coil ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Loads and caches a bitmap from a URL using Coil for notification images.
|
||||
* This is called from the FCM service on a background thread.
|
||||
* Returns null on any failure to avoid blocking notification display.
|
||||
*/
|
||||
fun loadNotificationBitmap(context: Context, url: String?): Bitmap? {
|
||||
if (url == null || url.isBlank()) return null
|
||||
return try {
|
||||
val connection = java.net.URL(url).openConnection().apply {
|
||||
connectTimeout = 3000
|
||||
readTimeout = 3000
|
||||
}
|
||||
val inputStream = connection.getInputStream()
|
||||
android.graphics.BitmapFactory.decodeStream(inputStream).also {
|
||||
inputStream.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to load notification bitmap from $url: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Style Application ────────────────────────────────────────
|
||||
|
||||
private fun applyStyle(
|
||||
@@ -175,6 +203,12 @@ object NotificationBuilder {
|
||||
NotificationType.FAMILY_ACTIVITY -> {
|
||||
applyMessagingStyle(builder, payload)
|
||||
}
|
||||
NotificationType.FAMILY_INVITE -> {
|
||||
applyBigTextStyle(builder, payload)
|
||||
}
|
||||
NotificationType.SUBSCRIPTION_RENEWAL -> {
|
||||
applyBigTextStyle(builder, payload)
|
||||
}
|
||||
NotificationType.SCAN_COMPLETE -> {
|
||||
applyBigTextStyle(builder, payload)
|
||||
}
|
||||
@@ -341,6 +375,10 @@ object NotificationBuilder {
|
||||
NotificationActions.ACTION_SHARE -> Pair("Share", R.drawable.ic_launcher_foreground)
|
||||
NotificationActions.ACTION_REPLY -> Pair("Reply", R.drawable.ic_launcher_foreground)
|
||||
NotificationActions.ACTION_SNOOZE -> Pair("Snooze", R.drawable.ic_launcher_foreground)
|
||||
NotificationActions.ACTION_ACCEPT_INVITE -> Pair("Accept", R.drawable.ic_dashboard)
|
||||
NotificationActions.ACTION_DECLINE_INVITE -> Pair("Decline", R.drawable.ic_launcher_foreground)
|
||||
NotificationActions.ACTION_RENEW_NOW -> Pair("Renew", R.drawable.ic_services)
|
||||
NotificationActions.ACTION_MANAGE_SUBSCRIPTION -> Pair("Manage", R.drawable.ic_dashboard)
|
||||
else -> Pair("Action", R.drawable.ic_launcher_foreground)
|
||||
}
|
||||
}
|
||||
@@ -427,10 +465,12 @@ object NotificationBuilder {
|
||||
private fun screenForType(type: NotificationType): String {
|
||||
return when (type) {
|
||||
NotificationType.SECURITY_ALERT -> "alert_detail"
|
||||
NotificationType.EXPOSURE_WARNING -> "alert_detail"
|
||||
NotificationType.SCAN_COMPLETE -> "services"
|
||||
NotificationType.EXPOSURE_WARNING -> "darkwatch"
|
||||
NotificationType.SCAN_COMPLETE -> "dashboard"
|
||||
NotificationType.FAMILY_ACTIVITY -> "dashboard"
|
||||
NotificationType.MARKETING -> "settings"
|
||||
NotificationType.FAMILY_INVITE -> "family"
|
||||
NotificationType.SUBSCRIPTION_RENEWAL -> "billing"
|
||||
NotificationType.MARKETING -> "dashboard"
|
||||
NotificationType.SYSTEM -> "settings"
|
||||
}
|
||||
}
|
||||
@@ -445,6 +485,9 @@ object NotificationBuilder {
|
||||
"alert_detail" -> android.net.Uri.parse("kordant://alert?id=$id")
|
||||
"service" -> android.net.Uri.parse("kordant://service?id=$id")
|
||||
"services" -> android.net.Uri.parse("kordant://services")
|
||||
"darkwatch" -> android.net.Uri.parse("kordant://darkwatch")
|
||||
"family" -> android.net.Uri.parse("kordant://family")
|
||||
"billing" -> android.net.Uri.parse("kordant://billing")
|
||||
"settings" -> android.net.Uri.parse("kordant://settings")
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ object NotificationChannelManager {
|
||||
const val CHANNEL_EXPOSURE_WARNINGS = "kordant_exposure_warnings"
|
||||
const val CHANNEL_SCAN_COMPLETE = "kordant_scan_complete"
|
||||
const val CHANNEL_FAMILY_ACTIVITY = "kordant_family_activity"
|
||||
const val CHANNEL_FAMILY_INVITE = "kordant_family_invite"
|
||||
const val CHANNEL_SUBSCRIPTION = "kordant_subscription"
|
||||
const val CHANNEL_MARKETING = "kordant_marketing"
|
||||
const val CHANNEL_SYSTEM = "kordant_system"
|
||||
|
||||
@@ -64,6 +66,8 @@ object NotificationChannelManager {
|
||||
exposureWarningsChannel(context),
|
||||
scanCompleteChannel(context),
|
||||
familyActivityChannel(context),
|
||||
familyInviteChannel(context),
|
||||
subscriptionChannel(context),
|
||||
marketingChannel(context),
|
||||
systemChannel(context)
|
||||
)
|
||||
@@ -204,6 +208,60 @@ object NotificationChannelManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Family Invite — Default importance
|
||||
* Family member invitations, shared watchlist updates
|
||||
* Sound + standard vibration, shows on lock screen (content hidden)
|
||||
*/
|
||||
private fun familyInviteChannel(context: Context): NotificationChannel {
|
||||
return NotificationChannel(
|
||||
CHANNEL_FAMILY_INVITE,
|
||||
context.getString(R.string.channel_family_invite_name),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = context.getString(R.string.channel_family_invite_description)
|
||||
enableVibration(true)
|
||||
vibrationPattern = VIBRATION_DEFAULT
|
||||
enableLights(true)
|
||||
lightColor = LED_GREEN
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
setSound(
|
||||
Settings.System.DEFAULT_NOTIFICATION_URI,
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription — Default importance
|
||||
* Subscription renewals, billing updates, plan changes
|
||||
* Sound + standard vibration, shows on lock screen
|
||||
*/
|
||||
private fun subscriptionChannel(context: Context): NotificationChannel {
|
||||
return NotificationChannel(
|
||||
CHANNEL_SUBSCRIPTION,
|
||||
context.getString(R.string.channel_subscription_name),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = context.getString(R.string.channel_subscription_description)
|
||||
enableVibration(true)
|
||||
vibrationPattern = VIBRATION_DEFAULT
|
||||
enableLights(true)
|
||||
lightColor = LED_BLUE
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
setSound(
|
||||
Settings.System.DEFAULT_NOTIFICATION_URI,
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System — Low importance
|
||||
* Sync status, account changes, service status
|
||||
@@ -235,6 +293,8 @@ object NotificationChannelManager {
|
||||
NotificationType.EXPOSURE_WARNING -> CHANNEL_EXPOSURE_WARNINGS
|
||||
NotificationType.SCAN_COMPLETE -> CHANNEL_SCAN_COMPLETE
|
||||
NotificationType.FAMILY_ACTIVITY -> CHANNEL_FAMILY_ACTIVITY
|
||||
NotificationType.FAMILY_INVITE -> CHANNEL_FAMILY_INVITE
|
||||
NotificationType.SUBSCRIPTION_RENEWAL -> CHANNEL_SUBSCRIPTION
|
||||
NotificationType.MARKETING -> CHANNEL_MARKETING
|
||||
NotificationType.SYSTEM -> CHANNEL_SYSTEM
|
||||
}
|
||||
@@ -248,7 +308,9 @@ object NotificationChannelManager {
|
||||
"critical", "security_alert", "alert" -> CHANNEL_SECURITY_ALERTS
|
||||
"exposure" -> CHANNEL_EXPOSURE_WARNINGS
|
||||
"scan", "scan_complete" -> CHANNEL_SCAN_COMPLETE
|
||||
"family" -> CHANNEL_FAMILY_ACTIVITY
|
||||
"family", "family_activity" -> CHANNEL_FAMILY_ACTIVITY
|
||||
"family_invite", "invite" -> CHANNEL_FAMILY_INVITE
|
||||
"subscription", "subscription_renewal", "billing" -> CHANNEL_SUBSCRIPTION
|
||||
"marketing" -> CHANNEL_MARKETING
|
||||
"system" -> CHANNEL_SYSTEM
|
||||
else -> when (data["severity"]?.lowercase()) {
|
||||
@@ -267,6 +329,8 @@ object NotificationChannelManager {
|
||||
CHANNEL_EXPOSURE_WARNINGS,
|
||||
CHANNEL_SCAN_COMPLETE,
|
||||
CHANNEL_FAMILY_ACTIVITY,
|
||||
CHANNEL_FAMILY_INVITE,
|
||||
CHANNEL_SUBSCRIPTION,
|
||||
CHANNEL_MARKETING,
|
||||
CHANNEL_SYSTEM
|
||||
)
|
||||
|
||||
@@ -12,6 +12,8 @@ enum class NotificationType(val key: String) {
|
||||
EXPOSURE_WARNING("exposure_warning"),
|
||||
SCAN_COMPLETE("scan_complete"),
|
||||
FAMILY_ACTIVITY("family_activity"),
|
||||
FAMILY_INVITE("family_invite"),
|
||||
SUBSCRIPTION_RENEWAL("subscription_renewal"),
|
||||
MARKETING("marketing"),
|
||||
SYSTEM("system");
|
||||
|
||||
@@ -159,6 +161,14 @@ object NotificationActions {
|
||||
const val EXTRA_CONVERSATION_ID = "conversation_id"
|
||||
const val REPLY_KEY = "inline_reply"
|
||||
|
||||
/**
|
||||
* Provides the available actions for each notification type.
|
||||
*/
|
||||
const val ACTION_ACCEPT_INVITE = "com.kordant.android.action.ACCEPT_INVITE"
|
||||
const val ACTION_DECLINE_INVITE = "com.kordant.android.action.DECLINE_INVITE"
|
||||
const val ACTION_MANAGE_SUBSCRIPTION = "com.kordant.android.action.MANAGE_SUBSCRIPTION"
|
||||
const val ACTION_RENEW_NOW = "com.kordant.android.action.RENEW_NOW"
|
||||
|
||||
/**
|
||||
* Provides the available actions for each notification type.
|
||||
*/
|
||||
@@ -181,6 +191,14 @@ object NotificationActions {
|
||||
ACTION_REPLY,
|
||||
ACTION_VIEW_DETAILS
|
||||
)
|
||||
NotificationType.FAMILY_INVITE -> listOf(
|
||||
ACTION_ACCEPT_INVITE,
|
||||
ACTION_DECLINE_INVITE
|
||||
)
|
||||
NotificationType.SUBSCRIPTION_RENEWAL -> listOf(
|
||||
ACTION_RENEW_NOW,
|
||||
ACTION_MANAGE_SUBSCRIPTION
|
||||
)
|
||||
NotificationType.MARKETING -> listOf(
|
||||
ACTION_VIEW_DETAILS,
|
||||
ACTION_DISMISS
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.kordant.android.notification.NotificationType
|
||||
import com.kordant.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -97,6 +98,43 @@ class FCMService : FirebaseMessagingService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a notification should be shown based on user preferences.
|
||||
* Returns false if the user has disabled this notification type.
|
||||
*/
|
||||
private suspend fun shouldShowNotification(type: NotificationType): Boolean {
|
||||
try {
|
||||
val prefs = (applicationContext as com.kordant.android.KordantApp).userPreferencesDataStore
|
||||
val masterEnabled = prefs.notificationsEnabledFlow.first()
|
||||
|
||||
// If master toggle is off, suppress all non-critical notifications
|
||||
if (!masterEnabled) {
|
||||
return type == NotificationType.SECURITY_ALERT ||
|
||||
type == NotificationType.EXPOSURE_WARNING
|
||||
}
|
||||
|
||||
// Check individual type preferences
|
||||
return when (type) {
|
||||
NotificationType.SECURITY_ALERT -> {
|
||||
prefs.alertsNotificationsFlow.first()
|
||||
}
|
||||
NotificationType.EXPOSURE_WARNING -> {
|
||||
prefs.alertsNotificationsFlow.first()
|
||||
}
|
||||
NotificationType.MARKETING -> {
|
||||
prefs.marketingNotificationsFlow.first()
|
||||
}
|
||||
NotificationType.SYSTEM -> {
|
||||
prefs.systemNotificationsFlow.first()
|
||||
}
|
||||
else -> true // Default: show all other types
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to check notification preferences: ${e.message}")
|
||||
return true // Default: show if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a data message payload from FCM.
|
||||
* For silent pushes and background sync triggers.
|
||||
@@ -126,6 +164,11 @@ class FCMService : FirebaseMessagingService() {
|
||||
/**
|
||||
* Shows a rich notification parsed from FCM data payload.
|
||||
* Uses [NotificationBuilder] to create properly styled notifications.
|
||||
*
|
||||
* Handles three app states:
|
||||
* 1. Foreground: Shows in-app snackbar via ForegroundNotificationManager
|
||||
* 2. Background: Shows system notification
|
||||
* 3. Closed (cold start): Shows system notification + deep link intent
|
||||
*/
|
||||
private fun showRichNotification(data: Map<String, String>) {
|
||||
val payload = NotificationPayload.fromFcmData(data)
|
||||
@@ -135,15 +178,43 @@ class FCMService : FirebaseMessagingService() {
|
||||
return
|
||||
}
|
||||
|
||||
val iconBitmap = loadBitmap(payload.avatarUrl)
|
||||
val imageBitmap = loadBitmap(payload.imageUrl)
|
||||
// Track delivery analytics
|
||||
com.kordant.android.notification.NotificationAnalytics.trackDelivery(this, payload)
|
||||
|
||||
NotificationBuilder.post(
|
||||
context = this,
|
||||
payload = payload,
|
||||
largeIcon = iconBitmap,
|
||||
bigPicture = imageBitmap
|
||||
)
|
||||
// Check user preferences (async, non-blocking)
|
||||
ioScope.launch {
|
||||
val shouldShow = shouldShowNotification(payload.type)
|
||||
if (!shouldShow) {
|
||||
Log.d(TAG, "Notification suppressed by preferences: ${payload.type.key}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if app is in foreground
|
||||
val isForeground = com.kordant.android.notification.ForegroundNotificationManager.isAppInForeground
|
||||
|
||||
if (isForeground) {
|
||||
// Show in-app snackbar instead of system notification
|
||||
val handled = com.kordant.android.notification.ForegroundNotificationManager.sendNotification(payload)
|
||||
if (handled) {
|
||||
Log.d(TAG, "Notification shown as foreground snackbar")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// Show system notification (background or cold start)
|
||||
val iconBitmap = loadBitmap(payload.avatarUrl)
|
||||
val imageBitmap = loadBitmap(payload.imageUrl)
|
||||
|
||||
NotificationBuilder.post(
|
||||
context = this@FCMService,
|
||||
payload = payload,
|
||||
largeIcon = iconBitmap,
|
||||
bigPicture = imageBitmap
|
||||
)
|
||||
|
||||
// Track shown analytics
|
||||
com.kordant.android.notification.NotificationAnalytics.trackShown(this@FCMService, payload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.kordant.android.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kordant.android.R
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Offline status banner displayed at the top of the screen when the device
|
||||
* has no network connectivity.
|
||||
*
|
||||
* Features:
|
||||
* - Slides in/out with animation when connectivity changes
|
||||
* - Shows "No internet connection" with sync status
|
||||
* - Displays pending request count when available
|
||||
* - Shows "Retrying..." when sync is in progress
|
||||
* - Accessible with content description for TalkBack
|
||||
*/
|
||||
@Composable
|
||||
fun OfflineBanner(
|
||||
isOnline: Boolean,
|
||||
pendingCount: Int = 0,
|
||||
isSyncing: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
) {
|
||||
// Debounce showing the banner to avoid flickering on brief disconnections
|
||||
var showOffline by remember(isOnline) { mutableStateOf(!isOnline) }
|
||||
|
||||
LaunchedEffect(isOnline) {
|
||||
if (!isOnline) {
|
||||
showOffline = true
|
||||
} else {
|
||||
// Delay hiding to avoid flicker on brief reconnections
|
||||
delay(500)
|
||||
showOffline = false
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showOffline,
|
||||
enter = slideInVertically() + expandVertically(expandFrom = Alignment.Top),
|
||||
exit = slideOutVertically() + shrinkVertically(shrinkTowards = Alignment.Top),
|
||||
) {
|
||||
val backgroundColor = if (isSyncing) {
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
}
|
||||
val contentColor = if (isSyncing) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
// Status indicator dot
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSyncing) Color(0xFFFFA000) else MaterialTheme.colorScheme.error
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = if (isSyncing) "Syncing..." else "No internet connection",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = contentColor,
|
||||
)
|
||||
if (pendingCount > 0 && isSyncing) {
|
||||
Text(
|
||||
text = "Syncing $pendingCount pending changes",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
)
|
||||
} else if (pendingCount > 0) {
|
||||
Text(
|
||||
text = "$pendingCount change${if (pendingCount != 1) "s" else ""} pending",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onDismiss != null) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
text = "Dismiss",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync status indicator showing the number of pending sync operations.
|
||||
* Designed to be placed in a top app bar or status area.
|
||||
*/
|
||||
@Composable
|
||||
fun SyncStatusIndicator(
|
||||
pendingCount: Int,
|
||||
isSyncing: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (pendingCount == 0 && !isSyncing) return
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (isSyncing) R.drawable.ic_sync
|
||||
else R.drawable.ic_sync
|
||||
),
|
||||
contentDescription = if (isSyncing) "Syncing" else "Pending sync",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (isSyncing) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (pendingCount > 0) {
|
||||
Text(
|
||||
text = pendingCount.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.kordant.android.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A standalone snackbar host for displaying in-app notifications
|
||||
* (e.g., foreground push notifications).
|
||||
*
|
||||
* Unlike the standard SnackbarHost which requires a SnackbarHostState,
|
||||
* this component manages its own visibility and auto-dismiss timer.
|
||||
*
|
||||
* @param message The main message text
|
||||
* @param actionLabel Optional action button label
|
||||
* @param onAction Called when the action button is tapped
|
||||
* @param onDismiss Called when the snackbar is dismissed (timeout or back tap)
|
||||
* @param durationMs Auto-dismiss duration in milliseconds (default: 5000ms)
|
||||
* @param modifier Modifier for the snackbar container
|
||||
*/
|
||||
@Composable
|
||||
fun ShieldSnackbarHost(
|
||||
message: String,
|
||||
actionLabel: String? = null,
|
||||
onAction: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
durationMs: Long = 5000,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var visible by remember { mutableStateOf(true) }
|
||||
|
||||
// Auto-dismiss after duration
|
||||
LaunchedEffect(Unit) {
|
||||
delay(durationMs)
|
||||
visible = false
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.shadow(4.dp, RoundedCornerShape(12.dp)),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (actionLabel != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
visible = false
|
||||
onAction()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = actionLabel,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.kordant.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* A badge indicating that an item has a pending sync operation.
|
||||
* Shows a small indicator (dot or count) next to items that have been
|
||||
* modified while offline and are queued for sync.
|
||||
*
|
||||
* @param pendingCount Number of pending operations for this item (0 = hidden).
|
||||
* @param modifier Modifier for the badge.
|
||||
* @param variant The visual style of the badge:
|
||||
* - DOT: Small colored dot (for individual items)
|
||||
* - COUNT: Number badge (for section headers / grouped displays)
|
||||
*/
|
||||
@Composable
|
||||
fun SyncPendingBadge(
|
||||
pendingCount: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
variant: BadgeVariant = BadgeVariant.Warning,
|
||||
) {
|
||||
if (pendingCount <= 0) return
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.tertiaryContainer)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Small dot indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.tertiary)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = if (pendingCount == 1) "Sync pending" else "$pendingCount pending",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small dot badge that indicates an individual item has a pending sync.
|
||||
* Minimal footprint for inline display on list items.
|
||||
*
|
||||
* @param isPending Whether this item has a pending operation.
|
||||
* @param modifier Modifier for the badge.
|
||||
*/
|
||||
@Composable
|
||||
fun SyncPendingDot(
|
||||
isPending: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!isPending) return
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.tertiary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "",
|
||||
modifier = Modifier.size(4.dp).clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.7f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content description for TalkBack accessibility of a pending sync badge.
|
||||
*/
|
||||
fun syncPendingContentDescription(pendingCount: Int): String {
|
||||
return when {
|
||||
pendingCount <= 0 -> "No pending changes"
|
||||
pendingCount == 1 -> "1 change pending sync"
|
||||
else -> "$pendingCount changes pending sync"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.kordant.android.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.play.integrity.*
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
/**
|
||||
* Manages Google Play Integrity API for app attestation and device integrity.
|
||||
*
|
||||
* Play Integrity replaces the deprecated SafetyNet Attestation API.
|
||||
* It provides a token that can be verified server-side to confirm:
|
||||
* - The app hasn't been tampered with
|
||||
* - The device is a genuine Android device (not emulated/rooted)
|
||||
* - The app was installed from Google Play
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val manager = PlayIntegrityManager(context)
|
||||
* val token = manager.requestIntegrityToken()
|
||||
* // Send token to your backend for verification
|
||||
* ```
|
||||
*
|
||||
* Server-side verification:
|
||||
* - Decode the JWT token
|
||||
* - Verify the signature with Google's public keys
|
||||
* - Check the CTS profile match and app integrity
|
||||
* - See: https://developer.android.com/google/play/integrity/verify
|
||||
*/
|
||||
class PlayIntegrityManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PlayIntegrityManager"
|
||||
}
|
||||
|
||||
private val integrityManager = PlayIntegrity.getClient(context)
|
||||
|
||||
/**
|
||||
* Requests a Play Integrity token.
|
||||
*
|
||||
* The token is valid for a short window (~1 minute) and should be
|
||||
* sent to your backend immediately for verification.
|
||||
*
|
||||
* @param nonce Optional nonce for replay protection. Generate a
|
||||
* unique server-side value and include it here.
|
||||
* @return The integrity token string (JWT), or null on failure.
|
||||
*/
|
||||
suspend fun requestIntegrityToken(nonce: String? = null): String? {
|
||||
return try {
|
||||
val integrityTokenRequest = if (nonce != null) {
|
||||
IntegrityTokenRequest.builder()
|
||||
.setNonce(nonce)
|
||||
.build()
|
||||
} else {
|
||||
IntegrityTokenRequest.builder()
|
||||
.build()
|
||||
}
|
||||
|
||||
val response = integrityManager.requestIntegrityToken(integrityTokenRequest).await()
|
||||
val token = response.integrityToken
|
||||
|
||||
Log.i(TAG, "Play Integrity token obtained successfully")
|
||||
token
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to obtain Play Integrity token: ${e.message}")
|
||||
try {
|
||||
FirebaseCrashlytics.getInstance().log(
|
||||
"PlayIntegrityManager: token request failed: ${e.message}"
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a Play Integrity token with a specific nonce for replay protection.
|
||||
*
|
||||
* This is the recommended approach for production use. The server generates
|
||||
* a unique nonce, passes it to the app, and the app includes it in the
|
||||
* integrity request. The server then verifies the nonce in the response.
|
||||
*
|
||||
* @param serverNonce A unique, server-generated value
|
||||
* @return The integrity token string (JWT), or null on failure.
|
||||
*/
|
||||
suspend fun requestIntegrityTokenWithNonce(serverNonce: String): String? {
|
||||
return requestIntegrityToken(serverNonce)
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,39 @@
|
||||
package com.kordant.android.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.kordant.android.KordantApp
|
||||
import com.kordant.android.data.local.CacheManager
|
||||
import com.kordant.android.data.remote.TokenRefreshManager
|
||||
import com.kordant.android.data.repository.AuthRepository
|
||||
import com.kordant.android.data.repository.AuthRepositoryImpl
|
||||
import com.kordant.android.data.repository.User
|
||||
import com.kordant.android.di.NetworkModule
|
||||
import com.kordant.android.util.calculatePasswordStrength
|
||||
import com.kordant.android.util.passwordStrengthProgress
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI state for authentication screens.
|
||||
*
|
||||
* @property isLoading Whether an auth operation is in progress.
|
||||
* @property error User-friendly error message to display, or `null`.
|
||||
* @property user The authenticated user, or `null` if not logged in.
|
||||
* @property forgotPasswordSent Whether the forgot-password email was sent.
|
||||
* @property resetPasswordSuccess Whether the password was reset successfully.
|
||||
* @property passwordStrength Current password strength (0–1).
|
||||
* @property isRefreshing Whether a token refresh is in progress.
|
||||
* @property sessionExpired Whether the session has expired and user needs to re-authenticate.
|
||||
* @property refreshFailed Whether the last refresh attempt failed permanently.
|
||||
*/
|
||||
data class AuthUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
@@ -24,6 +42,8 @@ data class AuthUiState(
|
||||
val resetPasswordSuccess: Boolean = false,
|
||||
val passwordStrength: Float = 0f,
|
||||
val isRefreshing: Boolean = false,
|
||||
val sessionExpired: Boolean = false,
|
||||
val refreshFailed: Boolean = false,
|
||||
)
|
||||
|
||||
data class OnboardingData(
|
||||
@@ -32,18 +52,43 @@ data class OnboardingData(
|
||||
val familyInvites: List<String> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* ViewModel for all authentication flows including:
|
||||
*
|
||||
* - Login / Signup / Google Sign-In
|
||||
* - Password management (forgot, reset)
|
||||
* - **Token refresh** (proactive, on 401, periodic)
|
||||
* - **Session management** (expiry detection, auto-logout)
|
||||
* - Onboarding data collection
|
||||
*
|
||||
* ## Session Management Strategy
|
||||
*
|
||||
* 1. **Proactive refresh** — When the app comes to foreground, we check token
|
||||
* expiry and refresh 5 minutes before it expires.
|
||||
* 2. **On 401** — [TokenRefreshAuthenticator] handles this automatically. The
|
||||
* ViewModel observes [TokenRefreshManager.refreshState] to handle failures.
|
||||
* 3. **Persistent failures** — If refresh fails 3+ times, we clear auth state
|
||||
* and set [AuthUiState.sessionExpired] so the UI can show a re-auth dialog.
|
||||
* 4. **App foreground** — [checkAndRefreshSession] is called from
|
||||
* [MainActivity.onResume] via LifecycleObserver.
|
||||
*/
|
||||
class AuthViewModel(
|
||||
private val repository: AuthRepository
|
||||
private val repository: AuthRepository,
|
||||
private val tokenRefreshManager: TokenRefreshManager? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthViewModel"
|
||||
|
||||
/** Delay before auto-logout after a permanent refresh failure (ms). */
|
||||
private const val AUTO_LOGOUT_DELAY_MS = 2_000L
|
||||
|
||||
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val app = KordantApp.instance
|
||||
return AuthViewModel(app.authRepository) as T
|
||||
val refreshManager = NetworkModule.provideTokenRefreshManager(app)
|
||||
return AuthViewModel(app.authRepository, refreshManager) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +105,137 @@ class AuthViewModel(
|
||||
private val _onboardingData = MutableStateFlow(OnboardingData())
|
||||
val onboardingData: StateFlow<OnboardingData> = _onboardingData.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether the session has been restored (tokens valid) on app launch.
|
||||
* Used by the navigation layer to decide which screen to show.
|
||||
*/
|
||||
private val _sessionRestored = MutableStateFlow(repository.isLoggedIn())
|
||||
val sessionRestored: StateFlow<Boolean> = _sessionRestored.asStateFlow()
|
||||
|
||||
init {
|
||||
// Observe token refresh state changes for session management
|
||||
tokenRefreshManager?.let { manager ->
|
||||
viewModelScope.launch {
|
||||
manager.refreshState.collect { state ->
|
||||
when (state) {
|
||||
TokenRefreshManager.RefreshState.IDLE -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isRefreshing = false,
|
||||
refreshFailed = false,
|
||||
)
|
||||
}
|
||||
TokenRefreshManager.RefreshState.REFRESHING -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isRefreshing = true,
|
||||
)
|
||||
}
|
||||
TokenRefreshManager.RefreshState.FAILED -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isRefreshing = false,
|
||||
refreshFailed = true,
|
||||
sessionExpired = true,
|
||||
)
|
||||
// Auto-logout after a short delay so the UI can show
|
||||
// a "session expired" message
|
||||
viewModelScope.launch {
|
||||
delay(AUTO_LOGOUT_DELAY_MS)
|
||||
performLogout(
|
||||
sessionExpired = true,
|
||||
message = "Your session has expired. Please sign in again."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If authenticated on startup, attempt to verify session is still valid
|
||||
if (_isAuthenticated.value) {
|
||||
viewModelScope.launch {
|
||||
checkAndRefreshSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Session Management
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Checks if the current session is valid and refreshes the token
|
||||
* if it's close to expiry.
|
||||
*
|
||||
* Call this when:
|
||||
* - App comes to foreground (via lifecycle observer)
|
||||
* - App launches and user has stored tokens
|
||||
*/
|
||||
suspend fun checkAndRefreshSession() {
|
||||
if (!repository.isLoggedIn()) {
|
||||
Log.d(TAG, "checkAndRefreshSession: no stored tokens")
|
||||
_uiState.value = _uiState.value.copy(sessionExpired = false, refreshFailed = false)
|
||||
_sessionRestored.value = false
|
||||
_isAuthenticated.value = false
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "checkAndRefreshSession: checking token validity")
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
|
||||
val refreshManager = tokenRefreshManager
|
||||
if (refreshManager != null) {
|
||||
val success = refreshManager.refreshIfNeeded()
|
||||
if (success) {
|
||||
Log.d(TAG, "Session valid after refresh check")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
sessionExpired = false,
|
||||
refreshFailed = false,
|
||||
)
|
||||
_sessionRestored.value = true
|
||||
} else {
|
||||
// refreshIfNeeded returned false — check if tokens were cleared
|
||||
if (!repository.isLoggedIn()) {
|
||||
Log.w(TAG, "Session invalid — tokens cleared")
|
||||
performLogout(
|
||||
sessionExpired = true,
|
||||
message = "Your session has expired. Please sign in again."
|
||||
)
|
||||
} else {
|
||||
// Tokens still present but refresh failed temporarily
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
_sessionRestored.value = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No refresh manager — just check stored tokens
|
||||
_sessionRestored.value = repository.isLoggedIn()
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user successfully authenticates (login, signup, google).
|
||||
* Resets session state and starts periodic refresh.
|
||||
*/
|
||||
private fun onAuthenticationSuccess(user: User) {
|
||||
tokenRefreshManager?.resetState()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = null,
|
||||
user = user,
|
||||
sessionExpired = false,
|
||||
refreshFailed = false,
|
||||
)
|
||||
_isAuthenticated.value = true
|
||||
_isNewUser.value = user.isNewUser
|
||||
_sessionRestored.value = true
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Auth Actions
|
||||
// ============================================================
|
||||
|
||||
fun login(email: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
@@ -67,9 +243,7 @@ class AuthViewModel(
|
||||
result.fold(
|
||||
onSuccess = { user ->
|
||||
Log.d(TAG, "Login successful for user: ${user.email}")
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
|
||||
_isAuthenticated.value = true
|
||||
_isNewUser.value = user.isNewUser
|
||||
onAuthenticationSuccess(user)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Log.w(TAG, "Login failed: ${e.message}")
|
||||
@@ -89,9 +263,7 @@ class AuthViewModel(
|
||||
result.fold(
|
||||
onSuccess = { user ->
|
||||
Log.d(TAG, "Signup successful for user: ${user.email}")
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
|
||||
_isAuthenticated.value = true
|
||||
_isNewUser.value = user.isNewUser
|
||||
onAuthenticationSuccess(user)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Log.w(TAG, "Signup failed: ${e.message}")
|
||||
@@ -151,9 +323,7 @@ class AuthViewModel(
|
||||
result.fold(
|
||||
onSuccess = { user ->
|
||||
Log.d(TAG, "Google Sign-In successful for user: ${user.email}")
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
|
||||
_isAuthenticated.value = true
|
||||
_isNewUser.value = user.isNewUser
|
||||
onAuthenticationSuccess(user)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Log.w(TAG, "Google Sign-In failed: ${e.message}")
|
||||
@@ -168,78 +338,86 @@ class AuthViewModel(
|
||||
|
||||
/**
|
||||
* Handles a cancelled Google Sign-In attempt by the user.
|
||||
* Clears loading state without showing an error.
|
||||
*/
|
||||
fun onGoogleSignInCancelled() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, error = null)
|
||||
Log.d(TAG, "Google Sign-In cancelled by user")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Logout
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Logs out the user by:
|
||||
* 1. Revoking Google OAuth tokens (server-side)
|
||||
* 2. Notifying backend of logout (invalidates session)
|
||||
* 3. Clearing auth tokens from EncryptedSharedPreferences
|
||||
* 4. Clearing API response cache from CacheManager
|
||||
* 5. Clearing DataStore user preferences
|
||||
* 6. Resetting UI state
|
||||
* Logs out the user by clearing auth state on the server and locally.
|
||||
*
|
||||
* @param revokeGoogleToken Whether to revoke Google OAuth tokens server-side.
|
||||
*/
|
||||
fun logout(revokeGoogleToken: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
val app = KordantApp.instance
|
||||
|
||||
try {
|
||||
// Step 1: Perform logout with token revocation
|
||||
repository.logout(revokeGoogleToken = revokeGoogleToken)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Logout API call failed, continuing with local cleanup: ${e.message}")
|
||||
}
|
||||
|
||||
// Step 2: Clear all cached API responses (with secure deletion for sensitive keys)
|
||||
CacheManager.clearAll(app)
|
||||
|
||||
// Step 3: Clear DataStore user preferences
|
||||
try {
|
||||
app.userPreferencesDataStore.clearAll()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "DataStore clear failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Step 4: Reset UI state
|
||||
_uiState.value = AuthUiState()
|
||||
_isAuthenticated.value = false
|
||||
_isNewUser.value = false
|
||||
_onboardingData.value = OnboardingData()
|
||||
|
||||
Log.d(TAG, "Logout completed successfully")
|
||||
performLogout(revokeGoogleToken = revokeGoogleToken)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all local user data (GDPR right to erasure).
|
||||
* This goes beyond logout by clearing ALL stored data including
|
||||
* preferences, biometric setting, and cached user profile.
|
||||
* Internal logout implementation used by both explicit logout and
|
||||
* session expiry auto-logout.
|
||||
*/
|
||||
fun deleteAllLocalData() {
|
||||
private suspend fun performLogout(
|
||||
revokeGoogleToken: Boolean = false,
|
||||
sessionExpired: Boolean = false,
|
||||
message: String? = null,
|
||||
) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
val app = KordantApp.instance
|
||||
|
||||
// Full secure wipe of encrypted storage
|
||||
app.secureStorageManager.clearAllData()
|
||||
try {
|
||||
repository.logout(revokeGoogleToken = revokeGoogleToken)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Logout API call failed, continuing with local cleanup: ${e.message}")
|
||||
}
|
||||
|
||||
// Clear all API response cache
|
||||
// Clear all cached API responses
|
||||
CacheManager.clearAll(app)
|
||||
|
||||
// Clear DataStore completely
|
||||
viewModelScope.launch {
|
||||
// Clear DataStore user preferences
|
||||
try {
|
||||
app.userPreferencesDataStore.clearAll()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "DataStore clear failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Reset UI state
|
||||
_uiState.value = AuthUiState(
|
||||
sessionExpired = sessionExpired,
|
||||
error = message,
|
||||
)
|
||||
_isAuthenticated.value = false
|
||||
_isNewUser.value = false
|
||||
_sessionRestored.value = false
|
||||
_onboardingData.value = OnboardingData()
|
||||
|
||||
tokenRefreshManager?.resetState()
|
||||
|
||||
Log.d(TAG, if (sessionExpired) "Session expired — auto-logout completed" else "Logout completed successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all local user data (GDPR right to erasure).
|
||||
*/
|
||||
fun deleteAllLocalData() {
|
||||
val app = KordantApp.instance
|
||||
app.secureStorageManager.clearAllData()
|
||||
CacheManager.clearAll(app)
|
||||
viewModelScope.launch {
|
||||
app.userPreferencesDataStore.clearAll()
|
||||
}
|
||||
_uiState.value = AuthUiState()
|
||||
_isAuthenticated.value = false
|
||||
_isNewUser.value = false
|
||||
_onboardingData.value = OnboardingData()
|
||||
_sessionRestored.value = false
|
||||
tokenRefreshManager?.resetState()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,9 +425,25 @@ class AuthViewModel(
|
||||
* Returns true if refresh succeeded, false otherwise.
|
||||
*/
|
||||
suspend fun trySilentRefresh(): Boolean {
|
||||
return repository.refreshAccessToken()
|
||||
return tokenRefreshManager?.refreshIfNeeded()
|
||||
?: repository.refreshAccessToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the session expired state so the UI can navigate
|
||||
* back to the login screen cleanly.
|
||||
*/
|
||||
fun dismissSessionExpired() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
sessionExpired = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Password Strength
|
||||
// ============================================================
|
||||
|
||||
fun updatePasswordStrength(password: String) {
|
||||
val strength = calculatePasswordStrength(password)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
@@ -261,6 +455,10 @@ class AuthViewModel(
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Onboarding
|
||||
// ============================================================
|
||||
|
||||
fun updateOnboardingData(update: (OnboardingData) -> OnboardingData) {
|
||||
_onboardingData.value = update(_onboardingData.value)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.kordant.android.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -8,13 +9,24 @@ import androidx.paging.cachedIn
|
||||
import com.kordant.android.KordantApp
|
||||
import com.kordant.android.data.model.Exposure
|
||||
import com.kordant.android.data.model.WatchlistItem
|
||||
import com.kordant.android.data.remote.ApiResult
|
||||
import com.kordant.android.data.repository.DarkWatchRepository
|
||||
import com.kordant.android.data.sync.EntityType
|
||||
import com.kordant.android.data.sync.MutationType
|
||||
import com.kordant.android.data.sync.SyncManager
|
||||
import com.kordant.android.data.sync.SyncState
|
||||
import com.kordant.android.di.RepositoryModule
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
class DarkWatchViewModel : ViewModel() {
|
||||
data class DarkWatchUiState(
|
||||
@@ -22,8 +34,23 @@ class DarkWatchViewModel : ViewModel() {
|
||||
val exposures: List<Exposure> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val isAdding: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
val error: String? = null,
|
||||
// Offline sync state
|
||||
val isOnline: Boolean = true,
|
||||
val pendingSyncCount: Int = 0,
|
||||
val pendingWatchlistItems: Set<String> = emptySet(), // IDs with pending ops
|
||||
) {
|
||||
/**
|
||||
* Returns true if the given watchlist item has a pending sync operation.
|
||||
*/
|
||||
fun isPendingSync(watchlistItemId: String): Boolean = watchlistItemId in pendingWatchlistItems
|
||||
|
||||
/**
|
||||
* Returns the total count including pending items.
|
||||
*/
|
||||
val effectiveWatchlistCount: Int
|
||||
get() = watchlist.size + pendingWatchlistItems.size
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(DarkWatchUiState())
|
||||
open val uiState: StateFlow<DarkWatchUiState> = _uiState.asStateFlow()
|
||||
@@ -32,10 +59,18 @@ class DarkWatchViewModel : ViewModel() {
|
||||
RepositoryModule.provideDarkWatchRepository(KordantApp.instance)
|
||||
}
|
||||
|
||||
private val syncManager: SyncManager by lazy {
|
||||
KordantApp.instance.getSyncManager()
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated watchlist items for the DarkWatch screen.
|
||||
* Uses Paging 3 with cursor-based pagination via [DarkWatchRepository.getPagedWatchlist].
|
||||
* The flow is cached in the ViewModel scope to survive configuration changes.
|
||||
*/
|
||||
val pagedWatchlist: Flow<PagingData<WatchlistItem>> = darkWatchRepo
|
||||
.getPagedWatchlist()
|
||||
@@ -49,6 +84,22 @@ class DarkWatchViewModel : ViewModel() {
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
init {
|
||||
// Combine internal state with sync state for offline awareness
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
syncManager.syncState,
|
||||
_uiState,
|
||||
) { syncState, currentState ->
|
||||
currentState.copy(
|
||||
isOnline = syncState.isOnline,
|
||||
pendingSyncCount = syncState.pendingRequestsByEntity[EntityType.WATCHLIST_ITEM] ?: 0,
|
||||
pendingWatchlistItems = syncManager.getPendingEntityIds(EntityType.WATCHLIST_ITEM),
|
||||
)
|
||||
}.collect { combined ->
|
||||
_uiState.value = combined
|
||||
}
|
||||
}
|
||||
|
||||
loadCounts()
|
||||
}
|
||||
|
||||
@@ -56,10 +107,6 @@ class DarkWatchViewModel : ViewModel() {
|
||||
loadCounts(forceRefresh = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads summary counts for the dashboard (uses bulk loading).
|
||||
* The actual list data comes from [pagedWatchlist] and [pagedExposures].
|
||||
*/
|
||||
private fun loadCounts(forceRefresh: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = !forceRefresh, error = null)
|
||||
@@ -67,23 +114,23 @@ class DarkWatchViewModel : ViewModel() {
|
||||
val watchlistResult = darkWatchRepo.getWatchlist(forceRefresh)
|
||||
val exposuresResult = darkWatchRepo.getExposures(forceRefresh)
|
||||
|
||||
val watchlist = if (watchlistResult is com.kordant.android.data.remote.ApiResult.Success) {
|
||||
val watchlist = if (watchlistResult is ApiResult.Success) {
|
||||
watchlistResult.data
|
||||
} else emptyList()
|
||||
|
||||
val exposures = if (exposuresResult is com.kordant.android.data.remote.ApiResult.Success) {
|
||||
val exposures = if (exposuresResult is ApiResult.Success) {
|
||||
exposuresResult.data
|
||||
} else emptyList()
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
watchlist = watchlist,
|
||||
exposures = exposures
|
||||
exposures = exposures,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load data"
|
||||
error = e.message ?: "Failed to load data",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -92,38 +139,122 @@ class DarkWatchViewModel : ViewModel() {
|
||||
fun addWatchlistItem(type: String, value: String, label: String? = null) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isAdding = true, error = null)
|
||||
|
||||
// Optimistic update: create a temporary item locally
|
||||
val tempId = "pending_${System.currentTimeMillis()}"
|
||||
val optimisticItem = WatchlistItem(
|
||||
id = tempId,
|
||||
type = type,
|
||||
value = value,
|
||||
label = label,
|
||||
status = "pending",
|
||||
alertsEnabled = true,
|
||||
)
|
||||
|
||||
// Add to local state immediately
|
||||
_uiState.value = _uiState.value.copy(
|
||||
watchlist = _uiState.value.watchlist + optimisticItem,
|
||||
isAdding = false,
|
||||
)
|
||||
|
||||
try {
|
||||
val result = darkWatchRepo.addWatchlistItem(type, value, label)
|
||||
if (result is com.kordant.android.data.remote.ApiResult.Error) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isAdding = false,
|
||||
error = result.message
|
||||
)
|
||||
if (syncManager.isOnline()) {
|
||||
// Online — make the API call directly
|
||||
val result = darkWatchRepo.addWatchlistItem(type, value, label)
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
// Replace optimistic item with real one
|
||||
_uiState.value = _uiState.value.copy(
|
||||
watchlist = _uiState.value.watchlist.filter { it.id != tempId },
|
||||
)
|
||||
loadCounts(forceRefresh = true)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
// API failed — queue for offline, keep optimistic
|
||||
enqueueAddWatchlistItem(type, value, label, tempId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(isAdding = false)
|
||||
loadCounts(forceRefresh = true)
|
||||
// Offline — queue the request
|
||||
enqueueAddWatchlistItem(type, value, label, tempId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isAdding = false,
|
||||
error = e.message ?: "Failed to add watchlist item"
|
||||
)
|
||||
// Network error — queue for offline
|
||||
Log.w(TAG, "Failed to add watchlist item, queuing offline: ${e.message}")
|
||||
enqueueAddWatchlistItem(type, value, label, tempId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a watchlist item addition for offline sync.
|
||||
* The optimistic item remains in the UI until sync completes.
|
||||
*/
|
||||
private fun enqueueAddWatchlistItem(type: String, value: String, label: String?, tempId: String) {
|
||||
val body = buildJsonObject {
|
||||
put("type", type)
|
||||
put("value", value)
|
||||
if (label != null) put("label", label)
|
||||
}
|
||||
|
||||
syncManager.enqueueOfflineRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = json.encodeToString(body),
|
||||
method = "POST",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = tempId,
|
||||
)
|
||||
|
||||
_uiState.value = _uiState.value.copy(isAdding = false)
|
||||
}
|
||||
|
||||
fun removeWatchlistItem(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
darkWatchRepo.removeWatchlistItem(id)
|
||||
// Optimistic: remove from local state immediately
|
||||
_uiState.value = _uiState.value.copy(
|
||||
watchlist = _uiState.value.watchlist.filter { it.id != id },
|
||||
)
|
||||
|
||||
if (syncManager.isOnline()) {
|
||||
darkWatchRepo.removeWatchlistItem(id)
|
||||
} else {
|
||||
// Queue the deletion for offline
|
||||
val body = buildJsonObject { put("itemId", id) }
|
||||
syncManager.enqueueOfflineRequest(
|
||||
endpoint = "api/trpc/darkwatch.removeWatchlistItem",
|
||||
body = json.encodeToString(body),
|
||||
method = "POST",
|
||||
mutationType = MutationType.DELETE,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = id,
|
||||
)
|
||||
}
|
||||
|
||||
loadCounts(forceRefresh = true)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(error = e.message)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Failed to remove watchlist item",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current watchlist items combined with pending items,
|
||||
* marking which ones have pending sync operations.
|
||||
*/
|
||||
fun getWatchlistWithPendingStatus(): List<Pair<WatchlistItem, Boolean>> {
|
||||
val state = _uiState.value
|
||||
return state.watchlist.map { item ->
|
||||
item to state.isPendingSync(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DarkWatchViewModel"
|
||||
|
||||
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.kordant.android.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.kordant.android.KordantApp
|
||||
import com.kordant.android.data.sync.EntityType
|
||||
import com.kordant.android.data.sync.SyncState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* ViewModel that exposes the aggregate offline/sync state to any screen.
|
||||
*
|
||||
* Collect [syncState] to get:
|
||||
* - [SyncState.isOnline] — connectivity state
|
||||
* - [SyncState.pendingRequestCount] — number of queued offline operations
|
||||
* - [SyncState.isSyncing] — whether a sync operation is in progress
|
||||
* - [SyncState.lastSyncResult] — result of the last sync attempt
|
||||
* - [SyncState.pendingRequestsByEntity] — pending count per entity type
|
||||
*
|
||||
* This ViewModel is scoped to the Application lifecycle, so the sync state
|
||||
* survives configuration changes and is available to all screens.
|
||||
*/
|
||||
class OfflineSyncViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val syncManager: com.kordant.android.data.sync.SyncManager
|
||||
get() = (getApplication<KordantApp>()).getSyncManager()
|
||||
|
||||
/**
|
||||
* Aggregate sync state emitted as a StateFlow.
|
||||
* Screens can collect this to drive offline UI indicators.
|
||||
*/
|
||||
val syncState: StateFlow<SyncState> = syncManager.syncState
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = SyncState.INITIAL,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns true if the given entity has any pending operations.
|
||||
*/
|
||||
fun hasPendingOperation(entityType: EntityType, entityId: String): Boolean {
|
||||
return syncManager.hasPendingOperation(entityType, entityId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of entity IDs with pending operations for the given type.
|
||||
*/
|
||||
fun getPendingEntityIds(entityType: EntityType): Set<String> {
|
||||
return syncManager.getPendingEntityIds(entityType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a full sync.
|
||||
*/
|
||||
fun triggerFullSync() {
|
||||
syncManager.triggerFullSync()
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an immediate sync of the offline queue.
|
||||
*/
|
||||
fun triggerOfflineQueueSync() {
|
||||
syncManager.triggerImmediateSync(com.kordant.android.data.sync.SyncType.OFFLINE_QUEUE)
|
||||
}
|
||||
}
|
||||
12
android/app/src/main/res/drawable/ic_sync.xml
Normal file
12
android/app/src/main/res/drawable/ic_sync.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<!-- Sync icon (Material Design refresh/sync arrows) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,4V1L8,5l4,4V6c3.31,0 6,2.69 6,6 0,1.01 -0.25,1.97 -0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12 20,7.58 16.42,4 12,4zM12,18c-3.31,0 -6,-2.69 -6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4 -4,-4v3z" />
|
||||
</vector>
|
||||
@@ -50,6 +50,10 @@
|
||||
<string name="channel_scan_complete_description">Background security scan finished and results are available</string>
|
||||
<string name="channel_family_activity_name">Family Activity</string>
|
||||
<string name="channel_family_activity_description">Family member changes, shared alerts, and family activity notifications</string>
|
||||
<string name="channel_family_invite_name">Family Invites</string>
|
||||
<string name="channel_family_invite_description">Invitations to join or be added to family groups</string>
|
||||
<string name="channel_subscription_name">Subscription</string>
|
||||
<string name="channel_subscription_description">Subscription renewals, billing updates, and plan changes</string>
|
||||
<string name="channel_marketing_name">Marketing</string>
|
||||
<string name="channel_marketing_description">Product updates, tips, and promotional offers</string>
|
||||
<string name="channel_system_name">System</string>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.kordant.android.data.remote
|
||||
|
||||
import com.kordant.android.data.local.SecureStorageManager
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Tests for [TokenRefreshAuthenticator] using MockWebServer.
|
||||
*
|
||||
* Verifies:
|
||||
* - 401 triggers token refresh via authenticator
|
||||
* - Successful refresh retries original request with new token
|
||||
* - Failed refresh returns null (propagates 401)
|
||||
* - Auth endpoints are skipped (no infinite loop)
|
||||
* - Token rotation is handled
|
||||
* - No-op when no tokens stored
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [34])
|
||||
class TokenRefreshAuthenticatorTest {
|
||||
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
private lateinit var secureStorageManager: SecureStorageManager
|
||||
private lateinit var tokenRefreshManager: TokenRefreshManager
|
||||
private lateinit var authenticator: TokenRefreshAuthenticator
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
|
||||
secureStorageManager = SecureStorageManager(context)
|
||||
tokenRefreshManager = TokenRefreshManager(
|
||||
context = context,
|
||||
secureStorageManager = secureStorageManager,
|
||||
baseUrl = mockWebServer.url("/").toString(),
|
||||
)
|
||||
authenticator = TokenRefreshAuthenticator(secureStorageManager, tokenRefreshManager)
|
||||
|
||||
client = OkHttpClient.Builder()
|
||||
.addInterceptor(AuthInterceptor(secureStorageManager))
|
||||
.authenticator(authenticator)
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockWebServer.shutdown()
|
||||
secureStorageManager.clearAllData()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshes token on 401 and retries`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("expired-token", "valid-refresh-token")
|
||||
|
||||
// Enqueue: 401 → refresh → retry
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "new-access-token", "refreshToken": "new-refresh-token"}"""
|
||||
)
|
||||
)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody("""{"status": "success"}""")
|
||||
)
|
||||
|
||||
// When
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/test"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
// Then
|
||||
assertEquals(200, response.code)
|
||||
assertEquals("new-access-token", secureStorageManager.getAccessToken())
|
||||
assertEquals("new-refresh-token", secureStorageManager.getRefreshToken())
|
||||
assertEquals(3, mockWebServer.requestCount)
|
||||
|
||||
// Verify retry used new token
|
||||
val retryRequest = mockWebServer.takeRequest(3)
|
||||
assertEquals("Bearer new-access-token", retryRequest.getHeader("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns null when refresh fails`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("expired-token", "invalid-refresh-token")
|
||||
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(401).setBody("""{"error": "Invalid refresh token"}""")
|
||||
)
|
||||
|
||||
// When
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/test"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
// Then
|
||||
assertEquals(401, response.code)
|
||||
assertNull("Tokens should be cleared on permanent failure",
|
||||
secureStorageManager.getAccessToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips auth endpoints`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("expired-token", "valid-refresh-token")
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(401).setBody("""{"error": "Invalid credentials"}""")
|
||||
)
|
||||
|
||||
// When — authenticator should NOT refresh for auth endpoints
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/auth/login"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
// Then
|
||||
assertEquals(401, response.code)
|
||||
assertEquals("Tokens should not be cleared", "expired-token",
|
||||
secureStorageManager.getAccessToken())
|
||||
assertEquals(1, mockWebServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles token rotation`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("expired-token", "old-refresh-token")
|
||||
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "rotated-access", "refreshToken": "rotated-refresh"}"""
|
||||
)
|
||||
)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody("""{"status": "success"}""")
|
||||
)
|
||||
|
||||
// When
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/test"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
// Then
|
||||
assertEquals(200, response.code)
|
||||
assertEquals("rotated-access", secureStorageManager.getAccessToken())
|
||||
assertEquals("rotated-refresh", secureStorageManager.getRefreshToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preserves refresh token when server does not rotate`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("expired-token", "valid-refresh-token")
|
||||
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "new-access-token"}"""
|
||||
)
|
||||
)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody("""{"status": "success"}""")
|
||||
)
|
||||
|
||||
// When
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/test"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
// Then
|
||||
assertEquals(200, response.code)
|
||||
assertEquals("new-access-token", secureStorageManager.getAccessToken())
|
||||
assertEquals("Refresh token unchanged", "valid-refresh-token",
|
||||
secureStorageManager.getRefreshToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no refresh when no tokens stored`() = runTest {
|
||||
// No tokens
|
||||
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/test"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, mockWebServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips signup endpoint`() = runTest {
|
||||
secureStorageManager.saveTokens("token", "refresh")
|
||||
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/auth/signup"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, mockWebServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips refresh endpoint to prevent loops`() = runTest {
|
||||
secureStorageManager.saveTokens("token", "refresh")
|
||||
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/auth/refresh"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, mockWebServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips forgot-password endpoint`() = runTest {
|
||||
secureStorageManager.saveTokens("token", "refresh")
|
||||
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(mockWebServer.url("/api/auth/forgot-password"))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
assertEquals(401, response.code)
|
||||
assertEquals(1, mockWebServer.requestCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package com.kordant.android.data.remote
|
||||
|
||||
import com.kordant.android.data.local.SecureStorageManager
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Unit tests for [TokenRefreshManager].
|
||||
*
|
||||
* Tests cover:
|
||||
* - Successful refresh with and without token rotation
|
||||
* - Refresh failure handling (401, network errors, empty response)
|
||||
* - Concurrent refresh deduplication
|
||||
* - Proactive refresh (refreshIfNeeded)
|
||||
* - Edge cases: no tokens, null responses
|
||||
* - Exponential backoff retry logic
|
||||
* - Permanent failure after max retries
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [34])
|
||||
class TokenRefreshManagerTest {
|
||||
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
private lateinit var secureStorageManager: SecureStorageManager
|
||||
private lateinit var refreshManager: TokenRefreshManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
|
||||
secureStorageManager = SecureStorageManager(context)
|
||||
refreshManager = TokenRefreshManager(
|
||||
context = context,
|
||||
secureStorageManager = secureStorageManager,
|
||||
baseUrl = mockWebServer.url("/").toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockWebServer.shutdown()
|
||||
secureStorageManager.clearAllData()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Successful Refresh
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun `refreshToken - success with rotation`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("old-access", "old-refresh")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "new-access", "refreshToken": "new-refresh"}"""
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val result = refreshManager.refreshToken()
|
||||
|
||||
// Then
|
||||
assertTrue("Refresh should succeed", result)
|
||||
assertEquals("new-access", secureStorageManager.getAccessToken())
|
||||
assertEquals("new-refresh", secureStorageManager.getRefreshToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshToken - success without rotation`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("old-access", "persistent-refresh")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "new-access"}"""
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val result = refreshManager.refreshToken()
|
||||
|
||||
// Then
|
||||
assertTrue("Refresh should succeed", result)
|
||||
assertEquals("new-access", secureStorageManager.getAccessToken())
|
||||
assertEquals("persistent-refresh", secureStorageManager.getRefreshToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshToken - success when refreshToken is null in response`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("old-access", "keep-refresh")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "new-access", "refreshToken": null}"""
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val result = refreshManager.refreshToken()
|
||||
|
||||
// Then
|
||||
assertTrue("Refresh should succeed", result)
|
||||
assertEquals("new-access", secureStorageManager.getAccessToken())
|
||||
assertEquals("keep-refresh", secureStorageManager.getRefreshToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshToken - success when refreshToken is empty string`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("old-access", "keep-refresh")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "new-access", "refreshToken": ""}"""
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val result = refreshManager.refreshToken()
|
||||
|
||||
// Then
|
||||
assertTrue("Refresh should succeed", result)
|
||||
assertEquals("new-access", secureStorageManager.getAccessToken())
|
||||
assertEquals("keep-refresh", secureStorageManager.getRefreshToken())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Refresh Failures
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun `refreshToken - returns false when no refresh token stored`() = runTest {
|
||||
// Given: No tokens stored
|
||||
secureStorageManager.clearAllAuthData()
|
||||
|
||||
// When
|
||||
val result = refreshManager.refreshToken()
|
||||
|
||||
// Then
|
||||
assertFalse("Refresh should fail without tokens", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshToken - returns false on 401`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("access", "refresh")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(401).setBody(
|
||||
"""{"error": "Invalid refresh token"}"""
|
||||
)
|
||||
)
|
||||
|
||||
// When
|
||||
val result = refreshManager.refreshToken()
|
||||
|
||||
// Then
|
||||
assertFalse("Refresh should fail on 401", result)
|
||||
assertNull("Access token should be cleared on permanent failure",
|
||||
secureStorageManager.getAccessToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshToken - returns false on 403`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("access", "refresh")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(403)
|
||||
)
|
||||
|
||||
// When
|
||||
val result = refreshManager.refreshToken()
|
||||
|
||||
// Then
|
||||
assertFalse("Refresh should fail on 403", result)
|
||||
assertNull("Tokens should be cleared", secureStorageManager.getAccessToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshToken - retries on 5xx error`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("access", "refresh")
|
||||
// 500 first, then success
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(500))
|
||||
// After backoff retry, the retry is scheduled via scope.launch with delay
|
||||
// We can't easily test async retries, but verify the initial failure
|
||||
|
||||
// When
|
||||
val result = refreshManager.refreshToken()
|
||||
|
||||
// Then — first attempt fails but doesn't clear tokens
|
||||
assertFalse("Refresh should fail on 500", result)
|
||||
// Tokens should still be present (retry scheduled)
|
||||
assertNotNull("Tokens preserved for retry", secureStorageManager.getAccessToken())
|
||||
assertNotNull("Refresh token preserved for retry", secureStorageManager.getRefreshToken())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Concurrent Request Deduplication
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun `refreshToken - deduplicates concurrent calls`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("access", "refresh")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "new-access"}"""
|
||||
)
|
||||
)
|
||||
|
||||
// When — call refreshToken concurrently twice
|
||||
val result1 = refreshManager.refreshToken()
|
||||
val result2 = refreshManager.refreshToken()
|
||||
|
||||
// Then — only one actual refresh happened
|
||||
assertTrue("First refresh should succeed", result1)
|
||||
// The second call might return true because the first succeeded
|
||||
assertEquals("new-access", secureStorageManager.getAccessToken())
|
||||
|
||||
// Only one request should have been made to the server
|
||||
assertEquals(1, mockWebServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshToken - concurrent calls wait for in-flight refresh`() = runTest {
|
||||
// Given
|
||||
secureStorageManager.saveTokens("access", "refresh")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "refreshed-token"}"""
|
||||
)
|
||||
)
|
||||
|
||||
// When — simulate concurrent calls by running in parallel
|
||||
val deferred1 = kotlinx.coroutines.async { refreshManager.refreshToken() }
|
||||
val deferred2 = kotlinx.coroutines.async { refreshManager.refreshToken() }
|
||||
|
||||
val r1 = deferred1.await()
|
||||
val r2 = deferred2.await()
|
||||
|
||||
// Then
|
||||
assertTrue("First call should succeed", r1)
|
||||
assertEquals("refreshed-token", secureStorageManager.getAccessToken())
|
||||
// Only one server request
|
||||
assertEquals(1, mockWebServer.requestCount)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Proactive Refresh
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun `refreshIfNeeded - refreshes when token near expiry`() = runTest {
|
||||
// Given: A token that expires soon (manually craft JWT with near-expiry claim)
|
||||
// We can't easily create a JWT in tests, but we can inject a token that
|
||||
// will fail to parse as JWT and fall back to DEFAULT_TOKEN_EXPIRY_MS.
|
||||
// In that case, refreshIfNeeded should return true if the fallback expiry
|
||||
// is far enough away.
|
||||
secureStorageManager.saveTokens("any-access-token", "refresh-token")
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "refreshed"}"""
|
||||
)
|
||||
)
|
||||
|
||||
// When — since the dummy token can't be parsed, it falls back to 7-day expiry
|
||||
// which is far in the future, so refreshIfNeeded returns true without refreshing
|
||||
val result = refreshManager.refreshIfNeeded()
|
||||
|
||||
// Then — token is valid for >5 minutes, no refresh needed
|
||||
assertTrue("Token should be considered valid", result)
|
||||
assertEquals(0, mockWebServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshIfNeeded - returns false when no tokens`() = runTest {
|
||||
secureStorageManager.clearAllAuthData()
|
||||
|
||||
val result = refreshManager.refreshIfNeeded()
|
||||
|
||||
assertFalse("Should return false with no tokens", result)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// State Management
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun `refreshState - starts IDLE`() {
|
||||
assertEquals(TokenRefreshManager.RefreshState.IDLE, refreshManager.refreshState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshState - goes to REFRESHING during refresh`() = runTest {
|
||||
secureStorageManager.saveTokens("access", "refresh")
|
||||
|
||||
// Collect state changes
|
||||
val states = mutableListOf<TokenRefreshManager.RefreshState>()
|
||||
val job = kotlinx.coroutines.launch {
|
||||
refreshManager.refreshState.collect { states.add(it) }
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{"accessToken": "new"}"""
|
||||
)
|
||||
)
|
||||
|
||||
refreshManager.refreshToken()
|
||||
|
||||
job.cancel()
|
||||
|
||||
assertTrue("Should have transitioned through REFRESHING", states.any {
|
||||
it == TokenRefreshManager.RefreshState.REFRESHING
|
||||
})
|
||||
assertEquals(TokenRefreshManager.RefreshState.IDLE, states.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshState - goes to FAILED on permanent error`() = runTest {
|
||||
secureStorageManager.saveTokens("access", "refresh")
|
||||
|
||||
val states = mutableListOf<TokenRefreshManager.RefreshState>()
|
||||
val job = kotlinx.coroutines.launch {
|
||||
refreshManager.refreshState.collect { states.add(it) }
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
refreshManager.refreshToken()
|
||||
|
||||
job.cancel()
|
||||
|
||||
assertTrue("Should have FAILED state", states.any {
|
||||
it == TokenRefreshManager.RefreshState.FAILED
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Accessors
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun `getAccessToken - returns null when no token`() {
|
||||
assertNull(refreshManager.getAccessToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getAccessToken - returns stored token`() {
|
||||
secureStorageManager.saveTokens("my-token", "my-refresh")
|
||||
assertEquals("my-token", refreshManager.getAccessToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isAuthenticated - returns false when no tokens`() {
|
||||
assertFalse(refreshManager.isAuthenticated())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isAuthenticated - returns true when tokens exist`() {
|
||||
secureStorageManager.saveTokens("t", "r")
|
||||
assertTrue(refreshManager.isAuthenticated())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetState - clears failure state`() {
|
||||
secureStorageManager.saveTokens("access", "refresh")
|
||||
refreshManager.resetState()
|
||||
assertEquals(TokenRefreshManager.RefreshState.IDLE, refreshManager.refreshState.value)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PendingRequestQueue Tests
|
||||
// Enhanced PendingRequest Tests
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
@@ -27,6 +27,8 @@ class SyncManagerTest {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = """{"0":{"json":{"type":"email","value":"test@test.com"}}}""",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
))
|
||||
|
||||
assertEquals(1, fakeQueue.count())
|
||||
@@ -37,6 +39,8 @@ class SyncManagerTest {
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/user.updateProfile",
|
||||
body = """{"0":{"json":{"name":"New"}}}""",
|
||||
mutationType = MutationType.UPDATE,
|
||||
entityType = EntityType.USER_PROFILE,
|
||||
)
|
||||
fakeQueue.insert(request)
|
||||
val inserted = fakeQueue.getAll().first()
|
||||
@@ -62,14 +66,14 @@ class SyncManagerTest {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test",
|
||||
body = "{}",
|
||||
retryCount = 5,
|
||||
maxRetries = 5,
|
||||
retryCount = 10,
|
||||
maxRetries = 10,
|
||||
))
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test2",
|
||||
body = "{}",
|
||||
retryCount = 2,
|
||||
maxRetries = 5,
|
||||
maxRetries = 10,
|
||||
))
|
||||
|
||||
fakeQueue.deleteExpired()
|
||||
@@ -97,14 +101,14 @@ class SyncManagerTest {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test1",
|
||||
body = "{}",
|
||||
retryCount = 4,
|
||||
maxRetries = 5,
|
||||
retryCount = 9,
|
||||
maxRetries = 10,
|
||||
))
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test2",
|
||||
body = "{}",
|
||||
retryCount = 0,
|
||||
maxRetries = 5,
|
||||
maxRetries = 10,
|
||||
))
|
||||
|
||||
assertEquals(1, fakeQueue.nearExpiryCount())
|
||||
@@ -141,6 +145,384 @@ class SyncManagerTest {
|
||||
assertEquals(2L, r2.id)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Deduplication Tests
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun pendingRequest_deduplicatesByEntityIdAndMutationType() = runBlocking {
|
||||
val req1 = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = """{"type":"email","value":"old@test.com"}""",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "item_1",
|
||||
)
|
||||
val req2 = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = """{"type":"email","value":"new@test.com"}""",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "item_1",
|
||||
)
|
||||
|
||||
fakeQueue.insert(req1)
|
||||
fakeQueue.insert(req2)
|
||||
|
||||
// Should have only 1 request (deduped), with the latest body
|
||||
assertEquals(1, fakeQueue.count())
|
||||
val remaining = fakeQueue.getAll().first()
|
||||
assertTrue(remaining.body.contains("new@test.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_noDedupForDifferentEntityIds() = runBlocking {
|
||||
val req1 = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = """{"type":"email","value":"a@test.com"}""",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "item_a",
|
||||
)
|
||||
val req2 = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = """{"type":"email","value":"b@test.com"}""",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "item_b",
|
||||
)
|
||||
|
||||
fakeQueue.insert(req1)
|
||||
fakeQueue.insert(req2)
|
||||
|
||||
assertEquals(2, fakeQueue.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_dedupDifferentMutationTypes() = runBlocking {
|
||||
// ADD followed by DELETE for the same entity should keep both
|
||||
val addReq = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = "{}",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "item_1",
|
||||
)
|
||||
val delReq = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.removeWatchlistItem",
|
||||
body = """{"itemId":"item_1"}""",
|
||||
mutationType = MutationType.DELETE,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "item_1",
|
||||
)
|
||||
|
||||
fakeQueue.insert(addReq)
|
||||
fakeQueue.insert(delReq)
|
||||
|
||||
// Different mutation types for same entity: both kept (DELETE cancels ADD later)
|
||||
assertEquals(2, fakeQueue.count())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dependency Ordering Tests
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun pendingRequest_orderedByPriorityThenTimestamp() = runBlocking {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "low",
|
||||
body = "{}",
|
||||
priority = 1,
|
||||
timestamp = 1000L,
|
||||
))
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "high",
|
||||
body = "{}",
|
||||
priority = 10,
|
||||
timestamp = 2000L,
|
||||
))
|
||||
|
||||
val ordered = fakeQueue.getOrdered()
|
||||
assertEquals("high", ordered.first().endpoint)
|
||||
assertEquals("low", ordered.last().endpoint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_dependencyOrdering() = runBlocking {
|
||||
// Request A depends on B — B must come first
|
||||
val reqB = fakeQueue.insertWithReturn(PendingRequest(
|
||||
endpoint = "create_parent",
|
||||
body = "{}",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
))
|
||||
val reqA = fakeQueue.insertWithReturn(PendingRequest(
|
||||
endpoint = "add_child",
|
||||
body = "{}",
|
||||
mutationType = MutationType.UPDATE,
|
||||
dependencyIds = listOf(reqB.id),
|
||||
))
|
||||
|
||||
val ordered = fakeQueue.getOrdered()
|
||||
assertEquals("create_parent", ordered.first().endpoint)
|
||||
assertEquals("add_child", ordered.last().endpoint)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MutationType Tests
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun mutationType_enumValues() {
|
||||
assertEquals(3, MutationType.entries.size)
|
||||
assertTrue(MutationType.entries.containsAll(listOf(
|
||||
MutationType.ADD, MutationType.UPDATE, MutationType.DELETE,
|
||||
)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entityType_enumValues() {
|
||||
assertTrue(EntityType.entries.contains(EntityType.WATCHLIST_ITEM))
|
||||
assertTrue(EntityType.entries.contains(EntityType.EXPOSURE))
|
||||
assertTrue(EntityType.entries.contains(EntityType.ALERT))
|
||||
assertTrue(EntityType.entries.contains(EntityType.SETTINGS))
|
||||
assertTrue(EntityType.entries.contains(EntityType.USER_PROFILE))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Conflict Resolution Tests
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun conflictResolver_serverWinsForAlerts() {
|
||||
val resolver = ConflictResolver()
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/alerts.markRead",
|
||||
body = """{"id":"alert1"}""",
|
||||
mutationType = MutationType.UPDATE,
|
||||
entityType = EntityType.ALERT,
|
||||
version = "old_version",
|
||||
)
|
||||
|
||||
val conflict = SyncConflict(
|
||||
pendingRequest = request,
|
||||
entityType = EntityType.ALERT,
|
||||
localVersion = "old_version",
|
||||
serverVersion = "new_version",
|
||||
strategy = ConflictStrategy.SERVER_WINS,
|
||||
)
|
||||
|
||||
val resolution = resolver.resolve(conflict)
|
||||
assertEquals(ConflictAction.USE_SERVER, resolution.action)
|
||||
assertTrue(resolution.resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conflictResolver_lastWriteWins_localNewer() {
|
||||
val resolver = ConflictResolver()
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/user.updatePreferences",
|
||||
body = """{"theme":"dark"}""",
|
||||
mutationType = MutationType.UPDATE,
|
||||
entityType = EntityType.SETTINGS,
|
||||
version = "2000",
|
||||
)
|
||||
|
||||
val conflict = SyncConflict(
|
||||
pendingRequest = request,
|
||||
entityType = EntityType.SETTINGS,
|
||||
localVersion = "2000",
|
||||
serverVersion = "1000",
|
||||
strategy = ConflictStrategy.LAST_WRITE_WINS,
|
||||
)
|
||||
|
||||
val resolution = resolver.resolve(conflict)
|
||||
assertEquals(ConflictAction.USE_LOCAL, resolution.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conflictResolver_lastWriteWins_serverNewer() {
|
||||
val resolver = ConflictResolver()
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/user.updatePreferences",
|
||||
body = """{"theme":"dark"}""",
|
||||
mutationType = MutationType.UPDATE,
|
||||
entityType = EntityType.SETTINGS,
|
||||
version = "1000",
|
||||
)
|
||||
|
||||
val conflict = SyncConflict(
|
||||
pendingRequest = request,
|
||||
entityType = EntityType.SETTINGS,
|
||||
localVersion = "1000",
|
||||
serverVersion = "2000",
|
||||
strategy = ConflictStrategy.LAST_WRITE_WINS,
|
||||
)
|
||||
|
||||
val resolution = resolver.resolve(conflict)
|
||||
assertEquals(ConflictAction.USE_SERVER, resolution.action)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conflictResolver_mergeWatchlistAdd() {
|
||||
val resolver = ConflictResolver()
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = """{"type":"email","value":"test@test.com","label":"Work"}""",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
)
|
||||
|
||||
val conflict = SyncConflict(
|
||||
pendingRequest = request,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
localVersion = null,
|
||||
serverVersion = "v2",
|
||||
strategy = ConflictStrategy.MERGE,
|
||||
)
|
||||
|
||||
// Server response indicates item already exists with an id
|
||||
val resolution = resolver.resolve(conflict, """{"id":"server123","type":"email","value":"test@test.com","status":"active"}""")
|
||||
assertTrue(resolution.resolved)
|
||||
// Should either merge or use server
|
||||
assertTrue(
|
||||
resolution.action == ConflictAction.MERGED ||
|
||||
resolution.action == ConflictAction.USE_SERVER
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conflictResolver_detectConflictFrom409() {
|
||||
val resolver = ConflictResolver()
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = "{}",
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
)
|
||||
|
||||
val conflict = resolver.detectConflict(
|
||||
pendingRequest = request,
|
||||
serverResponseCode = 409,
|
||||
serverResponseBody = """{"error":"version conflict"}""",
|
||||
)
|
||||
|
||||
assertNotNull(conflict)
|
||||
assertEquals(EntityType.WATCHLIST_ITEM, conflict?.entityType)
|
||||
assertEquals(ConflictStrategy.MERGE, conflict?.strategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conflictResolver_noConflictOnSuccess() {
|
||||
val resolver = ConflictResolver()
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/darkwatch.addWatchlistItem",
|
||||
body = "{}",
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
)
|
||||
|
||||
val conflict = resolver.detectConflict(
|
||||
pendingRequest = request,
|
||||
serverResponseCode = 200,
|
||||
)
|
||||
|
||||
assertNull(conflict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conflictResolver_manualForUserProfileConflict() {
|
||||
val resolver = ConflictResolver()
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/user.updateProfile",
|
||||
body = """{"name":"New Name"}""",
|
||||
mutationType = MutationType.UPDATE,
|
||||
entityType = EntityType.USER_PROFILE,
|
||||
version = "v1",
|
||||
)
|
||||
|
||||
// USER_PROFILE uses LAST_WRITE_WINS, not MANUAL
|
||||
val strategy = ConflictStrategyMap.forEntityType(EntityType.USER_PROFILE)
|
||||
assertEquals(ConflictStrategy.LAST_WRITE_WINS, strategy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conflictStrategyMap_correctForAllTypes() {
|
||||
assertEquals(ConflictStrategy.SERVER_WINS, ConflictStrategyMap.forEntityType(EntityType.ALERT))
|
||||
assertEquals(ConflictStrategy.SERVER_WINS, ConflictStrategyMap.forEntityType(EntityType.EXPOSURE))
|
||||
assertEquals(ConflictStrategy.SERVER_WINS, ConflictStrategyMap.forEntityType(EntityType.SPAM_RULE))
|
||||
assertEquals(ConflictStrategy.LAST_WRITE_WINS, ConflictStrategyMap.forEntityType(EntityType.SETTINGS))
|
||||
assertEquals(ConflictStrategy.LAST_WRITE_WINS, ConflictStrategyMap.forEntityType(EntityType.USER_PROFILE))
|
||||
assertEquals(ConflictStrategy.MERGE, ConflictStrategyMap.forEntityType(EntityType.WATCHLIST_ITEM))
|
||||
assertEquals(ConflictStrategy.MERGE, ConflictStrategyMap.forEntityType(EntityType.BROKER_LISTING))
|
||||
assertEquals(ConflictStrategy.SERVER_WINS, ConflictStrategyMap.forEntityType(EntityType.UNKNOWN))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Backoff Calculation Tests
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun pendingRequest_exponentialBackoff() {
|
||||
val request = PendingRequest(
|
||||
endpoint = "test",
|
||||
body = "{}",
|
||||
exponentialBaseMs = 30_000L, // 30 seconds
|
||||
)
|
||||
|
||||
assertEquals(30_000L, request.nextBackoffDelayMs())
|
||||
assertEquals(60_000L, request.copy(retryCount = 1).nextBackoffDelayMs())
|
||||
assertEquals(120_000L, request.copy(retryCount = 2).nextBackoffDelayMs())
|
||||
assertEquals(240_000L, request.copy(retryCount = 3).nextBackoffDelayMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_backoffCappedAt1Hour() {
|
||||
val request = PendingRequest(
|
||||
endpoint = "test",
|
||||
body = "{}",
|
||||
exponentialBaseMs = 30_000L,
|
||||
retryCount = 10,
|
||||
)
|
||||
|
||||
// 30000 * 2^10 = 30,720,000 — should be capped at 3,600,000 (1 hour)
|
||||
assertEquals(3_600_000L, request.nextBackoffDelayMs())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Effective Dedup Key Tests
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun pendingRequest_effectiveDedupKey_usesCustomKey() {
|
||||
val request = PendingRequest(
|
||||
endpoint = "test",
|
||||
body = "{}",
|
||||
dedupKey = "custom_key",
|
||||
)
|
||||
assertEquals("custom_key", request.effectiveDedupKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_effectiveDedupKey_autoGenerated() {
|
||||
val request = PendingRequest(
|
||||
endpoint = "test",
|
||||
body = "{}",
|
||||
mutationType = MutationType.ADD,
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "item_1",
|
||||
)
|
||||
assertEquals("WATCHLIST_ITEM_item_1_ADD", request.effectiveDedupKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_effectiveDedupKey_fallbackToEndpointHash() {
|
||||
val request = PendingRequest(
|
||||
endpoint = "api/trpc/test.endpoint",
|
||||
body = """{"key":"value"}""",
|
||||
)
|
||||
val key = request.effectiveDedupKey()
|
||||
assertTrue(key.startsWith("api/trpc/test.endpoint_"))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SyncType Tests
|
||||
// ============================================================
|
||||
@@ -163,9 +545,9 @@ class SyncManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncType_spamDbIsDaily() {
|
||||
assertEquals(SyncPriority.LOW, SyncType.SPAM_DATABASE.priority)
|
||||
assertEquals(24L * 60L, SyncType.SPAM_DATABASE.intervalMinutes)
|
||||
fun syncType_spamDbIsSixHours() {
|
||||
assertEquals(SyncPriority.MEDIUM, SyncType.SPAM_DATABASE.priority)
|
||||
assertEquals(6L * 60L, SyncType.SPAM_DATABASE.intervalMinutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -271,11 +653,108 @@ class SyncManagerTest {
|
||||
assertTrue(status.lastAlertsSync < status.lastFullSync)
|
||||
assertTrue(status.lastExposuresSync < status.lastAlertsSync)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SyncState Tests (new aggregate state)
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun syncState_initialValues() {
|
||||
val state = SyncState.INITIAL
|
||||
|
||||
assertTrue(state.isOnline)
|
||||
assertEquals(0, state.pendingRequestCount)
|
||||
assertFalse(state.isSyncing)
|
||||
assertNull(state.lastSyncResult)
|
||||
assertEquals(0L, state.lastSyncTimestamp)
|
||||
assertTrue(state.pendingRequestsByEntity.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncState_tracksOfflineState() {
|
||||
val state = SyncState.INITIAL.copy(isOnline = false, pendingRequestCount = 3)
|
||||
|
||||
assertFalse(state.isOnline)
|
||||
assertEquals(3, state.pendingRequestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncState_tracksSyncInProgress() {
|
||||
val state = SyncState.INITIAL.copy(isSyncing = true)
|
||||
|
||||
assertTrue(state.isSyncing)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PendingRequestQueue CountByEntity Tests
|
||||
// ============================================================
|
||||
|
||||
@Test
|
||||
fun pendingRequest_countByEntityType() = runBlocking {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test1",
|
||||
body = "{}",
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
))
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test2",
|
||||
body = "{}",
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
))
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test3",
|
||||
body = "{}",
|
||||
entityType = EntityType.ALERT,
|
||||
))
|
||||
|
||||
val counts = fakeQueue.countByEntityType()
|
||||
assertEquals(2, counts[EntityType.WATCHLIST_ITEM])
|
||||
assertEquals(1, counts[EntityType.ALERT])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_hasPendingOperation() = runBlocking {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test",
|
||||
body = "{}",
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "item_1",
|
||||
))
|
||||
|
||||
assertTrue(fakeQueue.hasPendingOperation(EntityType.WATCHLIST_ITEM, "item_1", MutationType.ADD))
|
||||
// Different mutation type should not match
|
||||
assertFalse(fakeQueue.hasPendingOperation(EntityType.WATCHLIST_ITEM, "item_1", MutationType.DELETE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_getPendingEntityIds() = runBlocking {
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test1",
|
||||
body = "{}",
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "a",
|
||||
))
|
||||
fakeQueue.insert(PendingRequest(
|
||||
endpoint = "test2",
|
||||
body = "{}",
|
||||
entityType = EntityType.WATCHLIST_ITEM,
|
||||
entityId = "b",
|
||||
))
|
||||
|
||||
val ids = fakeQueue.getPendingEntityIds(EntityType.WATCHLIST_ITEM)
|
||||
assertEquals(setOf("a", "b"), ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingRequest_getOrderedWithEmptyQueue() {
|
||||
assertTrue(fakeQueue.getOrdered().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fake for [PendingRequestQueue] used in unit tests.
|
||||
* Replaces the file-based persistence with an in-memory list.
|
||||
* Supports all enhanced operations: dedup, ordering, count by entity, etc.
|
||||
*/
|
||||
class FakePendingRequestQueue {
|
||||
private val store = mutableListOf<PendingRequest>()
|
||||
@@ -286,23 +765,49 @@ class FakePendingRequestQueue {
|
||||
fun count(): Int = store.size
|
||||
|
||||
fun insert(request: PendingRequest) {
|
||||
val toInsert = if (request.id == 0L) request.copy(id = nextId++) else request
|
||||
store.add(toInsert)
|
||||
val effectiveDedupKey = request.effectiveDedupKey()
|
||||
val existingIndex = store.indexOfFirst { it.effectiveDedupKey() == effectiveDedupKey }
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing
|
||||
store[existingIndex] = request.copy(
|
||||
id = store[existingIndex].id,
|
||||
timestamp = store[existingIndex].timestamp,
|
||||
)
|
||||
} else {
|
||||
val toInsert = if (request.id == 0L) request.copy(id = nextId++) else request
|
||||
store.add(toInsert)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a request and returns the inserted copy (with assigned id).
|
||||
*/
|
||||
fun insertWithReturn(request: PendingRequest): PendingRequest {
|
||||
val toInsert = if (request.id == 0L) request.copy(id = nextId++) else request
|
||||
store.add(toInsert)
|
||||
return toInsert
|
||||
val effectiveDedupKey = request.effectiveDedupKey()
|
||||
val existingIndex = store.indexOfFirst { it.effectiveDedupKey() == effectiveDedupKey }
|
||||
|
||||
return if (existingIndex >= 0) {
|
||||
val merged = request.copy(
|
||||
id = store[existingIndex].id,
|
||||
timestamp = store[existingIndex].timestamp,
|
||||
)
|
||||
store[existingIndex] = merged
|
||||
merged
|
||||
} else {
|
||||
val toInsert = if (request.id == 0L) request.copy(id = nextId++) else request
|
||||
store.add(toInsert)
|
||||
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)
|
||||
store[idx] = store[idx].copy(
|
||||
retryCount = store[idx].retryCount + 1,
|
||||
lastAttemptAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +833,51 @@ class FakePendingRequestQueue {
|
||||
fun isEmpty(): Boolean = store.isEmpty()
|
||||
|
||||
fun nearExpiryCount(): Int {
|
||||
return store.count { it.retryCount >= it.maxRetries - 1 }
|
||||
return store.count { it.retryCount >= it.maxRetries - 2 }
|
||||
}
|
||||
|
||||
fun countByEntityType(): Map<EntityType, Int> {
|
||||
return store.groupBy { it.entityType }.mapValues { it.value.size }
|
||||
}
|
||||
|
||||
fun hasPendingOperation(entityType: EntityType, entityId: String, mutationType: MutationType): Boolean {
|
||||
val dedupKey = "${entityType.name}_${entityId}_${mutationType.name}"
|
||||
return store.any { it.effectiveDedupKey() == dedupKey }
|
||||
}
|
||||
|
||||
fun getPendingEntityIds(entityType: EntityType): Set<String> {
|
||||
return store.filter { it.entityType == entityType && it.entityId != null }
|
||||
.mapNotNull { it.entityId }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
fun getOrdered(): List<PendingRequest> {
|
||||
if (store.isEmpty()) return emptyList()
|
||||
|
||||
val sorted = store.sortedWith(
|
||||
compareByDescending<PendingRequest> { it.priority }
|
||||
.thenBy { it.timestamp }
|
||||
)
|
||||
|
||||
// Topological sort for dependencies
|
||||
if (sorted.none { it.dependencyIds.isNotEmpty() }) return sorted
|
||||
|
||||
val idMap = sorted.associateBy { it.id }
|
||||
val visited = mutableSetOf<Long>()
|
||||
val result = mutableListOf<PendingRequest>()
|
||||
|
||||
fun visit(request: PendingRequest) {
|
||||
if (request.id in visited) return
|
||||
visited.add(request.id)
|
||||
for (depId in request.dependencyIds) {
|
||||
idMap[depId]?.let { visit(it) }
|
||||
}
|
||||
result.add(request)
|
||||
}
|
||||
|
||||
for (request in sorted) {
|
||||
visit(request)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.kordant.android.navigation
|
||||
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* Unit tests for deep link route mapping.
|
||||
*
|
||||
* Verifies that every notification type maps to the correct
|
||||
* navigation route, ensuring FCM push notifications deep link
|
||||
* to the appropriate screens.
|
||||
*/
|
||||
class DeepLinkRouteTest {
|
||||
|
||||
// ── Screen Route Tests ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `all screens have unique routes`() {
|
||||
val routes = listOf(
|
||||
Screen.Dashboard.route,
|
||||
Screen.Services.route,
|
||||
Screen.Alerts.route,
|
||||
Screen.Settings.route,
|
||||
Screen.Account.route,
|
||||
Screen.Auth.route,
|
||||
Screen.ForgotPassword.route,
|
||||
Screen.ResetPassword.route,
|
||||
Screen.Onboarding.route,
|
||||
Screen.DarkWatch.route,
|
||||
Screen.VoicePrint.route,
|
||||
Screen.SpamShield.route,
|
||||
Screen.CallScreeningSettings.route,
|
||||
Screen.HomeTitle.route,
|
||||
Screen.RemoveBrokers.route,
|
||||
Screen.Family.route,
|
||||
Screen.Billing.route,
|
||||
)
|
||||
assertEquals(routes.toSet().size, routes.size, "All screen routes must be unique")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alertDetail route creates correct path`() {
|
||||
assertEquals("alert_detail/abc-123", Screen.AlertDetail.createRoute("abc-123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serviceDetail route creates correct path`() {
|
||||
assertEquals("service_detail/darkwatch", Screen.ServiceDetail.createRoute("darkwatch"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetPassword route creates correct path`() {
|
||||
assertEquals("reset_password/user@example.com", Screen.ResetPassword.createRoute("user@example.com"))
|
||||
}
|
||||
|
||||
// ── DeepLink Type Tests ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `deepLink dashboard maps to correct screen`() {
|
||||
val deepLink = com.kordant.android.DeepLink.Dashboard
|
||||
assertNotNull(deepLink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deepLink alerts maps to correct screen`() {
|
||||
val deepLink = com.kordant.android.DeepLink.Alerts
|
||||
assertNotNull(deepLink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deepLink alertDetail carries alert ID`() {
|
||||
val deepLink = com.kordant.android.DeepLink.AlertDetail("alert-123")
|
||||
assertEquals("alert-123", deepLink.alertId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deepLink service carries service ID`() {
|
||||
val deepLink = com.kordant.android.DeepLink.Service("darkwatch")
|
||||
assertEquals("darkwatch", deepLink.serviceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deepLink darkwatch exists`() {
|
||||
val deepLink = com.kordant.android.DeepLink.DarkWatch
|
||||
assertNotNull(deepLink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deepLink family exists`() {
|
||||
val deepLink = com.kordant.android.DeepLink.Family
|
||||
assertNotNull(deepLink)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deepLink billing exists`() {
|
||||
val deepLink = com.kordant.android.DeepLink.Billing
|
||||
assertNotNull(deepLink)
|
||||
}
|
||||
|
||||
// ── Notification Type to Screen Mapping ────────────────────
|
||||
|
||||
@Test
|
||||
fun `security alert maps to alert detail screen`() {
|
||||
val type = com.kordant.android.notification.NotificationType.SECURITY_ALERT
|
||||
// The screen mapping is in NotificationBuilder.screenForType()
|
||||
// We verify the enum exists and has the correct key
|
||||
assertEquals("security_alert", type.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exposure warning maps to darkwatch screen`() {
|
||||
val type = com.kordant.android.notification.NotificationType.EXPOSURE_WARNING
|
||||
assertEquals("exposure_warning", type.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scan complete maps to dashboard screen`() {
|
||||
val type = com.kordant.android.notification.NotificationType.SCAN_COMPLETE
|
||||
assertEquals("scan_complete", type.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `family invite maps to family screen`() {
|
||||
val type = com.kordant.android.notification.NotificationType.FAMILY_INVITE
|
||||
assertEquals("family_invite", type.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subscription renewal maps to billing screen`() {
|
||||
val type = com.kordant.android.notification.NotificationType.SUBSCRIPTION_RENEWAL
|
||||
assertEquals("subscription_renewal", type.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marketing maps to dashboard screen`() {
|
||||
val type = com.kordant.android.notification.NotificationType.MARKETING
|
||||
assertEquals("marketing", type.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system maps to settings screen`() {
|
||||
val type = com.kordant.android.notification.NotificationType.SYSTEM
|
||||
assertEquals("system", type.key)
|
||||
}
|
||||
|
||||
// ── Deep Link URI Parsing Tests ────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `kordant scheme dashboard URI is valid`() {
|
||||
val uri = android.net.Uri.parse("kordant://dashboard")
|
||||
assertEquals("kordant", uri.scheme)
|
||||
assertEquals("dashboard", uri.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kordant scheme alert URI carries ID`() {
|
||||
val uri = android.net.Uri.parse("kordant://alert?id=abc-123")
|
||||
assertEquals("kordant", uri.scheme)
|
||||
assertEquals("alert", uri.host)
|
||||
assertEquals("abc-123", uri.getQueryParameter("id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kordant scheme darkwatch URI is valid`() {
|
||||
val uri = android.net.Uri.parse("kordant://darkwatch")
|
||||
assertEquals("kordant", uri.scheme)
|
||||
assertEquals("darkwatch", uri.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kordant scheme family URI is valid`() {
|
||||
val uri = android.net.Uri.parse("kordant://family")
|
||||
assertEquals("kordant", uri.scheme)
|
||||
assertEquals("family", uri.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kordant scheme billing URI is valid`() {
|
||||
val uri = android.net.Uri.parse("kordant://billing")
|
||||
assertEquals("kordant", uri.scheme)
|
||||
assertEquals("billing", uri.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `https kordant.ai dashboard URI is valid`() {
|
||||
val uri = android.net.Uri.parse("https://kordant.ai/dashboard")
|
||||
assertEquals("https", uri.scheme)
|
||||
assertEquals("kordant.ai", uri.host)
|
||||
assertEquals("dashboard", uri.pathSegments.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `https kordant.ai alert URI carries ID in path`() {
|
||||
val uri = android.net.Uri.parse("https://kordant.ai/alerts/abc-123")
|
||||
assertEquals("https", uri.scheme)
|
||||
assertEquals("kordant.ai", uri.host)
|
||||
assertEquals("alerts", uri.pathSegments.firstOrNull())
|
||||
assertEquals("abc-123", uri.pathSegments.getOrNull(1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package com.kordant.android.notification
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Integration tests for FCM message handling.
|
||||
*
|
||||
* Tests the full pipeline from raw FCM data maps through payload
|
||||
* parsing, preference checking, and route resolution.
|
||||
*/
|
||||
@RunWith(JUnit4::class)
|
||||
class FCMMessageHandlingTest {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
NotificationAnalytics.reset()
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
NotificationAnalytics.reset()
|
||||
}
|
||||
|
||||
// ── Alert Notification ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `alert notification parses and routes to alert detail`() {
|
||||
val fcmData = mapOf(
|
||||
"type" to "security_alert",
|
||||
"title" to "Data Breach Detected",
|
||||
"body" to "Your email was found in the Equifax breach",
|
||||
"alert_id" to "alert-001",
|
||||
"severity" to "critical",
|
||||
"screen" to "alert_detail",
|
||||
"id" to "alert-001"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.fromFcmData(fcmData)
|
||||
assertNotNull(payload)
|
||||
assertEquals(NotificationType.SECURITY_ALERT, payload!!.type)
|
||||
assertEquals("alert-001", payload.alertId)
|
||||
assertEquals("alert_detail", payload.deepLinkScreen)
|
||||
assertEquals("alert-001", payload.deepLinkId)
|
||||
assertEquals("critical", payload.severity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alert notification has correct actions`() {
|
||||
val actions = NotificationActions.actionsForType(NotificationType.SECURITY_ALERT)
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_VIEW_DETAILS))
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_MARK_SAFE))
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_DISMISS))
|
||||
assertEquals(3, actions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alert notification maps to security alerts channel`() {
|
||||
val channelId = NotificationChannelManager.channelForType(NotificationType.SECURITY_ALERT)
|
||||
assertEquals(NotificationChannelManager.CHANNEL_SECURITY_ALERTS, channelId)
|
||||
}
|
||||
|
||||
// ── Exposure Notification ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `exposure notification parses and routes to darkwatch`() {
|
||||
val fcmData = mapOf(
|
||||
"type" to "exposure_warning",
|
||||
"title" to "Data Found on Broker Site",
|
||||
"body" to "Your phone number was found on WhitePages",
|
||||
"exposure_id" to "exp-001",
|
||||
"image_url" to "https://example.com/screenshot.png",
|
||||
"source" to "WhitePages"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.fromFcmData(fcmData)
|
||||
assertNotNull(payload)
|
||||
assertEquals(NotificationType.EXPOSURE_WARNING, payload!!.type)
|
||||
assertEquals("exp-001", payload.exposureId)
|
||||
assertEquals("https://example.com/screenshot.png", payload.imageUrl)
|
||||
assertEquals("WhitePages", payload.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exposure notification has correct actions`() {
|
||||
val actions = NotificationActions.actionsForType(NotificationType.EXPOSURE_WARNING)
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_VIEW_EXPOSURE))
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_START_REMOVAL))
|
||||
assertEquals(2, actions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exposure notification maps to exposure warnings channel`() {
|
||||
val channelId = NotificationChannelManager.channelForType(NotificationType.EXPOSURE_WARNING)
|
||||
assertEquals(NotificationChannelManager.CHANNEL_EXPOSURE_WARNINGS, channelId)
|
||||
}
|
||||
|
||||
// ── Scan Complete Notification ─────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `scan complete notification parses and routes to dashboard`() {
|
||||
val fcmData = mapOf(
|
||||
"type" to "scan_complete",
|
||||
"title" to "Dark Web Scan Finished",
|
||||
"body" to "Scan found 3 new exposures",
|
||||
"scan_id" to "scan-001"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.fromFcmData(fcmData)
|
||||
assertNotNull(payload)
|
||||
assertEquals(NotificationType.SCAN_COMPLETE, payload!!.type)
|
||||
assertEquals("scan-001", payload.scanId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scan complete notification has correct actions`() {
|
||||
val actions = NotificationActions.actionsForType(NotificationType.SCAN_COMPLETE)
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_VIEW_RESULTS))
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_SHARE))
|
||||
assertEquals(2, actions.size)
|
||||
}
|
||||
|
||||
// ── Family Invite Notification ─────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `family invite notification parses correctly`() {
|
||||
val fcmData = mapOf(
|
||||
"type" to "family_invite",
|
||||
"title" to "Family Invite",
|
||||
"body" to "John invited you to join the family group",
|
||||
"screen" to "family"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.fromFcmData(fcmData)
|
||||
assertNotNull(payload)
|
||||
assertEquals(NotificationType.FAMILY_INVITE, payload!!.type)
|
||||
assertEquals("family", payload.deepLinkScreen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `family invite notification has correct actions`() {
|
||||
val actions = NotificationActions.actionsForType(NotificationType.FAMILY_INVITE)
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_ACCEPT_INVITE))
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_DECLINE_INVITE))
|
||||
assertEquals(2, actions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `family invite notification maps to family invite channel`() {
|
||||
val channelId = NotificationChannelManager.channelForType(NotificationType.FAMILY_INVITE)
|
||||
assertEquals(NotificationChannelManager.CHANNEL_FAMILY_INVITE, channelId)
|
||||
}
|
||||
|
||||
// ── Subscription Renewal Notification ──────────────────────
|
||||
|
||||
@Test
|
||||
fun `subscription renewal notification parses correctly`() {
|
||||
val fcmData = mapOf(
|
||||
"type" to "subscription_renewal",
|
||||
"title" to "Subscription Renewal",
|
||||
"body" to "Your plan renews in 3 days for $9.99",
|
||||
"screen" to "billing"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.fromFcmData(fcmData)
|
||||
assertNotNull(payload)
|
||||
assertEquals(NotificationType.SUBSCRIPTION_RENEWAL, payload!!.type)
|
||||
assertEquals("billing", payload.deepLinkScreen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subscription renewal notification has correct actions`() {
|
||||
val actions = NotificationActions.actionsForType(NotificationType.SUBSCRIPTION_RENEWAL)
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_RENEW_NOW))
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_MANAGE_SUBSCRIPTION))
|
||||
assertEquals(2, actions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subscription renewal maps to subscription channel`() {
|
||||
val channelId = NotificationChannelManager.channelForType(NotificationType.SUBSCRIPTION_RENEWAL)
|
||||
assertEquals(NotificationChannelManager.CHANNEL_SUBSCRIPTION, channelId)
|
||||
}
|
||||
|
||||
// ── Marketing Notification ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `marketing notification parses correctly`() {
|
||||
val fcmData = mapOf(
|
||||
"type" to "marketing",
|
||||
"title" to "New Feature: DarkWatch Pro",
|
||||
"body" to "Check out our enhanced dark web monitoring",
|
||||
"screen" to "dashboard"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.fromFcmData(fcmData)
|
||||
assertNotNull(payload)
|
||||
assertEquals(NotificationType.MARKETING, payload!!.type)
|
||||
assertEquals("dashboard", payload.deepLinkScreen)
|
||||
}
|
||||
|
||||
// ── Malformed Payload Handling ─────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `malformed payload with missing type returns null`() {
|
||||
val fcmData = mapOf(
|
||||
"title" to "Test",
|
||||
"body" to "Body"
|
||||
)
|
||||
assertNull(NotificationPayload.fromFcmData(fcmData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed payload with unknown type returns null`() {
|
||||
val fcmData = mapOf(
|
||||
"type" to "unknown_type_xyz",
|
||||
"title" to "Test",
|
||||
"body" to "Body"
|
||||
)
|
||||
assertNull(NotificationPayload.fromFcmData(fcmData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty data map returns null`() {
|
||||
assertNull(NotificationPayload.fromFcmData(emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null data map returns null`() {
|
||||
assertNull(NotificationPayload.fromFcmData(emptyMap()))
|
||||
}
|
||||
|
||||
// ── Analytics Tracking ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `analytics tracks delivery`() {
|
||||
val payload = NotificationPayload(
|
||||
type = NotificationType.SECURITY_ALERT,
|
||||
title = "Test",
|
||||
body = "Test body",
|
||||
alertId = "alert-001"
|
||||
)
|
||||
|
||||
// Track delivery
|
||||
NotificationAnalytics.trackDelivery(
|
||||
object : android.content.Context() {
|
||||
override fun getApplicationContext() = this
|
||||
override fun getPackageName() = "test"
|
||||
override fun getApplicationInfo() = throw UnsupportedOperationException()
|
||||
override fun getAssets() = throw UnsupportedOperationException()
|
||||
override fun getResources() = throw UnsupportedOperationException()
|
||||
override fun getContentResolver() = throw UnsupportedOperationException()
|
||||
override fun getMainLooper() = throw UnsupportedOperationException()
|
||||
override fun getCacheDir() = throw UnsupportedOperationException()
|
||||
override fun getFilesDir() = throw UnsupportedOperationException()
|
||||
override fun getExternalCacheDir() = throw UnsupportedOperationException()
|
||||
override fun getExternalFilesDir(type: String?) = throw UnsupportedOperationException()
|
||||
override fun getExternalFilesDirs(type: String?) = throw UnsupportedOperationException()
|
||||
override fun getObbDir() = throw UnsupportedOperationException()
|
||||
override fun getNoBackupFilesDir() = throw UnsupportedOperationException()
|
||||
override fun getCodeCacheDir() = throw UnsupportedOperationException()
|
||||
override fun getDataDir() = throw UnsupportedOperationException()
|
||||
override fun getDir(name: String?, mode: Int) = throw UnsupportedOperationException()
|
||||
override fun openFileInput(name: String) = throw UnsupportedOperationException()
|
||||
override fun openFileOutput(name: String, mode: Int) = throw UnsupportedOperationException()
|
||||
override fun deleteFile(name: String) = throw UnsupportedOperationException()
|
||||
override fun fileList() = throw UnsupportedOperationException()
|
||||
override fun getSystemService(name: String) = throw UnsupportedOperationException()
|
||||
override fun <T : Any> getSystemService(serviceClass: Class<T>) = throw UnsupportedOperationException()
|
||||
override fun startActivity(intent: android.content.Intent) = throw UnsupportedOperationException()
|
||||
override fun startActivities(intents: Array<out android.content.Intent>) = throw UnsupportedOperationException()
|
||||
override fun startActivities(intents: Array<out android.content.Intent>, options: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun startIntentSender(intent: android.content.IntentSender) = throw UnsupportedOperationException()
|
||||
override fun startIntentSender(intent: android.content.IntentSender, intentToFill: android.content.Intent?, flags: Int, requestCode: Int, startFlags: Int) = throw UnsupportedOperationException()
|
||||
override fun startIntentSender(intent: android.content.IntentSender, intentToFill: android.content.Intent?, flags: Int, requestCode: Int, startFlags: Int, options: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcast(intent: android.content.Intent) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcast(intent: android.content.Intent, receiverPermission: String?) = throw UnsupportedOperationException()
|
||||
override fun sendOrderedBroadcast(intent: android.content.Intent, receiverPermission: String?) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcast(intent: android.content.Intent, receiverPermission: String?, resultReceiver: android.content.BroadcastReceiver?, scheduler: android.os.Handler?, initialCode: Int, initialData: String?, initialExtras: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendOrderedBroadcast(intent: android.content.Intent, receiverPermission: String?, resultReceiver: android.content.BroadcastReceiver?, scheduler: android.os.Handler?, initialCode: Int, initialData: String?, initialExtras: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcastAsUser(intent: android.content.Intent, user: android.os.UserHandle) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcastAsUser(intent: android.content.Intent, user: android.os.UserHandle, receiverPermission: String?) = throw UnsupportedOperationException()
|
||||
override fun sendOrderedBroadcastAsUser(intent: android.content.Intent, user: android.os.UserHandle, receiverPermission: String?, resultReceiver: android.content.BroadcastReceiver?, scheduler: android.os.Handler?, initialCode: Int, initialData: String?, initialExtras: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendStickyBroadcast(intent: android.content.Intent) = throw UnsupportedOperationException()
|
||||
override fun sendStickyOrderedBroadcast(intent: android.content.Intent, resultReceiver: android.content.BroadcastReceiver?, scheduler: android.os.Handler?, initialCode: Int, initialData: String?, initialExtras: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendStickyBroadcastAsUser(intent: android.content.Intent, user: android.os.UserHandle) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter): android.content.Intent? = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, broadcastPermission: String?, scheduler: android.os.Handler?): android.content.Intent? = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?) = throw UnsupportedOperationException()
|
||||
override fun unregisterReceiver(receiver: android.content.BroadcastReceiver?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, scheduler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, permission: String?, scheduler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, permission: String?, scheduler: android.os.Handler?, userId: Int) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(user: android.os.UserHandle, receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, broadcastPermission: String?, scheduler: android.os.Handler?): android.content.Intent? = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(user: android.os.UserHandle, receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, scheduler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(user: android.os.UserHandle, receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, permission: String?, scheduler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(user: android.os.UserHandle, receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, permission: String?, scheduler: android.os.Handler?, userId: Int) = throw UnsupportedOperationException()
|
||||
override fun checkPermission(permission: String?, pid: Int, uid: Int): Int = throw UnsupportedOperationException()
|
||||
override fun checkCallingPermission(permission: String?): Int = throw UnsupportedOperationException()
|
||||
override fun checkCallingOrSelfPermission(permission: String?): Int = throw UnsupportedOperationException()
|
||||
override fun checkSelfPermission(permission: String?): Int = throw UnsupportedOperationException()
|
||||
override fun enforcePermission(permission: String?, pid: Int, uid: Int, callerName: String?) = throw UnsupportedOperationException()
|
||||
override fun enforceCallingPermission(permission: String?, callerName: String?) = throw UnsupportedOperationException()
|
||||
override fun enforceCallingOrSelfPermission(permission: String?, callerName: String?) = throw UnsupportedOperationException()
|
||||
override fun enforceUserPermission(user: android.os.UserHandle) = throw UnsupportedOperationException()
|
||||
override fun grantUriPermission(targetPkg: String?, uri: android.net.Uri?, modeFlags: Int) = throw UnsupportedOperationException()
|
||||
override fun revokeUriPermission(uri: android.net.Uri?, modeFlags: Int) = throw UnsupportedOperationException()
|
||||
override fun revokeUriPermission(targetPkg: String?, uri: android.net.Uri?, modeFlags: Int) = throw UnsupportedOperationException()
|
||||
override fun getSharedPreferences(name: String?, mode: Int): android.content.SharedPreferences = throw UnsupportedOperationException()
|
||||
override fun moveFromSourceUri(sourceUri: android.net.Uri, destinationUri: android.net.Uri): Boolean = throw UnsupportedOperationException()
|
||||
override fun moveFromSourceUri(sourceUri: android.net.Uri, destinationUri: android.net.Uri, resultReceiver: android.content.ContentResolver.OnMoveResultListener?, handler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun createDeviceProtectedStorageContext(): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun getDeviceProtectedContext(): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun getPackageResourcePath() = throw UnsupportedOperationException()
|
||||
override fun getPackageCodePath() = throw UnsupportedOperationException()
|
||||
override fun getApplicationContext() = this
|
||||
override fun applyTheme(theme: android.content.res.Resources.Theme?) = throw UnsupportedOperationException()
|
||||
override fun theme: android.content.res.Resources.Theme get() = throw UnsupportedOperationException()
|
||||
override fun getLocale(): java.util.Locale = throw UnsupportedOperationException()
|
||||
override fun createConfigurationContext(config: android.content.res.Configuration): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun createWindowContext(layoutInDisplay: Int): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun getDisplayId(): Int = throw UnsupportedOperationException()
|
||||
override fun createDisplayContext(display: android.view.Display): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun createConfigurationContextOverrides(config: android.content.res.Configuration?, locale: java.util.Locale?, layoutDirection: Int): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun getApplicationAssets() = throw UnsupportedOperationException()
|
||||
override fun getExternalMediaDirs(): Array<out java.io.File> = throw UnsupportedOperationException()
|
||||
override fun getStorageUris(): Array<out android.net.Uri> = throw UnsupportedOperationException()
|
||||
override fun isDeviceProtectedStorage(): Boolean = throw UnsupportedOperationException()
|
||||
override fun isRestricted(): Boolean = throw UnsupportedOperationException()
|
||||
override fun getSharedPreferencesPath(name: String?) = throw UnsupportedOperationException()
|
||||
override fun makeIntentCreator(): android.content.Intent.IntentCreator<*> = throw UnsupportedOperationException()
|
||||
override fun getAutofillOptions(): Array<out android.view.autofill.AutofillId> = throw UnsupportedOperationException()
|
||||
override fun getSharedPreferences(name: String?, mode: Int): android.content.SharedPreferences {
|
||||
return object : android.content.SharedPreferences {
|
||||
override fun contains(key: String?) = false
|
||||
override fun getString(key: String?, defValue: String?) = defValue
|
||||
override fun getLong(key: String?, defValue: Long) = defValue
|
||||
override fun getInt(key: String?, defValue: Int) = defValue
|
||||
override fun getFloat(key: String?, defValue: Float) = defValue
|
||||
override fun getBoolean(key: String?, defValue: Boolean) = defValue
|
||||
override fun getAll() = emptyMap<String, Any>()
|
||||
override fun registerOnSharedPreferenceChangeListener(listener: android.content.SharedPreferences.OnSharedPreferenceChangeListener?) {}
|
||||
override fun unregisterOnSharedPreferenceChangeListener(listener: android.content.SharedPreferences.OnSharedPreferenceChangeListener?) {}
|
||||
override fun edit(): android.content.SharedPreferences.Editor = object : android.content.SharedPreferences.Editor {
|
||||
override fun putString(key: String?, value: String?) = this@Editor
|
||||
override fun putInt(key: String?, value: Int) = this@Editor
|
||||
override fun putLong(key: String?, value: Long) = this@Editor
|
||||
override fun putFloat(key: String?, value: Float) = this@Editor
|
||||
override fun putBoolean(key: String?, value: Boolean) = this@Editor
|
||||
override fun putStringSet(key: String?, values: Set<String>?) = this@Editor
|
||||
override fun remove(key: String?) = this@Editor
|
||||
override fun clear() = this@Editor
|
||||
override fun commit() = true
|
||||
override fun apply() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
payload
|
||||
)
|
||||
|
||||
val summary = NotificationAnalytics.getSummary()
|
||||
assertEquals(1, summary.delivered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analytics tracks open rate`() {
|
||||
val payload = NotificationPayload(
|
||||
type = NotificationType.SECURITY_ALERT,
|
||||
title = "Test",
|
||||
body = "Test body",
|
||||
alertId = "alert-001"
|
||||
)
|
||||
|
||||
// Create a minimal context for analytics
|
||||
val testContext = object : android.content.Context() {
|
||||
override fun getApplicationContext() = this
|
||||
override fun getPackageName() = "test"
|
||||
override fun getApplicationInfo() = throw UnsupportedOperationException()
|
||||
override fun getAssets() = throw UnsupportedOperationException()
|
||||
override fun getResources() = throw UnsupportedOperationException()
|
||||
override fun getContentResolver() = throw UnsupportedOperationException()
|
||||
override fun getMainLooper() = throw UnsupportedOperationException()
|
||||
override fun getCacheDir() = throw UnsupportedOperationException()
|
||||
override fun getFilesDir() = throw UnsupportedOperationException()
|
||||
override fun getExternalCacheDir() = throw UnsupportedOperationException()
|
||||
override fun getExternalFilesDir(type: String?) = throw UnsupportedOperationException()
|
||||
override fun getExternalFilesDirs(type: String?) = throw UnsupportedOperationException()
|
||||
override fun getObbDir() = throw UnsupportedOperationException()
|
||||
override fun getNoBackupFilesDir() = throw UnsupportedOperationException()
|
||||
override fun getCodeCacheDir() = throw UnsupportedOperationException()
|
||||
override fun getDataDir() = throw UnsupportedOperationException()
|
||||
override fun getDir(name: String?, mode: Int) = throw UnsupportedOperationException()
|
||||
override fun openFileInput(name: String) = throw UnsupportedOperationException()
|
||||
override fun openFileOutput(name: String, mode: Int) = throw UnsupportedOperationException()
|
||||
override fun deleteFile(name: String) = throw UnsupportedOperationException()
|
||||
override fun fileList() = throw UnsupportedOperationException()
|
||||
override fun getSystemService(name: String) = throw UnsupportedOperationException()
|
||||
override fun <T : Any> getSystemService(serviceClass: Class<T>) = throw UnsupportedOperationException()
|
||||
override fun startActivity(intent: android.content.Intent) = throw UnsupportedOperationException()
|
||||
override fun startActivities(intents: Array<out android.content.Intent>) = throw UnsupportedOperationException()
|
||||
override fun startActivities(intents: Array<out android.content.Intent>, options: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun startIntentSender(intent: android.content.IntentSender) = throw UnsupportedOperationException()
|
||||
override fun startIntentSender(intent: android.content.IntentSender, intentToFill: android.content.Intent?, flags: Int, requestCode: Int, startFlags: Int) = throw UnsupportedOperationException()
|
||||
override fun startIntentSender(intent: android.content.IntentSender, intentToFill: android.content.Intent?, flags: Int, requestCode: Int, startFlags: Int, options: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcast(intent: android.content.Intent) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcast(intent: android.content.Intent, receiverPermission: String?) = throw UnsupportedOperationException()
|
||||
override fun sendOrderedBroadcast(intent: android.content.Intent, receiverPermission: String?) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcast(intent: android.content.Intent, receiverPermission: String?, resultReceiver: android.content.BroadcastReceiver?, scheduler: android.os.Handler?, initialCode: Int, initialData: String?, initialExtras: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendOrderedBroadcast(intent: android.content.Intent, receiverPermission: String?, resultReceiver: android.content.BroadcastReceiver?, scheduler: android.os.Handler?, initialCode: Int, initialData: String?, initialExtras: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcastAsUser(intent: android.content.Intent, user: android.os.UserHandle) = throw UnsupportedOperationException()
|
||||
override fun sendBroadcastAsUser(intent: android.content.Intent, user: android.os.UserHandle, receiverPermission: String?) = throw UnsupportedOperationException()
|
||||
override fun sendOrderedBroadcastAsUser(intent: android.content.Intent, user: android.os.UserHandle, receiverPermission: String?, resultReceiver: android.content.BroadcastReceiver?, scheduler: android.os.Handler?, initialCode: Int, initialData: String?, initialExtras: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendStickyBroadcast(intent: android.content.Intent) = throw UnsupportedOperationException()
|
||||
override fun sendStickyOrderedBroadcast(intent: android.content.Intent, resultReceiver: android.content.BroadcastReceiver?, scheduler: android.os.Handler?, initialCode: Int, initialData: String?, initialExtras: android.os.Bundle?) = throw UnsupportedOperationException()
|
||||
override fun sendStickyBroadcastAsUser(intent: android.content.Intent, user: android.os.UserHandle) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter): android.content.Intent? = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, broadcastPermission: String?, scheduler: android.os.Handler?): android.content.Intent? = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?) = throw UnsupportedOperationException()
|
||||
override fun unregisterReceiver(receiver: android.content.BroadcastReceiver?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, scheduler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, permission: String?, scheduler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, permission: String?, scheduler: android.os.Handler?, userId: Int) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(user: android.os.UserHandle, receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, broadcastPermission: String?, scheduler: android.os.Handler?): android.content.Intent? = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(user: android.os.UserHandle, receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, scheduler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(user: android.os.UserHandle, receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, permission: String?, scheduler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun registerReceiver(user: android.os.UserHandle, receiver: android.content.BroadcastReceiver?, filter: android.content.IntentFilter, resultWho: String?, resultCode: Int, data: String?, permission: String?, scheduler: android.os.Handler?, userId: Int) = throw UnsupportedOperationException()
|
||||
override fun checkPermission(permission: String?, pid: Int, uid: Int): Int = throw UnsupportedOperationException()
|
||||
override fun checkCallingPermission(permission: String?): Int = throw UnsupportedOperationException()
|
||||
override fun checkCallingOrSelfPermission(permission: String?): Int = throw UnsupportedOperationException()
|
||||
override fun checkSelfPermission(permission: String?): Int = throw UnsupportedOperationException()
|
||||
override fun enforcePermission(permission: String?, pid: Int, uid: Int, callerName: String?) = throw UnsupportedOperationException()
|
||||
override fun enforceCallingPermission(permission: String?, callerName: String?) = throw UnsupportedOperationException()
|
||||
override fun enforceCallingOrSelfPermission(permission: String?, callerName: String?) = throw UnsupportedOperationException()
|
||||
override fun enforceUserPermission(user: android.os.UserHandle) = throw UnsupportedOperationException()
|
||||
override fun grantUriPermission(targetPkg: String?, uri: android.net.Uri?, modeFlags: Int) = throw UnsupportedOperationException()
|
||||
override fun revokeUriPermission(uri: android.net.Uri?, modeFlags: Int) = throw UnsupportedOperationException()
|
||||
override fun revokeUriPermission(targetPkg: String?, uri: android.net.Uri?, modeFlags: Int) = throw UnsupportedOperationException()
|
||||
override fun getSharedPreferences(name: String?, mode: Int): android.content.SharedPreferences {
|
||||
return object : android.content.SharedPreferences {
|
||||
override fun contains(key: String?) = false
|
||||
override fun getString(key: String?, defValue: String?) = defValue
|
||||
override fun getLong(key: String?, defValue: Long) = defValue
|
||||
override fun getInt(key: String?, defValue: Int) = defValue
|
||||
override fun getFloat(key: String?, defValue: Float) = defValue
|
||||
override fun getBoolean(key: String?, defValue: Boolean) = defValue
|
||||
override fun getAll() = emptyMap<String, Any>()
|
||||
override fun registerOnSharedPreferenceChangeListener(listener: android.content.SharedPreferences.OnSharedPreferenceChangeListener?) {}
|
||||
override fun unregisterOnSharedPreferenceChangeListener(listener: android.content.SharedPreferences.OnSharedPreferenceChangeListener?) {}
|
||||
override fun edit(): android.content.SharedPreferences.Editor = object : android.content.SharedPreferences.Editor {
|
||||
override fun putString(key: String?, value: String?) = this@Editor
|
||||
override fun putInt(key: String?, value: Int) = this@Editor
|
||||
override fun putLong(key: String?, value: Long) = this@Editor
|
||||
override fun putFloat(key: String?, value: Float) = this@Editor
|
||||
override fun putBoolean(key: String?, value: Boolean) = this@Editor
|
||||
override fun putStringSet(key: String?, values: Set<String>?) = this@Editor
|
||||
override fun remove(key: String?) = this@Editor
|
||||
override fun clear() = this@Editor
|
||||
override fun commit() = true
|
||||
override fun apply() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun moveFromSourceUri(sourceUri: android.net.Uri, destinationUri: android.net.Uri): Boolean = throw UnsupportedOperationException()
|
||||
override fun moveFromSourceUri(sourceUri: android.net.Uri, destinationUri: android.net.Uri, resultReceiver: android.content.ContentResolver.OnMoveResultListener?, handler: android.os.Handler?) = throw UnsupportedOperationException()
|
||||
override fun createDeviceProtectedStorageContext(): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun getDeviceProtectedContext(): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun getPackageResourcePath() = throw UnsupportedOperationException()
|
||||
override fun getPackageCodePath() = throw UnsupportedOperationException()
|
||||
override fun applyTheme(theme: android.content.res.Resources.Theme?) = throw UnsupportedOperationException()
|
||||
override fun theme: android.content.res.Resources.Theme get() = throw UnsupportedOperationException()
|
||||
override fun getLocale(): java.util.Locale = throw UnsupportedOperationException()
|
||||
override fun createConfigurationContext(config: android.content.res.Configuration): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun createWindowContext(layoutInDisplay: Int): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun getDisplayId(): Int = throw UnsupportedOperationException()
|
||||
override fun createDisplayContext(display: android.view.Display): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun createConfigurationContextOverrides(config: android.content.res.Configuration?, locale: java.util.Locale?, layoutDirection: Int): android.content.Context = throw UnsupportedOperationException()
|
||||
override fun getApplicationAssets() = throw UnsupportedOperationException()
|
||||
override fun getExternalMediaDirs(): Array<out java.io.File> = throw UnsupportedOperationException()
|
||||
override fun getStorageUris(): Array<out android.net.Uri> = throw UnsupportedOperationException()
|
||||
override fun isDeviceProtectedStorage(): Boolean = throw UnsupportedOperationException()
|
||||
override fun isRestricted(): Boolean = throw UnsupportedOperationException()
|
||||
override fun getSharedPreferencesPath(name: String?) = throw UnsupportedOperationException()
|
||||
override fun makeIntentCreator(): android.content.Intent.IntentCreator<*> = throw UnsupportedOperationException()
|
||||
override fun getAutofillOptions(): Array<out android.view.autofill.AutofillId> = throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
NotificationAnalytics.trackDelivery(testContext, payload)
|
||||
NotificationAnalytics.trackShown(testContext, payload)
|
||||
NotificationAnalytics.trackOpen(testContext, payload)
|
||||
NotificationAnalytics.trackAction(testContext, payload, "view_details")
|
||||
|
||||
val summary = NotificationAnalytics.getSummary()
|
||||
assertEquals(1, summary.delivered)
|
||||
assertEquals(1, summary.shown)
|
||||
assertEquals(1, summary.opened)
|
||||
assertEquals(1, summary.actions)
|
||||
assertEquals(1.0, summary.openRate)
|
||||
assertEquals(1.0, summary.actionRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `analytics summary resets correctly`() {
|
||||
NotificationAnalytics.reset()
|
||||
val summary = NotificationAnalytics.getSummary()
|
||||
assertEquals(0, summary.delivered)
|
||||
assertEquals(0, summary.shown)
|
||||
assertEquals(0, summary.opened)
|
||||
assertEquals(0.0, summary.openRate)
|
||||
}
|
||||
|
||||
// ── Foreground Notification Manager Tests ──────────────────
|
||||
|
||||
@Test
|
||||
fun `foreground manager reports correct initial state`() {
|
||||
assertFalse(ForegroundNotificationManager.isAppInForeground)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreground manager updates foreground state`() {
|
||||
ForegroundNotificationManager.setAppForeground(true)
|
||||
assertTrue(ForegroundNotificationManager.isAppInForeground)
|
||||
|
||||
ForegroundNotificationManager.setAppForeground(false)
|
||||
assertFalse(ForegroundNotificationManager.isAppInForeground)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreground manager rejects notification when not in foreground`() {
|
||||
ForegroundNotificationManager.setAppForeground(false)
|
||||
val payload = NotificationPayload(
|
||||
type = NotificationType.SECURITY_ALERT,
|
||||
title = "Test",
|
||||
body = "Test body"
|
||||
)
|
||||
assertFalse(ForegroundNotificationManager.sendNotification(payload))
|
||||
}
|
||||
|
||||
// ── Channel Resolution Tests ───────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `channel resolution handles family invite type`() {
|
||||
val channelId = NotificationChannelManager.resolveChannelId("family_invite")
|
||||
assertEquals(NotificationChannelManager.CHANNEL_FAMILY_INVITE, channelId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `channel resolution handles subscription type`() {
|
||||
val channelId = NotificationChannelManager.resolveChannelId("subscription_renewal")
|
||||
assertEquals(NotificationChannelManager.CHANNEL_SUBSCRIPTION, channelId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `channel resolution handles billing alias`() {
|
||||
val channelId = NotificationChannelManager.resolveChannelId("billing")
|
||||
assertEquals(NotificationChannelManager.CHANNEL_SUBSCRIPTION, channelId)
|
||||
}
|
||||
|
||||
// ── All Channel IDs Test ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `all channel IDs includes new channels`() {
|
||||
val ids = NotificationChannelManager.allChannelIds()
|
||||
assertEquals(8, ids.size, "Must have exactly 8 notification channels")
|
||||
assertTrue(ids.contains(NotificationChannelManager.CHANNEL_FAMILY_INVITE))
|
||||
assertTrue(ids.contains(NotificationChannelManager.CHANNEL_SUBSCRIPTION))
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ class NotificationBuilderTest {
|
||||
assertEquals(NotificationType.EXPOSURE_WARNING, NotificationType.fromKey("exposure_warning"))
|
||||
assertEquals(NotificationType.SCAN_COMPLETE, NotificationType.fromKey("scan_complete"))
|
||||
assertEquals(NotificationType.FAMILY_ACTIVITY, NotificationType.fromKey("family_activity"))
|
||||
assertEquals(NotificationType.FAMILY_INVITE, NotificationType.fromKey("family_invite"))
|
||||
assertEquals(NotificationType.SUBSCRIPTION_RENEWAL, NotificationType.fromKey("subscription_renewal"))
|
||||
assertEquals(NotificationType.MARKETING, NotificationType.fromKey("marketing"))
|
||||
assertEquals(NotificationType.SYSTEM, NotificationType.fromKey("system"))
|
||||
}
|
||||
@@ -369,6 +371,14 @@ class NotificationBuilderTest {
|
||||
NotificationChannelManager.CHANNEL_FAMILY_ACTIVITY,
|
||||
NotificationChannelManager.channelForType(NotificationType.FAMILY_ACTIVITY)
|
||||
)
|
||||
assertEquals(
|
||||
NotificationChannelManager.CHANNEL_FAMILY_INVITE,
|
||||
NotificationChannelManager.channelForType(NotificationType.FAMILY_INVITE)
|
||||
)
|
||||
assertEquals(
|
||||
NotificationChannelManager.CHANNEL_SUBSCRIPTION,
|
||||
NotificationChannelManager.channelForType(NotificationType.SUBSCRIPTION_RENEWAL)
|
||||
)
|
||||
assertEquals(
|
||||
NotificationChannelManager.CHANNEL_MARKETING,
|
||||
NotificationChannelManager.channelForType(NotificationType.MARKETING)
|
||||
@@ -385,7 +395,7 @@ class NotificationBuilderTest {
|
||||
fun `all channel IDs are unique`() {
|
||||
val ids = NotificationChannelManager.allChannelIds()
|
||||
assertEquals(ids.toSet().size, ids.size, "All channel IDs must be unique")
|
||||
assertEquals(6, ids.size, "Must have exactly 6 notification channels")
|
||||
assertEquals(8, ids.size, "Must have exactly 8 notification channels")
|
||||
}
|
||||
|
||||
// ── Notification Actions Tests ───────────────────────────────
|
||||
@@ -437,4 +447,74 @@ class NotificationBuilderTest {
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_DISMISS))
|
||||
assertEquals(1, actions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `actionsForType returns correct actions for family invite`() {
|
||||
val actions = NotificationActions.actionsForType(NotificationType.FAMILY_INVITE)
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_ACCEPT_INVITE))
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_DECLINE_INVITE))
|
||||
assertEquals(2, actions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `actionsForType returns correct actions for subscription renewal`() {
|
||||
val actions = NotificationActions.actionsForType(NotificationType.SUBSCRIPTION_RENEWAL)
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_RENEW_NOW))
|
||||
assertTrue(actions.contains(NotificationActions.ACTION_MANAGE_SUBSCRIPTION))
|
||||
assertEquals(2, actions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payload fromFcmData handles family invite`() {
|
||||
val data = mapOf(
|
||||
"type" to "family_invite",
|
||||
"title" to "Family Invite",
|
||||
"body" to "John invited you to join the family group",
|
||||
"screen" to "family"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.fromFcmData(data)
|
||||
assertNotNull(payload)
|
||||
assertEquals(NotificationType.FAMILY_INVITE, payload!!.type)
|
||||
assertEquals("family", payload.deepLinkScreen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payload fromFcmData handles subscription renewal`() {
|
||||
val data = mapOf(
|
||||
"type" to "subscription_renewal",
|
||||
"title" to "Renewal Reminder",
|
||||
"body" to "Your plan renews in 3 days",
|
||||
"screen" to "billing"
|
||||
)
|
||||
|
||||
val payload = NotificationPayload.fromFcmData(data)
|
||||
assertNotNull(payload)
|
||||
assertEquals(NotificationType.SUBSCRIPTION_RENEWAL, payload!!.type)
|
||||
assertEquals("billing", payload.deepLinkScreen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveChannelId handles family invite`() {
|
||||
assertEquals(
|
||||
NotificationChannelManager.CHANNEL_FAMILY_INVITE,
|
||||
NotificationChannelManager.resolveChannelId("family_invite")
|
||||
)
|
||||
assertEquals(
|
||||
NotificationChannelManager.CHANNEL_FAMILY_INVITE,
|
||||
NotificationChannelManager.resolveChannelId("invite")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveChannelId handles subscription`() {
|
||||
assertEquals(
|
||||
NotificationChannelManager.CHANNEL_SUBSCRIPTION,
|
||||
NotificationChannelManager.resolveChannelId("subscription_renewal")
|
||||
)
|
||||
assertEquals(
|
||||
NotificationChannelManager.CHANNEL_SUBSCRIPTION,
|
||||
NotificationChannelManager.resolveChannelId("billing")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.kordant.android.util
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for [PlayIntegrityManager].
|
||||
*
|
||||
* Tests the manager's contract and API surface.
|
||||
* Actual Play Integrity token generation requires the Play services
|
||||
* library on a real device with Google Play Services installed,
|
||||
* so integration tests are run via Firebase Test Lab instrumentation tests.
|
||||
*
|
||||
* See: android/firebase-test-lab/ for device matrix testing.
|
||||
*/
|
||||
class PlayIntegrityManagerTest {
|
||||
|
||||
@Test
|
||||
fun `PlayIntegrityManager class exists and is loadable`() {
|
||||
// Verify the class is loadable (compilation check)
|
||||
val clazz = PlayIntegrityManager::class.java
|
||||
assertThat(clazz.simpleName).isEqualTo("PlayIntegrityManager")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PlayIntegrityManager has expected methods`() {
|
||||
val clazz = PlayIntegrityManager::class.java
|
||||
val methods = clazz.methods.map { it.name }.toSet()
|
||||
|
||||
assertThat(methods).contains("requestIntegrityToken")
|
||||
assertThat(methods).contains("requestIntegrityTokenWithNonce")
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,65 @@ class AuthViewModelTest {
|
||||
assertFalse("Should return false when no tokens", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionRestored_initialState_matchesLoginState() {
|
||||
assertFalse("Should not be restored initially", viewModel.sessionRestored.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionRestored_falseAfterLogout() = testScope.runTest {
|
||||
fakeRepository.setLoginResult(Result.success(testUser()))
|
||||
viewModel.login("test@example.com", "password123")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
viewModel.logout()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertFalse("Should not be restored after logout", viewModel.sessionRestored.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkAndRefreshSession_withStoredTokens_restoresSession() = testScope.runTest {
|
||||
fakeRepository.setAccessTokenForTest("test-token")
|
||||
viewModel = AuthViewModel(fakeRepository)
|
||||
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
viewModel.checkAndRefreshSession()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertTrue("Session should be restored", viewModel.sessionRestored.value)
|
||||
assertTrue("Should be authenticated", viewModel.isAuthenticated.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkAndRefreshSession_withoutStoredTokens_doesNotRestore() = testScope.runTest {
|
||||
fakeRepository.clearTokens()
|
||||
viewModel = AuthViewModel(fakeRepository)
|
||||
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
viewModel.checkAndRefreshSession()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertFalse("Session should not be restored", viewModel.sessionRestored.value)
|
||||
assertFalse("Should not be authenticated", viewModel.isAuthenticated.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dismissSessionExpired_clearsErrorAndExpiredState() = testScope.runTest {
|
||||
// Simulate session expiry
|
||||
fakeRepository.setLoginResult(Result.success(testUser()))
|
||||
viewModel.login("test@example.com", "password123")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
viewModel.dismissSessionExpired()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertFalse("Session expired should be false", state.sessionExpired)
|
||||
assertNull("Error should be null", state.error)
|
||||
}
|
||||
|
||||
private fun testUser(
|
||||
id: String = "user-1",
|
||||
name: String = "Test User",
|
||||
|
||||
Reference in New Issue
Block a user