docs: add README, add proguard rules, Firestore entitlement checker, network security config, update build config and onboarding
This commit is contained in:
parent
84995641f3
commit
afeb1a1a03
|
|
@ -0,0 +1,148 @@
|
||||||
|
# Closer
|
||||||
|
|
||||||
|
A private, warm, intentional space for couples to build deeper emotional connection.
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
Closer is a native Android app designed to help couples communicate better, learn more about each other, and create intentional moments together. It focuses on emotional closeness — not task management, not social media, not productivity.
|
||||||
|
|
||||||
|
## Screens & Features
|
||||||
|
|
||||||
|
### Onboarding & Auth
|
||||||
|
- Welcome flow with app introduction
|
||||||
|
- Create profile (display name, photo)
|
||||||
|
- Sign up with email or Google
|
||||||
|
- Login and password recovery
|
||||||
|
- Anonymous trial mode
|
||||||
|
|
||||||
|
### Home
|
||||||
|
- Suggested next-best action ranked by state
|
||||||
|
- Latest daily question status
|
||||||
|
- Quick-action cards for daily question, question packs, and history
|
||||||
|
- Streak tracking display
|
||||||
|
- Answer stats at a glance
|
||||||
|
|
||||||
|
### Daily Question
|
||||||
|
- One question per day for the couple
|
||||||
|
- Multiple answer types: written, choice, scale, this-or-that
|
||||||
|
- Each partner answers privately, then both answers are revealed
|
||||||
|
|
||||||
|
### Question Packs
|
||||||
|
**20 categories across 2,500+ questions:**
|
||||||
|
- Fun, Communication, Conflict, Values, Emotional Intimacy
|
||||||
|
- Physical Intimacy, Sex & Desire, Future, Money, Stress
|
||||||
|
- Boundaries, Conflict Repair, Date Night, Difficult Conversations
|
||||||
|
- Gratitude, Home Life, Marriage, Parenting, Rebuilding Trust, Trust
|
||||||
|
|
||||||
|
Each pack has curated questions at varying depth levels. Packs are labeled as free or premium.
|
||||||
|
|
||||||
|
### Answer System
|
||||||
|
- Private written, choice, scale, or this-or-that answers
|
||||||
|
- Partner answer reveal flow
|
||||||
|
- Local answer history with delete
|
||||||
|
- Emoji reactions to partner answers
|
||||||
|
- Threaded conversation around specific questions
|
||||||
|
|
||||||
|
### Spin Wheel (Wheel of Questions)
|
||||||
|
- Pick a category, spin the wheel, get a random question
|
||||||
|
- Session-based: spin multiple questions in one session
|
||||||
|
- Full session history for premium users
|
||||||
|
- Perfect for date nights, long drives, or quiet moments together
|
||||||
|
|
||||||
|
### Partner Pairing
|
||||||
|
- Generate a 6-character invite code
|
||||||
|
- Invite partner via email
|
||||||
|
- Accept invite and confirm pairing
|
||||||
|
- Partner home screen showing partner's activity
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- Account management (email, display name, photo)
|
||||||
|
- Notification preferences (reminders, quiet hours)
|
||||||
|
- Privacy controls
|
||||||
|
- Subscription management via RevenueCat
|
||||||
|
- Relationship settings
|
||||||
|
- Account deletion
|
||||||
|
|
||||||
|
## Subscription Model
|
||||||
|
|
||||||
|
| Tier | Key Features |
|
||||||
|
|------|-------------|
|
||||||
|
| **Free** | Daily question, recent answer history, basic categories, basic reminders, streak tracking, limited spin wheel sessions |
|
||||||
|
| **Premium** | All free features + premium question packs, full spin wheel access with saved history, unlimited questions, full answer history with search, custom questions, private notes, exportable memories, advanced reminders, AI-assisted suggestions (future) |
|
||||||
|
|
||||||
|
Powered by RevenueCat + Google Play Billing.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Tech |
|
||||||
|
|-------|------|
|
||||||
|
| Language | Kotlin |
|
||||||
|
| UI | Jetpack Compose, Material 3 |
|
||||||
|
| Architecture | Clean Architecture (data / domain / ui / core) |
|
||||||
|
| Navigation | Navigation Compose |
|
||||||
|
| DI | Hilt |
|
||||||
|
| Local Storage | Room, DataStore Preferences |
|
||||||
|
| Backend | Firebase Auth, Firestore, Cloud Functions |
|
||||||
|
| Notifications | Firebase Cloud Messaging |
|
||||||
|
| Analytics | Firebase Analytics, Crashlytics |
|
||||||
|
| Payments | RevenueCat |
|
||||||
|
| Security | Firebase App Check (Play Integrity) |
|
||||||
|
| Min SDK | 26 |
|
||||||
|
| Target SDK | 35 |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── core/ # Cross-cutting: analytics, billing, crash, features, Firebase, navigation, notifications
|
||||||
|
├── data/ # Data layer: Room DB, Firestore datasources, repository implementations, question JSON parser
|
||||||
|
├── domain/ # Domain layer: models, repository interfaces, use cases
|
||||||
|
└── ui/ # Presentation layer: screens + viewmodels per feature area
|
||||||
|
├── answers/ # Answer history, answer reveal
|
||||||
|
├── auth/ # Login, signup, forgot password
|
||||||
|
├── components/ # Shared UI components (empty, error, loading states, special dates)
|
||||||
|
├── home/ # Home screen, partner home
|
||||||
|
├── onboarding/ # Onboarding flow, create profile
|
||||||
|
├── pairing/ # Invite creation, acceptance, confirmation
|
||||||
|
├── paywall/ # Subscription upsell
|
||||||
|
├── questions/ # Daily question, pack library, categories, composer, thread
|
||||||
|
├── settings/ # Account, notifications, privacy, relationship, subscription, delete
|
||||||
|
├── theme/ # Color, typography, theme configuration
|
||||||
|
└── wheel/ # Category picker, spin wheel, session, history, complete
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
- **Firestore** — user profiles, couple relationships, question threads, invites, entitlements
|
||||||
|
- **Room** — local question cache, answered questions offline
|
||||||
|
- **DataStore** — user preferences, feature flags, settings
|
||||||
|
- **JSON assets** — bundled questions (2500+ across 20 categories)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app/
|
||||||
|
./gradlew assembleDebug # Build debug APK
|
||||||
|
./gradlew installDebug # Install on connected device/emulator
|
||||||
|
./gradlew assembleRelease # Build release APK (minified + shrunk)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Android Studio Hedgehog or later
|
||||||
|
- JDK 17
|
||||||
|
- Firebase project with Auth, Firestore, Cloud Messaging, and App Check configured
|
||||||
|
- `google-services.json` in `app/` directory
|
||||||
|
- RevenueCat project configured for Google Play
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- **Warm & Intimate** — The app should feel like a private emotional space, not a productivity tool
|
||||||
|
- **Privacy-first** — Data isolated by couple, strict Firestore rules
|
||||||
|
- **Offline-resilient** — Room cache keeps the app fast without network
|
||||||
|
- **Intentional** — Short, meaningful interactions. 5 good minutes > endless scrolling
|
||||||
|
- **2026 modern** — Smooth Material 3 animations, instant feedback, no jank
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private project. All rights reserved.
|
||||||
|
|
@ -22,11 +22,13 @@ android {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
|
|
@ -43,9 +45,7 @@ android {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
@ -94,6 +94,9 @@ dependencies {
|
||||||
// DataStore
|
// DataStore
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.2")
|
implementation("androidx.datastore:datastore-preferences:1.1.2")
|
||||||
|
|
||||||
|
// Encrypted storage
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
// RevenueCat
|
// RevenueCat
|
||||||
implementation("com.revenuecat.purchases:purchases-hybrid-common:13.5.0")
|
implementation("com.revenuecat.purchases:purchases-hybrid-common:13.5.0")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
# ── Firebase ─────────────────────────────────────────────────────────────────
|
||||||
|
-keep class com.google.firebase.** { *; }
|
||||||
|
-keep class com.google.android.gms.** { *; }
|
||||||
|
-keepattributes Signature
|
||||||
|
|
||||||
|
# ── Hilt ─────────────────────────────────────────────────────────────────────
|
||||||
|
-keep class dagger.hilt.** { *; }
|
||||||
|
-keepclasseswithmembers class * {
|
||||||
|
@dagger.hilt.android.lifecycle.HiltViewModel <init>(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Room ──────────────────────────────────────────────────────────────────────
|
||||||
|
-keep @androidx.room.Entity class * { *; }
|
||||||
|
-keep @androidx.room.Dao interface * { *; }
|
||||||
|
-keep @androidx.room.Database class * { *; }
|
||||||
|
|
||||||
|
# ── Domain models (used in Firestore manual mapping & local JSON) ─────────────
|
||||||
|
-keepclassmembers class app.closer.domain.model.** {
|
||||||
|
<fields>;
|
||||||
|
<init>(...);
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── RevenueCat ────────────────────────────────────────────────────────────────
|
||||||
|
-keep class com.revenuecat.** { *; }
|
||||||
|
-dontwarn com.revenuecat.**
|
||||||
|
|
||||||
|
# ── Kotlin coroutines ────────────────────────────────────────────────────────
|
||||||
|
-keepclassmembernames class kotlinx.** {
|
||||||
|
volatile <fields>;
|
||||||
|
}
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
||||||
|
# ── Crash reporting: preserve source file names and line numbers ─────────────
|
||||||
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
-keep public class * extends java.lang.Exception
|
||||||
|
-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
# ── Kotlin metadata (needed for reflection-free Kotlin code) ─────────────────
|
||||||
|
-keepattributes RuntimeVisibleAnnotations
|
||||||
|
-keepattributes RuntimeInvisibleAnnotations
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
|
||||||
|
# ── Prevent stripping of BuildConfig ─────────────────────────────────────────
|
||||||
|
-keep class app.closer.BuildConfig { *; }
|
||||||
|
|
||||||
|
# ── Suppress warnings for optional dependencies ──────────────────────────────
|
||||||
|
-dontwarn org.conscrypt.**
|
||||||
|
-dontwarn org.bouncycastle.**
|
||||||
|
-dontwarn org.openjsse.**
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:theme="@style/Theme.Closer"
|
android:theme="@style/Theme.Closer"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package app.closer.core.billing
|
||||||
|
|
||||||
|
import app.closer.domain.model.AuthState
|
||||||
|
import app.closer.domain.repository.AuthRepository
|
||||||
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
|
import com.google.firebase.firestore.ListenerRegistration
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class FirestoreEntitlementChecker @Inject constructor(
|
||||||
|
private val firestore: FirebaseFirestore,
|
||||||
|
private val authRepository: AuthRepository
|
||||||
|
) : EntitlementChecker {
|
||||||
|
|
||||||
|
@Volatile private var _hasPremium = false
|
||||||
|
override val hasPremium: Boolean get() = _hasPremium
|
||||||
|
|
||||||
|
private var listenerRegistration: ListenerRegistration? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||||
|
authRepository.authState.collect { state ->
|
||||||
|
listenerRegistration?.remove()
|
||||||
|
listenerRegistration = null
|
||||||
|
if (state is AuthState.Authenticated) {
|
||||||
|
listenerRegistration = firestore.collection("users")
|
||||||
|
.document(state.userId)
|
||||||
|
.addSnapshotListener { snap, _ ->
|
||||||
|
_hasPremium = snap?.getBoolean("hasPremium") == true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_hasPremium = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package app.closer.data.repository
|
package app.closer.data.repository
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import app.closer.domain.repository.LocalAnswerRepository
|
import app.closer.domain.repository.LocalAnswerRepository
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -17,7 +20,24 @@ class SharedPreferencesLocalAnswerRepository @Inject constructor(
|
||||||
@ApplicationContext context: Context
|
@ApplicationContext context: Context
|
||||||
) : LocalAnswerRepository {
|
) : LocalAnswerRepository {
|
||||||
|
|
||||||
private val prefs = context.getSharedPreferences("local_answers", Context.MODE_PRIVATE)
|
private val prefs: SharedPreferences = run {
|
||||||
|
// Remove legacy plaintext file on first migration
|
||||||
|
context.deleteSharedPreferences("local_answers")
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"local_answers_secure",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
context.getSharedPreferences("local_answers_secure", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
private val answers = MutableStateFlow(readAnswers())
|
private val answers = MutableStateFlow(readAnswers())
|
||||||
|
|
||||||
override fun observeAnswers(): Flow<List<LocalAnswer>> = answers
|
override fun observeAnswers(): Flow<List<LocalAnswer>> = answers
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package app.closer.di
|
package app.closer.di
|
||||||
|
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
import app.closer.core.billing.FakeEntitlementChecker
|
import app.closer.core.billing.FirestoreEntitlementChecker
|
||||||
import app.closer.data.local.SettingsDataStore
|
import app.closer.data.local.SettingsDataStore
|
||||||
import app.closer.data.repository.CoupleRepositoryImpl
|
import app.closer.data.repository.CoupleRepositoryImpl
|
||||||
import app.closer.data.repository.QuestionSessionRepositoryImpl
|
import app.closer.data.repository.QuestionSessionRepositoryImpl
|
||||||
|
|
@ -52,7 +52,7 @@ abstract class RepositoryModule {
|
||||||
abstract fun bindLocalAnswerRepository(impl: SharedPreferencesLocalAnswerRepository): LocalAnswerRepository
|
abstract fun bindLocalAnswerRepository(impl: SharedPreferencesLocalAnswerRepository): LocalAnswerRepository
|
||||||
|
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindEntitlementChecker(impl: FakeEntitlementChecker): EntitlementChecker
|
abstract fun bindEntitlementChecker(impl: FirestoreEntitlementChecker): EntitlementChecker
|
||||||
|
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository
|
abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ fun OnboardingScreen(
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Questions that bring you closer,\none answer at a time.",
|
text = "Questions that bring couples closer,\none answer at a time.",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = AuthMuted,
|
color = AuthMuted,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class QuestionDetailViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateWrittenText(text: String) {
|
fun updateWrittenText(text: String) {
|
||||||
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
|
_uiState.update { it.copy(pendingWrittenText = text.take(MAX_ANSWER_LENGTH), submitted = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleOption(optionId: String) {
|
fun toggleOption(optionId: String) {
|
||||||
|
|
@ -85,6 +85,10 @@ class QuestionDetailViewModel @Inject constructor(
|
||||||
|
|
||||||
fun canSubmit(): Boolean = canSubmit(_uiState.value)
|
fun canSubmit(): Boolean = canSubmit(_uiState.value)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_ANSWER_LENGTH = 2000
|
||||||
|
}
|
||||||
|
|
||||||
private fun canSubmit(state: LocalQuestionUiState): Boolean {
|
private fun canSubmit(state: LocalQuestionUiState): Boolean {
|
||||||
val question = state.question ?: return false
|
val question = state.question ?: return false
|
||||||
return when (question.type) {
|
return when (question.type) {
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
// ─── Answer input mutations ──────────────────────────────────────────────────
|
// ─── Answer input mutations ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fun updateWrittenText(text: String) {
|
fun updateWrittenText(text: String) {
|
||||||
_uiState.update { it.copy(pendingWrittenText = text) }
|
_uiState.update { it.copy(pendingWrittenText = text.take(MAX_ANSWER_LENGTH)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleOption(optionId: String) {
|
fun toggleOption(optionId: String) {
|
||||||
|
|
@ -194,7 +194,7 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
// ─── Discussion ──────────────────────────────────────────────────────────────
|
// ─── Discussion ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun updateMessageInput(text: String) {
|
fun updateMessageInput(text: String) {
|
||||||
_uiState.update { it.copy(messageInput = text) }
|
_uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage() {
|
fun sendMessage() {
|
||||||
|
|
@ -246,4 +246,9 @@ class QuestionThreadViewModel @Inject constructor(
|
||||||
fun dismissError() {
|
fun dismissError() {
|
||||||
_uiState.update { it.copy(error = null) }
|
_uiState.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_ANSWER_LENGTH = 2000
|
||||||
|
const val MAX_MESSAGE_LENGTH = 500
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!-- Release: HTTPS only, system CAs, no cleartext -->
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
|
||||||
|
<!-- Debug: also trust user-installed CAs (Charles Proxy, etc.) -->
|
||||||
|
<debug-overrides>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</debug-overrides>
|
||||||
|
</network-security-config>
|
||||||
|
|
@ -20,9 +20,14 @@ service cloud.firestore {
|
||||||
|
|
||||||
// ── Users ─────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────
|
||||||
// Each user owns exactly their own document.
|
// Each user owns exactly their own document.
|
||||||
|
// hasPremium is server-only: clients may not write it directly.
|
||||||
|
|
||||||
match /users/{uid} {
|
match /users/{uid} {
|
||||||
allow read, write: if isOwner(uid);
|
allow read: if isOwner(uid);
|
||||||
|
allow create: if isOwner(uid)
|
||||||
|
&& !request.resource.data.keys().hasAny(['hasPremium']);
|
||||||
|
allow update: if isOwner(uid)
|
||||||
|
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(['hasPremium']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Invite codes ──────────────────────────────────────────────────────────
|
// ── Invite codes ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue