From afeb1a1a0301b4547a9955694fcfaad7158e0183 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 20:16:47 -0500 Subject: [PATCH] docs: add README, add proguard rules, Firestore entitlement checker, network security config, update build config and onboarding --- README.md | 148 ++++++++++++++++++ app/build.gradle.kts | 11 +- app/proguard-rules.pro | 49 ++++++ app/src/main/AndroidManifest.xml | 1 + .../billing/FirestoreEntitlementChecker.kt | 42 +++++ .../SharedPreferencesLocalAnswerRepository.kt | 22 ++- .../java/app/closer/di/RepositoryModule.kt | 4 +- .../closer/ui/onboarding/OnboardingScreen.kt | 2 +- .../ui/questions/QuestionDetailViewModel.kt | 6 +- .../ui/questions/QuestionThreadViewModel.kt | 9 +- .../main/res/xml/network_security_config.xml | 17 ++ firestore.rules | 7 +- 12 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 README.md create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/java/app/closer/core/billing/FirestoreEntitlementChecker.kt create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/README.md b/README.md new file mode 100644 index 00000000..74c079b9 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa27deff..1469da02 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,11 +22,13 @@ android { buildFeatures { buildConfig = true + compose = true } buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -43,9 +45,7 @@ android { jvmTarget = "17" } - buildFeatures { - compose = true - } + } dependencies { @@ -94,6 +94,9 @@ dependencies { // DataStore implementation("androidx.datastore:datastore-preferences:1.1.2") + // Encrypted storage + implementation("androidx.security:security-crypto:1.1.0-alpha06") + // RevenueCat implementation("com.revenuecat.purchases:purchases-hybrid-common:13.5.0") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..93fd25d6 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 (...); +} + +# ── 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.** { + ; + (...); +} + +# ── RevenueCat ──────────────────────────────────────────────────────────────── +-keep class com.revenuecat.** { *; } +-dontwarn com.revenuecat.** + +# ── Kotlin coroutines ──────────────────────────────────────────────────────── +-keepclassmembernames class kotlinx.** { + volatile ; +} +-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.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8f46930c..4637c3f0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:theme="@style/Theme.Closer" + android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true"> + 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 + } + } + } + } +} diff --git a/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt b/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt index 74b75ad8..98361c05 100644 --- a/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt +++ b/app/src/main/java/app/closer/data/repository/SharedPreferencesLocalAnswerRepository.kt @@ -1,6 +1,9 @@ package app.closer.data.repository 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.repository.LocalAnswerRepository import dagger.hilt.android.qualifiers.ApplicationContext @@ -17,7 +20,24 @@ class SharedPreferencesLocalAnswerRepository @Inject constructor( @ApplicationContext context: Context ) : 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()) override fun observeAnswers(): Flow> = answers diff --git a/app/src/main/java/app/closer/di/RepositoryModule.kt b/app/src/main/java/app/closer/di/RepositoryModule.kt index c1b32297..2200c8d2 100644 --- a/app/src/main/java/app/closer/di/RepositoryModule.kt +++ b/app/src/main/java/app/closer/di/RepositoryModule.kt @@ -1,7 +1,7 @@ package app.closer.di 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.repository.CoupleRepositoryImpl import app.closer.data.repository.QuestionSessionRepositoryImpl @@ -52,7 +52,7 @@ abstract class RepositoryModule { abstract fun bindLocalAnswerRepository(impl: SharedPreferencesLocalAnswerRepository): LocalAnswerRepository @Binds @Singleton - abstract fun bindEntitlementChecker(impl: FakeEntitlementChecker): EntitlementChecker + abstract fun bindEntitlementChecker(impl: FirestoreEntitlementChecker): EntitlementChecker @Binds @Singleton abstract fun bindSettingsRepository(impl: SettingsDataStore): SettingsRepository diff --git a/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt index 81817c31..6174f04b 100644 --- a/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/app/closer/ui/onboarding/OnboardingScreen.kt @@ -116,7 +116,7 @@ fun OnboardingScreen( ) Spacer(Modifier.height(12.dp)) 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, color = AuthMuted, textAlign = TextAlign.Center diff --git a/app/src/main/java/app/closer/ui/questions/QuestionDetailViewModel.kt b/app/src/main/java/app/closer/ui/questions/QuestionDetailViewModel.kt index a5dfae81..825b9aa7 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionDetailViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionDetailViewModel.kt @@ -50,7 +50,7 @@ class QuestionDetailViewModel @Inject constructor( } 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) { @@ -85,6 +85,10 @@ class QuestionDetailViewModel @Inject constructor( fun canSubmit(): Boolean = canSubmit(_uiState.value) + companion object { + const val MAX_ANSWER_LENGTH = 2000 + } + private fun canSubmit(state: LocalQuestionUiState): Boolean { val question = state.question ?: return false return when (question.type) { diff --git a/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt b/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt index b3511fe7..47555b38 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt @@ -115,7 +115,7 @@ class QuestionThreadViewModel @Inject constructor( // ─── Answer input mutations ────────────────────────────────────────────────── fun updateWrittenText(text: String) { - _uiState.update { it.copy(pendingWrittenText = text) } + _uiState.update { it.copy(pendingWrittenText = text.take(MAX_ANSWER_LENGTH)) } } fun toggleOption(optionId: String) { @@ -194,7 +194,7 @@ class QuestionThreadViewModel @Inject constructor( // ─── Discussion ────────────────────────────────────────────────────────────── fun updateMessageInput(text: String) { - _uiState.update { it.copy(messageInput = text) } + _uiState.update { it.copy(messageInput = text.take(MAX_MESSAGE_LENGTH)) } } fun sendMessage() { @@ -246,4 +246,9 @@ class QuestionThreadViewModel @Inject constructor( fun dismissError() { _uiState.update { it.copy(error = null) } } + + companion object { + const val MAX_ANSWER_LENGTH = 2000 + const val MAX_MESSAGE_LENGTH = 500 + } } diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..fa046275 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/firestore.rules b/firestore.rules index 53a815b4..5f39bd0b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -20,9 +20,14 @@ service cloud.firestore { // ── Users ───────────────────────────────────────────────────────────────── // Each user owns exactly their own document. + // hasPremium is server-only: clients may not write it directly. 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 ──────────────────────────────────────────────────────────