From 59962c7753f0b0ac7e3550fd74c9cf5935d5db4c Mon Sep 17 00:00:00 2001 From: digital-coder-jack <2.31938068e+08+digital-coder-jack@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:32:05 +0000 Subject: [PATCH 1/2] Comprehensive debug and stabilization of Android app --- api/Android/app/build.gradle.kts | 11 +++++++ .../com/ainotebook/app/data/NetworkMonitor.kt | 3 +- .../com/ainotebook/app/data/SessionStore.kt | 22 +++++++++---- .../com/ainotebook/app/data/StreamClient.kt | 4 +-- .../ainotebook/app/data/ThemePreferences.kt | 32 ++++++++++++------- .../main/java/com/ainotebook/app/ui/AppNav.kt | 3 +- .../com/ainotebook/app/ui/AuthViewModel.kt | 21 ++++++------ .../ainotebook/app/ui/DashboardViewModel.kt | 15 +++++---- .../com/ainotebook/app/ui/ProfileViewModel.kt | 25 ++++++++------- .../com/ainotebook/app/ui/ToolsViewModel.kt | 19 +++++------ api/Android/gradlew | 0 11 files changed, 95 insertions(+), 60 deletions(-) mode change 100644 => 100755 api/Android/gradlew diff --git a/api/Android/app/build.gradle.kts b/api/Android/app/build.gradle.kts index 26c1d55..6d4bb93 100644 --- a/api/Android/app/build.gradle.kts +++ b/api/Android/app/build.gradle.kts @@ -105,5 +105,16 @@ dependencies { // DataStore for token persistence implementation("androidx.datastore:datastore-preferences:1.1.1") + // Room + val roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + // annotationProcessor("androidx.room:room-compiler:$roomVersion") // Use KSP if available + + // WorkManager + val workVersion = "2.9.0" + implementation("androidx.work:work-runtime-ktx:$workVersion") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/NetworkMonitor.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/NetworkMonitor.kt index ab8f54d..22510e1 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/NetworkMonitor.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/data/NetworkMonitor.kt @@ -61,6 +61,7 @@ class NetworkMonitor(context: Context) { fun currentlyOnline(): Boolean { val active = connectivityManager.activeNetwork ?: return false val caps = connectivityManager.getNetworkCapabilities(active) ?: return false - return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } } diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/SessionStore.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/SessionStore.kt index 37495b1..8eeff55 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/SessionStore.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/data/SessionStore.kt @@ -5,8 +5,10 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import java.io.IOException import kotlinx.serialization.json.Json private val Context.dataStore by preferencesDataStore(name = "ai_notebook_session") @@ -22,15 +24,23 @@ class SessionStore(private val context: Context) { private val json = Json { ignoreUnknownKeys = true } } - val tokenFlow: Flow = context.dataStore.data.map { it[TOKEN_KEY] } + val tokenFlow: Flow = context.dataStore.data + .catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it } + .map { it[TOKEN_KEY] } - val userFlow: Flow = context.dataStore.data.map { prefs -> - prefs[USER_KEY]?.let { - runCatching { json.decodeFromString(it) }.getOrNull() + val userFlow: Flow = context.dataStore.data + .catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it } + .map { prefs -> + prefs[USER_KEY]?.let { + runCatching { json.decodeFromString(it) }.getOrNull() + } } - } - suspend fun token(): String? = context.dataStore.data.first()[TOKEN_KEY] + suspend fun token(): String? = try { + context.dataStore.data.first()[TOKEN_KEY] + } catch (e: IOException) { + null + } suspend fun save(token: String, user: User) { context.dataStore.edit { prefs -> diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/StreamClient.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/StreamClient.kt index 78bcf12..4820779 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/StreamClient.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/data/StreamClient.kt @@ -110,9 +110,7 @@ object StreamClient { .addHeader("Accept", "text/event-stream") .addHeader("Cache-Control", "no-cache") .post(payload.toRequestBody(mediaType)) - if (!token.isNullOrBlank()) { - reqBuilder.addHeader("Authorization", "Bearer $token") - } + // Authorization header is already added by the shared ApiClient interceptor. return streamingClient.newCall(reqBuilder.build()) } diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/ThemePreferences.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/ThemePreferences.kt index 1ef131a..47579c7 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/ThemePreferences.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/data/ThemePreferences.kt @@ -6,7 +6,9 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map +import java.io.IOException private val Context.themeStore by preferencesDataStore(name = "ai_notebook_prefs") @@ -26,22 +28,28 @@ class ThemePreferences(private val context: Context) { private val MODEL_KEY = stringPreferencesKey("pref_ai_model") } - val themeMode: Flow = context.themeStore.data.map { prefs -> - when (prefs[THEME_KEY]) { - "LIGHT" -> ThemeMode.LIGHT - "DARK" -> ThemeMode.DARK - else -> ThemeMode.DARK // premium dark-first default, like Perplexity/Claude + val themeMode: Flow = context.themeStore.data + .catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it } + .map { prefs -> + when (prefs[THEME_KEY]) { + "LIGHT" -> ThemeMode.LIGHT + "DARK" -> ThemeMode.DARK + "SYSTEM" -> ThemeMode.SYSTEM + else -> ThemeMode.DARK // premium dark-first default + } } - } - val dynamicColor: Flow = - context.themeStore.data.map { it[DYNAMIC_KEY] ?: false } + val dynamicColor: Flow = context.themeStore.data + .catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it } + .map { it[DYNAMIC_KEY] ?: false } - val hapticsEnabled: Flow = - context.themeStore.data.map { it[HAPTICS_KEY] ?: true } + val hapticsEnabled: Flow = context.themeStore.data + .catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it } + .map { it[HAPTICS_KEY] ?: true } - val aiModel: Flow = - context.themeStore.data.map { it[MODEL_KEY] ?: "auto" } + val aiModel: Flow = context.themeStore.data + .catch { if (it is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw it } + .map { it[MODEL_KEY] ?: "auto" } suspend fun setThemeMode(mode: ThemeMode) { context.themeStore.edit { it[THEME_KEY] = mode.name } diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/AppNav.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/AppNav.kt index 92ad126..303a6fa 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/AppNav.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/ui/AppNav.kt @@ -53,6 +53,7 @@ object Routes { const val SIGNUP = "signup" const val DASHBOARD = "dashboard" const val CHAT = "chat" + const val CHAT_DETAIL = "chat?chatId={chatId}" const val TOOLS = "tools" const val PROFILE = "profile" } @@ -196,7 +197,7 @@ private fun MainShell(factory: VMFactory) { ) } composable( - route = "${Routes.CHAT}?chatId={chatId}", + route = Routes.CHAT_DETAIL, arguments = listOf(navArgument("chatId") { type = NavType.IntType; defaultValue = -1 }) diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/AuthViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/AuthViewModel.kt index 8c6f0d2..68f5cff 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/AuthViewModel.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/ui/AuthViewModel.kt @@ -7,6 +7,7 @@ import com.ainotebook.app.data.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class AuthUiState( @@ -23,24 +24,24 @@ class AuthViewModel(private val repo: Repository) : ViewModel() { val userFlow = repo.userFlow fun clearError() { - _state.value = _state.value.copy(error = null) + _state.update { it.copy(error = null) } } private fun run(block: suspend () -> User) { viewModelScope.launch { - _state.value = AuthUiState(loading = true) + _state.update { AuthUiState(loading = true) } try { block() - _state.value = AuthUiState(success = true) + _state.update { AuthUiState(success = true) } } catch (e: Exception) { - _state.value = AuthUiState(error = friendly(e)) + _state.update { AuthUiState(error = friendly(e)) } } } } fun login(identifier: String, password: String) { if (identifier.isBlank() || password.isBlank()) { - _state.value = AuthUiState(error = "Please enter your email/username and password.") + _state.update { AuthUiState(error = "Please enter your email/username and password.") } return } run { repo.login(identifier.trim(), password) } @@ -52,13 +53,13 @@ class AuthViewModel(private val repo: Repository) : ViewModel() { ) { when { name.isBlank() || username.isBlank() || email.isBlank() -> - _state.value = AuthUiState(error = "Please fill in all fields.") - !email.contains("@") || !email.contains(".") -> // ← ADD THIS - _state.value = AuthUiState(error = "Please enter a valid email address.") + _state.update { AuthUiState(error = "Please fill in all fields.") } + !email.contains("@") || !email.contains(".") -> + _state.update { AuthUiState(error = "Please enter a valid email address.") } password.length < 6 -> - _state.value = AuthUiState(error = "Password must be at least 6 characters.") + _state.update { AuthUiState(error = "Password must be at least 6 characters.") } password != confirm -> - _state.value = AuthUiState(error = "Passwords do not match.") + _state.update { AuthUiState(error = "Passwords do not match.") } else -> run { repo.signup(name.trim(), username.trim(), email.trim(), password, confirm) } diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/DashboardViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/DashboardViewModel.kt index 28fdca6..c8322ed 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/DashboardViewModel.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/ui/DashboardViewModel.kt @@ -7,6 +7,7 @@ import com.ainotebook.app.data.Stats import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class DashboardUiState( @@ -24,15 +25,17 @@ class DashboardViewModel(private val repo: Repository) : ViewModel() { fun load() { viewModelScope.launch { - _state.value = _state.value.copy(loading = true, error = null) + _state.update { it.copy(loading = true, error = null) } try { val s = repo.stats() - _state.value = DashboardUiState(loading = false, stats = s) + _state.update { DashboardUiState(loading = false, stats = s) } } catch (e: Exception) { - _state.value = DashboardUiState( - loading = false, - error = e.message ?: "Could not load your dashboard." - ) + _state.update { + DashboardUiState( + loading = false, + error = e.message ?: "Could not load your dashboard." + ) + } } } } diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/ProfileViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/ProfileViewModel.kt index 3c1e1d6..e283600 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/ProfileViewModel.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/ui/ProfileViewModel.kt @@ -7,6 +7,7 @@ import com.ainotebook.app.data.User import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class ProfileUiState( @@ -27,41 +28,41 @@ class ProfileViewModel(private val repo: Repository) : ViewModel() { fun refresh() { viewModelScope.launch { try { - _state.value = _state.value.copy(user = repo.me()) + _state.update { it.copy(user = repo.me()) } } catch (e: Exception) { - _state.value = _state.value.copy(error = e.message) + _state.update { it.copy(error = e.message) } } } } fun updateName(name: String) { if (name.isBlank()) { - _state.value = _state.value.copy(error = "Please enter your name.") + _state.update { it.copy(error = "Please enter your name.") } return } viewModelScope.launch { - _state.value = _state.value.copy(loading = true, message = null, error = null) + _state.update { it.copy(loading = true, message = null, error = null) } try { val u = repo.updateProfile(name.trim()) - _state.value = _state.value.copy(loading = false, user = u, message = "Profile updated.") + _state.update { it.copy(loading = false, user = u, message = "Profile updated.") } } catch (e: Exception) { - _state.value = _state.value.copy(loading = false, error = e.message) + _state.update { it.copy(loading = false, error = e.message) } } } } fun changePassword(current: String, new: String) { if (new.length < 6) { - _state.value = _state.value.copy(error = "New password must be at least 6 characters.") + _state.update { it.copy(error = "New password must be at least 6 characters.") } return } viewModelScope.launch { - _state.value = _state.value.copy(loading = true, message = null, error = null) + _state.update { it.copy(loading = true, message = null, error = null) } try { val msg = repo.changePassword(current, new) - _state.value = _state.value.copy(loading = false, message = msg) + _state.update { it.copy(loading = false, message = msg) } } catch (e: Exception) { - _state.value = _state.value.copy(loading = false, error = e.message) + _state.update { it.copy(loading = false, error = e.message) } } } } @@ -69,11 +70,11 @@ class ProfileViewModel(private val repo: Repository) : ViewModel() { fun logout() { viewModelScope.launch { repo.logout() - _state.value = _state.value.copy(loggedOut = true) + _state.update { it.copy(loggedOut = true) } } } fun clearMessages() { - _state.value = _state.value.copy(message = null, error = null) + _state.update { it.copy(message = null, error = null) } } } diff --git a/api/Android/app/src/main/java/com/ainotebook/app/ui/ToolsViewModel.kt b/api/Android/app/src/main/java/com/ainotebook/app/ui/ToolsViewModel.kt index be64e13..9fed6e8 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/ui/ToolsViewModel.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/ui/ToolsViewModel.kt @@ -8,6 +8,7 @@ import com.ainotebook.app.data.Repository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** Generic result holder for the study tools. */ @@ -27,23 +28,23 @@ class ToolsViewModel(private val repo: Repository) : ViewModel() { val state: StateFlow = _state.asStateFlow() fun reset() { - _state.value = ToolsUiState() + _state.update { ToolsUiState() } } private fun begin() { - _state.value = ToolsUiState(loading = true) + _state.update { ToolsUiState(loading = true) } } private fun fail(e: Exception) { - _state.value = ToolsUiState(error = e.message ?: "Something went wrong.") + _state.update { ToolsUiState(error = e.message ?: "Something went wrong.") } } - fun notes(topic: String) = launch { _state.value = ToolsUiState(textResult = repo.generateNotes(topic).content) } - fun plan(goal: String, days: Int) = launch { _state.value = ToolsUiState(textResult = repo.generatePlan(goal, days).content) } - fun summarize(text: String) = launch { _state.value = ToolsUiState(textResult = repo.summarize(text).summary) } - fun homework(q: String) = launch { _state.value = ToolsUiState(textResult = repo.homework(q).answer) } - fun quiz(topic: String, n: Int) = launch { _state.value = ToolsUiState(quiz = repo.generateQuiz(topic, n).questions) } - fun flashcards(topic: String, n: Int) = launch { _state.value = ToolsUiState(flashcards = repo.generateFlashcards(topic, n).cards) } + fun notes(topic: String) = launch { _state.update { ToolsUiState(textResult = repo.generateNotes(topic).content) } } + fun plan(goal: String, days: Int) = launch { _state.update { ToolsUiState(textResult = repo.generatePlan(goal, days).content) } } + fun summarize(text: String) = launch { _state.update { ToolsUiState(textResult = repo.summarize(text).summary) } } + fun homework(q: String) = launch { _state.update { ToolsUiState(textResult = repo.homework(q).answer) } } + fun quiz(topic: String, n: Int) = launch { _state.update { ToolsUiState(quiz = repo.generateQuiz(topic, n).questions) } } + fun flashcards(topic: String, n: Int) = launch { _state.update { ToolsUiState(flashcards = repo.generateFlashcards(topic, n).cards) } } private fun launch(block: suspend () -> Unit) { viewModelScope.launch { diff --git a/api/Android/gradlew b/api/Android/gradlew old mode 100644 new mode 100755 From 5c8d447e1675a3b9d750ece480cf3420e18acc54 Mon Sep 17 00:00:00 2001 From: digital-coder-jack <2.31938068e+08+digital-coder-jack@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:32:32 +0000 Subject: [PATCH 2/2] Fix: Add defensive check for token retrieval in ApiClient --- .../src/main/java/com/ainotebook/app/data/ApiClient.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/Android/app/src/main/java/com/ainotebook/app/data/ApiClient.kt b/api/Android/app/src/main/java/com/ainotebook/app/data/ApiClient.kt index 2b0b159..3c60065 100644 --- a/api/Android/app/src/main/java/com/ainotebook/app/data/ApiClient.kt +++ b/api/Android/app/src/main/java/com/ainotebook/app/data/ApiClient.kt @@ -37,7 +37,13 @@ object ApiClient { fun init(session: SessionStore) { val authInterceptor = Interceptor { chain -> - val token = runBlocking { session.token() } + // DataStore access via runBlocking is generally safe in OkHttp Interceptors + // as they run on background threads. Added try-catch for extra safety. + val token = try { + runBlocking { session.token() } + } catch (e: Exception) { + null + } val builder = chain.request().newBuilder() if (!token.isNullOrBlank()) { builder.addHeader("Authorization", "Bearer $token")