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

@@ -1,10 +1,33 @@
package com.example.casera.utils
import com.example.casera.cache.SubscriptionCache
import com.example.casera.data.DataManager
/**
* Canonical product IDs for in-app subscriptions.
*
* These must match the product IDs configured in:
* - Apple App Store Connect (StoreKit configuration)
* - Google Play Console (subscription products)
*
* All code referencing subscription product IDs should use these constants
* instead of hardcoded strings to ensure consistency.
*/
object SubscriptionProducts {
const val MONTHLY = "com.example.casera.pro.monthly"
const val ANNUAL = "com.example.casera.pro.annual"
/** All product IDs as a list, useful for querying store product details. */
val all: List<String> = listOf(MONTHLY, ANNUAL)
}
/**
* Helper for checking subscription limits and determining when to show upgrade prompts.
*
* Reads ALL subscription state from DataManager (single source of truth).
* The current tier is derived from the backend subscription status:
* - If expiresAt is present and non-empty, user is "pro"
* - Otherwise, user is "free"
*
* RULES:
* 1. Backend limitations OFF: Never show upgrade view, allow everything
* 2. Backend limitations ON + limit=0: Show upgrade view, block access entirely (no add button)
@@ -20,9 +43,30 @@ object SubscriptionHelper {
*/
data class UsageCheck(val allowed: Boolean, val triggerKey: String?)
// NOTE: For Android, currentTier should be set from Google Play Billing
// For iOS, tier is managed by SubscriptionCacheWrapper from StoreKit
var currentTier: String = "free"
/**
* Derive the current subscription tier from DataManager.
* "pro" if the backend subscription has a non-empty expiresAt (active paid plan),
* "free" otherwise.
*/
val currentTier: String
get() {
val subscription = DataManager.subscription.value ?: return "free"
val expiresAt = subscription.expiresAt
return if (!expiresAt.isNullOrEmpty()) "pro" else "free"
}
/**
* Whether the user has a premium (pro) subscription.
* True when limitations are disabled (everyone gets full access)
* OR when the user is on the pro tier.
*/
val isPremium: Boolean
get() {
val subscription = DataManager.subscription.value ?: return false
// If limitations are disabled, everyone effectively has premium access
if (!subscription.limitationsEnabled) return true
return currentTier == "pro"
}
// ===== PROPERTY (RESIDENCE) =====
@@ -31,18 +75,19 @@ object SubscriptionHelper {
* Returns true (blocked) only when limitations are ON and limit=0.
*/
fun isResidencesBlocked(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(false, null) // Allow access while loading
if (!subscription.limitationsEnabled) {
return UsageCheck(false, null) // Limitations disabled, never block
}
if (currentTier == "pro") {
val tier = currentTier
if (tier == "pro") {
return UsageCheck(false, null) // Pro users never blocked
}
val limit = subscription.limits[currentTier]?.properties
val limit = subscription.limits[tier]?.properties
// If limit is 0, block access entirely
if (limit == 0) {
@@ -57,19 +102,20 @@ object SubscriptionHelper {
* Used when limit > 0 and user has reached the limit.
*/
fun canAddProperty(currentCount: Int = 0): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(true, null) // Allow if no subscription data
if (!subscription.limitationsEnabled) {
return UsageCheck(true, null) // Limitations disabled, allow everything
}
if (currentTier == "pro") {
val tier = currentTier
if (tier == "pro") {
return UsageCheck(true, null) // Pro tier gets unlimited access
}
// Get limit for current tier (null = unlimited)
val limit = subscription.limits[currentTier]?.properties
val limit = subscription.limits[tier]?.properties
// null means unlimited
if (limit == null) {
@@ -97,18 +143,19 @@ object SubscriptionHelper {
* Returns true (blocked) only when limitations are ON and limit=0.
*/
fun isTasksBlocked(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(false, null) // Allow access while loading
if (!subscription.limitationsEnabled) {
return UsageCheck(false, null)
}
if (currentTier == "pro") {
val tier = currentTier
if (tier == "pro") {
return UsageCheck(false, null)
}
val limit = subscription.limits[currentTier]?.tasks
val limit = subscription.limits[tier]?.tasks
if (limit == 0) {
return UsageCheck(true, "add_11th_task")
@@ -121,18 +168,19 @@ object SubscriptionHelper {
* Check if user can add a task (when trying to add).
*/
fun canAddTask(currentCount: Int = 0): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(true, null)
if (!subscription.limitationsEnabled) {
return UsageCheck(true, null)
}
if (currentTier == "pro") {
val tier = currentTier
if (tier == "pro") {
return UsageCheck(true, null)
}
val limit = subscription.limits[currentTier]?.tasks
val limit = subscription.limits[tier]?.tasks
if (limit == null) {
return UsageCheck(true, null) // Unlimited
@@ -156,18 +204,19 @@ object SubscriptionHelper {
* Returns true (blocked) only when limitations are ON and limit=0.
*/
fun isContractorsBlocked(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(false, null) // Allow access while loading
if (!subscription.limitationsEnabled) {
return UsageCheck(false, null)
}
if (currentTier == "pro") {
val tier = currentTier
if (tier == "pro") {
return UsageCheck(false, null)
}
val limit = subscription.limits[currentTier]?.contractors
val limit = subscription.limits[tier]?.contractors
if (limit == 0) {
return UsageCheck(true, "view_contractors")
@@ -180,18 +229,19 @@ object SubscriptionHelper {
* Check if user can add a contractor (when trying to add).
*/
fun canAddContractor(currentCount: Int = 0): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(true, null)
if (!subscription.limitationsEnabled) {
return UsageCheck(true, null)
}
if (currentTier == "pro") {
val tier = currentTier
if (tier == "pro") {
return UsageCheck(true, null)
}
val limit = subscription.limits[currentTier]?.contractors
val limit = subscription.limits[tier]?.contractors
if (limit == null) {
return UsageCheck(true, null)
@@ -215,18 +265,19 @@ object SubscriptionHelper {
* Returns true (blocked) only when limitations are ON and limit=0.
*/
fun isDocumentsBlocked(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(false, null) // Allow access while loading
if (!subscription.limitationsEnabled) {
return UsageCheck(false, null)
}
if (currentTier == "pro") {
val tier = currentTier
if (tier == "pro") {
return UsageCheck(false, null)
}
val limit = subscription.limits[currentTier]?.documents
val limit = subscription.limits[tier]?.documents
if (limit == 0) {
return UsageCheck(true, "view_documents")
@@ -239,18 +290,19 @@ object SubscriptionHelper {
* Check if user can add a document (when trying to add).
*/
fun canAddDocument(currentCount: Int = 0): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(true, null)
if (!subscription.limitationsEnabled) {
return UsageCheck(true, null)
}
if (currentTier == "pro") {
val tier = currentTier
if (tier == "pro") {
return UsageCheck(true, null)
}
val limit = subscription.limits[currentTier]?.documents
val limit = subscription.limits[tier]?.documents
if (limit == null) {
return UsageCheck(true, null)
@@ -274,7 +326,7 @@ object SubscriptionHelper {
* Returns true (blocked) when limitations are ON and user is not pro.
*/
fun canShareResidence(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(true, null) // Allow if no subscription data (fallback)
if (!subscription.limitationsEnabled) {
@@ -296,7 +348,7 @@ object SubscriptionHelper {
* Returns true (blocked) when limitations are ON and user is not pro.
*/
fun canShareContractor(): UsageCheck {
val subscription = SubscriptionCache.currentSubscription.value
val subscription = DataManager.subscription.value
?: return UsageCheck(true, null) // Allow if no subscription data (fallback)
if (!subscription.limitationsEnabled) {