Add the native Hesychia Android client so the repo can build beyond the placeholder README.
CI / build (push) Canceled after 0s

This commit is contained in:
westfarn committed 2026-10-25 07:56:32 -05:00
1 parent d9d2916a49
commit e36d97760a
52 files changed
+5050 -1

No files matched your search

+34
View File
@@ -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
+5
View File
@@ -2,9 +2,11 @@
# Gradle files # Gradle files
.gradle/ .gradle/
build/ build/
**/build/
# Local configuration file (sdk path, etc) # Local configuration file (sdk path, etc)
local.properties local.properties
keystore.properties
# Log/OS Files # Log/OS Files
*.log *.log
@@ -14,6 +16,7 @@ captures/
.externalNativeBuild/ .externalNativeBuild/
.cxx/ .cxx/
*.apk *.apk
*.aab
output.json output.json
# IntelliJ # IntelliJ
@@ -33,3 +36,5 @@ google-services.json
# Android Profiling # Android Profiling
*.hprof *.hprof
# OS
.DS_Store
+79 -1
View File
@@ -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)
+156
View File
@@ -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")
}
+4
View File
@@ -0,0 +1,4 @@
# Hesychia — release minify rules (enable later with R8)
-keepattributes *Annotation*, InnerClasses
-dontwarn okhttp3.**
-dontwarn retrofit2.**
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".HesychiaApp"
android:allowBackup="false"
android:icon="@drawable/ic_launcher_foreground"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@drawable/ic_launcher_foreground"
android:supportsRtl="true"
android:theme="@style/Theme.Hesychia">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Hesychia">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Password-reset / SSO deep links (register IdP redirect URIs separately) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hesychia.ai"
android:pathPrefix="/auth"
android:scheme="https" />
<data
android:host="hesychia.ai"
android:pathPrefix="/password_reset"
android:scheme="https" />
<data
android:host="hesychia.ai"
android:pathPrefix="/set_password"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,7 @@
package ai.hesychia.chat
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class HesychiaApp : Application()
@@ -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) }
}
}
@@ -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
}
@@ -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<InvoiceDto>
@GET("monetization/payments/")
suspend fun payments(): List<PaymentDto>
@GET("monetization/plans/")
suspend fun plans(): List<PlanDto>
@POST("monetization/store/confirm/")
suspend fun confirmStore(@Body body: StoreConfirmRequestDto)
@GET("company_users")
suspend fun companyUsers(): List<UserDto>
@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<ConversationDto>
@GET("conversation_details")
suspend fun conversationDetails(@Query("conversation_id") conversationId: Int): List<PromptDto>
@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<DriveConnectionDto>
@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<FeedbackDto>
@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<MonthSeriesDto>
@GET("analytics/user_conversations/")
suspend fun userConversations(): List<MonthSeriesDto>
@GET("analytics/company_usage/")
suspend fun companyUsage(): List<MonthSeriesDto>
@GET("analytics/admin/")
suspend fun adminAnalytics(): List<MonthSeriesDto>
}
@@ -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<CitationDto> = 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<DocumentDto> = 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<String> = emptyList(),
)
@Serializable
data class DriveResourceSelectionDto(
val resource_ids: List<String>,
val resource_labels: List<String>,
)
@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<String> = emptyList(),
val hours: List<Int> = emptyList(),
val matrix: List<List<Int>> = 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,
)
@@ -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<List<Purchase>> { 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<Purchase>?) {
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
})
}
}
@@ -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"}""")
}
}
@@ -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<String>) : 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()
@@ -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
}
}
@@ -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<Preferences> 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<SessionSnapshot> = 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 }
}
}
@@ -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)
}
}
}
@@ -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") } },
)
}
}
@@ -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<InvoiceDto> = emptyList(),
val payments: List<PaymentDto> = emptyList(),
val plans: List<PlanDto> = emptyList(),
val prefs: PreferencesDto = PreferencesDto(),
val companyUsers: List<UserDto> = 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<AccountUiState> = _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.")) } }
}
}
}
@@ -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<MonthSeriesDto> = emptyList(),
val conversations: List<MonthSeriesDto> = emptyList(),
val company: List<MonthSeriesDto> = emptyList(),
val admin: List<MonthSeriesDto> = emptyList(),
)
@HiltViewModel
class AnalyticsViewModel @Inject constructor(
private val api: HesychiaApi,
private val sessionRepository: SessionRepository,
) : ViewModel() {
private val _state = MutableStateFlow(AnalyticsUiState())
val state: StateFlow<AnalyticsUiState> = _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<MonthSeriesDto>, 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),
)
}
}
}
}
}
@@ -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<String?>(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<String?>(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<String?>(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<String?>(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<String?>(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))
}
@@ -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<PublicAuthState> = _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<String> = 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<PublicAuthState> = _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<String> = 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<Unit> = 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<SetPasswordState> = _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<Unit> = 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<Unit> = runCatching {
api.acknowledgeTos()
sessionRepository.markTosSigned()
}
}
fun Throwable.userMessage(fallback: String): String = apiMessage(fallback)
@@ -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<String?>(null) }
var attachmentBytes by remember { mutableStateOf<ByteArray?>(null) }
var attachmentType by remember { mutableStateOf<String?>(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<ConversationRow>,
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")
}
}
}
}
}
@@ -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<String> = emptyList(),
val rating: String? = null,
)
data class ConversationRow(val id: Int, val title: String)
data class ChatUiState(
val conversations: List<ConversationRow> = emptyList(),
val selectedId: Int? = null,
val messages: List<ChatMessage> = 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<ChatUiState> = _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,
)
@@ -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<DocumentDto> = emptyList(),
val drives: List<DriveConnectionDto> = 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<DocumentsUiState> = _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))
}
@@ -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<FeedbackDto> = 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<FeedbackUiState> = _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)
}
}
}
}
@@ -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<SessionSnapshot> = sessionRepository.session
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SessionSnapshot())
private val _subscription = MutableStateFlow<SubscriptionDto?>(null)
val subscription: StateFlow<SubscriptionDto?> = _subscription
private val _boot = MutableStateFlow(BootState())
val boot: StateFlow<BootState> = _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<Uri>(extraBufferCapacity = 1)
fun consume(): Uri? = uri.also { uri = null }
}
@@ -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,
)
}
}
@@ -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,
)
@@ -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,
),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30c-13.2,0 -24,10.8 -24,24s10.8,24 24,24 24,-10.8 24,-24 -10.8,-24 -24,-24zM54,70c-8.8,0 -16,-7.2 -16,-16s7.2,-16 16,-16 16,7.2 16,16 -7.2,16 -16,16z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1A73E8</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hesychia</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Hesychia" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
@@ -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"))
}
}
@@ -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()
}
}
}
+8
View File
@@ -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
}
+69
View File
@@ -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" }
}
}
+34
View File
@@ -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.
+30
View File
@@ -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.
+15
View File
@@ -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).
+36
View File
@@ -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`
+8
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+251
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+18
View File
@@ -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")