diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70ff642..d71eed4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,8 +3,9 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0" + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20" id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") + id("kotlin-parcelize") } android { diff --git a/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt b/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt index 9a00017..9e48218 100644 --- a/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt +++ b/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningScreen.kt @@ -95,10 +95,12 @@ import com.google.ai.sample.util.Command import com.google.ai.sample.util.SystemMessageEntry import com.google.ai.sample.util.SystemMessageEntryPreferences import com.google.ai.sample.util.UriSaver -import com.google.ai.sample.util.shareTextFile // Added for sharing +import com.google.ai.sample.util.shareTextFile +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.serialization.builtins.ListSerializer // Added for JSON -import kotlinx.serialization.json.Json // Added for JSON +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json import android.util.Log import kotlinx.serialization.SerializationException @@ -148,7 +150,7 @@ internal fun PhotoReasoningRoute( viewModel.updateSystemMessage(message, context) }, onReasonClicked = { inputText, selectedItems -> - coroutineScope.launch { + coroutineScope.launch { val bitmaps = selectedItems.mapNotNull { val imageRequest = imageRequestBuilder.data(it).precision(Precision.EXACT).build() try { @@ -205,7 +207,7 @@ fun PhotoReasoningScreen( LaunchedEffect(Unit) { systemMessageEntries = SystemMessageEntryPreferences.loadEntries(context) } - LaunchedEffect(showDatabaseListPopup) { // Reload entries when database popup becomes visible + LaunchedEffect(showDatabaseListPopup) { if (showDatabaseListPopup) { systemMessageEntries = SystemMessageEntryPreferences.loadEntries(context) } @@ -283,7 +285,7 @@ fun PhotoReasoningScreen( Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = { onEnableAccessibilityService() - Toast.makeText(context, "Open Accessibility Settings...", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Open Accessibility Settings..." as CharSequence, Toast.LENGTH_SHORT).show() }) { Text("Activate Accessibility Service") } } } @@ -324,7 +326,7 @@ fun PhotoReasoningScreen( } } else { onEnableAccessibilityService() - Toast.makeText(context, "Enable the Accessibility service for Screen Operator", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Enable the Accessibility service for Screen Operator" as CharSequence, Toast.LENGTH_LONG).show() } }, modifier = Modifier.padding(all = 4.dp).align(Alignment.CenterVertically)) { Icon(Icons.Default.Send, stringResource(R.string.action_go), tint = MaterialTheme.colorScheme.primary) @@ -354,7 +356,6 @@ fun PhotoReasoningScreen( is Command.ClickButton -> "Click on button: \"${command.buttonText}\"" is Command.TapCoordinates -> "Tap coordinates: (${command.x}, ${command.y})" is Command.TakeScreenshot -> "Take screenshot" - // ... (other command types) ... else -> command::class.simpleName ?: "Unknown Command" } Text("${index + 1}. $commandText", color = MaterialTheme.colorScheme.onTertiaryContainer) @@ -380,7 +381,7 @@ fun PhotoReasoningScreen( SystemMessageEntryPreferences.deleteEntry(context, entry) systemMessageEntries = SystemMessageEntryPreferences.loadEntries(context) }, - onImportCompleted = { // Add this new lambda + onImportCompleted = { systemMessageEntries = SystemMessageEntryPreferences.loadEntries(context) } ) @@ -393,7 +394,7 @@ fun PhotoReasoningScreen( onSaveClicked = { title, guide, originalEntry -> val currentEntry = SystemMessageEntry(title.trim(), guide.trim()) if (title.isBlank() || guide.isBlank()) { - Toast.makeText(context, "Title and Guide cannot be empty.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Title and Guide cannot be empty." as CharSequence, Toast.LENGTH_SHORT).show() return@EditEntryPopup } if (originalEntry == null) { @@ -403,13 +404,13 @@ fun PhotoReasoningScreen( showEditEntryPopup = false systemMessageEntries = SystemMessageEntryPreferences.loadEntries(context) } else { - Toast.makeText(context, "An entry with this title already exists.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "An entry with this title already exists." as CharSequence, Toast.LENGTH_SHORT).show() return@EditEntryPopup } } else { val existingEntryWithNewTitle = systemMessageEntries.find { it.title.equals(currentEntry.title, ignoreCase = true) && it.guide != originalEntry.guide } if (existingEntryWithNewTitle != null && originalEntry.title != currentEntry.title) { - Toast.makeText(context, "Another entry with this new title already exists.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Another entry with this new title already exists." as CharSequence, Toast.LENGTH_SHORT).show() return@EditEntryPopup } SystemMessageEntryPreferences.updateEntry(context, originalEntry, currentEntry) @@ -429,8 +430,10 @@ fun DatabaseListPopup( onNewClicked: () -> Unit, onEntryClicked: (SystemMessageEntry) -> Unit, onDeleteClicked: (SystemMessageEntry) -> Unit, - onImportCompleted: () -> Unit // New lambda parameter + onImportCompleted: () -> Unit ) { + val TAG_IMPORT_PROCESS = "ImportProcess" + val scope = rememberCoroutineScope() var entryMenuToShow: SystemMessageEntry? by remember { mutableStateOf(null) } var selectionModeActive by rememberSaveable { mutableStateOf(false) } var selectedEntryTitles by rememberSaveable { mutableStateOf(emptySet()) } @@ -441,58 +444,136 @@ fun DatabaseListPopup( var remainingEntriesToImport by remember { mutableStateOf>(emptyList()) } var skipAllDuplicates by remember { mutableStateOf(false) } - fun processImportedEntries( + // processImportedEntries is defined within DatabaseListPopup, so it has access to context, onImportCompleted, etc. + fun processImportedEntries( imported: List, - currentSystemEntries: List // Pass current entries for comparison + currentSystemEntries: List ) { + val TAG_IMPORT_PROCESS_FUNCTION = "ImportProcessFunction" + Log.d(TAG_IMPORT_PROCESS_FUNCTION, "Starting processImportedEntries. Imported: ${imported.size}, Current: ${currentSystemEntries.size}, SkipAll: $skipAllDuplicates") + var newCount = 0 - var updatedCount = 0 + var updatedCount = 0 var skippedCount = 0 val entriesToProcess = imported.toMutableList() - fun continueProcessing(remainingToProcess: MutableList) { - while (remainingToProcess.isNotEmpty()) { - val newEntry = remainingToProcess.removeAt(0) - val existingEntry = currentSystemEntries.find { it.title.equals(newEntry.title, ignoreCase = true) } + while (entriesToProcess.isNotEmpty()) { + val newEntry = entriesToProcess.removeAt(0) + Log.d(TAG_IMPORT_PROCESS_FUNCTION, "Processing entry: Title='${newEntry.title}'. Remaining in batch: ${entriesToProcess.size}") + val existingEntry = currentSystemEntries.find { it.title.equals(newEntry.title, ignoreCase = true) } - if (existingEntry != null) { - if (skipAllDuplicates) { - skippedCount++ - continue - } - entryToConfirmOverwrite = Pair(existingEntry, newEntry) - remainingEntriesToImport = remainingToProcess.toList() // Save remaining for after dialog - return // Stop processing, let dialog handle this one - } else { - SystemMessageEntryPreferences.addEntry(context, newEntry) - newCount++ + if (existingEntry != null) { + Log.d(TAG_IMPORT_PROCESS_FUNCTION, "Duplicate found for title: '${newEntry.title}'. Existing guide: '${existingEntry.guide.take(50)}', New guide: '${newEntry.guide.take(50)}'") + if (skipAllDuplicates) { + Log.d(TAG_IMPORT_PROCESS_FUNCTION, "Skipping duplicate '${newEntry.title}' due to skipAllDuplicates flag.") + skippedCount++ + continue } + Log.d(TAG_IMPORT_PROCESS_FUNCTION, "Calling askForOverwrite for '${newEntry.title}'.") + entryToConfirmOverwrite = Pair(existingEntry, newEntry) + remainingEntriesToImport = entriesToProcess.toList() + return + } else { + Log.i(TAG_IMPORT_PROCESS_FUNCTION, "Adding new entry: Title='${newEntry.title}'") + SystemMessageEntryPreferences.addEntry(context, newEntry) + newCount++ } - // All processed or skipped - val summary = "Import finished: $newCount added, $updatedCount updated, $skippedCount skipped." - Toast.makeText(context, summary, Toast.LENGTH_LONG).show() - onImportCompleted() // Refresh the main list - skipAllDuplicates = false // Reset for next import operation } - continueProcessing(entriesToProcess) + Log.i(TAG_IMPORT_PROCESS_FUNCTION, "Finished processing batch. newCount=$newCount, updatedCount=$updatedCount, skippedCount=$skippedCount") + val summary = "Import finished: $newCount added, $updatedCount updated, $skippedCount skipped." + Toast.makeText(context, summary as CharSequence, Toast.LENGTH_LONG).show() + onImportCompleted() + skipAllDuplicates = false } val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), onResult = { uri: Uri? -> - uri?.let { + Log.d(TAG_IMPORT_PROCESS, "FilePickerLauncher onResult triggered.") + if (uri == null) { + Log.w(TAG_IMPORT_PROCESS, "URI is null, no file selected or operation cancelled.") + scope.launch(Dispatchers.Main) { + Toast.makeText(context, "No file selected." as CharSequence, Toast.LENGTH_SHORT).show() + } + return@rememberLauncherForActivityResult + } + + Log.i(TAG_IMPORT_PROCESS, "Selected file URI: $uri") + scope.launch(Dispatchers.Main) { + Toast.makeText(context, "File selected: $uri. Starting import..." as CharSequence, Toast.LENGTH_SHORT).show() + } + + scope.launch(Dispatchers.IO) { try { - context.contentResolver.openInputStream(it)?.use { inputStream -> - val jsonString = inputStream.bufferedReader().readText() - val parsedEntries = Json.decodeFromString(ListSerializer(SystemMessageEntry.serializer()), jsonString) - val currentSystemEntries = SystemMessageEntryPreferences.loadEntries(context) // Load fresh list - skipAllDuplicates = false // Reset for new import - processImportedEntries(parsedEntries, currentSystemEntries) + Log.d(TAG_IMPORT_PROCESS, "Attempting to open InputStream for URI: $uri on thread: ${Thread.currentThread().name}") + + var fileSize = -1L + try { + context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + fileSize = pfd.statSize + } + Log.i(TAG_IMPORT_PROCESS, "Estimated file size: $fileSize bytes.") + } catch (e: Exception) { + Log.w(TAG_IMPORT_PROCESS, "Could not determine file size for URI: $uri. Will proceed without size check.", e) } + + val MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 + if (fileSize != -1L && fileSize > MAX_FILE_SIZE_BYTES) { + Log.e(TAG_IMPORT_PROCESS, "File size ($fileSize bytes) exceeds limit of $MAX_FILE_SIZE_BYTES bytes.") + withContext(Dispatchers.Main) { + Toast.makeText(context, "File is too large (max 10MB)." as CharSequence, Toast.LENGTH_LONG).show() + } + return@launch + } + if (fileSize == 0L) { + Log.w(TAG_IMPORT_PROCESS, "Imported file is empty (0 bytes).") + withContext(Dispatchers.Main) { + Toast.makeText(context, "Imported file is empty." as CharSequence, Toast.LENGTH_LONG).show() + } + return@launch + } + + context.contentResolver.openInputStream(uri)?.use { inputStream -> + Log.i(TAG_IMPORT_PROCESS, "InputStream opened. Reading text on thread: ${Thread.currentThread().name}") + val jsonString = inputStream.bufferedReader().readText() + Log.i(TAG_IMPORT_PROCESS, "File content read. Size: ${jsonString.length} chars.") + Log.v(TAG_IMPORT_PROCESS, "File content snippet: ${jsonString.take(500)}") + + if (jsonString.isBlank()) { + Log.w(TAG_IMPORT_PROCESS, "Imported file content is blank.") + withContext(Dispatchers.Main) { + Toast.makeText(context, "Imported file content is blank." as CharSequence, Toast.LENGTH_LONG).show() + } + return@use + } + + Log.d(TAG_IMPORT_PROCESS, "Attempting to parse JSON string on thread: ${Thread.currentThread().name}") + val parsedEntries = Json.decodeFromString(ListSerializer(SystemMessageEntry.serializer()), jsonString) + Log.i(TAG_IMPORT_PROCESS, "JSON parsed. Found ${parsedEntries.size} entries.") + + val currentSystemEntries = SystemMessageEntryPreferences.loadEntries(context) + Log.d(TAG_IMPORT_PROCESS, "Current system entries loaded: ${currentSystemEntries.size} entries.") + + withContext(Dispatchers.Main) { + Log.d(TAG_IMPORT_PROCESS, "Switching to Main thread for processImportedEntries: ${Thread.currentThread().name}") + skipAllDuplicates = false + processImportedEntries( + imported = parsedEntries, + currentSystemEntries = currentSystemEntries + ) + } + } ?: Log.w(TAG_IMPORT_PROCESS, "ContentResolver.openInputStream returned null for URI: $uri (second check).") } catch (e: Exception) { - Log.e("DatabaseListPopup", "Error reading or parsing imported file", e) - Toast.makeText(context, "Error importing file: ${e.message}", Toast.LENGTH_LONG).show() + Log.e(TAG_IMPORT_PROCESS, "Error during file import for URI: $uri on thread: ${Thread.currentThread().name}", e) + withContext(Dispatchers.Main) { + val errorMessage = if (e is OutOfMemoryError) { + "Out of memory. File may be too large or contain too many entries." + } else { + e.message ?: "Unknown error during import." + } + Toast.makeText(context, "Error importing file: $errorMessage" as CharSequence, Toast.LENGTH_LONG).show() + } } } } @@ -503,33 +584,45 @@ fun DatabaseListPopup( OverwriteConfirmationDialog( entryTitle = newEntry.title, onConfirm = { + Log.d(TAG_IMPORT_PROCESS, "Overwrite confirmed for title: '${newEntry.title}'") SystemMessageEntryPreferences.updateEntry(context, existingEntry, newEntry) - // updatedCount++ // This logic needs to be inside processImportedEntries or passed back - Toast.makeText(context, "Entry '${newEntry.title}' overwritten.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Entry '${newEntry.title}' overwritten." as CharSequence, Toast.LENGTH_SHORT).show() entryToConfirmOverwrite = null - val currentSystemEntries = SystemMessageEntryPreferences.loadEntries(context) // Reload for next check - processImportedEntries(remainingEntriesToImport, currentSystemEntries) // Continue with remaining + val currentSystemEntriesAfterUpdate = SystemMessageEntryPreferences.loadEntries(context) + Log.d(TAG_IMPORT_PROCESS, "Continuing with remaining ${remainingEntriesToImport.size} entries after dialog (Confirm).") + processImportedEntries( + imported = remainingEntriesToImport, + currentSystemEntries = currentSystemEntriesAfterUpdate + ) }, - onDeny = { // User chose "No" for this specific entry - // skippedCount++ // This logic needs to be inside processImportedEntries or passed back + onDeny = { + Log.d(TAG_IMPORT_PROCESS, "Overwrite denied for title: '${newEntry.title}'") entryToConfirmOverwrite = null - val currentSystemEntries = SystemMessageEntryPreferences.loadEntries(context) // Reload for next check - // Here you could ask about "Skip All remaining?" - // For now, just continue processing without skipping all by default - processImportedEntries(remainingEntriesToImport, currentSystemEntries) // Continue with remaining + val currentSystemEntriesAfterDeny = SystemMessageEntryPreferences.loadEntries(context) + Log.d(TAG_IMPORT_PROCESS, "Continuing with remaining ${remainingEntriesToImport.size} entries after dialog (Deny).") + processImportedEntries( + imported = remainingEntriesToImport, + currentSystemEntries = currentSystemEntriesAfterDeny + ) }, - onSkipAll = { // This is now a conceptual third option in the dialog, or handled differently + onSkipAll = { + Log.d(TAG_IMPORT_PROCESS, "Skip All selected for title: '${newEntry.title}'") skipAllDuplicates = true entryToConfirmOverwrite = null - val currentSystemEntries = SystemMessageEntryPreferences.loadEntries(context) // Reload for next check - processImportedEntries(remainingEntriesToImport, currentSystemEntries) // Continue with remaining, now skipping + val currentSystemEntriesAfterSkipAll = SystemMessageEntryPreferences.loadEntries(context) + Log.d(TAG_IMPORT_PROCESS, "Continuing with remaining ${remainingEntriesToImport.size} entries after dialog (SkipAll).") + processImportedEntries( + imported = remainingEntriesToImport, + currentSystemEntries = currentSystemEntriesAfterSkipAll + ) }, - onDismiss = { // Dialog dismissed externally - entryToConfirmOverwrite = null - remainingEntriesToImport = emptyList() // Stop this import batch + onDismiss = { + Log.d(TAG_IMPORT_PROCESS, "Overwrite dialog dismissed for title: '${entryToConfirmOverwrite?.second?.title}'. Import process for this batch might halt.") + entryToConfirmOverwrite = null + remainingEntriesToImport = emptyList() skipAllDuplicates = false - Toast.makeText(context, "Import cancelled.", Toast.LENGTH_SHORT).show() - onImportCompleted() // Refresh to show any partial imports before dismissal + Toast.makeText(context, "Import cancelled for remaining items." as CharSequence, Toast.LENGTH_SHORT).show() + onImportCompleted() } ) } @@ -652,9 +745,9 @@ fun DatabaseListPopup( } } Spacer(modifier = Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { if (selectionModeActive) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( checked = selectAllChecked, onCheckedChange = { isChecked -> @@ -665,27 +758,32 @@ fun DatabaseListPopup( ) Text("All", color = Color.Black, style = MaterialTheme.typography.bodyMedium) } + } else { + Spacer(modifier = Modifier.width(80.dp)) // Placeholder for alignment } - Button(onClick = { filePickerLauncher.launch("*/*") }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), modifier = Modifier.padding(end = 8.dp)) { Text("Import") } - Button( - onClick = { - if (selectionModeActive) { - if (selectedEntryTitles.isEmpty()) { - Toast.makeText(context, "No entries selected for export.", Toast.LENGTH_SHORT).show() - } else { - val entriesToExport = entries.filter { selectedEntryTitles.contains(it.title) } - val jsonString = Json.encodeToString(ListSerializer(SystemMessageEntry.serializer()), entriesToExport) - shareTextFile(context, "system_messages_export.txt", jsonString) + + Row(verticalAlignment = Alignment.CenterVertically) { + Button(onClick = { filePickerLauncher.launch("*/*") }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), modifier = Modifier.padding(end = 8.dp)) { Text("Import") } + Button( + onClick = { + if (selectionModeActive) { + if (selectedEntryTitles.isEmpty()) { + Toast.makeText(context, "No entries selected for export." as CharSequence, Toast.LENGTH_SHORT).show() + } else { + val entriesToExport = entries.filter { selectedEntryTitles.contains(it.title) } + val jsonString = Json.encodeToString(ListSerializer(SystemMessageEntry.serializer()), entriesToExport) + shareTextFile(context, "system_messages_export.txt", jsonString) + } + selectionModeActive = false + selectedEntryTitles = emptySet() + selectAllChecked = false + } else { + selectionModeActive = true } - selectionModeActive = false - selectedEntryTitles = emptySet() - selectAllChecked = false - } else { - selectionModeActive = true - } - }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) - ) { Text(if (selectionModeActive) "Share" else "Export") } + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { Text("Export") } // Text is now always "Export" + } } } } @@ -698,7 +796,7 @@ fun OverwriteConfirmationDialog( entryTitle: String, onConfirm: () -> Unit, onDeny: () -> Unit, - onSkipAll: () -> Unit, // Added for conceptual completeness, might need UI adjustment + onSkipAll: () -> Unit, onDismiss: () -> Unit ) { AlertDialog( @@ -711,13 +809,6 @@ fun OverwriteConfirmationDialog( dismissButton = { TextButton(onClick = onDeny) { Text("No") } } - // To add "Skip All", AlertDialog might need to be replaced with a custom Dialog, - // or we could add a checkbox "Apply to all future duplicates in this session?" to this dialog. - // For this iteration, Skip All is handled by the calling logic if "No" is chosen. - // A more integrated "Skip All" button could be: - // neutralButton = { TextButton(onClick = onSkipAll) { Text("Skip All") } } - // However, standard AlertDialog doesn't have a direct neutralButton like this. - // Consider adding a TextButton next to No/Yes if UI permits, or a checkbox. ) } diff --git a/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningViewModel.kt b/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningViewModel.kt index d566512..ed39b87 100644 --- a/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningViewModel.kt +++ b/app/src/main/kotlin/com/google/ai/sample/feature/multimodal/PhotoReasoningViewModel.kt @@ -1,5 +1,6 @@ package com.google.ai.sample.feature.multimodal +import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.net.Uri @@ -23,6 +24,8 @@ import com.google.ai.sample.util.ChatHistoryPreferences import com.google.ai.sample.util.Command import com.google.ai.sample.util.CommandParser import com.google.ai.sample.util.SystemMessagePreferences +import com.google.ai.sample.util.SystemMessageEntryPreferences // Added import +import com.google.ai.sample.util.SystemMessageEntry // Added import import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -90,15 +93,7 @@ class PhotoReasoningViewModel( ) { _uiState.value = PhotoReasoningUiState.Loading - // Get the system message - val systemMessageText = _systemMessage.value - - // Create the prompt with system message if available - val prompt = if (systemMessageText.isNotBlank()) { - "System Message: $systemMessageText\n\nFOLLOW THE INSTRUCTIONS STRICTLY: $userInput" - } else { - "FOLLOW THE INSTRUCTIONS STRICTLY: $userInput" - } + val prompt = "FOLLOW THE INSTRUCTIONS STRICTLY: $userInput" // Store the current user input and selected images currentUserInput = userInput @@ -452,7 +447,7 @@ class PhotoReasoningViewModel( /** * Update the system message */ - fun updateSystemMessage(message: String, context: android.content.Context) { + fun updateSystemMessage(message: String, context: Context) { _systemMessage.value = message // Save to SharedPreferences for persistence @@ -462,13 +457,31 @@ class PhotoReasoningViewModel( /** * Load the system message from SharedPreferences */ - fun loadSystemMessage(context: android.content.Context) { + fun loadSystemMessage(context: Context) { val message = SystemMessagePreferences.loadSystemMessage(context) _systemMessage.value = message // Also load chat history loadChatHistory(context) } + + /** + * Helper function to format database entries as text. + */ + private fun formatDatabaseEntriesAsText(context: Context): String { + val entries = SystemMessageEntryPreferences.loadEntries(context) + if (entries.isEmpty()) { + return "" + } + val builder = StringBuilder() + builder.append("Available System Guides:\n---\n") + for (entry in entries) { + builder.append("Title: ${entry.title}\n") + builder.append("Guide: ${entry.guide}\n") + builder.append("---\n") + } + return builder.toString() + } /** * Process commands found in the AI response @@ -513,7 +526,7 @@ class PhotoReasoningViewModel( /** * Save chat history to SharedPreferences */ - private fun saveChatHistory(context: android.content.Context?) { + private fun saveChatHistory(context: Context?) { context?.let { ChatHistoryPreferences.saveChatMessages(it, chatMessages) } @@ -522,7 +535,7 @@ class PhotoReasoningViewModel( /** * Load chat history from SharedPreferences */ - fun loadChatHistory(context: android.content.Context) { + fun loadChatHistory(context: Context) { val savedMessages = ChatHistoryPreferences.loadChatMessages(context) if (savedMessages.isNotEmpty()) { _chatState.clearMessages() @@ -532,18 +545,29 @@ class PhotoReasoningViewModel( _chatMessagesFlow.value = chatMessages // Rebuild the chat history for the AI - rebuildChatHistory() + rebuildChatHistory(context) // Pass context here } } /** * Rebuild the chat history for the AI based on the current messages */ - private fun rebuildChatHistory() { + private fun rebuildChatHistory(context: Context) { // Added context parameter // Convert the current chat messages to Content objects for the chat history val history = mutableListOf() + + // 1. Active System Message + if (_systemMessage.value.isNotBlank()) { + history.add(content(role = "user") { text(_systemMessage.value) }) + } + + // 2. Formatted Database Entries + val formattedDbEntries = formatDatabaseEntriesAsText(context) + if (formattedDbEntries.isNotBlank()) { + history.add(content(role = "user") { text(formattedDbEntries) }) + } - // Group messages by participant to create proper conversation turns + // 3. Group messages by participant to create proper conversation turns var currentUserContent = "" var currentModelContent = "" @@ -597,20 +621,30 @@ class PhotoReasoningViewModel( chat = generativeModel.startChat( history = history ) + } else { + // Ensure chat is reset even if history is empty (e.g. only system message was there and it's now blank) + chat = generativeModel.startChat(history = emptyList()) } } /** * Clear the chat history */ - fun clearChatHistory(context: android.content.Context? = null) { + fun clearChatHistory(context: Context? = null) { _chatState.clearMessages() _chatMessagesFlow.value = emptyList() - // Reset the chat with empty history - chat = generativeModel.startChat( - history = emptyList() - ) + val initialHistory = mutableListOf() + if (_systemMessage.value.isNotBlank()) { + initialHistory.add(content(role = "user") { text(_systemMessage.value) }) + } + context?.let { ctx -> + val formattedDbEntries = formatDatabaseEntriesAsText(ctx) + if (formattedDbEntries.isNotBlank()) { + initialHistory.add(content(role = "user") { text(formattedDbEntries) }) + } + } + chat = generativeModel.startChat(history = initialHistory.toList()) // Also clear from SharedPreferences if context is provided context?.let { @@ -627,7 +661,7 @@ class PhotoReasoningViewModel( */ fun addScreenshotToConversation( screenshotUri: Uri, - context: android.content.Context, + context: Context, screenInfo: String? = null ) { PhotoReasoningApplication.applicationScope.launch(Dispatchers.Main) { diff --git a/app/src/main/kotlin/com/google/ai/sample/util/SystemMessageEntry.kt b/app/src/main/kotlin/com/google/ai/sample/util/SystemMessageEntry.kt index 20f9961..d816624 100644 --- a/app/src/main/kotlin/com/google/ai/sample/util/SystemMessageEntry.kt +++ b/app/src/main/kotlin/com/google/ai/sample/util/SystemMessageEntry.kt @@ -1,9 +1,12 @@ package com.google.ai.sample.util +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable +@Parcelize @Serializable data class SystemMessageEntry( val title: String, val guide: String -) +) : Parcelable diff --git a/app/src/main/kotlin/com/google/ai/sample/util/SystemMessageEntryPreferences.kt b/app/src/main/kotlin/com/google/ai/sample/util/SystemMessageEntryPreferences.kt index 165ec11..b6280ad 100644 --- a/app/src/main/kotlin/com/google/ai/sample/util/SystemMessageEntryPreferences.kt +++ b/app/src/main/kotlin/com/google/ai/sample/util/SystemMessageEntryPreferences.kt @@ -19,7 +19,8 @@ object SystemMessageEntryPreferences { fun saveEntries(context: Context, entries: List) { try { val jsonString = Json.encodeToString(ListSerializer(SystemMessageEntry.serializer()), entries) - Log.d(TAG, "Saving entries: $jsonString") + Log.d(TAG, "Saving ${entries.size} entries. First entry title if exists: ${entries.firstOrNull()?.title}.") + // Log.v(TAG, "Saving JSON: $jsonString") // Verbose, uncomment if needed for deep debugging val editor = getSharedPreferences(context).edit() editor.putString(KEY_SYSTEM_MESSAGE_ENTRIES, jsonString) editor.apply() @@ -32,8 +33,10 @@ object SystemMessageEntryPreferences { try { val jsonString = getSharedPreferences(context).getString(KEY_SYSTEM_MESSAGE_ENTRIES, null) if (jsonString != null) { - Log.d(TAG, "Loaded entries: $jsonString") - return Json.decodeFromString(ListSerializer(SystemMessageEntry.serializer()), jsonString) + // Log.v(TAG, "Loaded JSON: $jsonString") // Verbose + val loadedEntries = Json.decodeFromString(ListSerializer(SystemMessageEntry.serializer()), jsonString) + Log.d(TAG, "Loaded ${loadedEntries.size} entries. First entry title if exists: ${loadedEntries.firstOrNull()?.title}.") + return loadedEntries } Log.d(TAG, "No entries found, returning empty list.") return emptyList() @@ -44,19 +47,22 @@ object SystemMessageEntryPreferences { } fun addEntry(context: Context, entry: SystemMessageEntry) { + Log.d(TAG, "Adding entry: Title='${entry.title}'") val entries = loadEntries(context).toMutableList() entries.add(entry) saveEntries(context, entries) } fun updateEntry(context: Context, oldEntry: SystemMessageEntry, newEntry: SystemMessageEntry) { + Log.d(TAG, "Updating entry: OldTitle='${oldEntry.title}', NewTitle='${newEntry.title}'") val entries = loadEntries(context).toMutableList() - val index = entries.indexOfFirst { it.title == oldEntry.title } // Assuming title is unique for now + val index = entries.indexOfFirst { it.title == oldEntry.title } if (index != -1) { entries[index] = newEntry saveEntries(context, entries) + Log.i(TAG, "Entry updated successfully: NewTitle='${newEntry.title}'") } else { - Log.w(TAG, "Entry with title '${oldEntry.title}' not found for update.") + Log.w(TAG, "Entry with old title '${oldEntry.title}' not found for update.") // Optionally, add the new entry if the old one is not found // addEntry(context, newEntry) } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/local.properties b/local.properties index 15d500e..8dbb40e 100644 --- a/local.properties +++ b/local.properties @@ -1,8 +1 @@ -## This file must *NOT* be checked into Version Control Systems, -# as it contains information specific to your local configuration. -# -# Location of the SDK. This is only used by Gradle. -# For customization when using a Version Control System, please read the -# header note. -#Tue Apr 01 14:08:59 CEST 2025 -sdk.dir=D\:\\AndroidStudioSDK +sdk.dir=/system/sdk