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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user