docs: add README, add proguard rules, Firestore entitlement checker, network security config, update build config and onboarding

This commit is contained in:
null 2026-06-16 20:16:47 -05:00
parent 84995641f3
commit afeb1a1a03
12 changed files with 306 additions and 12 deletions

148
README.md Normal file
View File

@ -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.

View File

@ -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")

49
app/proguard-rules.pro vendored Normal file
View File

@ -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.**

View File

@ -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">
<activity

View File

@ -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
}
}
}
}
}

View File

@ -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<List<LocalAnswer>> = answers

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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>

View File

@ -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 ──────────────────────────────────────────────────────────