diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..feff8f3
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ push:
+ branches: [master, main]
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Unit tests
+ run: ./gradlew :app:testProdDebugUnitTest --stacktrace
+
+ - name: Assemble debug APK
+ run: ./gradlew :app:assembleProdDebug --stacktrace
+
+ - name: Upload debug APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: hesychia-prod-debug
+ path: app/build/outputs/apk/prod/debug/*.apk
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index a8b0d1d..bd30a55 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,9 +2,11 @@
# Gradle files
.gradle/
build/
+**/build/
# Local configuration file (sdk path, etc)
local.properties
+keystore.properties
# Log/OS Files
*.log
@@ -14,6 +16,7 @@ captures/
.externalNativeBuild/
.cxx/
*.apk
+*.aab
output.json
# IntelliJ
@@ -33,3 +36,5 @@ google-services.json
# Android Profiling
*.hprof
+# OS
+.DS_Store
diff --git a/README.md b/README.md
index 01b2f0b..2340fc1 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,80 @@
-# chat_android
+# Hesychia Android (native)
+Kotlin + Jetpack Compose client for [Hesychia](https://hesychia.ai), reusing `chat_backend` APIs.
+
+Epic: [chat_web_app#111](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/111)
+
+Capacitor WebView shell remains in `chat_web_app/llm-fe` as the interim Play track — see [docs/ADR-002-capacitor-coexistence.md](docs/ADR-002-capacitor-coexistence.md).
+
+## Stack
+
+- Kotlin, Jetpack Compose, Material 3
+- Hilt, Navigation Compose, DataStore
+- Retrofit + OkHttp (+ WebSocket next)
+- RevenueCat Android SDK (IAP Workstream E)
+- Min SDK 23 / target SDK 35
+- Application id: `ai.hesychia.chat`
+
+## Docs
+
+| Doc | Purpose |
+|-----|---------|
+| [ADR-001](docs/ADR-001-architecture.md) | Architecture choices |
+| [ADR-002](docs/ADR-002-capacitor-coexistence.md) | Play id + Capacitor sunset |
+| [SCREEN_INVENTORY](docs/SCREEN_INVENTORY.md) | Routes / flows to mirror |
+| [OPEN_QUESTIONS](docs/OPEN_QUESTIONS.md) | Spike leftovers |
+| [design-tokens/hesychia.tokens.json](design-tokens/hesychia.tokens.json) | Shared visual tokens |
+
+## Prerequisites
+
+- JDK 17+
+- Android SDK Platform 35 + build-tools
+- Android Studio (Ladybug+) recommended
+- Set `sdk.dir` in `local.properties` (gitignored), or open once in Android Studio
+
+```properties
+sdk.dir=/path/to/Android/Sdk
+```
+
+## Build
+
+```bash
+./gradlew :app:assembleProdDebug
+./gradlew :app:testProdDebugUnitTest
+# APK → app/build/outputs/apk/prod/debug/
+```
+
+Flavors:
+
+| Flavor | REST / WS | applicationId |
+|--------|-----------|---------------|
+| `prod` (default) | production `chatbackend.aimloperations.com` | `ai.hesychia.chat` (+ `.debug` for debug) |
+| `beta` | `beta.chatbackend.aimloperations.com` | `ai.hesychia.chat.beta` (+ `.debug`) |
+
+## Signing (release)
+
+Never commit keystores. Create `keystore.properties` (gitignored) at repo root:
+
+```properties
+storeFile=/absolute/path/to/aiml-chat-upload.jks
+storePassword=...
+keyAlias=aiml-chat-upload
+keyPassword=...
+```
+
+```bash
+./gradlew :app:bundleProdRelease
+```
+
+## Monetization
+
+Native Play uses **RevenueCat** only (no Stripe Checkout). Align with `chat_web_app/llm-fe/MONETIZATION.md`:
+
+- `appUserID` = Django user pk string
+- Provider badge: `revenuecat` + `PLAY_STORE`
+
+## Related repos
+
+- `chat_web_app` — product / Capacitor / design source
+- `chat_backend` — REST + WS + RevenueCat webhooks
+- Future: `chat_ios` (SwiftUI twin epic)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..bd54882
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,156 @@
+import java.io.FileInputStream
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.kotlin.plugin.serialization")
+ id("com.google.dagger.hilt.android")
+ id("com.google.devtools.ksp")
+}
+
+val keystorePropertiesFile = rootProject.file("keystore.properties")
+val keystoreProperties = Properties()
+if (keystorePropertiesFile.exists()) {
+ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
+}
+
+android {
+ namespace = "ai.hesychia.chat"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "ai.hesychia.chat"
+ minSdk = 23
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ buildConfigField("String", "REVENUECAT_GOOGLE_API_KEY", "\"\"")
+ buildConfigField("String", "REVENUECAT_OFFERING_ID", "\"\"")
+ }
+
+ signingConfigs {
+ create("release") {
+ if (keystorePropertiesFile.exists()) {
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ storeFile = file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ }
+ }
+ }
+
+ flavorDimensions += "env"
+ productFlavors {
+ create("prod") {
+ dimension = "env"
+ isDefault = true
+ buildConfigField(
+ "String",
+ "REST_API_BASE_URL",
+ "\"https://backend.hesychia.ai/api/\"",
+ )
+ buildConfigField(
+ "String",
+ "WS_API_BASE_URL",
+ "\"wss://backend.hesychia.ai/ws/chat_again/\"",
+ )
+ }
+ create("beta") {
+ dimension = "env"
+ applicationIdSuffix = ".beta"
+ versionNameSuffix = "-beta"
+ buildConfigField(
+ "String",
+ "REST_API_BASE_URL",
+ "\"https://beta.backend.hesychia.ai/api/\"",
+ )
+ buildConfigField(
+ "String",
+ "WS_API_BASE_URL",
+ "\"wss://beta.backend.hesychia.ai/ws/chat_again/\"",
+ )
+ }
+ }
+
+ buildTypes {
+ debug {
+ applicationIdSuffix = ".debug"
+ versionNameSuffix = "-debug"
+ }
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ if (keystorePropertiesFile.exists()) {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.navigation:navigation-compose:2.8.5")
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+ implementation("androidx.browser:browser:1.8.0")
+ implementation("io.coil-kt:coil-compose:2.7.0")
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
+
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ implementation("com.google.dagger:hilt-android:2.53.1")
+ ksp("com.google.dagger:hilt-compiler:2.53.1")
+ implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
+
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
+ implementation("com.squareup.retrofit2:retrofit:2.11.0")
+ implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+
+ implementation("com.android.billingclient:billing-ktx:7.1.1")
+ // RevenueCat — wire keys via BuildConfig / local.properties later (Workstream E)
+ implementation("com.revenuecat.purchases:purchases:8.12.2")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
+ testImplementation("com.google.truth:truth:1.4.4")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..0685c1f
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,4 @@
+# Hesychia — release minify rules (enable later with R8)
+-keepattributes *Annotation*, InnerClasses
+-dontwarn okhttp3.**
+-dontwarn retrofit2.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d982578
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt b/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt
new file mode 100644
index 0000000..3fee61c
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt
@@ -0,0 +1,7 @@
+package ai.hesychia.chat
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class HesychiaApp : Application()
diff --git a/app/src/main/java/ai/hesychia/chat/MainActivity.kt b/app/src/main/java/ai/hesychia/chat/MainActivity.kt
new file mode 100644
index 0000000..11ce9af
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/MainActivity.kt
@@ -0,0 +1,87 @@
+package ai.hesychia.chat
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.compose.rememberNavController
+import ai.hesychia.chat.navigation.HesychiaNavHost
+import ai.hesychia.chat.navigation.Routes
+import ai.hesychia.chat.ui.session.LaunchUri
+import ai.hesychia.chat.ui.session.SessionViewModel
+import ai.hesychia.chat.ui.theme.HesychiaTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ LaunchUri.uri = intent?.data
+ val launchUri = intent?.data
+ setContent {
+ val sessionVm: SessionViewModel = hiltViewModel()
+ val session by sessionVm.state.collectAsStateWithLifecycle()
+ val boot by sessionVm.boot.collectAsStateWithLifecycle()
+ val subscription by sessionVm.subscription.collectAsStateWithLifecycle()
+ HesychiaTheme(
+ darkTheme = session.darkMode ?: isSystemInDarkTheme(),
+ themeColor = session.themeColor,
+ ) {
+ if (!boot.ready) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ } else {
+ val navController = rememberNavController()
+ var driveConnected by remember {
+ mutableStateOf(launchUri?.getQueryParameter("drive_connected") == "1")
+ }
+ LaunchedEffect(navController) {
+ LaunchUri.incoming.collect { uri ->
+ driveConnected = uri.getQueryParameter("drive_connected") == "1"
+ val route = sessionVm.routeFor(uri)
+ navController.navigate(route) {
+ popUpTo(0) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+ HesychiaNavHost(
+ startDestination = boot.startRoute,
+ hasRag = subscription?.hasRag == true,
+ driveJustConnected = driveConnected,
+ onSessionChanged = sessionVm::refreshSubscription,
+ onSignOut = {
+ sessionVm.signOut {
+ navController.navigate(Routes.SignIn) {
+ popUpTo(0) { inclusive = true }
+ }
+ }
+ },
+ navController = navController,
+ )
+ }
+ }
+ }
+ }
+
+ override fun onNewIntent(intent: android.content.Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ intent.data?.let { LaunchUri.incoming.tryEmit(it) }
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt b/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt
new file mode 100644
index 0000000..5dc0545
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt
@@ -0,0 +1,37 @@
+package ai.hesychia.chat.data.api
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import retrofit2.HttpException
+
+fun Throwable.apiMessage(fallback: String, json: Json = ApiJson): String {
+ val http = this as? HttpException ?: return message?.takeIf { it.isNotBlank() } ?: fallback
+ val raw = runCatching { http.response()?.errorBody()?.string() }.getOrNull().orEmpty()
+ if (raw.isBlank()) {
+ return if (http.code() == 403) "Account registration is currently disabled." else fallback
+ }
+ val parsed = runCatching { json.decodeFromString(ErrorEnvelope.serializer(), raw) }.getOrNull()
+ val detail = parsed?.detail?.let { elementText(it) }
+ if (!detail.isNullOrBlank()) return detail
+ val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()
+ val email = (obj?.get("email") as? JsonArray)?.firstOrNull()?.let { elementText(it) }
+ if (!email.isNullOrBlank()) return email
+ return fallback
+}
+
+private fun elementText(element: kotlinx.serialization.json.JsonElement): String? = when (element) {
+ is JsonPrimitive -> element.contentOrNull
+ is JsonArray -> element.firstOrNull()?.let { elementText(it) }
+ is JsonObject -> element.entries.firstOrNull()?.value?.let { elementText(it) }
+ else -> null
+}
+
+val ApiJson: Json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ coerceInputValues = true
+ explicitNulls = false
+}
diff --git a/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt b/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt
new file mode 100644
index 0000000..97fa059
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt
@@ -0,0 +1,151 @@
+package ai.hesychia.chat.data.api
+
+import okhttp3.MultipartBody
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.HTTP
+import retrofit2.http.Multipart
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.Part
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface HesychiaApi {
+ @GET("public/settings/")
+ suspend fun publicSettings(): PublicSettingsDto
+
+ @POST("token/obtain/")
+ suspend fun login(@Body body: LoginRequestDto): TokenPairDto
+
+ @POST("token/refresh/")
+ suspend fun refresh(@Body body: RefreshRequestDto): TokenPairDto
+
+ @GET("user/get/")
+ suspend fun currentUser(): UserDto
+
+ @POST("user/create/")
+ suspend fun createUser(@Body body: SignUpRequestDto): SignUpResponseDto
+
+ @POST("user/acknowledge_tos/")
+ suspend fun acknowledgeTos()
+
+ @POST("user/reset_password/")
+ suspend fun resetPassword(@Body body: PasswordResetRequestDto)
+
+ @GET("user/set_password/{slug}/")
+ suspend fun checkSetPassword(@Path("slug") slug: String)
+
+ @POST("user/set_password/{slug}/")
+ suspend fun setPassword(@Path("slug") slug: String, @Body body: SetPasswordRequestDto)
+
+ @POST("blacklist/")
+ suspend fun blacklist(@Body body: RefreshTokenBody)
+
+ @HTTP(method = "DELETE", path = "user/", hasBody = true)
+ suspend fun deleteUser(@Body body: RefreshTokenBody)
+
+ @GET("conversation_preferences")
+ suspend fun preferences(): PreferencesDto
+
+ @POST("conversation_preferences")
+ suspend fun savePreferences(@Body body: PreferencesDto): PreferencesDto
+
+ @GET("monetization/subscription/")
+ suspend fun subscription(): SubscriptionDto
+
+ @GET("monetization/invoices/")
+ suspend fun invoices(): List
+
+ @GET("monetization/payments/")
+ suspend fun payments(): List
+
+ @GET("monetization/plans/")
+ suspend fun plans(): List
+
+ @POST("monetization/store/confirm/")
+ suspend fun confirmStore(@Body body: StoreConfirmRequestDto)
+
+ @GET("company_users")
+ suspend fun companyUsers(): List
+
+ @POST("user/invite/")
+ suspend fun invite(@Body body: EmailBody)
+
+ @POST("company_users")
+ suspend fun updateCompanyUser(@Body body: CompanyUserUpdateDto)
+
+ @HTTP(method = "DELETE", path = "company_users", hasBody = true)
+ suspend fun deleteCompanyUser(@Body body: EmailBody)
+
+ @GET("conversations")
+ suspend fun conversations(): List
+
+ @GET("conversation_details")
+ suspend fun conversationDetails(@Query("conversation_id") conversationId: Int): List
+
+ @HTTP(method = "DELETE", path = "conversation_details", hasBody = true)
+ suspend fun deleteConversation(@Body body: ConversationIdBody)
+
+ @POST("prompt_feedback")
+ suspend fun upsertPromptFeedback(@Body body: PromptFeedbackRequestDto)
+
+ @DELETE("prompt_feedback")
+ suspend fun clearPromptFeedback(@Query("prompt_id") promptId: Int)
+
+ @GET("documents/")
+ suspend fun documents(
+ @Query("workspace") workspace: String,
+ @Query("page") page: Int,
+ @Query("page_size") pageSize: Int,
+ @Query("search") search: String?,
+ @Query("ordering") ordering: String,
+ ): DocumentPageDto
+
+ @Multipart
+ @POST("documents/")
+ suspend fun uploadDocument(
+ @Query("workspace") workspace: String,
+ @Part file: MultipartBody.Part,
+ )
+
+ @PATCH("documents_details/{id}")
+ suspend fun setDocumentActive(@Path("id") id: Int, @Body body: ActiveBody)
+
+ @GET("drive/connections/")
+ suspend fun driveConnections(): List
+
+ @DELETE("drive/connections/{id}/")
+ suspend fun disconnectDrive(@Path("id") id: Int)
+
+ @POST("drive/connections/{id}/resources/")
+ suspend fun saveDriveResources(
+ @Path("id") id: Int,
+ @Body body: DriveResourceSelectionDto,
+ ): DriveConnectionDto
+
+ @POST("drive/connections/{id}/sync/")
+ suspend fun syncDrive(@Path("id") id: Int): DriveSyncResponseDto
+
+ @GET("feedbacks/")
+ suspend fun feedbacks(): List
+
+ @POST("feedbacks/")
+ suspend fun createFeedback(@Body body: FeedbackCreateDto)
+
+ @GET("analytics/user_prompt_heatmap/")
+ suspend fun promptHeatmap(@Query("tz") tz: String): HeatmapDto
+
+ @GET("analytics/user_prompts/")
+ suspend fun userPrompts(): List
+
+ @GET("analytics/user_conversations/")
+ suspend fun userConversations(): List
+
+ @GET("analytics/company_usage/")
+ suspend fun companyUsage(): List
+
+ @GET("analytics/admin/")
+ suspend fun adminAnalytics(): List
+}
diff --git a/app/src/main/java/ai/hesychia/chat/data/api/Models.kt b/app/src/main/java/ai/hesychia/chat/data/api/Models.kt
new file mode 100644
index 0000000..02d7c3b
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/data/api/Models.kt
@@ -0,0 +1,309 @@
+package ai.hesychia.chat.data.api
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+
+@Serializable
+data class TokenPairDto(
+ val access: String,
+ val refresh: String? = null,
+)
+
+@Serializable
+data class LoginRequestDto(
+ val username: String,
+ val password: String,
+)
+
+@Serializable
+data class RefreshRequestDto(
+ val refresh: String,
+)
+
+@Serializable
+data class RefreshTokenBody(
+ val refresh_token: String,
+)
+
+@Serializable
+data class OAuthFlagsDto(
+ val google: Boolean = false,
+ val microsoft: Boolean = false,
+)
+
+@Serializable
+data class PublicSettingsDto(
+ val enable_account_registration: Boolean = false,
+ val oauth: OAuthFlagsDto? = null,
+)
+
+@Serializable
+data class CompanyDto(
+ val id: Int? = null,
+ val name: String? = null,
+)
+
+@Serializable
+data class UserDto(
+ val id: Int,
+ val email: String = "",
+ val first_name: String? = null,
+ val last_name: String? = null,
+ val is_company_manager: Boolean = false,
+ val has_signed_tos: Boolean = false,
+ val has_usable_password: Boolean = true,
+ val is_active: Boolean = true,
+ val role: String? = null,
+ val company: CompanyDto? = null,
+)
+
+@Serializable
+data class SignUpRequestDto(
+ val email: String,
+ val password: String,
+ val first_name: String = "",
+ val last_name: String = "",
+ val company_name: String = "",
+)
+
+@Serializable
+data class SignUpResponseDto(
+ val access: String,
+ val refresh: String? = null,
+ val needs_checkout: Boolean = true,
+)
+
+@Serializable
+data class EmailBody(
+ val email: String,
+)
+
+@Serializable
+data class PasswordResetRequestDto(
+ val email: String,
+ val recaptchaToken: String = "",
+)
+
+@Serializable
+data class SetPasswordRequestDto(
+ val password1: String,
+ val password2: String,
+)
+
+@Serializable
+data class PreferencesDto(
+ val order: Boolean = true,
+ val use_conversation_context: Boolean = false,
+)
+
+@Serializable
+data class PlanFeaturesDto(
+ val rag: Boolean = false,
+ val all_future_features: Boolean = false,
+)
+
+@Serializable
+data class PlanDto(
+ val slug: String = "",
+ val name: String = "",
+ val store_product_id: String? = null,
+ val features: PlanFeaturesDto? = null,
+ val is_selectable: Boolean = false,
+)
+
+@Serializable
+data class UsageDto(
+ val prompts_in_window: Int = 0,
+ val prompt_quota: Int? = null,
+ val prompts_remaining: Int? = null,
+ val window_hours: Int = 0,
+)
+
+@Serializable
+data class SubscriptionDto(
+ val plan: PlanDto? = null,
+ val status: String = "",
+ val source: String = "",
+ val needs_checkout: Boolean = false,
+ val usage: UsageDto? = null,
+ val store_account_token: String? = null,
+ val current_period_end: String? = null,
+) {
+ val hasRag: Boolean
+ get() = plan?.features?.rag == true || plan?.features?.all_future_features == true
+}
+
+@Serializable
+data class InvoiceDto(
+ val id: Int? = null,
+ val number: String? = null,
+ val status: String? = null,
+ val total: String? = null,
+ val amount_due: String? = null,
+ val created: String? = null,
+)
+
+@Serializable
+data class PaymentDto(
+ val id: Int? = null,
+ val status: String? = null,
+ val amount: String? = null,
+ val created: String? = null,
+)
+
+@Serializable
+data class StoreConfirmRequestDto(
+ val store: String,
+ val product_id: String,
+ val transaction_id: String = "",
+ val purchase_token: String = "",
+ val signed_transaction: String = "",
+)
+
+@Serializable
+data class CompanyUserUpdateDto(
+ val email: String,
+ val field: String,
+ val value: String,
+)
+
+@Serializable
+data class ConversationDto(
+ val id: Int,
+ val title: String? = null,
+)
+
+@Serializable
+data class ConversationIdBody(
+ val conversation_id: Int,
+)
+
+@Serializable
+data class CitationDto(
+ val index: Int = 0,
+ val title: String = "",
+ val url: String = "",
+)
+
+@Serializable
+data class FeedbackRatingDto(
+ val rating: String? = null,
+ val reason: String? = null,
+ val comment: String? = null,
+)
+
+@Serializable
+data class PromptDto(
+ val id: Int? = null,
+ val message: String = "",
+ val user_created: Boolean = false,
+ val created_timestamp: String? = null,
+ val citations: List = emptyList(),
+ val feedback: FeedbackRatingDto? = null,
+ val file_type: String? = null,
+ val image: String? = null,
+)
+
+@Serializable
+data class PromptFeedbackRequestDto(
+ val prompt_id: Int,
+ val rating: String,
+ val reason: String? = null,
+ val comment: String? = null,
+)
+
+@Serializable
+data class DocumentDto(
+ val id: Int,
+ val file: String = "",
+ val created: String = "",
+ val active: Boolean = true,
+ val processed: Boolean = false,
+)
+
+@Serializable
+data class DocumentPageDto(
+ val count: Int = 0,
+ val results: List = emptyList(),
+)
+
+@Serializable
+data class ActiveBody(
+ val active: Boolean,
+)
+
+@Serializable
+data class DriveConnectionDto(
+ val id: Int,
+ val provider: String = "",
+ val kind: String? = null,
+ val external_account_email: String? = null,
+ val last_sync_status: String? = null,
+ val last_sync_error: String? = null,
+ val selected_resource_labels: List = emptyList(),
+)
+
+@Serializable
+data class DriveResourceSelectionDto(
+ val resource_ids: List,
+ val resource_labels: List,
+)
+
+@Serializable
+data class DriveSyncResponseDto(
+ val connection: DriveConnectionDto? = null,
+)
+
+@Serializable
+data class FeedbackDto(
+ val id: Int? = null,
+ val title: String = "",
+ val text: String = "",
+ val status: String = "",
+ val category: String = "",
+)
+
+@Serializable
+data class FeedbackCreateDto(
+ val title: String,
+ val text: String,
+ val category: String,
+)
+
+@Serializable
+data class MonthSeriesDto(
+ val month: String = "",
+ val you: Int = 0,
+ val others: Int = 0,
+ val all: Int = 0,
+ val used: Int = 0,
+ val not_used: Int = 0,
+ val avg: Double? = null,
+ val median: Double? = null,
+)
+
+@Serializable
+data class HeatmapPeakDto(
+ val day: String = "",
+ val hour: Int = 0,
+ val count: Int = 0,
+)
+
+@Serializable
+data class HeatmapDto(
+ val tz: String = "",
+ val total: Int = 0,
+ val max: Int = 0,
+ val days: List = emptyList(),
+ val hours: List = emptyList(),
+ val matrix: List> = emptyList(),
+ val most_active_day: String? = null,
+ val most_active_hour: Int? = null,
+ val peak_cell: HeatmapPeakDto? = null,
+)
+
+/** Loose error body. Backend mixes `detail` strings and field arrays. */
+@Serializable
+data class ErrorEnvelope(
+ val detail: JsonElement? = null,
+)
diff --git a/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt b/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt
new file mode 100644
index 0000000..f9aa76a
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt
@@ -0,0 +1,283 @@
+package ai.hesychia.chat.data.billing
+
+import android.app.Activity
+import android.content.Context
+import ai.hesychia.chat.BuildConfig
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.StoreConfirmRequestDto
+import ai.hesychia.chat.data.api.apiMessage
+import com.android.billingclient.api.AcknowledgePurchaseParams
+import com.android.billingclient.api.BillingClient
+import com.android.billingclient.api.BillingClientStateListener
+import com.android.billingclient.api.BillingFlowParams
+import com.android.billingclient.api.BillingResult
+import com.android.billingclient.api.PendingPurchasesParams
+import com.android.billingclient.api.Purchase
+import com.android.billingclient.api.PurchasesUpdatedListener
+import com.android.billingclient.api.QueryProductDetailsParams
+import com.android.billingclient.api.QueryPurchasesParams
+import com.revenuecat.purchases.CustomerInfo
+import com.revenuecat.purchases.Purchases
+import com.revenuecat.purchases.PurchasesConfiguration
+import com.revenuecat.purchases.PurchasesError
+import com.revenuecat.purchases.interfaces.LogInCallback
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+/**
+ * Play purchase sheet uses BillingClient so the backend gets a real
+ * purchase token and the user's store account id. RevenueCat still
+ * identifies the signed-in user when an API key is configured.
+ * Entitlement is granted only after `POST /monetization/store/confirm/`.
+ */
+@Singleton
+class StoreBilling @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val api: HesychiaApi,
+) : PurchasesUpdatedListener {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private var billingClient: BillingClient? = null
+ private var pendingProductId: String = ""
+ private var pendingError: ((String) -> Unit)? = null
+ private var pendingDone: (() -> Unit)? = null
+
+ fun configure() {
+ val key = BuildConfig.REVENUECAT_GOOGLE_API_KEY
+ if (key.isBlank() || Purchases.isConfigured) return
+ Purchases.configure(PurchasesConfiguration.Builder(context, key).build())
+ }
+
+ suspend fun logIn(userId: String) {
+ configure()
+ if (!Purchases.isConfigured) return
+ suspendCancellableCoroutine { cont ->
+ Purchases.sharedInstance.logIn(userId, object : LogInCallback {
+ override fun onReceived(customerInfo: CustomerInfo, created: Boolean) {
+ if (cont.isActive) cont.resume(Unit)
+ }
+
+ override fun onError(error: PurchasesError) {
+ if (cont.isActive) cont.resume(Unit)
+ }
+ })
+ }
+ }
+
+ suspend fun logOut() {
+ if (!Purchases.isConfigured) return
+ runCatching { Purchases.sharedInstance.logOut() }
+ }
+
+ fun purchase(
+ activity: Activity,
+ productId: String,
+ accountToken: String,
+ onError: (String) -> Unit,
+ onDone: () -> Unit,
+ ) {
+ if (productId.isBlank()) {
+ onError("No Play product is configured for this plan.")
+ return
+ }
+ connect(onError) {
+ queryAndLaunch(activity, productId, accountToken, onError, onDone)
+ }
+ }
+
+ suspend fun restore() {
+ val owned = suspendCancellableCoroutine> { cont ->
+ connect(
+ onError = { message ->
+ if (cont.isActive) cont.resumeWithException(IllegalStateException(message))
+ },
+ ) {
+ val client = billingClient
+ if (client == null || !client.isReady) {
+ if (cont.isActive) cont.resumeWithException(IllegalStateException("Play Billing is not ready."))
+ return@connect
+ }
+ val params = QueryPurchasesParams.newBuilder()
+ .setProductType(BillingClient.ProductType.SUBS)
+ .build()
+ client.queryPurchasesAsync(params) { result, purchases ->
+ if (!cont.isActive) return@queryPurchasesAsync
+ if (result.responseCode != BillingClient.BillingResponseCode.OK) {
+ cont.resumeWithException(
+ IllegalStateException("Could not restore Play purchases: ${result.debugMessage}"),
+ )
+ return@queryPurchasesAsync
+ }
+ cont.resume(
+ purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED },
+ )
+ }
+ }
+ }
+ if (owned.isEmpty()) throw IllegalStateException("No active store purchases found.")
+ for (purchase in owned) {
+ acknowledge(purchase)
+ confirm(purchase)
+ }
+ }
+
+ override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList?) {
+ val onError = pendingError
+ val onDone = pendingDone
+ val productId = pendingProductId
+ pendingError = null
+ pendingDone = null
+ pendingProductId = ""
+ if (onError == null || onDone == null) return
+ when (billingResult.responseCode) {
+ BillingClient.BillingResponseCode.USER_CANCELED -> onError("Purchase cancelled")
+ BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> queryOwned(productId, onError, onDone)
+ BillingClient.BillingResponseCode.OK -> {
+ val match = purchases?.firstOrNull { purchase ->
+ purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
+ (productId.isBlank() || purchase.products.contains(productId))
+ }
+ if (match == null) onError("Play purchase did not complete")
+ else finish(match, onError, onDone)
+ }
+ else -> onError("Play purchase failed: ${billingResult.debugMessage}")
+ }
+ }
+
+ private fun queryAndLaunch(
+ activity: Activity,
+ productId: String,
+ accountToken: String,
+ onError: (String) -> Unit,
+ onDone: () -> Unit,
+ ) {
+ val client = billingClient ?: return onError("Play Billing is not ready.")
+ val params = QueryProductDetailsParams.newBuilder()
+ .setProductList(
+ listOf(
+ QueryProductDetailsParams.Product.newBuilder()
+ .setProductId(productId)
+ .setProductType(BillingClient.ProductType.SUBS)
+ .build(),
+ ),
+ )
+ .build()
+ client.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
+ if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
+ onError("Could not load Play product: ${billingResult.debugMessage}")
+ return@queryProductDetailsAsync
+ }
+ val details = productDetailsList.firstOrNull()
+ if (details == null) {
+ onError("Play product was not found: $productId")
+ return@queryProductDetailsAsync
+ }
+ val offerToken = details.subscriptionOfferDetails?.firstOrNull()?.offerToken
+ if (offerToken.isNullOrBlank()) {
+ onError("Play product has no subscription offer")
+ return@queryProductDetailsAsync
+ }
+ val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
+ .setProductDetails(details)
+ .setOfferToken(offerToken)
+ .build()
+ val flow = BillingFlowParams.newBuilder()
+ .setProductDetailsParamsList(listOf(productParams))
+ if (accountToken.isNotBlank()) flow.setObfuscatedAccountId(accountToken)
+ activity.runOnUiThread {
+ pendingProductId = productId
+ pendingError = onError
+ pendingDone = onDone
+ val launched = client.launchBillingFlow(activity, flow.build())
+ if (launched.responseCode != BillingClient.BillingResponseCode.OK) {
+ pendingError = null
+ pendingDone = null
+ pendingProductId = ""
+ onError("Could not open Play purchase sheet: ${launched.debugMessage}")
+ }
+ }
+ }
+ }
+
+ private fun queryOwned(productId: String, onError: (String) -> Unit, onDone: () -> Unit) {
+ val client = billingClient ?: return onError("Play Billing is not ready.")
+ val params = QueryPurchasesParams.newBuilder()
+ .setProductType(BillingClient.ProductType.SUBS)
+ .build()
+ client.queryPurchasesAsync(params) { billingResult, purchases ->
+ if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
+ onError("Could not restore Play purchases: ${billingResult.debugMessage}")
+ return@queryPurchasesAsync
+ }
+ val match = purchases.firstOrNull { purchase ->
+ purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
+ (productId.isBlank() || purchase.products.contains(productId))
+ }
+ if (match == null) onError("No active Play purchase for $productId")
+ else finish(match, onError, onDone)
+ }
+ }
+
+ private fun finish(purchase: Purchase, onError: (String) -> Unit, onDone: () -> Unit) {
+ acknowledge(purchase)
+ scope.launch {
+ runCatching { confirm(purchase) }
+ .onSuccess { onDone() }
+ .onFailure { error -> onError(error.apiMessage("Could not confirm purchase")) }
+ }
+ }
+
+ private suspend fun confirm(purchase: Purchase) {
+ val productId = purchase.products.firstOrNull().orEmpty()
+ if (productId.isBlank()) error("Play purchase did not include a product.")
+ api.confirmStore(
+ StoreConfirmRequestDto(
+ store = "play_store",
+ product_id = productId,
+ transaction_id = purchase.orderId ?: purchase.purchaseToken,
+ purchase_token = purchase.purchaseToken,
+ ),
+ )
+ }
+
+ private fun acknowledge(purchase: Purchase) {
+ val client = billingClient ?: return
+ if (purchase.isAcknowledged) return
+ val params = AcknowledgePurchaseParams.newBuilder()
+ .setPurchaseToken(purchase.purchaseToken)
+ .build()
+ client.acknowledgePurchase(params) { }
+ }
+
+ private fun connect(onError: (String) -> Unit, whenReady: () -> Unit) {
+ val client = billingClient ?: BillingClient.newBuilder(context)
+ .setListener(this)
+ .enablePendingPurchases(
+ PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
+ )
+ .build()
+ .also { billingClient = it }
+ if (client.isReady) {
+ whenReady()
+ return
+ }
+ client.startConnection(object : BillingClientStateListener {
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ whenReady()
+ } else {
+ onError("Play Billing setup failed: ${billingResult.debugMessage}")
+ }
+ }
+
+ override fun onBillingServiceDisconnected() = Unit
+ })
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt b/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt
new file mode 100644
index 0000000..1e15f36
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt
@@ -0,0 +1,62 @@
+package ai.hesychia.chat.data.chat
+
+import javax.inject.Inject
+import javax.inject.Singleton
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+
+enum class SocketStatus { Connecting, Connected, Disconnected }
+
+@Singleton
+class ChatSocket @Inject constructor(
+ private val client: OkHttpClient,
+) {
+ private var socket: WebSocket? = null
+
+ var onStatus: ((SocketStatus) -> Unit)? = null
+ var onText: ((String) -> Unit)? = null
+
+ @Synchronized
+ fun connect(url: String) {
+ close()
+ onStatus?.invoke(SocketStatus.Connecting)
+ val request = Request.Builder().url(url).build()
+ socket = client.newWebSocket(request, object : WebSocketListener() {
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ onStatus?.invoke(SocketStatus.Connected)
+ }
+
+ override fun onMessage(webSocket: WebSocket, text: String) {
+ onText?.invoke(text)
+ }
+
+ override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+ webSocket.close(code, reason)
+ }
+
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ onStatus?.invoke(SocketStatus.Disconnected)
+ }
+
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ onStatus?.invoke(SocketStatus.Disconnected)
+ }
+ })
+ }
+
+ fun send(payload: String): Boolean = socket?.send(payload) == true
+
+ @Synchronized
+ fun close() {
+ socket?.close(1000, "bye")
+ socket = null
+ }
+
+ fun ping(email: String?) {
+ val safe = email?.replace("\"", "") ?: ""
+ socket?.send("""{"type":"ping","email":"$safe"}""")
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt b/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt
new file mode 100644
index 0000000..f0c222a
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt
@@ -0,0 +1,74 @@
+package ai.hesychia.chat.data.chat
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.jsonPrimitive
+
+sealed interface IncomingFrame {
+ data object ConversationIdMarker : IncomingFrame
+ data object StreamStart : IncomingFrame
+ data object StreamEnd : IncomingFrame
+ data object Ignore : IncomingFrame
+ data class Text(val value: String) : IncomingFrame
+ data class Status(val stage: String, val label: String, val detail: String?) : IncomingFrame
+ data class Citations(val titles: List) : IncomingFrame
+ data class Error(val content: String) : IncomingFrame
+}
+
+private val frameJson = Json { ignoreUnknownKeys = true; isLenient = true }
+
+fun classifyIncoming(raw: String): IncomingFrame {
+ val text = raw.trim()
+ return when (text) {
+ "CONVERSATION_ID" -> IncomingFrame.ConversationIdMarker
+ "START_OF_THE_STREAM_ENDER_GAME_42" -> IncomingFrame.StreamStart
+ "END_OF_THE_STREAM_ENDER_GAME_42" -> IncomingFrame.StreamEnd
+ else -> classifyJsonOrText(text)
+ }
+}
+
+private fun classifyJsonOrText(text: String): IncomingFrame {
+ if (!text.startsWith("{")) return IncomingFrame.Text(text)
+ val obj = runCatching { frameJson.parseToJsonElement(text) as? JsonObject }.getOrNull()
+ ?: return IncomingFrame.Text(text)
+ val type = obj["type"]?.jsonPrimitive?.contentOrNull
+ if (obj["v"] != null && type != null) {
+ return when (type) {
+ "status" -> {
+ val data = obj["data"] as? JsonObject
+ val stage = data?.get("stage")?.jsonPrimitive?.contentOrNull
+ val label = data?.get("label")?.jsonPrimitive?.contentOrNull
+ if (stage.isNullOrBlank() || label == null) IncomingFrame.Ignore
+ else IncomingFrame.Status(
+ stage = stage,
+ label = label,
+ detail = data["detail"]?.jsonPrimitive?.contentOrNull,
+ )
+ }
+ "citations" -> {
+ val titles = (obj["data"] as? JsonArray).orEmpty().mapNotNull { item ->
+ (item as? JsonObject)?.get("title")?.jsonPrimitive?.contentOrNull
+ }
+ IncomingFrame.Citations(titles)
+ }
+ "error" -> IncomingFrame.Error(
+ obj["content"]?.jsonPrimitive?.contentOrNull ?: "Chat request failed",
+ )
+ else -> IncomingFrame.Ignore
+ }
+ }
+ if (type == "error") {
+ return IncomingFrame.Error(obj["content"]?.jsonPrimitive?.contentOrNull ?: "Chat request failed")
+ }
+ if (type == "ping") return IncomingFrame.Ignore
+ obj["content"]?.jsonPrimitive?.contentOrNull?.let { content ->
+ if (type == null) return IncomingFrame.Error(content)
+ }
+ return IncomingFrame.Text(text)
+}
+
+fun conversationIdOrNull(text: String): Int? = text.trim().toIntOrNull()
+ ?: text.trim().toDoubleOrNull()?.toInt()
diff --git a/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt b/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt
new file mode 100644
index 0000000..9e3e062
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt
@@ -0,0 +1,162 @@
+package ai.hesychia.chat.data.network
+
+import ai.hesychia.chat.BuildConfig
+import ai.hesychia.chat.data.api.ApiJson
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.RefreshRequestDto
+import ai.hesychia.chat.data.session.SessionRepository
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Named
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+import okhttp3.Authenticator
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import java.util.concurrent.TimeUnit
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+ @Provides
+ @Singleton
+ fun provideJson(): Json = ApiJson
+
+ @Provides
+ @Singleton
+ @Named("bare")
+ fun provideBareClient(): OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideAuthInterceptor(sessionRepository: SessionRepository): Interceptor =
+ Interceptor { chain ->
+ val token = sessionRepository.cachedAccessToken
+ ?: runBlocking { sessionRepository.session.first().accessToken }
+ android.util.Log.d("NetworkAuth", "Outgoing request: ${chain.request().url} | HasToken: ${!token.isNullOrBlank()}")
+ val request = if (token.isNullOrBlank()) {
+ chain.request()
+ } else {
+ chain.request().newBuilder()
+ .header("Authorization", "JWT $token")
+ .build()
+ }
+ val response = chain.proceed(request)
+ android.util.Log.d("NetworkAuth", "Response for ${request.url}: status=${response.code}")
+ response
+ }
+
+ @Provides
+ @Singleton
+ fun provideAuthenticator(
+ sessionRepository: SessionRepository,
+ @Named("bare") bare: OkHttpClient,
+ json: Json,
+ ): Authenticator = TokenAuthenticator(sessionRepository, bare, json)
+
+ @Provides
+ @Singleton
+ fun provideOkHttp(
+ authInterceptor: Interceptor,
+ authenticator: Authenticator,
+ ): OkHttpClient {
+ val logging = HttpLoggingInterceptor().apply {
+ level = if (BuildConfig.DEBUG) {
+ HttpLoggingInterceptor.Level.BODY
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
+ }
+ return OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(120, TimeUnit.SECONDS)
+ .writeTimeout(120, TimeUnit.SECONDS)
+ .addInterceptor(authInterceptor)
+ .authenticator(authenticator)
+ .addInterceptor(logging)
+ .pingInterval(25, TimeUnit.SECONDS)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit =
+ Retrofit.Builder()
+ .baseUrl(BuildConfig.REST_API_BASE_URL)
+ .client(client)
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideApi(retrofit: Retrofit): HesychiaApi = retrofit.create(HesychiaApi::class.java)
+}
+
+/**
+ * On 401, exchange the refresh token once and retry. Skips the auth endpoints
+ * so a bad password does not recurse.
+ */
+class TokenAuthenticator(
+ private val sessionRepository: SessionRepository,
+ private val bare: OkHttpClient,
+ private val json: Json,
+) : Authenticator {
+ override fun authenticate(route: Route?, response: Response): Request? {
+ if (responseCount(response) >= 2) return null
+ val path = response.request.url.encodedPath
+ if (path.contains("token/obtain") || path.contains("token/refresh") || path.contains("user/create")) {
+ return null
+ }
+ val refresh = sessionRepository.cachedRefreshToken
+ ?: runBlocking { sessionRepository.session.first().refreshToken }
+ if (refresh.isNullOrBlank()) return null
+ val refreshed = runCatching { refreshBlocking(refresh) }.getOrNull() ?: return null
+ runBlocking {
+ sessionRepository.saveTokens(
+ access = refreshed.access,
+ refresh = refreshed.refresh ?: refresh,
+ )
+ }
+ return response.request.newBuilder()
+ .header("Authorization", "JWT ${refreshed.access}")
+ .build()
+ }
+
+ private fun refreshBlocking(refresh: String): ai.hesychia.chat.data.api.TokenPairDto {
+ val body = json.encodeToString(RefreshRequestDto.serializer(), RefreshRequestDto(refresh))
+ val request = Request.Builder()
+ .url(BuildConfig.REST_API_BASE_URL + "token/refresh/")
+ .post(body.toRequestBody("application/json".toMediaType()))
+ .build()
+ bare.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) error("refresh failed ${response.code}")
+ val raw = response.body?.string().orEmpty()
+ return json.decodeFromString(ai.hesychia.chat.data.api.TokenPairDto.serializer(), raw)
+ }
+ }
+
+ private fun responseCount(response: Response): Int {
+ var count = 1
+ var prior = response.priorResponse
+ while (prior != null) {
+ count += 1
+ prior = prior.priorResponse
+ }
+ return count
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt b/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt
new file mode 100644
index 0000000..170d25c
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt
@@ -0,0 +1,146 @@
+package ai.hesychia.chat.data.session
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import ai.hesychia.chat.data.api.UserDto
+import ai.hesychia.chat.ui.theme.ThemeColor
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.runBlocking
+
+private val Context.sessionDataStore: DataStore by preferencesDataStore(
+ name = "hesychia_session",
+)
+
+data class SessionSnapshot(
+ /** False until DataStore has emitted. Avoids flashing Sign in over a saved session. */
+ val hydrated: Boolean = false,
+ val accessToken: String? = null,
+ val refreshToken: String? = null,
+ val userId: String? = null,
+ val email: String? = null,
+ val firstName: String? = null,
+ val lastName: String? = null,
+ val isCompanyManager: Boolean = false,
+ val hasSignedTos: Boolean = false,
+ val hasCompany: Boolean = false,
+ val role: String? = null,
+ val darkMode: Boolean? = null,
+ val themeColor: ThemeColor = ThemeColor.Blue,
+) {
+ val isAuthenticated: Boolean get() = !accessToken.isNullOrBlank()
+}
+
+@Singleton
+class SessionRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val accessKey = stringPreferencesKey("access_token")
+ private val refreshKey = stringPreferencesKey("refresh_token")
+ private val userIdKey = stringPreferencesKey("user_id")
+ private val emailKey = stringPreferencesKey("email")
+ private val firstNameKey = stringPreferencesKey("first_name")
+ private val lastNameKey = stringPreferencesKey("last_name")
+ private val managerKey = booleanPreferencesKey("is_company_manager")
+ private val tosKey = booleanPreferencesKey("has_signed_tos")
+ private val companyKey = booleanPreferencesKey("has_company")
+ private val roleKey = stringPreferencesKey("role")
+ private val darkModeKey = booleanPreferencesKey("dark_mode")
+ private val themeColorKey = stringPreferencesKey("theme_color")
+
+ @Volatile
+ var cachedAccessToken: String? = null
+ private set
+
+ @Volatile
+ var cachedRefreshToken: String? = null
+ private set
+
+ init {
+ runCatching {
+ val prefs = runBlocking { context.sessionDataStore.data.first() }
+ cachedAccessToken = prefs[accessKey]
+ cachedRefreshToken = prefs[refreshKey]
+ }
+ }
+
+ val session: Flow = context.sessionDataStore.data.map { prefs ->
+ SessionSnapshot(
+ hydrated = true,
+ accessToken = prefs[accessKey],
+ refreshToken = prefs[refreshKey],
+ userId = prefs[userIdKey],
+ email = prefs[emailKey],
+ firstName = prefs[firstNameKey],
+ lastName = prefs[lastNameKey],
+ isCompanyManager = prefs[managerKey] ?: false,
+ hasSignedTos = prefs[tosKey] ?: false,
+ hasCompany = prefs[companyKey] ?: false,
+ role = prefs[roleKey],
+ darkMode = prefs[darkModeKey],
+ themeColor = prefs[themeColorKey]?.let { runCatching { ThemeColor.valueOf(it) }.getOrNull() }
+ ?: ThemeColor.Blue,
+ )
+ }
+
+ suspend fun saveTokens(access: String, refresh: String?) {
+ cachedAccessToken = access
+ if (refresh != null) cachedRefreshToken = refresh
+ context.sessionDataStore.edit { prefs ->
+ prefs[accessKey] = access
+ if (refresh != null) prefs[refreshKey] = refresh
+ }
+ }
+
+ suspend fun saveUser(user: UserDto) {
+ context.sessionDataStore.edit { prefs ->
+ prefs[userIdKey] = user.id.toString()
+ prefs[emailKey] = user.email
+ if (user.first_name != null) prefs[firstNameKey] = user.first_name else prefs.remove(firstNameKey)
+ if (user.last_name != null) prefs[lastNameKey] = user.last_name else prefs.remove(lastNameKey)
+ prefs[managerKey] = user.is_company_manager
+ prefs[tosKey] = user.has_signed_tos
+ prefs[companyKey] = user.company?.id != null
+ if (user.role != null) prefs[roleKey] = user.role else prefs.remove(roleKey)
+ }
+ }
+
+ suspend fun markTosSigned() {
+ context.sessionDataStore.edit { it[tosKey] = true }
+ }
+
+ /** Drops the session. Theme preferences stay. */
+ suspend fun clearAuth() {
+ cachedAccessToken = null
+ cachedRefreshToken = null
+ context.sessionDataStore.edit { prefs ->
+ prefs.remove(accessKey)
+ prefs.remove(refreshKey)
+ prefs.remove(userIdKey)
+ prefs.remove(emailKey)
+ prefs.remove(firstNameKey)
+ prefs.remove(lastNameKey)
+ prefs.remove(managerKey)
+ prefs.remove(tosKey)
+ prefs.remove(companyKey)
+ prefs.remove(roleKey)
+ }
+ }
+
+ suspend fun setDarkMode(enabled: Boolean) {
+ context.sessionDataStore.edit { it[darkModeKey] = enabled }
+ }
+
+ suspend fun setThemeColor(color: ThemeColor) {
+ context.sessionDataStore.edit { it[themeColorKey] = color.name }
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt b/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt
new file mode 100644
index 0000000..a848bd0
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt
@@ -0,0 +1,126 @@
+package ai.hesychia.chat.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import ai.hesychia.chat.ui.account.AccountScreen
+import ai.hesychia.chat.ui.analytics.AnalyticsScreen
+import ai.hesychia.chat.ui.auth.PasswordResetConfirmationScreen
+import ai.hesychia.chat.ui.auth.PasswordResetScreen
+import ai.hesychia.chat.ui.auth.SetPasswordScreen
+import ai.hesychia.chat.ui.auth.SignInScreen
+import ai.hesychia.chat.ui.auth.SignUpScreen
+import ai.hesychia.chat.ui.auth.TermsScreen
+import ai.hesychia.chat.ui.chat.ChatScreen
+import ai.hesychia.chat.ui.documents.DocumentsScreen
+import ai.hesychia.chat.ui.feedback.FeedbackScreen
+
+object Routes {
+ const val SignIn = "signin"
+ const val SignUp = "signup"
+ const val PasswordReset = "password_reset"
+ const val PasswordResetConfirmation = "password_reset_confirmation"
+ const val SetPassword = "set_password"
+ const val Terms = "terms"
+ const val Chat = "chat"
+ const val Account = "account"
+ const val Documents = "documents"
+ const val Analytics = "analytics"
+ const val Feedback = "feedback"
+}
+
+private val authRoutes = setOf(
+ Routes.SignIn,
+ Routes.SignUp,
+ Routes.PasswordReset,
+ Routes.PasswordResetConfirmation,
+ Routes.SetPassword,
+)
+
+@Composable
+fun HesychiaNavHost(
+ startDestination: String,
+ hasRag: Boolean,
+ driveJustConnected: Boolean,
+ onSignOut: () -> Unit,
+ onSessionChanged: () -> Unit = {},
+ navController: NavHostController,
+) {
+ fun go(route: String) {
+ navController.navigate(route) {
+ if (route == Routes.SignIn || route == Routes.Chat || route == Routes.Terms || route == Routes.Account) {
+ popUpTo(0) { inclusive = true }
+ }
+ launchSingleTop = true
+ }
+ }
+
+ NavHost(navController = navController, startDestination = startDestination) {
+ composable(Routes.SignIn) {
+ SignInScreen(
+ onDone = { route ->
+ onSessionChanged()
+ go(route)
+ },
+ onNavigateSignUp = { navController.navigate(Routes.SignUp) },
+ onForgotPassword = { navController.navigate(Routes.PasswordReset) },
+ )
+ }
+ composable(Routes.SignUp) {
+ SignUpScreen(
+ onDone = { route ->
+ onSessionChanged()
+ go(route)
+ },
+ onNavigateSignIn = { navController.popBackStack() },
+ )
+ }
+ composable(Routes.PasswordReset) {
+ PasswordResetScreen(
+ onSent = { navController.navigate(Routes.PasswordResetConfirmation) },
+ onBack = { go(Routes.SignIn) },
+ )
+ }
+ composable(Routes.PasswordResetConfirmation) {
+ PasswordResetConfirmationScreen(onBack = { go(Routes.SignIn) })
+ }
+ composable(
+ route = "${Routes.SetPassword}?slug={slug}",
+ arguments = listOf(navArgument("slug") { type = NavType.StringType; defaultValue = "" }),
+ ) { entry ->
+ SetPasswordScreen(
+ slug = entry.arguments?.getString("slug").orEmpty(),
+ onDone = { go(Routes.SignIn) },
+ )
+ }
+ composable(Routes.Terms) {
+ TermsScreen(onAccepted = {
+ onSessionChanged()
+ go(Routes.Chat)
+ })
+ }
+ composable(Routes.Chat) {
+ ChatScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut)
+ }
+ composable(Routes.Account) {
+ AccountScreen(hasRag = hasRag, onNavigate = ::go, onSignedOut = { go(Routes.SignIn) })
+ }
+ composable(Routes.Documents) {
+ DocumentsScreen(
+ hasRag = hasRag,
+ driveJustConnected = driveJustConnected,
+ onNavigate = ::go,
+ onSignOut = onSignOut,
+ )
+ }
+ composable(Routes.Analytics) {
+ AnalyticsScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut)
+ }
+ composable(Routes.Feedback) {
+ FeedbackScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut)
+ }
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt
new file mode 100644
index 0000000..444ea0c
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt
@@ -0,0 +1,181 @@
+package ai.hesychia.chat.ui.account
+
+import android.app.Activity
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ai.hesychia.chat.navigation.Routes
+import ai.hesychia.chat.ui.shell.AppScaffold
+import ai.hesychia.chat.ui.theme.ThemeColor
+
+@Composable
+fun AccountScreen(
+ hasRag: Boolean,
+ onNavigate: (String) -> Unit,
+ onSignedOut: () -> Unit,
+ viewModel: AccountViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ var invite by remember { mutableStateOf("") }
+ var confirmDelete by remember { mutableStateOf(false) }
+ AppScaffold("Account", Routes.Account, hasRag, onNavigate, onSignedOut) { padding ->
+ if (state.loading) {
+ Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
+ CircularProgressIndicator()
+ }
+ return@AppScaffold
+ }
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .imePadding()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ state.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Theme", style = MaterialTheme.typography.titleMedium)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Dark mode", modifier = Modifier.weight(1f))
+ Switch(
+ checked = state.session.darkMode ?: androidx.compose.foundation.isSystemInDarkTheme(),
+ onCheckedChange = viewModel::setDark,
+ )
+ }
+ Text("Palette")
+ ThemeColor.entries.forEach { color ->
+ TextButton(onClick = { viewModel.setColor(color) }) {
+ Text(if (state.session.themeColor == color) "• ${color.name}" else color.name)
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Use conversation context", modifier = Modifier.weight(1f))
+ Switch(checked = state.prefs.use_conversation_context, onCheckedChange = viewModel::setContext)
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Newest conversations first", modifier = Modifier.weight(1f))
+ Switch(checked = state.prefs.order, onCheckedChange = viewModel::setOrder)
+ }
+ }
+ }
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text("Usage", style = MaterialTheme.typography.titleMedium)
+ val usage = state.subscription?.usage
+ val quota = usage?.prompt_quota
+ Text(
+ if (quota == null) "Prompt quota: —"
+ else "Prompts ${usage.prompts_in_window} / $quota this window",
+ )
+ Text(state.session.email ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text("Billing", style = MaterialTheme.typography.titleMedium)
+ val sub = state.subscription
+ Text("Plan: ${sub?.plan?.name ?: "None"}")
+ Text("Status: ${sub?.status ?: "—"} · source ${sub?.source ?: "—"}")
+ Text("Subscribe in the Play Store. Stripe checkout stays on the web.")
+ val activity = LocalContext.current as? Activity
+ val accountToken = sub?.store_account_token.orEmpty()
+ state.plans.filter { it.is_selectable && !it.store_product_id.isNullOrBlank() }.forEach { plan ->
+ Button(
+ onClick = {
+ val host = activity ?: return@Button
+ viewModel.purchase(host, plan.store_product_id.orEmpty(), accountToken)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Subscribe to ${plan.name}")
+ }
+ }
+ Text("Invoices: ${state.invoices.size} · Payments: ${state.payments.size}")
+ OutlinedButton(onClick = viewModel::restore, modifier = Modifier.fillMaxWidth()) {
+ Text("Restore purchases")
+ }
+ }
+ }
+ if (state.session.isCompanyManager) {
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Company accounts", style = MaterialTheme.typography.titleMedium)
+ state.companyUsers.forEach { user ->
+ Text(user.email)
+ Row {
+ TextButton(onClick = {
+ viewModel.updateUser(user.email, "is_active", (!user.is_active).toString())
+ }) { Text(if (user.is_active) "Deactivate" else "Activate") }
+ if (user.email != state.session.email) {
+ TextButton(onClick = {
+ viewModel.updateUser(user.email, "company_manager", (!user.is_company_manager).toString())
+ }) { Text(if (user.is_company_manager) "Remove manager" else "Make manager") }
+ }
+ TextButton(onClick = { viewModel.updateUser(user.email, "delete", "") }) { Text("Delete") }
+ }
+ }
+ OutlinedTextField(invite, { invite = it }, label = { Text("Invite email") }, modifier = Modifier.fillMaxWidth())
+ Button(onClick = { viewModel.invite(invite.trim()); invite = "" }, enabled = invite.contains("@")) {
+ Text("Invite")
+ }
+ }
+ }
+ } else {
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp)) {
+ Text("Account information", style = MaterialTheme.typography.titleMedium)
+ Text("Account and prompt information will be available soon")
+ }
+ }
+ }
+ OutlinedButton(onClick = { confirmDelete = true }, modifier = Modifier.fillMaxWidth()) {
+ Text("Delete account")
+ }
+ }
+ }
+ if (confirmDelete) {
+ AlertDialog(
+ onDismissRequest = { confirmDelete = false },
+ title = { Text("Delete account") },
+ text = { Text("Permanently deactivate your account. Conversations are hidden and you will be signed out. This cannot be undone from the app.") },
+ confirmButton = {
+ TextButton(onClick = {
+ confirmDelete = false
+ viewModel.deleteAccount(onSignedOut)
+ }) { Text("Delete") }
+ },
+ dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
+ )
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt
new file mode 100644
index 0000000..c47486b
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt
@@ -0,0 +1,167 @@
+package ai.hesychia.chat.ui.account
+
+import android.app.Activity
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ai.hesychia.chat.data.api.CompanyUserUpdateDto
+import ai.hesychia.chat.data.api.EmailBody
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.InvoiceDto
+import ai.hesychia.chat.data.api.PaymentDto
+import ai.hesychia.chat.data.api.PlanDto
+import ai.hesychia.chat.data.api.PreferencesDto
+import ai.hesychia.chat.data.api.RefreshTokenBody
+import ai.hesychia.chat.data.api.SubscriptionDto
+import ai.hesychia.chat.data.api.UserDto
+import ai.hesychia.chat.data.api.apiMessage
+import ai.hesychia.chat.data.billing.StoreBilling
+import ai.hesychia.chat.data.session.SessionRepository
+import ai.hesychia.chat.data.session.SessionSnapshot
+import ai.hesychia.chat.ui.theme.ThemeColor
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class AccountUiState(
+ val loading: Boolean = true,
+ val session: SessionSnapshot = SessionSnapshot(hydrated = true),
+ val subscription: SubscriptionDto? = null,
+ val invoices: List = emptyList(),
+ val payments: List = emptyList(),
+ val plans: List = emptyList(),
+ val prefs: PreferencesDto = PreferencesDto(),
+ val companyUsers: List = emptyList(),
+ val notice: String? = null,
+ val error: String? = null,
+)
+
+@HiltViewModel
+class AccountViewModel @Inject constructor(
+ private val api: HesychiaApi,
+ private val sessionRepository: SessionRepository,
+ private val storeBilling: StoreBilling,
+) : ViewModel() {
+ private val _state = MutableStateFlow(AccountUiState())
+ val state: StateFlow = _state
+
+ init {
+ refresh()
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ _state.update { it.copy(loading = true, error = null) }
+ val session = sessionRepository.session.first { it.hydrated }
+ val sub = runCatching { api.subscription() }.getOrNull()
+ val invoices = runCatching { api.invoices() }.getOrDefault(emptyList())
+ val payments = runCatching { api.payments() }.getOrDefault(emptyList())
+ val plans = runCatching { api.plans() }.getOrDefault(emptyList())
+ val prefs = runCatching { api.preferences() }.getOrDefault(PreferencesDto())
+ val users = if (session.isCompanyManager) {
+ runCatching { api.companyUsers() }.getOrDefault(emptyList())
+ } else {
+ emptyList()
+ }
+ _state.value = AccountUiState(
+ loading = false,
+ session = session,
+ subscription = sub,
+ invoices = invoices,
+ payments = payments,
+ plans = plans,
+ prefs = prefs,
+ companyUsers = users,
+ )
+ }
+ }
+
+ fun setDark(enabled: Boolean) {
+ viewModelScope.launch { sessionRepository.setDarkMode(enabled) }
+ _state.update { it.copy(session = it.session.copy(darkMode = enabled)) }
+ }
+
+ fun setColor(color: ThemeColor) {
+ viewModelScope.launch { sessionRepository.setThemeColor(color) }
+ _state.update { it.copy(session = it.session.copy(themeColor = color)) }
+ }
+
+ fun setContext(enabled: Boolean) {
+ viewModelScope.launch {
+ runCatching { api.savePreferences(PreferencesDto(use_conversation_context = enabled)) }
+ .onSuccess { saved -> _state.update { it.copy(prefs = saved) } }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save preferences")) } }
+ }
+ }
+
+ fun setOrder(newestFirst: Boolean) {
+ viewModelScope.launch {
+ runCatching { api.savePreferences(PreferencesDto(order = newestFirst)) }
+ .onSuccess { saved -> _state.update { it.copy(prefs = saved) } }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save preferences")) } }
+ }
+ }
+
+ fun invite(email: String) {
+ viewModelScope.launch {
+ runCatching { api.invite(EmailBody(email)) }
+ .onSuccess {
+ _state.update { it.copy(notice = "Invite sent") }
+ refresh()
+ }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not invite that user")) } }
+ }
+ }
+
+ fun updateUser(email: String, field: String, value: String) {
+ viewModelScope.launch {
+ val call = if (field == "delete") {
+ runCatching { api.deleteCompanyUser(EmailBody(email)) }
+ } else {
+ runCatching { api.updateCompanyUser(CompanyUserUpdateDto(email, field, value)) }
+ }
+ call.onSuccess { refresh() }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not update that user")) } }
+ }
+ }
+
+ fun purchase(activity: Activity, productId: String, accountToken: String) {
+ storeBilling.purchase(
+ activity = activity,
+ productId = productId,
+ accountToken = accountToken,
+ onError = { message -> _state.update { it.copy(error = message, notice = null) } },
+ onDone = {
+ _state.update { it.copy(notice = "Purchase confirmed", error = null) }
+ refresh()
+ },
+ )
+ }
+
+ fun restore() {
+ viewModelScope.launch {
+ runCatching { storeBilling.restore() }
+ .onSuccess {
+ _state.update { it.copy(notice = "Purchases restored") }
+ refresh()
+ }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not restore purchases")) } }
+ }
+ }
+
+ fun deleteAccount(onDone: () -> Unit) {
+ viewModelScope.launch {
+ val refresh = sessionRepository.session.first().refreshToken
+ runCatching {
+ api.deleteUser(RefreshTokenBody(refresh.orEmpty()))
+ if (!refresh.isNullOrBlank()) runCatching { api.blacklist(RefreshTokenBody(refresh)) }
+ storeBilling.logOut()
+ sessionRepository.clearAuth()
+ }.onSuccess { onDone() }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not delete account. Try again.")) } }
+ }
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt
new file mode 100644
index 0000000..19aefbf
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt
@@ -0,0 +1,162 @@
+package ai.hesychia.chat.ui.analytics
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import ai.hesychia.chat.data.api.HeatmapDto
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.MonthSeriesDto
+import ai.hesychia.chat.data.session.SessionRepository
+import ai.hesychia.chat.navigation.Routes
+import ai.hesychia.chat.ui.shell.AppScaffold
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.util.TimeZone
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+data class AnalyticsUiState(
+ val loading: Boolean = true,
+ val showCompany: Boolean = false,
+ val isManager: Boolean = false,
+ val showOps: Boolean = false,
+ val heatmap: HeatmapDto? = null,
+ val prompts: List = emptyList(),
+ val conversations: List = emptyList(),
+ val company: List = emptyList(),
+ val admin: List = emptyList(),
+)
+
+@HiltViewModel
+class AnalyticsViewModel @Inject constructor(
+ private val api: HesychiaApi,
+ private val sessionRepository: SessionRepository,
+) : ViewModel() {
+ private val _state = MutableStateFlow(AnalyticsUiState())
+ val state: StateFlow = _state
+
+ init {
+ viewModelScope.launch {
+ val session = sessionRepository.session.first { it.hydrated }
+ val showOps = isOpsAdmin(session.email, session.role)
+ val showCompany = session.isCompanyManager || session.hasCompany
+ val tz = TimeZone.getDefault().id
+ _state.value = AnalyticsUiState(
+ loading = false,
+ showCompany = showCompany,
+ isManager = session.isCompanyManager,
+ showOps = showOps,
+ heatmap = runCatching { api.promptHeatmap(tz) }.getOrNull(),
+ prompts = runCatching { api.userPrompts() }.getOrDefault(emptyList()),
+ conversations = runCatching { api.userConversations() }.getOrDefault(emptyList()),
+ company = if (session.isCompanyManager) runCatching { api.companyUsage() }.getOrDefault(emptyList()) else emptyList(),
+ admin = if (showOps) runCatching { api.adminAnalytics() }.getOrDefault(emptyList()) else emptyList(),
+ )
+ }
+ }
+}
+
+private fun isOpsAdmin(email: String?, role: String?): Boolean {
+ if (role != null && role.lowercase() in setOf("admin", "ops", "staff")) return true
+ return email == "ryan+admin@aimloperations.com"
+}
+
+@Composable
+fun AnalyticsScreen(
+ hasRag: Boolean,
+ onNavigate: (String) -> Unit,
+ onSignOut: () -> Unit,
+ viewModel: AnalyticsViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ AppScaffold("Analytics", Routes.Analytics, hasRag, onNavigate, onSignOut) { padding ->
+ if (state.loading) {
+ Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center) {
+ CircularProgressIndicator()
+ }
+ return@AppScaffold
+ }
+ Column(
+ Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("Your activity", style = MaterialTheme.typography.titleLarge)
+ Text("Personal prompt timing and volume. Other users' private messages are never shown here.")
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp)) {
+ Text("Prompt timing", style = MaterialTheme.typography.titleMedium)
+ val heat = state.heatmap
+ if (heat == null) {
+ Text("No timing data.")
+ } else {
+ Text("${heat.total} prompts · busiest ${heat.most_active_day ?: "—"} ${heat.most_active_hour ?: ""}:00")
+ heat.peak_cell?.let { Text("Peak ${it.day} ${it.hour}:00 · ${it.count}") }
+ }
+ }
+ }
+ SeriesCard("Conversation volume", state.conversations) { "${it.month}: ${it.all}" }
+ SeriesCard("Prompt volume", state.prompts) { "${it.month}: you ${it.you} · others ${it.others} · all ${it.all}" }
+ if (state.showCompany) {
+ Text("Company", style = MaterialTheme.typography.titleLarge)
+ Text("Aggregated seat and usage trends for your workspace. No message content.")
+ if (state.isManager) {
+ SeriesCard("Team seat activity", state.company) { "${it.month}: used ${it.used} · not used ${it.not_used}" }
+ } else {
+ Card(Modifier.fillMaxWidth()) {
+ Text(
+ "Detailed team seat charts are available to company managers.",
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
+ }
+ if (state.showOps) {
+ Text("Operations", style = MaterialTheme.typography.titleLarge)
+ SeriesCard("Response times", state.admin) {
+ "${it.month}: avg ${it.avg ?: "—"} · median ${it.median ?: "—"}"
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SeriesCard(title: String, rows: List, line: (MonthSeriesDto) -> String) {
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ if (rows.isEmpty()) {
+ Text("No data.")
+ } else {
+ val max = rows.maxOf { maxOf(it.you, it.others, it.all, it.used, it.not_used, (it.avg ?: 0.0).toInt()) }.coerceAtLeast(1)
+ rows.forEach { row ->
+ Text(line(row), style = MaterialTheme.typography.bodySmall)
+ LinearProgressIndicator(
+ progress = { row.all.coerceAtLeast(row.used).toFloat() / max },
+ modifier = Modifier.fillMaxWidth().height(6.dp),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt
new file mode 100644
index 0000000..ab8590a
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt
@@ -0,0 +1,431 @@
+package ai.hesychia.chat.ui.auth
+
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ai.hesychia.chat.R
+import kotlinx.coroutines.launch
+
+@Composable
+fun SignInScreen(
+ onDone: (String) -> Unit,
+ onNavigateSignUp: () -> Unit,
+ onForgotPassword: () -> Unit,
+ viewModel: SignInViewModel = hiltViewModel(),
+) {
+ val public by viewModel.publicState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ var email by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ AuthColumn {
+ Text("Hesychia.ai", style = MaterialTheme.typography.headlineMedium)
+ Text("Sign In", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.height(24.dp))
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ label = { Text("Email Address") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(12.dp))
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text("Password") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ error?.let {
+ Spacer(Modifier.height(8.dp))
+ Text(it, color = MaterialTheme.colorScheme.error)
+ }
+ Spacer(Modifier.height(20.dp))
+ Button(
+ onClick = {
+ scope.launch {
+ loading = true
+ error = null
+ viewModel.signIn(email.trim(), password)
+ .onSuccess(onDone)
+ .onFailure { error = it.userMessage("Error retrieving account. Try again") }
+ loading = false
+ }
+ },
+ enabled = !loading && email.isNotBlank() && password.isNotBlank(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (loading) CircularProgressIndicator(Modifier.height(20.dp)) else Text("Sign In")
+ }
+ TextButton(onClick = onForgotPassword) { Text("Forgot Password?") }
+ if (public.registrationEnabled) {
+ TextButton(onClick = onNavigateSignUp) { Text("Don't have an account? Sign up") }
+ if (public.google) {
+ OutlinedButton(
+ onClick = { openTab(context, viewModel.oauthUrl("google", "login")) },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Continue with Google") }
+ }
+ if (public.microsoft) {
+ Spacer(Modifier.height(8.dp))
+ OutlinedButton(
+ onClick = { openTab(context, viewModel.oauthUrl("microsoft", "login")) },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Continue with Microsoft") }
+ }
+ }
+ }
+}
+
+@Composable
+fun SignUpScreen(
+ onDone: (String) -> Unit,
+ onNavigateSignIn: () -> Unit,
+ viewModel: SignUpViewModel = hiltViewModel(),
+) {
+ val public by viewModel.publicState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ var email by remember { mutableStateOf("") }
+ var first by remember { mutableStateOf("") }
+ var last by remember { mutableStateOf("") }
+ var company by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var confirm by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ if (public.loadingSettings) {
+ AuthColumn { CircularProgressIndicator() }
+ return
+ }
+ if (!public.registrationEnabled) {
+ AuthColumn {
+ Text("Sign up", style = MaterialTheme.typography.headlineMedium)
+ Spacer(Modifier.height(12.dp))
+ Text("Self-serve registration is not available right now.")
+ TextButton(onClick = onNavigateSignIn) { Text("Already have an account? Sign in") }
+ }
+ return
+ }
+
+ AuthColumn {
+ Text("Sign up", style = MaterialTheme.typography.headlineMedium)
+ Text(
+ "Create an account, then subscribe with Play billing.",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(16.dp))
+ OutlinedTextField(email, { email = it }, label = { Text("Email Address") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(first, { first = it }, label = { Text("First name (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(last, { last = it }, label = { Text("Last name (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(company, { company = it }, label = { Text("Company / workspace (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(password, { password = it }, label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(confirm, { confirm = it }, label = { Text("Confirm password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+ error?.let {
+ Spacer(Modifier.height(8.dp))
+ Text(it, color = MaterialTheme.colorScheme.error)
+ }
+ Spacer(Modifier.height(16.dp))
+ Button(
+ onClick = {
+ if (password.length < 8) {
+ error = "Password must be at least 8 characters"
+ return@Button
+ }
+ if (password != confirm) {
+ error = "Passwords must match"
+ return@Button
+ }
+ scope.launch {
+ loading = true
+ error = null
+ viewModel.signUp(email.trim(), password, first.trim(), last.trim(), company.trim())
+ .onSuccess(onDone)
+ .onFailure { error = it.userMessage("Could not create account. Try again.") }
+ loading = false
+ }
+ },
+ enabled = !loading && email.isNotBlank() && password.isNotBlank(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (loading) CircularProgressIndicator(Modifier.height(20.dp)) else Text("Sign up")
+ }
+ if (public.google) {
+ Spacer(Modifier.height(8.dp))
+ OutlinedButton(
+ onClick = { openTab(context, viewModel.oauthUrl("google")) },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Sign up with Google") }
+ }
+ if (public.microsoft) {
+ Spacer(Modifier.height(8.dp))
+ OutlinedButton(
+ onClick = { openTab(context, viewModel.oauthUrl("microsoft")) },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Sign up with Microsoft") }
+ }
+ TextButton(onClick = onNavigateSignIn) { Text("Already have an account? Sign in") }
+ }
+}
+
+@Composable
+fun PasswordResetScreen(
+ onSent: () -> Unit,
+ onBack: () -> Unit,
+ viewModel: PasswordResetViewModel = hiltViewModel(),
+) {
+ var email by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ AuthColumn {
+ Text("Reset password", style = MaterialTheme.typography.headlineMedium)
+ Text(
+ "Enter your email and we will send a link to set a new password.",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(16.dp))
+ OutlinedTextField(
+ email,
+ { email = it },
+ label = { Text("Email Address") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ error?.let {
+ Spacer(Modifier.height(8.dp))
+ Text(it, color = MaterialTheme.colorScheme.error)
+ }
+ Spacer(Modifier.height(16.dp))
+ Button(
+ onClick = {
+ scope.launch {
+ loading = true
+ error = null
+ viewModel.requestReset(email.trim())
+ .onSuccess { onSent() }
+ .onFailure { error = it.userMessage("Unable to reset password. Please try again.") }
+ loading = false
+ }
+ },
+ enabled = !loading && email.contains("@"),
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(if (loading) "Sending…" else "Reset password") }
+ TextButton(onClick = onBack) { Text("Back to sign in") }
+ }
+}
+
+@Composable
+fun PasswordResetConfirmationScreen(onBack: () -> Unit) {
+ AuthColumn {
+ Text("Check your email", style = MaterialTheme.typography.headlineMedium)
+ Spacer(Modifier.height(8.dp))
+ Text("If an account exists for that address, we sent a link to set a new password.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ TextButton(onClick = onBack) { Text("Back to sign in") }
+ }
+}
+
+@Composable
+fun SetPasswordScreen(
+ slug: String,
+ onDone: () -> Unit,
+ viewModel: SetPasswordViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ var password by remember { mutableStateOf("") }
+ var confirm by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(slug) { viewModel.check(slug) }
+ if (state.checking) {
+ AuthColumn { CircularProgressIndicator() }
+ return
+ }
+ if (!state.linkValid) {
+ AuthColumn {
+ Text("This password link is invalid or expired.")
+ TextButton(onClick = onDone) { Text("Back to sign in") }
+ }
+ return
+ }
+ AuthColumn {
+ Text("Set a new password", style = MaterialTheme.typography.headlineMedium)
+ Spacer(Modifier.height(12.dp))
+ OutlinedTextField(password, { password = it }, label = { Text("New password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(confirm, { confirm = it }, label = { Text("Confirm password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
+ error?.let {
+ Spacer(Modifier.height(8.dp))
+ Text(it, color = MaterialTheme.colorScheme.error)
+ }
+ Spacer(Modifier.height(16.dp))
+ Button(
+ onClick = {
+ val problem = setPasswordProblem(password, confirm)
+ if (problem != null) {
+ error = problem
+ return@Button
+ }
+ scope.launch {
+ loading = true
+ viewModel.submit(slug, password)
+ .onSuccess { onDone() }
+ .onFailure { error = it.userMessage("Could not set password.") }
+ loading = false
+ }
+ },
+ enabled = !loading,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Save password") }
+ }
+}
+
+@Composable
+fun TermsScreen(
+ onAccepted: () -> Unit,
+ viewModel: TermsViewModel = hiltViewModel(),
+) {
+ var error by remember { mutableStateOf(null) }
+ var loading by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ AuthColumn {
+ Text("Terms of Service", style = MaterialTheme.typography.headlineMedium)
+ Spacer(Modifier.height(8.dp))
+ Text("Acknowledge the Hesychia terms to open the dashboard. The public marketing site is not part of this app.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ error?.let {
+ Spacer(Modifier.height(8.dp))
+ Text(it, color = MaterialTheme.colorScheme.error)
+ }
+ Spacer(Modifier.height(16.dp))
+ Button(
+ onClick = {
+ scope.launch {
+ loading = true
+ viewModel.acknowledge()
+ .onSuccess { onAccepted() }
+ .onFailure { error = it.userMessage("Could not save acknowledgement.") }
+ loading = false
+ }
+ },
+ enabled = !loading,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text(if (loading) "Saving…" else "I agree") }
+ }
+}
+
+private fun setPasswordProblem(password: String, confirm: String): String? = when {
+ password.length < 8 -> "Must be at least 8 characters"
+ password.none { it.isDigit() } -> "Must include a number"
+ password.none { !it.isLetterOrDigit() } -> "Must include a special character"
+ password != confirm -> "Passwords must match"
+ else -> null
+}
+
+@Composable
+private fun AuthColumn(content: @Composable () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .widthIn(max = 480.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(28.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(64.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
+ shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.hesychia_mark),
+ contentDescription = "Hesychia Logo",
+ modifier = Modifier.size(40.dp)
+ )
+ }
+ Spacer(Modifier.height(16.dp))
+ content()
+ }
+ }
+ }
+}
+
+private fun openTab(context: android.content.Context, url: String) {
+ CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt
new file mode 100644
index 0000000..b5cb6a1
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt
@@ -0,0 +1,177 @@
+package ai.hesychia.chat.ui.auth
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ai.hesychia.chat.BuildConfig
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.LoginRequestDto
+import ai.hesychia.chat.data.api.PasswordResetRequestDto
+import ai.hesychia.chat.data.api.PublicSettingsDto
+import ai.hesychia.chat.data.api.SetPasswordRequestDto
+import ai.hesychia.chat.data.api.SignUpRequestDto
+import ai.hesychia.chat.data.api.apiMessage
+import ai.hesychia.chat.data.billing.StoreBilling
+import ai.hesychia.chat.data.session.SessionRepository
+import ai.hesychia.chat.navigation.Routes
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+data class PublicAuthState(
+ val loadingSettings: Boolean = true,
+ val registrationEnabled: Boolean = false,
+ val google: Boolean = false,
+ val microsoft: Boolean = false,
+)
+
+@HiltViewModel
+class SignInViewModel @Inject constructor(
+ private val api: HesychiaApi,
+ private val sessionRepository: SessionRepository,
+ private val storeBilling: StoreBilling,
+) : ViewModel() {
+ private val _public = MutableStateFlow(PublicAuthState())
+ val publicState: StateFlow = _public
+
+ init {
+ viewModelScope.launch { loadSettings() }
+ }
+
+ private suspend fun loadSettings() {
+ val settings = runCatching { api.publicSettings() }.getOrElse { PublicSettingsDto() }
+ _public.value = PublicAuthState(
+ loadingSettings = false,
+ registrationEnabled = settings.enable_account_registration,
+ google = settings.oauth?.google == true,
+ microsoft = settings.oauth?.microsoft == true,
+ )
+ }
+
+ /** Returns the route to open after a successful password sign-in. */
+ suspend fun signIn(email: String, password: String): Result = runCatching {
+ Log.d("AuthViewModel", "Attempting login for $email")
+ val tokens = api.login(LoginRequestDto(username = email, password = password))
+ Log.d("AuthViewModel", "Login tokens received: access token len=${tokens.access.length}")
+ sessionRepository.saveTokens(tokens.access, tokens.refresh)
+ val user = api.currentUser()
+ Log.d("AuthViewModel", "Current user fetched: id=${user.id}, email=${user.email}")
+ sessionRepository.saveUser(user)
+ runCatching { storeBilling.logIn(user.id.toString()) }
+ if (user.has_signed_tos) Routes.Chat else Routes.Terms
+ }.onFailure { e ->
+ Log.e("AuthViewModel", "SignIn failed: ${e.message}", e)
+ }
+
+ fun oauthUrl(provider: String, intent: String): String {
+ val base = BuildConfig.REST_API_BASE_URL.trimEnd('/')
+ return "$base/auth/oauth/$provider/start/?intent=$intent"
+ }
+}
+
+@HiltViewModel
+class SignUpViewModel @Inject constructor(
+ private val api: HesychiaApi,
+ private val sessionRepository: SessionRepository,
+ private val storeBilling: StoreBilling,
+) : ViewModel() {
+ private val _public = MutableStateFlow(PublicAuthState())
+ val publicState: StateFlow = _public
+
+ init {
+ viewModelScope.launch {
+ val settings = runCatching { api.publicSettings() }.getOrElse { PublicSettingsDto() }
+ _public.value = PublicAuthState(
+ loadingSettings = false,
+ registrationEnabled = settings.enable_account_registration,
+ google = settings.oauth?.google == true,
+ microsoft = settings.oauth?.microsoft == true,
+ )
+ }
+ }
+
+ suspend fun signUp(
+ email: String,
+ password: String,
+ firstName: String,
+ lastName: String,
+ company: String,
+ ): Result = runCatching {
+ Log.d("AuthViewModel", "Attempting signup for $email")
+ val created = api.createUser(
+ SignUpRequestDto(
+ email = email,
+ password = password,
+ first_name = firstName,
+ last_name = lastName,
+ company_name = company,
+ ),
+ )
+ Log.d("AuthViewModel", "Signup user created: access token len=${created.access.length}")
+ sessionRepository.saveTokens(created.access, created.refresh)
+ val user = api.currentUser()
+ Log.d("AuthViewModel", "Current user fetched after signup: id=${user.id}, email=${user.email}")
+ sessionRepository.saveUser(user)
+ runCatching { storeBilling.logIn(user.id.toString()) }
+ if (created.needs_checkout) Routes.Account else if (user.has_signed_tos) Routes.Chat else Routes.Terms
+ }.onFailure { e ->
+ Log.e("AuthViewModel", "SignUp failed: ${e.message}", e)
+ }
+
+ fun oauthUrl(provider: String): String {
+ val base = BuildConfig.REST_API_BASE_URL.trimEnd('/')
+ return "$base/auth/oauth/$provider/start/?intent=signup"
+ }
+}
+
+@HiltViewModel
+class PasswordResetViewModel @Inject constructor(
+ private val api: HesychiaApi,
+) : ViewModel() {
+ suspend fun requestReset(email: String): Result = runCatching {
+ api.resetPassword(PasswordResetRequestDto(email = email, recaptchaToken = ""))
+ }
+}
+
+data class SetPasswordState(
+ val checking: Boolean = true,
+ val linkValid: Boolean = false,
+)
+
+@HiltViewModel
+class SetPasswordViewModel @Inject constructor(
+ private val api: HesychiaApi,
+) : ViewModel() {
+ private val _state = MutableStateFlow(SetPasswordState())
+ val state: StateFlow = _state
+
+ fun check(slug: String) {
+ viewModelScope.launch {
+ if (slug.isBlank()) {
+ _state.value = SetPasswordState(checking = false, linkValid = false)
+ return@launch
+ }
+ val ok = runCatching { api.checkSetPassword(slug) }.isSuccess
+ _state.value = SetPasswordState(checking = false, linkValid = ok)
+ }
+ }
+
+ suspend fun submit(slug: String, password: String): Result = runCatching {
+ api.setPassword(slug, SetPasswordRequestDto(password1 = password, password2 = password))
+ }
+}
+
+@HiltViewModel
+class TermsViewModel @Inject constructor(
+ private val api: HesychiaApi,
+ private val sessionRepository: SessionRepository,
+) : ViewModel() {
+ suspend fun acknowledge(): Result = runCatching {
+ api.acknowledgeTos()
+ sessionRepository.markTosSigned()
+ }
+}
+
+fun Throwable.userMessage(fallback: String): String = apiMessage(fallback)
diff --git a/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt
new file mode 100644
index 0000000..48110a0
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt
@@ -0,0 +1,226 @@
+package ai.hesychia.chat.ui.chat
+
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.ThumbDown
+import androidx.compose.material.icons.filled.ThumbUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import android.util.Base64
+import ai.hesychia.chat.navigation.Routes
+import ai.hesychia.chat.ui.shell.AppScaffold
+
+@Composable
+fun ChatScreen(
+ hasRag: Boolean,
+ onNavigate: (String) -> Unit,
+ onSignOut: () -> Unit,
+ viewModel: ChatViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ var draft by remember { mutableStateOf("") }
+ var attachmentName by remember { mutableStateOf(null) }
+ var attachmentBytes by remember { mutableStateOf(null) }
+ var attachmentType by remember { mutableStateOf(null) }
+ val listState = rememberLazyListState()
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ attachmentBytes = bytes
+ attachmentType = context.contentResolver.getType(uri)
+ attachmentName = uri.lastPathSegment
+ }
+
+ LaunchedEffect(state.messages.size, state.messages.lastOrNull()?.text) {
+ if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex)
+ }
+
+ AppScaffold(
+ title = "Dashboard",
+ currentRoute = Routes.Chat,
+ hasRag = hasRag,
+ onNavigate = onNavigate,
+ onSignOut = onSignOut,
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .imePadding()
+ .padding(horizontal = 12.dp),
+ ) {
+ ConversationStrip(
+ rows = state.conversations,
+ selectedId = state.selectedId,
+ onSelect = viewModel::select,
+ onNew = viewModel::newChat,
+ onDelete = viewModel::deleteSelected,
+ )
+ if (state.connectionLabel.isNotBlank()) {
+ Text(state.connectionLabel, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 4.dp))
+ }
+ if (state.interrupted) {
+ Text(
+ "Last response was interrupted. You can re-send your message.",
+ color = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.padding(bottom = 4.dp),
+ )
+ }
+ state.activityLabel?.let { Text(it, style = MaterialTheme.typography.labelMedium) }
+ state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (state.messages.isEmpty()) {
+ item {
+ Text(
+ "Select a conversation or start a new one.",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(24.dp),
+ )
+ }
+ }
+ items(state.messages, key = { "${it.id}-${it.userCreated}-${it.text.take(12)}-${it.streaming}" }) { msg ->
+ MessageBubble(msg, onRate = { viewModel.rate(msg.id, it) })
+ }
+ }
+ attachmentName?.let { Text("Attached: $it", style = MaterialTheme.typography.labelSmall) }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = { picker.launch("*/*") }) {
+ Icon(Icons.Default.AttachFile, contentDescription = "Attach")
+ }
+ IconButton(onClick = {
+ val body = state.messages.joinToString("\n\n") { msg ->
+ val who = if (msg.userCreated) "You" else "Hesychia"
+ "$who: ${msg.text}"
+ }
+ val share = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, body)
+ }
+ context.startActivity(Intent.createChooser(share, "Export conversation"))
+ }) {
+ Icon(Icons.Default.Share, contentDescription = "Export")
+ }
+ OutlinedTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Type a message…") },
+ maxLines = 4,
+ )
+ IconButton(
+ onClick = {
+ val file = attachmentBytes?.let { Base64.encodeToString(it, Base64.NO_WRAP) }
+ viewModel.send(draft, file, attachmentType)
+ draft = ""
+ attachmentBytes = null
+ attachmentName = null
+ attachmentType = null
+ },
+ enabled = draft.isNotBlank() && state.connected,
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConversationStrip(
+ rows: List,
+ selectedId: Int?,
+ onSelect: (Int) -> Unit,
+ onNew: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ androidx.compose.material3.TextButton(onClick = onNew) { Text("+ New chat") }
+ if (selectedId != null) {
+ IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Delete conversation") }
+ }
+ }
+ if (rows.isEmpty()) {
+ Text("No conversations yet. Start with + New chat.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ LazyRow(
+ modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(rows, key = { it.id }) { row ->
+ Surface(
+ onClick = { onSelect(row.id) },
+ color = if (row.id == selectedId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ Text(row.title, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), maxLines = 1)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageBubble(msg: ChatMessage, onRate: (String?) -> Unit) {
+ val align = if (msg.userCreated) Alignment.End else Alignment.Start
+ Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = align) {
+ Surface(
+ color = if (msg.userCreated) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Text(
+ text = if (msg.streaming && msg.text.isEmpty()) "…" else msg.text,
+ modifier = Modifier.padding(12.dp),
+ )
+ }
+ msg.citations.forEach { cite ->
+ Text(cite, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
+ }
+ if (!msg.userCreated && msg.id != null && !msg.streaming) {
+ Row {
+ IconButton(onClick = { onRate(if (msg.rating == "up") null else "up") }) {
+ Icon(Icons.Default.ThumbUp, contentDescription = "Up")
+ }
+ IconButton(onClick = { onRate(if (msg.rating == "down") null else "down") }) {
+ Icon(Icons.Default.ThumbDown, contentDescription = "Down")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt
new file mode 100644
index 0000000..87e2665
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt
@@ -0,0 +1,294 @@
+package ai.hesychia.chat.ui.chat
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ai.hesychia.chat.BuildConfig
+import ai.hesychia.chat.data.api.ConversationIdBody
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.PromptDto
+import ai.hesychia.chat.data.api.PromptFeedbackRequestDto
+import ai.hesychia.chat.data.api.apiMessage
+import ai.hesychia.chat.data.chat.ChatSocket
+import ai.hesychia.chat.data.chat.IncomingFrame
+import ai.hesychia.chat.data.chat.SocketStatus
+import ai.hesychia.chat.data.chat.classifyIncoming
+import ai.hesychia.chat.data.chat.conversationIdOrNull
+import ai.hesychia.chat.data.session.SessionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+
+data class ChatMessage(
+ val id: Int? = null,
+ val text: String,
+ val userCreated: Boolean,
+ val streaming: Boolean = false,
+ val citations: List = emptyList(),
+ val rating: String? = null,
+)
+
+data class ConversationRow(val id: Int, val title: String)
+
+data class ChatUiState(
+ val conversations: List = emptyList(),
+ val selectedId: Int? = null,
+ val messages: List = emptyList(),
+ val connected: Boolean = false,
+ val connectionLabel: String = "Connecting to chat…",
+ val activityLabel: String? = null,
+ val interrupted: Boolean = false,
+ val error: String? = null,
+)
+
+@HiltViewModel
+class ChatViewModel @Inject constructor(
+ private val api: HesychiaApi,
+ private val socket: ChatSocket,
+ private val sessionRepository: SessionRepository,
+) : ViewModel() {
+ private val _state = MutableStateFlow(ChatUiState())
+ val state: StateFlow = _state
+
+ private var phase = 0
+ private var heartbeat: Job? = null
+ private var refetch: Job? = null
+
+ init {
+ socket.onStatus = { status ->
+ viewModelScope.launch {
+ when (status) {
+ SocketStatus.Connecting -> _state.update {
+ it.copy(connected = false, connectionLabel = "Connecting to chat…")
+ }
+ SocketStatus.Connected -> _state.update {
+ it.copy(connected = true, connectionLabel = "")
+ }
+ SocketStatus.Disconnected -> {
+ val wasStreaming = phase == 2
+ phase = 0
+ _state.update {
+ it.copy(
+ connected = false,
+ connectionLabel = "Connection to the LLM is not active",
+ interrupted = it.interrupted || wasStreaming,
+ activityLabel = null,
+ )
+ }
+ }
+ }
+ }
+ }
+ socket.onText = { raw -> viewModelScope.launch { onFrame(raw) } }
+ socket.connect(BuildConfig.WS_API_BASE_URL)
+ heartbeat = viewModelScope.launch {
+ while (isActive) {
+ delay(25_000)
+ val email = sessionRepository.session.first().email
+ socket.ping(email)
+ }
+ }
+ viewModelScope.launch { reloadConversations() }
+ }
+
+ fun newChat() {
+ phase = 0
+ _state.update { it.copy(selectedId = null, messages = emptyList(), interrupted = false, error = null) }
+ }
+
+ fun select(id: Int) {
+ phase = 0
+ _state.update { it.copy(selectedId = id, interrupted = false, error = null) }
+ viewModelScope.launch { loadDetails(id) }
+ }
+
+ fun deleteSelected() {
+ val id = _state.value.selectedId ?: return
+ viewModelScope.launch {
+ runCatching { api.deleteConversation(ConversationIdBody(id)) }
+ _state.update {
+ it.copy(
+ conversations = it.conversations.filterNot { row -> row.id == id },
+ selectedId = null,
+ messages = emptyList(),
+ )
+ }
+ }
+ }
+
+ fun send(text: String, fileBase64: String? = null, fileType: String? = null) {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty() || !_state.value.connected) return
+ val selected = _state.value.selectedId
+ viewModelScope.launch {
+ val session = sessionRepository.session.first()
+ val payload = buildJsonObject {
+ put("message", trimmed)
+ if (selected == null) put("conversation_id", JsonNull) else put("conversation_id", selected)
+ put("email", session.email ?: "")
+ put("token", session.accessToken ?: "")
+ if (fileBase64 == null) put("file", JsonNull) else put("file", fileBase64)
+ if (fileType == null) put("fileType", JsonNull) else put("fileType", fileType)
+ put("modelName", "THINKING")
+ }.toString()
+ val ok = socket.send(payload)
+ if (!ok) {
+ _state.update { it.copy(error = "Connection to the LLM is not active") }
+ return@launch
+ }
+ phase = 0
+ _state.update {
+ it.copy(
+ interrupted = false,
+ error = null,
+ activityLabel = null,
+ messages = it.messages + ChatMessage(text = trimmed, userCreated = true) +
+ ChatMessage(text = "", userCreated = false, streaming = true),
+ )
+ }
+ }
+ }
+
+ fun rate(promptId: Int?, rating: String?) {
+ if (promptId == null) return
+ viewModelScope.launch {
+ val result = if (rating == null) {
+ runCatching { api.clearPromptFeedback(promptId) }
+ } else {
+ runCatching { api.upsertPromptFeedback(PromptFeedbackRequestDto(promptId, rating)) }
+ }
+ result.onSuccess {
+ _state.update { state ->
+ state.copy(
+ messages = state.messages.map { msg ->
+ if (msg.id == promptId) msg.copy(rating = rating) else msg
+ },
+ )
+ }
+ }.onFailure { err ->
+ _state.update { it.copy(error = err.apiMessage("Could not save rating")) }
+ }
+ }
+ }
+
+ private suspend fun onFrame(raw: String) {
+ when (val frame = classifyIncoming(raw)) {
+ IncomingFrame.ConversationIdMarker -> phase = 1
+ IncomingFrame.StreamStart -> phase = 2
+ IncomingFrame.StreamEnd -> {
+ phase = 0
+ _state.update { state ->
+ state.copy(
+ activityLabel = null,
+ interrupted = false,
+ messages = state.messages.map { msg ->
+ if (msg.streaming) msg.copy(streaming = false) else msg
+ },
+ )
+ }
+ scheduleRefetch()
+ }
+ is IncomingFrame.Text -> when (phase) {
+ 1 -> {
+ val id = conversationIdOrNull(frame.value)
+ if (id != null && _state.value.selectedId == null) {
+ _state.update { it.copy(selectedId = id) }
+ reloadConversations()
+ }
+ }
+ 2 -> appendToken(frame.value)
+ else -> {
+ if (frame.value.startsWith("{")) return
+ }
+ }
+ is IncomingFrame.Status -> {
+ val label = if (frame.stage == "writing") null else frame.label
+ _state.update { it.copy(activityLabel = label ?: frame.detail) }
+ }
+ is IncomingFrame.Citations -> {
+ _state.update { state ->
+ val msgs = state.messages.toMutableList()
+ val index = msgs.indexOfLast { !it.userCreated }
+ if (index >= 0) msgs[index] = msgs[index].copy(citations = frame.titles)
+ state.copy(messages = msgs)
+ }
+ }
+ is IncomingFrame.Error -> {
+ phase = 0
+ _state.update { state ->
+ val msgs = state.messages.toMutableList()
+ val index = msgs.indexOfLast { !it.userCreated }
+ if (index >= 0) {
+ msgs[index] = msgs[index].copy(text = frame.content, streaming = false)
+ }
+ state.copy(messages = msgs, error = frame.content, activityLabel = null)
+ }
+ }
+ IncomingFrame.Ignore -> Unit
+ }
+ }
+
+ private fun appendToken(token: String) {
+ _state.update { state ->
+ val msgs = state.messages.toMutableList()
+ val index = msgs.indexOfLast { it.streaming || !it.userCreated }
+ if (index >= 0) {
+ msgs[index] = msgs[index].copy(text = msgs[index].text + token, streaming = true)
+ }
+ state.copy(messages = msgs)
+ }
+ }
+
+ private fun scheduleRefetch() {
+ val id = _state.value.selectedId ?: return
+ refetch?.cancel()
+ refetch = viewModelScope.launch {
+ delay(800)
+ loadDetails(id)
+ reloadConversations()
+ }
+ }
+
+ private suspend fun reloadConversations() {
+ val rows = runCatching { api.conversations() }.getOrDefault(emptyList())
+ .map { ConversationRow(it.id, it.title?.ifBlank { null } ?: "New conversation") }
+ _state.update { it.copy(conversations = rows) }
+ }
+
+ private suspend fun loadDetails(id: Int) {
+ val prompts = runCatching { api.conversationDetails(id) }.getOrElse { err ->
+ _state.update { it.copy(error = err.apiMessage("Could not load conversation")) }
+ return
+ }
+ val messages = prompts.map { it.toMessage() }.toMutableList()
+ if (messages.size == 1) {
+ messages += ChatMessage(text = "", userCreated = false, streaming = true)
+ }
+ _state.update { it.copy(messages = messages, selectedId = id) }
+ }
+
+ override fun onCleared() {
+ heartbeat?.cancel()
+ socket.onText = null
+ socket.onStatus = null
+ socket.close()
+ super.onCleared()
+ }
+}
+
+private fun PromptDto.toMessage() = ChatMessage(
+ id = id,
+ text = message,
+ userCreated = user_created,
+ citations = citations.map { cite -> cite.title.ifBlank { cite.url } },
+ rating = feedback?.rating,
+)
diff --git a/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt
new file mode 100644
index 0000000..66d3483
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt
@@ -0,0 +1,330 @@
+package ai.hesychia.chat.ui.documents
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import ai.hesychia.chat.BuildConfig
+import ai.hesychia.chat.data.api.DocumentDto
+import ai.hesychia.chat.data.api.DriveConnectionDto
+import ai.hesychia.chat.data.api.DriveResourceSelectionDto
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.apiMessage
+import ai.hesychia.chat.data.session.SessionRepository
+import ai.hesychia.chat.navigation.Routes
+import ai.hesychia.chat.ui.shell.AppScaffold
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.toRequestBody
+
+data class DocumentsUiState(
+ val loading: Boolean = true,
+ val hasRag: Boolean = false,
+ val hasCompany: Boolean = false,
+ val isManager: Boolean = false,
+ val scope: String = "personal",
+ val search: String = "",
+ val ordering: String = "-created",
+ val page: Int = 1,
+ val total: Int = 0,
+ val documents: List = emptyList(),
+ val drives: List = emptyList(),
+ val driveBanner: Boolean = false,
+ val error: String? = null,
+)
+
+@HiltViewModel
+class DocumentsViewModel @Inject constructor(
+ private val api: HesychiaApi,
+ private val sessionRepository: SessionRepository,
+) : ViewModel() {
+ private val _state = MutableStateFlow(DocumentsUiState())
+ val state: StateFlow = _state
+
+ init {
+ viewModelScope.launch {
+ val session = sessionRepository.session.first { it.hydrated }
+ val sub = runCatching { api.subscription() }.getOrNull()
+ val scope = if (session.hasCompany) "company" else "personal"
+ _state.update {
+ it.copy(
+ hasRag = sub?.hasRag == true,
+ hasCompany = session.hasCompany,
+ isManager = session.isCompanyManager,
+ scope = scope,
+ loading = false,
+ )
+ }
+ if (sub?.hasRag == true) reload()
+ }
+ }
+
+ fun showDriveBanner() {
+ _state.update { it.copy(driveBanner = true) }
+ }
+
+ fun setScope(scope: String) {
+ _state.update { it.copy(scope = scope, page = 1) }
+ viewModelScope.launch { reload() }
+ }
+
+ fun setSearch(value: String) {
+ _state.update { it.copy(search = value, page = 1) }
+ viewModelScope.launch {
+ kotlinx.coroutines.delay(300)
+ if (_state.value.search == value) reload()
+ }
+ }
+
+ fun toggleSort() {
+ val next = if (_state.value.ordering == "-created") "created" else "-created"
+ _state.update { it.copy(ordering = next, page = 1) }
+ viewModelScope.launch { reload() }
+ }
+
+ fun setPage(page: Int) {
+ _state.update { it.copy(page = page) }
+ viewModelScope.launch { reload() }
+ }
+
+ fun toggleActive(doc: DocumentDto, active: Boolean) {
+ _state.update { state ->
+ state.copy(documents = state.documents.map { if (it.id == doc.id) it.copy(active = active) else it })
+ }
+ viewModelScope.launch {
+ runCatching { api.setDocumentActive(doc.id, ai.hesychia.chat.data.api.ActiveBody(active)) }
+ .onFailure {
+ _state.update { state ->
+ state.copy(documents = state.documents.map { item ->
+ if (item.id == doc.id) item.copy(active = !active) else item
+ })
+ }
+ }
+ }
+ }
+
+ fun upload(name: String, bytes: ByteArray, mime: String?) {
+ viewModelScope.launch {
+ val body = bytes.toRequestBody((mime ?: "application/octet-stream").toMediaTypeOrNull())
+ val part = MultipartBody.Part.createFormData("file", name, body)
+ runCatching { api.uploadDocument(_state.value.scope, part) }
+ .onSuccess { reload() }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Upload failed")) } }
+ }
+ }
+
+ fun disconnect(id: Int) {
+ viewModelScope.launch {
+ runCatching { api.disconnectDrive(id) }
+ .onSuccess { reload() }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not disconnect this drive")) } }
+ }
+ }
+
+ fun sync(id: Int) {
+ viewModelScope.launch {
+ runCatching { api.syncDrive(id) }
+ .onSuccess { reload() }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Drive sync failed")) } }
+ }
+ }
+
+ fun saveResources(id: Int, raw: String) {
+ val ids = raw.split(',', ' ', '\n').map { it.trim() }.filter { it.isNotEmpty() }
+ if (ids.isEmpty()) return
+ viewModelScope.launch {
+ runCatching { api.saveDriveResources(id, DriveResourceSelectionDto(ids, ids)) }
+ .onSuccess { reload() }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save folder selection")) } }
+ }
+ }
+
+ fun oauthUrl(provider: String, company: Boolean): String {
+ val intent = if (company) "link_company_drive" else "link_drive"
+ return BuildConfig.REST_API_BASE_URL.trimEnd('/') + "/auth/oauth/$provider/start/?intent=$intent"
+ }
+
+ private suspend fun reload() {
+ val state = _state.value
+ val page = runCatching {
+ api.documents(state.scope, state.page, 20, state.search.ifBlank { null }, state.ordering)
+ }.getOrElse { err ->
+ _state.update { it.copy(error = err.apiMessage("Could not load documents"), documents = emptyList()) }
+ return
+ }
+ val drives = runCatching { api.driveConnections() }.getOrDefault(emptyList())
+ _state.update { it.copy(documents = page.results, total = page.count, drives = drives, loading = false) }
+ }
+}
+
+@Composable
+fun DocumentsScreen(
+ hasRag: Boolean,
+ driveJustConnected: Boolean,
+ onNavigate: (String) -> Unit,
+ onSignOut: () -> Unit,
+ viewModel: DocumentsViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ var resourceDraft by remember { mutableStateOf("") }
+ LaunchedEffect(driveJustConnected) {
+ if (driveJustConnected) viewModel.showDriveBanner()
+ }
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ val name = uri.lastPathSegment ?: "upload"
+ val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
+ viewModel.upload(name, bytes, context.contentResolver.getType(uri))
+ }
+ AppScaffold("Documents", Routes.Documents, hasRag, onNavigate, onSignOut) { padding ->
+ if (state.loading) {
+ Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
+ CircularProgressIndicator()
+ }
+ return@AppScaffold
+ }
+ if (!state.hasRag) {
+ Column(Modifier.fillMaxSize().padding(padding).padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text("Unlock document storage", style = MaterialTheme.typography.headlineSmall)
+ Text("Document uploads and cloud drive connections are available on plans that include RAG document search.")
+ Button(onClick = { onNavigate(Routes.Account) }) { Text("Upgrade in Billing") }
+ }
+ return@AppScaffold
+ }
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (state.driveBanner) {
+ item { Text("Drive connected. Choose folders below to include them in your knowledge base.") }
+ }
+ state.error?.let { err -> item { Text(err, color = MaterialTheme.colorScheme.error) } }
+ if (state.hasCompany) {
+ item {
+ Row {
+ TextButton(onClick = { viewModel.setScope("company") }) { Text(if (state.scope == "company") "• Company" else "Company") }
+ TextButton(onClick = { viewModel.setScope("personal") }) { Text(if (state.scope == "personal") "• Personal" else "Personal") }
+ }
+ }
+ }
+ item {
+ OutlinedTextField(
+ state.search,
+ viewModel::setSearch,
+ label = { Text("Search") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ item {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("${state.total} documents", modifier = Modifier.weight(1f))
+ TextButton(onClick = viewModel::toggleSort) { Text(if (state.ordering == "-created") "Newest" else "Oldest") }
+ }
+ }
+ items(state.documents, key = { it.id }) { doc ->
+ Card(Modifier.fillMaxWidth()) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(doc.file.substringAfterLast('/'))
+ Text(doc.created.take(10), style = MaterialTheme.typography.labelSmall)
+ Text(if (doc.processed) "Processed" else "Processing", style = MaterialTheme.typography.labelSmall)
+ }
+ Switch(checked = doc.active, onCheckedChange = { viewModel.toggleActive(doc, it) })
+ }
+ }
+ }
+ item {
+ Row {
+ TextButton(onClick = { if (state.page > 1) viewModel.setPage(state.page - 1) }, enabled = state.page > 1) { Text("Previous") }
+ TextButton(onClick = { viewModel.setPage(state.page + 1) }, enabled = state.page * 20 < state.total) { Text("Next") }
+ }
+ }
+ item { Button(onClick = { picker.launch("*/*") }) { Text("Upload a document") } }
+ item { Text("Cloud drives", style = MaterialTheme.typography.titleMedium) }
+ item {
+ Row {
+ TextButton(onClick = { openTab(context, viewModel.oauthUrl("google", false)) }) { Text("Google Drive") }
+ TextButton(onClick = { openTab(context, viewModel.oauthUrl("microsoft", false)) }) { Text("OneDrive") }
+ }
+ }
+ items(state.drives.filter { (it.kind ?: "personal") == "personal" }, key = { "p${it.id}" }) { drive ->
+ DriveCard(drive, resourceDraft, { resourceDraft = it }, viewModel)
+ }
+ if (state.isManager) {
+ item { Text("Company knowledge sources", style = MaterialTheme.typography.titleMedium) }
+ item {
+ Row {
+ TextButton(onClick = { openTab(context, viewModel.oauthUrl("google", true)) }) { Text("Shared Drive") }
+ TextButton(onClick = { openTab(context, viewModel.oauthUrl("microsoft", true)) }) { Text("Microsoft 365") }
+ }
+ }
+ items(state.drives.filter { it.kind == "company" }, key = { "c${it.id}" }) { drive ->
+ DriveCard(drive, resourceDraft, { resourceDraft = it }, viewModel)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DriveCard(
+ drive: DriveConnectionDto,
+ draft: String,
+ onDraft: (String) -> Unit,
+ viewModel: DocumentsViewModel,
+) {
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Text("${drive.provider} · ${drive.external_account_email ?: "connected"}")
+ Text("Sync: ${drive.last_sync_status ?: "never"}")
+ OutlinedTextField(draft, onDraft, label = { Text("Folder ids, comma separated") }, modifier = Modifier.fillMaxWidth())
+ Row {
+ TextButton(onClick = { viewModel.saveResources(drive.id, draft) }) { Text("Save folders") }
+ TextButton(onClick = { viewModel.sync(drive.id) }) { Text("Sync") }
+ TextButton(onClick = { viewModel.disconnect(drive.id) }) { Text("Disconnect") }
+ }
+ }
+ }
+}
+
+private fun openTab(context: android.content.Context, url: String) {
+ CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt
new file mode 100644
index 0000000..7937d95
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt
@@ -0,0 +1,141 @@
+package ai.hesychia.chat.ui.feedback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewModelScope
+import ai.hesychia.chat.data.api.FeedbackCreateDto
+import ai.hesychia.chat.data.api.FeedbackDto
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.apiMessage
+import ai.hesychia.chat.navigation.Routes
+import ai.hesychia.chat.ui.shell.AppScaffold
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+private val categories = listOf(
+ "Bug" to "BUG",
+ "Enhancement" to "ENCHANCEMENT",
+ "Other" to "OTHER",
+)
+
+data class FeedbackUiState(
+ val items: List = emptyList(),
+ val error: String? = null,
+ val notice: String? = null,
+)
+
+@HiltViewModel
+class FeedbackViewModel @Inject constructor(
+ private val api: HesychiaApi,
+) : ViewModel() {
+ private val _state = MutableStateFlow(FeedbackUiState())
+ val state: StateFlow = _state
+
+ init {
+ viewModelScope.launch { reload() }
+ }
+
+ fun submit(title: String, text: String, category: String) {
+ viewModelScope.launch {
+ runCatching { api.createFeedback(FeedbackCreateDto(title, text, category)) }
+ .onSuccess {
+ _state.update { it.copy(notice = "Feedback sent", error = null) }
+ reload()
+ }
+ .onFailure { err ->
+ _state.update { it.copy(error = err.apiMessage("Could not send feedback")) }
+ }
+ }
+ }
+
+ private suspend fun reload() {
+ runCatching { api.feedbacks() }
+ .onSuccess { rows -> _state.update { it.copy(items = rows) } }
+ .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not load feedback")) } }
+ }
+}
+
+@Composable
+fun FeedbackScreen(
+ hasRag: Boolean,
+ onNavigate: (String) -> Unit,
+ onSignOut: () -> Unit,
+ viewModel: FeedbackViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ var title by remember { mutableStateOf("") }
+ var body by remember { mutableStateOf("") }
+ var category by remember { mutableStateOf("BUG") }
+ AppScaffold("Feedback", Routes.Feedback, hasRag, onNavigate, onSignOut) { padding ->
+ Column(
+ Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+ state.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Submit feedback", style = MaterialTheme.typography.titleMedium)
+ OutlinedTextField(title, { title = it }, label = { Text("Title") }, modifier = Modifier.fillMaxWidth())
+ RowOfCategories(category) { category = it }
+ OutlinedTextField(body, { body = it }, label = { Text("Text") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
+ Button(
+ onClick = {
+ viewModel.submit(title.trim(), body.trim(), category)
+ title = ""
+ body = ""
+ },
+ enabled = title.isNotBlank() && body.isNotBlank(),
+ ) { Text("Send") }
+ }
+ }
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Feedback history", style = MaterialTheme.typography.titleMedium)
+ if (state.items.isEmpty()) Text("No feedback yet.")
+ state.items.forEach { item ->
+ Text(item.title, style = MaterialTheme.typography.titleSmall)
+ Text("${item.category} · ${item.status}", style = MaterialTheme.typography.labelSmall)
+ Text(item.text)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RowOfCategories(selected: String, onSelect: (String) -> Unit) {
+ Column {
+ categories.forEach { (label, value) ->
+ TextButton(onClick = { onSelect(value) }) {
+ Text(if (selected == value) "• $label" else label)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt
new file mode 100644
index 0000000..0787650
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt
@@ -0,0 +1,146 @@
+package ai.hesychia.chat.ui.session
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import ai.hesychia.chat.data.api.HesychiaApi
+import ai.hesychia.chat.data.api.RefreshTokenBody
+import ai.hesychia.chat.data.api.SubscriptionDto
+import ai.hesychia.chat.data.billing.StoreBilling
+import ai.hesychia.chat.data.session.SessionRepository
+import ai.hesychia.chat.data.session.SessionSnapshot
+import ai.hesychia.chat.navigation.Routes
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class BootState(
+ val ready: Boolean = false,
+ val startRoute: String = Routes.SignIn,
+)
+
+@HiltViewModel
+class SessionViewModel @Inject constructor(
+ private val sessionRepository: SessionRepository,
+ private val api: HesychiaApi,
+ private val storeBilling: StoreBilling,
+) : ViewModel() {
+ val state: StateFlow = sessionRepository.session
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SessionSnapshot())
+
+ private val _subscription = MutableStateFlow(null)
+ val subscription: StateFlow = _subscription
+
+ private val _boot = MutableStateFlow(BootState())
+ val boot: StateFlow = _boot
+
+ val hasRag: Boolean get() = _subscription.value?.hasRag == true
+
+ init {
+ viewModelScope.launch {
+ val snap = sessionRepository.session.first { it.hydrated }
+ if (snap.isAuthenticated) {
+ refreshUser()
+ val updatedSnap = sessionRepository.session.first { it.hydrated }
+ if (updatedSnap.isAuthenticated) {
+ loadSubscription()
+ updatedSnap.userId?.let { runCatching { storeBilling.logIn(it) } }
+ }
+ }
+ val uri = LaunchUri.consume()
+ val route = if (uri != null) {
+ routeFor(uri)
+ } else {
+ defaultRoute(sessionRepository.session.first { it.hydrated })
+ }
+ _boot.value = BootState(ready = true, startRoute = route)
+ }
+ }
+
+ fun refreshSubscription() {
+ viewModelScope.launch { loadSubscription() }
+ }
+
+ private suspend fun loadSubscription() {
+ _subscription.value = runCatching { api.subscription() }.getOrNull()
+ }
+
+ suspend fun refreshUser() {
+ val user = runCatching { api.currentUser() }.getOrNull()
+ if (user != null) {
+ sessionRepository.saveUser(user)
+ } else {
+ sessionRepository.clearAuth()
+ }
+ }
+
+ fun signOut(onDone: () -> Unit = {}) {
+ viewModelScope.launch {
+ val refresh = state.value.refreshToken
+ if (!refresh.isNullOrBlank()) {
+ runCatching { api.blacklist(RefreshTokenBody(refresh)) }
+ }
+ runCatching { storeBilling.logOut() }
+ sessionRepository.clearAuth()
+ _subscription.value = null
+ onDone()
+ }
+ }
+
+ /** Deep link from https://hesychia.ai/... Returns the route to open. */
+ suspend fun routeFor(uri: Uri): String {
+ val path = uri.path.orEmpty()
+ if (path.startsWith("/set_password")) {
+ val slug = uri.getQueryParameter("slug").orEmpty()
+ return "${Routes.SetPassword}?slug=$slug"
+ }
+ if (path.startsWith("/password_reset_confirmation")) return Routes.PasswordResetConfirmation
+ if (path.startsWith("/password_reset")) return Routes.PasswordReset
+ if (path.startsWith("/auth")) {
+ if (uri.getQueryParameter("drive_connected") == "1") return Routes.Documents
+ val access = uri.getQueryParameter("access")
+ val refresh = uri.getQueryParameter("refresh")
+ val error = uri.getQueryParameter("error")
+ if (!error.isNullOrBlank()) return Routes.SignIn
+ if (!access.isNullOrBlank() && !refresh.isNullOrBlank()) {
+ sessionRepository.saveTokens(access, refresh)
+ refreshUser()
+ loadSubscription()
+ val user = sessionRepository.session.first { it.hydrated }
+ user.userId?.let { runCatching { storeBilling.logIn(it) } }
+ if (uri.getQueryParameter("needs_checkout") == "1") return Routes.Account
+ if (!user.hasSignedTos) return Routes.Terms
+ return Routes.Chat
+ }
+ }
+ val latest = sessionRepository.session.first { it.hydrated }
+ return defaultRoute(latest)
+ }
+
+ private fun defaultRoute(snap: SessionSnapshot): String = when {
+ !snap.isAuthenticated -> Routes.SignIn
+ !snap.hasSignedTos -> Routes.Terms
+ else -> Routes.Chat
+ }
+
+ fun noteBootRoute(route: String) {
+ _boot.update { it.copy(ready = true, startRoute = route) }
+ }
+}
+
+/** Set from [ai.hesychia.chat.MainActivity] before Compose reads the session. */
+object LaunchUri {
+ @Volatile
+ var uri: Uri? = null
+
+ val incoming = MutableSharedFlow(extraBufferCapacity = 1)
+
+ fun consume(): Uri? = uri.also { uri = null }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt b/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt
new file mode 100644
index 0000000..cfee088
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt
@@ -0,0 +1,88 @@
+package ai.hesychia.chat.ui.shell
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import ai.hesychia.chat.navigation.Routes
+import kotlinx.coroutines.launch
+
+data class DrawerDest(val route: String, val label: String)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppScaffold(
+ title: String,
+ currentRoute: String,
+ hasRag: Boolean,
+ onNavigate: (String) -> Unit,
+ onSignOut: () -> Unit,
+ content: @Composable (PaddingValues) -> Unit,
+) {
+ val drawerState = rememberDrawerState(DrawerValue.Closed)
+ val scope = rememberCoroutineScope()
+ val dests = buildList {
+ add(DrawerDest(Routes.Chat, "Dashboard"))
+ add(DrawerDest(Routes.Account, "Account"))
+ if (hasRag) add(DrawerDest(Routes.Documents, "Documents"))
+ add(DrawerDest(Routes.Analytics, "Analytics"))
+ add(DrawerDest(Routes.Feedback, "Feedback"))
+ }
+ ModalNavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = {
+ ModalDrawerSheet {
+ Text("Hesychia", modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp))
+ dests.forEach { dest ->
+ NavigationDrawerItem(
+ label = { Text(dest.label) },
+ selected = currentRoute == dest.route,
+ onClick = {
+ scope.launch { drawerState.close() }
+ if (currentRoute != dest.route) onNavigate(dest.route)
+ },
+ )
+ }
+ NavigationDrawerItem(
+ label = { Text("Sign out") },
+ selected = false,
+ onClick = {
+ scope.launch { drawerState.close() }
+ onSignOut()
+ },
+ )
+ }
+ },
+ ) {
+ Scaffold(
+ containerColor = Color.Transparent,
+ topBar = {
+ TopAppBar(
+ title = { Text(title) },
+ navigationIcon = {
+ IconButton(onClick = { scope.launch { drawerState.open() } }) {
+ Icon(Icons.Default.Menu, contentDescription = "Menu")
+ }
+ },
+ )
+ },
+ content = content,
+ )
+ }
+}
diff --git a/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt b/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt
new file mode 100644
index 0000000..aac7547
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt
@@ -0,0 +1,214 @@
+package ai.hesychia.chat.ui.theme
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Theme color variants mirrored from llm-fe `ui-kit/assets/theme/base/palettes.js`.
+ * Default product accent on web often follows `info` / blue (#1A73E8).
+ */
+enum class ThemeColor {
+ Blue,
+ Green,
+ Purple,
+ Orange,
+ Red,
+}
+
+data class BrandPalette(
+ val main: Color,
+ val focus: Color,
+)
+
+object HesychiaTokens {
+ val blue = BrandPalette(Color(0xFF1A73E8), Color(0xFF1662C4))
+ val green = BrandPalette(Color(0xFF4CAF50), Color(0xFF67BB6A))
+ val purple = BrandPalette(Color(0xFF9C27B0), Color(0xFFBA68C8))
+ val orange = BrandPalette(Color(0xFFFB8C00), Color(0xFFFC9D26))
+ val red = BrandPalette(Color(0xFFF44335), Color(0xFFF65F53))
+
+ val backgroundLight = Color(0xFFF0F2F5)
+ val backgroundDark = Color(0xFF1A2035)
+ val surfaceLight = Color(0xFFFFFFFF)
+ val surfaceDark = Color(0xFF202940)
+ val textMuted = Color(0xFF7B809A)
+ val dark = Color(0xFF344767)
+ val grey300 = Color(0xFFDEE2E6)
+ val error = Color(0xFFF44335)
+ val success = Color(0xFF4CAF50)
+ val warning = Color(0xFFFB8C00)
+
+ /** Corner radii from web borders (approx rem→dp). */
+ val radiusSm = 4
+ val radiusMd = 8
+ val radiusLg = 12
+ val radiusXl = 16
+
+ fun palette(themeColor: ThemeColor): BrandPalette = when (themeColor) {
+ ThemeColor.Blue -> blue
+ ThemeColor.Green -> green
+ ThemeColor.Purple -> purple
+ ThemeColor.Orange -> orange
+ ThemeColor.Red -> red
+ }
+}
+
+private fun lightScheme(accent: BrandPalette) = lightColorScheme(
+ primary = accent.main,
+ onPrimary = Color.White,
+ primaryContainer = accent.focus,
+ onPrimaryContainer = Color.White,
+ secondary = HesychiaTokens.textMuted,
+ onSecondary = Color.White,
+ background = HesychiaTokens.backgroundLight,
+ onBackground = HesychiaTokens.dark,
+ surface = HesychiaTokens.surfaceLight,
+ onSurface = HesychiaTokens.dark,
+ surfaceVariant = HesychiaTokens.grey300,
+ onSurfaceVariant = HesychiaTokens.textMuted,
+ error = HesychiaTokens.error,
+ onError = Color.White,
+ outline = HesychiaTokens.grey300,
+ outlineVariant = HesychiaTokens.grey300,
+)
+
+private fun darkScheme(accent: BrandPalette) = darkColorScheme(
+ primary = accent.main,
+ onPrimary = Color.White,
+ primaryContainer = accent.focus,
+ onPrimaryContainer = Color.White,
+ secondary = HesychiaTokens.textMuted,
+ onSecondary = Color.White,
+ background = HesychiaTokens.backgroundDark,
+ onBackground = Color.White,
+ surface = HesychiaTokens.surfaceDark,
+ onSurface = Color.White,
+ surfaceVariant = Color(0xFF2C3C58),
+ onSurfaceVariant = Color(0xFFADB5BD),
+ error = HesychiaTokens.error,
+ onError = Color.White,
+ outline = Color(0xFF495057),
+ outlineVariant = Color(0xFF495057),
+)
+
+@Composable
+fun HesychiaTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ themeColor: ThemeColor = ThemeColor.Blue,
+ content: @Composable () -> Unit,
+) {
+ val palette = HesychiaTokens.palette(themeColor)
+ val colors = if (darkTheme) darkScheme(palette) else lightScheme(palette)
+ MaterialTheme(
+ colorScheme = colors,
+ typography = HesychiaTypography,
+ ) {
+ androidx.compose.material3.Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ AnimatedConstellationBackground()
+ content()
+ }
+ }
+ }
+}
+
+@Composable
+fun AnimatedConstellationBackground(modifier: Modifier = Modifier) {
+ val infiniteTransition = rememberInfiniteTransition(label = "constellation")
+ val progress by infiniteTransition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1000f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 60000, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "progress"
+ )
+
+ val primaryColor = MaterialTheme.colorScheme.primary
+ val dotColor = primaryColor.copy(alpha = 0.35f)
+ val lineColor = primaryColor.copy(alpha = 0.15f)
+
+ val particles = remember {
+ List(28) { i ->
+ Particle(
+ x = (i * 37.5f) % 1f,
+ y = (i * 53.2f) % 1f,
+ vx = ((i % 5) - 2f) * 0.00008f,
+ vy = (((i * 7) % 5) - 2f) * 0.00008f,
+ radius = 2f + (i % 3) * 1.5f
+ )
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val width = size.width
+ val height = size.height
+
+ val currentParticles = particles.map { p ->
+ var nx = (p.x + p.vx * progress) % 1f
+ if (nx < 0f) nx += 1f
+ var ny = (p.y + p.vy * progress) % 1f
+ if (ny < 0f) ny += 1f
+ Offset(nx * width, ny * height)
+ }
+
+ for (i in currentParticles.indices) {
+ for (j in i + 1 until currentParticles.size) {
+ val p1 = currentParticles[i]
+ val p2 = currentParticles[j]
+ val dx = p1.x - p2.x
+ val dy = p1.y - p2.y
+ val distSq = dx * dx + dy * dy
+ if (distSq < 18000f) {
+ val alphaFactor = (1f - (distSq / 18000f)).coerceIn(0f, 1f)
+ drawLine(
+ color = lineColor.copy(alpha = lineColor.alpha * alphaFactor),
+ start = p1,
+ end = p2,
+ strokeWidth = 1.2f
+ )
+ }
+ }
+ }
+
+ for (pt in currentParticles) {
+ drawCircle(
+ color = dotColor,
+ radius = 3f,
+ center = pt
+ )
+ }
+ }
+ }
+}
+
+private data class Particle(
+ val x: Float,
+ val y: Float,
+ val vx: Float,
+ val vy: Float,
+ val radius: Float,
+)
diff --git a/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt b/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt
new file mode 100644
index 0000000..1237a2f
--- /dev/null
+++ b/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt
@@ -0,0 +1,74 @@
+package ai.hesychia.chat.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+/**
+ * Typography scale from llm-fe `ui-kit/assets/theme/base/typography.js`.
+ * Web uses Roboto; Compose defaults to platform sans until branded fonts packaged.
+ */
+val HesychiaTypography = Typography(
+ displayLarge = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Light,
+ fontSize = 48.sp,
+ lineHeight = 60.sp,
+ ),
+ headlineLarge = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Bold,
+ fontSize = 36.sp,
+ lineHeight = 46.sp,
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Bold,
+ fontSize = 30.sp,
+ lineHeight = 39.sp,
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Bold,
+ fontSize = 24.sp,
+ lineHeight = 31.sp,
+ ),
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ lineHeight = 28.sp,
+ ),
+ titleMedium = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 18.sp,
+ lineHeight = 25.sp,
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ ),
+ bodySmall = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ ),
+ labelLarge = TextStyle(
+ fontFamily = FontFamily.SansSerif,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ ),
+)
diff --git a/app/src/main/res/drawable/hesychia_mark.png b/app/src/main/res/drawable/hesychia_mark.png
new file mode 100644
index 0000000..b97ffcc
Binary files /dev/null and b/app/src/main/res/drawable/hesychia_mark.png differ
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..31f6ffa
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..a8a8fa5
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f67db2f
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #1A73E8
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..bd8111d
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Hesychia
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..8e52ab0
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
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 0000000..6115950
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/test/java/ai/hesychia/chat/data/chat/WsFramesTest.kt b/app/src/test/java/ai/hesychia/chat/data/chat/WsFramesTest.kt
new file mode 100644
index 0000000..4881c68
--- /dev/null
+++ b/app/src/test/java/ai/hesychia/chat/data/chat/WsFramesTest.kt
@@ -0,0 +1,26 @@
+package ai.hesychia.chat.data.chat
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class WsFramesTest {
+ @Test
+ fun sentinels() {
+ assertThat(classifyIncoming("CONVERSATION_ID")).isEqualTo(IncomingFrame.ConversationIdMarker)
+ assertThat(classifyIncoming("START_OF_THE_STREAM_ENDER_GAME_42")).isEqualTo(IncomingFrame.StreamStart)
+ assertThat(classifyIncoming("END_OF_THE_STREAM_ENDER_GAME_42")).isEqualTo(IncomingFrame.StreamEnd)
+ }
+
+ @Test
+ fun statusAndErrorFrames() {
+ val status = classifyIncoming("""{"v":1,"type":"status","data":{"stage":"search","label":"Searching","detail":"docs"}}""")
+ assertThat(status).isEqualTo(IncomingFrame.Status("search", "Searching", "docs"))
+ val error = classifyIncoming("""{"type":"error","content":"Quota exceeded"}""")
+ assertThat(error).isEqualTo(IncomingFrame.Error("Quota exceeded"))
+ }
+
+ @Test
+ fun plainTokensStayText() {
+ assertThat(classifyIncoming("Hello")).isEqualTo(IncomingFrame.Text("Hello"))
+ }
+}
diff --git a/app/src/test/java/ai/hesychia/chat/ui/theme/HesychiaTokensTest.kt b/app/src/test/java/ai/hesychia/chat/ui/theme/HesychiaTokensTest.kt
new file mode 100644
index 0000000..5aff5c1
--- /dev/null
+++ b/app/src/test/java/ai/hesychia/chat/ui/theme/HesychiaTokensTest.kt
@@ -0,0 +1,20 @@
+package ai.hesychia.chat.ui.theme
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class HesychiaTokensTest {
+ @Test
+ fun bluePaletteMatchesWeb() {
+ assertThat(HesychiaTokens.blue.main.red).isWithin(0.01f).of(0x1A / 255f)
+ assertThat(HesychiaTokens.blue.main.green).isWithin(0.01f).of(0x73 / 255f)
+ assertThat(HesychiaTokens.blue.main.blue).isWithin(0.01f).of(0xE8 / 255f)
+ }
+
+ @Test
+ fun allThemeColorsResolve() {
+ ThemeColor.entries.forEach { color ->
+ assertThat(HesychiaTokens.palette(color).main).isNotNull()
+ }
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..a56d328
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ id("com.android.application") version "8.13.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+ id("com.google.dagger.hilt.android") version "2.53.1" apply false
+ id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
+}
diff --git a/design-tokens/hesychia.tokens.json b/design-tokens/hesychia.tokens.json
new file mode 100644
index 0000000..bbe9bd6
--- /dev/null
+++ b/design-tokens/hesychia.tokens.json
@@ -0,0 +1,69 @@
+{
+ "$schema": "https://design-tokens.github.io/community-group/format/",
+ "meta": {
+ "name": "hesychia",
+ "source": "chat_web_app/llm-fe/src/llm-fe/ui-kit/assets/theme/base/",
+ "parityBar": "brand + layout rhythm; native platform patterns OK for nav/keyboard"
+ },
+ "color": {
+ "palette": {
+ "blue": { "main": { "value": "#1A73E8" }, "focus": { "value": "#1662C4" } },
+ "green": { "main": { "value": "#4CAF50" }, "focus": { "value": "#67bb6a" } },
+ "purple": { "main": { "value": "#9c27b0" }, "focus": { "value": "#ba68c8" } },
+ "orange": { "main": { "value": "#fb8c00" }, "focus": { "value": "#fc9d26" } },
+ "red": { "main": { "value": "#F44335" }, "focus": { "value": "#f65f53" } }
+ },
+ "semantic": {
+ "background": { "light": { "value": "#f0f2f5" }, "dark": { "value": "#1A2035" } },
+ "surface": { "light": { "value": "#ffffff" }, "dark": { "value": "#202940" } },
+ "text": { "muted": { "value": "#7b809a" }, "dark": { "value": "#344767" } },
+ "error": { "value": "#F44335" },
+ "success": { "value": "#4CAF50" },
+ "warning": { "value": "#fb8c00" },
+ "info": { "value": "#1A73E8" }
+ },
+ "grey": {
+ "100": { "value": "#f8f9fa" },
+ "200": { "value": "#f0f2f5" },
+ "300": { "value": "#dee2e6" },
+ "400": { "value": "#ced4da" },
+ "500": { "value": "#adb5bd" },
+ "600": { "value": "#6c757d" },
+ "700": { "value": "#495057" },
+ "800": { "value": "#343a40" },
+ "900": { "value": "#212529" }
+ }
+ },
+ "typography": {
+ "fontFamily": { "value": "Roboto, Helvetica, Arial, sans-serif" },
+ "fontWeight": {
+ "lighter": { "value": 100 },
+ "light": { "value": 300 },
+ "regular": { "value": 400 },
+ "medium": { "value": 600 },
+ "bold": { "value": 700 }
+ },
+ "fontSize": {
+ "xxs": { "value": "10.4px" },
+ "xs": { "value": "12px" },
+ "sm": { "value": "14px" },
+ "md": { "value": "16px" },
+ "lg": { "value": "18px" },
+ "xl": { "value": "20px" },
+ "2xl": { "value": "24px" },
+ "3xl": { "value": "30px" },
+ "h1": { "value": "48px" },
+ "h2": { "value": "36px" }
+ }
+ },
+ "radius": {
+ "sm": { "value": "4px" },
+ "md": { "value": "8px" },
+ "lg": { "value": "12px" },
+ "xl": { "value": "16px" }
+ },
+ "brand": {
+ "mark": { "value": "llm-fe/assets + hesychia-mark.png" },
+ "applicationId": { "value": "ai.hesychia.chat" }
+ }
+}
diff --git a/docs/ADR-001-architecture.md b/docs/ADR-001-architecture.md
new file mode 100644
index 0000000..e67d932
--- /dev/null
+++ b/docs/ADR-001-architecture.md
@@ -0,0 +1,34 @@
+# ADR-001: Architecture for native Hesychia Android
+
+- **Status:** Accepted (spike default)
+- **Date:** 2026-08-05
+- **Context:** Epic [chat_web_app#111](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/111)
+
+## Decision
+
+Ship a **Kotlin + Jetpack Compose + Material 3** client in `chat_android`, single `:app` module for v1.
+
+| Concern | Choice |
+|---------|--------|
+| UI | Jetpack Compose + Material 3 (`HesychiaTheme` from design tokens) |
+| DI | Hilt |
+| REST | Retrofit + OkHttp + kotlinx.serialization |
+| WebSocket | OkHttp WebSocket (parity with FE `WebSocketContext`) — Workstream D |
+| Session | DataStore Preferences (JWT access/refresh); migrate to EncryptedSharedPreferences if threat model requires |
+| Nav | Navigation Compose |
+| IAP | RevenueCat Android SDK (`com.revenuecat.purchases`) |
+| Flavors | `prod` / `beta` (env dimension); debug adds `.debug` applicationIdSuffix |
+| Min / target SDK | 23 / 35 (match Capacitor shell) |
+| Application id | **`ai.hesychia.chat`** (same as Capacitor) — see ADR-002 |
+
+## Alternatives considered
+
+1. **Keep Capacitor only** — lower eng cost short-term; WebView UX / IAP friction remain.
+2. **Flutter** — one codebase for Android+iOS, but new stack vs existing Kotlin skill + no shared UI with React web.
+3. **Multi-module now** — premature until chat + auth + billing vertical slices exist.
+
+## Consequences
+
+- Feature modules (`:feature:auth`, `:feature:chat`, `:core:network`) deferred until MVP vertical slice ships.
+- iOS twin is a **separate epic / repo** (SwiftUI), not Flutter.
+- Visual parity bar: **brand + layout rhythm**, not pixel-perfect MUI clones.
diff --git a/docs/ADR-002-capacitor-coexistence.md b/docs/ADR-002-capacitor-coexistence.md
new file mode 100644
index 0000000..af57e6c
--- /dev/null
+++ b/docs/ADR-002-capacitor-coexistence.md
@@ -0,0 +1,30 @@
+# ADR-002: Capacitor coexistence & Play application id
+
+- **Status:** Accepted (initial)
+- **Date:** 2026-08-05
+- **Context:** Epic chat_web_app#111 — package-name continuity matters for IAP + reviews.
+
+## Decision
+
+1. Native app **`applicationId` = `ai.hesychia.chat`** (same as Capacitor / `llm-fe/android`).
+2. Until native internal testing replaces Capacitor on Play:
+ - Capacitor remains interim Play track.
+ - Do **not** upload both Capacitor and native AABs under the same listing as competing “current” production binaries without a freeze plan.
+3. When native auth → chat → IAP path is green on internal testing:
+ - **Freeze** Capacitor Play uploads.
+ - Promote native AAB on the same listing (versionCode continues upward from Capacitor’s last upload).
+4. `beta` flavor uses `applicationIdSuffix = ".beta"` → `ai.hesychia.chat.beta` for side-by-side installs during development only (not the production listing).
+
+## Rationale
+
+Same application id preserves Play listing, reviews, and RevenueCat / Play Billing product bindings already tied to `ai.hesychia.chat`.
+
+## Rejected
+
+- New id (`ai.hesychia.chat.native`) then migrate users — splits listings, breaks IAP continuity, worse for reviews.
+
+## Open ops
+
+- Confirm last Capacitor `versionCode` before first native Play upload.
+- RevenueCat dashboard already includes package id `ai.hesychia.chat` (Capacitor path).
+- Capacitor shell in `llm-fe/android` stays for web mobile wrap until product sunsets it; document deprecation in `chat_web_app` when freeze date set.
diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md
new file mode 100644
index 0000000..e844fc1
--- /dev/null
+++ b/docs/OPEN_QUESTIONS.md
@@ -0,0 +1,15 @@
+# Open questions
+
+1. **SSO redirect URIs** — which App Auth / Custom Tabs redirect URIs to register with Google / Microsoft + `chat_backend`?
+2. **Document storage on mobile** — still required for Play v1, or web-only?
+3. **Markdown strategy** — native Markwon/Coil vs small WebView islands for charts?
+4. **Analytics** — Tianji parity, Firebase, or none for native v1?
+5. **Push notifications** — defer confirmed?
+6. **Encrypted session storage** — DataStore OK for MVP, or EncryptedSharedPreferences required at launch?
+7. **Capacitor freeze date** — who owns Play Console cutover once native internal test passes?
+8. **JWT login field** — confirm backend `token/` expects `email` vs `username` (wire against live OpenAPI).
+9. **Cost estimate** — eng weeks for auth → chat → IAP after scaffolding (update epic AC #1).
+
+## Spike verdict (initial)
+
+**Proceed** with native Compose client. Capacitor remains interim Play track until native IAP path is proven (ADR-002). Rough order-of-magnitude after scaffold: **6–10 eng weeks** for auth + chat MVP + RevenueCat + store checklist (SSO + polish extra).
diff --git a/docs/SCREEN_INVENTORY.md b/docs/SCREEN_INVENTORY.md
new file mode 100644
index 0000000..f8dd893
--- /dev/null
+++ b/docs/SCREEN_INVENTORY.md
@@ -0,0 +1,36 @@
+# Screen inventory (mirror of llm-fe)
+
+Source: `chat_web_app/llm-fe/src/App.tsx` routes + key shells.
+
+| Web route | Native v1 | Priority | Notes |
+|-----------|-----------|----------|-------|
+| `/signin/` | SignIn | P0 | Email/password JWT (`POST token/`) |
+| `/signup/` | SignUp | P0 | After create → RevenueCat purchase (Workstream E) |
+| `/auth/callback/` | Deep link / Custom Tab return | P1 | Google / Microsoft SSO |
+| `/password_reset/` | Custom Tab + deep link | P1 | |
+| `/password_reset_confirmation/` | Deep link | P1 | |
+| `/set_password/` | Deep link | P2 | |
+| `/` (AsyncDashboard2) | Chat | P0 | Conversations list + detail + compose |
+| `/account/` | Account | P0 | Usage, theme, billing (RC), delete account |
+| `/billing/success` `/billing/cancel` | N/A on Play | — | Stripe web only |
+| `/document_storage` | Deferred | P2 | Confirm product need on mobile |
+| `/feedback/` | Deferred | P2 | |
+| `/analytics/` | Deferred / omit | P3 | Tianji subset TBD |
+| `/terms_of_service/` `/legal/terms-of-service` | Custom Tab to web | P1 | |
+| `/account-deletion/` | Custom Tab to public URL | P0 (store) | `https://hesychia.ai/account-deletion/` (#103) |
+| NotFound | System back / empty | — | |
+
+## Chat subflows (from AsyncDashboard2 / ConversationDetailCard)
+
+- Conversation list create / select / rename / delete
+- Message history load + pagination
+- Streaming tokens over WebSocket
+- Markdown / code / citations
+- Message actions (copy, regenerate, …) — product prune
+
+## Account subflows
+
+- Usage meters
+- Theme: dark mode + palette (`blue|green|purple|orange|red`)
+- Billing: subscribe / restore / manage (RevenueCat; badge `revenuecat` + `PLAY_STORE`)
+- Delete account → `DELETE /api/user/` + RC `logOut`
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..fff4375
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,8 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
+org.gradle.parallel=true
+
+# Enabled parallel sync for Gradle 9.4+
+org.gradle.tooling.parallel=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..4f5eb9d
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..23d15a9
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..458f879
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Hesychia"
+include(":app")