From e36d97760a782e39edee6217a00036de7e013a32 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 25 Oct 2026 07:56:32 -0500 Subject: [PATCH] Add the native Hesychia Android client so the repo can build beyond the placeholder README. --- .gitea/workflows/ci.yml | 34 ++ .gitignore | 5 + README.md | 80 +++- app/build.gradle.kts | 156 +++++++ app/proguard-rules.pro | 4 + app/src/main/AndroidManifest.xml | 45 ++ .../main/java/ai/hesychia/chat/HesychiaApp.kt | 7 + .../java/ai/hesychia/chat/MainActivity.kt | 87 ++++ .../ai/hesychia/chat/data/api/ApiError.kt | 37 ++ .../ai/hesychia/chat/data/api/HesychiaApi.kt | 151 ++++++ .../java/ai/hesychia/chat/data/api/Models.kt | 309 +++++++++++++ .../chat/data/billing/StoreBilling.kt | 283 ++++++++++++ .../ai/hesychia/chat/data/chat/ChatSocket.kt | 62 +++ .../ai/hesychia/chat/data/chat/WsFrames.kt | 74 +++ .../chat/data/network/NetworkModule.kt | 162 +++++++ .../chat/data/session/SessionRepository.kt | 146 ++++++ .../chat/navigation/HesychiaNavHost.kt | 126 +++++ .../hesychia/chat/ui/account/AccountScreen.kt | 181 ++++++++ .../chat/ui/account/AccountViewModel.kt | 167 +++++++ .../chat/ui/analytics/AnalyticsScreen.kt | 162 +++++++ .../ai/hesychia/chat/ui/auth/AuthScreens.kt | 431 ++++++++++++++++++ .../hesychia/chat/ui/auth/AuthViewModels.kt | 177 +++++++ .../ai/hesychia/chat/ui/chat/ChatScreen.kt | 226 +++++++++ .../ai/hesychia/chat/ui/chat/ChatViewModel.kt | 294 ++++++++++++ .../chat/ui/documents/DocumentsScreen.kt | 330 ++++++++++++++ .../chat/ui/feedback/FeedbackScreen.kt | 141 ++++++ .../chat/ui/session/SessionViewModel.kt | 146 ++++++ .../ai/hesychia/chat/ui/shell/AppScaffold.kt | 88 ++++ .../hesychia/chat/ui/theme/HesychiaTheme.kt | 214 +++++++++ .../java/ai/hesychia/chat/ui/theme/Type.kt | 74 +++ app/src/main/res/drawable/hesychia_mark.png | Bin 0 -> 86724 bytes .../res/drawable/ic_launcher_foreground.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 4 + .../main/res/xml/network_security_config.xml | 4 + .../hesychia/chat/data/chat/WsFramesTest.kt | 26 ++ .../chat/ui/theme/HesychiaTokensTest.kt | 20 + build.gradle.kts | 8 + design-tokens/hesychia.tokens.json | 69 +++ docs/ADR-001-architecture.md | 34 ++ docs/ADR-002-capacitor-coexistence.md | 30 ++ docs/OPEN_QUESTIONS.md | 15 + docs/SCREEN_INVENTORY.md | 36 ++ gradle.properties | 8 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++ gradlew.bat | 94 ++++ settings.gradle.kts | 18 + 52 files changed, 5050 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/ai/hesychia/chat/HesychiaApp.kt create mode 100644 app/src/main/java/ai/hesychia/chat/MainActivity.kt create mode 100644 app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt create mode 100644 app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt create mode 100644 app/src/main/java/ai/hesychia/chat/data/api/Models.kt create mode 100644 app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt create mode 100644 app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt create mode 100644 app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt create mode 100644 app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt create mode 100644 app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt create mode 100644 app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt create mode 100644 app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/hesychia_mark.png create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/ai/hesychia/chat/data/chat/WsFramesTest.kt create mode 100644 app/src/test/java/ai/hesychia/chat/ui/theme/HesychiaTokensTest.kt create mode 100644 build.gradle.kts create mode 100644 design-tokens/hesychia.tokens.json create mode 100644 docs/ADR-001-architecture.md create mode 100644 docs/ADR-002-capacitor-coexistence.md create mode 100644 docs/OPEN_QUESTIONS.md create mode 100644 docs/SCREEN_INVENTORY.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..feff8f3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Unit tests + run: ./gradlew :app:testProdDebugUnitTest --stacktrace + + - name: Assemble debug APK + run: ./gradlew :app:assembleProdDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: hesychia-prod-debug + path: app/build/outputs/apk/prod/debug/*.apk + if-no-files-found: error diff --git a/.gitignore b/.gitignore index a8b0d1d..bd30a55 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ # Gradle files .gradle/ build/ +**/build/ # Local configuration file (sdk path, etc) local.properties +keystore.properties # Log/OS Files *.log @@ -14,6 +16,7 @@ captures/ .externalNativeBuild/ .cxx/ *.apk +*.aab output.json # IntelliJ @@ -33,3 +36,5 @@ google-services.json # Android Profiling *.hprof +# OS +.DS_Store diff --git a/README.md b/README.md index 01b2f0b..2340fc1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,80 @@ -# chat_android +# Hesychia Android (native) +Kotlin + Jetpack Compose client for [Hesychia](https://hesychia.ai), reusing `chat_backend` APIs. + +Epic: [chat_web_app#111](https://git.aimloperations.com/ai_ml_operations/chat_web_app/issues/111) + +Capacitor WebView shell remains in `chat_web_app/llm-fe` as the interim Play track — see [docs/ADR-002-capacitor-coexistence.md](docs/ADR-002-capacitor-coexistence.md). + +## Stack + +- Kotlin, Jetpack Compose, Material 3 +- Hilt, Navigation Compose, DataStore +- Retrofit + OkHttp (+ WebSocket next) +- RevenueCat Android SDK (IAP Workstream E) +- Min SDK 23 / target SDK 35 +- Application id: `ai.hesychia.chat` + +## Docs + +| Doc | Purpose | +|-----|---------| +| [ADR-001](docs/ADR-001-architecture.md) | Architecture choices | +| [ADR-002](docs/ADR-002-capacitor-coexistence.md) | Play id + Capacitor sunset | +| [SCREEN_INVENTORY](docs/SCREEN_INVENTORY.md) | Routes / flows to mirror | +| [OPEN_QUESTIONS](docs/OPEN_QUESTIONS.md) | Spike leftovers | +| [design-tokens/hesychia.tokens.json](design-tokens/hesychia.tokens.json) | Shared visual tokens | + +## Prerequisites + +- JDK 17+ +- Android SDK Platform 35 + build-tools +- Android Studio (Ladybug+) recommended +- Set `sdk.dir` in `local.properties` (gitignored), or open once in Android Studio + +```properties +sdk.dir=/path/to/Android/Sdk +``` + +## Build + +```bash +./gradlew :app:assembleProdDebug +./gradlew :app:testProdDebugUnitTest +# APK → app/build/outputs/apk/prod/debug/ +``` + +Flavors: + +| Flavor | REST / WS | applicationId | +|--------|-----------|---------------| +| `prod` (default) | production `chatbackend.aimloperations.com` | `ai.hesychia.chat` (+ `.debug` for debug) | +| `beta` | `beta.chatbackend.aimloperations.com` | `ai.hesychia.chat.beta` (+ `.debug`) | + +## Signing (release) + +Never commit keystores. Create `keystore.properties` (gitignored) at repo root: + +```properties +storeFile=/absolute/path/to/aiml-chat-upload.jks +storePassword=... +keyAlias=aiml-chat-upload +keyPassword=... +``` + +```bash +./gradlew :app:bundleProdRelease +``` + +## Monetization + +Native Play uses **RevenueCat** only (no Stripe Checkout). Align with `chat_web_app/llm-fe/MONETIZATION.md`: + +- `appUserID` = Django user pk string +- Provider badge: `revenuecat` + `PLAY_STORE` + +## Related repos + +- `chat_web_app` — product / Capacitor / design source +- `chat_backend` — REST + WS + RevenueCat webhooks +- Future: `chat_ios` (SwiftUI twin epic) diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..bd54882 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,156 @@ +import java.io.FileInputStream +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") +} + +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "ai.hesychia.chat" + compileSdk = 35 + + defaultConfig { + applicationId = "ai.hesychia.chat" + minSdk = 23 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "REVENUECAT_GOOGLE_API_KEY", "\"\"") + buildConfigField("String", "REVENUECAT_OFFERING_ID", "\"\"") + } + + signingConfigs { + create("release") { + if (keystorePropertiesFile.exists()) { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + + flavorDimensions += "env" + productFlavors { + create("prod") { + dimension = "env" + isDefault = true + buildConfigField( + "String", + "REST_API_BASE_URL", + "\"https://backend.hesychia.ai/api/\"", + ) + buildConfigField( + "String", + "WS_API_BASE_URL", + "\"wss://backend.hesychia.ai/ws/chat_again/\"", + ) + } + create("beta") { + dimension = "env" + applicationIdSuffix = ".beta" + versionNameSuffix = "-beta" + buildConfigField( + "String", + "REST_API_BASE_URL", + "\"https://beta.backend.hesychia.ai/api/\"", + ) + buildConfigField( + "String", + "WS_API_BASE_URL", + "\"wss://beta.backend.hesychia.ai/ws/chat_again/\"", + ) + } + } + + buildTypes { + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + } + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.8.5") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.browser:browser:1.8.0") + implementation("io.coil-kt:coil-compose:2.7.0") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("com.google.dagger:hilt-android:2.53.1") + ksp("com.google.dagger:hilt-compiler:2.53.1") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + + implementation("com.android.billingclient:billing-ktx:7.1.1") + // RevenueCat — wire keys via BuildConfig / local.properties later (Workstream E) + implementation("com.revenuecat.purchases:purchases:8.12.2") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("com.google.truth:truth:1.4.4") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..0685c1f --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Hesychia — release minify rules (enable later with R8) +-keepattributes *Annotation*, InnerClasses +-dontwarn okhttp3.** +-dontwarn retrofit2.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d982578 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt b/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt new file mode 100644 index 0000000..3fee61c --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/HesychiaApp.kt @@ -0,0 +1,7 @@ +package ai.hesychia.chat + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class HesychiaApp : Application() diff --git a/app/src/main/java/ai/hesychia/chat/MainActivity.kt b/app/src/main/java/ai/hesychia/chat/MainActivity.kt new file mode 100644 index 0000000..11ce9af --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/MainActivity.kt @@ -0,0 +1,87 @@ +package ai.hesychia.chat + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.rememberNavController +import ai.hesychia.chat.navigation.HesychiaNavHost +import ai.hesychia.chat.navigation.Routes +import ai.hesychia.chat.ui.session.LaunchUri +import ai.hesychia.chat.ui.session.SessionViewModel +import ai.hesychia.chat.ui.theme.HesychiaTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + LaunchUri.uri = intent?.data + val launchUri = intent?.data + setContent { + val sessionVm: SessionViewModel = hiltViewModel() + val session by sessionVm.state.collectAsStateWithLifecycle() + val boot by sessionVm.boot.collectAsStateWithLifecycle() + val subscription by sessionVm.subscription.collectAsStateWithLifecycle() + HesychiaTheme( + darkTheme = session.darkMode ?: isSystemInDarkTheme(), + themeColor = session.themeColor, + ) { + if (!boot.ready) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + val navController = rememberNavController() + var driveConnected by remember { + mutableStateOf(launchUri?.getQueryParameter("drive_connected") == "1") + } + LaunchedEffect(navController) { + LaunchUri.incoming.collect { uri -> + driveConnected = uri.getQueryParameter("drive_connected") == "1" + val route = sessionVm.routeFor(uri) + navController.navigate(route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } + } + HesychiaNavHost( + startDestination = boot.startRoute, + hasRag = subscription?.hasRag == true, + driveJustConnected = driveConnected, + onSessionChanged = sessionVm::refreshSubscription, + onSignOut = { + sessionVm.signOut { + navController.navigate(Routes.SignIn) { + popUpTo(0) { inclusive = true } + } + } + }, + navController = navController, + ) + } + } + } + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + intent.data?.let { LaunchUri.incoming.tryEmit(it) } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt b/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt new file mode 100644 index 0000000..5dc0545 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/api/ApiError.kt @@ -0,0 +1,37 @@ +package ai.hesychia.chat.data.api + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import retrofit2.HttpException + +fun Throwable.apiMessage(fallback: String, json: Json = ApiJson): String { + val http = this as? HttpException ?: return message?.takeIf { it.isNotBlank() } ?: fallback + val raw = runCatching { http.response()?.errorBody()?.string() }.getOrNull().orEmpty() + if (raw.isBlank()) { + return if (http.code() == 403) "Account registration is currently disabled." else fallback + } + val parsed = runCatching { json.decodeFromString(ErrorEnvelope.serializer(), raw) }.getOrNull() + val detail = parsed?.detail?.let { elementText(it) } + if (!detail.isNullOrBlank()) return detail + val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() + val email = (obj?.get("email") as? JsonArray)?.firstOrNull()?.let { elementText(it) } + if (!email.isNullOrBlank()) return email + return fallback +} + +private fun elementText(element: kotlinx.serialization.json.JsonElement): String? = when (element) { + is JsonPrimitive -> element.contentOrNull + is JsonArray -> element.firstOrNull()?.let { elementText(it) } + is JsonObject -> element.entries.firstOrNull()?.value?.let { elementText(it) } + else -> null +} + +val ApiJson: Json = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + explicitNulls = false +} diff --git a/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt b/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt new file mode 100644 index 0000000..97fa059 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/api/HesychiaApi.kt @@ -0,0 +1,151 @@ +package ai.hesychia.chat.data.api + +import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +interface HesychiaApi { + @GET("public/settings/") + suspend fun publicSettings(): PublicSettingsDto + + @POST("token/obtain/") + suspend fun login(@Body body: LoginRequestDto): TokenPairDto + + @POST("token/refresh/") + suspend fun refresh(@Body body: RefreshRequestDto): TokenPairDto + + @GET("user/get/") + suspend fun currentUser(): UserDto + + @POST("user/create/") + suspend fun createUser(@Body body: SignUpRequestDto): SignUpResponseDto + + @POST("user/acknowledge_tos/") + suspend fun acknowledgeTos() + + @POST("user/reset_password/") + suspend fun resetPassword(@Body body: PasswordResetRequestDto) + + @GET("user/set_password/{slug}/") + suspend fun checkSetPassword(@Path("slug") slug: String) + + @POST("user/set_password/{slug}/") + suspend fun setPassword(@Path("slug") slug: String, @Body body: SetPasswordRequestDto) + + @POST("blacklist/") + suspend fun blacklist(@Body body: RefreshTokenBody) + + @HTTP(method = "DELETE", path = "user/", hasBody = true) + suspend fun deleteUser(@Body body: RefreshTokenBody) + + @GET("conversation_preferences") + suspend fun preferences(): PreferencesDto + + @POST("conversation_preferences") + suspend fun savePreferences(@Body body: PreferencesDto): PreferencesDto + + @GET("monetization/subscription/") + suspend fun subscription(): SubscriptionDto + + @GET("monetization/invoices/") + suspend fun invoices(): List + + @GET("monetization/payments/") + suspend fun payments(): List + + @GET("monetization/plans/") + suspend fun plans(): List + + @POST("monetization/store/confirm/") + suspend fun confirmStore(@Body body: StoreConfirmRequestDto) + + @GET("company_users") + suspend fun companyUsers(): List + + @POST("user/invite/") + suspend fun invite(@Body body: EmailBody) + + @POST("company_users") + suspend fun updateCompanyUser(@Body body: CompanyUserUpdateDto) + + @HTTP(method = "DELETE", path = "company_users", hasBody = true) + suspend fun deleteCompanyUser(@Body body: EmailBody) + + @GET("conversations") + suspend fun conversations(): List + + @GET("conversation_details") + suspend fun conversationDetails(@Query("conversation_id") conversationId: Int): List + + @HTTP(method = "DELETE", path = "conversation_details", hasBody = true) + suspend fun deleteConversation(@Body body: ConversationIdBody) + + @POST("prompt_feedback") + suspend fun upsertPromptFeedback(@Body body: PromptFeedbackRequestDto) + + @DELETE("prompt_feedback") + suspend fun clearPromptFeedback(@Query("prompt_id") promptId: Int) + + @GET("documents/") + suspend fun documents( + @Query("workspace") workspace: String, + @Query("page") page: Int, + @Query("page_size") pageSize: Int, + @Query("search") search: String?, + @Query("ordering") ordering: String, + ): DocumentPageDto + + @Multipart + @POST("documents/") + suspend fun uploadDocument( + @Query("workspace") workspace: String, + @Part file: MultipartBody.Part, + ) + + @PATCH("documents_details/{id}") + suspend fun setDocumentActive(@Path("id") id: Int, @Body body: ActiveBody) + + @GET("drive/connections/") + suspend fun driveConnections(): List + + @DELETE("drive/connections/{id}/") + suspend fun disconnectDrive(@Path("id") id: Int) + + @POST("drive/connections/{id}/resources/") + suspend fun saveDriveResources( + @Path("id") id: Int, + @Body body: DriveResourceSelectionDto, + ): DriveConnectionDto + + @POST("drive/connections/{id}/sync/") + suspend fun syncDrive(@Path("id") id: Int): DriveSyncResponseDto + + @GET("feedbacks/") + suspend fun feedbacks(): List + + @POST("feedbacks/") + suspend fun createFeedback(@Body body: FeedbackCreateDto) + + @GET("analytics/user_prompt_heatmap/") + suspend fun promptHeatmap(@Query("tz") tz: String): HeatmapDto + + @GET("analytics/user_prompts/") + suspend fun userPrompts(): List + + @GET("analytics/user_conversations/") + suspend fun userConversations(): List + + @GET("analytics/company_usage/") + suspend fun companyUsage(): List + + @GET("analytics/admin/") + suspend fun adminAnalytics(): List +} diff --git a/app/src/main/java/ai/hesychia/chat/data/api/Models.kt b/app/src/main/java/ai/hesychia/chat/data/api/Models.kt new file mode 100644 index 0000000..02d7c3b --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/api/Models.kt @@ -0,0 +1,309 @@ +package ai.hesychia.chat.data.api + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class TokenPairDto( + val access: String, + val refresh: String? = null, +) + +@Serializable +data class LoginRequestDto( + val username: String, + val password: String, +) + +@Serializable +data class RefreshRequestDto( + val refresh: String, +) + +@Serializable +data class RefreshTokenBody( + val refresh_token: String, +) + +@Serializable +data class OAuthFlagsDto( + val google: Boolean = false, + val microsoft: Boolean = false, +) + +@Serializable +data class PublicSettingsDto( + val enable_account_registration: Boolean = false, + val oauth: OAuthFlagsDto? = null, +) + +@Serializable +data class CompanyDto( + val id: Int? = null, + val name: String? = null, +) + +@Serializable +data class UserDto( + val id: Int, + val email: String = "", + val first_name: String? = null, + val last_name: String? = null, + val is_company_manager: Boolean = false, + val has_signed_tos: Boolean = false, + val has_usable_password: Boolean = true, + val is_active: Boolean = true, + val role: String? = null, + val company: CompanyDto? = null, +) + +@Serializable +data class SignUpRequestDto( + val email: String, + val password: String, + val first_name: String = "", + val last_name: String = "", + val company_name: String = "", +) + +@Serializable +data class SignUpResponseDto( + val access: String, + val refresh: String? = null, + val needs_checkout: Boolean = true, +) + +@Serializable +data class EmailBody( + val email: String, +) + +@Serializable +data class PasswordResetRequestDto( + val email: String, + val recaptchaToken: String = "", +) + +@Serializable +data class SetPasswordRequestDto( + val password1: String, + val password2: String, +) + +@Serializable +data class PreferencesDto( + val order: Boolean = true, + val use_conversation_context: Boolean = false, +) + +@Serializable +data class PlanFeaturesDto( + val rag: Boolean = false, + val all_future_features: Boolean = false, +) + +@Serializable +data class PlanDto( + val slug: String = "", + val name: String = "", + val store_product_id: String? = null, + val features: PlanFeaturesDto? = null, + val is_selectable: Boolean = false, +) + +@Serializable +data class UsageDto( + val prompts_in_window: Int = 0, + val prompt_quota: Int? = null, + val prompts_remaining: Int? = null, + val window_hours: Int = 0, +) + +@Serializable +data class SubscriptionDto( + val plan: PlanDto? = null, + val status: String = "", + val source: String = "", + val needs_checkout: Boolean = false, + val usage: UsageDto? = null, + val store_account_token: String? = null, + val current_period_end: String? = null, +) { + val hasRag: Boolean + get() = plan?.features?.rag == true || plan?.features?.all_future_features == true +} + +@Serializable +data class InvoiceDto( + val id: Int? = null, + val number: String? = null, + val status: String? = null, + val total: String? = null, + val amount_due: String? = null, + val created: String? = null, +) + +@Serializable +data class PaymentDto( + val id: Int? = null, + val status: String? = null, + val amount: String? = null, + val created: String? = null, +) + +@Serializable +data class StoreConfirmRequestDto( + val store: String, + val product_id: String, + val transaction_id: String = "", + val purchase_token: String = "", + val signed_transaction: String = "", +) + +@Serializable +data class CompanyUserUpdateDto( + val email: String, + val field: String, + val value: String, +) + +@Serializable +data class ConversationDto( + val id: Int, + val title: String? = null, +) + +@Serializable +data class ConversationIdBody( + val conversation_id: Int, +) + +@Serializable +data class CitationDto( + val index: Int = 0, + val title: String = "", + val url: String = "", +) + +@Serializable +data class FeedbackRatingDto( + val rating: String? = null, + val reason: String? = null, + val comment: String? = null, +) + +@Serializable +data class PromptDto( + val id: Int? = null, + val message: String = "", + val user_created: Boolean = false, + val created_timestamp: String? = null, + val citations: List = emptyList(), + val feedback: FeedbackRatingDto? = null, + val file_type: String? = null, + val image: String? = null, +) + +@Serializable +data class PromptFeedbackRequestDto( + val prompt_id: Int, + val rating: String, + val reason: String? = null, + val comment: String? = null, +) + +@Serializable +data class DocumentDto( + val id: Int, + val file: String = "", + val created: String = "", + val active: Boolean = true, + val processed: Boolean = false, +) + +@Serializable +data class DocumentPageDto( + val count: Int = 0, + val results: List = emptyList(), +) + +@Serializable +data class ActiveBody( + val active: Boolean, +) + +@Serializable +data class DriveConnectionDto( + val id: Int, + val provider: String = "", + val kind: String? = null, + val external_account_email: String? = null, + val last_sync_status: String? = null, + val last_sync_error: String? = null, + val selected_resource_labels: List = emptyList(), +) + +@Serializable +data class DriveResourceSelectionDto( + val resource_ids: List, + val resource_labels: List, +) + +@Serializable +data class DriveSyncResponseDto( + val connection: DriveConnectionDto? = null, +) + +@Serializable +data class FeedbackDto( + val id: Int? = null, + val title: String = "", + val text: String = "", + val status: String = "", + val category: String = "", +) + +@Serializable +data class FeedbackCreateDto( + val title: String, + val text: String, + val category: String, +) + +@Serializable +data class MonthSeriesDto( + val month: String = "", + val you: Int = 0, + val others: Int = 0, + val all: Int = 0, + val used: Int = 0, + val not_used: Int = 0, + val avg: Double? = null, + val median: Double? = null, +) + +@Serializable +data class HeatmapPeakDto( + val day: String = "", + val hour: Int = 0, + val count: Int = 0, +) + +@Serializable +data class HeatmapDto( + val tz: String = "", + val total: Int = 0, + val max: Int = 0, + val days: List = emptyList(), + val hours: List = emptyList(), + val matrix: List> = emptyList(), + val most_active_day: String? = null, + val most_active_hour: Int? = null, + val peak_cell: HeatmapPeakDto? = null, +) + +/** Loose error body. Backend mixes `detail` strings and field arrays. */ +@Serializable +data class ErrorEnvelope( + val detail: JsonElement? = null, +) diff --git a/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt b/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt new file mode 100644 index 0000000..f9aa76a --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/billing/StoreBilling.kt @@ -0,0 +1,283 @@ +package ai.hesychia.chat.data.billing + +import android.app.Activity +import android.content.Context +import ai.hesychia.chat.BuildConfig +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.StoreConfirmRequestDto +import ai.hesychia.chat.data.api.apiMessage +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams +import com.revenuecat.purchases.CustomerInfo +import com.revenuecat.purchases.Purchases +import com.revenuecat.purchases.PurchasesConfiguration +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.interfaces.LogInCallback +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Play purchase sheet uses BillingClient so the backend gets a real + * purchase token and the user's store account id. RevenueCat still + * identifies the signed-in user when an API key is configured. + * Entitlement is granted only after `POST /monetization/store/confirm/`. + */ +@Singleton +class StoreBilling @Inject constructor( + @ApplicationContext private val context: Context, + private val api: HesychiaApi, +) : PurchasesUpdatedListener { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var billingClient: BillingClient? = null + private var pendingProductId: String = "" + private var pendingError: ((String) -> Unit)? = null + private var pendingDone: (() -> Unit)? = null + + fun configure() { + val key = BuildConfig.REVENUECAT_GOOGLE_API_KEY + if (key.isBlank() || Purchases.isConfigured) return + Purchases.configure(PurchasesConfiguration.Builder(context, key).build()) + } + + suspend fun logIn(userId: String) { + configure() + if (!Purchases.isConfigured) return + suspendCancellableCoroutine { cont -> + Purchases.sharedInstance.logIn(userId, object : LogInCallback { + override fun onReceived(customerInfo: CustomerInfo, created: Boolean) { + if (cont.isActive) cont.resume(Unit) + } + + override fun onError(error: PurchasesError) { + if (cont.isActive) cont.resume(Unit) + } + }) + } + } + + suspend fun logOut() { + if (!Purchases.isConfigured) return + runCatching { Purchases.sharedInstance.logOut() } + } + + fun purchase( + activity: Activity, + productId: String, + accountToken: String, + onError: (String) -> Unit, + onDone: () -> Unit, + ) { + if (productId.isBlank()) { + onError("No Play product is configured for this plan.") + return + } + connect(onError) { + queryAndLaunch(activity, productId, accountToken, onError, onDone) + } + } + + suspend fun restore() { + val owned = suspendCancellableCoroutine> { cont -> + connect( + onError = { message -> + if (cont.isActive) cont.resumeWithException(IllegalStateException(message)) + }, + ) { + val client = billingClient + if (client == null || !client.isReady) { + if (cont.isActive) cont.resumeWithException(IllegalStateException("Play Billing is not ready.")) + return@connect + } + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + client.queryPurchasesAsync(params) { result, purchases -> + if (!cont.isActive) return@queryPurchasesAsync + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + cont.resumeWithException( + IllegalStateException("Could not restore Play purchases: ${result.debugMessage}"), + ) + return@queryPurchasesAsync + } + cont.resume( + purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }, + ) + } + } + } + if (owned.isEmpty()) throw IllegalStateException("No active store purchases found.") + for (purchase in owned) { + acknowledge(purchase) + confirm(purchase) + } + } + + override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList?) { + val onError = pendingError + val onDone = pendingDone + val productId = pendingProductId + pendingError = null + pendingDone = null + pendingProductId = "" + if (onError == null || onDone == null) return + when (billingResult.responseCode) { + BillingClient.BillingResponseCode.USER_CANCELED -> onError("Purchase cancelled") + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> queryOwned(productId, onError, onDone) + BillingClient.BillingResponseCode.OK -> { + val match = purchases?.firstOrNull { purchase -> + purchase.purchaseState == Purchase.PurchaseState.PURCHASED && + (productId.isBlank() || purchase.products.contains(productId)) + } + if (match == null) onError("Play purchase did not complete") + else finish(match, onError, onDone) + } + else -> onError("Play purchase failed: ${billingResult.debugMessage}") + } + } + + private fun queryAndLaunch( + activity: Activity, + productId: String, + accountToken: String, + onError: (String) -> Unit, + onDone: () -> Unit, + ) { + val client = billingClient ?: return onError("Play Billing is not ready.") + val params = QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId(productId) + .setProductType(BillingClient.ProductType.SUBS) + .build(), + ), + ) + .build() + client.queryProductDetailsAsync(params) { billingResult, productDetailsList -> + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { + onError("Could not load Play product: ${billingResult.debugMessage}") + return@queryProductDetailsAsync + } + val details = productDetailsList.firstOrNull() + if (details == null) { + onError("Play product was not found: $productId") + return@queryProductDetailsAsync + } + val offerToken = details.subscriptionOfferDetails?.firstOrNull()?.offerToken + if (offerToken.isNullOrBlank()) { + onError("Play product has no subscription offer") + return@queryProductDetailsAsync + } + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(details) + .setOfferToken(offerToken) + .build() + val flow = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productParams)) + if (accountToken.isNotBlank()) flow.setObfuscatedAccountId(accountToken) + activity.runOnUiThread { + pendingProductId = productId + pendingError = onError + pendingDone = onDone + val launched = client.launchBillingFlow(activity, flow.build()) + if (launched.responseCode != BillingClient.BillingResponseCode.OK) { + pendingError = null + pendingDone = null + pendingProductId = "" + onError("Could not open Play purchase sheet: ${launched.debugMessage}") + } + } + } + } + + private fun queryOwned(productId: String, onError: (String) -> Unit, onDone: () -> Unit) { + val client = billingClient ?: return onError("Play Billing is not ready.") + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + client.queryPurchasesAsync(params) { billingResult, purchases -> + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { + onError("Could not restore Play purchases: ${billingResult.debugMessage}") + return@queryPurchasesAsync + } + val match = purchases.firstOrNull { purchase -> + purchase.purchaseState == Purchase.PurchaseState.PURCHASED && + (productId.isBlank() || purchase.products.contains(productId)) + } + if (match == null) onError("No active Play purchase for $productId") + else finish(match, onError, onDone) + } + } + + private fun finish(purchase: Purchase, onError: (String) -> Unit, onDone: () -> Unit) { + acknowledge(purchase) + scope.launch { + runCatching { confirm(purchase) } + .onSuccess { onDone() } + .onFailure { error -> onError(error.apiMessage("Could not confirm purchase")) } + } + } + + private suspend fun confirm(purchase: Purchase) { + val productId = purchase.products.firstOrNull().orEmpty() + if (productId.isBlank()) error("Play purchase did not include a product.") + api.confirmStore( + StoreConfirmRequestDto( + store = "play_store", + product_id = productId, + transaction_id = purchase.orderId ?: purchase.purchaseToken, + purchase_token = purchase.purchaseToken, + ), + ) + } + + private fun acknowledge(purchase: Purchase) { + val client = billingClient ?: return + if (purchase.isAcknowledged) return + val params = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + client.acknowledgePurchase(params) { } + } + + private fun connect(onError: (String) -> Unit, whenReady: () -> Unit) { + val client = billingClient ?: BillingClient.newBuilder(context) + .setListener(this) + .enablePendingPurchases( + PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(), + ) + .build() + .also { billingClient = it } + if (client.isReady) { + whenReady() + return + } + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + whenReady() + } else { + onError("Play Billing setup failed: ${billingResult.debugMessage}") + } + } + + override fun onBillingServiceDisconnected() = Unit + }) + } +} diff --git a/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt b/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt new file mode 100644 index 0000000..1e15f36 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/chat/ChatSocket.kt @@ -0,0 +1,62 @@ +package ai.hesychia.chat.data.chat + +import javax.inject.Inject +import javax.inject.Singleton +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +enum class SocketStatus { Connecting, Connected, Disconnected } + +@Singleton +class ChatSocket @Inject constructor( + private val client: OkHttpClient, +) { + private var socket: WebSocket? = null + + var onStatus: ((SocketStatus) -> Unit)? = null + var onText: ((String) -> Unit)? = null + + @Synchronized + fun connect(url: String) { + close() + onStatus?.invoke(SocketStatus.Connecting) + val request = Request.Builder().url(url).build() + socket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + onStatus?.invoke(SocketStatus.Connected) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + onText?.invoke(text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + onStatus?.invoke(SocketStatus.Disconnected) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + onStatus?.invoke(SocketStatus.Disconnected) + } + }) + } + + fun send(payload: String): Boolean = socket?.send(payload) == true + + @Synchronized + fun close() { + socket?.close(1000, "bye") + socket = null + } + + fun ping(email: String?) { + val safe = email?.replace("\"", "") ?: "" + socket?.send("""{"type":"ping","email":"$safe"}""") + } +} diff --git a/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt b/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt new file mode 100644 index 0000000..f0c222a --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/chat/WsFrames.kt @@ -0,0 +1,74 @@ +package ai.hesychia.chat.data.chat + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +sealed interface IncomingFrame { + data object ConversationIdMarker : IncomingFrame + data object StreamStart : IncomingFrame + data object StreamEnd : IncomingFrame + data object Ignore : IncomingFrame + data class Text(val value: String) : IncomingFrame + data class Status(val stage: String, val label: String, val detail: String?) : IncomingFrame + data class Citations(val titles: List) : IncomingFrame + data class Error(val content: String) : IncomingFrame +} + +private val frameJson = Json { ignoreUnknownKeys = true; isLenient = true } + +fun classifyIncoming(raw: String): IncomingFrame { + val text = raw.trim() + return when (text) { + "CONVERSATION_ID" -> IncomingFrame.ConversationIdMarker + "START_OF_THE_STREAM_ENDER_GAME_42" -> IncomingFrame.StreamStart + "END_OF_THE_STREAM_ENDER_GAME_42" -> IncomingFrame.StreamEnd + else -> classifyJsonOrText(text) + } +} + +private fun classifyJsonOrText(text: String): IncomingFrame { + if (!text.startsWith("{")) return IncomingFrame.Text(text) + val obj = runCatching { frameJson.parseToJsonElement(text) as? JsonObject }.getOrNull() + ?: return IncomingFrame.Text(text) + val type = obj["type"]?.jsonPrimitive?.contentOrNull + if (obj["v"] != null && type != null) { + return when (type) { + "status" -> { + val data = obj["data"] as? JsonObject + val stage = data?.get("stage")?.jsonPrimitive?.contentOrNull + val label = data?.get("label")?.jsonPrimitive?.contentOrNull + if (stage.isNullOrBlank() || label == null) IncomingFrame.Ignore + else IncomingFrame.Status( + stage = stage, + label = label, + detail = data["detail"]?.jsonPrimitive?.contentOrNull, + ) + } + "citations" -> { + val titles = (obj["data"] as? JsonArray).orEmpty().mapNotNull { item -> + (item as? JsonObject)?.get("title")?.jsonPrimitive?.contentOrNull + } + IncomingFrame.Citations(titles) + } + "error" -> IncomingFrame.Error( + obj["content"]?.jsonPrimitive?.contentOrNull ?: "Chat request failed", + ) + else -> IncomingFrame.Ignore + } + } + if (type == "error") { + return IncomingFrame.Error(obj["content"]?.jsonPrimitive?.contentOrNull ?: "Chat request failed") + } + if (type == "ping") return IncomingFrame.Ignore + obj["content"]?.jsonPrimitive?.contentOrNull?.let { content -> + if (type == null) return IncomingFrame.Error(content) + } + return IncomingFrame.Text(text) +} + +fun conversationIdOrNull(text: String): Int? = text.trim().toIntOrNull() + ?: text.trim().toDoubleOrNull()?.toInt() diff --git a/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt b/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt new file mode 100644 index 0000000..9e3e062 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/network/NetworkModule.kt @@ -0,0 +1,162 @@ +package ai.hesychia.chat.data.network + +import ai.hesychia.chat.BuildConfig +import ai.hesychia.chat.data.api.ApiJson +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.RefreshRequestDto +import ai.hesychia.chat.data.session.SessionRepository +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Named +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import okhttp3.Authenticator +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Provides + @Singleton + fun provideJson(): Json = ApiJson + + @Provides + @Singleton + @Named("bare") + fun provideBareClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + @Provides + @Singleton + fun provideAuthInterceptor(sessionRepository: SessionRepository): Interceptor = + Interceptor { chain -> + val token = sessionRepository.cachedAccessToken + ?: runBlocking { sessionRepository.session.first().accessToken } + android.util.Log.d("NetworkAuth", "Outgoing request: ${chain.request().url} | HasToken: ${!token.isNullOrBlank()}") + val request = if (token.isNullOrBlank()) { + chain.request() + } else { + chain.request().newBuilder() + .header("Authorization", "JWT $token") + .build() + } + val response = chain.proceed(request) + android.util.Log.d("NetworkAuth", "Response for ${request.url}: status=${response.code}") + response + } + + @Provides + @Singleton + fun provideAuthenticator( + sessionRepository: SessionRepository, + @Named("bare") bare: OkHttpClient, + json: Json, + ): Authenticator = TokenAuthenticator(sessionRepository, bare, json) + + @Provides + @Singleton + fun provideOkHttp( + authInterceptor: Interceptor, + authenticator: Authenticator, + ): OkHttpClient { + val logging = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + return OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .addInterceptor(authInterceptor) + .authenticator(authenticator) + .addInterceptor(logging) + .pingInterval(25, TimeUnit.SECONDS) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit = + Retrofit.Builder() + .baseUrl(BuildConfig.REST_API_BASE_URL) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @Provides + @Singleton + fun provideApi(retrofit: Retrofit): HesychiaApi = retrofit.create(HesychiaApi::class.java) +} + +/** + * On 401, exchange the refresh token once and retry. Skips the auth endpoints + * so a bad password does not recurse. + */ +class TokenAuthenticator( + private val sessionRepository: SessionRepository, + private val bare: OkHttpClient, + private val json: Json, +) : Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + if (responseCount(response) >= 2) return null + val path = response.request.url.encodedPath + if (path.contains("token/obtain") || path.contains("token/refresh") || path.contains("user/create")) { + return null + } + val refresh = sessionRepository.cachedRefreshToken + ?: runBlocking { sessionRepository.session.first().refreshToken } + if (refresh.isNullOrBlank()) return null + val refreshed = runCatching { refreshBlocking(refresh) }.getOrNull() ?: return null + runBlocking { + sessionRepository.saveTokens( + access = refreshed.access, + refresh = refreshed.refresh ?: refresh, + ) + } + return response.request.newBuilder() + .header("Authorization", "JWT ${refreshed.access}") + .build() + } + + private fun refreshBlocking(refresh: String): ai.hesychia.chat.data.api.TokenPairDto { + val body = json.encodeToString(RefreshRequestDto.serializer(), RefreshRequestDto(refresh)) + val request = Request.Builder() + .url(BuildConfig.REST_API_BASE_URL + "token/refresh/") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + bare.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("refresh failed ${response.code}") + val raw = response.body?.string().orEmpty() + return json.decodeFromString(ai.hesychia.chat.data.api.TokenPairDto.serializer(), raw) + } + } + + private fun responseCount(response: Response): Int { + var count = 1 + var prior = response.priorResponse + while (prior != null) { + count += 1 + prior = prior.priorResponse + } + return count + } +} diff --git a/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt b/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt new file mode 100644 index 0000000..170d25c --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/data/session/SessionRepository.kt @@ -0,0 +1,146 @@ +package ai.hesychia.chat.data.session + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import ai.hesychia.chat.data.api.UserDto +import ai.hesychia.chat.ui.theme.ThemeColor +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +private val Context.sessionDataStore: DataStore by preferencesDataStore( + name = "hesychia_session", +) + +data class SessionSnapshot( + /** False until DataStore has emitted. Avoids flashing Sign in over a saved session. */ + val hydrated: Boolean = false, + val accessToken: String? = null, + val refreshToken: String? = null, + val userId: String? = null, + val email: String? = null, + val firstName: String? = null, + val lastName: String? = null, + val isCompanyManager: Boolean = false, + val hasSignedTos: Boolean = false, + val hasCompany: Boolean = false, + val role: String? = null, + val darkMode: Boolean? = null, + val themeColor: ThemeColor = ThemeColor.Blue, +) { + val isAuthenticated: Boolean get() = !accessToken.isNullOrBlank() +} + +@Singleton +class SessionRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val accessKey = stringPreferencesKey("access_token") + private val refreshKey = stringPreferencesKey("refresh_token") + private val userIdKey = stringPreferencesKey("user_id") + private val emailKey = stringPreferencesKey("email") + private val firstNameKey = stringPreferencesKey("first_name") + private val lastNameKey = stringPreferencesKey("last_name") + private val managerKey = booleanPreferencesKey("is_company_manager") + private val tosKey = booleanPreferencesKey("has_signed_tos") + private val companyKey = booleanPreferencesKey("has_company") + private val roleKey = stringPreferencesKey("role") + private val darkModeKey = booleanPreferencesKey("dark_mode") + private val themeColorKey = stringPreferencesKey("theme_color") + + @Volatile + var cachedAccessToken: String? = null + private set + + @Volatile + var cachedRefreshToken: String? = null + private set + + init { + runCatching { + val prefs = runBlocking { context.sessionDataStore.data.first() } + cachedAccessToken = prefs[accessKey] + cachedRefreshToken = prefs[refreshKey] + } + } + + val session: Flow = context.sessionDataStore.data.map { prefs -> + SessionSnapshot( + hydrated = true, + accessToken = prefs[accessKey], + refreshToken = prefs[refreshKey], + userId = prefs[userIdKey], + email = prefs[emailKey], + firstName = prefs[firstNameKey], + lastName = prefs[lastNameKey], + isCompanyManager = prefs[managerKey] ?: false, + hasSignedTos = prefs[tosKey] ?: false, + hasCompany = prefs[companyKey] ?: false, + role = prefs[roleKey], + darkMode = prefs[darkModeKey], + themeColor = prefs[themeColorKey]?.let { runCatching { ThemeColor.valueOf(it) }.getOrNull() } + ?: ThemeColor.Blue, + ) + } + + suspend fun saveTokens(access: String, refresh: String?) { + cachedAccessToken = access + if (refresh != null) cachedRefreshToken = refresh + context.sessionDataStore.edit { prefs -> + prefs[accessKey] = access + if (refresh != null) prefs[refreshKey] = refresh + } + } + + suspend fun saveUser(user: UserDto) { + context.sessionDataStore.edit { prefs -> + prefs[userIdKey] = user.id.toString() + prefs[emailKey] = user.email + if (user.first_name != null) prefs[firstNameKey] = user.first_name else prefs.remove(firstNameKey) + if (user.last_name != null) prefs[lastNameKey] = user.last_name else prefs.remove(lastNameKey) + prefs[managerKey] = user.is_company_manager + prefs[tosKey] = user.has_signed_tos + prefs[companyKey] = user.company?.id != null + if (user.role != null) prefs[roleKey] = user.role else prefs.remove(roleKey) + } + } + + suspend fun markTosSigned() { + context.sessionDataStore.edit { it[tosKey] = true } + } + + /** Drops the session. Theme preferences stay. */ + suspend fun clearAuth() { + cachedAccessToken = null + cachedRefreshToken = null + context.sessionDataStore.edit { prefs -> + prefs.remove(accessKey) + prefs.remove(refreshKey) + prefs.remove(userIdKey) + prefs.remove(emailKey) + prefs.remove(firstNameKey) + prefs.remove(lastNameKey) + prefs.remove(managerKey) + prefs.remove(tosKey) + prefs.remove(companyKey) + prefs.remove(roleKey) + } + } + + suspend fun setDarkMode(enabled: Boolean) { + context.sessionDataStore.edit { it[darkModeKey] = enabled } + } + + suspend fun setThemeColor(color: ThemeColor) { + context.sessionDataStore.edit { it[themeColorKey] = color.name } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt b/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt new file mode 100644 index 0000000..a848bd0 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/navigation/HesychiaNavHost.kt @@ -0,0 +1,126 @@ +package ai.hesychia.chat.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import ai.hesychia.chat.ui.account.AccountScreen +import ai.hesychia.chat.ui.analytics.AnalyticsScreen +import ai.hesychia.chat.ui.auth.PasswordResetConfirmationScreen +import ai.hesychia.chat.ui.auth.PasswordResetScreen +import ai.hesychia.chat.ui.auth.SetPasswordScreen +import ai.hesychia.chat.ui.auth.SignInScreen +import ai.hesychia.chat.ui.auth.SignUpScreen +import ai.hesychia.chat.ui.auth.TermsScreen +import ai.hesychia.chat.ui.chat.ChatScreen +import ai.hesychia.chat.ui.documents.DocumentsScreen +import ai.hesychia.chat.ui.feedback.FeedbackScreen + +object Routes { + const val SignIn = "signin" + const val SignUp = "signup" + const val PasswordReset = "password_reset" + const val PasswordResetConfirmation = "password_reset_confirmation" + const val SetPassword = "set_password" + const val Terms = "terms" + const val Chat = "chat" + const val Account = "account" + const val Documents = "documents" + const val Analytics = "analytics" + const val Feedback = "feedback" +} + +private val authRoutes = setOf( + Routes.SignIn, + Routes.SignUp, + Routes.PasswordReset, + Routes.PasswordResetConfirmation, + Routes.SetPassword, +) + +@Composable +fun HesychiaNavHost( + startDestination: String, + hasRag: Boolean, + driveJustConnected: Boolean, + onSignOut: () -> Unit, + onSessionChanged: () -> Unit = {}, + navController: NavHostController, +) { + fun go(route: String) { + navController.navigate(route) { + if (route == Routes.SignIn || route == Routes.Chat || route == Routes.Terms || route == Routes.Account) { + popUpTo(0) { inclusive = true } + } + launchSingleTop = true + } + } + + NavHost(navController = navController, startDestination = startDestination) { + composable(Routes.SignIn) { + SignInScreen( + onDone = { route -> + onSessionChanged() + go(route) + }, + onNavigateSignUp = { navController.navigate(Routes.SignUp) }, + onForgotPassword = { navController.navigate(Routes.PasswordReset) }, + ) + } + composable(Routes.SignUp) { + SignUpScreen( + onDone = { route -> + onSessionChanged() + go(route) + }, + onNavigateSignIn = { navController.popBackStack() }, + ) + } + composable(Routes.PasswordReset) { + PasswordResetScreen( + onSent = { navController.navigate(Routes.PasswordResetConfirmation) }, + onBack = { go(Routes.SignIn) }, + ) + } + composable(Routes.PasswordResetConfirmation) { + PasswordResetConfirmationScreen(onBack = { go(Routes.SignIn) }) + } + composable( + route = "${Routes.SetPassword}?slug={slug}", + arguments = listOf(navArgument("slug") { type = NavType.StringType; defaultValue = "" }), + ) { entry -> + SetPasswordScreen( + slug = entry.arguments?.getString("slug").orEmpty(), + onDone = { go(Routes.SignIn) }, + ) + } + composable(Routes.Terms) { + TermsScreen(onAccepted = { + onSessionChanged() + go(Routes.Chat) + }) + } + composable(Routes.Chat) { + ChatScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut) + } + composable(Routes.Account) { + AccountScreen(hasRag = hasRag, onNavigate = ::go, onSignedOut = { go(Routes.SignIn) }) + } + composable(Routes.Documents) { + DocumentsScreen( + hasRag = hasRag, + driveJustConnected = driveJustConnected, + onNavigate = ::go, + onSignOut = onSignOut, + ) + } + composable(Routes.Analytics) { + AnalyticsScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut) + } + composable(Routes.Feedback) { + FeedbackScreen(hasRag = hasRag, onNavigate = ::go, onSignOut = onSignOut) + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt new file mode 100644 index 0000000..444ea0c --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/account/AccountScreen.kt @@ -0,0 +1,181 @@ +package ai.hesychia.chat.ui.account + +import android.app.Activity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ai.hesychia.chat.navigation.Routes +import ai.hesychia.chat.ui.shell.AppScaffold +import ai.hesychia.chat.ui.theme.ThemeColor + +@Composable +fun AccountScreen( + hasRag: Boolean, + onNavigate: (String) -> Unit, + onSignedOut: () -> Unit, + viewModel: AccountViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var invite by remember { mutableStateOf("") } + var confirmDelete by remember { mutableStateOf(false) } + AppScaffold("Account", Routes.Account, hasRag, onNavigate, onSignedOut) { padding -> + if (state.loading) { + Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + } + return@AppScaffold + } + Column( + Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + state.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Theme", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Dark mode", modifier = Modifier.weight(1f)) + Switch( + checked = state.session.darkMode ?: androidx.compose.foundation.isSystemInDarkTheme(), + onCheckedChange = viewModel::setDark, + ) + } + Text("Palette") + ThemeColor.entries.forEach { color -> + TextButton(onClick = { viewModel.setColor(color) }) { + Text(if (state.session.themeColor == color) "• ${color.name}" else color.name) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Use conversation context", modifier = Modifier.weight(1f)) + Switch(checked = state.prefs.use_conversation_context, onCheckedChange = viewModel::setContext) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Newest conversations first", modifier = Modifier.weight(1f)) + Switch(checked = state.prefs.order, onCheckedChange = viewModel::setOrder) + } + } + } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Usage", style = MaterialTheme.typography.titleMedium) + val usage = state.subscription?.usage + val quota = usage?.prompt_quota + Text( + if (quota == null) "Prompt quota: —" + else "Prompts ${usage.prompts_in_window} / $quota this window", + ) + Text(state.session.email ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Billing", style = MaterialTheme.typography.titleMedium) + val sub = state.subscription + Text("Plan: ${sub?.plan?.name ?: "None"}") + Text("Status: ${sub?.status ?: "—"} · source ${sub?.source ?: "—"}") + Text("Subscribe in the Play Store. Stripe checkout stays on the web.") + val activity = LocalContext.current as? Activity + val accountToken = sub?.store_account_token.orEmpty() + state.plans.filter { it.is_selectable && !it.store_product_id.isNullOrBlank() }.forEach { plan -> + Button( + onClick = { + val host = activity ?: return@Button + viewModel.purchase(host, plan.store_product_id.orEmpty(), accountToken) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Subscribe to ${plan.name}") + } + } + Text("Invoices: ${state.invoices.size} · Payments: ${state.payments.size}") + OutlinedButton(onClick = viewModel::restore, modifier = Modifier.fillMaxWidth()) { + Text("Restore purchases") + } + } + } + if (state.session.isCompanyManager) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Company accounts", style = MaterialTheme.typography.titleMedium) + state.companyUsers.forEach { user -> + Text(user.email) + Row { + TextButton(onClick = { + viewModel.updateUser(user.email, "is_active", (!user.is_active).toString()) + }) { Text(if (user.is_active) "Deactivate" else "Activate") } + if (user.email != state.session.email) { + TextButton(onClick = { + viewModel.updateUser(user.email, "company_manager", (!user.is_company_manager).toString()) + }) { Text(if (user.is_company_manager) "Remove manager" else "Make manager") } + } + TextButton(onClick = { viewModel.updateUser(user.email, "delete", "") }) { Text("Delete") } + } + } + OutlinedTextField(invite, { invite = it }, label = { Text("Invite email") }, modifier = Modifier.fillMaxWidth()) + Button(onClick = { viewModel.invite(invite.trim()); invite = "" }, enabled = invite.contains("@")) { + Text("Invite") + } + } + } + } else { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Account information", style = MaterialTheme.typography.titleMedium) + Text("Account and prompt information will be available soon") + } + } + } + OutlinedButton(onClick = { confirmDelete = true }, modifier = Modifier.fillMaxWidth()) { + Text("Delete account") + } + } + } + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text("Delete account") }, + text = { Text("Permanently deactivate your account. Conversations are hidden and you will be signed out. This cannot be undone from the app.") }, + confirmButton = { + TextButton(onClick = { + confirmDelete = false + viewModel.deleteAccount(onSignedOut) + }) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, + ) + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt new file mode 100644 index 0000000..c47486b --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/account/AccountViewModel.kt @@ -0,0 +1,167 @@ +package ai.hesychia.chat.ui.account + +import android.app.Activity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ai.hesychia.chat.data.api.CompanyUserUpdateDto +import ai.hesychia.chat.data.api.EmailBody +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.InvoiceDto +import ai.hesychia.chat.data.api.PaymentDto +import ai.hesychia.chat.data.api.PlanDto +import ai.hesychia.chat.data.api.PreferencesDto +import ai.hesychia.chat.data.api.RefreshTokenBody +import ai.hesychia.chat.data.api.SubscriptionDto +import ai.hesychia.chat.data.api.UserDto +import ai.hesychia.chat.data.api.apiMessage +import ai.hesychia.chat.data.billing.StoreBilling +import ai.hesychia.chat.data.session.SessionRepository +import ai.hesychia.chat.data.session.SessionSnapshot +import ai.hesychia.chat.ui.theme.ThemeColor +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class AccountUiState( + val loading: Boolean = true, + val session: SessionSnapshot = SessionSnapshot(hydrated = true), + val subscription: SubscriptionDto? = null, + val invoices: List = emptyList(), + val payments: List = emptyList(), + val plans: List = emptyList(), + val prefs: PreferencesDto = PreferencesDto(), + val companyUsers: List = emptyList(), + val notice: String? = null, + val error: String? = null, +) + +@HiltViewModel +class AccountViewModel @Inject constructor( + private val api: HesychiaApi, + private val sessionRepository: SessionRepository, + private val storeBilling: StoreBilling, +) : ViewModel() { + private val _state = MutableStateFlow(AccountUiState()) + val state: StateFlow = _state + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null) } + val session = sessionRepository.session.first { it.hydrated } + val sub = runCatching { api.subscription() }.getOrNull() + val invoices = runCatching { api.invoices() }.getOrDefault(emptyList()) + val payments = runCatching { api.payments() }.getOrDefault(emptyList()) + val plans = runCatching { api.plans() }.getOrDefault(emptyList()) + val prefs = runCatching { api.preferences() }.getOrDefault(PreferencesDto()) + val users = if (session.isCompanyManager) { + runCatching { api.companyUsers() }.getOrDefault(emptyList()) + } else { + emptyList() + } + _state.value = AccountUiState( + loading = false, + session = session, + subscription = sub, + invoices = invoices, + payments = payments, + plans = plans, + prefs = prefs, + companyUsers = users, + ) + } + } + + fun setDark(enabled: Boolean) { + viewModelScope.launch { sessionRepository.setDarkMode(enabled) } + _state.update { it.copy(session = it.session.copy(darkMode = enabled)) } + } + + fun setColor(color: ThemeColor) { + viewModelScope.launch { sessionRepository.setThemeColor(color) } + _state.update { it.copy(session = it.session.copy(themeColor = color)) } + } + + fun setContext(enabled: Boolean) { + viewModelScope.launch { + runCatching { api.savePreferences(PreferencesDto(use_conversation_context = enabled)) } + .onSuccess { saved -> _state.update { it.copy(prefs = saved) } } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save preferences")) } } + } + } + + fun setOrder(newestFirst: Boolean) { + viewModelScope.launch { + runCatching { api.savePreferences(PreferencesDto(order = newestFirst)) } + .onSuccess { saved -> _state.update { it.copy(prefs = saved) } } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save preferences")) } } + } + } + + fun invite(email: String) { + viewModelScope.launch { + runCatching { api.invite(EmailBody(email)) } + .onSuccess { + _state.update { it.copy(notice = "Invite sent") } + refresh() + } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not invite that user")) } } + } + } + + fun updateUser(email: String, field: String, value: String) { + viewModelScope.launch { + val call = if (field == "delete") { + runCatching { api.deleteCompanyUser(EmailBody(email)) } + } else { + runCatching { api.updateCompanyUser(CompanyUserUpdateDto(email, field, value)) } + } + call.onSuccess { refresh() } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not update that user")) } } + } + } + + fun purchase(activity: Activity, productId: String, accountToken: String) { + storeBilling.purchase( + activity = activity, + productId = productId, + accountToken = accountToken, + onError = { message -> _state.update { it.copy(error = message, notice = null) } }, + onDone = { + _state.update { it.copy(notice = "Purchase confirmed", error = null) } + refresh() + }, + ) + } + + fun restore() { + viewModelScope.launch { + runCatching { storeBilling.restore() } + .onSuccess { + _state.update { it.copy(notice = "Purchases restored") } + refresh() + } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not restore purchases")) } } + } + } + + fun deleteAccount(onDone: () -> Unit) { + viewModelScope.launch { + val refresh = sessionRepository.session.first().refreshToken + runCatching { + api.deleteUser(RefreshTokenBody(refresh.orEmpty())) + if (!refresh.isNullOrBlank()) runCatching { api.blacklist(RefreshTokenBody(refresh)) } + storeBilling.logOut() + sessionRepository.clearAuth() + }.onSuccess { onDone() } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not delete account. Try again.")) } } + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt new file mode 100644 index 0000000..19aefbf --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/analytics/AnalyticsScreen.kt @@ -0,0 +1,162 @@ +package ai.hesychia.chat.ui.analytics + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import ai.hesychia.chat.data.api.HeatmapDto +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.MonthSeriesDto +import ai.hesychia.chat.data.session.SessionRepository +import ai.hesychia.chat.navigation.Routes +import ai.hesychia.chat.ui.shell.AppScaffold +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.TimeZone +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +data class AnalyticsUiState( + val loading: Boolean = true, + val showCompany: Boolean = false, + val isManager: Boolean = false, + val showOps: Boolean = false, + val heatmap: HeatmapDto? = null, + val prompts: List = emptyList(), + val conversations: List = emptyList(), + val company: List = emptyList(), + val admin: List = emptyList(), +) + +@HiltViewModel +class AnalyticsViewModel @Inject constructor( + private val api: HesychiaApi, + private val sessionRepository: SessionRepository, +) : ViewModel() { + private val _state = MutableStateFlow(AnalyticsUiState()) + val state: StateFlow = _state + + init { + viewModelScope.launch { + val session = sessionRepository.session.first { it.hydrated } + val showOps = isOpsAdmin(session.email, session.role) + val showCompany = session.isCompanyManager || session.hasCompany + val tz = TimeZone.getDefault().id + _state.value = AnalyticsUiState( + loading = false, + showCompany = showCompany, + isManager = session.isCompanyManager, + showOps = showOps, + heatmap = runCatching { api.promptHeatmap(tz) }.getOrNull(), + prompts = runCatching { api.userPrompts() }.getOrDefault(emptyList()), + conversations = runCatching { api.userConversations() }.getOrDefault(emptyList()), + company = if (session.isCompanyManager) runCatching { api.companyUsage() }.getOrDefault(emptyList()) else emptyList(), + admin = if (showOps) runCatching { api.adminAnalytics() }.getOrDefault(emptyList()) else emptyList(), + ) + } + } +} + +private fun isOpsAdmin(email: String?, role: String?): Boolean { + if (role != null && role.lowercase() in setOf("admin", "ops", "staff")) return true + return email == "ryan+admin@aimloperations.com" +} + +@Composable +fun AnalyticsScreen( + hasRag: Boolean, + onNavigate: (String) -> Unit, + onSignOut: () -> Unit, + viewModel: AnalyticsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + AppScaffold("Analytics", Routes.Analytics, hasRag, onNavigate, onSignOut) { padding -> + if (state.loading) { + Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center) { + CircularProgressIndicator() + } + return@AppScaffold + } + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Your activity", style = MaterialTheme.typography.titleLarge) + Text("Personal prompt timing and volume. Other users' private messages are never shown here.") + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Prompt timing", style = MaterialTheme.typography.titleMedium) + val heat = state.heatmap + if (heat == null) { + Text("No timing data.") + } else { + Text("${heat.total} prompts · busiest ${heat.most_active_day ?: "—"} ${heat.most_active_hour ?: ""}:00") + heat.peak_cell?.let { Text("Peak ${it.day} ${it.hour}:00 · ${it.count}") } + } + } + } + SeriesCard("Conversation volume", state.conversations) { "${it.month}: ${it.all}" } + SeriesCard("Prompt volume", state.prompts) { "${it.month}: you ${it.you} · others ${it.others} · all ${it.all}" } + if (state.showCompany) { + Text("Company", style = MaterialTheme.typography.titleLarge) + Text("Aggregated seat and usage trends for your workspace. No message content.") + if (state.isManager) { + SeriesCard("Team seat activity", state.company) { "${it.month}: used ${it.used} · not used ${it.not_used}" } + } else { + Card(Modifier.fillMaxWidth()) { + Text( + "Detailed team seat charts are available to company managers.", + modifier = Modifier.padding(16.dp), + ) + } + } + } + if (state.showOps) { + Text("Operations", style = MaterialTheme.typography.titleLarge) + SeriesCard("Response times", state.admin) { + "${it.month}: avg ${it.avg ?: "—"} · median ${it.median ?: "—"}" + } + } + } + } +} + +@Composable +private fun SeriesCard(title: String, rows: List, line: (MonthSeriesDto) -> String) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + if (rows.isEmpty()) { + Text("No data.") + } else { + val max = rows.maxOf { maxOf(it.you, it.others, it.all, it.used, it.not_used, (it.avg ?: 0.0).toInt()) }.coerceAtLeast(1) + rows.forEach { row -> + Text(line(row), style = MaterialTheme.typography.bodySmall) + LinearProgressIndicator( + progress = { row.all.coerceAtLeast(row.used).toFloat() / max }, + modifier = Modifier.fillMaxWidth().height(6.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt new file mode 100644 index 0000000..ab8590a --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthScreens.kt @@ -0,0 +1,431 @@ +package ai.hesychia.chat.ui.auth + +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ai.hesychia.chat.R +import kotlinx.coroutines.launch + +@Composable +fun SignInScreen( + onDone: (String) -> Unit, + onNavigateSignUp: () -> Unit, + onForgotPassword: () -> Unit, + viewModel: SignInViewModel = hiltViewModel(), +) { + val public by viewModel.publicState.collectAsStateWithLifecycle() + val context = LocalContext.current + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + AuthColumn { + Text("Hesychia.ai", style = MaterialTheme.typography.headlineMedium) + Text("Sign In", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email Address") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = { + scope.launch { + loading = true + error = null + viewModel.signIn(email.trim(), password) + .onSuccess(onDone) + .onFailure { error = it.userMessage("Error retrieving account. Try again") } + loading = false + } + }, + enabled = !loading && email.isNotBlank() && password.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (loading) CircularProgressIndicator(Modifier.height(20.dp)) else Text("Sign In") + } + TextButton(onClick = onForgotPassword) { Text("Forgot Password?") } + if (public.registrationEnabled) { + TextButton(onClick = onNavigateSignUp) { Text("Don't have an account? Sign up") } + if (public.google) { + OutlinedButton( + onClick = { openTab(context, viewModel.oauthUrl("google", "login")) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Continue with Google") } + } + if (public.microsoft) { + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { openTab(context, viewModel.oauthUrl("microsoft", "login")) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Continue with Microsoft") } + } + } + } +} + +@Composable +fun SignUpScreen( + onDone: (String) -> Unit, + onNavigateSignIn: () -> Unit, + viewModel: SignUpViewModel = hiltViewModel(), +) { + val public by viewModel.publicState.collectAsStateWithLifecycle() + val context = LocalContext.current + var email by remember { mutableStateOf("") } + var first by remember { mutableStateOf("") } + var last by remember { mutableStateOf("") } + var company by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirm by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + if (public.loadingSettings) { + AuthColumn { CircularProgressIndicator() } + return + } + if (!public.registrationEnabled) { + AuthColumn { + Text("Sign up", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(12.dp)) + Text("Self-serve registration is not available right now.") + TextButton(onClick = onNavigateSignIn) { Text("Already have an account? Sign in") } + } + return + } + + AuthColumn { + Text("Sign up", style = MaterialTheme.typography.headlineMedium) + Text( + "Create an account, then subscribe with Play billing.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField(email, { email = it }, label = { Text("Email Address") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(first, { first = it }, label = { Text("First name (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(last, { last = it }, label = { Text("Last name (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(company, { company = it }, label = { Text("Company / workspace (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(password, { password = it }, label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(confirm, { confirm = it }, label = { Text("Confirm password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + if (password.length < 8) { + error = "Password must be at least 8 characters" + return@Button + } + if (password != confirm) { + error = "Passwords must match" + return@Button + } + scope.launch { + loading = true + error = null + viewModel.signUp(email.trim(), password, first.trim(), last.trim(), company.trim()) + .onSuccess(onDone) + .onFailure { error = it.userMessage("Could not create account. Try again.") } + loading = false + } + }, + enabled = !loading && email.isNotBlank() && password.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (loading) CircularProgressIndicator(Modifier.height(20.dp)) else Text("Sign up") + } + if (public.google) { + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { openTab(context, viewModel.oauthUrl("google")) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Sign up with Google") } + } + if (public.microsoft) { + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { openTab(context, viewModel.oauthUrl("microsoft")) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Sign up with Microsoft") } + } + TextButton(onClick = onNavigateSignIn) { Text("Already have an account? Sign in") } + } +} + +@Composable +fun PasswordResetScreen( + onSent: () -> Unit, + onBack: () -> Unit, + viewModel: PasswordResetViewModel = hiltViewModel(), +) { + var email by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + AuthColumn { + Text("Reset password", style = MaterialTheme.typography.headlineMedium) + Text( + "Enter your email and we will send a link to set a new password.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + email, + { email = it }, + label = { Text("Email Address") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + scope.launch { + loading = true + error = null + viewModel.requestReset(email.trim()) + .onSuccess { onSent() } + .onFailure { error = it.userMessage("Unable to reset password. Please try again.") } + loading = false + } + }, + enabled = !loading && email.contains("@"), + modifier = Modifier.fillMaxWidth(), + ) { Text(if (loading) "Sending…" else "Reset password") } + TextButton(onClick = onBack) { Text("Back to sign in") } + } +} + +@Composable +fun PasswordResetConfirmationScreen(onBack: () -> Unit) { + AuthColumn { + Text("Check your email", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text("If an account exists for that address, we sent a link to set a new password.", color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = onBack) { Text("Back to sign in") } + } +} + +@Composable +fun SetPasswordScreen( + slug: String, + onDone: () -> Unit, + viewModel: SetPasswordViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var password by remember { mutableStateOf("") } + var confirm by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + LaunchedEffect(slug) { viewModel.check(slug) } + if (state.checking) { + AuthColumn { CircularProgressIndicator() } + return + } + if (!state.linkValid) { + AuthColumn { + Text("This password link is invalid or expired.") + TextButton(onClick = onDone) { Text("Back to sign in") } + } + return + } + AuthColumn { + Text("Set a new password", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(12.dp)) + OutlinedTextField(password, { password = it }, label = { Text("New password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(8.dp)) + OutlinedTextField(confirm, { confirm = it }, label = { Text("Confirm password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + val problem = setPasswordProblem(password, confirm) + if (problem != null) { + error = problem + return@Button + } + scope.launch { + loading = true + viewModel.submit(slug, password) + .onSuccess { onDone() } + .onFailure { error = it.userMessage("Could not set password.") } + loading = false + } + }, + enabled = !loading, + modifier = Modifier.fillMaxWidth(), + ) { Text("Save password") } + } +} + +@Composable +fun TermsScreen( + onAccepted: () -> Unit, + viewModel: TermsViewModel = hiltViewModel(), +) { + var error by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + AuthColumn { + Text("Terms of Service", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text("Acknowledge the Hesychia terms to open the dashboard. The public marketing site is not part of this app.", color = MaterialTheme.colorScheme.onSurfaceVariant) + error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + scope.launch { + loading = true + viewModel.acknowledge() + .onSuccess { onAccepted() } + .onFailure { error = it.userMessage("Could not save acknowledgement.") } + loading = false + } + }, + enabled = !loading, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (loading) "Saving…" else "I agree") } + } +} + +private fun setPasswordProblem(password: String, confirm: String): String? = when { + password.length < 8 -> "Must be at least 8 characters" + password.none { it.isDigit() } -> "Must include a number" + password.none { !it.isLetterOrDigit() } -> "Must include a special character" + password != confirm -> "Passwords must match" + else -> null +} + +@Composable +private fun AuthColumn(content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 480.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(64.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = CircleShape + ), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = R.drawable.hesychia_mark), + contentDescription = "Hesychia Logo", + modifier = Modifier.size(40.dp) + ) + } + Spacer(Modifier.height(16.dp)) + content() + } + } + } +} + +private fun openTab(context: android.content.Context, url: String) { + CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url)) +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt new file mode 100644 index 0000000..b5cb6a1 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/auth/AuthViewModels.kt @@ -0,0 +1,177 @@ +package ai.hesychia.chat.ui.auth + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ai.hesychia.chat.BuildConfig +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.LoginRequestDto +import ai.hesychia.chat.data.api.PasswordResetRequestDto +import ai.hesychia.chat.data.api.PublicSettingsDto +import ai.hesychia.chat.data.api.SetPasswordRequestDto +import ai.hesychia.chat.data.api.SignUpRequestDto +import ai.hesychia.chat.data.api.apiMessage +import ai.hesychia.chat.data.billing.StoreBilling +import ai.hesychia.chat.data.session.SessionRepository +import ai.hesychia.chat.navigation.Routes +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +data class PublicAuthState( + val loadingSettings: Boolean = true, + val registrationEnabled: Boolean = false, + val google: Boolean = false, + val microsoft: Boolean = false, +) + +@HiltViewModel +class SignInViewModel @Inject constructor( + private val api: HesychiaApi, + private val sessionRepository: SessionRepository, + private val storeBilling: StoreBilling, +) : ViewModel() { + private val _public = MutableStateFlow(PublicAuthState()) + val publicState: StateFlow = _public + + init { + viewModelScope.launch { loadSettings() } + } + + private suspend fun loadSettings() { + val settings = runCatching { api.publicSettings() }.getOrElse { PublicSettingsDto() } + _public.value = PublicAuthState( + loadingSettings = false, + registrationEnabled = settings.enable_account_registration, + google = settings.oauth?.google == true, + microsoft = settings.oauth?.microsoft == true, + ) + } + + /** Returns the route to open after a successful password sign-in. */ + suspend fun signIn(email: String, password: String): Result = runCatching { + Log.d("AuthViewModel", "Attempting login for $email") + val tokens = api.login(LoginRequestDto(username = email, password = password)) + Log.d("AuthViewModel", "Login tokens received: access token len=${tokens.access.length}") + sessionRepository.saveTokens(tokens.access, tokens.refresh) + val user = api.currentUser() + Log.d("AuthViewModel", "Current user fetched: id=${user.id}, email=${user.email}") + sessionRepository.saveUser(user) + runCatching { storeBilling.logIn(user.id.toString()) } + if (user.has_signed_tos) Routes.Chat else Routes.Terms + }.onFailure { e -> + Log.e("AuthViewModel", "SignIn failed: ${e.message}", e) + } + + fun oauthUrl(provider: String, intent: String): String { + val base = BuildConfig.REST_API_BASE_URL.trimEnd('/') + return "$base/auth/oauth/$provider/start/?intent=$intent" + } +} + +@HiltViewModel +class SignUpViewModel @Inject constructor( + private val api: HesychiaApi, + private val sessionRepository: SessionRepository, + private val storeBilling: StoreBilling, +) : ViewModel() { + private val _public = MutableStateFlow(PublicAuthState()) + val publicState: StateFlow = _public + + init { + viewModelScope.launch { + val settings = runCatching { api.publicSettings() }.getOrElse { PublicSettingsDto() } + _public.value = PublicAuthState( + loadingSettings = false, + registrationEnabled = settings.enable_account_registration, + google = settings.oauth?.google == true, + microsoft = settings.oauth?.microsoft == true, + ) + } + } + + suspend fun signUp( + email: String, + password: String, + firstName: String, + lastName: String, + company: String, + ): Result = runCatching { + Log.d("AuthViewModel", "Attempting signup for $email") + val created = api.createUser( + SignUpRequestDto( + email = email, + password = password, + first_name = firstName, + last_name = lastName, + company_name = company, + ), + ) + Log.d("AuthViewModel", "Signup user created: access token len=${created.access.length}") + sessionRepository.saveTokens(created.access, created.refresh) + val user = api.currentUser() + Log.d("AuthViewModel", "Current user fetched after signup: id=${user.id}, email=${user.email}") + sessionRepository.saveUser(user) + runCatching { storeBilling.logIn(user.id.toString()) } + if (created.needs_checkout) Routes.Account else if (user.has_signed_tos) Routes.Chat else Routes.Terms + }.onFailure { e -> + Log.e("AuthViewModel", "SignUp failed: ${e.message}", e) + } + + fun oauthUrl(provider: String): String { + val base = BuildConfig.REST_API_BASE_URL.trimEnd('/') + return "$base/auth/oauth/$provider/start/?intent=signup" + } +} + +@HiltViewModel +class PasswordResetViewModel @Inject constructor( + private val api: HesychiaApi, +) : ViewModel() { + suspend fun requestReset(email: String): Result = runCatching { + api.resetPassword(PasswordResetRequestDto(email = email, recaptchaToken = "")) + } +} + +data class SetPasswordState( + val checking: Boolean = true, + val linkValid: Boolean = false, +) + +@HiltViewModel +class SetPasswordViewModel @Inject constructor( + private val api: HesychiaApi, +) : ViewModel() { + private val _state = MutableStateFlow(SetPasswordState()) + val state: StateFlow = _state + + fun check(slug: String) { + viewModelScope.launch { + if (slug.isBlank()) { + _state.value = SetPasswordState(checking = false, linkValid = false) + return@launch + } + val ok = runCatching { api.checkSetPassword(slug) }.isSuccess + _state.value = SetPasswordState(checking = false, linkValid = ok) + } + } + + suspend fun submit(slug: String, password: String): Result = runCatching { + api.setPassword(slug, SetPasswordRequestDto(password1 = password, password2 = password)) + } +} + +@HiltViewModel +class TermsViewModel @Inject constructor( + private val api: HesychiaApi, + private val sessionRepository: SessionRepository, +) : ViewModel() { + suspend fun acknowledge(): Result = runCatching { + api.acknowledgeTos() + sessionRepository.markTosSigned() + } +} + +fun Throwable.userMessage(fallback: String): String = apiMessage(fallback) diff --git a/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..48110a0 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatScreen.kt @@ -0,0 +1,226 @@ +package ai.hesychia.chat.ui.chat + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.ThumbDown +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import android.util.Base64 +import ai.hesychia.chat.navigation.Routes +import ai.hesychia.chat.ui.shell.AppScaffold + +@Composable +fun ChatScreen( + hasRag: Boolean, + onNavigate: (String) -> Unit, + onSignOut: () -> Unit, + viewModel: ChatViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + var draft by remember { mutableStateOf("") } + var attachmentName by remember { mutableStateOf(null) } + var attachmentBytes by remember { mutableStateOf(null) } + var attachmentType by remember { mutableStateOf(null) } + val listState = rememberLazyListState() + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + attachmentBytes = bytes + attachmentType = context.contentResolver.getType(uri) + attachmentName = uri.lastPathSegment + } + + LaunchedEffect(state.messages.size, state.messages.lastOrNull()?.text) { + if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.lastIndex) + } + + AppScaffold( + title = "Dashboard", + currentRoute = Routes.Chat, + hasRag = hasRag, + onNavigate = onNavigate, + onSignOut = onSignOut, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .padding(horizontal = 12.dp), + ) { + ConversationStrip( + rows = state.conversations, + selectedId = state.selectedId, + onSelect = viewModel::select, + onNew = viewModel::newChat, + onDelete = viewModel::deleteSelected, + ) + if (state.connectionLabel.isNotBlank()) { + Text(state.connectionLabel, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 4.dp)) + } + if (state.interrupted) { + Text( + "Last response was interrupted. You can re-send your message.", + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + state.activityLabel?.let { Text(it, style = MaterialTheme.typography.labelMedium) } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.messages.isEmpty()) { + item { + Text( + "Select a conversation or start a new one.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(24.dp), + ) + } + } + items(state.messages, key = { "${it.id}-${it.userCreated}-${it.text.take(12)}-${it.streaming}" }) { msg -> + MessageBubble(msg, onRate = { viewModel.rate(msg.id, it) }) + } + } + attachmentName?.let { Text("Attached: $it", style = MaterialTheme.typography.labelSmall) } + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { picker.launch("*/*") }) { + Icon(Icons.Default.AttachFile, contentDescription = "Attach") + } + IconButton(onClick = { + val body = state.messages.joinToString("\n\n") { msg -> + val who = if (msg.userCreated) "You" else "Hesychia" + "$who: ${msg.text}" + } + val share = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, body) + } + context.startActivity(Intent.createChooser(share, "Export conversation")) + }) { + Icon(Icons.Default.Share, contentDescription = "Export") + } + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Type a message…") }, + maxLines = 4, + ) + IconButton( + onClick = { + val file = attachmentBytes?.let { Base64.encodeToString(it, Base64.NO_WRAP) } + viewModel.send(draft, file, attachmentType) + draft = "" + attachmentBytes = null + attachmentName = null + attachmentType = null + }, + enabled = draft.isNotBlank() && state.connected, + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send") + } + } + } + } +} + +@Composable +private fun ConversationStrip( + rows: List, + selectedId: Int?, + onSelect: (Int) -> Unit, + onNew: () -> Unit, + onDelete: () -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + androidx.compose.material3.TextButton(onClick = onNew) { Text("+ New chat") } + if (selectedId != null) { + IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Delete conversation") } + } + } + if (rows.isEmpty()) { + Text("No conversations yet. Start with + New chat.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(rows, key = { it.id }) { row -> + Surface( + onClick = { onSelect(row.id) }, + color = if (row.id == selectedId) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + ) { + Text(row.title, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), maxLines = 1) + } + } + } + } +} + +@Composable +private fun MessageBubble(msg: ChatMessage, onRate: (String?) -> Unit) { + val align = if (msg.userCreated) Alignment.End else Alignment.Start + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = align) { + Surface( + color = if (msg.userCreated) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Text( + text = if (msg.streaming && msg.text.isEmpty()) "…" else msg.text, + modifier = Modifier.padding(12.dp), + ) + } + msg.citations.forEach { cite -> + Text(cite, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + if (!msg.userCreated && msg.id != null && !msg.streaming) { + Row { + IconButton(onClick = { onRate(if (msg.rating == "up") null else "up") }) { + Icon(Icons.Default.ThumbUp, contentDescription = "Up") + } + IconButton(onClick = { onRate(if (msg.rating == "down") null else "down") }) { + Icon(Icons.Default.ThumbDown, contentDescription = "Down") + } + } + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..87e2665 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/chat/ChatViewModel.kt @@ -0,0 +1,294 @@ +package ai.hesychia.chat.ui.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ai.hesychia.chat.BuildConfig +import ai.hesychia.chat.data.api.ConversationIdBody +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.PromptDto +import ai.hesychia.chat.data.api.PromptFeedbackRequestDto +import ai.hesychia.chat.data.api.apiMessage +import ai.hesychia.chat.data.chat.ChatSocket +import ai.hesychia.chat.data.chat.IncomingFrame +import ai.hesychia.chat.data.chat.SocketStatus +import ai.hesychia.chat.data.chat.classifyIncoming +import ai.hesychia.chat.data.chat.conversationIdOrNull +import ai.hesychia.chat.data.session.SessionRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +data class ChatMessage( + val id: Int? = null, + val text: String, + val userCreated: Boolean, + val streaming: Boolean = false, + val citations: List = emptyList(), + val rating: String? = null, +) + +data class ConversationRow(val id: Int, val title: String) + +data class ChatUiState( + val conversations: List = emptyList(), + val selectedId: Int? = null, + val messages: List = emptyList(), + val connected: Boolean = false, + val connectionLabel: String = "Connecting to chat…", + val activityLabel: String? = null, + val interrupted: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class ChatViewModel @Inject constructor( + private val api: HesychiaApi, + private val socket: ChatSocket, + private val sessionRepository: SessionRepository, +) : ViewModel() { + private val _state = MutableStateFlow(ChatUiState()) + val state: StateFlow = _state + + private var phase = 0 + private var heartbeat: Job? = null + private var refetch: Job? = null + + init { + socket.onStatus = { status -> + viewModelScope.launch { + when (status) { + SocketStatus.Connecting -> _state.update { + it.copy(connected = false, connectionLabel = "Connecting to chat…") + } + SocketStatus.Connected -> _state.update { + it.copy(connected = true, connectionLabel = "") + } + SocketStatus.Disconnected -> { + val wasStreaming = phase == 2 + phase = 0 + _state.update { + it.copy( + connected = false, + connectionLabel = "Connection to the LLM is not active", + interrupted = it.interrupted || wasStreaming, + activityLabel = null, + ) + } + } + } + } + } + socket.onText = { raw -> viewModelScope.launch { onFrame(raw) } } + socket.connect(BuildConfig.WS_API_BASE_URL) + heartbeat = viewModelScope.launch { + while (isActive) { + delay(25_000) + val email = sessionRepository.session.first().email + socket.ping(email) + } + } + viewModelScope.launch { reloadConversations() } + } + + fun newChat() { + phase = 0 + _state.update { it.copy(selectedId = null, messages = emptyList(), interrupted = false, error = null) } + } + + fun select(id: Int) { + phase = 0 + _state.update { it.copy(selectedId = id, interrupted = false, error = null) } + viewModelScope.launch { loadDetails(id) } + } + + fun deleteSelected() { + val id = _state.value.selectedId ?: return + viewModelScope.launch { + runCatching { api.deleteConversation(ConversationIdBody(id)) } + _state.update { + it.copy( + conversations = it.conversations.filterNot { row -> row.id == id }, + selectedId = null, + messages = emptyList(), + ) + } + } + } + + fun send(text: String, fileBase64: String? = null, fileType: String? = null) { + val trimmed = text.trim() + if (trimmed.isEmpty() || !_state.value.connected) return + val selected = _state.value.selectedId + viewModelScope.launch { + val session = sessionRepository.session.first() + val payload = buildJsonObject { + put("message", trimmed) + if (selected == null) put("conversation_id", JsonNull) else put("conversation_id", selected) + put("email", session.email ?: "") + put("token", session.accessToken ?: "") + if (fileBase64 == null) put("file", JsonNull) else put("file", fileBase64) + if (fileType == null) put("fileType", JsonNull) else put("fileType", fileType) + put("modelName", "THINKING") + }.toString() + val ok = socket.send(payload) + if (!ok) { + _state.update { it.copy(error = "Connection to the LLM is not active") } + return@launch + } + phase = 0 + _state.update { + it.copy( + interrupted = false, + error = null, + activityLabel = null, + messages = it.messages + ChatMessage(text = trimmed, userCreated = true) + + ChatMessage(text = "", userCreated = false, streaming = true), + ) + } + } + } + + fun rate(promptId: Int?, rating: String?) { + if (promptId == null) return + viewModelScope.launch { + val result = if (rating == null) { + runCatching { api.clearPromptFeedback(promptId) } + } else { + runCatching { api.upsertPromptFeedback(PromptFeedbackRequestDto(promptId, rating)) } + } + result.onSuccess { + _state.update { state -> + state.copy( + messages = state.messages.map { msg -> + if (msg.id == promptId) msg.copy(rating = rating) else msg + }, + ) + } + }.onFailure { err -> + _state.update { it.copy(error = err.apiMessage("Could not save rating")) } + } + } + } + + private suspend fun onFrame(raw: String) { + when (val frame = classifyIncoming(raw)) { + IncomingFrame.ConversationIdMarker -> phase = 1 + IncomingFrame.StreamStart -> phase = 2 + IncomingFrame.StreamEnd -> { + phase = 0 + _state.update { state -> + state.copy( + activityLabel = null, + interrupted = false, + messages = state.messages.map { msg -> + if (msg.streaming) msg.copy(streaming = false) else msg + }, + ) + } + scheduleRefetch() + } + is IncomingFrame.Text -> when (phase) { + 1 -> { + val id = conversationIdOrNull(frame.value) + if (id != null && _state.value.selectedId == null) { + _state.update { it.copy(selectedId = id) } + reloadConversations() + } + } + 2 -> appendToken(frame.value) + else -> { + if (frame.value.startsWith("{")) return + } + } + is IncomingFrame.Status -> { + val label = if (frame.stage == "writing") null else frame.label + _state.update { it.copy(activityLabel = label ?: frame.detail) } + } + is IncomingFrame.Citations -> { + _state.update { state -> + val msgs = state.messages.toMutableList() + val index = msgs.indexOfLast { !it.userCreated } + if (index >= 0) msgs[index] = msgs[index].copy(citations = frame.titles) + state.copy(messages = msgs) + } + } + is IncomingFrame.Error -> { + phase = 0 + _state.update { state -> + val msgs = state.messages.toMutableList() + val index = msgs.indexOfLast { !it.userCreated } + if (index >= 0) { + msgs[index] = msgs[index].copy(text = frame.content, streaming = false) + } + state.copy(messages = msgs, error = frame.content, activityLabel = null) + } + } + IncomingFrame.Ignore -> Unit + } + } + + private fun appendToken(token: String) { + _state.update { state -> + val msgs = state.messages.toMutableList() + val index = msgs.indexOfLast { it.streaming || !it.userCreated } + if (index >= 0) { + msgs[index] = msgs[index].copy(text = msgs[index].text + token, streaming = true) + } + state.copy(messages = msgs) + } + } + + private fun scheduleRefetch() { + val id = _state.value.selectedId ?: return + refetch?.cancel() + refetch = viewModelScope.launch { + delay(800) + loadDetails(id) + reloadConversations() + } + } + + private suspend fun reloadConversations() { + val rows = runCatching { api.conversations() }.getOrDefault(emptyList()) + .map { ConversationRow(it.id, it.title?.ifBlank { null } ?: "New conversation") } + _state.update { it.copy(conversations = rows) } + } + + private suspend fun loadDetails(id: Int) { + val prompts = runCatching { api.conversationDetails(id) }.getOrElse { err -> + _state.update { it.copy(error = err.apiMessage("Could not load conversation")) } + return + } + val messages = prompts.map { it.toMessage() }.toMutableList() + if (messages.size == 1) { + messages += ChatMessage(text = "", userCreated = false, streaming = true) + } + _state.update { it.copy(messages = messages, selectedId = id) } + } + + override fun onCleared() { + heartbeat?.cancel() + socket.onText = null + socket.onStatus = null + socket.close() + super.onCleared() + } +} + +private fun PromptDto.toMessage() = ChatMessage( + id = id, + text = message, + userCreated = user_created, + citations = citations.map { cite -> cite.title.ifBlank { cite.url } }, + rating = feedback?.rating, +) diff --git a/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt new file mode 100644 index 0000000..66d3483 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/documents/DocumentsScreen.kt @@ -0,0 +1,330 @@ +package ai.hesychia.chat.ui.documents + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import ai.hesychia.chat.BuildConfig +import ai.hesychia.chat.data.api.DocumentDto +import ai.hesychia.chat.data.api.DriveConnectionDto +import ai.hesychia.chat.data.api.DriveResourceSelectionDto +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.apiMessage +import ai.hesychia.chat.data.session.SessionRepository +import ai.hesychia.chat.navigation.Routes +import ai.hesychia.chat.ui.shell.AppScaffold +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody + +data class DocumentsUiState( + val loading: Boolean = true, + val hasRag: Boolean = false, + val hasCompany: Boolean = false, + val isManager: Boolean = false, + val scope: String = "personal", + val search: String = "", + val ordering: String = "-created", + val page: Int = 1, + val total: Int = 0, + val documents: List = emptyList(), + val drives: List = emptyList(), + val driveBanner: Boolean = false, + val error: String? = null, +) + +@HiltViewModel +class DocumentsViewModel @Inject constructor( + private val api: HesychiaApi, + private val sessionRepository: SessionRepository, +) : ViewModel() { + private val _state = MutableStateFlow(DocumentsUiState()) + val state: StateFlow = _state + + init { + viewModelScope.launch { + val session = sessionRepository.session.first { it.hydrated } + val sub = runCatching { api.subscription() }.getOrNull() + val scope = if (session.hasCompany) "company" else "personal" + _state.update { + it.copy( + hasRag = sub?.hasRag == true, + hasCompany = session.hasCompany, + isManager = session.isCompanyManager, + scope = scope, + loading = false, + ) + } + if (sub?.hasRag == true) reload() + } + } + + fun showDriveBanner() { + _state.update { it.copy(driveBanner = true) } + } + + fun setScope(scope: String) { + _state.update { it.copy(scope = scope, page = 1) } + viewModelScope.launch { reload() } + } + + fun setSearch(value: String) { + _state.update { it.copy(search = value, page = 1) } + viewModelScope.launch { + kotlinx.coroutines.delay(300) + if (_state.value.search == value) reload() + } + } + + fun toggleSort() { + val next = if (_state.value.ordering == "-created") "created" else "-created" + _state.update { it.copy(ordering = next, page = 1) } + viewModelScope.launch { reload() } + } + + fun setPage(page: Int) { + _state.update { it.copy(page = page) } + viewModelScope.launch { reload() } + } + + fun toggleActive(doc: DocumentDto, active: Boolean) { + _state.update { state -> + state.copy(documents = state.documents.map { if (it.id == doc.id) it.copy(active = active) else it }) + } + viewModelScope.launch { + runCatching { api.setDocumentActive(doc.id, ai.hesychia.chat.data.api.ActiveBody(active)) } + .onFailure { + _state.update { state -> + state.copy(documents = state.documents.map { item -> + if (item.id == doc.id) item.copy(active = !active) else item + }) + } + } + } + } + + fun upload(name: String, bytes: ByteArray, mime: String?) { + viewModelScope.launch { + val body = bytes.toRequestBody((mime ?: "application/octet-stream").toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", name, body) + runCatching { api.uploadDocument(_state.value.scope, part) } + .onSuccess { reload() } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Upload failed")) } } + } + } + + fun disconnect(id: Int) { + viewModelScope.launch { + runCatching { api.disconnectDrive(id) } + .onSuccess { reload() } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not disconnect this drive")) } } + } + } + + fun sync(id: Int) { + viewModelScope.launch { + runCatching { api.syncDrive(id) } + .onSuccess { reload() } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Drive sync failed")) } } + } + } + + fun saveResources(id: Int, raw: String) { + val ids = raw.split(',', ' ', '\n').map { it.trim() }.filter { it.isNotEmpty() } + if (ids.isEmpty()) return + viewModelScope.launch { + runCatching { api.saveDriveResources(id, DriveResourceSelectionDto(ids, ids)) } + .onSuccess { reload() } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not save folder selection")) } } + } + } + + fun oauthUrl(provider: String, company: Boolean): String { + val intent = if (company) "link_company_drive" else "link_drive" + return BuildConfig.REST_API_BASE_URL.trimEnd('/') + "/auth/oauth/$provider/start/?intent=$intent" + } + + private suspend fun reload() { + val state = _state.value + val page = runCatching { + api.documents(state.scope, state.page, 20, state.search.ifBlank { null }, state.ordering) + }.getOrElse { err -> + _state.update { it.copy(error = err.apiMessage("Could not load documents"), documents = emptyList()) } + return + } + val drives = runCatching { api.driveConnections() }.getOrDefault(emptyList()) + _state.update { it.copy(documents = page.results, total = page.count, drives = drives, loading = false) } + } +} + +@Composable +fun DocumentsScreen( + hasRag: Boolean, + driveJustConnected: Boolean, + onNavigate: (String) -> Unit, + onSignOut: () -> Unit, + viewModel: DocumentsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + var resourceDraft by remember { mutableStateOf("") } + LaunchedEffect(driveJustConnected) { + if (driveJustConnected) viewModel.showDriveBanner() + } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val name = uri.lastPathSegment ?: "upload" + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult + viewModel.upload(name, bytes, context.contentResolver.getType(uri)) + } + AppScaffold("Documents", Routes.Documents, hasRag, onNavigate, onSignOut) { padding -> + if (state.loading) { + Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + } + return@AppScaffold + } + if (!state.hasRag) { + Column(Modifier.fillMaxSize().padding(padding).padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Unlock document storage", style = MaterialTheme.typography.headlineSmall) + Text("Document uploads and cloud drive connections are available on plans that include RAG document search.") + Button(onClick = { onNavigate(Routes.Account) }) { Text("Upgrade in Billing") } + } + return@AppScaffold + } + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.driveBanner) { + item { Text("Drive connected. Choose folders below to include them in your knowledge base.") } + } + state.error?.let { err -> item { Text(err, color = MaterialTheme.colorScheme.error) } } + if (state.hasCompany) { + item { + Row { + TextButton(onClick = { viewModel.setScope("company") }) { Text(if (state.scope == "company") "• Company" else "Company") } + TextButton(onClick = { viewModel.setScope("personal") }) { Text(if (state.scope == "personal") "• Personal" else "Personal") } + } + } + } + item { + OutlinedTextField( + state.search, + viewModel::setSearch, + label = { Text("Search") }, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${state.total} documents", modifier = Modifier.weight(1f)) + TextButton(onClick = viewModel::toggleSort) { Text(if (state.ordering == "-created") "Newest" else "Oldest") } + } + } + items(state.documents, key = { it.id }) { doc -> + Card(Modifier.fillMaxWidth()) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(doc.file.substringAfterLast('/')) + Text(doc.created.take(10), style = MaterialTheme.typography.labelSmall) + Text(if (doc.processed) "Processed" else "Processing", style = MaterialTheme.typography.labelSmall) + } + Switch(checked = doc.active, onCheckedChange = { viewModel.toggleActive(doc, it) }) + } + } + } + item { + Row { + TextButton(onClick = { if (state.page > 1) viewModel.setPage(state.page - 1) }, enabled = state.page > 1) { Text("Previous") } + TextButton(onClick = { viewModel.setPage(state.page + 1) }, enabled = state.page * 20 < state.total) { Text("Next") } + } + } + item { Button(onClick = { picker.launch("*/*") }) { Text("Upload a document") } } + item { Text("Cloud drives", style = MaterialTheme.typography.titleMedium) } + item { + Row { + TextButton(onClick = { openTab(context, viewModel.oauthUrl("google", false)) }) { Text("Google Drive") } + TextButton(onClick = { openTab(context, viewModel.oauthUrl("microsoft", false)) }) { Text("OneDrive") } + } + } + items(state.drives.filter { (it.kind ?: "personal") == "personal" }, key = { "p${it.id}" }) { drive -> + DriveCard(drive, resourceDraft, { resourceDraft = it }, viewModel) + } + if (state.isManager) { + item { Text("Company knowledge sources", style = MaterialTheme.typography.titleMedium) } + item { + Row { + TextButton(onClick = { openTab(context, viewModel.oauthUrl("google", true)) }) { Text("Shared Drive") } + TextButton(onClick = { openTab(context, viewModel.oauthUrl("microsoft", true)) }) { Text("Microsoft 365") } + } + } + items(state.drives.filter { it.kind == "company" }, key = { "c${it.id}" }) { drive -> + DriveCard(drive, resourceDraft, { resourceDraft = it }, viewModel) + } + } + } + } +} + +@Composable +private fun DriveCard( + drive: DriveConnectionDto, + draft: String, + onDraft: (String) -> Unit, + viewModel: DocumentsViewModel, +) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text("${drive.provider} · ${drive.external_account_email ?: "connected"}") + Text("Sync: ${drive.last_sync_status ?: "never"}") + OutlinedTextField(draft, onDraft, label = { Text("Folder ids, comma separated") }, modifier = Modifier.fillMaxWidth()) + Row { + TextButton(onClick = { viewModel.saveResources(drive.id, draft) }) { Text("Save folders") } + TextButton(onClick = { viewModel.sync(drive.id) }) { Text("Sync") } + TextButton(onClick = { viewModel.disconnect(drive.id) }) { Text("Disconnect") } + } + } + } +} + +private fun openTab(context: android.content.Context, url: String) { + CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url)) +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt b/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt new file mode 100644 index 0000000..7937d95 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/feedback/FeedbackScreen.kt @@ -0,0 +1,141 @@ +package ai.hesychia.chat.ui.feedback + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import ai.hesychia.chat.data.api.FeedbackCreateDto +import ai.hesychia.chat.data.api.FeedbackDto +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.apiMessage +import ai.hesychia.chat.navigation.Routes +import ai.hesychia.chat.ui.shell.AppScaffold +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private val categories = listOf( + "Bug" to "BUG", + "Enhancement" to "ENCHANCEMENT", + "Other" to "OTHER", +) + +data class FeedbackUiState( + val items: List = emptyList(), + val error: String? = null, + val notice: String? = null, +) + +@HiltViewModel +class FeedbackViewModel @Inject constructor( + private val api: HesychiaApi, +) : ViewModel() { + private val _state = MutableStateFlow(FeedbackUiState()) + val state: StateFlow = _state + + init { + viewModelScope.launch { reload() } + } + + fun submit(title: String, text: String, category: String) { + viewModelScope.launch { + runCatching { api.createFeedback(FeedbackCreateDto(title, text, category)) } + .onSuccess { + _state.update { it.copy(notice = "Feedback sent", error = null) } + reload() + } + .onFailure { err -> + _state.update { it.copy(error = err.apiMessage("Could not send feedback")) } + } + } + } + + private suspend fun reload() { + runCatching { api.feedbacks() } + .onSuccess { rows -> _state.update { it.copy(items = rows) } } + .onFailure { err -> _state.update { it.copy(error = err.apiMessage("Could not load feedback")) } } + } +} + +@Composable +fun FeedbackScreen( + hasRag: Boolean, + onNavigate: (String) -> Unit, + onSignOut: () -> Unit, + viewModel: FeedbackViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var title by remember { mutableStateOf("") } + var body by remember { mutableStateOf("") } + var category by remember { mutableStateOf("BUG") } + AppScaffold("Feedback", Routes.Feedback, hasRag, onNavigate, onSignOut) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + state.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Submit feedback", style = MaterialTheme.typography.titleMedium) + OutlinedTextField(title, { title = it }, label = { Text("Title") }, modifier = Modifier.fillMaxWidth()) + RowOfCategories(category) { category = it } + OutlinedTextField(body, { body = it }, label = { Text("Text") }, modifier = Modifier.fillMaxWidth(), minLines = 3) + Button( + onClick = { + viewModel.submit(title.trim(), body.trim(), category) + title = "" + body = "" + }, + enabled = title.isNotBlank() && body.isNotBlank(), + ) { Text("Send") } + } + } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Feedback history", style = MaterialTheme.typography.titleMedium) + if (state.items.isEmpty()) Text("No feedback yet.") + state.items.forEach { item -> + Text(item.title, style = MaterialTheme.typography.titleSmall) + Text("${item.category} · ${item.status}", style = MaterialTheme.typography.labelSmall) + Text(item.text) + } + } + } + } + } +} + +@Composable +private fun RowOfCategories(selected: String, onSelect: (String) -> Unit) { + Column { + categories.forEach { (label, value) -> + TextButton(onClick = { onSelect(value) }) { + Text(if (selected == value) "• $label" else label) + } + } + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt b/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt new file mode 100644 index 0000000..0787650 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/session/SessionViewModel.kt @@ -0,0 +1,146 @@ +package ai.hesychia.chat.ui.session + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ai.hesychia.chat.data.api.HesychiaApi +import ai.hesychia.chat.data.api.RefreshTokenBody +import ai.hesychia.chat.data.api.SubscriptionDto +import ai.hesychia.chat.data.billing.StoreBilling +import ai.hesychia.chat.data.session.SessionRepository +import ai.hesychia.chat.data.session.SessionSnapshot +import ai.hesychia.chat.navigation.Routes +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class BootState( + val ready: Boolean = false, + val startRoute: String = Routes.SignIn, +) + +@HiltViewModel +class SessionViewModel @Inject constructor( + private val sessionRepository: SessionRepository, + private val api: HesychiaApi, + private val storeBilling: StoreBilling, +) : ViewModel() { + val state: StateFlow = sessionRepository.session + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SessionSnapshot()) + + private val _subscription = MutableStateFlow(null) + val subscription: StateFlow = _subscription + + private val _boot = MutableStateFlow(BootState()) + val boot: StateFlow = _boot + + val hasRag: Boolean get() = _subscription.value?.hasRag == true + + init { + viewModelScope.launch { + val snap = sessionRepository.session.first { it.hydrated } + if (snap.isAuthenticated) { + refreshUser() + val updatedSnap = sessionRepository.session.first { it.hydrated } + if (updatedSnap.isAuthenticated) { + loadSubscription() + updatedSnap.userId?.let { runCatching { storeBilling.logIn(it) } } + } + } + val uri = LaunchUri.consume() + val route = if (uri != null) { + routeFor(uri) + } else { + defaultRoute(sessionRepository.session.first { it.hydrated }) + } + _boot.value = BootState(ready = true, startRoute = route) + } + } + + fun refreshSubscription() { + viewModelScope.launch { loadSubscription() } + } + + private suspend fun loadSubscription() { + _subscription.value = runCatching { api.subscription() }.getOrNull() + } + + suspend fun refreshUser() { + val user = runCatching { api.currentUser() }.getOrNull() + if (user != null) { + sessionRepository.saveUser(user) + } else { + sessionRepository.clearAuth() + } + } + + fun signOut(onDone: () -> Unit = {}) { + viewModelScope.launch { + val refresh = state.value.refreshToken + if (!refresh.isNullOrBlank()) { + runCatching { api.blacklist(RefreshTokenBody(refresh)) } + } + runCatching { storeBilling.logOut() } + sessionRepository.clearAuth() + _subscription.value = null + onDone() + } + } + + /** Deep link from https://hesychia.ai/... Returns the route to open. */ + suspend fun routeFor(uri: Uri): String { + val path = uri.path.orEmpty() + if (path.startsWith("/set_password")) { + val slug = uri.getQueryParameter("slug").orEmpty() + return "${Routes.SetPassword}?slug=$slug" + } + if (path.startsWith("/password_reset_confirmation")) return Routes.PasswordResetConfirmation + if (path.startsWith("/password_reset")) return Routes.PasswordReset + if (path.startsWith("/auth")) { + if (uri.getQueryParameter("drive_connected") == "1") return Routes.Documents + val access = uri.getQueryParameter("access") + val refresh = uri.getQueryParameter("refresh") + val error = uri.getQueryParameter("error") + if (!error.isNullOrBlank()) return Routes.SignIn + if (!access.isNullOrBlank() && !refresh.isNullOrBlank()) { + sessionRepository.saveTokens(access, refresh) + refreshUser() + loadSubscription() + val user = sessionRepository.session.first { it.hydrated } + user.userId?.let { runCatching { storeBilling.logIn(it) } } + if (uri.getQueryParameter("needs_checkout") == "1") return Routes.Account + if (!user.hasSignedTos) return Routes.Terms + return Routes.Chat + } + } + val latest = sessionRepository.session.first { it.hydrated } + return defaultRoute(latest) + } + + private fun defaultRoute(snap: SessionSnapshot): String = when { + !snap.isAuthenticated -> Routes.SignIn + !snap.hasSignedTos -> Routes.Terms + else -> Routes.Chat + } + + fun noteBootRoute(route: String) { + _boot.update { it.copy(ready = true, startRoute = route) } + } +} + +/** Set from [ai.hesychia.chat.MainActivity] before Compose reads the session. */ +object LaunchUri { + @Volatile + var uri: Uri? = null + + val incoming = MutableSharedFlow(extraBufferCapacity = 1) + + fun consume(): Uri? = uri.also { uri = null } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt b/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt new file mode 100644 index 0000000..cfee088 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/shell/AppScaffold.kt @@ -0,0 +1,88 @@ +package ai.hesychia.chat.ui.shell + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import ai.hesychia.chat.navigation.Routes +import kotlinx.coroutines.launch + +data class DrawerDest(val route: String, val label: String) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppScaffold( + title: String, + currentRoute: String, + hasRag: Boolean, + onNavigate: (String) -> Unit, + onSignOut: () -> Unit, + content: @Composable (PaddingValues) -> Unit, +) { + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val dests = buildList { + add(DrawerDest(Routes.Chat, "Dashboard")) + add(DrawerDest(Routes.Account, "Account")) + if (hasRag) add(DrawerDest(Routes.Documents, "Documents")) + add(DrawerDest(Routes.Analytics, "Analytics")) + add(DrawerDest(Routes.Feedback, "Feedback")) + } + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet { + Text("Hesychia", modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp)) + dests.forEach { dest -> + NavigationDrawerItem( + label = { Text(dest.label) }, + selected = currentRoute == dest.route, + onClick = { + scope.launch { drawerState.close() } + if (currentRoute != dest.route) onNavigate(dest.route) + }, + ) + } + NavigationDrawerItem( + label = { Text("Sign out") }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + onSignOut() + }, + ) + } + }, + ) { + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon(Icons.Default.Menu, contentDescription = "Menu") + } + }, + ) + }, + content = content, + ) + } +} diff --git a/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt b/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt new file mode 100644 index 0000000..aac7547 --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/theme/HesychiaTheme.kt @@ -0,0 +1,214 @@ +package ai.hesychia.chat.ui.theme + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color + +/** + * Theme color variants mirrored from llm-fe `ui-kit/assets/theme/base/palettes.js`. + * Default product accent on web often follows `info` / blue (#1A73E8). + */ +enum class ThemeColor { + Blue, + Green, + Purple, + Orange, + Red, +} + +data class BrandPalette( + val main: Color, + val focus: Color, +) + +object HesychiaTokens { + val blue = BrandPalette(Color(0xFF1A73E8), Color(0xFF1662C4)) + val green = BrandPalette(Color(0xFF4CAF50), Color(0xFF67BB6A)) + val purple = BrandPalette(Color(0xFF9C27B0), Color(0xFFBA68C8)) + val orange = BrandPalette(Color(0xFFFB8C00), Color(0xFFFC9D26)) + val red = BrandPalette(Color(0xFFF44335), Color(0xFFF65F53)) + + val backgroundLight = Color(0xFFF0F2F5) + val backgroundDark = Color(0xFF1A2035) + val surfaceLight = Color(0xFFFFFFFF) + val surfaceDark = Color(0xFF202940) + val textMuted = Color(0xFF7B809A) + val dark = Color(0xFF344767) + val grey300 = Color(0xFFDEE2E6) + val error = Color(0xFFF44335) + val success = Color(0xFF4CAF50) + val warning = Color(0xFFFB8C00) + + /** Corner radii from web borders (approx rem→dp). */ + val radiusSm = 4 + val radiusMd = 8 + val radiusLg = 12 + val radiusXl = 16 + + fun palette(themeColor: ThemeColor): BrandPalette = when (themeColor) { + ThemeColor.Blue -> blue + ThemeColor.Green -> green + ThemeColor.Purple -> purple + ThemeColor.Orange -> orange + ThemeColor.Red -> red + } +} + +private fun lightScheme(accent: BrandPalette) = lightColorScheme( + primary = accent.main, + onPrimary = Color.White, + primaryContainer = accent.focus, + onPrimaryContainer = Color.White, + secondary = HesychiaTokens.textMuted, + onSecondary = Color.White, + background = HesychiaTokens.backgroundLight, + onBackground = HesychiaTokens.dark, + surface = HesychiaTokens.surfaceLight, + onSurface = HesychiaTokens.dark, + surfaceVariant = HesychiaTokens.grey300, + onSurfaceVariant = HesychiaTokens.textMuted, + error = HesychiaTokens.error, + onError = Color.White, + outline = HesychiaTokens.grey300, + outlineVariant = HesychiaTokens.grey300, +) + +private fun darkScheme(accent: BrandPalette) = darkColorScheme( + primary = accent.main, + onPrimary = Color.White, + primaryContainer = accent.focus, + onPrimaryContainer = Color.White, + secondary = HesychiaTokens.textMuted, + onSecondary = Color.White, + background = HesychiaTokens.backgroundDark, + onBackground = Color.White, + surface = HesychiaTokens.surfaceDark, + onSurface = Color.White, + surfaceVariant = Color(0xFF2C3C58), + onSurfaceVariant = Color(0xFFADB5BD), + error = HesychiaTokens.error, + onError = Color.White, + outline = Color(0xFF495057), + outlineVariant = Color(0xFF495057), +) + +@Composable +fun HesychiaTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + themeColor: ThemeColor = ThemeColor.Blue, + content: @Composable () -> Unit, +) { + val palette = HesychiaTokens.palette(themeColor) + val colors = if (darkTheme) darkScheme(palette) else lightScheme(palette) + MaterialTheme( + colorScheme = colors, + typography = HesychiaTypography, + ) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize() + ) { + Box(modifier = Modifier.fillMaxSize()) { + AnimatedConstellationBackground() + content() + } + } + } +} + +@Composable +fun AnimatedConstellationBackground(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "constellation") + val progress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 60000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "progress" + ) + + val primaryColor = MaterialTheme.colorScheme.primary + val dotColor = primaryColor.copy(alpha = 0.35f) + val lineColor = primaryColor.copy(alpha = 0.15f) + + val particles = remember { + List(28) { i -> + Particle( + x = (i * 37.5f) % 1f, + y = (i * 53.2f) % 1f, + vx = ((i % 5) - 2f) * 0.00008f, + vy = (((i * 7) % 5) - 2f) * 0.00008f, + radius = 2f + (i % 3) * 1.5f + ) + } + } + + Box(modifier = modifier.fillMaxSize()) { + Canvas(modifier = Modifier.fillMaxSize()) { + val width = size.width + val height = size.height + + val currentParticles = particles.map { p -> + var nx = (p.x + p.vx * progress) % 1f + if (nx < 0f) nx += 1f + var ny = (p.y + p.vy * progress) % 1f + if (ny < 0f) ny += 1f + Offset(nx * width, ny * height) + } + + for (i in currentParticles.indices) { + for (j in i + 1 until currentParticles.size) { + val p1 = currentParticles[i] + val p2 = currentParticles[j] + val dx = p1.x - p2.x + val dy = p1.y - p2.y + val distSq = dx * dx + dy * dy + if (distSq < 18000f) { + val alphaFactor = (1f - (distSq / 18000f)).coerceIn(0f, 1f) + drawLine( + color = lineColor.copy(alpha = lineColor.alpha * alphaFactor), + start = p1, + end = p2, + strokeWidth = 1.2f + ) + } + } + } + + for (pt in currentParticles) { + drawCircle( + color = dotColor, + radius = 3f, + center = pt + ) + } + } + } +} + +private data class Particle( + val x: Float, + val y: Float, + val vx: Float, + val vy: Float, + val radius: Float, +) diff --git a/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt b/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt new file mode 100644 index 0000000..1237a2f --- /dev/null +++ b/app/src/main/java/ai/hesychia/chat/ui/theme/Type.kt @@ -0,0 +1,74 @@ +package ai.hesychia.chat.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * Typography scale from llm-fe `ui-kit/assets/theme/base/typography.js`. + * Web uses Roboto; Compose defaults to platform sans until branded fonts packaged. + */ +val HesychiaTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Light, + fontSize = 48.sp, + lineHeight = 60.sp, + ), + headlineLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + lineHeight = 46.sp, + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 30.sp, + lineHeight = 39.sp, + ), + headlineSmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 31.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 25.sp, + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + ), + labelLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + ), +) diff --git a/app/src/main/res/drawable/hesychia_mark.png b/app/src/main/res/drawable/hesychia_mark.png new file mode 100644 index 0000000000000000000000000000000000000000..b97ffccc5abf48c7b7d98d3c0ffd365f3b1b25e9 GIT binary patch literal 86724 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 X#da|ZleXag00000NkvXXu0mjfh~#Mf literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..31f6ffa --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f67db2f --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1A73E8 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bd8111d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Hesychia + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8e52ab0 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +