Close all 25 codex audit findings across KMP, iOS, and Android

Remediate all P0-S priority findings from cross-platform architecture audit:
- Harden token storage with EncryptedSharedPreferences (Android) and Keychain (iOS)
- Add SSL pinning and certificate validation to API clients
- Fix subscription cache race conditions and add thread-safe access
- Add input validation for document uploads and file type restrictions
- Refactor DocumentApi to use proper multipart upload flow
- Add rate limiting awareness and retry logic to API layer
- Harden subscription tier enforcement in SubscriptionHelper
- Add biometric prompt for sensitive actions (Login, Onboarding)
- Fix notification permission handling and device registration
- Add UI test infrastructure (page objects, fixtures, smoke tests)
- Add CI workflow for mobile builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-18 13:15:34 -06:00
parent ffe5716167
commit 7444f73b46
56 changed files with 1539 additions and 569 deletions

View File

@@ -8,7 +8,7 @@ import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.example.casera.cache.SubscriptionCache
import com.example.casera.data.DataManager
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
@@ -155,7 +155,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
}
private fun isPremiumUser(): Boolean {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
// User is premium if limitations are disabled
return subscription?.limitationsEnabled == false
}

View File

@@ -5,7 +5,7 @@ import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.example.casera.cache.SubscriptionCache
import com.example.casera.data.DataManager
import com.example.casera.models.TaskCompletionCreateRequest
import com.example.casera.network.APILayer
import com.example.casera.network.ApiResult
@@ -67,7 +67,7 @@ class NotificationActionReceiver : BroadcastReceiver() {
}
private fun isPremiumUser(): Boolean {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
// User is premium if limitations are disabled
return subscription?.limitationsEnabled == false
}

View File

@@ -32,7 +32,9 @@ actual fun createHttpClient(): HttpClient {
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.ALL
// Only log full request/response bodies in debug builds to avoid
// leaking auth tokens and PII in production logcat.
level = if (com.example.casera.BuildConfig.DEBUG) LogLevel.ALL else LogLevel.INFO
}
install(DefaultRequest) {

View File

@@ -4,10 +4,9 @@ import android.app.Activity
import android.content.Context
import android.util.Log
import com.android.billingclient.api.*
import com.example.casera.cache.SubscriptionCache
import com.example.casera.network.APILayer
import com.example.casera.network.ApiResult
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.utils.SubscriptionProducts
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -35,10 +34,7 @@ class BillingManager private constructor(private val context: Context) {
}
// Product IDs (must match Google Play Console)
private val productIDs = listOf(
"com.example.casera.pro.monthly",
"com.example.casera.pro.annual"
)
private val productIDs = SubscriptionProducts.all
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@@ -237,10 +233,7 @@ class BillingManager private constructor(private val context: Context) {
// Update local state
_purchasedProductIDs.value = _purchasedProductIDs.value + purchase.products.toSet()
// Update subscription tier
SubscriptionHelper.currentTier = "pro"
// Refresh subscription status from backend
// Refresh subscription status from backend (updates DataManager which derives tier)
APILayer.getSubscriptionStatus(forceRefresh = true)
Log.d(TAG, "Purchase verified and acknowledged")
@@ -341,10 +334,7 @@ class BillingManager private constructor(private val context: Context) {
)
}
// Update subscription tier
SubscriptionHelper.currentTier = "pro"
// Refresh subscription status from backend
// Refresh subscription status from backend (updates DataManager which derives tier)
APILayer.getSubscriptionStatus(forceRefresh = true)
true
@@ -423,14 +413,14 @@ class BillingManager private constructor(private val context: Context) {
* Get monthly product
*/
fun getMonthlyProduct(): ProductDetails? {
return _products.value.find { it.productId == "com.example.casera.pro.monthly" }
return _products.value.find { it.productId == SubscriptionProducts.MONTHLY }
}
/**
* Get annual product
*/
fun getAnnualProduct(): ProductDetails? {
return _products.value.find { it.productId == "com.example.casera.pro.annual" }
return _products.value.find { it.productId == SubscriptionProducts.ANNUAL }
}
/**

View File

@@ -2,15 +2,20 @@ package com.example.casera.storage
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
/**
* Android implementation of TokenManager using SharedPreferences.
* Android implementation of TokenManager using EncryptedSharedPreferences.
*
* Uses AndroidX Security Crypto library with AES256-GCM master key
* to encrypt the auth token at rest. Falls back to regular SharedPreferences
* if EncryptedSharedPreferences initialization fails (e.g., on very old devices
* or when the Keystore is in a broken state).
*/
actual class TokenManager(private val context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
PREFS_NAME,
Context.MODE_PRIVATE
)
private val prefs: SharedPreferences = createEncryptedPrefs(context)
actual fun saveToken(token: String) {
prefs.edit().putString(KEY_TOKEN, token).apply()
@@ -25,7 +30,9 @@ actual class TokenManager(private val context: Context) {
}
companion object {
private const val PREFS_NAME = "mycrib_prefs"
private const val TAG = "TokenManager"
private const val ENCRYPTED_PREFS_NAME = "mycrib_secure_prefs"
private const val FALLBACK_PREFS_NAME = "mycrib_prefs"
private const val KEY_TOKEN = "auth_token"
@Volatile
@@ -36,5 +43,34 @@ actual class TokenManager(private val context: Context) {
instance ?: TokenManager(context.applicationContext).also { instance = it }
}
}
/**
* Creates EncryptedSharedPreferences backed by an AES256-GCM master key.
* If initialization fails (broken Keystore, unsupported device, etc.),
* falls back to plain SharedPreferences with a warning log.
*/
private fun createEncryptedPrefs(context: Context): SharedPreferences {
return try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
Log.w(
TAG,
"Failed to create EncryptedSharedPreferences, falling back to plain SharedPreferences. " +
"Auth tokens will NOT be encrypted at rest on this device.",
e
)
context.getSharedPreferences(FALLBACK_PREFS_NAME, Context.MODE_PRIVATE)
}
}
}
}

View File

@@ -17,7 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.android.billingclient.api.ProductDetails
import com.example.casera.cache.SubscriptionCache
import com.example.casera.data.DataManager
import com.example.casera.platform.BillingManager
import com.example.casera.ui.theme.AppSpacing
import kotlinx.coroutines.launch
@@ -50,7 +50,7 @@ fun UpgradeFeatureScreenAndroid(
// Look up trigger data from cache
val triggerData by remember { derivedStateOf {
SubscriptionCache.upgradeTriggers.value[triggerKey]
DataManager.upgradeTriggers.value[triggerKey]
} }
// Fallback values if trigger not found