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 {
|
||||
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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/Theme.Closer"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
// 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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue