Add the native Hesychia Android client so the repo can build beyond the placeholder README.
CI / build (push) Canceled after 0s
CI / build (push) Canceled after 0s
This commit is contained in:
1 parent
d9d2916a49
commit
e36d97760a
52 files changed
+5050
-1
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
# Hesychia — release minify rules (enable later with R8)
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn retrofit2.**
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1A73E8</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Hesychia</string>
|
||||||
|
</resources>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in new issue
Block a user