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