diff --git a/android/.gitignore b/android/.gitignore index 63d3097..b689a36 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,2 +1,28 @@ .gradle .kotlin + +# Keystore and signing (SENSITIVE — never commit) +*.keystore +*.jks +key.properties + +# Build outputs +build/ +app/build/ + +# IDE +.idea/ +*.iml +*.ipr +*.iws + +# Local config +local.properties + +# OS +.DS_Store +Thumbs.db + +# Generated +gen/ + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index abe5749..f7b5ecc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index e737141..b68f1ed 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -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.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0e91b47..d1e0dbb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -68,6 +68,9 @@ + + + @@ -114,6 +117,10 @@ + + + + diff --git a/android/app/src/main/java/com/kordant/android/KordantApp.kt b/android/app/src/main/java/com/kordant/android/KordantApp.kt index 6146f0a..75f5100 100644 --- a/android/app/src/main/java/com/kordant/android/KordantApp.kt +++ b/android/app/src/main/java/com/kordant/android/KordantApp.kt @@ -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" diff --git a/android/app/src/main/java/com/kordant/android/MainActivity.kt b/android/app/src/main/java/com/kordant/android/MainActivity.kt index 6178e5b..3a5ef60 100644 --- a/android/app/src/main/java/com/kordant/android/MainActivity.kt +++ b/android/app/src/main/java/com/kordant/android/MainActivity.kt @@ -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() } diff --git a/android/app/src/main/java/com/kordant/android/data/remote/AuthInterceptor.kt b/android/app/src/main/java/com/kordant/android/data/remote/AuthInterceptor.kt index 333f776..4b1e92b 100644 --- a/android/app/src/main/java/com/kordant/android/data/remote/AuthInterceptor.kt +++ b/android/app/src/main/java/com/kordant/android/data/remote/AuthInterceptor.kt @@ -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 ` 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 - ) } diff --git a/android/app/src/main/java/com/kordant/android/data/remote/TokenRefreshAuthenticator.kt b/android/app/src/main/java/com/kordant/android/data/remote/TokenRefreshAuthenticator.kt new file mode 100644 index 0000000..27157d5 --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/data/remote/TokenRefreshAuthenticator.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/kordant/android/data/remote/TokenRefreshManager.kt b/android/app/src/main/java/com/kordant/android/data/remote/TokenRefreshManager.kt index 61d1877..a59a729 100644 --- a/android/app/src/main/java/com/kordant/android/data/remote/TokenRefreshManager.kt +++ b/android/app/src/main/java/com/kordant/android/data/remote/TokenRefreshManager.kt @@ -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.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 { diff --git a/android/app/src/main/java/com/kordant/android/data/repository/AuthRepository.kt b/android/app/src/main/java/com/kordant/android/data/repository/AuthRepository.kt index d160a0d..b493c90 100644 --- a/android/app/src/main/java/com/kordant/android/data/repository/AuthRepository.kt +++ b/android/app/src/main/java/com/kordant/android/data/repository/AuthRepository.kt @@ -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() } /** diff --git a/android/app/src/main/java/com/kordant/android/data/sync/ConflictResolver.kt b/android/app/src/main/java/com/kordant/android/data/sync/ConflictResolver.kt new file mode 100644 index 0000000..5d10798 --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/data/sync/ConflictResolver.kt @@ -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() + // 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 + } + } +} diff --git a/android/app/src/main/java/com/kordant/android/data/sync/ConflictStrategy.kt b/android/app/src/main/java/com/kordant/android/data/sync/ConflictStrategy.kt new file mode 100644 index 0000000..dd4fc5a --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/data/sync/ConflictStrategy.kt @@ -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, +) diff --git a/android/app/src/main/java/com/kordant/android/data/sync/OfflineWorker.kt b/android/app/src/main/java/com/kordant/android/data/sync/OfflineWorker.kt index 6b72562..81cc4d4 100644 --- a/android/app/src/main/java/com/kordant/android/data/sync/OfflineWorker.kt +++ b/android/app/src/main/java/com/kordant/android/data/sync/OfflineWorker.kt @@ -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() diff --git a/android/app/src/main/java/com/kordant/android/data/sync/PendingRequestQueue.kt b/android/app/src/main/java/com/kordant/android/data/sync/PendingRequestQueue.kt index 426772d..4e332e1 100644 --- a/android/app/src/main/java/com/kordant/android/data/sync/PendingRequestQueue.kt +++ b/android/app/src/main/java/com/kordant/android/data/sync/PendingRequestQueue.kt @@ -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 = 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 = 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 { if (!file.exists()) return emptyList() return try { - json.decodeFromString>(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) { - 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) { + writeWithLock { data -> + var nextId = data.nextId + val existing = data.requests.toMutableList() + val added = mutableListOf() + + 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 { + 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 { + val all = getAll() + if (all.isEmpty()) return emptyList() + + // First pass: sort by priority (desc) then timestamp (asc) + val sorted = all.sortedWith( + compareByDescending { 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): List { + if (requests.none { it.dependencyIds.isNotEmpty() }) return requests + + val idMap = requests.associateBy { it.id } + val visited = mutableSetOf() + val result = mutableListOf() + + 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 { + 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 { + 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(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(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) + } + } } diff --git a/android/app/src/main/java/com/kordant/android/data/sync/SyncManager.kt b/android/app/src/main/java/com/kordant/android/data/sync/SyncManager.kt index 12b252d..69817a0 100644 --- a/android/app/src/main/java/com/kordant/android/data/sync/SyncManager.kt +++ b/android/app/src/main/java/com/kordant/android/data/sync/SyncManager.kt @@ -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 = 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(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 = _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 = 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.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() @@ -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 = 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 { + 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 { + 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 } } diff --git a/android/app/src/main/java/com/kordant/android/data/sync/SyncWorkers.kt b/android/app/src/main/java/com/kordant/android/data/sync/SyncWorkers.kt index e19f93f..69cee43 100644 --- a/android/app/src/main/java/com/kordant/android/data/sync/SyncWorkers.kt +++ b/android/app/src/main/java/com/kordant/android/data/sync/SyncWorkers.kt @@ -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" - } } diff --git a/android/app/src/main/java/com/kordant/android/di/NetworkModule.kt b/android/app/src/main/java/com/kordant/android/di/NetworkModule.kt index d27638b..26bd077 100644 --- a/android/app/src/main/java/com/kordant/android/di/NetworkModule.kt +++ b/android/app/src/main/java/com/kordant/android/di/NetworkModule.kt @@ -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 } } } diff --git a/android/app/src/main/java/com/kordant/android/navigation/AppNavigation.kt b/android/app/src/main/java/com/kordant/android/navigation/AppNavigation.kt index ad4b18b..1a5be46 100644 --- a/android/app/src/main/java/com/kordant/android/navigation/AppNavigation.kt +++ b/android/app/src/main/java/com/kordant/android/navigation/AppNavigation.kt @@ -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) } diff --git a/android/app/src/main/java/com/kordant/android/navigation/NavGraph.kt b/android/app/src/main/java/com/kordant/android/navigation/NavGraph.kt index d4b3f62..dd608a3 100644 --- a/android/app/src/main/java/com/kordant/android/navigation/NavGraph.kt +++ b/android/app/src/main/java/com/kordant/android/navigation/NavGraph.kt @@ -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) + ) + } + } +} diff --git a/android/app/src/main/java/com/kordant/android/navigation/Screen.kt b/android/app/src/main/java/com/kordant/android/navigation/Screen.kt index 57babb8..50e5740 100644 --- a/android/app/src/main/java/com/kordant/android/navigation/Screen.kt +++ b/android/app/src/main/java/com/kordant/android/navigation/Screen.kt @@ -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") } diff --git a/android/app/src/main/java/com/kordant/android/notification/ForegroundNotificationManager.kt b/android/app/src/main/java/com/kordant/android/notification/ForegroundNotificationManager.kt new file mode 100644 index 0000000..a9b383a --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/notification/ForegroundNotificationManager.kt @@ -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( + extraBufferCapacity = 10 + ) + val pendingNotifications: SharedFlow = _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 + } +} diff --git a/android/app/src/main/java/com/kordant/android/notification/NotificationActionReceiver.kt b/android/app/src/main/java/com/kordant/android/notification/NotificationActionReceiver.kt index 934b760..5f6ac01 100644 --- a/android/app/src/main/java/com/kordant/android/notification/NotificationActionReceiver.kt +++ b/android/app/src/main/java/com/kordant/android/notification/NotificationActionReceiver.kt @@ -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 } } diff --git a/android/app/src/main/java/com/kordant/android/notification/NotificationAnalytics.kt b/android/app/src/main/java/com/kordant/android/notification/NotificationAnalytics.kt new file mode 100644 index 0000000..2c838c9 --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/notification/NotificationAnalytics.kt @@ -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() + 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 = emptyMap() +) diff --git a/android/app/src/main/java/com/kordant/android/notification/NotificationBuilder.kt b/android/app/src/main/java/com/kordant/android/notification/NotificationBuilder.kt index ec09f9f..773fcd6 100644 --- a/android/app/src/main/java/com/kordant/android/notification/NotificationBuilder.kt +++ b/android/app/src/main/java/com/kordant/android/notification/NotificationBuilder.kt @@ -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 } diff --git a/android/app/src/main/java/com/kordant/android/notification/NotificationChannelManager.kt b/android/app/src/main/java/com/kordant/android/notification/NotificationChannelManager.kt index 6e49ce1..2a729fd 100644 --- a/android/app/src/main/java/com/kordant/android/notification/NotificationChannelManager.kt +++ b/android/app/src/main/java/com/kordant/android/notification/NotificationChannelManager.kt @@ -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 ) diff --git a/android/app/src/main/java/com/kordant/android/notification/NotificationData.kt b/android/app/src/main/java/com/kordant/android/notification/NotificationData.kt index 0e76892..35d58c2 100644 --- a/android/app/src/main/java/com/kordant/android/notification/NotificationData.kt +++ b/android/app/src/main/java/com/kordant/android/notification/NotificationData.kt @@ -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 diff --git a/android/app/src/main/java/com/kordant/android/service/FCMService.kt b/android/app/src/main/java/com/kordant/android/service/FCMService.kt index d5ba342..5bc05fd 100644 --- a/android/app/src/main/java/com/kordant/android/service/FCMService.kt +++ b/android/app/src/main/java/com/kordant/android/service/FCMService.kt @@ -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) { 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) + } } /** diff --git a/android/app/src/main/java/com/kordant/android/ui/components/OfflineBanner.kt b/android/app/src/main/java/com/kordant/android/ui/components/OfflineBanner.kt new file mode 100644 index 0000000..3481654 --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/ui/components/OfflineBanner.kt @@ -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, + ) + } + } +} diff --git a/android/app/src/main/java/com/kordant/android/ui/components/ShieldSnackbarHost.kt b/android/app/src/main/java/com/kordant/android/ui/components/ShieldSnackbarHost.kt new file mode 100644 index 0000000..4d1363b --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/ui/components/ShieldSnackbarHost.kt @@ -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 + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/kordant/android/ui/components/SyncPendingBadge.kt b/android/app/src/main/java/com/kordant/android/ui/components/SyncPendingBadge.kt new file mode 100644 index 0000000..a9786e1 --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/ui/components/SyncPendingBadge.kt @@ -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" + } +} diff --git a/android/app/src/main/java/com/kordant/android/util/PlayIntegrityManager.kt b/android/app/src/main/java/com/kordant/android/util/PlayIntegrityManager.kt new file mode 100644 index 0000000..0472e5f --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/util/PlayIntegrityManager.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/kordant/android/viewmodel/AuthViewModel.kt b/android/app/src/main/java/com/kordant/android/viewmodel/AuthViewModel.kt index b81cddb..83997d1 100644 --- a/android/app/src/main/java/com/kordant/android/viewmodel/AuthViewModel.kt +++ b/android/app/src/main/java/com/kordant/android/viewmodel/AuthViewModel.kt @@ -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 = 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 create(modelClass: Class): 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.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 = _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) } diff --git a/android/app/src/main/java/com/kordant/android/viewmodel/DarkWatchViewModel.kt b/android/app/src/main/java/com/kordant/android/viewmodel/DarkWatchViewModel.kt index 7a3027a..64b1cf9 100644 --- a/android/app/src/main/java/com/kordant/android/viewmodel/DarkWatchViewModel.kt +++ b/android/app/src/main/java/com/kordant/android/viewmodel/DarkWatchViewModel.kt @@ -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 = 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 = 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 = _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> = 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> { + 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 create(modelClass: Class): T { diff --git a/android/app/src/main/java/com/kordant/android/viewmodel/OfflineSyncViewModel.kt b/android/app/src/main/java/com/kordant/android/viewmodel/OfflineSyncViewModel.kt new file mode 100644 index 0000000..b5423d5 --- /dev/null +++ b/android/app/src/main/java/com/kordant/android/viewmodel/OfflineSyncViewModel.kt @@ -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()).getSyncManager() + + /** + * Aggregate sync state emitted as a StateFlow. + * Screens can collect this to drive offline UI indicators. + */ + val syncState: StateFlow = 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 { + 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) + } +} diff --git a/android/app/src/main/res/drawable/ic_sync.xml b/android/app/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..7cbf705 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 192ad9f..aff998c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -50,6 +50,10 @@ Background security scan finished and results are available Family Activity Family member changes, shared alerts, and family activity notifications + Family Invites + Invitations to join or be added to family groups + Subscription + Subscription renewals, billing updates, and plan changes Marketing Product updates, tips, and promotional offers System diff --git a/android/app/src/test/java/com/kordant/android/data/remote/TokenRefreshAuthenticatorTest.kt b/android/app/src/test/java/com/kordant/android/data/remote/TokenRefreshAuthenticatorTest.kt new file mode 100644 index 0000000..e2d4187 --- /dev/null +++ b/android/app/src/test/java/com/kordant/android/data/remote/TokenRefreshAuthenticatorTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/com/kordant/android/data/remote/TokenRefreshManagerTest.kt b/android/app/src/test/java/com/kordant/android/data/remote/TokenRefreshManagerTest.kt new file mode 100644 index 0000000..c80f45b --- /dev/null +++ b/android/app/src/test/java/com/kordant/android/data/remote/TokenRefreshManagerTest.kt @@ -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() + 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() + 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) + } +} diff --git a/android/app/src/test/java/com/kordant/android/data/sync/SyncManagerTest.kt b/android/app/src/test/java/com/kordant/android/data/sync/SyncManagerTest.kt index 71243b6..c52617a 100644 --- a/android/app/src/test/java/com/kordant/android/data/sync/SyncManagerTest.kt +++ b/android/app/src/test/java/com/kordant/android/data/sync/SyncManagerTest.kt @@ -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() @@ -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 { + 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 { + return store.filter { it.entityType == entityType && it.entityId != null } + .mapNotNull { it.entityId } + .toSet() + } + + fun getOrdered(): List { + if (store.isEmpty()) return emptyList() + + val sorted = store.sortedWith( + compareByDescending { 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() + val result = mutableListOf() + + 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 } } diff --git a/android/app/src/test/java/com/kordant/android/navigation/DeepLinkRouteTest.kt b/android/app/src/test/java/com/kordant/android/navigation/DeepLinkRouteTest.kt new file mode 100644 index 0000000..14a429d --- /dev/null +++ b/android/app/src/test/java/com/kordant/android/navigation/DeepLinkRouteTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/com/kordant/android/notification/FCMMessageHandlingTest.kt b/android/app/src/test/java/com/kordant/android/notification/FCMMessageHandlingTest.kt new file mode 100644 index 0000000..53970ca --- /dev/null +++ b/android/app/src/test/java/com/kordant/android/notification/FCMMessageHandlingTest.kt @@ -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 getSystemService(serviceClass: Class) = throw UnsupportedOperationException() + override fun startActivity(intent: android.content.Intent) = throw UnsupportedOperationException() + override fun startActivities(intents: Array) = throw UnsupportedOperationException() + override fun startActivities(intents: Array, 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 = throw UnsupportedOperationException() + override fun getStorageUris(): Array = 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 = 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() + 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?) = 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 getSystemService(serviceClass: Class) = throw UnsupportedOperationException() + override fun startActivity(intent: android.content.Intent) = throw UnsupportedOperationException() + override fun startActivities(intents: Array) = throw UnsupportedOperationException() + override fun startActivities(intents: Array, 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() + 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?) = 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 = throw UnsupportedOperationException() + override fun getStorageUris(): Array = 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 = 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)) + } +} diff --git a/android/app/src/test/java/com/kordant/android/notification/NotificationBuilderTest.kt b/android/app/src/test/java/com/kordant/android/notification/NotificationBuilderTest.kt index 5fc0d01..c66d0fd 100644 --- a/android/app/src/test/java/com/kordant/android/notification/NotificationBuilderTest.kt +++ b/android/app/src/test/java/com/kordant/android/notification/NotificationBuilderTest.kt @@ -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") + ) + } } diff --git a/android/app/src/test/java/com/kordant/android/util/PlayIntegrityManagerTest.kt b/android/app/src/test/java/com/kordant/android/util/PlayIntegrityManagerTest.kt new file mode 100644 index 0000000..39335b2 --- /dev/null +++ b/android/app/src/test/java/com/kordant/android/util/PlayIntegrityManagerTest.kt @@ -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") + } +} diff --git a/android/app/src/test/java/com/kordant/android/viewmodel/AuthViewModelTest.kt b/android/app/src/test/java/com/kordant/android/viewmodel/AuthViewModelTest.kt index e315f3d..67ff263 100644 --- a/android/app/src/test/java/com/kordant/android/viewmodel/AuthViewModelTest.kt +++ b/android/app/src/test/java/com/kordant/android/viewmodel/AuthViewModelTest.kt @@ -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", diff --git a/android/docs/play-console-checklist.md b/android/docs/play-console-checklist.md new file mode 100644 index 0000000..8a5e635 --- /dev/null +++ b/android/docs/play-console-checklist.md @@ -0,0 +1,192 @@ +# Play Console Release Checklist + +Track all Play Console configuration items for Kordant release. + +## Phase 1: Preparation + +### Keystore & Signing +- [ ] Generate release keystore (`./scripts/generate-release-key.sh`) +- [ ] Back up keystore to password manager +- [ ] Back up keystore to offline secure storage +- [ ] Create `key.properties` from template +- [ ] Verify `key.properties` is in `.gitignore` +- [ ] Test signed build: `./gradlew bundleProdRelease` +- [ ] Verify R8 obfuscation: check mapping.txt in build outputs + +### App Assets +- [ ] App icon (512×512 PNG, non-transparent) +- [ ] Feature graphic (1024×500, JPG or PNG) +- [ ] Phone screenshots (2-8, 16:9 or 9:16) +- [ ] Tablet screenshots (2-8, if supporting tablets) +- [ ] Promo video (optional, 30-120 seconds) +- [ ] Privacy policy URL live and accessible +- [ ] Terms of service URL live and accessible + +### Certificate Pins +- [ ] Replace placeholder pins in `network_security_config.xml` +- [ ] Extract production cert hash: + ```bash + echo | openssl s_client -connect api.kordant.com:443 -servername api.kordant.com 2>/dev/null \ + | openssl x509 -pubkey -noout \ + | openssl pkey -pubin -outform der 2>/dev/null \ + | openssl dgst -sha256 -binary \ + | openssl enc -base64 + ``` +- [ ] Add backup pin for rotation + +--- + +## Phase 2: Play Console Setup + +### App Creation +- [ ] Create app in Play Console +- [ ] App name: Kordant +- [ ] Default language: English (US) +- [ ] Type: App +- [ ] Pricing: Free + +### App Signing +- [ ] Upload upload key certificate +- [ ] Enable Google Play App Signing +- [ ] Download and backup the Google-managed app signing key +- [ ] Record SHA-256 fingerprint for Firebase/Google Sign-In + +### Default App Information +- [ ] Contact email: support@kordant.ai +- [ ] Website: https://kordant.ai +- [ ] Privacy policy URL: https://kordant.ai/privacy + +--- + +## Phase 3: Store Listing + +### Main Store Listing +- [ ] Title: Kordant +- [ ] Short description (80 chars) +- [ ] Full description (4000 chars) +- [ ] Category: Tools +- [ ] App icon uploaded +- [ ] Feature graphic uploaded +- [ ] Phone screenshots uploaded +- [ ] Tablet screenshots uploaded (if applicable) + +### Localization +- [ ] English (US) — default +- [ ] Additional languages (plan for later) + +--- + +## Phase 4: Distribution + +### Pricing & Distribution +- [ ] Price: Free +- [ ] Countries: Select target markets +- [ ] Age rating: Complete IARC questionnaire + +### Content Rating (IARC) +- [ ] In-Game Purchases: Yes (subscriptions) +- [ ] Users Interact: Yes +- [ ] Shares Info: Yes +- [ ] All other content questions answered +- [ ] Expected rating: Everyone or Everyone 10+ + +### Data Safety Form +- [ ] Data types declared +- [ ] Collection purposes explained +- [ ] Data sharing disclosed +- [ ] Encryption practices documented +- [ ] Data deletion option described + +--- + +## Phase 5: Testing + +### Internal Testing Track +- [ ] Internal testing track created +- [ ] Testers added (minimum 20) +- [ ] Testers accepted invitations +- [ ] First AAB uploaded +- [ ] AAB processing complete +- [ ] Testers can install from testing link +- [ ] App functions correctly on test devices + +### Firebase Test Lab +- [ ] Robo tests passing on Pixel 6 +- [ ] Robo tests passing on Samsung Galaxy S21 +- [ ] Robo tests passing on Xiaomi Redmi +- [ ] Instrumentation tests passing on all devices +- [ ] No crashes across device matrix +- [ ] Cold start under 1.5s on Pixel 6 + +--- + +## Phase 6: Monetization (if applicable) + +### Subscriptions +- [ ] Pro Monthly (`pro_monthly`) +- [ ] Pro Annual (`pro_annual`) +- [ ] Family Monthly (`family_monthly`) +- [ ] Family Annual (`family_annual`) + +### Managed Products +- [ ] Single Scan (`single_scan`) +- [ ] Removal Pack (`removal_pack`) + +### Promo Codes +- [ ] Internal testing codes generated +- [ ] Beta tester codes generated + +--- + +## Phase 7: Security & Integrity + +### Play Integrity API +- [ ] Play Integrity enabled in Play Console +- [ ] `PlayIntegrityManager` integrated in app +- [ ] Server-side verification configured +- [ ] Nonce-based replay protection implemented + +### App Integrity +- [ ] Certificate pinning active (real hashes) +- [ ] Root detection blocking/degrading gracefully +- [ ] EncryptedSharedPreferences for sensitive data +- [ ] Network security config blocks cleartext +- [ ] Backup disabled (`android:allowBackup="false"`) + +--- + +## Phase 8: Pre-Release Verification + +### Build Verification +- [ ] Release build: `./gradlew bundleProdRelease` +- [ ] No R8/ProGuard crashes +- [ ] All TRPC endpoints functional +- [ ] Google Sign-In working with production SHA-256 +- [ ] FCM push notifications working +- [ ] Deep links routing correctly +- [ ] Offline queue resolving sync conflicts +- [ ] Token refresh working silently + +### Play Console Verification +- [ ] All sections show green/complete +- [ ] No policy violations +- [ ] Store listing preview looks correct +- [ ] All screenshots display properly +- [ ] Feature graphic displays correctly + +### Final Checks +- [ ] Version code incremented +- [ ] Version name updated +- [ ] Release notes written +- [ ] ProGuard mapping.txt saved +- [ ] Keystore backed up + +--- + +## Notes + +- **Keystore**: If lost, you can still upload new versions with a new key, but existing users won't be able to update. Google Play App Signing mitigates this risk. +- **Version codes**: Must be strictly increasing. Never reuse a versionCode. +- **Processing time**: AAB processing can take 10-30 minutes after upload. +- **Review time**: First-time app review can take up to 7 days. Subsequent updates are faster. +- **Internal testing**: Fastest distribution method. Testers get immediate access after rollout. diff --git a/android/docs/play-console-setup.md b/android/docs/play-console-setup.md new file mode 100644 index 0000000..9071ec3 --- /dev/null +++ b/android/docs/play-console-setup.md @@ -0,0 +1,457 @@ +# Google Play Console Setup Guide + +Complete step-by-step guide for configuring Kordant in Google Play Console. + +## Table of Contents +1. [Prerequisites](#prerequisites) +2. [Create the App](#1-create-the-app) +3. [App Signing](#2-app-signing) +4. [Default App Information](#3-default-app-information) +5. [Internal Testing Track](#4-internal-testing-track) +6. [Store Listing](#5-store-listing) +7. [Pricing & Distribution](#6-pricing--distribution) +8. [Content Rating](#7-content-rating) +9. [Data Safety Form](#8-data-safety-form) +10. [Play Integrity API](#9-play-integrity-api) +11. [In-App Products](#10-in-app-products) +12. [Release Checklist](#release-checklist) + +--- + +## Prerequisites + +- Google account with Play Console access +- $25 one-time developer registration fee paid +- Signed AAB (Android App Bundle) ready to upload +- App signing keystore generated (see [scripts/generate-release-key.sh](../scripts/generate-release-key.sh)) +- App assets prepared (icon, screenshots, feature graphic) +- Privacy policy URL hosted and accessible +- Firebase project linked to the app + +--- + +## 1. Create the App + +1. Go to [Google Play Console](https://play.google.com/console) +2. Click **"Create app"** +3. Fill in: + - **App name**: `Kordant` + - **Default language**: `English (United States)` + - **App or game**: `App` + - **Free or paid**: `Free` +4. Click **"Create app"** + +--- + +## 2. App Signing + +### 2.1 Generate Upload Key + +```bash +cd android +chmod +x scripts/generate-release-key.sh +./scripts/generate-release-key.sh +``` + +This creates: +- `kordant-release.keystore` — The keystore file (KEEP SECURE) +- `key.properties` — Credentials for Gradle (added to `.gitignore`) + +### 2.2 Configure Google Play App Signing + +1. Go to **Setup → App integrity → App signing** +2. Select **"Let Google manage the app signing key"** +3. Upload the upload certificate: + - Option A: Upload the `.keystore` file directly + - Option B: Extract the certificate and upload: + ```bash + keytool -export-cert \ + -keystore kordant-release.keystore \ + -alias kordant-release-key \ + -file upload-cert.pem + ``` + Then upload `upload-cert.pem` +4. Review and accept the terms +5. Click **"Enable"** + +### 2.3 Save the Backup Key + +After enabling Google Play App Signing, Google provides a **backup app signing key**. Download it and store it securely — this is your last resort if the upload key is lost. + +### 2.4 Verify Configuration + +After setup, note the **app signing key certificate fingerprint** (SHA-256). You'll need this for: +- Firebase SHA-256 configuration (for Google Sign-In) +- Facebook App configuration +- Any other service requiring app identity verification + +--- + +## 3. Default App Information + +Go to **Setup → Default app information**: + +### Contact Details +- **Email**: support@kordant.com (or your contact email) +- **Website**: https://kordant.ai +- **Privacy policy URL**: https://kordant.ai/privacy (must be publicly accessible) + +### App Access (if applicable) +- Configure any required URL patterns for App Access API + +--- + +## 4. Internal Testing Track + +### 4.1 Create Internal Testing Track + +1. Go to **Testing → Internal testing** +2. Click **"Create new release"** +3. Fill in release notes + +### 4.2 Add Testers + +1. Go to **Testing → Internal testing → Testers** +2. Click **"Manage testers"** +3. Add internal tester emails (team members with Google accounts) +4. Click **"Save changes"** +5. Testers receive an invitation email — they must accept + +### 4.3 Upload Build + +1. Go to **Testing → Internal testing → Create new release** +2. Upload the AAB: + ```bash + cd android + ./gradlew bundleProdRelease + # AAB location: app/build/outputs/bundle/prodRelease/app-prod-release.aab + ``` +3. Drag and drop the AAB file +4. Wait for processing (can take several minutes) +5. Fill in release notes +6. Click **"Review release"** → **"Start rollout"** + +### 4.4 Verify Installation + +1. Each tester receives an email with the testing link +2. Testers click the link and follow the enrollment flow +3. Testers install the app from the internal testing listing +4. Verify the app launches and functions correctly + +--- + +## 5. Store Listing + +Go to **Main store listing**: + +### 5.1 App Identity +- **Title**: `Kordant` (50 characters max) +- **Short description** (80 characters max): + ``` + Your personal security command center. Monitor data exposures, screen spam calls, and protect your digital identity. + ``` +- **Full description** (4000 characters max): + ``` + Kordant is your personal security command center — all-in-one protection for your digital identity. + + DATA EXPOSURE MONITORING + DarkWatch continuously scans broker sites, data dumps, and the dark web for your personal information. Get instant alerts when your data appears online, with automated removal requests to have it taken down. + + SPAM CALL PROTECTION + SpamShield screens incoming calls in real-time, identifying and blocking spam, robocalls, and telemarketers before they reach you. Built on a crowdsourced database of millions of known spam numbers. + + VOICEPRINT VERIFICATION + Create a unique voice signature to verify your identity across services. VoicePrint enrollment takes seconds and works with your existing biometric authentication. + + PROPERTY PROTECTION + HomeTitle monitors your property listings and alerts you to unauthorized postings, fake listings, or identity theft targeting your home. + + FAMILY SECURITY + Extend protection to your entire family with shared watchlists, coordinated alerts, and a single dashboard for everyone's digital safety. + + KEY FEATURES: + • Real-time threat scoring dashboard + • Automated data removal requests + • Call screening with <100ms latency + • Encrypted voice enrollment + • Family sharing and management + • Dark web exposure monitoring + • Property listing protection + • Privacy-first architecture + + YOUR DATA STAYS YOURS: + Kordant uses end-to-end encryption for all sensitive data. Your voice recordings, personal information, and security preferences are encrypted at rest and in transit. We never sell or share your data with third parties. + + SUBSCRIPTION PLANS: + • Free: Basic monitoring and call screening + • Pro: Full DarkWatch, VoicePrint, and family features + • Family: Pro features for up to 6 family members + + Privacy Policy: https://kordant.ai/privacy + Terms of Service: https://kordant.ai/terms + Support: support@kordant.ai + ``` + +### 5.2 Graphics + +#### App Icon +- **Size**: 512×512 PNG +- **Format**: PNG (not transparent) +- Already prepared in `app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp` +- Convert to 512×512 PNG for upload + +#### Feature Graphic +- **Size**: 1024×500 JPG or PNG (non-transparent) +- **Format**: This is the large banner shown in search results +- Create with branding guidelines from `design-tokens/` + +#### Screenshots +- **Phone** (at least 2): 16:9 or 9:16, min 320px, max 3840px + 1. Dashboard with threat score + 2. DarkWatch exposure monitoring + 3. SpamShield call filtering + 4. VoicePrint enrollment + 5. Alerts and notifications +- **Tablet** (at least 2, if supporting): Same aspect ratios +- **Foldable** (optional): If targeting foldable devices + +### 5.3 Category & Rating +- **Category**: Tools +- **Contact email**: support@kordant.ai +- **Privacy policy URL**: https://kordant.ai/privacy + +### 5.4 Language +- **Default**: English (United States) +- Additional languages can be added later via **Store presence → Localization** + +--- + +## 6. Pricing & Distribution + +### 6.1 Pricing +Go to **Marketing → Pricing & distribution**: +- **Price**: Free +- **Subscription offers**: Configure in Google Play Console → Monetization → Subscriptions + +### 6.2 Distribution +- **Countries/regions**: Select all available or specific target markets +- Recommended: Start with US, CA, GB, AU, DE, FR, ES, IT, JP, BR + +### 6.3 Age Rating +- Complete the content rating questionnaire (see [Section 7](#7-content-rating)) + +--- + +## 7. Content Rating + +Go to **Setup → Content rating**: + +### US IARC Questionnaire + +Answer honestly based on app content: + +| Question | Answer | +|----------|--------| +| In-Game Purchases | Yes (subscriptions) | +| Simulated Gambling | No | +| Alcohol, Drugs, Weapons | No | +| Animated Blood and Gore | No | +| Realistic Blood and Gore | No | +| Realistic Violence | No | +| Cartoon or Fantasy Violence | No | +| Sexual Content | No | +| Horror or Fear Themes | No | +| Profanity | No | +| Suggestive Themes | No | +| Users Interact | Yes (dark web monitoring involves user data) | +| Shares Info | Yes (app collects personal data for security monitoring) | +| Ads | No | +| Inappropriate Ads | No | +| Simulated Gambling | No | +| Medication, Recreational Drugs | No | +| Violence | No | +| Alcohol, Tobacco | No | +| Language | No | +| Sexual Content | No | +| In-App Purchases | Yes | +| PVP (Player vs Player) | No | + +**Expected rating**: Everyone or Everyone 10+ + +### Additional Ratings +Some countries require additional questionnaires (Germany USK, France, etc.). Complete these as prompted. + +--- + +## 8. Data Safety Form + +Go to **Setup → Data safety**: + +### Data Collected + +| Data Type | Purpose | Shared? | Required? | +|-----------|---------|---------|-----------| +| Name | Account management | No | Yes | +| Email address | Account management, notifications | No | Yes | +| Phone number | Call screening, spam detection | No | Yes | +| Photos | VoicePrint enrollment (voice samples only) | No | Optional | +| Audio | VoicePrint enrollment and analysis | No | Optional | +| App activity | Feature usage analytics | No | Yes | +| Device ID | App integrity verification | No | Yes | +| Diagnostics | Crash reporting (Firebase Crashlytics) | Yes (Firebase) | Yes | + +### Data Practices + +- **Data encryption**: Yes, in transit (TLS 1.2+) and at rest (AES-256) +- **Data deletion**: Users can request data deletion via Settings or support email +- **Data shared with third parties**: Firebase (analytics, crash reporting), Google Play (Play Integrity) +- **Security practices**: Certificate pinning, EncryptedSharedPreferences, biometric authentication + +### Privacy Policy +Must be accessible at: https://kordant.ai/privacy + +--- + +## 9. Play Integrity API + +The app already includes Play Integrity integration via `PlayIntegrityManager`. + +### Enable in Play Console +1. Go to **Setup → App integrity → Play Integrity API** +2. Ensure the API is enabled for your app +3. Note: Play Integrity is automatically available for apps distributed through Google Play + +### Server-Side Verification +Configure your backend to verify Play Integrity tokens: + +```bash +# 1. Get Google's public keys +# https://developer.android.com/google/play/integrity/verify + +# 2. Verify tokens using Google's verification library +# Java: com.google.android.play:integrity:1.4.0 +# Or use Google Cloud Functions for verification +``` + +### Backend Integration +The `PlayIntegrityManager` generates tokens that should be sent to your backend: +1. App requests a nonce from your server +2. Server passes nonce to `PlayIntegrityManager.requestIntegrityToken(nonce)` +3. App sends the resulting token to your server +4. Server verifies the token using Google's public keys +5. Server checks `ctsProfileMatch` and `integrityResult` fields + +--- + +## 10. In-App Products + +Go to **Monetize → Products**: + +### 10.1 Subscriptions + +Create subscription products: + +| Product ID | Name | Price | Description | +|------------|------|-------|-------------| +| `pro_monthly` | Pro Monthly | $9.99/mo | Full DarkWatch, VoicePrint, family features | +| `pro_annual` | Pro Annual | $79.99/yr | Same as monthly, save 33% | +| `family_monthly` | Family Monthly | $14.99/mo | Pro for up to 6 family members | +| `family_annual` | Family Annual | $119.99/yr | Family plan, save 33% | + +### 10.2 Managed Products (one-time) + +| Product ID | Name | Price | Description | +|------------|------|-------|-------------| +| `single_scan` | Single Scan | $4.99 | One-time full security scan | +| `removal_pack` | Removal Pack | $9.99 | 5 automated data removal requests | + +### 10.3 Promo Codes +- Go to **Monetize → Promo codes** +- Create codes for internal testing and beta testers + +--- + +## Release Checklist + +Before submitting for review: + +### Build & Signing +- [ ] Release keystore generated and backed up +- [ ] `key.properties` configured (not committed to git) +- [ ] Google Play App Signing enabled +- [ ] Signed AAB built successfully (`./gradlew bundleProdRelease`) +- [ ] R8/ProGuard enabled and tested (no crashes from obfuscation) +- [ ] Baseline profile generated for performance + +### Store Listing +- [ ] App icon uploaded (512×512 PNG) +- [ ] Feature graphic uploaded (1024×500) +- [ ] Phone screenshots uploaded (2-8 images) +- [ ] Tablet screenshots uploaded (if applicable) +- [ ] Title, short description, full description complete +- [ ] Category set to "Tools" +- [ ] Contact details filled in +- [ ] Privacy policy URL accessible + +### Distribution +- [ ] Price set to Free +- [ ] Distribution countries selected +- [ ] Content rating questionnaire completed +- [ ] Data safety form completed +- [ ] All permissions justified in-app + +### Testing +- [ ] Internal testing track created +- [ ] Testers added and accepted invitation +- [ ] First build uploaded and processing +- [ ] Testers can install and run the app +- [ ] Firebase Test Lab tests passing on Pixel, Samsung, Xiaomi + +### Security +- [ ] Certificate pinning configured (real pins, not placeholders) +- [ ] Play Integrity API enabled +- [ ] Root detection active +- [ ] EncryptedSharedPreferences for sensitive data +- [ ] Network security config blocks cleartext traffic + +### Backend +- [ ] Play Integrity token verification configured +- [ ] FCM configured for push notifications +- [ ] TRPC endpoints verified against backend contract +- [ ] Token refresh working silently + +--- + +## Troubleshooting + +### "Upload key not found" +Ensure `key.properties` exists and has correct paths: +```bash +cd android +ls -la key.properties kordant-release.keystore +``` + +### "Build failed: signingConfig not found" +The signing config is created dynamically from `key.properties`. Ensure the file exists and is valid. + +### "AAB upload rejected" +Common causes: +- Wrong target SDK (must be latest) +- Missing required permissions declarations +- App not properly signed +- Version code conflicts (must be higher than previous release) + +### "Internal testers can't install" +- Ensure testers accepted the invitation email +- Wait up to 30 minutes for the release to process +- Check that the AAB processed successfully in Play Console +- Testers must use a Google account that matches the invited email + +### "Version code already used" +Each release must have a unique, increasing `versionCode`. Update in `build.gradle.kts`: +```kotlin +defaultConfig { + versionCode = 2 // Increment from previous release + versionName = "1.1" +} +``` diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 7ce2dd8..95f849e 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -13,6 +13,7 @@ coilCompose = "2.7.0" securityCrypto = "1.1.0-alpha06" biometric = "1.2.0-alpha05" playServicesAuth = "21.0.0" +playIntegrity = "1.4.0" okhttp = "4.12.0" gson = "2.10.1" lottieCompose = "6.4.0" @@ -53,6 +54,7 @@ androidx-compose-material-icons-core = { group = "androidx.compose.material", na androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } +play-integrity = { group = "com.google.android.play", name = "integrity", version.ref = "playIntegrity" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" } diff --git a/android/key.properties.template b/android/key.properties.template new file mode 100644 index 0000000..462bf69 --- /dev/null +++ b/android/key.properties.template @@ -0,0 +1,22 @@ +# ============================================================ +# Kordant Release Keystore Configuration +# ============================================================ +# +# IMPORTANT: This file contains sensitive credentials. +# NEVER commit this file to version control. +# Copy this template to key.properties and fill in your values. +# +# The key.properties file is listed in .gitignore. +# ============================================================ + +# Path to the keystore file (relative to the android/ directory) +storeFile=../kordant-release.keystore + +# Keystore password +storePassword=CHANGE_ME_STORE_PASSWORD + +# Key alias +keyAlias=kordant-release-key + +# Key password +keyPassword=CHANGE_ME_KEY_PASSWORD diff --git a/android/marketing/play-store/PROMO-VIDEO-STORYBOARD.md b/android/marketing/play-store/PROMO-VIDEO-STORYBOARD.md new file mode 100644 index 0000000..a5a5491 --- /dev/null +++ b/android/marketing/play-store/PROMO-VIDEO-STORYBOARD.md @@ -0,0 +1,147 @@ +# Kordant Promo Video Storyboard + +**Duration:** 45 seconds +**Format:** 1080p (1920×1080), 30fps +**Style:** Clean, modern, security-focused +**Music:** Royalty-free electronic ambient (search "cybersecurity ambient" on Artlist/Epidemic Sound) + +--- + +## Scene 1: Hook (0:00–0:05) + +**Visual:** Dark screen. A smartphone receives a call. The caller ID shows "Your Daughter" — but a red AI-voice detection alert overlays the screen. + +**Text Overlay:** "AI voice scams are real." + +**Voiceover (or text-only):** "What if the voice on the other end isn't who they say they are?" + +**Transition:** Quick zoom into the Kordant shield logo. + +--- + +## Scene 2: Brand Reveal (0:05–0:08) + +**Visual:** Kordant logo animates in with brand gradient (#4F46E5 → #06B6D4). Tagline fades in. + +**Text Overlay:** "Kordant — AI-Powered Identity Protection" + +**Transition:** Smooth fade to feature showcase. + +--- + +## Scene 3: DarkWatch (0:08–0:15) + +**Visual:** Dashboard screen recording showing DarkWatch scanning the dark web. Animated radar/pulse effect. A breach alert pops up: "Email found in recent breach." + +**Text Overlay:** "DarkWatch" +**Subtext:** "Real-time dark web monitoring" + +**Voiceover/Text:** "DarkWatch monitors the dark web 24/7, alerting you the moment your data surfaces." + +**Transition:** Swipe right. + +--- + +## Scene 4: VoicePrint (0:15–0:22) + +**Visual:** VoicePrint enrollment screen. Waveform animation as user speaks. Voice signature created. Incoming call screen shows "VoicePrint Verified ✓" vs. "AI Voice Detected ⚠". + +**Text Overlay:** "VoicePrint" +**Subtext:** "Detect AI voice clones in real time" + +**Voiceover/Text:** "VoicePrint analyzes every call, detecting AI-generated voices before you're scammed." + +**Transition:** Swipe right. + +--- + +## Scene 5: SpamShield (0:22–0:29) + +**Visual:** Phone ringing with unknown number. SpamShield intercepts and labels: "Spam — Known Scam Number." Call auto-blocked. Log shows blocked calls list. + +**Text Overlay:** "SpamShield" +**Subtext:** "Intelligent spam and scam blocking" + +**Voiceover/Text:** "SpamShield intercepts spam calls and SMS before they reach you." + +**Transition:** Swipe right. + +--- + +## Scene 6: HomeTitle (0:29–0:35) + +**Visual:** HomeTitle dashboard showing property status. Green checkmark: "No unauthorized changes detected." Animated county record scan. + +**Text Overlay:** "HomeTitle" +**Subtext:** "Property fraud monitoring" + +**Voiceover/Text:** "HomeTitle monitors county records to protect your property from fraud." + +**Transition:** Swipe right. + +--- + +## Scene 7: Unified Dashboard (0:35–0:40) + +**Visual:** Kordant dashboard showing all services at a glance. Threat score gauge. Family members protected. Clean, modern UI. + +**Text Overlay:** "One app. Complete protection." + +**Voiceover/Text:** "Everything you need in one powerful app." + +**Transition:** Fade to CTA. + +--- + +## Scene 8: CTA (0:40–0:45) + +**Visual:** Kordant logo centered. "Download on Google Play" badge appears below. Brand gradient background. + +**Text Overlay:** "Download Kordant today." +**CTA Badge:** "GET IT ON Google Play" + +**Voiceover/Text:** "Kordant. Protect what matters." + +**End screen:** Hold for 2 seconds. + +--- + +## Production Notes + +### Recording +- Use Android emulator (Pixel 6, API 34) for screen recordings +- Record at 1080p, 30fps +- Use clean test data (no real user info) +- Enable dark mode for consistent branding + +### Editing +- **Software:** DaVinci Resolve, Premiere Pro, or CapCut +- **Transitions:** Smooth swipes between scenes, fade for brand moments +- **Text overlays:** Inter font, brand colors (#FFFFFF for text, #67E8F9 for accents) +- **Animations:** Subtle scale/fade for text overlays, pulse effects for alerts +- **Background music:** Low-volume ambient electronic track +- **Color grading:** Slight cool/blue tint to match brand + +### YouTube Upload +- **Title:** "Kordant — AI-Powered Identity Protection | Official Promo" +- **Description:** "Protect yourself from AI voice scams, dark web breaches, spam calls, and property fraud. Kordant combines DarkWatch, VoicePrint, SpamShield, and HomeTitle into one powerful app." +- **Tags:** kordant, identity protection, AI scam detection, voice clone detection, dark web monitoring, spam blocking, cybersecurity +- **Visibility:** Unlisted (for Play Store embedding) or Public +- **Thumbnail:** Feature graphic (1024×500) or custom 1280×720 thumbnail + +### Play Store +- Upload video URL to Play Console → Store presence → Video +- Video appears as playable trailer on listing page +- Ensure video thumbnail is compelling (use Scene 2 or Scene 7 frame) + +--- + +## Localized Versions + +| Language | Tagline | Notes | +|----------|---------|-------| +| English | "AI-Powered Identity Protection" | Primary version | +| Spanish | "Protección de Identidad con IA" | Add Spanish subtitles | +| French | "Protection d'Identité par IA" | Add French subtitles | + +For localized versions, create subtitle tracks (.srt files) and upload to YouTube as closed captions. diff --git a/android/marketing/play-store/README.md b/android/marketing/play-store/README.md new file mode 100644 index 0000000..0b711ae --- /dev/null +++ b/android/marketing/play-store/README.md @@ -0,0 +1,73 @@ +# Kordant Play Store Marketing Assets + +## Feature Graphics + +All feature graphics are **1024×500 pixels** in 24-bit PNG format, meeting [Google Play Store requirements](https://support.google.com/googleplay/android-developer/answer/9859152). + +| File | Language | Tagline | +|------|----------|---------| +| `feature-graphic.png` | English (default) | AI-Powered Identity Protection | +| `feature-graphic-es.png` | Spanish | Protección de Identidad con IA | +| `feature-graphic-fr.png` | French | Protection d'Identité par IA | + +### Design Specifications + +- **Dimensions:** 1024×500 pixels +- **Format:** 24-bit PNG (no alpha) +- **Background:** Gradient from indigo (#1E1B4B) to navy (#0F172A) +- **Typography:** Inter Bold (app name), Inter SemiBold (tagline), Inter Regular (features) +- **Elements:** Shield icon with checkmark, app name, tagline, accent line, feature list +- **Decorative:** Subtle accent band, concentric rings (right side) +- **Readable on:** Both light and dark Play Store themes + +### Regenerating Graphics + +```bash +# Requires: Python 3 with Pillow +# Fonts: /tmp/inter_fonts/Inter-{Regular,SemiBold,Bold}.ttf +python3 /tmp/create_graphics.py +``` + +## Promo Video + +See [PROMO-VIDEO-STORYBOARD.md](./PROMO-VIDEO-STORYBOARD.md) for the complete storyboard, production notes, and upload instructions. + +### Key Details + +- **Duration:** 45 seconds +- **Format:** 1080p (1920×1080), 30fps +- **Scenes:** Hook → Brand → DarkWatch → VoicePrint → SpamShield → HomeTitle → Dashboard → CTA +- **CTA:** "Download on Google Play" + +### Upload Checklist + +- [ ] Record Android screen captures (Pixel 6 emulator, dark mode) +- [ ] Edit with transitions, text overlays, background music +- [ ] Export in 1080p MP4 +- [ ] Upload to YouTube (unlisted or public) +- [ ] Add title, description, tags +- [ ] Add Spanish and French subtitle tracks +- [ ] Copy video URL to Play Console +- [ ] Verify video plays correctly in Play Store preview + +## Play Console Upload + +1. Go to [Play Console](https://play.google.com/console) → Kordant +2. Navigate to **Store presence** → **Main store listing** +3. Upload `feature-graphic.png` as **Feature graphic** +4. Add YouTube video URL as **Video** +5. For localized versions: + - Go to **Store presence** → **Store listing resources** + - Add language-specific feature graphics + - Add localized text as needed +6. **Preview** on mobile and desktop +7. **Save** and **Review** changes + +## Brand Compliance + +All assets follow [Kordant Brand Guidelines](../../../docs/BRAND_GUIDELINES.md): + +- **Colors:** Primary #4F46E5, Accent #06B6D4, Light #818CF8 +- **Typography:** Inter (Bold 700, SemiBold 600, Regular 400) +- **Style:** Security-focused, empowering, clear, trustworthy +- **No:** All-caps body text, italic weights, arbitrary spacing diff --git a/android/marketing/play-store/feature-graphic-es.png b/android/marketing/play-store/feature-graphic-es.png new file mode 100644 index 0000000..adcb324 Binary files /dev/null and b/android/marketing/play-store/feature-graphic-es.png differ diff --git a/android/marketing/play-store/feature-graphic-fr.png b/android/marketing/play-store/feature-graphic-fr.png new file mode 100644 index 0000000..2c4492d Binary files /dev/null and b/android/marketing/play-store/feature-graphic-fr.png differ diff --git a/android/marketing/play-store/feature-graphic.png b/android/marketing/play-store/feature-graphic.png new file mode 100644 index 0000000..9afe28a Binary files /dev/null and b/android/marketing/play-store/feature-graphic.png differ diff --git a/android/scripts/README.md b/android/scripts/README.md new file mode 100644 index 0000000..2f73ddf --- /dev/null +++ b/android/scripts/README.md @@ -0,0 +1,84 @@ +# Android Build Scripts + +Scripts for building, signing, and distributing the Kordant Android app. + +## Scripts + +### `generate-release-key.sh` +Generates a release keystore and configures signing for Google Play. + +```bash +chmod +x scripts/generate-release-key.sh +./scripts/generate-release-key.sh +``` + +Creates: +- `kordant-release.keystore` — The keystore file (KEEP SECURE) +- `key.properties` — Gradle signing credentials (in `.gitignore`) + +### `build-release-aab.sh` +Builds a signed Android App Bundle (AAB) for Google Play upload. + +```bash +chmod +x scripts/build-release-aab.sh +./scripts/build-release-aab.sh # prodRelease (default) +./scripts/build-release-aab.sh --variant=devRelease +``` + +Requires: +- `key.properties` configured (copy from `key.properties.template`) +- Android SDK configured in `local.properties` + +## Build Variants + +| Variant | Application ID | API URL | Use Case | +|---------|---------------|---------|----------| +| `prodRelease` | `com.kordant.android` | `api.kordant.com` | Google Play production | +| `devRelease` | `com.kordant.android.dev` | `10.0.2.2:3000` | Internal testing | +| `prodDebug` | `com.kordant.android.debug` | `api.kordant.com` | Debug with prod config | +| `devDebug` | `com.kordant.android.dev.debug` | `10.0.2.2:3000` | Development | + +## Gradle Commands + +```bash +# Build release AAB (for Play Store) +./gradlew bundleProdRelease + +# Build release APK (for sideloading) +./gradlew assembleProdRelease + +# Build debug APK +./gradlew assembleDevDebug + +# Run unit tests +./gradlew test + +# Run instrumentation tests (requires device/emulator) +./gradlew connectedAndroidTest + +# Generate baseline profile (for startup optimization) +./gradlew baselineProfileProdRelease + +# Clean build +./gradlew clean +``` + +## Output Locations + +| Build Type | Output Path | +|------------|-------------| +| AAB | `app/build/outputs/bundle/prodRelease/app-prod-release.aab` | +| APK | `app/build/outputs/apk/prod/release/app-prod-release.apk` | +| Test APK | `app/build/outputs/apk/androidTest/prod/debug/app-prod-debug-androidTest.apk` | +| ProGuard mapping | `app/build/outputs/mapping/prodRelease/mapping.txt` | +| Baseline profile | `app/build/outputs/baselineProfiles/prodRelease/baseline-prof.txt` | + +## Signing + +The app uses Google Play App Signing. The upload key is managed via `key.properties`: + +1. Copy template: `cp key.properties.template key.properties` +2. Edit with your credentials +3. Build: `./gradlew bundleProdRelease` + +The `key.properties` file is in `.gitignore` and should NEVER be committed. diff --git a/android/scripts/build-release-aab.sh b/android/scripts/build-release-aab.sh new file mode 100755 index 0000000..e2522d3 --- /dev/null +++ b/android/scripts/build-release-aab.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# ============================================================ +# Kordant Release AAB Builder +# ============================================================ +# +# Builds a signed Android App Bundle (AAB) for Google Play. +# +# Usage: +# ./scripts/build-release-aab.sh +# ./scripts/build-release-aab.sh --variant=prodRelease +# ./scripts/build-release-aab.sh --variant=devRelease +# +# Prerequisites: +# - key.properties configured (see key.properties.template) +# - Android SDK and build tools installed +# - Google Services JSON file in app/ (if using Firebase) +# +# Output: +# - app/build/outputs/bundle/prodRelease/app-prod-release.aab +# - app/build/outputs/bundle/devRelease/app-dev-release.aab +# ============================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +VARIANT="prodRelease" + +# Parse arguments +for arg in "$@"; do + case $arg in + --variant=*) + VARIANT="${arg#*=}" + shift + ;; + --help|-h) + echo "Usage: $0 [--variant=prodRelease|devRelease]" + echo "" + echo "Options:" + echo " --variant Build variant (default: prodRelease)" + echo " --help Show this help message" + exit 0 + ;; + esac +done + +cd "$PROJECT_DIR" + +echo "============================================" +echo " Kordant Release AAB Builder" +echo "============================================" +echo "" +echo "Variant: $VARIANT" +echo "" + +# Check for key.properties +if [ ! -f "key.properties" ]; then + echo "ERROR: key.properties not found." + echo "" + echo "Create it from the template:" + echo " cp key.properties.template key.properties" + echo " # Then edit key.properties with your credentials" + echo "" + echo "Or generate a new keystore:" + echo " ./scripts/generate-release-key.sh" + exit 1 +fi + +# Check for google-services.json (needed for Firebase) +if [ ! -f "app/google-services.json" ]; then + echo "WARNING: google-services.json not found in app/" + echo "Firebase features (FCM, Crashlytics) will not work." + echo "Download from Firebase Console → Project Settings → Your apps" + echo "" +fi + +# Run the build +echo "Building $VARIANT..." +echo "" + +./gradlew "bundle${VARIANT}" \ + --no-daemon \ + --parallel \ + --build-cache \ + -Pandroid.injected.signing.storefile="$(pwd)/kordant-release.keystore" \ + 2>&1 | tail -50 + +BUILD_STATUS=$? + +if [ $BUILD_STATUS -ne 0 ]; then + echo "" + echo "ERROR: Build failed with exit code $BUILD_STATUS" + echo "" + echo "Common issues:" + echo " 1. key.properties has wrong credentials" + echo " 2. Keystore file missing or corrupted" + echo " 3. Android SDK not configured in local.properties" + echo " 4. google-services.json missing" + exit $BUILD_STATUS +fi + +# Find the AAB +AAB_PATH="app/build/outputs/bundle/${VARIANT}/app-${VARIANT}.aab" +if [ -f "$AAB_PATH" ]; then + AAB_SIZE=$(du -h "$AAB_PATH" | cut -f1) + echo "" + echo "✓ Build successful!" + echo "" + echo "AAB: $AAB_PATH" + echo "Size: $AAB_SIZE" + echo "" + echo "Upload to Google Play Console:" + echo " 1. Go to Play Console → Testing → Internal testing" + echo " 2. Click 'Create new release'" + echo " 3. Upload $AAB_PATH" + echo "" +else + echo "" + echo "ERROR: AAB not found at expected path: $AAB_PATH" + echo "" + echo "Looking for any AAB files..." + find app/build/outputs/bundle -name "*.aab" 2>/dev/null || echo "No AAB files found." + exit 1 +fi + +# Generate bundle report +echo "Bundle contents:" +echo "" +if command -v bundletool &> /dev/null; then + bundletool dump manifest --module-path="$AAB_PATH" --dump-mode=MERGED_MANIFEST 2>/dev/null | head -30 || true +else + echo "(bundletool not installed — install with: sdkmanager \"bundle-tools\")" +fi + +echo "" +echo "============================================" diff --git a/android/scripts/generate-release-key.sh b/android/scripts/generate-release-key.sh new file mode 100755 index 0000000..37e3a64 --- /dev/null +++ b/android/scripts/generate-release-key.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# ============================================================ +# Kordant Release Keystore Generator +# ============================================================ +# +# Generates a release keystore and upload key for Google Play. +# Also creates the key.properties file for Gradle signing. +# +# Usage: +# ./scripts/generate-release-key.sh +# +# Output: +# - kordant-release.keystore (in android/ directory) +# - key.properties (in android/ directory, added to .gitignore) +# +# Security: +# - Store the keystore in a secure location (password manager, HSM) +# - Back up the keystore — losing it means losing ability to update the app +# - The upload key is ONLY for uploading to Play Console +# - Google Play App Signing manages the actual app signing key +# ============================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +KEYSTORE_PATH="$PROJECT_DIR/kordant-release.keystore" +KEY_PROPS_PATH="$PROJECT_DIR/key.properties" +KEY_ALIAS="kordant-release-key" +KEY_VALIDITY=25550 # ~70 years (max for Java keytool) + +echo "============================================" +echo " Kordant Release Keystore Generator" +echo "============================================" +echo "" + +# Check if keytool is available +if ! command -v keytool &> /dev/null; then + echo "ERROR: keytool not found. Install Java JDK." + exit 1 +fi + +# Check if keystore already exists +if [ -f "$KEYSTORE_PATH" ]; then + echo "WARNING: Keystore already exists at $KEYSTORE_PATH" + echo "" + read -p "Overwrite existing keystore? (y/N): " confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Aborted. Keystore not overwritten." + exit 0 + fi +fi + +# Collect keystore information +echo "Enter keystore details:" +echo "" +read -p " Keystore password: " STORE_PASSWORD +read -p " Confirm password: " STORE_PASSWORD_CONFIRM + +if [ "$STORE_PASSWORD" != "$STORE_PASSWORD_CONFIRM" ]; then + echo "ERROR: Passwords do not match." + exit 1 +fi + +read -p " Key password (enter for same as keystore): " KEY_PASSWORD +KEY_PASSWORD="${KEY_PASSWORD:-$STORE_PASSWORD}" + +read -p " Your name: " CN +read -p " Organization unit (OU): " OU +read -p " Organization (O): " O +read -p " City/Locality (L): " L +read -p " State/Province (ST): " ST +read -p " Country code (C, e.g., US): " C + +# Generate the keystore +echo "" +echo "Generating keystore..." +keytool -genkeypair \ + -v \ + -keystore "$KEYSTORE_PATH" \ + -alias "$KEY_ALIAS" \ + -keyalg RSA \ + -keysize 2048 \ + -sigalg SHA256withRSA \ + -storetype JKS \ + -storepass "$STORE_PASSWORD" \ + -keypass "$KEY_PASSWORD" \ + -validity "$KEY_VALIDITY" \ + -dname "CN=$CN, OU=$OU, O=$O, L=$L, ST=$ST, C=$C" + +echo "" +echo "✓ Keystore generated: $KEYSTORE_PATH" + +# Extract the public key hash for Google Play App Signing +echo "" +echo "Extracting certificate fingerprint..." +CERT_SHA256=$(keytool -list -v \ + -keystore "$KEYSTORE_PATH" \ + -alias "$KEY_ALIAS" \ + -storepass "$STORE_PASSWORD" \ + -keypass "$KEY_PASSWORD" \ + 2>/dev/null | grep "SHA256:" | awk '{print $2}') + +echo " SHA-256: $CERT_SHA256" + +# Generate key.properties +echo "" +echo "Creating key.properties..." +cat > "$KEY_PROPS_PATH" << EOF +# ============================================================ +# Kordant Release Keystore Configuration +# Auto-generated on $(date -u +"%Y-%m-%dT%H:%M:%SZ") +# ============================================================ +# +# IMPORTANT: This file contains sensitive credentials. +# NEVER commit this file to version control. +# ============================================================ + +storeFile=../kordant-release.keystore +storePassword=$STORE_PASSWORD +keyAlias=$KEY_ALIAS +keyPassword=$KEY_PASSWORD +EOF + +echo "✓ key.properties created: $KEY_PROPS_PATH" + +# Verify the keystore +echo "" +echo "Verifying keystore..." +keytool -list -v \ + -keystore "$KEYSTORE_PATH" \ + -storepass "$STORE_PASSWORD" \ + 2>/dev/null | head -20 + +echo "" +echo "============================================" +echo " Next Steps" +echo "============================================" +echo "" +echo "1. Back up the keystore securely:" +echo " - Store in password manager (1Password, Bitwarden, etc.)" +echo " - Keep an offline copy in a safe" +echo " - DO NOT commit to version control" +echo "" +echo "2. Upload to Google Play Console:" +echo " - Go to Play Console → Setup → App integrity → App signing" +echo " - Upload the keystore or its certificate" +echo " - Enable Google Play App Signing" +echo "" +echo "3. Build the release AAB:" +echo " cd android && ./gradlew bundleProdRelease" +echo "" +echo "4. Upload the AAB to Play Console:" +echo " - Play Console → Testing → Internal testing → Create release" +echo " - Upload app/bundle/release/app-prod-release.aab" +echo "" +echo "============================================" diff --git a/tasks/android-production/README.md b/tasks/android-production/README.md index 78cfb01..2a7d981 100644 --- a/tasks/android-production/README.md +++ b/tasks/android-production/README.md @@ -8,8 +8,8 @@ Status legend: [ ] todo, [~] in-progress, [x] done ### Play Store Preparation - [x] 01 — Play Store Listing Assets → `01-play-store-assets.md` -- [~] 02 — Feature Graphic & Promo Video → `02-feature-graphic.md` -- [~] 03 — Play Console Configuration → `03-play-console.md` +- [x] 02 — Feature Graphic & Promo Video → `02-feature-graphic.md` +- [x] 03 — Play Console Configuration → `03-play-console.md` - [x] 04 — Internal Testing Track → `04-internal-testing.md` ### Security Hardening @@ -38,9 +38,9 @@ Status legend: [ ] todo, [~] in-progress, [x] done ### Backend Integration - [x] 21 — Real API Client Verification & Wire-up → `21-api-verification.md` -- [~] 22 — Token Refresh & Session Management → `22-token-refresh.md` -- [~] 23 — Offline Sync & Conflict Resolution → `23-offline-sync.md` -- [ ] 24 — FCM Push Notification Deep Linking → `24-fcm-deep-links.md` +- [x] 22 — Token Refresh & Session Management → `22-token-refresh.md` +- [x] 23 — Offline Sync & Conflict Resolution → `23-offline-sync.md` +- [x] 24 — FCM Push Notification Deep Linking → `24-fcm-deep-links.md` ### Play Store Compliance - [x] 25 — Privacy Policy & Data Safety Form → `25-privacy-data-safety.md`