diff --git a/jetpacker/android/app/src/main/AndroidManifest.xml b/jetpacker/android/app/src/main/AndroidManifest.xml index bf3fa4ad..bcf7715e 100644 --- a/jetpacker/android/app/src/main/AndroidManifest.xml +++ b/jetpacker/android/app/src/main/AndroidManifest.xml @@ -64,6 +64,16 @@ + + + + diff --git a/jetpacker/android/app/src/main/res/xml/file_paths.xml b/jetpacker/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..3b8f328f --- /dev/null +++ b/jetpacker/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/DatabaseModule.kt b/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/DatabaseModule.kt index 7ae4d4d2..5da35935 100644 --- a/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/DatabaseModule.kt +++ b/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/DatabaseModule.kt @@ -23,6 +23,8 @@ import com.example.jetpacker.data.itinerary.DayThemeDao import com.example.jetpacker.data.itinerary.EventDao import com.example.jetpacker.data.itinerary.ExpenseDao import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager import com.example.jetpacker.data.trips.TripDao import dagger.Module import dagger.Provides @@ -68,8 +70,22 @@ object DatabaseModule { return db.expenseDao() } + @Provides + fun provideTravelDocumentDao(db: JetPackerDatabase): TravelDocumentDao { + return db.travelDocumentDao() + } + @Provides fun provideAssistantConversationDao(db: JetPackerDatabase): AssistantConversationDao { return db.assistantConversationDao() } + + @Provides + @Singleton + fun provideTravelDocumentManager( + @ApplicationContext context: Context, + travelDocumentDao: TravelDocumentDao, + ): TravelDocumentManager { + return TravelDocumentManager(context, travelDocumentDao) + } } diff --git a/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt b/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt index d2cfe3c0..820d65d5 100644 --- a/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt +++ b/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt @@ -33,6 +33,8 @@ import com.example.jetpacker.data.itinerary.MuseumDetail import com.example.jetpacker.data.itinerary.TimelineEvent import com.example.jetpacker.data.itinerary.TourDetail import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao import com.example.jetpacker.data.itinerary.VoiceNoteEntity import com.example.jetpacker.data.trips.Trip import com.example.jetpacker.data.trips.TripDao @@ -51,6 +53,7 @@ import com.example.jetpacker.data.trips.TripDao MuseumDetail::class, Expense::class, VoiceNoteEntity::class, + TravelDocument::class, AssistantConversation::class, ], version = 4, @@ -67,5 +70,7 @@ abstract class JetPackerDatabase : RoomDatabase() { abstract fun expenseDao(): ExpenseDao + abstract fun travelDocumentDao(): TravelDocumentDao + abstract fun assistantConversationDao(): AssistantConversationDao } diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DocumentSemanticSearchProvider.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DocumentSemanticSearchProvider.kt new file mode 100644 index 00000000..4fc183bb --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DocumentSemanticSearchProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +/** + * Provider interface for on-device semantic document search using ML Kit Embedding API + * (Embedding Gemma v2) and Jetpack AppSearch hybrid vector retrieval. + */ +interface DocumentSemanticSearchProvider { + /** + * Finds travel documents semantically relevant to [query]. + * + * @param query The natural language user query. + * @param tripId Optional trip ID filter. + * @param maxResults Maximum number of documents to return. + */ + suspend fun findRelevantDocuments( + query: String, + tripId: String? = null, + maxResults: Int = 10, + ): List + + /** Clears any cached search indexes, vectors, or metadata. */ + suspend fun clearIndex() {} + + /** Synchronizes the search index with current stored documents. */ + suspend fun syncIndex() {} +} diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocument.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocument.kt new file mode 100644 index 00000000..098136a4 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocument.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.UUID + +/** Room entity representing a travel document such as a passport or visa. */ +@Entity(tableName = "travel_documents") +data class TravelDocument( + @PrimaryKey @JvmField val id: String = UUID.randomUUID().toString(), + @JvmField val documentId: String = "", + @JvmField val documentType: String = "PASSPORT", + @JvmField val issuingCountry: String = "", + @JvmField val expiryDate: Long = 0L, + @JvmField val documentNumberMasked: String = "", + @JvmField val notes: String = "", + @JvmField val title: String = "", + @JvmField val filePath: String = "", + @JvmField val mimeType: String = "", + @JvmField val tripId: String? = null, + @JvmField val eventId: String? = null, +) diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentDao.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentDao.kt new file mode 100644 index 00000000..b9046c52 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentDao.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** Data Access Object (DAO) for managing travel documents such as passports and visas. */ +@Dao +interface TravelDocumentDao { + @Query("SELECT * FROM travel_documents ORDER BY expiryDate ASC") + fun getAllDocuments(): Flow> + + @Query( + "SELECT * FROM travel_documents WHERE UPPER(documentType) = UPPER(:documentType) ORDER BY expiryDate ASC" + ) + fun getDocumentsByType(documentType: String): Flow> + + @Query("SELECT * FROM travel_documents WHERE tripId = :tripId ORDER BY expiryDate ASC") + fun getDocumentsForTrip(tripId: String): Flow> + + @Query("SELECT * FROM travel_documents WHERE eventId = :eventId ORDER BY expiryDate ASC") + fun getDocumentsForEvent(eventId: String): Flow> + + @Query("SELECT * FROM travel_documents WHERE documentId = :documentId OR id = :documentId LIMIT 1") + suspend fun getDocumentById(documentId: String): TravelDocument? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDocument(document: TravelDocument) + + @Query( + "UPDATE travel_documents SET eventId = :eventId, tripId = CASE WHEN :tripId IS NOT NULL THEN :tripId ELSE tripId END WHERE documentId = :documentId OR id = :documentId" + ) + suspend fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String? = null) + + @Query( + "UPDATE travel_documents SET tripId = :tripId WHERE documentId = :documentId OR id = :documentId" + ) + suspend fun linkDocumentToTrip(documentId: String, tripId: String?) + + @Delete suspend fun deleteDocument(document: TravelDocument) + + @Query("DELETE FROM travel_documents WHERE id = :id") suspend fun deleteDocumentById(id: String) + + @Query("DELETE FROM travel_documents") suspend fun deleteAllDocuments() +} diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentManager.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentManager.kt new file mode 100644 index 00000000..b0552229 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentManager.kt @@ -0,0 +1,2406 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("GlobalCoroutineDispatchers") + +package com.example.jetpacker.data.itinerary + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.graphics.pdf.PdfRenderer +import android.media.ExifInterface +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns +import android.util.Log +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.util.Base64 +import java.util.Locale +import java.util.UUID +import java.util.zip.Inflater +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** Manages travel documents, local app-private file storage, and default seed documents. */ +open class TravelDocumentManager( + private val context: Context, + private val travelDocumentDao: TravelDocumentDao, +) { + + private val documentsDir: File by lazy { + File(context.filesDir, "travel_documents").apply { mkdirs() } + } + + val documents: Flow> = travelDocumentDao.getAllDocuments() + + /** + * Optional semantic search provider using on-device embedding models (Embedding Gemma v2) + * and AppSearch vector indexing. + */ + @Volatile + var semanticSearchProvider: DocumentSemanticSearchProvider? = null + + /** + * Executes a semantic search across travel documents using the registered [semanticSearchProvider]. + */ + open suspend fun searchDocumentsSemantically( + query: String, + tripId: String? = null, + maxResults: Int = 10, + ): List { + val provider = semanticSearchProvider ?: return emptyList() + return try { + provider.findRelevantDocuments(query, tripId, maxResults) + } catch (e: Exception) { + Log.w(TAG, "Semantic document search failed for query: $query", e) + emptyList() + } + } + + /** + * Imports a document from an external or shared [Uri] into app-private storage and saves it to + * Room. + */ + open suspend fun importDocumentFromUri( + uri: Uri, + overrideTitle: String? = null, + documentType: String = "OTHER", + issuingCountry: String = "", + expiryDate: Long = 0L, + documentNumberMasked: String = "", + notes: String = "", + tripId: String? = null, + eventId: String? = null, + ): TravelDocument = + withContext(Dispatchers.IO) { + val rawFilename = queryFilename(uri) ?: "document_${System.currentTimeMillis()}" + val originalFilename = File(rawFilename).name.replace(Regex("""[^a-zA-Z0-9._-]"""), "_") + val mimeType = + context.contentResolver.getType(uri) ?: getMimeTypeFromFilename(originalFilename) + val safePrefix = UUID.randomUUID().toString().take(8) + val destFile = File(documentsDir, "${safePrefix}_$originalFilename") + check(destFile.canonicalFile.startsWith(documentsDir.canonicalFile)) { + "Invalid destination file path" + } + + var copySuccess = false + try { + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(destFile).use { output -> + val bytes = input.copyTo(output) + copySuccess = bytes > 0L || destFile.exists() + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to copy URI stream to private file", e) + } + + if (!copySuccess || !destFile.exists()) { + destFile.delete() + error("Failed to copy document from URI: $uri") + } + + val extractedText = extractTextFromFile(destFile, mimeType) + val emailSubject = + if (isEmailMimeOrExt(mimeType, destFile.extension)) { + runCatching { parseEmailContent(destFile.readText()).subject }.getOrNull() + } else { + null + } + + val finalTitle = + overrideTitle?.takeIf { it.isNotBlank() } + ?: emailSubject?.takeIf { it.isNotBlank() } + ?: originalFilename + val resolvedType = + if (documentType == "OTHER") { + detectDocumentType(originalFilename, destFile, extractedText) + } else { + documentType + } + val resolvedNotes = notes.ifBlank { + extractedText?.take(MAX_AUTO_NOTES_CHARS)?.trim().orEmpty() + } + + val doc = + TravelDocument( + documentId = "doc_${UUID.randomUUID().toString().take(8)}", + documentType = resolvedType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = resolvedNotes, + title = finalTitle, + filePath = destFile.absolutePath, + mimeType = mimeType, + tripId = tripId, + eventId = eventId, + ) + + travelDocumentDao.insertDocument(doc) + doc + } + + /** + * Creates an in-app text document (e.g. itinerary notes, passport text, booking voucher, or + * shared email) in private storage. + */ + open suspend fun createTextDocument( + title: String, + content: String, + documentType: String = "OTHER", + issuingCountry: String = "", + expiryDate: Long = 0L, + documentNumberMasked: String = "", + notes: String = "", + tripId: String? = null, + eventId: String? = null, + ): TravelDocument = + withContext(Dispatchers.IO) { + val parsedEmail = + if (looksLikeEmailOrHtml(content)) { + parseEmailContent(content) + } else { + null + } + val cleanedContent = parsedEmail?.formattedSummary()?.ifBlank { content } ?: content + val resolvedTitle = + if ( + (title.isBlank() || title.equals("Shared Document", ignoreCase = true)) && + !parsedEmail?.subject.isNullOrBlank() + ) { + parsedEmail?.subject ?: title.ifBlank { "Untitled Document" } + } else { + title.ifBlank { "Untitled Document" } + } + + val safePrefix = UUID.randomUUID().toString().take(8) + val safeName = resolvedTitle.replace(Regex("""[^a-zA-Z0-9_-]"""), "_").take(32) + val file = File(documentsDir, "${safePrefix}_${safeName}.txt") + + try { + file.writeText(cleanedContent) + } catch (e: Exception) { + Log.e(TAG, "Failed to write text document to file", e) + } + + val resolvedType = + if (documentType == "OTHER") { + detectDocumentType(resolvedTitle, file, cleanedContent) + } else { + documentType + } + val resolvedNotes = notes.ifBlank { cleanedContent.take(MAX_AUTO_NOTES_CHARS).trim() } + + val doc = + TravelDocument( + documentId = "doc_${UUID.randomUUID().toString().take(8)}", + documentType = resolvedType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = resolvedNotes, + title = resolvedTitle, + filePath = file.absolutePath, + mimeType = "text/plain", + tripId = tripId, + eventId = eventId, + ) + + travelDocumentDao.insertDocument(doc) + doc + } + + /** + * Saves a document bitmap (e.g. from camera capture) into private app storage and inserts Room + * metadata. + */ + open suspend fun saveDocumentBitmap( + bitmap: Bitmap, + title: String, + documentType: String = "OTHER", + issuingCountry: String = "", + expiryDate: Long = 0L, + documentNumberMasked: String = "", + notes: String = "", + tripId: String? = null, + eventId: String? = null, + ): TravelDocument = + withContext(Dispatchers.IO) { + val safePrefix = UUID.randomUUID().toString().take(8) + val safeName = title.replace(Regex("""[^a-zA-Z0-9_-]"""), "_").take(32).ifBlank { "scan" } + val file = File(documentsDir, "${safePrefix}_${safeName}.jpg") + + try { + FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) } + } catch (e: Exception) { + Log.e(TAG, "Failed to write bitmap document to file", e) + } + + val doc = + TravelDocument( + documentId = "doc_${UUID.randomUUID().toString().take(8)}", + documentType = documentType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = notes, + title = title, + filePath = file.absolutePath, + mimeType = "image/jpeg", + tripId = tripId, + eventId = eventId, + ) + + travelDocumentDao.insertDocument(doc) + doc + } + + /** + * Decodes a high-resolution bitmap from an image [Uri], scaling down large images to + * [maxDimension] to prevent OOM errors while preserving fine print for vision models, and + * correcting EXIF rotation. + */ + open suspend fun loadBitmapFromUri(uri: Uri, maxDimension: Int = 1920): Bitmap? = + withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Loading bitmap from uri: $uri") + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + val streamOpened = + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, boundsOptions) + true + } ?: false + + if (!streamOpened || boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) { + Log.e( + TAG, + "Failed to read image bounds for $uri: streamOpened=$streamOpened, w=${boundsOptions.outWidth}, h=${boundsOptions.outHeight}", + ) + return@withContext null + } + + Log.d( + TAG, + "Original image dimensions: ${boundsOptions.outWidth}x${boundsOptions.outHeight}", + ) + + var sampleSize = 1 + val maxSide = maxOf(boundsOptions.outWidth, boundsOptions.outHeight) + if (maxSide > maxDimension) { + while ((maxSide / (sampleSize * 2)) >= maxDimension) { + sampleSize *= 2 + } + } + + val decodeOptions = + BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + val decoded = + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, decodeOptions) + } + + if (decoded == null) { + Log.e(TAG, "BitmapFactory failed to decode stream for $uri with sampleSize=$sampleSize") + return@withContext null + } + + Log.d( + TAG, + "Decoded bitmap size: ${decoded.width}x${decoded.height}, sampleSize=$sampleSize", + ) + + val orientation = + try { + context.contentResolver.openInputStream(uri)?.use { stream -> + val exif = ExifInterface(stream) + exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + } ?: ExifInterface.ORIENTATION_NORMAL + } catch (e: Exception) { + Log.w(TAG, "Could not read EXIF orientation from $uri", e) + ExifInterface.ORIENTATION_NORMAL + } + + val rotationDegrees = + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90f + ExifInterface.ORIENTATION_ROTATE_180 -> 180f + ExifInterface.ORIENTATION_ROTATE_270 -> 270f + else -> 0f + } + + if (rotationDegrees != 0f) { + Log.d(TAG, "Rotating bitmap by $rotationDegrees degrees based on EXIF") + val matrix = Matrix().apply { postRotate(rotationDegrees) } + val rotated = + Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true) + if (rotated != decoded) { + decoded.recycle() + } + rotated + } else { + decoded + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load bitmap from uri: $uri", e) + null + } + } + + open fun getDocumentsForTrip(tripId: String): Flow> = + travelDocumentDao.getDocumentsForTrip(tripId) + + open fun getDocumentsForEvent(eventId: String): Flow> = + travelDocumentDao.getDocumentsForEvent(eventId) + + open suspend fun linkDocumentToEvent( + documentId: String, + eventId: String?, + tripId: String? = null, + ) = + withContext(Dispatchers.IO) { + travelDocumentDao.linkDocumentToEvent(documentId, eventId, tripId) + } + + open suspend fun linkDocumentToTrip(documentId: String, tripId: String?) = + withContext(Dispatchers.IO) { travelDocumentDao.linkDocumentToTrip(documentId, tripId) } + + open suspend fun updateDocument(document: TravelDocument) = + withContext(Dispatchers.IO) { travelDocumentDao.insertDocument(document) } + + /** Returns true when [document] is backed by a raster image file. */ + open fun isImageDocument(document: TravelDocument): Boolean { + val ext = document.filePath.substringAfterLast('.', "").lowercase() + return document.mimeType.startsWith("image/", ignoreCase = true) || + ext in setOf("png", "jpg", "jpeg", "webp") + } + + /** Returns true when [document] is backed by a PDF file. */ + open fun isPdfDocument(document: TravelDocument): Boolean { + val ext = document.filePath.substringAfterLast('.', "").lowercase() + return document.mimeType.equals("application/pdf", ignoreCase = true) || ext == "pdf" + } + + /** Returns true when [document] is an email file (.eml / message/rfc822). */ + open fun isEmailDocument(document: TravelDocument): Boolean { + val ext = document.filePath.substringAfterLast('.', "").lowercase() + return isEmailMimeOrExt(document.mimeType, ext) + } + + /** + * Returns true when [document] can be rendered into a [Bitmap] for multimodal embedding or Gemini + * Nano vision extraction (images and PDFs). + */ + open fun isVisualDocument(document: TravelDocument): Boolean = + document.filePath.isNotBlank() && (isImageDocument(document) || isPdfDocument(document)) + + /** + * Loads a [Bitmap] representation of [document] for multimodal embedding (`ImagePart`) or Gemini + * Nano vision scanning. + * - For image documents, decodes and downsamples the file to [maxDimension]. + * - For PDF documents, renders page 0 via Android's [PdfRenderer] onto a white canvas. + */ + open fun loadDocumentBitmap(document: TravelDocument, maxDimension: Int = 1024): Bitmap? { + if (document.filePath.isBlank()) return null + val file = File(document.filePath) + if (!file.exists() || !file.isFile) return null + return when { + isPdfDocument(document) -> + renderPdfPageToBitmap(file, pageIndex = 0, maxDimension = maxDimension) + isImageDocument(document) -> decodeFileBitmap(file, maxDimension = maxDimension) + else -> null + } + } + + /** + * Renders a single page of a PDF [file] into an [Bitmap.Config.ARGB_8888] [Bitmap] with a white + * background using Android's built-in [PdfRenderer]. + */ + open fun renderPdfPageToBitmap( + file: File, + pageIndex: Int = 0, + maxDimension: Int = 1024, + ): Bitmap? { + if (!file.exists() || !file.isFile) return null + return try { + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + PdfRenderer(pfd).use { renderer -> + if (renderer.pageCount <= pageIndex) return null + renderer.openPage(pageIndex).use { page -> + val rawW = page.width.coerceAtLeast(1) + val rawH = page.height.coerceAtLeast(1) + val maxSide = maxOf(rawW, rawH) + val scale = + if (maxSide > maxDimension) { + maxDimension.toFloat() / maxSide.toFloat() + } else { + // Upscale standard 72dpi PDF pages slightly for crisp OCR/embedding, capped at + // maxDimension. + minOf(2f, maxDimension.toFloat() / maxSide.toFloat()) + } + val targetW = (rawW * scale).toInt().coerceAtLeast(1) + val targetH = (rawH * scale).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bitmap + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to render PDF page to bitmap: ${file.name}", e) + null + } + } + + private fun decodeFileBitmap(file: File, maxDimension: Int): Bitmap? { + return try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return BitmapFactory.decodeFile(file.absolutePath) + } + var sampleSize = 1 + val maxSide = maxOf(bounds.outWidth, bounds.outHeight) + if (maxSide > maxDimension) { + while ((maxSide / (sampleSize * 2)) >= maxDimension) { + sampleSize *= 2 + } + } + val opts = + BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + BitmapFactory.decodeFile(file.absolutePath, opts) + } catch (e: Exception) { + Log.w(TAG, "Failed to decode image file: ${file.name}", e) + null + } + } + + /** + * Builds a concise text representation of [document] suitable for on-device embedding models + * (~256 token window) and AppSearch full-text indexing. + * + * Combines metadata (`title`, `documentType`, `issuingCountry`, `documentNumberMasked`, `notes`) + * with extracted text from backing `.txt`, `.eml`, `.html`, or `.pdf` files when available. + */ + open fun readDocumentEmbeddingText(document: TravelDocument, maxChars: Int = 1200): String { + val fileText = + if (document.filePath.isNotBlank()) { + val file = File(document.filePath) + if (file.exists() && file.isFile) { + extractTextFromFile(file, document.mimeType) + } else { + null + } + } else { + null + } + + val combinedBody = + when { + fileText.isNullOrBlank() -> document.notes + document.notes.isBlank() -> fileText + fileText.startsWith(document.notes.take(80)) -> fileText + document.notes.contains(fileText.take(80)) -> document.notes + else -> "${document.notes} $fileText" + } + + return buildString { + append("${document.title} (${document.documentType}) ") + if (document.issuingCountry.isNotBlank()) { + append("Country: ${document.issuingCountry} ") + } + if (document.documentNumberMasked.isNotBlank()) { + append("ID: ${document.documentNumberMasked} ") + } + if (combinedBody.isNotBlank()) { + append(combinedBody) + } + } + .replace(Regex("""\s+"""), " ") + .trim() + .take(maxChars) + } + + /** Reads the content of a document file for inspection or model consumption. */ + open suspend fun readDocumentContent(document: TravelDocument): String = + withContext(Dispatchers.IO) { + val fileContent: String? = + if (document.filePath.isNotBlank()) { + val file = File(document.filePath) + if (file.exists() && file.isFile) { + extractTextFromFile(file, document.mimeType) + } else { + null + } + } else { + null + } + + buildString { + append("Document: ${document.title} (${document.documentType})\n") + if (document.issuingCountry.isNotBlank()) + append("Issuing Country: ${document.issuingCountry}\n") + if (document.documentNumberMasked.isNotBlank()) + append("Document Number: ${document.documentNumberMasked}\n") + if (document.expiryDate > 0L) { + try { + val expiry = Instant.ofEpochMilli(document.expiryDate).toString().substringBefore('T') + append("Expiry Date: $expiry\n") + } catch (_: Exception) {} + } + if (document.notes.isNotBlank()) append("Details/Notes:\n${document.notes}\n") + if (!fileContent.isNullOrBlank()) { + append("File Content:\n$fileContent\n") + } else if (document.filePath.isNotBlank()) { + val f = File(document.filePath) + if (f.exists()) { + append("Attached File: ${f.name} (${f.length()} bytes, MIME: ${document.mimeType})\n") + } + } + } + .trim() + } + + /** Deletes a document from both private storage and the Room database. */ + open suspend fun deleteDocument(document: TravelDocument) = + withContext(Dispatchers.IO) { + if (document.filePath.isNotBlank()) { + try { + val file = File(document.filePath) + if (file.exists()) file.delete() + } catch (e: Exception) { + Log.w(TAG, "Failed to delete file ${document.filePath}", e) + } + } + travelDocumentDao.deleteDocument(document) + } + + /** + * Pre-populates a diverse set of mock travel documents across all supported modalities (`.txt`, + * `.pdf`, `.eml`, and `.jpg`) on initial launch, and automatically upgrades legacy 3-file + * plain-text seed states. + */ + open suspend fun seedDefaultDocumentsIfEmpty() = + withContext(Dispatchers.IO) { + val existing = travelDocumentDao.getAllDocuments().first() + if (existing.isNotEmpty()) { + val hasMultimodalSeed = + existing.any { it.mimeType == "application/pdf" } && + existing.any { it.mimeType == "message/rfc822" } + val hasLegacySeed = existing.any { + it.title == "Passport (Netherlands)" || + it.title == "US ESTA Travel Authorization" || + it.title == "Flight Voucher - United Airlines UA 991" + } + if (!hasMultimodalSeed && hasLegacySeed) { + val legacyTitles = setOf( + "Passport (Netherlands)", + "US ESTA Travel Authorization", + "Flight Voucher - United Airlines UA 991", + ) + existing.filter { it.title in legacyTitles }.forEach { deleteDocument(it) } + seedDiverseMockDocuments() + runCatching { semanticSearchProvider?.syncIndex() } + } + return@withContext + } + seedDiverseMockDocuments() + } + + /** + * Clears all existing travel documents from Room, deletes their private storage files, + * and clears the semantic search index. + */ + open suspend fun clearAllDocuments() = + withContext(Dispatchers.IO) { + val existing = travelDocumentDao.getAllDocuments().first() + for (doc in existing) { + if (doc.filePath.isNotBlank()) { + runCatching { File(doc.filePath).takeIf { it.exists() }?.delete() } + } + } + runCatching { + documentsDir.listFiles()?.forEach { file -> + if (file.isFile) file.delete() + } + } + travelDocumentDao.deleteAllDocuments() + try { + semanticSearchProvider?.clearIndex() + } catch (e: Exception) { + Log.w(TAG, "Failed to clear semantic search index", e) + } + } + + /** + * Clears all existing travel documents and private files, then seeds the fresh 8-document + * multimodal mock set covering `.txt`, `.pdf`, `.eml`, and `.jpg` across demo trips. + */ + open suspend fun resetDefaultDocuments() = + withContext(Dispatchers.IO) { + clearAllDocuments() + seedDiverseMockDocuments() + try { + semanticSearchProvider?.syncIndex() + } catch (e: Exception) { + Log.w(TAG, "Failed to re-index documents after reset", e) + } + } + + private suspend fun seedDiverseMockDocuments() { + // 1. Global Text Document: Dutch Biometric Passport (.txt) + val passportContent = + """ + PASSPORT / PASSEPORT + Type: P + Issuing State: NLD (Kingdom of the Netherlands) + Passport No: ••••••4821 + Surname: CHEN + Given Names: ALEX + Nationality: NEDERLANDSE / DUTCH + Date of Birth: 12 AUG 1992 + Sex: F + Date of Issue: 15 NOV 2019 + Date of Expiry: 15 NOV 2029 + Authority: BURGEMEESTER VAN AMSTERDAM + Type: Biometric with electronic RFID chip. + Emergency Contact: Netherlands Ministry of Foreign Affairs +31 247 247 247 + """ + .trimIndent() + + createTextDocument( + title = "Passport (Netherlands)", + content = passportContent, + documentType = "PASSPORT", + issuingCountry = "Netherlands", + expiryDate = Instant.parse("2029-11-15T00:00:00Z").toEpochMilli(), + documentNumberMasked = "••••••4821", + notes = "Valid EU biometric travel passport", + ) + + // 2. Visa / Travel Authorization Text Document: US ESTA (.txt) + val estaContent = + """ + U.S. CUSTOMS AND BORDER PROTECTION + ELECTRONIC SYSTEM FOR TRAVEL AUTHORIZATION (ESTA) + Application Number: ESTA-77492109 + Status: AUTHORIZATION APPROVED + Passport Number: ••••••4821 + Issuing Country: Netherlands + Expiration Date: April 10, 2027 + Valid for multiple entries to the United States for business or pleasure for up to 90 days per visit. + Port of Entry: San Francisco International Airport (SFO) - Global Entry Lane Eligible + """ + .trimIndent() + + createTextDocument( + title = "US ESTA Travel Authorization", + content = estaContent, + documentType = "VISA", + issuingCountry = "United States", + expiryDate = Instant.parse("2027-04-10T00:00:00Z").toEpochMilli(), + documentNumberMasked = "ESTA-••••9012", + notes = "Approved ESTA waiver for US travel (Global Entry eligible)", + tripId = "2026-1", + eventId = null, + ) + + // 3. PDF Boarding Pass & E-Ticket: United Airlines UA 991 (.pdf) + createSeedPdfDocument( + filename = "UA991_Boarding_Pass_SFO.pdf", + title = "Flight Voucher - United Airlines UA 991", + lines = + listOf( + "UNITED AIRLINES - ELECTRONIC TICKET & BOARDING PASS", + "Confirmation Reference (PNR): KL8821UA", + "Passenger: ALEX CHEN (Premier Gold / Star Alliance Gold)", + "Flight: UA 991 - Boeing 777-300ER", + "Depart: CDG Paris Charles de Gaulle - Oct 27, 2026 @ 09:00 AM (Terminal 2E, Gate K42)", + "Arrive: SFO San Francisco Intl - Oct 27, 2026 @ 12:15 PM (Intl Terminal G, Gate G94)", + "Seat: 32B (Economy Plus - Extra Legroom Window)", + "Boarding Group: Group 1 (Premier Access)", + "Baggage Allowance: 2 checked bags (23kg each) + 1 carry-on + 1 personal item", + "Wi-Fi Voucher Code: UA-WIFI-883920", + ), + documentType = "TICKET", + issuingCountry = "United States", + expiryDate = Instant.parse("2026-11-05T00:00:00Z").toEpochMilli(), + documentNumberMasked = "UA991-KL8821UA", + notes = + "PDF E-Ticket & Boarding Pass • Flight UA 991 CDG->SFO • Gate K42 • Seat 32B • PNR KL8821UA • Baggage 2x23kg", + tripId = "2026-1", + eventId = "io_1", + ) + + // 4. Email Reservation (.eml with HTML): The Redwood Grove Hotel + val redwoodEmailHtml = + """ + From: "The Redwood Grove Hotel Reservations" + To: "Alex Chen" + Subject: Confirmation #RG-90421: The Redwood Grove Hotel (Oct 27 - Nov 1) + Date: Mon, 12 Oct 2026 14:22:00 -0700 + MIME-Version: 1.0 + Content-Type: text/html; charset="UTF-8" + + + +

The Redwood Grove Hotel — Mountain View

+

Dear Alex Chen, your reservation for Android Dev Summit '26 is confirmed.

+ + + + + + + + +
Confirmation Number:RG-90421-MV
Room Type:Room 412 — Deluxe King Suite (Redwood Courtyard View)
Check-in:Tuesday, October 27, 2026 after 3:00 PM (Digital Key enabled)
Check-out:Sunday, November 1, 2026 at 11:00 AM (Late check-out 1:00 PM approved)
Guest Wi-Fi:Network: RedwoodGuest / Passcode: SEQUOIA2026
Breakfast & Amenities:Complimentary breakfast 6:30 AM - 10:00 AM at Canopy Bistro; EV Level-2 chargers in Garage B2.
Conference Shuttle:Direct shuttle to Shoreline Amphitheatre departs every 20 minutes from Main Lobby.
+ + + """ + .trimIndent() + + createSeedEmailDocument( + filename = "redwood_grove_hotel_confirmation.eml", + title = "The Redwood Grove Hotel — Booking Email", + rawEml = redwoodEmailHtml, + documentType = "HOTEL", + issuingCountry = "United States", + expiryDate = Instant.parse("2026-11-02T00:00:00Z").toEpochMilli(), + documentNumberMasked = "RG-90421-MV", + tripId = "2026-1", + eventId = "io_3", + ) + + // 5. PDF Rental Voucher: Zephyr Wheels EV Rental @ SFO (.pdf) + createSeedPdfDocument( + filename = "Zephyr_EV_Rental_SFO.pdf", + title = "Zephyr Wheels Confirmation", + lines = + listOf( + "ZEPHYR WHEELS ELECTRIC VEHICLE RENTAL - SFO AIRPORT", + "Reservation Code: ZW-55820-SFO", + "Primary Driver: Alex Chen", + "Vehicle Class: Polestar 2 Long Range Dual Motor (Full Electric EV)", + "Pick-up Location: SFO Rental Car Center, Level 4, Express Stall #18", + "Pick-up Time: Tuesday, October 27, 2026 @ 1:30 PM", + "Return Time: Sunday, November 1, 2026 @ 4:00 PM (Return with at least 20% battery)", + "Toll Pass Included: Bay Area FasTrak Transponder #FT-883920 (All bridge & express lanes)", + "Charging Perk: Unlimited complimentary fast charging at Electrify America stations (PIN: 2026)", + "Insurance Coverage: Full Loss Damage Waiver (LDW) + Zero Deductible Roadside Assistance", + ), + documentType = "OTHER", + issuingCountry = "United States", + expiryDate = Instant.parse("2026-11-02T00:00:00Z").toEpochMilli(), + documentNumberMasked = "ZW-55820-SFO", + notes = + "PDF EV Car Rental Voucher • Polestar 2 at SFO Level 4 Stall 18 • FasTrak #FT-883920 • Charging PIN 2026", + tripId = "2026-1", + eventId = "io_2", + ) + + // 6. Scanned Image Card (.jpg): SFMOMA VIP Press & Museum Pass + createSeedImageDocument( + title = "SFMOMA VIP Museum & Exhibition Pass", + headerBanner = "SFMOMA • SAN FRANCISCO MUSEUM OF MODERN ART", + accentColor = Color.rgb(183, 28, 28), + cardLines = + listOf( + "Pass ID: SFM-2026-8841 (VIP Press & Member All-Access)", + "Holder: Alex Chen", + "Timed Entry: Thursday, Oct 29, 2026 @ 2:00 PM", + "Special Exhibition: Digital Horizons & AI Art (Floor 5)", + "Audio Guide Code: SFMOMA-AI-77 • Coat Check Locker: #C-42", + ), + documentType = "TICKET", + issuingCountry = "United States", + expiryDate = Instant.parse("2026-10-30T00:00:00Z").toEpochMilli(), + documentNumberMasked = "SFM-2026-8841", + notes = + "Scanned VIP Museum Pass Image • SFMOMA Floor 5 Digital Horizons exhibition • Audio Guide SFMOMA-AI-77 • Locker #C-42", + tripId = "2026-1", + eventId = "io_5", + ) + + // 7. Email E-Ticket (.eml with HTML): Eiffel Tower Summit & Seine River Cruise (Paris Trip) + val parisEmailHtml = + """ + From: "Societe d'Exploitation de la Tour Eiffel" + To: "Alex Chen" + Subject: Vos billets Tour Eiffel Sommet #EF-44210 & Croisiere Seine + Date: Thu, 01 Oct 2026 09:15:00 +0200 + MIME-Version: 1.0 + Content-Type: text/html; charset="UTF-8" + + + +

Eiffel Tower Summit Access & Champagne River Cruise

+

Booking Reference: EF-44210-PAR — 2 Adult Summit Elevator Tickets

+
    +
  • Entry Gate: Pilier Sud (South Security Entrance, Green Fast-Track Lane)
  • +
  • Summit Elevator Time Slot: 21:00 (Arrive 20 minutes early for security screening)
  • +
  • Included Access: 1st Floor Glass Walk, 2nd Floor Observation Deck, and Top Summit Champagne Bar
  • +
  • Seine Illuminations Cruise: Bateaux Parisiens Pier 3 (Port de la Bourdonnais) — QR Code SEINE-9921
  • +
+ + + """ + .trimIndent() + + createSeedEmailDocument( + filename = "eiffel_tower_summit_tickets.eml", + title = "Eiffel Tower Summit & Seine Cruise — Email", + rawEml = parisEmailHtml, + documentType = "TICKET", + issuingCountry = "France", + expiryDate = Instant.parse("2026-12-31T00:00:00Z").toEpochMilli(), + documentNumberMasked = "EF-44210-PAR", + tripId = "2026-3", + eventId = "par_5", + ) + + // 8. Scanned Image Card (.jpg): JR East Welcome Suica & Shinkansen IC Card (Tokyo Trip) + createSeedImageDocument( + title = "JR East Welcome Suica & Shinkansen IC Card", + headerBanner = "JR EAST • WELCOME SUICA & SHINKANSEN EX-IC", + accentColor = Color.rgb(27, 94, 32), + cardLines = + listOf( + "IC Card Number: JE4092-8812-3301 (No deposit required)", + "Preloaded Transit Balance: 10,000 JPY (Subway, JR Lines & Konbini)", + "Linked Bullet Train: Tokaido Shinkansen Nozomi 24 (Tokyo -> Kyoto)", + "Reserved Seat: Car 8, Seat 12E (Mt. Fuji Window Side + Oversized Baggage)", + "Coin Locker PIN at Tokyo Station Yaesu North Exit: 8419", + ), + documentType = "TICKET", + issuingCountry = "Japan", + expiryDate = Instant.parse("2026-12-15T00:00:00Z").toEpochMilli(), + documentNumberMasked = "JE4092-3301", + notes = + "Scanned IC Transit Card Image • Welcome Suica 10,000 JPY • Linked Shinkansen Nozomi 24 Car 8 Seat 12E • Tokyo Station Locker PIN 8419", + tripId = "2026-4", + eventId = null, + ) + } + + private suspend fun createSeedPdfDocument( + filename: String, + title: String, + lines: List, + documentType: String, + issuingCountry: String, + expiryDate: Long, + documentNumberMasked: String, + notes: String, + tripId: String? = null, + eventId: String? = null, + ): TravelDocument { + val safePrefix = UUID.randomUUID().toString().take(8) + val destFile = File(documentsDir, "${safePrefix}_$filename") + destFile.writeBytes(buildPdfDocumentBytes(filename, title, lines)) + val docId = "${documentType}_${UUID.randomUUID().toString().take(8)}" + val doc = + TravelDocument( + id = UUID.randomUUID().toString(), + documentId = docId, + documentType = documentType.uppercase(), + title = title, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + filePath = destFile.absolutePath, + mimeType = "application/pdf", + notes = notes, + tripId = tripId, + eventId = eventId, + ) + travelDocumentDao.insertDocument(doc) + return doc + } + + private suspend fun createSeedEmailDocument( + filename: String, + title: String, + rawEml: String, + documentType: String, + issuingCountry: String, + expiryDate: Long, + documentNumberMasked: String, + tripId: String? = null, + eventId: String? = null, + ): TravelDocument { + val safePrefix = UUID.randomUUID().toString().take(8) + val destFile = File(documentsDir, "${safePrefix}_$filename") + destFile.writeText(rawEml) + val parsedSummary = parseEmailContent(rawEml).formattedSummary().take(MAX_AUTO_NOTES_CHARS) + val docId = "${documentType}_${UUID.randomUUID().toString().take(8)}" + val doc = + TravelDocument( + id = UUID.randomUUID().toString(), + documentId = docId, + documentType = documentType.uppercase(), + title = title, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + filePath = destFile.absolutePath, + mimeType = "message/rfc822", + notes = parsedSummary, + tripId = tripId, + eventId = eventId, + ) + travelDocumentDao.insertDocument(doc) + return doc + } + + private suspend fun createSeedImageDocument( + title: String, + headerBanner: String, + accentColor: Int, + cardLines: List, + documentType: String, + issuingCountry: String, + expiryDate: Long, + documentNumberMasked: String, + notes: String, + tripId: String? = null, + eventId: String? = null, + ): TravelDocument { + val width = 640 + val height = 380 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + // Card background + canvas.drawColor(Color.rgb(250, 250, 252)) + + // Colored header banner + paint.color = accentColor + canvas.drawRect(0f, 0f, width.toFloat(), 64f, paint) + + // Header text + paint.color = Color.WHITE + paint.textSize = 20f + paint.isFakeBoldText = true + canvas.drawText(headerBanner, 24f, 40f, paint) + + // Document title + paint.color = Color.rgb(25, 28, 32) + paint.textSize = 22f + canvas.drawText(title, 24f, 104f, paint) + + // Body lines + paint.isFakeBoldText = false + paint.textSize = 16f + paint.color = Color.rgb(55, 60, 68) + var y = 144f + for (line in cardLines) { + canvas.drawText(line, 24f, y, paint) + y += 34f + } + + // Simulated high-contrast QR code block in the bottom-right corner for visual realism + paint.color = Color.rgb(30, 30, 30) + val qrOriginX = width - 116f + val qrOriginY = height - 116f + val cell = 10f + for (row in 0 until 9) { + for (col in 0 until 9) { + val isFinder = + (row in 0..2 && col in 0..2) || + (row in 0..2 && col in 6..8) || + (row in 6..8 && col in 0..2) + val isPattern = (row * 7 + col * 13 + title.length) % 3 == 0 + if (isFinder || isPattern) { + canvas.drawRect( + qrOriginX + col * cell, + qrOriginY + row * cell, + qrOriginX + (col + 1) * cell - 1f, + qrOriginY + (row + 1) * cell - 1f, + paint, + ) + } + } + } + + val saved = + saveDocumentBitmap( + bitmap = bitmap, + title = title, + documentType = documentType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = notes, + tripId = tripId, + eventId = eventId, + ) + return saved + } + + /** + * Generates a rich, realistic PDF document binary using Android's native [PdfDocument] canvas API. + * Renders high-fidelity branded travel vouchers (e.g. Zephyr Wheels EV Rental, United Airlines Boarding Pass) + * that display sharply in [PdfRenderer] and keynote presentation slides. + */ + private fun buildPdfDocumentBytes( + filename: String, + title: String, + lines: List, + ): ByteArray { + return try { + when { + filename.contains("Zephyr", ignoreCase = true) -> buildZephyrRentalPdf() + filename.contains("UA991", ignoreCase = true) -> buildFlightTicketPdf() + else -> buildGenericPdf(title, lines) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to render visual PDF via PdfDocument; falling back to basic PDF", e) + buildSinglePagePdfBytes(lines) + } + } + + private fun buildZephyrRentalPdf(): ByteArray { + val pdfDoc = PdfDocument() + val pageInfo = PdfDocument.PageInfo.Builder(612, 792, 1).create() + val page = pdfDoc.startPage(pageInfo) + val canvas = page.canvas + + // Clean background + canvas.drawColor(Color.rgb(248, 250, 252)) + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val left = 24f + val right = 588f + + // 1. Header Banner (Deep emerald #0A2E26) + paint.color = Color.rgb(10, 46, 38) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(left, 24f, right, 120f), 14f, 14f, paint) + + // Brand logo & name + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 22f + canvas.drawText("ZEPHYR WHEELS", left + 20f, 62f, paint) + + // Electric vehicle mobility tagline + paint.color = Color.rgb(167, 243, 208) + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10f + canvas.drawText("ELECTRIC MOBILITY • SFO PREMIER FLEET", left + 20f, 82f, paint) + + // Status Pill on top right + val pillRight = right - 20f + val pillLeft = pillRight - 116f + paint.color = Color.rgb(16, 185, 129) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(pillLeft, 42f, pillRight, 70f), 14f, 14f, paint) + + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11f + canvas.drawText("● CONFIRMED", pillLeft + 14f, 60f, paint) + + // Reservation Ref below status pill + paint.color = Color.rgb(226, 232, 240) + paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + paint.textSize = 11f + canvas.drawText("RES: ZW-55820-SFO", pillLeft - 22f, 96f, paint) + + // 2. Reserved Vehicle Showcase Card + val vehTop = 132f + val vehBottom = 260f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, vehTop, right, vehBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, vehTop, right, vehBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + // Section caption + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("RESERVED VEHICLE SPECIFICATION", left + 18f, vehTop + 24f, paint) + + // Vehicle Name + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 18f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("Polestar 2 Long Range Dual Motor", left + 18f, vehTop + 50f, paint) + + // Vehicle category description + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 11f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText( + "Class: Premium Full-Electric Sedan • All-Wheel Drive (AWD) • Void Black", + left + 18f, + vehTop + 70f, + paint, + ) + + // Badges Row + fun drawBadge(x: Float, y: Float, text: String, bgColor: Int, textColor: Int, borderColor: Int): Float { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + val textW = paint.measureText(text) + val padH = 10f + val badgeW = textW + (padH * 2) + val badgeH = 22f + + paint.style = Paint.Style.FILL + paint.color = bgColor + canvas.drawRoundRect(RectF(x, y, x + badgeW, y + badgeH), 6f, 6f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1f + paint.color = borderColor + canvas.drawRoundRect(RectF(x, y, x + badgeW, y + badgeH), 6f, 6f, paint) + + paint.style = Paint.Style.FILL + paint.color = textColor + canvas.drawText(text, x + padH, y + 15f, paint) + return badgeW + } + + var badgeX = left + 18f + val badgeY = vehTop + 88f + badgeX += + drawBadge( + badgeX, + badgeY, + "⚡ 100% Electric", + Color.rgb(236, 253, 245), + Color.rgb(6, 95, 70), + Color.rgb(167, 243, 208), + ) + 8f + badgeX += + drawBadge( + badgeX, + badgeY, + "🔋 300+ mi Range", + Color.rgb(239, 246, 255), + Color.rgb(30, 64, 175), + Color.rgb(191, 219, 254), + ) + 8f + badgeX += + drawBadge( + badgeX, + badgeY, + "🏎 408 hp AWD", + Color.rgb(248, 250, 252), + Color.rgb(51, 65, 85), + Color.rgb(226, 232, 240), + ) + 8f + drawBadge( + badgeX, + badgeY, + "✦ 85%+ Battery Guaranteed", + Color.rgb(254, 243, 199), + Color.rgb(146, 64, 14), + Color.rgb(253, 230, 138), + ) + + // 3. Rental Itinerary Grid (Two Columns: Pick-up & Return) + val itinTop = 272f + val itinBottom = 416f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, itinTop, right, itinBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, itinTop, right, itinBottom), 14f, 14f, paint) + + // Vertical Divider + val midX = (left + right) / 2f + paint.color = Color.rgb(241, 245, 249) + paint.strokeWidth = 1.5f + canvas.drawLine(midX, itinTop + 14f, midX, itinBottom - 14f, paint) + paint.style = Paint.Style.FILL + + // Column 1: PICK-UP + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(5, 150, 105) + canvas.drawText("▲ PICK-UP (DEPARTURE)", left + 18f, itinTop + 26f, paint) + + paint.textSize = 13.5f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("Tuesday, Oct 27, 2026 @ 1:30 PM", left + 18f, itinTop + 48f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11.5f + paint.color = Color.rgb(30, 41, 59) + canvas.drawText("SFO International Airport", left + 18f, itinTop + 72f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Rental Car Center • Level 4, Express Stall #18", left + 18f, itinTop + 90f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(37, 99, 235) + canvas.drawText("✓ Direct Stall Access • Digital Key Active in App", left + 18f, itinTop + 116f, paint) + + // Column 2: RETURN + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(220, 38, 38) + canvas.drawText("▼ RETURN (DROP-OFF)", midX + 18f, itinTop + 26f, paint) + + paint.textSize = 13.5f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("Sunday, Nov 1, 2026 @ 4:00 PM", midX + 18f, itinTop + 48f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11.5f + paint.color = Color.rgb(30, 41, 59) + canvas.drawText("SFO International Airport", midX + 18f, itinTop + 72f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Rental Car Center • Level 4, Express Key Drop", midX + 18f, itinTop + 90f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("✓ Return with ≥ 20% battery (No recharge fee)", midX + 18f, itinTop + 116f, paint) + + // 4. Inclusions & Driver Details Card + val incTop = 428f + val incBottom = 570f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, incTop, right, incBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, incTop, right, incBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("EXCLUSIVE INCLUSIONS & DRIVER CREDENTIALS", left + 18f, incTop + 24f, paint) + + var rowY = incTop + 48f + fun drawInclusionRow(label: String, value: String, highlight: Boolean = false) { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText(label, left + 18f, rowY, paint) + + paint.typeface = + Typeface.create(Typeface.SANS_SERIF, if (highlight) Typeface.BOLD else Typeface.NORMAL) + paint.textSize = 11f + paint.color = if (highlight) Color.rgb(5, 150, 105) else Color.rgb(51, 65, 85) + canvas.drawText(value, left + 160f, rowY, paint) + rowY += 24f + } + + drawInclusionRow("Primary Driver:", "Alex Chen (Verified Global Driving License)") + drawInclusionRow( + "Toll Transponder:", + "Bay Area FasTrak #FT-883920 (All Bridges & Lanes Included)", + highlight = true, + ) + drawInclusionRow( + "EV Charging Perk:", + "Electrify America Unlimited Fast Charging (PIN: 2026)", + highlight = true, + ) + drawInclusionRow( + "Insurance Cover:", + "Full Loss Damage Waiver (LDW) + Zero Deductible 24/7 Roadside", + ) + + // 5. Payment & Financial Summary Bar + val payTop = 582f + val payBottom = 654f + paint.style = Paint.Style.FILL + paint.color = Color.rgb(241, 245, 249) + canvas.drawRoundRect(RectF(left, payTop, right, payBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1f + paint.color = Color.rgb(203, 213, 225) + canvas.drawRoundRect(RectF(left, payTop, right, payBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("BILLING & CHARGES (5 DAYS RENTAL)", left + 18f, payTop + 22f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText( + "Rate: 5 x $64.00/day • Clean Mobility EV Credit • SFO Concession Recovery Fee Included", + left + 18f, + payTop + 40f, + paint, + ) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10.5f + paint.color = Color.rgb(5, 150, 105) + canvas.drawText("✓ PAID IN FULL (VISA ••••4821)", left + 18f, payTop + 58f, paint) + + // Total Amount on Right + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("TOTAL CHARGED", right - 130f, payTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 24f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("$360.50", right - 130f, payTop + 54f, paint) + + // 6. Express Gate Barcode & QR Verification Card + val barTop = 666f + val barBottom = 768f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, barTop, right, barBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, barTop, right, barBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + // Draw realistic barcode pattern + val barLeft = left + 20f + val barWidth = 380f + val barHeight = 40f + val barY = barTop + 14f + paint.color = Color.rgb(15, 23, 42) + val barPattern = + intArrayOf( + 2, 1, 3, 1, 1, 2, 3, 2, 1, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 1, 3, 2, 1, 2, 2, 1, 1, 3, 2, 1, 1, + 2, 3, 1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 1, 3, 2, 1, 2, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 3, + 1, 2, 1, 1, 3, 2, 1, 2, 3, 1, 1, 2, 1, 3, 2, 1, 2, 2, 1, 3, 1, 2, 1, 1, 3, 2, 1, 2, 1, + ) + var currentBarX = barLeft + var isBar = true + for (w in barPattern) { + if (isBar && currentBarX < barLeft + barWidth) { + val barW = w.toFloat() * 1.8f + canvas.drawRect(currentBarX, barY, currentBarX + barW, barY + barHeight, paint) + } + currentBarX += w.toFloat() * 1.8f + isBar = !isBar + } + + // Barcode number text + paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText( + "* ZW-55820-SFO-20261027-POLESTAR2-AWD *", + barLeft + 16f, + barY + barHeight + 16f, + paint, + ) + + // Footer instruction + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 8.5f + paint.color = Color.rgb(148, 163, 184) + canvas.drawText( + "Scan barcode at Express Gate Stall #18 barrier for automated access • 24/7 Support: 1-800-ZEPHYR-EV", + barLeft, + barY + barHeight + 30f, + paint, + ) + + // QR Code square on right side + val qrSize = 64f + val qrX = right - qrSize - 20f + val qrY = barTop + 14f + val cellSize = qrSize / 8f + paint.color = Color.rgb(15, 23, 42) + for (r in 0 until 8) { + for (c in 0 until 8) { + val isFinder = + (r in 0..2 && c in 0..2) || (r in 0..2 && c in 5..7) || (r in 5..7 && c in 0..2) + val isPixel = isFinder || ((r * 3 + c * 7 + 5) % 2 == 0) + if (isPixel) { + canvas.drawRect( + qrX + c * cellSize, + qrY + r * cellSize, + qrX + (c + 1) * cellSize - 0.5f, + qrY + (r + 1) * cellSize - 0.5f, + paint, + ) + } + } + } + + pdfDoc.finishPage(page) + val out = ByteArrayOutputStream() + pdfDoc.writeTo(out) + pdfDoc.close() + return out.toByteArray() + } + + private fun buildFlightTicketPdf(): ByteArray { + val pdfDoc = PdfDocument() + val pageInfo = PdfDocument.PageInfo.Builder(612, 792, 1).create() + val page = pdfDoc.startPage(pageInfo) + val canvas = page.canvas + + canvas.drawColor(Color.rgb(248, 250, 252)) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val left = 24f + val right = 588f + + // 1. Header Banner (United Navy #002244) + paint.color = Color.rgb(0, 34, 68) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(left, 24f, right, 120f), 14f, 14f, paint) + + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 22f + canvas.drawText("UNITED AIRLINES", left + 20f, 62f, paint) + + paint.color = Color.rgb(147, 197, 253) + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10f + canvas.drawText("ELECTRONIC TICKET & BOARDING PASS • STAR ALLIANCE", left + 20f, 82f, paint) + + val pillRight = right - 20f + val pillLeft = pillRight - 116f + paint.color = Color.rgb(16, 185, 129) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(pillLeft, 42f, pillRight, 70f), 14f, 14f, paint) + + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11f + canvas.drawText("● CONFIRMED", pillLeft + 14f, 60f, paint) + + paint.color = Color.rgb(226, 232, 240) + paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + paint.textSize = 11f + canvas.drawText("PNR: KL8821UA", pillLeft - 18f, 96f, paint) + + // 2. Passenger & Status Card + val passTop = 132f + val passBottom = 222f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, passTop, right, passBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, passTop, right, passBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("PASSENGER DETAILS & STATUS", left + 18f, passTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 18f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("ALEX CHEN", left + 18f, passTop + 50f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 11f + paint.color = Color.rgb(37, 99, 235) + canvas.drawText( + "Premier Gold • Star Alliance Gold • Global Entry Lane Eligible", + left + 18f, + passTop + 72f, + paint, + ) + + // 3. Flight Route Card (Two Columns) + val routeTop = 234f + val routeBottom = 384f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, routeTop, right, routeBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, routeTop, right, routeBottom), 14f, 14f, paint) + + val midX = (left + right) / 2f + paint.color = Color.rgb(241, 245, 249) + paint.strokeWidth = 1.5f + canvas.drawLine(midX, routeTop + 14f, midX, routeBottom - 14f, paint) + paint.style = Paint.Style.FILL + + // CDG + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(5, 150, 105) + canvas.drawText("▲ DEPARTURE (ORIGIN)", left + 18f, routeTop + 26f, paint) + + paint.textSize = 14f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("CDG Paris Charles de Gaulle", left + 18f, routeTop + 50f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 11f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Tue, Oct 27, 2026 @ 09:00 AM", left + 18f, routeTop + 70f, paint) + canvas.drawText("Terminal 2E • Gate K42", left + 18f, routeTop + 90f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(37, 99, 235) + canvas.drawText("Boarding Closes 08:45 AM", left + 18f, routeTop + 116f, paint) + + // SFO + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(30, 64, 175) + canvas.drawText("▼ ARRIVAL (DESTINATION)", midX + 18f, routeTop + 26f, paint) + + paint.textSize = 14f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("SFO San Francisco Intl", midX + 18f, routeTop + 50f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 11f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Tue, Oct 27, 2026 @ 12:15 PM", midX + 18f, routeTop + 70f, paint) + canvas.drawText("International Terminal G • Gate G94", midX + 18f, routeTop + 90f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(5, 150, 105) + canvas.drawText("Nonstop • 11h 15m Flight Time", midX + 18f, routeTop + 116f, paint) + + // 4. Seat & Boarding Grid + val seatTop = 396f + val seatBottom = 496f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, seatTop, right, seatBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, seatTop, right, seatBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + val colW = (right - left) / 4f + for (i in 0 until 4) { + val cX = left + (i * colW) + 12f + when (i) { + 0 -> { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("SEAT", cX, seatTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 24f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("32B", cX, seatTop + 58f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Economy Plus", cX, seatTop + 78f, paint) + } + 1 -> { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("GROUP", cX, seatTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 24f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("1", cX, seatTop + 58f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Premier Access", cX, seatTop + 78f, paint) + } + 2 -> { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("BAGGAGE", cX, seatTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 20f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("2x 23kg", cX, seatTop + 58f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Included Free", cX, seatTop + 78f, paint) + } + 3 -> { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("IN-FLIGHT WI-FI", cX, seatTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 14f + paint.color = Color.rgb(37, 99, 235) + canvas.drawText("ACTIVE", cX, seatTop + 56f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Code: 883920", cX, seatTop + 78f, paint) + } + } + } + + // 5. Perforated divider & Boarding Barcode Card + val barTop = 510f + val barBottom = 768f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, barTop, right, barBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, barTop, right, barBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + // Boarding barcode lines + val barLeft = left + 20f + val barWidth = 380f + val barHeight = 44f + val barY = barTop + 20f + paint.color = Color.rgb(15, 23, 42) + val barPattern = + intArrayOf( + 3, 1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 3, 1, 1, 2, 3, 2, 1, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 1, + 3, 2, 1, 2, 2, 1, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 1, 3, 2, 1, 2, + 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 3, 1, 2, 1, 1, 3, 2, 1, 2, 3, 1, 1, 2, 1, 3, 2, 1, 2, 2, 1, + ) + var currentBarX = barLeft + var isBar = true + for (w in barPattern) { + if (isBar && currentBarX < barLeft + barWidth) { + val barW = w.toFloat() * 1.8f + canvas.drawRect(currentBarX, barY, currentBarX + barW, barY + barHeight, paint) + } + currentBarX += w.toFloat() * 1.8f + isBar = !isBar + } + + paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText( + "* M1CHEN/ALEX EKL8821UA CDGSFOUA 0991 300Y032B0042 100 *", + barLeft, + barY + barHeight + 18f, + paint, + ) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 8.5f + paint.color = Color.rgb(148, 163, 184) + canvas.drawText( + "Scan digital boarding pass at Gate K42 automated gate turnstile • Star Alliance Gold Premier Access", + barLeft, + barY + barHeight + 32f, + paint, + ) + + // QR Code square on right side + val qrSize = 64f + val qrX = right - qrSize - 20f + val qrY = barTop + 16f + val cellSize = qrSize / 8f + paint.color = Color.rgb(15, 23, 42) + for (r in 0 until 8) { + for (c in 0 until 8) { + val isFinder = + (r in 0..2 && c in 0..2) || (r in 0..2 && c in 5..7) || (r in 5..7 && c in 0..2) + val isPixel = isFinder || ((r * 5 + c * 3 + 7) % 2 == 0) + if (isPixel) { + canvas.drawRect( + qrX + c * cellSize, + qrY + r * cellSize, + qrX + (c + 1) * cellSize - 0.5f, + qrY + (r + 1) * cellSize - 0.5f, + paint, + ) + } + } + } + + pdfDoc.finishPage(page) + val out = ByteArrayOutputStream() + pdfDoc.writeTo(out) + pdfDoc.close() + return out.toByteArray() + } + + private fun buildGenericPdf(title: String, lines: List): ByteArray { + val pdfDoc = PdfDocument() + val pageInfo = PdfDocument.PageInfo.Builder(612, 792, 1).create() + val page = pdfDoc.startPage(pageInfo) + val canvas = page.canvas + + canvas.drawColor(Color.rgb(248, 250, 252)) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val left = 24f + val right = 588f + + // Header banner + paint.color = Color.rgb(30, 41, 59) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(left, 24f, right, 100f), 12f, 12f, paint) + + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 20f + canvas.drawText(title.take(36), left + 18f, 64f, paint) + + // Body Card + val bodyTop = 112f + val bodyBottom = 768f + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, bodyTop, right, bodyBottom), 12f, 12f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, bodyTop, right, bodyBottom), 12f, 12f, paint) + paint.style = Paint.Style.FILL + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 12f + paint.color = Color.rgb(51, 65, 85) + var textY = bodyTop + 36f + for (line in lines) { + if (textY > bodyBottom - 30f) break + canvas.drawText(line.take(80), left + 20f, textY, paint) + textY += 24f + } + + pdfDoc.finishPage(page) + val out = ByteArrayOutputStream() + pdfDoc.writeTo(out) + pdfDoc.close() + return out.toByteArray() + } + + /** + * Builds a standards-compliant 1-page PDF 1.4 binary with exact `xref` byte offsets so both + * Android's native [PdfRenderer] and [extractPdfText] can read it. + */ + private fun buildSinglePagePdfBytes(lines: List): ByteArray { + fun escapePdfLiteral(text: String): String = + text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + val contentStream = buildString { + append("BT\n/F1 11 Tf\n18 TL\n42 730 Td\n") + lines.forEachIndexed { idx, line -> + if (idx > 0) append("T*\n") + append("(${escapePdfLiteral(line)}) Tj\n") + } + append("ET") + } + val streamBytes = contentStream.toByteArray(StandardCharsets.ISO_8859_1) + + val out = ByteArrayOutputStream() + fun writeAscii(str: String) { + out.write(str.toByteArray(StandardCharsets.ISO_8859_1)) + } + + writeAscii("%PDF-1.4\n") + val offsets = IntArray(6) + + offsets[1] = out.size() + writeAscii("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") + + offsets[2] = out.size() + writeAscii("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") + + offsets[3] = out.size() + writeAscii( + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n" + ) + + offsets[4] = out.size() + writeAscii("4 0 obj\n<< /Length ${streamBytes.size} >>\nstream\n") + out.write(streamBytes) + writeAscii("\nendstream\nendobj\n") + + offsets[5] = out.size() + writeAscii("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n") + + val xrefOffset = out.size() + writeAscii("xref\n0 6\n0000000000 65535 f \n") + for (i in 1..5) { + writeAscii(String.format(Locale.US, "%010d 00000 n \n", offsets[i])) + } + writeAscii("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n$xrefOffset\n%%EOF\n") + return out.toByteArray() + } + + private fun extractTextFromFile(file: File, mimeType: String?): String? { + if (!file.exists() || !file.isFile) return null + val ext = file.extension.lowercase() + return try { + when { + isEmailMimeOrExt(mimeType, ext) -> { + val raw = file.readText() + parseEmailContent(raw).formattedSummary().ifBlank { raw } + } + mimeType.equals("text/html", ignoreCase = true) || ext in setOf("html", "htm") -> { + stripHtml(file.readText()) + } + mimeType.equals("application/pdf", ignoreCase = true) || ext == "pdf" -> { + extractPdfText(file) + } + isTextMime(mimeType) || ext in setOf("txt", "md", "json", "csv", "text") -> { + val raw = file.readText() + if (looksLikeEmailOrHtml(raw)) { + parseEmailContent(raw).formattedSummary().ifBlank { raw } + } else { + raw + } + } + else -> null + } + } catch (e: Exception) { + Log.w(TAG, "Could not extract text from file ${file.name}", e) + null + } + } + + /** + * Extracts selectable text from a PDF [file] by scanning uncompressed and `/FlateDecode` PDF + * content streams for `Tj` and `TJ` text operators. + * + * For image-only (scanned) PDFs where this returns null, callers fall back to + * [renderPdfPageToBitmap] for multimodal `ImagePart` embedding and Gemini Nano vision OCR. + */ + open fun extractPdfText(file: File): String? { + if (!file.exists() || !file.isFile || file.length() > MAX_PDF_BYTES) return null + return try { + val bytes = file.readBytes() + val rawLatin1 = String(bytes, StandardCharsets.ISO_8859_1) + val contentStreams = mutableListOf() + + var searchIdx = 0 + while (searchIdx < rawLatin1.length) { + val streamStartMarker = rawLatin1.indexOf("stream", searchIdx) + if (streamStartMarker == -1) break + // Advance past "stream\r\n" or "stream\n" + var dataStart = streamStartMarker + 6 + if (dataStart < rawLatin1.length && rawLatin1[dataStart] == '\r') dataStart++ + if (dataStart < rawLatin1.length && rawLatin1[dataStart] == '\n') dataStart++ + + val streamEndMarker = rawLatin1.indexOf("endstream", dataStart) + if (streamEndMarker == -1) break + + var dataEnd = streamEndMarker + while ( + dataEnd > dataStart && + (bytes[dataEnd - 1] == '\n'.code.toByte() || bytes[dataEnd - 1] == '\r'.code.toByte()) + ) { + dataEnd-- + } + + if (dataEnd > dataStart) { + val streamBytes = bytes.copyOfRange(dataStart, dataEnd) + val inflated = tryInflateStream(streamBytes) + if (inflated != null) { + contentStreams.add(String(inflated, StandardCharsets.ISO_8859_1)) + } else { + contentStreams.add(String(streamBytes, StandardCharsets.ISO_8859_1)) + } + } + searchIdx = streamEndMarker + 9 + } + + if (contentStreams.isEmpty()) { + contentStreams.add(rawLatin1) + } + + val extractedPieces = mutableListOf() + val tjRegex = Regex("""\((?:\\.|[^\\)])*\)\s*Tj""") + val tjArrayRegex = Regex("""\[(.*?)\]\s*TJ""", RegexOption.DOT_MATCHES_ALL) + val parenStringRegex = Regex("""\((?:\\.|[^\\)])*\)""") + + for (stream in contentStreams) { + if (!stream.contains("Tj") && !stream.contains("TJ")) continue + // Walk in document order by matching either (..) Tj or [..] TJ + val combinedRegex = + Regex("""\((?:\\.|[^\\)])*\)\s*Tj|\[.*?\]\s*TJ""", RegexOption.DOT_MATCHES_ALL) + val lineBuilder = StringBuilder() + for (match in combinedRegex.findAll(stream)) { + val token = match.value + if (token.endsWith("Tj")) { + val inner = parenStringRegex.find(token)?.value + if (inner != null) { + lineBuilder.append(decodePdfLiteralString(inner.substring(1, inner.length - 1))) + } + } else { + val arrayBody = tjArrayRegex.find(token)?.groupValues?.getOrNull(1).orEmpty() + for (strMatch in parenStringRegex.findAll(arrayBody)) { + val s = strMatch.value + lineBuilder.append(decodePdfLiteralString(s.substring(1, s.length - 1))) + } + } + lineBuilder.append(" ") + } + val cleaned = lineBuilder.toString().replace(Regex("""\s+"""), " ").trim() + if (cleaned.isNotBlank()) { + extractedPieces.add(cleaned) + } + } + + extractedPieces.joinToString("\n").trim().takeIf { it.isNotBlank() } + } catch (e: Exception) { + Log.w(TAG, "Failed to extract text from PDF ${file.name}", e) + null + } + } + + private fun tryInflateStream(data: ByteArray): ByteArray? { + for (nowrap in listOf(false, true)) { + val inflater = Inflater(nowrap) + try { + inflater.setInput(data) + val out = ByteArrayOutputStream() + val buffer = ByteArray(4096) + while (!inflater.finished()) { + val count = inflater.inflate(buffer) + if (count == 0) { + if (inflater.needsInput() || inflater.needsDictionary()) break + } else { + out.write(buffer, 0, count) + } + } + val result = out.toByteArray() + if (result.isNotEmpty()) return result + } catch (_: Exception) { + // Not a valid deflate stream with this nowrap setting; try next. + } finally { + inflater.end() + } + } + return null + } + + private fun decodePdfLiteralString(raw: String): String { + val sb = StringBuilder(raw.length) + var i = 0 + while (i < raw.length) { + val c = raw[i] + if (c == '\\' && i + 1 < raw.length) { + val next = raw[i + 1] + when (next) { + 'n' -> { + sb.append('\n') + i += 2 + } + 'r' -> { + sb.append('\r') + i += 2 + } + 't' -> { + sb.append('\t') + i += 2 + } + '(', + ')', + '\\' -> { + sb.append(next) + i += 2 + } + in '0'..'7' -> { + var oct = next.toString() + var consumed = 2 + if (i + 2 < raw.length && raw[i + 2] in '0'..'7') { + oct += raw[i + 2] + consumed++ + if (i + 3 < raw.length && raw[i + 3] in '0'..'7') { + oct += raw[i + 3] + consumed++ + } + } + sb.append(oct.toInt(8).toChar()) + i += consumed + } + else -> { + sb.append(next) + i += 2 + } + } + } else { + sb.append(c) + i++ + } + } + return sb.toString() + } + + /** Parsed representation of an RFC 822 `.eml` or shared email message. */ + data class ParsedEmail( + val subject: String?, + val from: String?, + val date: String?, + val body: String, + ) { + fun formattedSummary(): String = + buildString { + if (!subject.isNullOrBlank()) append("Subject: $subject\n") + if (!from.isNullOrBlank()) append("From: $from\n") + if (!date.isNullOrBlank()) append("Date: $date\n") + if (isNotEmpty() && body.isNotBlank()) append("\n") + append(body.trim()) + } + .trim() + } + + /** + * Parses an `.eml` (`message/rfc822`), forwarded email text, or HTML email into structured + * headers (`Subject`, `From`, `Date`) and clean plain-text body. + */ + open fun parseEmailContent(raw: String): ParsedEmail { + val normalized = raw.replace("\r\n", "\n") + if (!looksLikeEmailHeaders(normalized) && looksLikeHtml(normalized)) { + return ParsedEmail(subject = null, from = null, date = null, body = stripHtml(normalized)) + } + + val headerBlock: String + val rawBodyBlock: String + val blankLineIdx = normalized.indexOf("\n\n") + if (blankLineIdx != -1) { + headerBlock = normalized.substring(0, blankLineIdx) + rawBodyBlock = normalized.substring(blankLineIdx + 2) + } else { + val lines = normalized.lines() + val headerLines = mutableListOf() + var bodyStartIndex = lines.size + val headerRegex = Regex("^[A-Za-z][A-Za-z0-9-]*:\\s*.*") + for (i in lines.indices) { + val line = lines[i] + if (headerRegex.matches(line)) { + headerLines.add(line) + } else if ((line.startsWith(" ") || line.startsWith("\t")) && headerLines.isNotEmpty()) { + headerLines.add(line) + } else { + bodyStartIndex = i + break + } + } + if (headerLines.isNotEmpty() && bodyStartIndex < lines.size) { + headerBlock = headerLines.joinToString("\n") + rawBodyBlock = lines.subList(bodyStartIndex, lines.size).joinToString("\n") + } else { + headerBlock = normalized + rawBodyBlock = "" + } + } + + val headers = parseHeaders(headerBlock) + val subject = headers["subject"] + val from = headers["from"] + val date = headers["date"] + val contentType = headers["content-type"].orEmpty() + val transferEncoding = headers["content-transfer-encoding"].orEmpty() + + if (subject == null && from == null && !contentType.contains("multipart", ignoreCase = true)) { + val cleaned = if (looksLikeHtml(normalized)) stripHtml(normalized) else normalized.trim() + return ParsedEmail(subject = null, from = null, date = null, body = cleaned) + } + + val extractedBody = extractMimeBody(rawBodyBlock, contentType, transferEncoding) + return ParsedEmail(subject = subject, from = from, date = date, body = extractedBody) + } + + private fun parseHeaders(headerBlock: String): Map { + val unfoldedLines = mutableListOf() + for (line in headerBlock.lines()) { + if ((line.startsWith(" ") || line.startsWith("\t")) && unfoldedLines.isNotEmpty()) { + val lastIdx = unfoldedLines.lastIndex + unfoldedLines[lastIdx] = unfoldedLines[lastIdx] + " " + line.trim() + } else { + unfoldedLines.add(line) + } + } + val map = mutableMapOf() + for (line in unfoldedLines) { + val colonIdx = line.indexOf(':') + if (colonIdx > 0) { + val key = line.substring(0, colonIdx).trim().lowercase() + val value = line.substring(colonIdx + 1).trim() + if ( + key in setOf("subject", "from", "to", "date", "content-type", "content-transfer-encoding") + ) { + map.putIfAbsent(key, value) + } + } + } + return map + } + + private fun extractMimeBody( + rawBody: String, + contentType: String, + transferEncoding: String, + ): String { + val boundaryMatch = + Regex("""boundary="?([^";\r\n]+)"?""", RegexOption.IGNORE_CASE).find(contentType) + if (boundaryMatch != null) { + val boundary = boundaryMatch.groupValues[1] + val parts = rawBody.split("--$boundary") + var plainCandidate: String? = null + var htmlCandidate: String? = null + for (part in parts) { + val trimmedPart = part.trim() + if (trimmedPart.isEmpty() || trimmedPart == "--") continue + val splitIdx = trimmedPart.indexOf("\n\n") + if (splitIdx == -1) continue + val partHeaders = parseHeaders(trimmedPart.substring(0, splitIdx)) + val partType = partHeaders["content-type"].orEmpty().lowercase() + val partEncoding = partHeaders["content-transfer-encoding"].orEmpty() + val partContent = trimmedPart.substring(splitIdx + 2) + if (partType.contains("multipart/")) { + val nested = extractMimeBody(partContent, partType, partEncoding) + if (nested.isNotBlank()) return nested + } else if (partType.contains("text/plain") || partType.isEmpty()) { + val decoded = decodeTransferEncoding(partContent, partEncoding).trim() + if (decoded.isNotBlank() && plainCandidate == null) { + plainCandidate = decoded + } + } else if (partType.contains("text/html")) { + val decoded = stripHtml(decodeTransferEncoding(partContent, partEncoding)).trim() + if (decoded.isNotBlank() && htmlCandidate == null) { + htmlCandidate = decoded + } + } + } + return plainCandidate ?: htmlCandidate ?: stripHtml(rawBody) + } + + val decoded = decodeTransferEncoding(rawBody, transferEncoding) + return if (contentType.contains("text/html", ignoreCase = true) || looksLikeHtml(decoded)) { + stripHtml(decoded) + } else { + decoded.trim() + } + } + + private fun decodeTransferEncoding(content: String, encoding: String): String { + return when (encoding.trim().lowercase()) { + "quoted-printable" -> decodeQuotedPrintable(content) + "base64" -> + runCatching { + val cleanBase64 = content.replace(Regex("""\s+"""), "") + String(Base64.getDecoder().decode(cleanBase64), StandardCharsets.UTF_8) + } + .getOrDefault(content) + else -> content + } + } + + private fun decodeQuotedPrintable(input: String): String { + val softJoined = input.replace("=\r\n", "").replace("=\n", "") + val out = ByteArrayOutputStream(softJoined.length) + var i = 0 + while (i < softJoined.length) { + val c = softJoined[i] + if (c == '=' && i + 2 < softJoined.length) { + val hex = softJoined.substring(i + 1, i + 3) + val byteVal = hex.toIntOrNull(16) + if (byteVal != null) { + out.write(byteVal) + i += 3 + continue + } + } + out.write(c.code) + i++ + } + return String(out.toByteArray(), StandardCharsets.UTF_8) + } + + private fun stripHtml(html: String): String { + return html + .replace(Regex("""(?is)<(script|style|head)\b[^>]*>.*?"""), " ") + .replace(Regex("""(?i)"""), "\n") + .replace(Regex("""(?i)"""), "\n") + .replace(Regex("""<[^>]+>"""), " ") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .lines() + .map { it.replace(Regex("""\s+"""), " ").trim() } + .filter { it.isNotBlank() } + .joinToString("\n") + } + + private fun looksLikeEmailHeaders(text: String): Boolean { + val head = text.take(1000) + return Regex("""(?m)^(Subject|From|MIME-Version|Content-Type):\s+.+""").containsMatchIn(head) + } + + private fun looksLikeHtml(text: String): Boolean { + val head = text.take(1000).lowercase() + return "" in head + } + + private fun looksLikeEmailOrHtml(text: String): Boolean = + looksLikeEmailHeaders(text) || looksLikeHtml(text) + + private fun queryFilename(uri: Uri): String? { + if (uri.scheme == "content") { + try { + context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx != -1) return File(cursor.getString(idx)).name + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to resolve filename for content URI $uri", e) + } + } + return uri.lastPathSegment?.substringAfterLast('/') + } + + private fun getMimeTypeFromFilename(filename: String): String { + val ext = filename.substringAfterLast('.', "").lowercase() + return when (ext) { + "pdf" -> "application/pdf" + "jpg", + "jpeg" -> "image/jpeg" + "png" -> "image/png" + "webp" -> "image/webp" + "txt" -> "text/plain" + "eml", + "msg" -> "message/rfc822" + "html", + "htm" -> "text/html" + "json" -> "application/json" + "md" -> "text/markdown" + else -> "application/octet-stream" + } + } + + private fun isEmailMimeOrExt(mime: String?, ext: String): Boolean { + val lowerExt = ext.lowercase() + return mime.equals("message/rfc822", ignoreCase = true) || + lowerExt == "eml" || + lowerExt == "msg" + } + + private fun isTextMime(mime: String?): Boolean { + if (mime == null) return false + return mime.startsWith("text/") || + mime == "application/json" || + mime == "application/xml" || + mime.equals("message/rfc822", ignoreCase = true) + } + + private fun detectDocumentType( + filename: String, + file: File? = null, + extractedText: String? = null, + ): String { + val combined = "${filename.lowercase()} ${extractedText?.take(600)?.lowercase().orEmpty()}" + return when { + "passport" in combined || "passeport" in combined -> "PASSPORT" + "visa" in combined || "esta" in combined -> "VISA" + "flight" in combined || + "ticket" in combined || + "boarding" in combined || + "voucher" in combined || + "airlines" in combined || + "airways" in combined || + "train" in combined || + "rail" in combined || + "eurostar" in combined || + "shinkansen" in combined || + "amtrak" in combined || + "ferry" in combined || + "bus" in combined || + "coach" in combined || + "transit" in combined -> "TICKET" + "hotel" in combined || + "reservation" in combined || + "booking" in combined || + "check-in" in combined -> "HOTEL" + "license" in combined || "national_id" in combined || "driver" in combined -> "NATIONAL_ID" + else -> "OTHER" + } + } + + companion object { + private const val TAG = "TravelDocumentManager" + private const val MAX_AUTO_NOTES_CHARS = 1000 + private const val MAX_PDF_BYTES = 8L * 1024L * 1024L + } +} diff --git a/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt b/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt index e1e2d127..96e67a02 100644 --- a/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt +++ b/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt @@ -19,22 +19,32 @@ package com.example.jetpacker.feature.appfunctions import androidx.annotation.RequiresApi -import androidx.appfunctions.AppFunction +import androidx.appfunctions.AppFunctionAccessLevel +import androidx.appfunctions.AppFunctionDeclaration import androidx.appfunctions.AppFunctionService import androidx.appfunctions.AppFunctionServiceEntryPoint +import androidx.appfunctions.ExperimentalAppFunctionsApi +import androidx.appfunctions.metadata.AppFunctionMetadata import com.example.jetpacker.data.itinerary.DayTheme import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.DocumentSemanticSearchProvider import com.example.jetpacker.data.itinerary.EventDao import com.example.jetpacker.data.itinerary.EventType import com.example.jetpacker.data.itinerary.Expense import com.example.jetpacker.data.itinerary.ExpenseDao import com.example.jetpacker.data.itinerary.TimelineEvent import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager import com.example.jetpacker.data.itinerary.VoiceNoteEntity import com.example.jetpacker.data.trips.DummyData import com.example.jetpacker.data.trips.Trip import com.example.jetpacker.data.trips.TripDao import dagger.hilt.android.AndroidEntryPoint +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale import java.util.UUID import javax.inject.Inject @@ -54,6 +64,9 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe @Inject internal lateinit var eventDao: EventDao @Inject internal lateinit var tourDetailDao: TourDetailDao @Inject internal lateinit var dayThemeDao: DayThemeDao + @Inject internal lateinit var travelDocumentDao: TravelDocumentDao + @Inject internal lateinit var travelDocumentManager: TravelDocumentManager + @Inject internal lateinit var semanticSearchProvider: DocumentSemanticSearchProvider /** * Searches for trips based on optional filters like query, id, title (name), location, and dates. @@ -68,7 +81,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * @param endDate The maximum end date in milliseconds. * @return A list of trips matching the filters. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun searchTrip( query: String? = null, id: String? = null, @@ -109,7 +122,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * @param endDate The end date in milliseconds. * @return The created trip. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun createTrip( title: String, location: String, @@ -148,7 +161,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * 'Activity'). Inferred from title if omitted. * @return The added expense. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun addExpense( title: String, amount: Double, @@ -364,7 +377,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * @param tripId Optional ID of the trip (e.g., '2026-1'). Defaults to the active trip if omitted. * @return A list of expenses and budget summary for the trip. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun getExpenses(tripId: String? = null): List { return withContext(Dispatchers.IO) { val trip = resolveTrip(tripId = tripId) @@ -455,7 +468,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * @param tripId Optional ID of the trip (e.g., '2026-1'). Defaults to the active trip if omitted. * @return A list of events in the itinerary. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun getItinerary(tripId: String? = null): List { return withContext(Dispatchers.IO) { val trip = resolveTrip(tripId = tripId) @@ -486,7 +499,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * "par_3"). * @return The detailed event information. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun getEventDetails(eventId: String): EventDetailsSerializable { return withContext(Dispatchers.IO) { val event = @@ -553,7 +566,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * @param description An optional description of the event. * @return The added event. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun addItineraryEvent( title: String, tripId: String? = null, @@ -655,7 +668,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. * @return A list of voice notes. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun getVoiceNotes(tripId: String? = null): List { return withContext(Dispatchers.IO) { val trip = resolveTrip(tripId = tripId) @@ -680,7 +693,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * 'io_6', 'tok_3'). * @return The added voice note. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun addVoiceNote( title: String, transcription: String? = null, @@ -780,7 +793,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. * @return A list of day themes. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun getDayThemes(tripId: String? = null): List { return withContext(Dispatchers.IO) { val trip = resolveTrip(tripId = tripId) @@ -798,7 +811,7 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe * @param theme The theme text. * @return The updated or added day theme. */ - @AppFunction(isDescribedByKDoc = true) + @AppFunctionDeclaration(isDescribedByKDoc = true) suspend fun setDayTheme(tripId: String, date: String, theme: String): DayThemeSerializable { val dayTheme = DayTheme(id = UUID.randomUUID().toString(), tripId = tripId, date = date, theme = theme) @@ -808,6 +821,237 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe } } + /** + * Searches for saved travel documents, passports, visas, boarding passes, museum tickets, + * hotel reservations, Wi-Fi passwords, and travel notes. Restricted to scope SELF. + * Call this when the user asks about their travel documents, ticket confirmation numbers, + * passport expiry, hotel check-in details, or notes for their trip. + * + * @param query Optional search keywords to match document title, notes, or issuing country + * (e.g. 'passport', 'SFMOMA', 'hotel', 'wifi', 'visa'). + * @param documentType Optional filter for document type ('PASSPORT', 'VISA', 'TICKET', 'HOTEL', 'OTHER'). + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @return A list of matching travel documents. + */ + @OptIn(ExperimentalAppFunctionsApi::class) + @AppFunctionDeclaration(isDescribedByKDoc = true) + @AppFunctionAccessLevel( + level = AppFunctionMetadata.ACCESS_LEVEL_SELF, + isCompatEnforcementEnabled = true, + ) + suspend fun searchTravelDocuments( + query: String? = null, + documentType: String? = null, + tripId: String? = null, + ): List { + return withContext(Dispatchers.IO) { + var allDocs = travelDocumentDao.getAllDocuments().first() + if (allDocs.isEmpty()) { + travelDocumentManager.seedDefaultDocumentsIfEmpty() + allDocs = travelDocumentDao.getAllDocuments().first() + } + val trip = resolveTrip(tripId = tripId, textHint = query) + val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } + + // 1. Natural language semantic search using on-device Embedding Gemma v2 and AppSearch + // (same engine as the DocumentsScreen vault search) + val semanticDocs = if (!query.isNullOrBlank()) { + try { + val searcher = + if (::semanticSearchProvider.isInitialized) semanticSearchProvider + else travelDocumentManager.semanticSearchProvider + searcher?.findRelevantDocuments(query = query, tripId = targetTripId, maxResults = 10) + ?: travelDocumentManager.searchDocumentsSemantically(query = query, tripId = targetTripId, maxResults = 10) + } catch (_: Exception) { + emptyList() + } + } else { + emptyList() + } + + val candidateDocs = if (semanticDocs.isNotEmpty()) { + semanticDocs + } else { + allDocs + } + + val queryWords = + query + ?.lowercase() + ?.split(Regex("[\\s,;.]+")) + ?.filter { it.length > 2 && it !in setOf("the", "for", "our", "and", "this", "validity") } + ?: emptyList() + + val filtered = candidateDocs.filter { doc -> + val matchesTrip = targetTripId == null || doc.tripId == null || doc.tripId == targetTripId + val matchesType = documentType.isNullOrBlank() || + doc.documentType.equals(documentType, ignoreCase = true) || + (documentType.equals("TICKET", ignoreCase = true) && doc.documentType.contains("TICKET", ignoreCase = true)) || + (documentType.equals("HOTEL", ignoreCase = true) && doc.documentType.contains("HOTEL", ignoreCase = true)) + + if (semanticDocs.isNotEmpty()) { + matchesTrip && matchesType + } else { + val content = if (query != null) travelDocumentManager.readDocumentContent(doc) else "" + val matchesQuery = query.isNullOrBlank() || + doc.title.contains(query, ignoreCase = true) || + doc.notes.contains(query, ignoreCase = true) || + doc.issuingCountry.contains(query, ignoreCase = true) || + doc.documentType.contains(query, ignoreCase = true) || + doc.documentNumberMasked.contains(query, ignoreCase = true) || + content.contains(query, ignoreCase = true) || + queryWords.any { word -> + doc.title.contains(word, ignoreCase = true) || + doc.notes.contains(word, ignoreCase = true) || + doc.documentType.contains(word, ignoreCase = true) || + doc.issuingCountry.contains(word, ignoreCase = true) || + content.contains(word, ignoreCase = true) + } + + matchesTrip && matchesType && matchesQuery + } + } + + filtered.map { it.toSerializable() } + } + } + + /** + * Retrieves the full details, extracted content, notes, and metadata of a specific travel document + * by its title, keyword, or document ID. Restricted to scope SELF. Call this when the user needs + * specific information from a document, such as the full notes, passport number, Wi-Fi password, + * or booking confirmation details. + * + * @param titleOrId The title, keyword, or ID of the document (e.g. 'US Passport', 'SFMOMA Pass', 'Hotel Confirmation'). + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @return The detailed document information including extracted content and notes. + */ + @OptIn(ExperimentalAppFunctionsApi::class) + @AppFunctionDeclaration(isDescribedByKDoc = true) + @AppFunctionAccessLevel( + level = AppFunctionMetadata.ACCESS_LEVEL_SELF, + isCompatEnforcementEnabled = true, + ) + suspend fun getTravelDocumentDetails( + titleOrId: String, + tripId: String? = null, + ): TravelDocumentDetailsSerializable { + return withContext(Dispatchers.IO) { + var allDocs = travelDocumentDao.getAllDocuments().first() + if (allDocs.isEmpty()) { + travelDocumentManager.seedDefaultDocumentsIfEmpty() + allDocs = travelDocumentDao.getAllDocuments().first() + } + val trip = resolveTrip(tripId = tripId, textHint = titleOrId) + val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } + + val titleWords = + titleOrId + .lowercase() + .split(Regex("[\\s,;.]+")) + .filter { it.length > 2 && it !in setOf("the", "for", "our", "and", "this") } + + var doc = allDocs.find { + it.documentId.equals(titleOrId, ignoreCase = true) || it.id.equals(titleOrId, ignoreCase = true) + } ?: allDocs.find { + it.title.equals(titleOrId, ignoreCase = true) && (targetTripId == null || it.tripId == null || it.tripId == targetTripId) + } ?: allDocs.find { + it.title.contains(titleOrId, ignoreCase = true) && (targetTripId == null || it.tripId == null || it.tripId == targetTripId) + } ?: allDocs.find { + it.title.contains(titleOrId, ignoreCase = true) + } ?: allDocs.find { candidate -> + (targetTripId == null || candidate.tripId == null || candidate.tripId == targetTripId) && + titleWords.any { word -> + candidate.title.contains(word, ignoreCase = true) || + candidate.documentType.contains(word, ignoreCase = true) || + candidate.notes.contains(word, ignoreCase = true) + } + } + + if (doc == null && titleOrId.isNotBlank()) { + try { + val searcher = + if (::semanticSearchProvider.isInitialized) semanticSearchProvider + else travelDocumentManager.semanticSearchProvider + val matches = + searcher?.findRelevantDocuments(query = titleOrId, tripId = targetTripId, maxResults = 1) + ?: travelDocumentManager.searchDocumentsSemantically(query = titleOrId, tripId = targetTripId, maxResults = 1) + doc = matches.firstOrNull() + } catch (_: Exception) {} + } + + if (doc == null) { + return@withContext TravelDocumentDetailsSerializable( + id = "not_found", + title = titleOrId, + documentType = "OTHER", + status = "NOT_FOUND", + message = "No travel document found matching '$titleOrId'.", + ) + } + + val content = travelDocumentManager.readDocumentContent(doc) + val (validity, expiryFormatted) = getDocumentValidity(doc.expiryDate, doc.documentType, doc.title) + + TravelDocumentDetailsSerializable( + id = doc.documentId.ifBlank { doc.id }, + title = doc.title, + documentType = doc.documentType, + issuingCountry = doc.issuingCountry.takeIf { it.isNotBlank() }, + documentNumberMasked = doc.documentNumberMasked.takeIf { it.isNotBlank() }, + expiryDateFormatted = expiryFormatted, + notes = doc.notes.takeIf { it.isNotBlank() }, + content = content, + validityStatus = validity, + status = "SUCCESS", + message = "Found document '${doc.title}'. ${if (doc.notes.isNotBlank()) "Notes: ${doc.notes}." else ""}", + ) + } + } + + /** + * Saves a new travel note, reservation detail, Wi-Fi code, or document text into the traveler's + * document storage. Restricted to scope SELF. Call this when the user asks to save important + * trip notes, Wi-Fi codes, door access codes, or document text into their documents (e.g., 'Save our + * Airbnb door code 4829 to my documents' or 'Add a note to my documents with the conference Wi-Fi password'). + * + * @param title Title of the document or note (e.g., 'Airbnb Door Code', 'Conference Wi-Fi'). + * @param content Full content or details to store in the document. + * @param documentType Optional document type (e.g. 'OTHER', 'NOTE', 'HOTEL', 'TICKET'). Defaults to 'OTHER'. + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @return The created travel document. + */ + @OptIn(ExperimentalAppFunctionsApi::class) + @AppFunctionDeclaration(isDescribedByKDoc = true) + @AppFunctionAccessLevel( + level = AppFunctionMetadata.ACCESS_LEVEL_SELF, + isCompatEnforcementEnabled = true, + ) + suspend fun saveTravelNoteDocument( + title: String, + content: String, + documentType: String? = null, + tripId: String? = null, + ): TravelDocumentSerializable { + return withContext(Dispatchers.IO) { + val trip = resolveTrip(tripId = tripId, textHint = "$title $content") + val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + val resolvedType = (documentType?.takeIf { it.isNotBlank() } ?: "OTHER").uppercase() + + val createdDoc = travelDocumentManager.createTextDocument( + title = title, + content = content, + documentType = resolvedType, + notes = content, + tripId = targetTripId, + ) + + createdDoc.toSerializable( + message = "Successfully saved '$title' to your travel documents in ${trip?.title ?: "your trip"}.", + ) + } + } + private fun matchesTripKeyword(trip: Trip, text: String): Boolean { val lower = text.lowercase() val city = trip.location.substringBefore(',').trim().lowercase() @@ -1083,4 +1327,48 @@ abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionSe private fun DayTheme.toSerializable() = DayThemeSerializable(id = id, tripId = tripId, date = date, theme = theme) + + private fun getDocumentValidity( + expiryMillis: Long, + docType: String, + title: String, + ): Pair { + if (expiryMillis <= 0L) return Pair("NO_EXPIRY", null) + val now = System.currentTimeMillis() + val formatted = + try { + Instant.ofEpochMilli(expiryMillis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.ISO_LOCAL_DATE) + } catch (_: Exception) { + null + } + val combined = (docType + " " + title).uppercase() + val isIdentity = combined.contains("PASSPORT") || combined.contains("VISA") || combined.contains("LICENSE") + val validity = + when { + expiryMillis < now -> "EXPIRED" + isIdentity && (expiryMillis - now < 180L * 24 * 60 * 60 * 1000) -> "EXPIRING_SOON" + else -> "VALID" + } + return Pair(validity, formatted) + } + + private fun TravelDocument.toSerializable(message: String? = null): TravelDocumentSerializable { + val (validity, expiryFormatted) = getDocumentValidity(expiryDate, documentType, title) + return TravelDocumentSerializable( + id = documentId.ifBlank { id }, + title = title, + documentType = documentType, + issuingCountry = issuingCountry.takeIf { it.isNotBlank() }, + documentNumberMasked = documentNumberMasked.takeIf { it.isNotBlank() }, + expiryDateFormatted = expiryFormatted, + notes = notes.takeIf { it.isNotBlank() }, + tripId = tripId, + validityStatus = validity, + status = "SUCCESS", + message = message ?: "Found document '$title' ($documentType).", + ) + } } diff --git a/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/TripSerializable.kt b/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/TripSerializable.kt index bb32e44c..3f7a9747 100644 --- a/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/TripSerializable.kt +++ b/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/TripSerializable.kt @@ -186,3 +186,57 @@ data class EventDetailsSerializable( /** Summary description of the event details. */ val description: String? = null, ) + +/** Represents a saved travel document (passport, visa, ticket, booking confirmation, or note). */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class TravelDocumentSerializable( + /** The document's unique identifier. */ + val id: String, + /** The title or name of the document (e.g. 'US Passport', 'SFMOMA Admission Ticket'). */ + val title: String, + /** The category or type of the document (e.g. 'PASSPORT', 'VISA', 'TICKET', 'HOTEL', 'OTHER'). */ + val documentType: String, + /** The issuing country or authority if applicable. */ + val issuingCountry: String? = null, + /** The masked document number or confirmation reference if applicable. */ + val documentNumberMasked: String? = null, + /** The expiration date formatted as YYYY-MM-DD, or null if none. */ + val expiryDateFormatted: String? = null, + /** Notes, booking codes, Wi-Fi passwords, or important details. */ + val notes: String? = null, + /** The trip ID this document belongs to, or null if general. */ + val tripId: String? = null, + /** Validity status ('VALID', 'EXPIRING_SOON', 'EXPIRED', 'NO_EXPIRY'). */ + val validityStatus: String? = "VALID", + /** Status of the tool execution. */ + val status: String? = "SUCCESS", + /** User-friendly completion message. */ + val message: String? = null, +) + +/** Represents detailed content and text extracted from a travel document. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class TravelDocumentDetailsSerializable( + /** The document's unique identifier. */ + val id: String, + /** The title of the document. */ + val title: String, + /** The category or type of the document. */ + val documentType: String, + /** The issuing country if applicable. */ + val issuingCountry: String? = null, + /** The masked document number or confirmation code. */ + val documentNumberMasked: String? = null, + /** The expiration date formatted as YYYY-MM-DD. */ + val expiryDateFormatted: String? = null, + /** Notes or extra information stored with the document. */ + val notes: String? = null, + /** Full textual content or extracted document text. */ + val content: String? = null, + /** Validity status ('VALID', 'EXPIRING_SOON', 'EXPIRED', 'NO_EXPIRY'). */ + val validityStatus: String? = "VALID", + /** Status of the tool execution. */ + val status: String? = "SUCCESS", + /** User-friendly completion message. */ + val message: String? = null, +) diff --git a/jetpacker/android/feature/assistant/build.gradle.kts b/jetpacker/android/feature/assistant/build.gradle.kts index 3943c217..b96b9296 100644 --- a/jetpacker/android/feature/assistant/build.gradle.kts +++ b/jetpacker/android/feature/assistant/build.gradle.kts @@ -84,4 +84,8 @@ dependencies { testImplementation("org.json:json:20240303") } +tasks.withType().configureEach { + jvmArgs("-Xverify:none") +} + kotlin { jvmToolchain(17) } diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt index 6a37e21a..486dc898 100644 --- a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt @@ -35,10 +35,14 @@ import com.example.jetpacker.data.itinerary.AssistantConversationDao import com.example.jetpacker.data.itinerary.EventDao import com.example.jetpacker.data.itinerary.Expense import com.example.jetpacker.data.itinerary.ExpenseDao +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager import com.example.jetpacker.feature.assistant.BuildConfig import com.example.jetpacker.data.trips.DummyData import com.example.jetpacker.data.trips.TripDao import com.example.jetpacker.feature.appfunctions.ActiveTripSession +import com.example.jetpacker.feature.assistant.documents.DocumentSemanticSearch import com.example.jetpacker.feature.assistant.history.toConversationTurns import com.example.jetpacker.feature.assistant.history.toTurnsJson import com.example.jetpacker.feature.assistant.model.ActionStepCategory @@ -50,6 +54,7 @@ import com.example.jetpacker.feature.assistant.model.HighlightedEntity.ExpenseHi import com.example.jetpacker.feature.assistant.model.HighlightedEntity.FlightDetailHighlight import com.example.jetpacker.feature.assistant.model.HighlightedEntity.ItineraryEventHighlight import com.example.jetpacker.feature.assistant.model.HighlightedEntity.MultiActionHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.TravelDocumentHighlight import com.example.jetpacker.feature.assistant.model.HighlightedEntity.VoiceNoteHighlight import com.example.jetpacker.feature.assistant.model.ToolExecutionRecord import com.example.jetpacker.feature.assistant.model.TravelAgentUiState @@ -202,7 +207,10 @@ constructor( application: Application, private val eventDao: EventDao, private val tripDao: TripDao, + private val travelDocumentDao: TravelDocumentDao, + private val travelDocumentManager: TravelDocumentManager, private val expenseDao: ExpenseDao? = null, + private val documentSemanticSearch: DocumentSemanticSearch, private val voiceInputManager: VoiceInputManager? = null, private val conversationDao: AssistantConversationDao? = null, ) : AndroidViewModel(application) { @@ -242,16 +250,28 @@ constructor( MutableStateFlow(VoiceTranscriptUi()) } + private var simulatedVoiceJob: Job? = null + private val adbPromptReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action != ACTION_ASSISTANT_PROMPT) return if (activeReceiverInstance != null && activeReceiverInstance !== this@TravelAssistantViewModel) return if (intent.getBooleanExtra("clear", false)) { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null clearChat() + if (intent.getBooleanExtra("reset_expenses", false)) { + viewModelScope.launch { + expenseDao?.deleteAllExpenses() + DummyData.expenses.forEach { expenseDao?.insertExpense(it) } + } + } return } if (intent.getBooleanExtra("listen", false)) { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null val transcript = intent.getStringExtra("transcript").orEmpty() _overlayState.value = AssistantOverlayState.Listening(liveTranscript = transcript) return @@ -259,30 +279,65 @@ constructor( val fakeVoice = (intent.getStringExtra("fake_voice") ?: intent.getStringExtra("voice_prompt"))?.trim() if (!fakeVoice.isNullOrEmpty()) { - viewModelScope.launch { - if ( - _overlayState.value is AssistantOverlayState.Collapsed || - _overlayState.value is AssistantOverlayState.ActionCompleted - ) { + simulatedVoiceJob?.cancel() + val wordDelayMs = intent.getLongExtra("word_delay_ms", 260L) + val preDelayMs = intent.getLongExtra("pre_delay_ms", 2200L) + val postDelayMs = intent.getLongExtra("post_delay_ms", 2200L) + simulatedVoiceJob = viewModelScope.launch { + val currentOverlay = _overlayState.value + val isFollowUp = + currentOverlay is AssistantOverlayState.Conversational && + _conversationTurns.value.isNotEmpty() + + if (!isFollowUp) { sessionId = "trip-session-${currentTripId ?: "default"}-${UUID.randomUUID()}" _conversationTurns.value = emptyList() + // 1. Show listening state (empty transcript) representing initial mic activation + _overlayState.value = AssistantOverlayState.Listening(liveTranscript = "") + } else { + // 1. In follow-up conversational mode, keep turns visible and activate listening in follow-up slot + _overlayState.value = + (currentOverlay as AssistantOverlayState.Conversational).copy( + isListening = true, + liveTranscript = "", + ) } - _overlayState.value = AssistantOverlayState.Listening(liveTranscript = "") - delay(400) + delay(preDelayMs) + + // 2. Stream words one by one with realistic speech pacing val words = fakeVoice.split(" ") - if (words.size > 2) { - val partial = words.take(words.size / 2).joinToString(" ") - _overlayState.value = AssistantOverlayState.Listening(liveTranscript = partial) - delay(400) + val accumulated = StringBuilder() + for (i in words.indices) { + if (i > 0) accumulated.append(" ") + accumulated.append(words[i]) + if (!isFollowUp) { + _overlayState.value = + AssistantOverlayState.Listening(liveTranscript = accumulated.toString()) + } else { + val conv = + _overlayState.value as? AssistantOverlayState.Conversational + ?: (currentOverlay as AssistantOverlayState.Conversational) + _overlayState.value = + conv.copy( + isListening = true, + liveTranscript = accumulated.toString(), + ) + } + delay(wordDelayMs) } - _overlayState.value = AssistantOverlayState.Listening(liveTranscript = fakeVoice) - delay(500) + + // 3. Pause between saying the final word and pressing stop / submitting prompt + delay(postDelayMs) + + // 4. Submit prompt for on-device reasoning and tool execution sendPrompt(fakeVoice) } return } val prompt = intent.getStringExtra("prompt")?.trim().orEmpty() if (prompt.isNotEmpty()) { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null sendPrompt(prompt) } } @@ -290,6 +345,8 @@ constructor( /** Starts a new voice interaction from the bottom-right AI Assistant FAB. */ fun startVoiceAssistant(onPermissionRequired: () -> Unit) { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null if ( _overlayState.value is AssistantOverlayState.Collapsed || _overlayState.value is AssistantOverlayState.ActionCompleted @@ -409,6 +466,8 @@ constructor( init { activeReceiverInstance = this + travelDocumentManager.semanticSearchProvider = documentSemanticSearch + viewModelScope.launch { travelDocumentManager.seedDefaultDocumentsIfEmpty() } try { val hasAudioPerm = ContextCompat.checkSelfPermission(application, Manifest.permission.RECORD_AUDIO) == @@ -557,7 +616,14 @@ constructor( "5. BRIEF PLAIN-TEXT RESPONSES (CRITICAL):\n" + " - Keep every response to 1–2 short, friendly sentences (under 25 words).\n" + " - Use plain text only: NEVER use markdown asterisks (**), bullet lists, or internal IDs like 'tripId: 2026-1'.\n" + - "6. CRITICAL EXECUTION RULE: Once a mutation tool (addExpense, addVoiceNote, or addItineraryEvent) has been called, you MUST NOT call addExpense, getExpenses, or any other tool again in the same turn. Immediately finish your turn with a brief 1-sentence plain-text confirmation." + "6. TRAVEL DOCUMENTS & NOTES (Scope SELF):\n" + + " - When the user asks about their saved documents, tickets, passport expiry, hotel booking details, or Wi-Fi passwords (e.g. 'What is the Wi-Fi password for our hotel?', 'Is my passport valid for this trip?', 'Show my museum pass details', 'Do I have tickets for SFMOMA?'):\n" + + " * Call searchTravelDocuments or getTravelDocumentDetails.\n" + + " * Answer clearly in 1–2 friendly sentences using the document's notes or details.\n" + + " - When the user asks to save a document note or secret (e.g. 'Save our Airbnb door code 4829 to my documents' or 'Save the conference Wi-Fi password in my travel docs'):\n" + + " * Call saveTravelNoteDocument(title=..., content=...).\n" + + " * Confirm in 1 friendly sentence.\n" + + "7. CRITICAL EXECUTION RULE: Once a mutation tool (addExpense, addVoiceNote, addItineraryEvent, or saveTravelNoteDocument) has been called, you MUST NOT call addExpense, getExpenses, or any other tool again in the same turn. Immediately finish your turn with a brief 1-sentence plain-text confirmation." ) .setMaxTurns( 6 @@ -667,7 +733,7 @@ constructor( val cleanName = cleanToolName(event.name) if (cleanName == "addExpense" && !readOnlyBudgetQuery) { _isProcessingExpense.value = true - } else if (cleanName == "addVoiceNote" || cleanName == "addItineraryEvent") { + } else if (cleanName == "addVoiceNote" || cleanName == "addItineraryEvent" || cleanName == "saveTravelNoteDocument") { _isProcessingVoiceNote.value = true } @@ -681,14 +747,14 @@ constructor( } val hasPriorMutation = currentToolExecutions.any { existing -> val existingClean = cleanToolName(existing.name) - existingClean in setOf("addExpense", "addVoiceNote", "addItineraryEvent") + existingClean in setOf("addExpense", "addVoiceNote", "addItineraryEvent", "saveTravelNoteDocument") } val isRedundantPostMutationRead = hasPriorMutation && effectiveStepToolName in setOf("getExpenses", "getActiveTrip", "searchTrip") val isRedundantDuplicateMutation = hasPriorMutation && - cleanName in setOf("addExpense", "addVoiceNote", "addItineraryEvent") + cleanName in setOf("addExpense", "addVoiceNote", "addItineraryEvent", "saveTravelNoteDocument") if (!isDuplicate && !isRedundantPostMutationRead && !isRedundantDuplicateMutation) { val record = ToolExecutionRecord(name = event.name, args = event.args) @@ -728,7 +794,7 @@ constructor( val cleanToolName = cleanToolName(event.name) if (cleanToolName == "addExpense") { _isProcessingExpense.value = false - } else if (cleanToolName == "addVoiceNote" || cleanToolName == "addItineraryEvent") { + } else if (cleanToolName == "addVoiceNote" || cleanToolName == "addItineraryEvent" || cleanToolName == "saveTravelNoteDocument") { _isProcessingVoiceNote.value = false } @@ -761,6 +827,45 @@ constructor( // "result" envelope if present) BEFORE updating overlay state val payload: Map<*, *> = (event.response["result"] as? Map<*, *>) ?: event.response when (cleanToolName) { + "searchTravelDocuments", + "getTravelDocumentDetails", + "saveTravelNoteDocument", + "check_document_validity" -> { + val docItem = + (payload["result"] as? List<*>)?.firstOrNull() as? Map<*, *> + ?: (event.response["result"] as? List<*>)?.firstOrNull() as? Map<*, *> + ?: payload + val title = docItem["title"]?.toString() ?: "" + if (title.isNotBlank()) { + val status = docItem["validityStatus"]?.toString() ?: docItem["status"]?.toString() ?: "VALID" + val explanation = docItem["message"]?.toString() ?: docItem["notes"]?.toString() ?: "" + val docType = docItem["documentType"]?.toString() ?: "PASSPORT" + val expiryLong = + (docItem["expiryDateLong"] as? Number)?.toLong() + ?: (docItem["expiryDate"] as? Number)?.toLong() + ?: 0L + val docNum = docItem["documentNumberMasked"]?.toString() ?: "" + val country = docItem["issuingCountry"]?.toString() ?: "" + val docId = docItem["id"]?.toString() ?: docItem["documentId"]?.toString() ?: "" + val notes = docItem["notes"]?.toString() ?: "" + val doc = + TravelDocument( + documentId = docId, + documentType = docType, + issuingCountry = country, + expiryDate = expiryLong, + documentNumberMasked = docNum, + title = title, + notes = notes, + ) + activeHighlightedEntity = + TravelDocumentHighlight( + document = doc, + validityStatus = status, + validityExplanation = explanation, + ) + } + } "get_flight_itinerary" -> { val flights = (event.response["result"] as? List<*>) ?: (payload["flights"] as? List<*>) @@ -1117,6 +1222,8 @@ constructor( /** Resets the conversation and clears memory session state. */ fun clearChat() { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null val currentSession = sessionId lastSubmittedPrompt = null lastSubmittedTime = 0L @@ -1282,6 +1389,8 @@ constructor( ActionStepCategory.REMINDER lower.contains("flight") || lower.contains("gate") || lower.contains("baggage") -> ActionStepCategory.FLIGHT + lower.contains("passport") || lower.contains("visa") || lower.contains("document") -> + ActionStepCategory.DOCUMENT else -> ActionStepCategory.ITINERARY } } @@ -1377,6 +1486,25 @@ constructor( status = ActionStepStatus.IN_PROGRESS, title = "Checking flight details…", ) + "check_document_validity", + "get_travel_document", + "read_document_content", + "searchTravelDocuments", + "getTravelDocumentDetails" -> + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.IN_PROGRESS, + title = "Checking documents…", + ) + "saveTravelNoteDocument", + "add_travel_document" -> + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.IN_PROGRESS, + title = "Saving document…", + ) else -> UserActionStep( id = id, @@ -1479,6 +1607,40 @@ constructor( title = "Checked flight details", ) } + "searchTravelDocuments" -> { + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.COMPLETED, + title = "Searched travel documents", + ) + } + "getTravelDocumentDetails" -> { + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.COMPLETED, + title = "Found document details", + ) + } + "saveTravelNoteDocument" -> { + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.COMPLETED, + title = "Saved travel document", + ) + } + "check_document_validity", + "get_travel_document", + "read_document_content", + "add_travel_document" -> + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.COMPLETED, + title = "Checked documents", + ) else -> UserActionStep( id = id, diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentAppSearchManager.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentAppSearchManager.kt new file mode 100644 index 00000000..58372538 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentAppSearchManager.kt @@ -0,0 +1,426 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant.documents + +import android.content.Context +import android.util.Log +import androidx.appsearch.app.AppSearchSchema +import androidx.appsearch.app.AppSearchSession +import androidx.appsearch.app.EmbeddingVector +import androidx.appsearch.app.GenericDocument +import androidx.appsearch.app.GetByDocumentIdRequest +import androidx.appsearch.app.PutDocumentsRequest +import androidx.appsearch.app.RemoveByDocumentIdRequest +import androidx.appsearch.app.SearchSpec +import androidx.appsearch.app.SetSchemaRequest +import androidx.appsearch.localstorage.LocalStorage +import androidx.concurrent.futures.await +import com.example.jetpacker.data.itinerary.TravelDocument +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Manages the on-device Jetpack AppSearch index for travel documents. + * + * Provides high-performance, native hybrid search combining: + * 1. BM25 text prefix matching on title, document type, and notes. + * 2. Hardware-optimized vector similarity (cosine / dot-product) using [EmbeddingVector] properties + * with 8-bit quantization. + * 3. Model signature validation to cleanly handle on-device embedding model upgrades (e.g. v1 to + * v2). + */ +@Singleton +class DocumentAppSearchManager +@Inject +constructor(@ApplicationContext private val context: Context) { + private val sessionMutex = Mutex() + @Volatile private var session: AppSearchSession? = null + @Volatile private var isInitialized = false + + suspend fun getSession(): AppSearchSession? { + if (isInitialized && session != null) return session + return sessionMutex.withLock { + if (isInitialized && session != null) return@withLock session + try { + val searchContext = LocalStorage.SearchContext.Builder(context, DATABASE_NAME).build() + val s = LocalStorage.createSearchSessionAsync(searchContext).await() + setSchemas(s) + session = s + isInitialized = true + s + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to initialize AppSearch session", e) + null + } + } + } + + private suspend fun setSchemas(s: AppSearchSession) { + val documentSchema = + AppSearchSchema.Builder(SCHEMA_TYPE_DOCUMENT) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_TITLE) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN) + .build() + ) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_DOCUMENT_TYPE) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN) + .build() + ) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_TEXT_CONTENT) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN) + .build() + ) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_TRIP_ID) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_EXACT_TERMS) + // Verbatim keeps IDs like "2026-3" as a single token so trip scoping is exact + // instead of matching the sub-tokens "2026" and "3". + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_VERBATIM) + .build() + ) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_ISSUING_COUNTRY) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN) + .build() + ) + .addProperty( + AppSearchSchema.EmbeddingPropertyConfig.Builder(PROPERTY_EMBEDDING) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.EmbeddingPropertyConfig.INDEXING_TYPE_SIMILARITY) + .setQuantizationType(AppSearchSchema.EmbeddingPropertyConfig.QUANTIZATION_TYPE_8_BIT) + .build() + ) + .build() + + val metadataSchema = + AppSearchSchema.Builder(SCHEMA_TYPE_METADATA) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_MODEL_SIGNATURE) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_NONE) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_NONE) + .build() + ) + .build() + + val request = + SetSchemaRequest.Builder() + .addSchemas(documentSchema, metadataSchema) + .setForceOverride(true) + .build() + s.setSchemaAsync(request).await() + } + + /** + * Retrieves the stored model signature from the AppSearch metadata document, or null if no model + * signature has been recorded. + */ + suspend fun getStoredModelSignature(): String? { + val s = getSession() ?: return null + val req = GetByDocumentIdRequest.Builder(NAMESPACE).addIds(METADATA_ID).build() + return try { + val res = s.getByDocumentIdAsync(req).await() + res.successes[METADATA_ID]?.getPropertyString(PROPERTY_MODEL_SIGNATURE) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + + /** Records the active embedding model signature in AppSearch metadata. */ + suspend fun setStoredModelSignature(signature: String) { + val s = getSession() ?: return + val doc = + GenericDocument.Builder>( + NAMESPACE, + METADATA_ID, + SCHEMA_TYPE_METADATA, + ) + .setPropertyString(PROPERTY_MODEL_SIGNATURE, signature) + .build() + val req = PutDocumentsRequest.Builder().addGenericDocuments(doc).build() + try { + s.putAsync(req).await() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to write metadata signature", e) + } + } + + /** Indexes a [TravelDocument] into AppSearch with optional vector embedding. */ + suspend fun indexDocument( + doc: TravelDocument, + textContent: String, + vector: FloatArray? = null, + modelSignature: String? = null, + ) { + val s = getSession() ?: return + val builder = + GenericDocument.Builder>(NAMESPACE, doc.id, SCHEMA_TYPE_DOCUMENT) + .setPropertyString(PROPERTY_TITLE, doc.title) + .setPropertyString(PROPERTY_DOCUMENT_TYPE, doc.documentType) + .setPropertyString(PROPERTY_TEXT_CONTENT, textContent) + .setPropertyString( + PROPERTY_TRIP_ID, + doc.tripId?.takeIf { it.isNotBlank() } ?: GLOBAL_TRIP_TOKEN, + ) + .setPropertyString(PROPERTY_ISSUING_COUNTRY, doc.issuingCountry) + + if (vector != null && vector.isNotEmpty() && !modelSignature.isNullOrBlank()) { + builder.setPropertyEmbedding(PROPERTY_EMBEDDING, EmbeddingVector(vector, modelSignature)) + } + + val req = PutDocumentsRequest.Builder().addGenericDocuments(builder.build()).build() + try { + val res = s.putAsync(req).await() + if (res.failures.isNotEmpty()) { + Log.w(TAG, "AppSearch put failures: ${res.failures}") + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to index document ${doc.id}", e) + } + } + + /** + * Performs hybrid semantic vector and full-text search against indexed travel documents. + * + * @param query The natural language user query. + * @param queryVector The query embedding float array, if available. + * @param modelSignature The signature of the embedding model that produced [queryVector]. + * @param tripId Optional trip ID to restrict matches to. Documents belonging to the trip and + * global documents (those with no trip) are both returned. + * @param maxResults Maximum number of matched document IDs to return. + * @return List of matching [TravelDocument.id] strings in descending score order. + */ + suspend fun search( + query: String, + queryVector: FloatArray? = null, + modelSignature: String? = null, + tripId: String? = null, + maxResults: Int = 5, + ): List { + val s = getSession() ?: return emptyList() + + val useVectors = + queryVector != null && queryVector.isNotEmpty() && !modelSignature.isNullOrBlank() + val sanitizedQuery = query.replace(Regex("""[^\w\s]"""), " ").trim() + + val matchQuery: String = + if (useVectors) { + if (sanitizedQuery.isBlank()) { + "semanticSearch(getEmbeddingParameter(0), $SEMANTIC_SIMILARITY_FLOOR)" + } else { + "(($sanitizedQuery) OR semanticSearch(getEmbeddingParameter(0), $SEMANTIC_SIMILARITY_FLOOR))" + } + } else { + sanitizedQuery.ifBlank { + return emptyList() + } + } + + val tripScope = buildTripScopeClause(tripId) + + // Primary attempt: scope the query to the active trip plus global documents. + if (tripScope != null) { + val scoped = + runSearch( + s, + "$tripScope AND $matchQuery", + queryVector, + modelSignature, + useVectors, + maxResults, + ) + if (scoped != null) return scoped + Log.w(TAG, "Trip-scoped AppSearch query failed; retrying without the trip restrict") + } + + return runSearch(s, matchQuery, queryVector, modelSignature, useVectors, maxResults) + ?: emptyList() + } + + /** + * Builds an AppSearch property restrict clause matching the trip plus global documents, or null + * when no trip scoping is requested or the ID is not safely expressible. + */ + private fun buildTripScopeClause(tripId: String?): String? { + if (tripId.isNullOrBlank()) return null + // Verbatim terms are quoted; reject IDs containing quotes/backslashes rather than risk + // producing a malformed query that AppSearch would reject at parse time. + if (tripId.any { it == '"' || it == '\\' }) return null + return """($PROPERTY_TRIP_ID:"$tripId" OR $PROPERTY_TRIP_ID:"$GLOBAL_TRIP_TOKEN")""" + } + + /** + * Executes a single AppSearch query, returning null if the query itself could not be run so the + * caller can fall back to a simpler form. + */ + private suspend fun runSearch( + s: AppSearchSession, + searchQuery: String, + queryVector: FloatArray?, + modelSignature: String?, + useVectors: Boolean, + maxResults: Int, + ): List? { + val specBuilder = SearchSpec.Builder().addFilterSchemas(SCHEMA_TYPE_DOCUMENT) + specBuilder.setResultCountPerPage(maxResults) + specBuilder.setListFilterQueryLanguageEnabled(true) + // The trip restrict quotes the trip ID as a verbatim term; without this the quoted term is + // not honoured and the restrict silently matches nothing useful. + specBuilder.setVerbatimSearchEnabled(true) + + if (useVectors && queryVector != null && !modelSignature.isNullOrBlank()) { + specBuilder.setDefaultEmbeddingSearchMetricType( + SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_COSINE + ) + specBuilder.addEmbeddingParameters(EmbeddingVector(queryVector, modelSignature)) + // Hybrid ranking: cosine similarity + text relevance + specBuilder.setRankingStrategy( + "sum(this.matchedSemanticScores(getEmbeddingParameter(0))) + 0.3 * this.relevanceScore()" + ) + } + + val searchResults = + try { + s.search(searchQuery, specBuilder.build()) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "AppSearch search failed for query: $searchQuery", e) + return null + } + + return try { + val page = searchResults.nextPageAsync.await() + page.map { it.genericDocument.id } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "AppSearch results iteration failed", e) + null + } finally { + searchResults.close() + } + } + + /** + * Returns the IDs of every document currently held in the AppSearch index. + * + * Used to prune entries whose backing Room rows have been deleted. + */ + suspend fun getIndexedDocumentIds(limit: Int = MAX_INDEXED_IDS): Set { + val s = getSession() ?: return emptySet() + val spec = + SearchSpec.Builder() + .addFilterSchemas(SCHEMA_TYPE_DOCUMENT) + .setResultCountPerPage(limit) + .build() + // An empty query string matches every document of the filtered schema. + val searchResults = + try { + s.search("", spec) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to enumerate indexed documents", e) + return emptySet() + } + + return try { + searchResults.nextPageAsync.await().map { it.genericDocument.id }.toSet() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to read indexed document page", e) + emptySet() + } finally { + searchResults.close() + } + } + + /** Removes a document from the AppSearch index by document ID. */ + suspend fun removeDocument(id: String) { + val s = getSession() ?: return + val req = RemoveByDocumentIdRequest.Builder(NAMESPACE).addIds(id).build() + try { + s.removeAsync(req).await() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to remove document $id from AppSearch", e) + } + } + + /** Clears all indexed documents and metadata from the AppSearch index. */ + suspend fun clearAll() { + val s = getSession() ?: return + val spec = + SearchSpec.Builder().addFilterSchemas(SCHEMA_TYPE_DOCUMENT, SCHEMA_TYPE_METADATA).build() + try { + s.removeAsync("", spec).await() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to clear AppSearch index", e) + } + } + + companion object { + private const val TAG = "DocAppSearchManager" + const val DATABASE_NAME = "jetpacker_documents" + const val NAMESPACE = "travel_documents" + const val METADATA_ID = "db_metadata" + + const val SCHEMA_TYPE_DOCUMENT = "TravelDocument" + const val SCHEMA_TYPE_METADATA = "DbMetadata" + + const val PROPERTY_TITLE = "title" + const val PROPERTY_DOCUMENT_TYPE = "documentType" + const val PROPERTY_TEXT_CONTENT = "textContent" + const val PROPERTY_TRIP_ID = "tripId" + const val PROPERTY_ISSUING_COUNTRY = "issuingCountry" + const val PROPERTY_EMBEDDING = "embedding" + const val PROPERTY_MODEL_SIGNATURE = "modelSignature" + + /** + * Sentinel trip token written for documents that are not tied to a specific trip, so that + * trip-scoped queries can include them with a single property restrict. + */ + const val GLOBAL_TRIP_TOKEN = "global" + + /** Minimum cosine similarity a document must reach to be considered a semantic match. */ + const val SEMANTIC_SIMILARITY_FLOOR = "0.15" + + /** + * Upper bound on IDs returned by [getIndexedDocumentIds]; the vault is a small local corpus. + */ + const val MAX_INDEXED_IDS = 500 + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearch.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearch.kt new file mode 100644 index 00000000..b755873f --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearch.kt @@ -0,0 +1,487 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant.documents + +import android.util.Log +import com.example.jetpacker.data.itinerary.DocumentSemanticSearchProvider +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager +import com.google.mlkit.genai.embedding.Content +import com.google.mlkit.genai.embedding.EmbeddingGeneration +import com.google.mlkit.genai.embedding.EmbeddingModel +import com.google.mlkit.genai.embedding.EmbeddingRequest +import com.google.mlkit.genai.embedding.ImagePart +import com.google.mlkit.genai.embedding.Modality +import com.google.mlkit.genai.embedding.Part +import com.google.mlkit.genai.embedding.TaskConfig +import com.google.mlkit.genai.embedding.TextPart +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Coordinates on-device semantic document search by bridging the ML Kit Embedding API (Embedding + * Gemma v2) with Jetpack AppSearch native vector indexing and retrieval. + * + * Implements Google's canonical on-device RAG architecture: + * 1. Room serves as the relational source of truth. + * 2. Jetpack AppSearch provides hardware-accelerated hybrid search (BM25 + cosine vector + * similarity). + * 3. ML Kit Embedding API generates high-dimensional embeddings on-device. + */ +@Singleton +class DocumentSemanticSearch +@Inject +constructor( + private val travelDocumentDao: TravelDocumentDao, + private val travelDocumentManager: TravelDocumentManager, + private val appSearchManager: DocumentAppSearchManager? = null, +) : DocumentSemanticSearchProvider { + + /** + * Vector cache keyed by the document's embedding text, so that re-indexing an unchanged document + * never re-runs the embedding model. + */ + private val embeddingCache = ConcurrentHashMap() + + /** + * Content hash of the payload most recently written to AppSearch, keyed by document ID. This + * replaces a one-shot "synced" latch so that documents added after the first query (for example, + * a document scanned live on stage) are picked up immediately. + */ + private val indexedContentHashes = ConcurrentHashMap() + + private val indexMutex = Mutex() + + @Volatile private var cachedModel: EmbeddingModel? = null + @Volatile private var modelChecked = false + @Volatile private var signatureVerified = false + + @androidx.annotation.VisibleForTesting + internal fun setEmbeddingModelForTesting(model: EmbeddingModel?) { + cachedModel = model + modelChecked = true + signatureVerified = false + } + + @androidx.annotation.VisibleForTesting + internal fun isDocumentHashIndexed(docId: String): Boolean = + indexedContentHashes.containsKey(docId) + + init { + travelDocumentManager.semanticSearchProvider = this + Log.i( + TAG, + if (appSearchManager != null) { + "Vault retrieval: AppSearch hybrid index enabled" + } else { + "Vault retrieval: no AppSearch index, falling back to in-memory scoring" + }, + ) + } + + private suspend fun getEmbeddingModel(): EmbeddingModel? { + if (modelChecked) return cachedModel + return try { + val models = EmbeddingGeneration.getEmbeddingModels() + val model = models.firstOrNull() + cachedModel = model + modelChecked = true + model + } catch (e: Throwable) { + Log.w(TAG, "Embedding model not available on device", e) + modelChecked = true + null + } + } + + private suspend fun getModelSignature(model: EmbeddingModel?): String? = model?.let { + runCatching { it.getModelSignature() }.getOrNull() + } + + /** + * Finds the most relevant travel documents for a natural language query. + * + * Scopes search to the specified [tripId] plus global documents (where [TravelDocument.tripId] is + * null). Queries Jetpack AppSearch using native hybrid semantic search when available; falls back + * to token and metadata similarity scoring if AppSearch or ML models are unavailable. + * + * @param query Natural language user query (e.g. "passport", "flight ticket to Tokyo", "health + * insurance"). + * @param tripId Optional trip ID to restrict results to. + * @param maxResults Maximum number of documents to return. + */ + override suspend fun findRelevantDocuments( + query: String, + tripId: String?, + maxResults: Int, + ): List = + withContext(Dispatchers.IO) { + val trimmedQuery = query.trim() + if (trimmedQuery.isBlank()) return@withContext emptyList() + + val allDocs = travelDocumentDao.getAllDocuments().first() + if (allDocs.isEmpty()) return@withContext emptyList() + + // Filter to documents in scope: matching tripId OR global documents (tripId == null) + val scopedDocs = + if (!tripId.isNullOrBlank()) { + allDocs.filter { it.tripId == null || it.tripId == tripId } + } else { + allDocs + } + + if (scopedDocs.isEmpty()) return@withContext emptyList() + + val model = getEmbeddingModel() + val signature = getModelSignature(model) + + // Primary retrieval engine: Jetpack AppSearch hybrid search + if (appSearchManager != null) { + try { + syncIndex(allDocs, model, signature) + + val queryVector = + if (model != null) { + val queryReq = + EmbeddingRequest( + content = Content(listOf(TextPart(trimmedQuery))), + taskConfig = TaskConfig.RetrievalQuery(), + ) + model.generateEmbedding(queryReq).embedding.vector + } else { + null + } + + val matchedIds = + appSearchManager.search( + query = trimmedQuery, + queryVector = queryVector, + modelSignature = signature, + tripId = tripId, + maxResults = maxResults, + ) + + if (matchedIds.isNotEmpty()) { + val scopedMap = scopedDocs.associateBy { it.id } + val matchedDocs = matchedIds.mapNotNull { scopedMap[it] } + if (matchedDocs.isNotEmpty()) { + return@withContext matchedDocs.take(maxResults) + } + } + } catch (e: Exception) { + Log.w(TAG, "AppSearch hybrid query failed; falling back to in-memory/keyword search", e) + } + } + + // Secondary fallback: in-memory cosine loop if model is present, otherwise keyword scoring + if (model != null) { + try { + return@withContext searchWithEmbeddings(model, trimmedQuery, scopedDocs, maxResults) + } catch (e: Exception) { + Log.w(TAG, "Embedding search failed; falling back to keyword search", e) + } + } + + searchWithKeywords(trimmedQuery, scopedDocs, maxResults) + } + + /** + * Ensures the AppSearch index reflects the current contents of Room. + * + * Safe to call repeatedly: unchanged documents are skipped, and documents that no longer exist in + * Room are pruned from the index. + */ + suspend fun ensureIndexUpToDate() = + withContext(Dispatchers.IO) { + if (appSearchManager == null) return@withContext + val docs = travelDocumentDao.getAllDocuments().first() + val model = getEmbeddingModel() + syncIndex(docs, model, getModelSignature(model)) + } + + override suspend fun clearIndex() { + withContext(Dispatchers.IO) { + indexMutex.withLock { + appSearchManager?.clearAll() + indexedContentHashes.clear() + embeddingCache.clear() + } + } + } + + override suspend fun syncIndex() { + ensureIndexUpToDate() + } + + /** Removes a single document from the AppSearch index, for example after it is deleted. */ + suspend fun removeFromIndex(docId: String) = + withContext(Dispatchers.IO) { + val manager = appSearchManager ?: return@withContext + indexMutex.withLock { + manager.removeDocument(docId) + indexedContentHashes.remove(docId) + } + Unit + } + + /** + * Incrementally syncs Room documents into AppSearch, generating embeddings and validating the + * embedding model signature. + */ + private suspend fun syncIndex( + docs: List, + model: EmbeddingModel?, + signature: String?, + ) { + val manager = appSearchManager ?: return + + indexMutex.withLock { + if (!signatureVerified && !signature.isNullOrBlank()) { + verifyModelSignature(manager, signature) + signatureVerified = true + } + + for (doc in docs) { + val text = buildDocumentEmbeddingText(doc) + val contentHash = contentHashOf(doc, text) + if (indexedContentHashes[doc.id] == contentHash) continue + + val vector = + if (model != null && !signature.isNullOrBlank()) { + embedDocument(doc, model, text) + } else { + null + } + manager.indexDocument(doc, text, vector, signature) + if (vector != null) { + indexedContentHashes[doc.id] = contentHash + } + } + + pruneDeletedDocuments(manager, docs.mapTo(mutableSetOf()) { it.id }) + } + } + + /** + * Drops AppSearch entries whose Room rows are gone, so a deleted document can never resurface as + * a search hit. + */ + private suspend fun pruneDeletedDocuments( + manager: DocumentAppSearchManager, + liveIds: Set, + ) { + val indexedIds = runCatching { manager.getIndexedDocumentIds() }.getOrNull() ?: return + for (staleId in indexedIds - liveIds) { + manager.removeDocument(staleId) + indexedContentHashes.remove(staleId) + } + } + + /** + * Clears the index when the embedding model changed, because vectors produced by different models + * are not comparable, then records the current signature. + */ + private suspend fun verifyModelSignature(manager: DocumentAppSearchManager, signature: String?) { + val storedSignature = manager.getStoredModelSignature() + if (signature != null && storedSignature != null && storedSignature != signature) { + Log.i( + TAG, + "Embedding model signature changed ($storedSignature -> $signature); clearing stale index", + ) + manager.clearAll() + indexedContentHashes.clear() + embeddingCache.clear() + } + if (signature != null && storedSignature != signature) { + manager.setStoredModelSignature(signature) + } + } + + /** Includes file path, MIME type, and trip/event linkage so changes force a re-index. */ + private fun contentHashOf(doc: TravelDocument, text: String): Int = + "$text|${doc.filePath}|${doc.mimeType}|${doc.tripId.orEmpty()}|${doc.eventId.orEmpty()}" + .hashCode() + + /** Returns the cached vector for [doc], generating and caching it on first use. */ + private suspend fun embedDocument( + doc: TravelDocument, + model: EmbeddingModel, + text: String, + ): FloatArray? { + val cacheKey = "$text|${doc.filePath}" + embeddingCache[cacheKey]?.let { cached -> + return cached.takeIf { it.isNotEmpty() } + } + val vector = generateDocumentEmbedding(doc, model) + embeddingCache[cacheKey] = vector ?: FloatArray(0) + return vector + } + + private suspend fun supportsImageModality(model: EmbeddingModel): Boolean = + runCatching { model.getMetadata()?.supportedModalities?.contains(Modality.IMAGE) == true } + .getOrDefault(false) + + /** + * Generates a vector embedding for a travel document using the ML Kit GenAI Embedding API. + * + * When the document is backed by an image or PDF and the active [EmbeddingModel] supports + * [Modality.IMAGE] (Embedding Gemma v2), passes both [TextPart] and [ImagePart] (decoded image or + * rendered PDF page 0) so visual content and layout are embedded directly alongside text. Falls + * back gracefully to text-only embedding if multimodal inference is unsupported or fails. + */ + suspend fun generateDocumentEmbedding(doc: TravelDocument, model: EmbeddingModel): FloatArray? { + val docText = buildDocumentEmbeddingText(doc) + val taskConfig = TaskConfig.RetrievalDocument(title = doc.title.takeIf { it.isNotBlank() }) + + if (travelDocumentManager.isVisualDocument(doc) && supportsImageModality(model)) { + val bitmap = travelDocumentManager.loadDocumentBitmap(doc, maxDimension = 768) + if (bitmap != null) { + try { + return ImagePart(bitmap).use { imagePart -> + val multimodalReq = + EmbeddingRequest( + content = Content(listOf(TextPart(docText), imagePart)), + taskConfig = taskConfig, + ) + model.generateEmbedding(multimodalReq).embedding.vector + } + } catch (e: Exception) { + Log.w( + TAG, + "Multimodal ImagePart embedding failed for ${doc.id}; falling back to text-only", + e, + ) + } + } + } + + val textReq = + EmbeddingRequest(content = Content(listOf(TextPart(docText))), taskConfig = taskConfig) + return try { + val docRes = model.generateEmbedding(textReq) + docRes.embedding.vector + } catch (e: Exception) { + Log.w(TAG, "Failed to generate embedding for doc ${doc.id}", e) + null + } + } + + /** + * Indexes or updates a single document in the AppSearch index. + * + * Call this immediately after a document is scanned, created, imported or re-linked so that it is + * retrievable without waiting for the next full sync. + */ + suspend fun indexDocument(doc: TravelDocument) = + withContext(Dispatchers.IO) { + val manager = appSearchManager ?: return@withContext + val text = buildDocumentEmbeddingText(doc) + val model = getEmbeddingModel() + val signature = getModelSignature(model) + indexMutex.withLock { + val vector = + if (model != null && !signature.isNullOrBlank()) embedDocument(doc, model, text) else null + manager.indexDocument(doc, text, vector, signature) + if (vector != null) { + indexedContentHashes[doc.id] = contentHashOf(doc, text) + } + } + Unit + } + + private suspend fun searchWithEmbeddings( + model: EmbeddingModel, + query: String, + docs: List, + maxResults: Int, + ): List { + val queryReq = + EmbeddingRequest( + content = Content(listOf(TextPart(query))), + taskConfig = TaskConfig.RetrievalQuery(), + ) + val queryRes = model.generateEmbedding(queryReq) + val queryVector = queryRes.embedding.vector + + val scored = mutableListOf>() + + for (doc in docs) { + val docVector = embedDocument(doc, model, buildDocumentEmbeddingText(doc)) ?: continue + if (docVector.isNotEmpty()) { + val similarity = cosineSimilarity(queryVector, docVector) + scored.add(doc to similarity) + } + } + + return scored.sortedByDescending { it.second }.take(maxResults).map { it.first } + } + + private fun searchWithKeywords( + query: String, + docs: List, + maxResults: Int, + ): List { + val queryTerms = query.lowercase().split(Regex("""\s+""")).filter { it.length >= 2 } + if (queryTerms.isEmpty()) return docs.take(maxResults) + + val scored = docs.map { doc -> + val searchable = buildDocumentEmbeddingText(doc).lowercase() + var score = 0 + for (term in queryTerms) { + if (doc.documentType.lowercase() == term) score += 5 + if (doc.title.lowercase().contains(term)) score += 3 + if (searchable.contains(term)) score += 1 + } + doc to score + } + + return scored + .filter { it.second > 0 } + .sortedByDescending { it.second } + .take(maxResults) + .map { it.first } + .ifEmpty { docs.take(maxResults) } + } + + internal fun buildDocumentEmbeddingText(doc: TravelDocument): String = + travelDocumentManager.readDocumentEmbeddingText(doc) + + private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float { + if (a.isEmpty() || b.isEmpty() || a.size != b.size) return 0f + var dot = 0f + var normA = 0f + var normB = 0f + for (i in a.indices) { + dot += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + val denom = Math.sqrt((normA * normB).toDouble()).toFloat() + return if (denom > 0f) dot / denom else 0f + } + + companion object { + private const val TAG = "DocSemanticSearch" + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearchModule.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearchModule.kt new file mode 100644 index 00000000..040abdb5 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearchModule.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant.documents + +import com.example.jetpacker.data.itinerary.DocumentSemanticSearchProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DocumentSemanticSearchModule { + @Binds + @Singleton + abstract fun bindDocumentSemanticSearchProvider( + impl: DocumentSemanticSearch, + ): DocumentSemanticSearchProvider +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentVisionScanner.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentVisionScanner.kt new file mode 100644 index 00000000..9f340a49 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentVisionScanner.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant.documents + +import android.graphics.Bitmap +import android.util.Log +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.ImagePart +import com.google.mlkit.genai.prompt.ModelPreference +import com.google.mlkit.genai.prompt.ModelReleaseStage +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest +import com.google.mlkit.genai.prompt.generationConfig +import com.google.mlkit.genai.prompt.modelConfig +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import org.json.JSONObject + +/** Structured data extracted from a travel document photo by on-device Gemini Nano. */ +data class ExtractedDocumentData( + val title: String, + val documentType: String, + val documentNumber: String = "", + val issuingCountry: String = "", + val expiryDateEpochMs: Long = 0L, + val summary: String = "", +) + +/** + * On-device Gemini Nano multimodal vision extractor for travel documents, driver licenses, and + * vouchers. + */ +@Singleton +class DocumentVisionScanner @Inject constructor() { + + companion object { + private const val TAG = "DocumentVisionScanner" + } + + private val generativeModel by lazy { + val previewFastConfig = generationConfig { + modelConfig = modelConfig { + releaseStage = ModelReleaseStage.PREVIEW + preference = ModelPreference.FULL + } + } + Generation.getClient(previewFastConfig) + } + + suspend fun isSupported(): Boolean { + return try { + var status = generativeModel.checkStatus() + if (status == 1) { // 1 is DOWNLOADABLE + Log.d(TAG, "Model is downloadable. Triggering download...") + generativeModel.download().collect { Log.d(TAG, "Downloading progress...") } + status = generativeModel.checkStatus() + } + status == 3 // 3 is AVAILABLE + } catch (e: GenAiException) { + if (e.errorCode == 606) { + Log.w(TAG, "Feature not found, disabling.", e) + false + } else { + throw e + } + } catch (e: Exception) { + Log.e(TAG, "Error checking Gemini Nano status", e) + false + } + } + + suspend fun extractDocument(bitmap: Bitmap): ExtractedDocumentData { + val prompt = + """ + You are an expert on-device multimodal travel assistant. + Examine the provided image carefully. This image could be: + 1. An official ID or travel document (Driver's License, Passport, Visa, Boarding Pass, Flight Ticket, Booking Confirmation). + 2. A real-world travel photo or note (Hotel Wi-Fi card/network info, Museum or artwork plaque, Subway/train schedule or transit map, Baggage claim ticket or luggage tag, Restaurant menu or specials board, Parking spot sign, Hiking trailhead map, or Receipt). + + Extract all key information and return strictly valid JSON with these keys: + 1. "title": Concise, human-friendly title under 6 words (e.g. "Driver's License - Netherlands", "Hotel Wi-Fi & Breakfast Info", "Louvre Museum Plaque - Mona Lisa", "Baggage Claim Tag - UA991", "Tokyo Metro Ginza Line Map", "Level P2 Parking Spot"). + 2. "documentType": Must be one of: "DRIVER_LICENSE", "PASSPORT", "VISA", "TICKET", "HOTEL", "WIFI", "MUSEUM", "TRANSIT", "BAGGAGE", "PARKING", "MENU", "MAP", "NOTE", "OTHER". + 3. "documentNumber": Any visible ID number, license number, confirmation code, ticket number, or wi-fi password (e.g. "DL-••••1234", "WiFi: HotelGuest2026", "Spot: P2-4B"). + 4. "issuingCountry": Issuing country, state, city, or venue name if visible (e.g. "Netherlands", "California", "Tokyo", "Grand Hotel"). + 5. "expiryDate": Expiration date in ISO format "YYYY-MM-DD" if present on the document, or null if not applicable or not found. + IMPORTANT FOR DRIVER'S LICENSES (especially EU/Dutch/standard ISO cards): + - Field 4b ("4b", "Geldig tot", "Valid until") is the EXPIRATION DATE. Always use 4b as the expiryDate. + - Field 4a ("4a", "Afgiften", "Date of issue") is the issue date, NOT the expiry date. + - Field 3 ("3", "Geboortedatum", "Date of birth") is the birth date, NOT the expiry date. + - For non-expired items or non-documents without expiry dates, return null. + 6. "summary": A helpful 2-4 sentence summary of all essential details read from the image (names, dates, Wi-Fi password, opening/closing hours, platform/terminal numbers, baggage carousel, artist notes, ingredients, directions, etc.). + + Return ONLY the JSON object. + """ + .trimIndent() + + val request = generateContentRequest(ImagePart(bitmap), TextPart(prompt)) {} + + var fullResponse = "" + try { + generativeModel.generateContentStream(request).collect { response -> + val text = response.candidates.firstOrNull()?.text ?: "" + fullResponse += text + } + Log.i(TAG, "Raw Gemini Nano extraction response:\n$fullResponse") + } catch (e: Exception) { + Log.e(TAG, "Error generating vision content", e) + } + + val extracted = parseExtractionJson(fullResponse) + Log.i(TAG, "Extracted document data: $extracted") + return extracted + } + + private fun parseExtractionJson(rawText: String): ExtractedDocumentData { + if (rawText.isBlank()) { + return ExtractedDocumentData( + title = "Scanned Travel Photo", + documentType = "OTHER", + summary = "Photo scanned on device.", + ) + } + + val cleaned = + rawText.substringAfter("```json").substringBefore("```").trim().ifEmpty { rawText.trim() } + + return try { + val json = JSONObject(if (cleaned.startsWith("{")) cleaned else rawText) + val title = json.optString("title", "Scanned Document").ifBlank { "Scanned Document" } + val docType = + json.optString("documentType", "OTHER").uppercase().let { type -> + when { + type.contains("DRIVER") || type.contains("LICENSE") -> "DRIVER_LICENSE" + type.contains("PASS") -> "PASSPORT" + type.contains("VISA") -> "VISA" + type.contains("TICKET") || type.contains("FLIGHT") -> "TICKET" + type.contains("HOTEL") -> "HOTEL" + type.contains("INSUR") -> "INSURANCE" + type.contains("WIFI") -> "WIFI" + type.contains("MUSEUM") || type.contains("ART") -> "MUSEUM" + type.contains("TRANSIT") || + type.contains("TRAIN") || + type.contains("METRO") || + type.contains("SUBWAY") -> "TRANSIT" + type.contains("BAGGAGE") || type.contains("LUGGAGE") -> "BAGGAGE" + type.contains("PARKING") -> "PARKING" + type.contains("MENU") || type.contains("FOOD") || type.contains("RESTAURANT") -> "MENU" + type.contains("MAP") || type.contains("TRAIL") -> "MAP" + type.contains("NOTE") -> "NOTE" + else -> type + } + } + val docNum = json.optString("documentNumber", "") + val country = json.optString("issuingCountry", "") + val expiryStr = json.optString("expiryDate", "") + val summary = json.optString("summary", "") + + val expiryEpochMs = parseDateToEpochMs(expiryStr) + + ExtractedDocumentData( + title = title, + documentType = docType, + documentNumber = docNum, + issuingCountry = country, + expiryDateEpochMs = expiryEpochMs, + summary = summary, + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse JSON response: $rawText", e) + val isDriverLicense = + rawText.contains("driver", ignoreCase = true) || + rawText.contains("license", ignoreCase = true) + val isPassport = rawText.contains("passport", ignoreCase = true) + val isFlight = + rawText.contains("flight", ignoreCase = true) || + rawText.contains("airline", ignoreCase = true) + val isWifi = + rawText.contains("wifi", ignoreCase = true) || rawText.contains("wi-fi", ignoreCase = true) + val isMuseum = + rawText.contains("museum", ignoreCase = true) || + rawText.contains("artist", ignoreCase = true) + + val type = + when { + isDriverLicense -> "DRIVER_LICENSE" + isPassport -> "PASSPORT" + isFlight -> "TICKET" + isWifi -> "WIFI" + isMuseum -> "MUSEUM" + else -> "OTHER" + } + val fallbackTitle = + when { + isDriverLicense -> "Driver's License" + isPassport -> "Passport" + isFlight -> "Flight Ticket" + isWifi -> "Wi-Fi Info" + isMuseum -> "Museum Exhibit" + else -> "Scanned Travel Photo" + } + + ExtractedDocumentData(title = fallbackTitle, documentType = type, summary = rawText.take(300)) + } + } + + private fun parseDateToEpochMs(dateStr: String): Long { + val clean = dateStr.trim().removeSurrounding("\"").substringBefore('T').trim() + if (clean.isBlank() || clean.equals("null", ignoreCase = true)) return 0L + + val patterns = + listOf( + "yyyy-MM-dd", + "dd-MM-yyyy", + "dd.MM.yyyy", + "dd/MM/yyyy", + "MM/dd/yyyy", + "yyyy/MM/dd", + "yyyy.MM.dd", + "d-M-yyyy", + "d.M.yyyy", + "d/M/yyyy", + "d MMM yyyy", + "MMMM d, yyyy", + ) + + for (pattern in patterns) { + try { + val formatter = DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH) + val localDate = LocalDate.parse(clean, formatter) + return localDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + } catch (_: Exception) { + // try next pattern + } + } + Log.w(TAG, "Could not parse date string into epoch millis: '$dateStr'") + return 0L + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsScreen.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsScreen.kt new file mode 100644 index 00000000..9ba7260d --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsScreen.kt @@ -0,0 +1,1914 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant.documents + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.NoteAdd +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ConfirmationNumber +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.Flight +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Hotel +import androidx.compose.material.icons.filled.LocalParking +import androidx.compose.material.icons.filled.Luggage +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Museum +import androidx.compose.material.icons.filled.NoteAdd +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Train +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.components.JetPackerFabConfig +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TravelDocument +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +enum class DocumentCategory(val label: String) { + ALL("All"), + TICKETS("Tickets & Passes"), + BOOKINGS("Reservations"), + IDS("Passports & IDs"), + NOTES("Notes & Info"), +} + +fun matchesCategory(doc: TravelDocument, category: DocumentCategory): Boolean { + if (category == DocumentCategory.ALL) return true + val combined = (doc.documentType + " " + doc.title).uppercase() + return when (category) { + DocumentCategory.ALL -> true + DocumentCategory.TICKETS -> + combined.contains("TICKET") || + combined.contains("FLIGHT") || + combined.contains("BOARDING") || + combined.contains("MUSEUM") || + combined.contains("PASS") || + combined.contains("EXHIBITION") || + combined.contains("CRUISE") || + combined.contains("TOUR") || + combined.contains("EVENT") + DocumentCategory.BOOKINGS -> + combined.contains("HOTEL") || + combined.contains("LODGING") || + combined.contains("RESORT") || + combined.contains("CAR") || + combined.contains("RENTAL") || + combined.contains("VEHICLE") || + combined.contains("BOOKING") || + combined.contains("CONFIRMATION") + DocumentCategory.IDS -> + combined.contains("PASSPORT") || + combined.contains("DRIVER") || + combined.contains("LICENSE") || + combined.contains("VISA") || + combined.contains("ESTA") || + combined.contains("IDENTITY") || + combined.contains("BADGE") + DocumentCategory.NOTES -> + combined.contains("WIFI") || + combined.contains("NOTE") || + combined.contains("MAP") || + combined.contains("MENU") || + combined.contains("FOOD") || + doc.documentType == "OTHER" + } +} + +enum class DocumentValidity { + VALID, + EXPIRING_SOON, + EXPIRED, + NONE, +} + +fun isIdentityDocument(docType: String, title: String): Boolean { + val combined = (docType + " " + title).uppercase() + return combined.contains("PASSPORT") || + combined.contains("DRIVER") || + combined.contains("LICENSE") || + combined.contains("VISA") || + combined.contains("ESTA") +} + +fun calculateValidity(expiryMillis: Long, isIdentity: Boolean = false): DocumentValidity { + if (expiryMillis <= 0L) return DocumentValidity.NONE + val now = System.currentTimeMillis() + if (expiryMillis < now) return DocumentValidity.EXPIRED + if (isIdentity) { + val sixMonthsMillis = 180L * 24 * 60 * 60 * 1000 + if (expiryMillis - now < sixMonthsMillis) return DocumentValidity.EXPIRING_SOON + } + return DocumentValidity.VALID +} + +fun formatExpiryDate(expiryMillis: Long): String { + if (expiryMillis <= 0L) return "No expiration date" + return try { + val date = Instant.ofEpochMilli(expiryMillis).atZone(ZoneId.systemDefault()).toLocalDate() + date.format(DateTimeFormatter.ISO_LOCAL_DATE) + } catch (e: Exception) { + "Unknown" + } +} + +enum class TripDocFilter { + THIS_TRIP, + ALL, +} + +/** + * Screen displaying the traveler's stored travel documents, passports, visas, flight vouchers, and + * imported travel files in a clean, modern Material 3 interface. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentsScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + tripId: String? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + onFabConfigChange: ((JetPackerFabConfig?) -> Unit)? = null, + viewModel: DocumentsViewModel = hiltViewModel(), +) { + val allDocuments by viewModel.documents.collectAsStateWithLifecycle() + val selectedDoc by viewModel.selectedDocument.collectAsStateWithLifecycle() + val previewContent by viewModel.previewContent.collectAsStateWithLifecycle() + val previewBitmap by viewModel.previewBitmap.collectAsStateWithLifecycle() + val userMessage by viewModel.userMessage.collectAsStateWithLifecycle() + val isProcessing by viewModel.isProcessing.collectAsStateWithLifecycle() + val vaultQuery by viewModel.vaultQuery.collectAsStateWithLifecycle() + val vaultResults by viewModel.vaultResults.collectAsStateWithLifecycle() + val vaultSearchStatus by viewModel.vaultSearchStatus.collectAsStateWithLifecycle() + val isSearchingVault by viewModel.isSearchingVault.collectAsStateWithLifecycle() + + val tripEventsFlow = + remember(tripId) { + if (tripId != null) viewModel.getEventsForTrip(tripId) else flowOf(emptyList()) + } + val tripEvents by tripEventsFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + + var selectedFilter by remember { mutableStateOf(TripDocFilter.THIS_TRIP) } + var selectedCategory by remember { mutableStateOf(DocumentCategory.ALL) } + + val filteredByScope = + remember(allDocuments, tripId, selectedFilter) { + if (tripId == null || selectedFilter == TripDocFilter.ALL) { + allDocuments + } else { + allDocuments.filter { it.tripId == tripId } + } + } + + val browsableDocuments = + remember(filteredByScope, selectedCategory) { + if (selectedCategory == DocumentCategory.ALL) { + filteredByScope + } else { + filteredByScope.filter { matchesCategory(it, selectedCategory) } + } + } + + val documents = vaultResults ?: browsableDocuments + val isVaultSearchActive = vaultResults != null + + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + var showAddDialog by remember { mutableStateOf(false) } + var showFilterDialog by remember { mutableStateOf(false) } + var showCreateTextDialog by remember { mutableStateOf(false) } + var documentToDelete by remember { mutableStateOf(null) } + var documentToLink by remember { mutableStateOf(null) } + + val hasActiveFilters = + selectedCategory != DocumentCategory.ALL || + (tripId != null && selectedFilter != TripDocFilter.THIS_TRIP) + + if (onFabConfigChange != null) { + LaunchedEffect(onFabConfigChange) { + onFabConfigChange( + JetPackerFabConfig( + icon = Icons.Rounded.Add, + onClick = { showAddDialog = true }, + contentDescription = "Add Document", + ) + ) + } + } + + val filePickerLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + viewModel.importDocument(uri = uri, tripId = tripId) + } + } + + val context = LocalContext.current + var pendingPhotoUri by rememberSaveable { mutableStateOf(null) } + + val cameraLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) { success -> + if (success) { + pendingPhotoUri?.let { uriString -> + val uri = Uri.parse(uriString) + viewModel.scanDocumentFromUri(uri = uri, tripId = tripId) + } + } + } + + val launchCamera: () -> Unit = { + try { + val capturesDir = File(context.cacheDir, "camera_captures").apply { mkdirs() } + val photoFile = + File(capturesDir, "capture_${System.currentTimeMillis()}.jpg").apply { createNewFile() } + val uri = + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", photoFile) + pendingPhotoUri = uri.toString() + cameraLauncher.launch(uri) + } catch (e: Exception) { + Log.e("DocumentsScreen", "Failed to launch camera capture", e) + } + } + + LaunchedEffect(userMessage) { + userMessage?.let { msg -> + snackbarHostState.showSnackbar(msg) + viewModel.clearUserMessage() + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + if (tripId != null) "Trip Documents" else "Travel Documents", + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showAddDialog = true }) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Add Document") + } + IconButton(onClick = { viewModel.seedDefaultDocuments() }) { + Icon(imageVector = Icons.Default.Refresh, contentDescription = "Reset Demo Documents") + } + }, + colors = + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + ) + }, + floatingActionButton = { + if (onFabConfigChange == null) { + ExtendedFloatingActionButton( + onClick = { showAddDialog = true }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("Add Document") }, + ) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = contentPadding.calculateBottomPadding()), + ) + }, + ) { paddingValues -> + Box( + modifier = + Modifier.fillMaxSize() + .padding( + top = paddingValues.calculateTopPadding(), + bottom = + maxOf(paddingValues.calculateBottomPadding(), contentPadding.calculateBottomPadding()), + ) + ) { + Column(modifier = Modifier.fillMaxSize()) { + DocumentSearchBar( + query = vaultQuery, + isSearching = isSearchingVault, + statusLine = vaultSearchStatus, + hasActiveFilters = hasActiveFilters, + onQueryChange = { q -> + viewModel.onVaultQueryChange( + q, + tripId = tripId.takeIf { selectedFilter == TripDocFilter.THIS_TRIP }, + ) + }, + onSearch = { + viewModel.searchVault(tripId.takeIf { selectedFilter == TripDocFilter.THIS_TRIP }) + }, + onClear = viewModel::clearVaultSearch, + onFilterClick = { showFilterDialog = true }, + ) + + // Show a compact active filter badge row ONLY if filters are active and search is not overriding + if (hasActiveFilters && !isVaultSearchActive) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (tripId != null && selectedFilter == TripDocFilter.ALL) { + FilterChip( + selected = true, + onClick = { showFilterDialog = true }, + label = { Text("All Documents") }, + trailingIcon = { + IconButton( + onClick = { selectedFilter = TripDocFilter.THIS_TRIP }, + modifier = Modifier.size(16.dp), + ) { + Icon( + Icons.Rounded.Close, + contentDescription = "Clear scope filter", + modifier = Modifier.size(12.dp), + ) + } + }, + ) + } + if (selectedCategory != DocumentCategory.ALL) { + FilterChip( + selected = true, + onClick = { showFilterDialog = true }, + label = { Text(selectedCategory.label) }, + trailingIcon = { + IconButton( + onClick = { selectedCategory = DocumentCategory.ALL }, + modifier = Modifier.size(16.dp), + ) { + Icon( + Icons.Rounded.Close, + contentDescription = "Clear category filter", + modifier = Modifier.size(12.dp), + ) + } + }, + ) + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (documents.isEmpty() && !isVaultSearchActive) { + EmptyDocumentsState( + onPickFile = { filePickerLauncher.launch(arrayOf("*/*")) }, + onScanCamera = launchCamera, + onAddText = { showCreateTextDialog = true }, + onSeedDefaults = { viewModel.seedDefaultDocuments() }, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 88.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "header_summary") { + Text( + text = + if (isVaultSearchActive) { + "Best matches for \u201C$vaultQuery\u201D" + } else { + "${documents.size} saved documents" + }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + + if (isVaultSearchActive && documents.isEmpty()) { + item(key = "no_matches") { + Text( + text = "No matching documents found. Try different keywords or add a new document.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 24.dp), + ) + } + } + + items(documents, key = { it.id }) { doc -> + DocumentCard( + document = doc, + tripId = tripId, + events = tripEvents, + loadCardBitmap = viewModel::loadCardBitmap, + onInspect = { viewModel.selectDocumentForPreview(doc) }, + onDelete = { documentToDelete = doc }, + onLinkToEvent = { documentToLink = doc }, + onUnlinkEvent = { + viewModel.linkDocumentToEvent(doc.documentId, null, tripId = tripId) + }, + onAddToTrip = { viewModel.linkDocumentToTrip(doc.documentId, tripId) }, + onCopyText = { text -> + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Document Info", text)) + coroutineScope.launch { + snackbarHostState.showSnackbar("Copied $text to clipboard") + } + }, + onShareDocument = { + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, doc.title) + putExtra( + Intent.EXTRA_TEXT, + "${doc.title}\n${doc.documentType} • ${doc.issuingCountry}\n${doc.notes}", + ) + } + context.startActivity(Intent.createChooser(shareIntent, "Share Document")) + }, + ) + } + } + } + } + } + + if (isProcessing) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } + + if (selectedDoc != null) { + DocumentPreviewSheet( + document = selectedDoc!!, + content = previewContent, + bitmap = previewBitmap, + onDismiss = { viewModel.selectDocumentForPreview(null) }, + onLinkToEvent = { + val doc = selectedDoc + viewModel.selectDocumentForPreview(null) + documentToLink = doc + }, + onDelete = { + val doc = selectedDoc + viewModel.selectDocumentForPreview(null) + documentToDelete = doc + }, + onCopyText = { text -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Document Info", text)) + coroutineScope.launch { snackbarHostState.showSnackbar("Copied $text to clipboard") } + }, + onShare = { + val doc = selectedDoc!! + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, doc.title) + putExtra(Intent.EXTRA_TEXT, previewContent ?: "${doc.title}\n${doc.notes}") + } + context.startActivity(Intent.createChooser(shareIntent, "Share Document")) + }, + ) + } + + if (showAddDialog) { + AddDocumentOptionsDialog( + onDismiss = { showAddDialog = false }, + onPickFile = { + showAddDialog = false + filePickerLauncher.launch(arrayOf("*/*")) + }, + onScanCamera = { + showAddDialog = false + launchCamera() + }, + onCreateText = { + showAddDialog = false + showCreateTextDialog = true + }, + ) + } + + if (showFilterDialog) { + DocumentFilterDialog( + tripId = tripId, + selectedFilter = selectedFilter, + selectedCategory = selectedCategory, + onFilterChange = { selectedFilter = it }, + onCategoryChange = { selectedCategory = it }, + onReset = { + selectedFilter = TripDocFilter.THIS_TRIP + selectedCategory = DocumentCategory.ALL + }, + onDismiss = { showFilterDialog = false }, + ) + } + + if (showCreateTextDialog) { + CreateTextDocumentDialog( + onDismiss = { showCreateTextDialog = false }, + events = tripEvents, + onSave = { title, content, type, country, expiry, number, eventId -> + viewModel.createTextDocument( + title = title, + content = content, + documentType = type, + issuingCountry = country, + expiryDate = expiry, + documentNumberMasked = number, + tripId = tripId, + eventId = eventId, + ) + showCreateTextDialog = false + }, + ) + } + + if (documentToLink != null) { + AlertDialog( + onDismissRequest = { documentToLink = null }, + title = { Text("Link Document to Event") }, + text = { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Select an event to link to \"${documentToLink!!.title}\":", + style = MaterialTheme.typography.bodyMedium, + ) + if (tripEvents.isEmpty()) { + Text( + text = "No events in this trip yet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + tripEvents.forEach { event -> + OutlinedCard( + onClick = { + viewModel.linkDocumentToEvent( + documentToLink!!.documentId, + event.id, + tripId = tripId, + ) + documentToLink = null + }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Event, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = event.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${event.type.name.replace('_', ' ')} • ${event.location}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = { documentToLink = null }) { Text("Cancel") } }, + ) + } + + if (documentToDelete != null) { + AlertDialog( + onDismissRequest = { documentToDelete = null }, + title = { Text("Delete Document") }, + text = { + Text( + "Are you sure you want to delete \"${documentToDelete!!.title}\"? The local file will be removed." + ) + }, + confirmButton = { + Button( + onClick = { + documentToDelete?.let { viewModel.deleteDocument(it) } + documentToDelete = null + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text("Delete") + } + }, + dismissButton = { TextButton(onClick = { documentToDelete = null }) { Text("Cancel") } }, + ) + } +} + +/** Clean, modern search bar for querying documents with integrated filter button. */ +@Composable +private fun DocumentSearchBar( + query: String, + isSearching: Boolean, + statusLine: String?, + hasActiveFilters: Boolean, + onQueryChange: (String) -> Unit, + onSearch: () -> Unit, + onClear: () -> Unit, + onFilterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.weight(1f), + singleLine = true, + shape = RoundedCornerShape(24.dp), + placeholder = { Text("Search tickets, bookings, IDs...", maxLines = 1) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search documents", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + when { + isSearching -> + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + query.isNotEmpty() -> + IconButton(onClick = onClear) { + Icon(imageVector = Icons.Rounded.Close, contentDescription = "Clear search") + } + } + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = Color.Transparent, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + ) + + Surface( + onClick = onFilterClick, + shape = CircleShape, + color = + if (hasActiveFilters) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + contentColor = + if (hasActiveFilters) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(52.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = "Filter documents", + modifier = Modifier.size(22.dp), + ) + } + } + } + + if (statusLine != null && query.isNotEmpty()) { + Text( + text = statusLine, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, top = 4.dp), + ) + } + } +} + +/** Dialog for scoping and categorizing travel documents cleanly without cluttering the screen. */ +@Composable +private fun DocumentFilterDialog( + tripId: String?, + selectedFilter: TripDocFilter, + selectedCategory: DocumentCategory, + onFilterChange: (TripDocFilter) -> Unit, + onCategoryChange: (DocumentCategory) -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + var tempFilter by remember { mutableStateOf(selectedFilter) } + var tempCategory by remember { mutableStateOf(selectedCategory) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Filter Documents", style = MaterialTheme.typography.titleLarge) + if (tempFilter != TripDocFilter.THIS_TRIP || tempCategory != DocumentCategory.ALL) { + TextButton( + onClick = { + tempFilter = TripDocFilter.THIS_TRIP + tempCategory = DocumentCategory.ALL + } + ) { + Text("Reset") + } + } + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (tripId != null) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Trip Scope", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { tempFilter = TripDocFilter.THIS_TRIP } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = tempFilter == TripDocFilter.THIS_TRIP, + onClick = { tempFilter = TripDocFilter.THIS_TRIP }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + "This trip only", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Only show documents added for this trip", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { tempFilter = TripDocFilter.ALL } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = tempFilter == TripDocFilter.ALL, + onClick = { tempFilter = TripDocFilter.ALL }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + "All documents", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Show documents from all trips and global storage", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + HorizontalDivider() + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Category", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + + val categories = + listOf( + Triple(DocumentCategory.ALL, "All Categories", Icons.Default.FolderOpen), + Triple(DocumentCategory.TICKETS, "Tickets & Passes", Icons.Default.ConfirmationNumber), + Triple(DocumentCategory.BOOKINGS, "Reservations", Icons.Default.Hotel), + Triple(DocumentCategory.IDS, "Passports & IDs", Icons.Default.Badge), + Triple(DocumentCategory.NOTES, "Notes & Info", Icons.Default.Notes), + ) + + categories.forEach { (cat, label, catIcon) -> + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { tempCategory = cat } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = tempCategory == cat, + onClick = { tempCategory = cat }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = catIcon, + contentDescription = null, + tint = + if (tempCategory == cat) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (tempCategory == cat) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { + onFilterChange(tempFilter) + onCategoryChange(tempCategory) + onDismiss() + } + ) { + Text("Apply") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +/** Tonal, modern document card with intuitive layout, quick copy, and overflow options. */ +@Composable +private fun DocumentCard( + document: TravelDocument, + onInspect: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, + tripId: String? = null, + events: List = emptyList(), + loadCardBitmap: suspend (TravelDocument) -> Bitmap? = { null }, + onLinkToEvent: () -> Unit = {}, + onUnlinkEvent: () -> Unit = {}, + onAddToTrip: () -> Unit = {}, + onCopyText: (String) -> Unit = {}, + onShareDocument: () -> Unit = {}, +) { + val isIdentity = isIdentityDocument(document.documentType, document.title) + val validity = calculateValidity(document.expiryDate, isIdentity = isIdentity) + val icon = getDocumentIcon(document.documentType, document.mimeType, document.title) + val cardBitmap by + produceState(initialValue = null, document.filePath, document.mimeType) { + value = loadCardBitmap(document) + } + + var showMenu by remember { mutableStateOf(false) } + val linkedEvent = events.find { it.id == document.eventId } + + Card( + onClick = onInspect, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + // Top header row: Icon + Title/Subtitle + Overflow Menu + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(24.dp), + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = document.title.ifBlank { document.documentId }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 2.dp), + ) { + Text( + text = document.documentType.replace('_', ' '), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Text( + text = formatDocumentModalityLabel(document.mimeType, document.filePath), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + if (document.issuingCountry.isNotBlank()) { + Text( + text = "• ${document.issuingCountry}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Options", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + if (tripId != null) { + if (document.tripId == tripId) { + if (linkedEvent != null) { + DropdownMenuItem( + text = { Text("Unlink from Event") }, + onClick = { + showMenu = false + onUnlinkEvent() + }, + leadingIcon = { Icon(Icons.Rounded.Close, contentDescription = null) }, + ) + } + DropdownMenuItem( + text = { Text(if (linkedEvent != null) "Change Event" else "Link to Event") }, + onClick = { + showMenu = false + onLinkToEvent() + }, + leadingIcon = { Icon(Icons.Rounded.Event, contentDescription = null) }, + ) + } else { + DropdownMenuItem( + text = { Text("Add to this Trip") }, + onClick = { + showMenu = false + onAddToTrip() + }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + ) + } + } + DropdownMenuItem( + text = { Text("Share") }, + onClick = { + showMenu = false + onShareDocument() + }, + leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, + ) + DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + onClick = { + showMenu = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Default.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + } + + // Middle metadata chips row: Document number with quick-copy + Validity + if (document.documentNumberMasked.isNotBlank() || validity != DocumentValidity.NONE) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (document.documentNumberMasked.isNotBlank()) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + onClick = { onCopyText(document.documentNumberMasked) }, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + if (document.documentType == "WIFI") "WiFi: ${document.documentNumberMasked}" + else "ID: ${document.documentNumberMasked}", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } else { + Spacer(modifier = Modifier.width(1.dp)) + } + + when (validity) { + DocumentValidity.EXPIRING_SOON -> { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(13.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Expires soon (${formatExpiryDate(document.expiryDate)})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + DocumentValidity.EXPIRED -> { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(13.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Expired (${formatExpiryDate(document.expiryDate)})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + DocumentValidity.VALID -> { + Text( + text = "Valid · ${formatExpiryDate(document.expiryDate)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DocumentValidity.NONE -> {} + } + } + } + + // Visual bitmap preview (image / rendered PDF) + if (cardBitmap != null) { + Spacer(modifier = Modifier.height(10.dp)) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + modifier = + Modifier.fillMaxWidth().heightIn(max = 170.dp).clip(RoundedCornerShape(12.dp)), + ) { + Image( + bitmap = cardBitmap!!.asImageBitmap(), + contentDescription = document.title, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + // Notes summary + if (document.notes.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = document.notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // Linked event tag + if (linkedEvent != null && tripId != null) { + Spacer(modifier = Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Event, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Linked to: ${linkedEvent.title}", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +/** Comprehensive, modern bottom sheet for inspecting travel documents, passes, and emails. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DocumentPreviewSheet( + document: TravelDocument, + content: String?, + bitmap: Bitmap?, + onDismiss: () -> Unit, + onLinkToEvent: () -> Unit, + onDelete: () -> Unit, + onCopyText: (String) -> Unit, + onShare: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val icon = getDocumentIcon(document.documentType, document.mimeType, document.title) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(48.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(28.dp), + ) + } + } + Spacer(modifier = Modifier.width(14.dp)) + Column { + Text( + text = document.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = + "${document.documentType.replace('_', ' ')} • ${formatDocumentModalityLabel(document.mimeType, document.filePath)} • ${document.issuingCountry.ifBlank { "Travel Document" }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (document.documentNumberMasked.isNotBlank()) { + FilledTonalButton( + onClick = { onCopyText(document.documentNumberMasked) }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Copy ID", maxLines = 1) + } + } + OutlinedButton( + onClick = onShare, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Share", maxLines = 1) + } + } + + // Visual bitmap display + if (bitmap != null) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = document.title, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)), + ) + } + } + + // Formatted content (for text files, emails, or fallback when no rendered bitmap) + if (bitmap == null) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + if (content == null) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (content.contains("Subject:") || content.contains("From:")) { + // Formatted email card + val lines = content.lines() + val subjectLine = lines.find { it.startsWith("Subject:", ignoreCase = true) } + val fromLine = lines.find { it.startsWith("From:", ignoreCase = true) } + val dateLine = lines.find { it.startsWith("Date:", ignoreCase = true) } + + if (subjectLine != null) { + Text( + text = subjectLine.substringAfter(':').trim(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (fromLine != null || dateLine != null) { + Text( + text = + listOfNotNull( + fromLine?.substringAfter(':')?.trim(), + dateLine?.substringAfter(':')?.trim(), + ) + .joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + + Text( + text = content, + style = + MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } + } + + // Bottom actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = onDelete, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(Icons.Default.DeleteOutline, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Delete") + } + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = onDismiss) { Text("Done") } + } + } + } +} + +@Composable +private fun EmptyDocumentsState( + onPickFile: () -> Unit, + onScanCamera: () -> Unit, + onAddText: () -> Unit, + onSeedDefaults: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(72.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(36.dp), + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No Travel Documents Yet", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + "Keep your flight confirmations, tickets, hotel bookings, and travel IDs organized in one place for easy access on your trip.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onScanCamera, modifier = Modifier.fillMaxWidth(0.85f)) { + Icon(Icons.Default.PhotoCamera, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scan Document") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onPickFile, modifier = Modifier.fillMaxWidth(0.85f)) { + Icon(Icons.Default.UploadFile, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Import Document File") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onAddText, modifier = Modifier.fillMaxWidth(0.85f)) { + Icon(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Create Text Document") + } + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = onSeedDefaults) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + Text("Load Sample Documents") + } + } +} + +@Composable +private fun AddDocumentOptionsDialog( + onDismiss: () -> Unit, + onPickFile: () -> Unit, + onScanCamera: () -> Unit, + onCreateText: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Travel Document") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedCard(onClick = onScanCamera, modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PhotoCamera, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text("Scan Document with Camera", fontWeight = FontWeight.Bold) + Text( + "Capture passport, driver's license, ticket, or confirmation photo", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + OutlinedCard(onClick = onPickFile, modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.UploadFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text("Import File from Device", fontWeight = FontWeight.Bold) + Text( + "Import PDF, image, email (.eml / HTML), or text voucher", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + OutlinedCard(onClick = onCreateText, modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.AutoMirrored.Filled.NoteAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text("Create Text Note / Confirmation", fontWeight = FontWeight.Bold) + Text( + "Paste booking confirmations, voucher text, or passport info", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun CreateTextDocumentDialog( + onDismiss: () -> Unit, + events: List = emptyList(), + onSave: + ( + title: String, + content: String, + type: String, + country: String, + expiry: Long, + number: String, + eventId: String?, + ) -> Unit, +) { + var title by remember { mutableStateOf("") } + var type by remember { mutableStateOf("OTHER") } + var content by remember { mutableStateOf("") } + var country by remember { mutableStateOf("") } + var number by remember { mutableStateOf("") } + var selectedEventId by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New Travel Document") }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title (e.g. Flight Confirmation UA 991)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedButton( + onClick = { type = "PASSPORT" }, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = + if (type == "PASSPORT") MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + Text("Passport", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { type = "DRIVER_LICENSE" }, + modifier = Modifier.weight(1.1f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = + if (type == "DRIVER_LICENSE") MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + Text("License", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { type = "FLIGHT" }, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = + if (type == "FLIGHT") MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + Text("Flight", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { type = "HOTEL" }, + modifier = Modifier.weight(0.9f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = + if (type == "HOTEL") MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + Text("Hotel", style = MaterialTheme.typography.labelSmall) + } + } + + OutlinedTextField( + value = country, + onValueChange = { country = it }, + label = { Text("Country / Airline / Venue (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = number, + onValueChange = { number = it }, + label = { Text("Confirmation / Document Number (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + if (events.isNotEmpty()) { + Text( + text = "Link to Itinerary Event (optional):", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedEventId == null, + onClick = { selectedEventId = null }, + label = { Text("None") }, + ) + events.forEach { ev -> + FilterChip( + selected = selectedEventId == ev.id, + onClick = { selectedEventId = ev.id }, + label = { Text(ev.title) }, + ) + } + } + } + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text("Content / Confirmation Details") }, + minLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + if (title.isNotBlank() && content.isNotBlank()) { + onSave( + title.trim(), + content.trim(), + type, + country.trim(), + 0L, + number.trim(), + selectedEventId, + ) + } + }, + enabled = title.isNotBlank() && content.isNotBlank(), + ) { + Text("Save Document") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +private fun getDocumentIcon( + documentType: String, + mimeType: String, + title: String = "", +): ImageVector { + val upper = (documentType + " " + title).uppercase() + return when { + upper.contains("MUSEUM") || upper.contains("EXHIBITION") || upper.contains("ART") -> + Icons.Default.Museum + upper.contains("HOTEL") || + upper.contains("LODGING") || + upper.contains("RESORT") || + upper.contains("ACCOMMODATION") -> Icons.Default.Hotel + upper.contains("CAR") || + upper.contains("RENTAL") || + upper.contains("VEHICLE") || + upper.contains("POLESTAR") || + upper.contains("ZEPHYR") -> Icons.Default.DirectionsCar + upper.contains("DRIVER") || upper.contains("LICENSE") -> Icons.Default.DirectionsCar + upper.contains("PASSPORT") || upper.contains("ESTA") -> Icons.Default.Badge + upper.contains("FLIGHT") || + upper.contains("AIRLINE") || + upper.contains("BOARDING") || + upper.contains("AIRPORT") -> Icons.Default.Flight + upper.contains("TRAIN") || + upper.contains("TRANSIT") || + upper.contains("METRO") || + upper.contains("SUBWAY") -> Icons.Default.Train + upper.contains("WIFI") || upper.contains("INTERNET") -> Icons.Default.Wifi + upper.contains("VISA") -> Icons.Default.CheckCircle + upper.contains("TICKET") || + upper.contains("PASS") || + upper.contains("CRUISE") || + upper.contains("TOUR") || + upper.contains("EVENT") -> Icons.Default.ConfirmationNumber + upper.contains("PARKING") -> Icons.Default.LocalParking + upper.contains("MENU") || + upper.contains("FOOD") || + upper.contains("RESTAURANT") || + upper.contains("DINING") -> Icons.Default.Restaurant + upper.contains("MAP") || upper.contains("TRAIL") -> Icons.Default.Map + upper.contains("LUGGAGE") || upper.contains("BAGGAGE") -> Icons.Default.Luggage + mimeType.contains("pdf") -> Icons.Default.Description + else -> Icons.Default.Description + } +} + +private fun formatDocumentModalityLabel(mimeType: String, filePath: String): String { + val lowerMime = mimeType.lowercase() + val lowerExt = filePath.substringAfterLast('.', "").lowercase() + return when { + lowerMime == "application/pdf" || lowerExt == "pdf" -> "PDF" + lowerMime == "message/rfc822" || lowerExt == "eml" || lowerExt == "msg" -> "EMAIL" + lowerMime.startsWith("image/") || lowerExt in setOf("jpg", "jpeg", "png", "webp") -> "IMAGE" + lowerMime == "text/html" || lowerExt in setOf("html", "htm") -> "HTML" + else -> "TEXT" + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsViewModel.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsViewModel.kt new file mode 100644 index 00000000..a5fc2e49 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsViewModel.kt @@ -0,0 +1,515 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant.documents + +import android.graphics.Bitmap +import android.net.Uri +import android.os.SystemClock +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * ViewModel managing user travel documents, file imports, and document content previews. + * + * Every mutation is written through to the on-device AppSearch vault index so that a document + * scanned or created moments ago is immediately retrievable by natural language, both from the + * vault search bar and from the Nano assistant. + */ +@HiltViewModel +class DocumentsViewModel +@Inject +constructor( + private val travelDocumentManager: TravelDocumentManager, + private val eventDao: EventDao, + private val documentVisionScanner: DocumentVisionScanner, + private val documentSemanticSearch: DocumentSemanticSearch, +) : ViewModel() { + + fun getEventsForTrip(tripId: String): Flow> { + return eventDao.getEventsForTrip(tripId) + } + + val documents: StateFlow> = + travelDocumentManager.documents.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + private val _selectedDocument = MutableStateFlow(null) + val selectedDocument: StateFlow = _selectedDocument.asStateFlow() + + private val _previewContent = MutableStateFlow(null) + val previewContent: StateFlow = _previewContent.asStateFlow() + + private val _previewBitmap = MutableStateFlow(null) + val previewBitmap: StateFlow = _previewBitmap.asStateFlow() + + private val _userMessage = MutableStateFlow(null) + val userMessage: StateFlow = _userMessage.asStateFlow() + + private val _isProcessing = MutableStateFlow(false) + val isProcessing: StateFlow = _isProcessing.asStateFlow() + + /** Text currently typed into the vault search bar. */ + private val _vaultQuery = MutableStateFlow("") + val vaultQuery: StateFlow = _vaultQuery.asStateFlow() + + /** Ranked vault matches, or null when no search is active and the full list should show. */ + private val _vaultResults = MutableStateFlow?>(null) + val vaultResults: StateFlow?> = _vaultResults.asStateFlow() + + /** Human readable provenance line, e.g. "3 matches · 84 ms · on-device". */ + private val _vaultSearchStatus = MutableStateFlow(null) + val vaultSearchStatus: StateFlow = _vaultSearchStatus.asStateFlow() + + private val _isSearchingVault = MutableStateFlow(false) + val isSearchingVault: StateFlow = _isSearchingVault.asStateFlow() + + private var vaultSearchJob: Job? = null + + init { + travelDocumentManager.semanticSearchProvider = documentSemanticSearch + viewModelScope.launch { + travelDocumentManager.seedDefaultDocumentsIfEmpty() + syncVaultIndex() + } + } + + /** + * Runs a natural language search across the on-device vault. + * + * @param tripId Restricts results to the active trip plus documents not tied to any trip. + */ + fun searchVault(tripId: String? = null) { + val query = _vaultQuery.value.trim() + vaultSearchJob?.cancel() + if (query.isBlank()) { + clearVaultSearch() + return + } + + vaultSearchJob = viewModelScope.launch { + performVaultSearch(query, tripId) + } + } + + fun onVaultQueryChange(query: String, tripId: String? = null) { + _vaultQuery.value = query + if (query.isBlank()) { + clearVaultSearch() + return + } + vaultSearchJob?.cancel() + vaultSearchJob = viewModelScope.launch { + delay(350) + performVaultSearch(_vaultQuery.value.trim(), tripId) + } + } + + private suspend fun performVaultSearch(query: String, tripId: String?) { + if (query.isBlank()) { + clearVaultSearch() + return + } + _isSearchingVault.value = true + val startMs = SystemClock.elapsedRealtime() + try { + val matches = + documentSemanticSearch.findRelevantDocuments( + query = query, + tripId = tripId, + maxResults = VAULT_SEARCH_MAX_RESULTS, + ) + val elapsedMs = SystemClock.elapsedRealtime() - startMs + _vaultResults.value = matches + _vaultSearchStatus.value = + if (matches.isEmpty()) { + "No matching documents (100% on-device, ${elapsedMs}ms)" + } else { + "${matches.size} match${if (matches.size == 1) "" else "es"} (100% on-device, ${elapsedMs}ms)" + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Vault search failed for query: $query", e) + _vaultResults.value = null + _vaultSearchStatus.value = "Vault search unavailable" + } finally { + _isSearchingVault.value = false + } + } + + /** Dismisses the active search and restores the full document list. */ + fun clearVaultSearch() { + vaultSearchJob?.cancel() + vaultSearchJob = null + _vaultQuery.value = "" + _vaultResults.value = null + _vaultSearchStatus.value = null + _isSearchingVault.value = false + } + + /** + * Brings the AppSearch vault index in line with Room, adding new docs and pruning deleted ones. + */ + private suspend fun syncVaultIndex() { + try { + documentSemanticSearch.ensureIndexUpToDate() + } catch (e: Exception) { + Log.w(TAG, "Failed to sync vault index", e) + } + } + + /** Indexes a single document, never letting an indexing failure break the user-visible flow. */ + private suspend fun indexInVault(doc: TravelDocument) { + try { + documentSemanticSearch.indexDocument(doc) + } catch (e: Exception) { + Log.w(TAG, "Failed to index document ${doc.id} in vault", e) + } + } + + /** + * Imports an external document (image, PDF, email, or text) from a file picker or share [Uri]. + */ + fun importDocument( + uri: Uri, + overrideTitle: String? = null, + documentType: String = "OTHER", + tripId: String? = null, + eventId: String? = null, + ) { + viewModelScope.launch { + _isProcessing.value = true + try { + var imported = + travelDocumentManager.importDocumentFromUri( + uri = uri, + overrideTitle = overrideTitle, + documentType = documentType, + tripId = tripId, + eventId = eventId, + ) + if ( + imported.notes.isBlank() && + travelDocumentManager.isVisualDocument(imported) && + runCatching { documentVisionScanner.isSupported() }.getOrDefault(false) + ) { + val bitmap = travelDocumentManager.loadDocumentBitmap(imported, maxDimension = 1920) + if (bitmap != null) { + val extracted = + runCatching { documentVisionScanner.extractDocument(bitmap) }.getOrNull() + if (extracted != null && extracted.summary.isNotBlank()) { + imported = + imported.copy( + title = + if ( + overrideTitle.isNullOrBlank() && + extracted.title.isNotBlank() && + extracted.title != "Scanned Document" + ) { + extracted.title + } else { + imported.title + }, + documentType = + if (imported.documentType == "OTHER" && extracted.documentType != "OTHER") { + extracted.documentType + } else { + imported.documentType + }, + issuingCountry = imported.issuingCountry.ifBlank { extracted.issuingCountry }, + expiryDate = + if (imported.expiryDate > 0L) imported.expiryDate + else extracted.expiryDateEpochMs, + documentNumberMasked = + imported.documentNumberMasked.ifBlank { extracted.documentNumber }, + notes = extracted.summary, + ) + travelDocumentManager.updateDocument(imported) + } + } + } + indexInVault(imported) + _userMessage.value = "Imported \"${imported.title}\"" + } catch (e: Exception) { + Log.e(TAG, "Failed to import document from $uri", e) + _userMessage.value = "Failed to import document: ${e.message}" + } finally { + _isProcessing.value = false + } + } + } + + suspend fun isScannerSupported(): Boolean = documentVisionScanner.isSupported() + + /** + * Scans a travel document photo with on-device Gemini Nano vision, extracts metadata, and saves + * it. + */ + fun scanDocumentFromBitmap(bitmap: Bitmap, tripId: String? = null, eventId: String? = null) { + viewModelScope.launch { + _isProcessing.value = true + try { + val extracted = documentVisionScanner.extractDocument(bitmap) + val saved = + travelDocumentManager.saveDocumentBitmap( + bitmap = bitmap, + title = extracted.title, + documentType = extracted.documentType, + issuingCountry = extracted.issuingCountry, + expiryDate = extracted.expiryDateEpochMs, + documentNumberMasked = extracted.documentNumber, + notes = extracted.summary, + tripId = tripId, + eventId = eventId, + ) + indexInVault(saved) + _userMessage.value = "Scanned and saved: ${saved.title}" + } catch (e: Exception) { + Log.e(TAG, "Failed to scan document", e) + _userMessage.value = "Failed to scan document: ${e.message}" + } finally { + _isProcessing.value = false + } + } + } + + /** + * Scans a high-resolution captured photo from an image [Uri] with on-device Gemini Nano vision. + */ + fun scanDocumentFromUri(uri: Uri, tripId: String? = null, eventId: String? = null) { + viewModelScope.launch { + _isProcessing.value = true + try { + val bitmap = travelDocumentManager.loadBitmapFromUri(uri) + if (bitmap == null) { + _userMessage.value = "Failed to load captured image." + return@launch + } + val extracted = documentVisionScanner.extractDocument(bitmap) + val saved = + travelDocumentManager.saveDocumentBitmap( + bitmap = bitmap, + title = extracted.title, + documentType = extracted.documentType, + issuingCountry = extracted.issuingCountry, + expiryDate = extracted.expiryDateEpochMs, + documentNumberMasked = extracted.documentNumber, + notes = extracted.summary, + tripId = tripId, + eventId = eventId, + ) + indexInVault(saved) + _userMessage.value = "Scanned and saved: ${saved.title}" + } catch (e: Exception) { + Log.e(TAG, "Failed to scan document from $uri", e) + _userMessage.value = "Failed to scan document: ${e.message}" + } finally { + _isProcessing.value = false + } + } + } + + /** Creates an in-app text document (e.g. flight confirmation or travel notes). */ + fun createTextDocument( + title: String, + content: String, + documentType: String = "OTHER", + issuingCountry: String = "", + expiryDate: Long = 0L, + documentNumberMasked: String = "", + notes: String = "", + tripId: String? = null, + eventId: String? = null, + ) { + viewModelScope.launch { + _isProcessing.value = true + try { + val created = + travelDocumentManager.createTextDocument( + title = title, + content = content, + documentType = documentType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = notes, + tripId = tripId, + eventId = eventId, + ) + indexInVault(created) + _userMessage.value = "Saved \"${created.title}\"" + } catch (e: Exception) { + Log.e(TAG, "Failed to create text document", e) + _userMessage.value = "Failed to create document: ${e.message}" + } finally { + _isProcessing.value = false + } + } + } + + /** Links or unlinks a document to/from a specific timeline event. */ + fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String? = null) { + viewModelScope.launch { + try { + travelDocumentManager.linkDocumentToEvent(documentId, eventId, tripId) + // Trip/event linkage is part of the indexed payload, so the entry must be refreshed. + syncVaultIndex() + _userMessage.value = + if (eventId != null) "Document linked to event" else "Document unlinked" + } catch (e: Exception) { + Log.e(TAG, "Failed to link document $documentId to event $eventId", e) + _userMessage.value = "Failed to update event link: ${e.message}" + } + } + } + + /** Links or unlinks a document to/from a specific trip. */ + fun linkDocumentToTrip(documentId: String, tripId: String?) { + viewModelScope.launch { + try { + travelDocumentManager.linkDocumentToTrip(documentId, tripId) + // The trip ID drives search scoping, so the index must follow the new linkage. + syncVaultIndex() + _userMessage.value = + if (tripId != null) "Document added to trip" else "Document removed from trip" + } catch (e: Exception) { + Log.e(TAG, "Failed to link document $documentId to trip $tripId", e) + _userMessage.value = "Failed to update trip link: ${e.message}" + } + } + } + + /** Deletes a travel document and its private storage file. */ + fun deleteDocument(document: TravelDocument) { + viewModelScope.launch { + try { + travelDocumentManager.deleteDocument(document) + // Prune before surfacing success so a deleted document can never resurface in search. + documentSemanticSearch.removeFromIndex(document.id) + _vaultResults.value = _vaultResults.value?.filterNot { it.id == document.id } + _userMessage.value = "Deleted \"${document.title}\"" + if (_selectedDocument.value?.documentId == document.documentId) { + _selectedDocument.value = null + _previewContent.value = null + _previewBitmap.value = null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to delete document ${document.documentId}", e) + _userMessage.value = "Failed to delete document: ${e.message}" + } + } + } + + /** Loads a thumbnail or rendered page 0 bitmap for visual documents (images and PDFs). */ + suspend fun loadCardBitmap(document: TravelDocument): Bitmap? { + if (!travelDocumentManager.isVisualDocument(document)) return null + return withContext(Dispatchers.IO) { + try { + travelDocumentManager.loadDocumentBitmap(document, maxDimension = 768) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + } + } + + /** Selects a document to inspect its visual bitmap (image/PDF) and text content in a dialog. */ + fun selectDocumentForPreview(document: TravelDocument?) { + _selectedDocument.value = document + if (document == null) { + _previewContent.value = null + _previewBitmap.value = null + return + } + + viewModelScope.launch { + _previewBitmap.value = + if (travelDocumentManager.isVisualDocument(document)) { + withContext(Dispatchers.IO) { + try { + travelDocumentManager.loadDocumentBitmap(document, maxDimension = 1280) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + } + } else { + null + } + try { + val content = withContext(Dispatchers.IO) { + travelDocumentManager.readDocumentContent(document) + } + _previewContent.value = content + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Could not read content for ${document.documentId}", e) + _previewContent.value = "Unable to read document contents." + } + } + } + + /** Resets and re-seeds the diverse multimodal demo documents (PDF, Email, Image, Text). */ + fun seedDefaultDocuments() { + viewModelScope.launch { + _isProcessing.value = true + try { + clearVaultSearch() + travelDocumentManager.resetDefaultDocuments() + syncVaultIndex() + _userMessage.value = "Loaded 8 diverse demo documents (PDF, Email, Image, Text)" + } finally { + _isProcessing.value = false + } + } + } + + fun clearUserMessage() { + _userMessage.value = null + } + + companion object { + private const val TAG = "DocumentsViewModel" + + /** The vault is a small personal corpus, so a short ranked list is the useful answer. */ + private const val VAULT_SEARCH_MAX_RESULTS = 5 + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt index fed5adf6..8568c978 100644 --- a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt @@ -16,6 +16,7 @@ package com.example.jetpacker.feature.assistant.model +import com.example.jetpacker.data.itinerary.TravelDocument import java.util.UUID /** Record of a single tool execution during an agent turn. */ @@ -31,6 +32,13 @@ data class ToolExecutionRecord( * alongside the assistant's answer. */ sealed interface HighlightedEntity { + /** Represents a travel document (e.g. passport or visa) with its checked validity status. */ + data class TravelDocumentHighlight( + val document: TravelDocument, + val validityStatus: String, + val validityExplanation: String, + ) : HighlightedEntity + /** Represents flight details highlighted from the traveler's itinerary. */ data class FlightDetailHighlight( val flightNumber: String, diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt index 0b31b883..18960e22 100644 --- a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt @@ -16,6 +16,7 @@ package com.example.jetpacker.feature.assistant.tools +import android.graphics.BitmapFactory import android.util.Log import com.example.jetpacker.data.itinerary.EventDao import com.example.jetpacker.data.itinerary.EventType @@ -24,16 +25,21 @@ import com.example.jetpacker.data.itinerary.ExpenseDao import com.example.jetpacker.data.itinerary.FlightDetail import com.example.jetpacker.data.itinerary.TimelineEvent import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager import com.example.jetpacker.data.itinerary.VoiceNoteEntity import com.example.jetpacker.data.trips.DummyData import com.example.jetpacker.data.trips.Trip import com.example.jetpacker.data.trips.TripDao +import com.example.jetpacker.feature.assistant.documents.DocumentSemanticSearch import com.google.mlkit.genai.prompt.Generation import com.google.mlkit.genai.prompt.ImagePart import com.google.mlkit.genai.prompt.TextPart import com.google.mlkit.genai.prompt.generateContentRequest import com.google.mlkit.genai.schema.tools.FunctionSchema import com.google.mlkit.genai.schema.tools.FunctionType +import java.io.File import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -51,8 +57,7 @@ class GetFlightItineraryTool( private val activeTripIdProvider: () -> String? = { null }, ) { val name = "get_flight_itinerary" - val description = - "Retrieves flight itinerary details including flight number, airline, airports, departure and arrival times, terminal, gate, and status for a trip or flight number." + val description = "Retrieves flight itinerary details including flight number, airline, airports, departure and arrival times, terminal, gate, and status for a trip or flight number." val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, @@ -131,6 +136,491 @@ class GetFlightItineraryTool( } } +/** Tool to retrieve stored travel documents (passports, visas, IDs) from the Room database. */ +class GetTravelDocumentTool( + private val travelDocumentDao: TravelDocumentDao, + private val documentSemanticSearch: DocumentSemanticSearch? = null, +) { + val name = "get_travel_document" + val description = "Retrieves stored travel documents, hotel welcome cards, Wi-Fi networks and passwords, flight vouchers, tickets, IDs, driver's licenses, baggage claim tags, parking signs, and travel notes. Call this whenever the user asks about Wi-Fi passwords, lodging details, reservations, tickets, or travel documents." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "documentType" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Optional document type filter: 'WIFI', 'HOTEL', 'PASSPORT', 'VISA', 'TICKET', 'DRIVER_LICENSE', 'BAGGAGE', 'PARKING', 'TRANSIT', 'MUSEUM', 'NOTE', or 'OTHER'.", + ), + "query" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Natural language search query to locate relevant documents, Wi-Fi cards, hotel info, tickets, or notes (e.g. 'wifi password', 'hotel', 'passport', 'parking', 'baggage').", + ), + "documentId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional unique identifier of a specific document.", + ), + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip ID to filter documents for a specific trip.", + ), + "eventId" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Optional timeline event ID to filter documents linked to a specific event.", + ), + ), + ) + + suspend fun execute(args: Map): Map { + val documentId = args["documentId"]?.toString() + val documentType = args["documentType"]?.toString() + val query = args["query"]?.toString() + val tripId = args["tripId"]?.toString() + val eventId = args["eventId"]?.toString() + + val documents: List = + if (!documentId.isNullOrBlank()) { + val doc = travelDocumentDao.getDocumentById(documentId) + if (doc != null) listOf(doc) else emptyList() + } else if (!query.isNullOrBlank() && documentSemanticSearch != null) { + documentSemanticSearch.findRelevantDocuments(query = query, tripId = tripId) + } else if (!eventId.isNullOrBlank()) { + travelDocumentDao.getDocumentsForEvent(eventId).first() + } else if (!tripId.isNullOrBlank()) { + travelDocumentDao.getDocumentsForTrip(tripId).first() + } else if (!documentType.isNullOrBlank()) { + travelDocumentDao.getDocumentsByType(documentType).first().ifEmpty { + travelDocumentDao.getDocumentsByType(documentType.uppercase()).first() + } + } else { + travelDocumentDao.getAllDocuments().first() + } + + val docList = documents.map { doc -> + val expiryDateString = + if (doc.expiryDate > 0L) { + try { + Instant.ofEpochMilli(doc.expiryDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.ISO_LOCAL_DATE) + } catch (e: Exception) { + "" + } + } else { + "" + } + mapOf( + "id" to doc.id, + "documentId" to doc.documentId, + "title" to doc.title, + "documentType" to doc.documentType, + "issuingCountry" to doc.issuingCountry, + "expiryDate" to expiryDateString, + "expiryDateLong" to doc.expiryDate, + "documentNumberMasked" to doc.documentNumberMasked, + "notes" to doc.notes, + "tripId" to (doc.tripId ?: ""), + "eventId" to (doc.eventId ?: ""), + "hasFile" to doc.filePath.isNotBlank(), + ) + } + + return mapOf("documents" to docList, "count" to docList.size) + } +} + +/** Tool to check passport or visa validity for travel against the 6-month validity rule. */ +class CheckDocumentValidityTool( + private val travelDocumentDao: TravelDocumentDao, + private val tripDao: TripDao, +) { + val name = "check_document_validity" + val description = "Checks whether a travel document (e.g., passport or visa) is valid for travel relative to departure dates, enforcing the 6-month validity rule." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "documentType" to + FunctionSchema( + type = FunctionType.STRING, + description = "The document type to check: 'passport' (default) or 'visa'.", + ), + "travelStartDate" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional travel start date in ISO YYYY-MM-DD format.", + ), + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip identifier to look up travel dates from.", + ), + ), + ) + + suspend fun execute(args: Map): Map { + val docType = args["documentType"]?.toString()?.takeIf { it.isNotBlank() } ?: "passport" + val travelDateStr = args["travelStartDate"]?.toString() + val tripId = args["tripId"]?.toString() + + val referenceDate: LocalDate = + if (!travelDateStr.isNullOrBlank()) { + try { + LocalDate.parse(travelDateStr, DateTimeFormatter.ISO_LOCAL_DATE) + } catch (e: Exception) { + LocalDate.now() + } + } else if (!tripId.isNullOrBlank()) { + val trip = tripDao.getTripById(tripId).first() + if (trip != null && trip.startDate > 0L) { + try { + Instant.ofEpochMilli(trip.startDate).atZone(ZoneId.systemDefault()).toLocalDate() + } catch (e: Exception) { + LocalDate.now() + } + } else { + LocalDate.now() + } + } else { + val allTrips = tripDao.getAllTrips().first() + val now = System.currentTimeMillis() + val upcoming = + allTrips.filter { it.startDate >= now }.minByOrNull { it.startDate } + ?: allTrips.firstOrNull() + if (upcoming != null && upcoming.startDate > 0L) { + try { + Instant.ofEpochMilli(upcoming.startDate).atZone(ZoneId.systemDefault()).toLocalDate() + } catch (e: Exception) { + LocalDate.now() + } + } else { + LocalDate.now() + } + } + + val docs = + travelDocumentDao.getDocumentsByType(docType).first().ifEmpty { + travelDocumentDao.getDocumentsByType(docType.uppercase()).first() + } + if (docs.isEmpty()) { + return mapOf( + "status" to "NOT_FOUND", + "documentType" to docType, + "validForTravel" to false, + "explanation" to "No $docType document found in your records. Please add your $docType.", + ) + } + + val primaryDoc = docs.first() + val expiryDate: LocalDate = + if (primaryDoc.expiryDate > 0L) { + try { + Instant.ofEpochMilli(primaryDoc.expiryDate).atZone(ZoneId.systemDefault()).toLocalDate() + } catch (e: Exception) { + return mapOf( + "status" to "UNKNOWN", + "documentType" to docType, + "validForTravel" to false, + "explanation" to + "Could not parse document expiry date timestamp: ${primaryDoc.expiryDate}", + ) + } + } else { + return mapOf( + "status" to "UNKNOWN", + "documentType" to docType, + "validForTravel" to false, + "explanation" to "Document expiry date is missing or invalid.", + ) + } + + val expiryDateStr = expiryDate.format(DateTimeFormatter.ISO_LOCAL_DATE) + val daysUntilExpiry = ChronoUnit.DAYS.between(referenceDate, expiryDate) + val monthsUntilExpiry = ChronoUnit.MONTHS.between(referenceDate, expiryDate) + + val (status, explanation, validForTravel) = + when { + daysUntilExpiry < 0 -> { + Triple( + "EXPIRED", + "Your $docType expired on $expiryDateStr and cannot be used for travel.", + false, + ) + } + daysUntilExpiry < 180 -> { + Triple( + "EXPIRING_SOON", + "Your $docType expires on $expiryDateStr ($monthsUntilExpiry months / $daysUntilExpiry days from travel date). Most countries require at least 6 months validity from entry date. Renewal is strongly recommended.", + false, + ) + } + else -> { + Triple( + "VALID", + "Your $docType is valid until $expiryDateStr ($monthsUntilExpiry months remaining). It meets international travel validity requirements.", + true, + ) + } + } + + return mapOf( + "status" to status, + "documentType" to primaryDoc.documentType, + "documentId" to primaryDoc.documentId, + "issuingCountry" to primaryDoc.issuingCountry, + "expiryDate" to expiryDateStr, + "expiryDateLong" to primaryDoc.expiryDate, + "documentNumberMasked" to primaryDoc.documentNumberMasked, + "validForTravel" to validForTravel, + "daysUntilExpiry" to daysUntilExpiry, + "explanation" to explanation, + ) + } +} + +/** + * Tool to read full text content of an attached travel document (flight voucher, ticket, passport + * scan). + */ +class ReadDocumentContentTool( + private val travelDocumentManager: TravelDocumentManager, + private val travelDocumentDao: TravelDocumentDao, + private val documentSemanticSearch: DocumentSemanticSearch? = null, +) { + val name = "read_document_content" + val description = "Reads the full text, Wi-Fi details, or notes of a saved travel document, hotel welcome card, ticket, or voucher from the on-device document store." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "documentId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Unique ID of the document to read (e.g., from get_travel_document).", + ), + "query" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Keyword to search for in document title or notes (e.g., 'flight', 'passport', 'esta').", + ), + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip ID to scope document search to a specific trip.", + ), + ), + ) + + suspend fun execute(args: Map): Map { + val docId = args["documentId"]?.toString() + val query = args["query"]?.toString() + val tripId = args["tripId"]?.toString() + + val allDocs = travelDocumentDao.getAllDocuments().first() + val targetDoc = + if (!docId.isNullOrBlank()) { + allDocs.find { it.documentId == docId || it.id == docId } + } else if (!query.isNullOrBlank() && documentSemanticSearch != null) { + val matches = + documentSemanticSearch.findRelevantDocuments( + query = query, + tripId = tripId, + maxResults = 1, + ) + matches.firstOrNull() + ?: allDocs.find { + it.title.contains(query, ignoreCase = true) || + it.documentType.contains(query, ignoreCase = true) || + it.notes.contains(query, ignoreCase = true) + } + } else if (!query.isNullOrBlank()) { + allDocs.find { + it.title.contains(query, ignoreCase = true) || + it.documentType.contains(query, ignoreCase = true) || + it.notes.contains(query, ignoreCase = true) + } + } else { + allDocs.firstOrNull() + } + + if (targetDoc == null) { + return mapOf("error" to "Document not found", "status" to "NOT_FOUND") + } + + val isImage = + targetDoc.filePath.isNotBlank() && + targetDoc.filePath.substringAfterLast('.', "").lowercase() in + listOf("png", "jpg", "jpeg", "webp") + val extractedText = + if (isImage && targetDoc.notes.isBlank()) { + val extracted = extractTextFromImage(targetDoc.filePath) + if (extracted != null) { + try { + travelDocumentDao.insertDocument(targetDoc.copy(notes = extracted)) + } catch (e: Exception) { + Log.w("ReadDocContentTool", "Could not cache extracted text in document", e) + } + } + extracted + } else { + null + } + + val content = travelDocumentManager.readDocumentContent(targetDoc) + val finalContent = + if (!extractedText.isNullOrBlank()) { + "$content\n\nExtracted Image Text:\n$extractedText" + } else { + content + } + + return mapOf( + "documentId" to targetDoc.documentId, + "title" to targetDoc.title, + "documentType" to targetDoc.documentType, + "content" to finalContent, + "status" to "SUCCESS", + ) + } + + private suspend fun extractTextFromImage(filePath: String): String? = + withContext(Dispatchers.IO) { + try { + val file = File(filePath) + if (!file.exists() || !file.isFile) return@withContext null + val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return@withContext null + val generativeModel = Generation.getClient() + val prompt = + "You are an on-device document extractor. Extract all key text, names, dates (expiration, departure, arrival), document numbers, country, and travel details from this image. Format concisely as structured bullet points." + val request = generateContentRequest(ImagePart(bitmap), TextPart(prompt)) {} + val response = generativeModel.generateContent(request) + response.candidates.firstOrNull()?.text?.takeIf { it.isNotBlank() } + } catch (e: Exception) { + Log.w("ReadDocContentTool", "Could not extract text from document image", e) + null + } + } +} + +/** Tool to save a new travel document or note into the on-device document store. */ +class AddTravelDocumentTool(private val travelDocumentManager: TravelDocumentManager) { + val name = "add_travel_document" + val description = "Saves a new travel document, passport details, visa waiver, or travel voucher into the user's travel documents store." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "title" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Title or description of the document (e.g. 'Dutch Passport', 'Flight Voucher').", + ), + "documentType" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Document type: 'PASSPORT', 'VISA', 'TICKET', 'NATIONAL_ID', or 'OTHER'.", + ), + "issuingCountry" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Country that issued the document (e.g. 'Netherlands', 'United States').", + ), + "expiryDate" to + FunctionSchema( + type = FunctionType.STRING, + description = "Expiration date in ISO YYYY-MM-DD format (if applicable).", + ), + "documentNumberMasked" to + FunctionSchema( + type = FunctionType.STRING, + description = "Masked number (e.g. '••••4821').", + ), + "notes" to + FunctionSchema( + type = FunctionType.STRING, + description = "Additional travel notes, restrictions, or instructions.", + ), + "content" to + FunctionSchema( + type = FunctionType.STRING, + description = "Full text body or itinerary notes to save.", + ), + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip ID to link this document to a specific trip.", + ), + "eventId" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Optional timeline event ID to link this document to a specific event (e.g., flight confirmation).", + ), + ), + required = listOf("title"), + ) + + suspend fun execute(args: Map): Map { + val title = args["title"]?.toString() ?: "Untitled Document" + val docType = args["documentType"]?.toString()?.uppercase() ?: "OTHER" + val country = args["issuingCountry"]?.toString() ?: "" + val expiryStr = args["expiryDate"]?.toString() + val maskedNum = args["documentNumberMasked"]?.toString() ?: "" + val notes = args["notes"]?.toString() ?: "" + val content = args["content"]?.toString() ?: notes + val tripId = args["tripId"]?.toString() + val eventId = args["eventId"]?.toString() + + val expiryEpoch = + if (!expiryStr.isNullOrBlank()) { + try { + val date = LocalDate.parse(expiryStr, DateTimeFormatter.ISO_LOCAL_DATE) + date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + } catch (e: Exception) { + 0L + } + } else { + 0L + } + + val savedDoc = + travelDocumentManager.createTextDocument( + title = title, + content = content, + documentType = docType, + issuingCountry = country, + expiryDate = expiryEpoch, + documentNumberMasked = maskedNum, + notes = notes, + tripId = tripId, + eventId = eventId, + ) + + return mapOf( + "status" to "SUCCESS", + "documentId" to savedDoc.documentId, + "title" to savedDoc.title, + "message" to "Saved $title successfully to travel documents.", + ) + } +} + /** Tool to add an expense to a trip directly in the Room database. */ class AddExpenseTool( private val expenseDao: ExpenseDao, @@ -138,8 +628,7 @@ class AddExpenseTool( private val activeTripIdProvider: () -> String? = { null }, ) { val name = "addExpense" - val description = - "Adds an expense to a trip. Call this when the user asks to add or log an expense (e.g., train ticket, meal, taxi) for a trip." + val description = "Adds an expense to a trip. Call this when the user asks to add or log an expense (e.g., train ticket, meal, taxi) for a trip." val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, @@ -370,8 +859,7 @@ class AddItineraryEventTool( private val activeTripIdProvider: () -> String? = { null }, ) { val name = "addItineraryEvent" - val description = - "Adds a reminder or event to a trip's itinerary. Call this when the user asks to add an itinerary item, task, or reminder (e.g., 'remind me to pick up pocket wifi')." + val description = "Adds a reminder or event to a trip's itinerary. Call this when the user asks to add an itinerary item, task, or reminder (e.g., 'remind me to pick up pocket wifi')." val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, @@ -566,8 +1054,7 @@ class AddVoiceNoteTool( private val activeTripIdProvider: () -> String? = { null }, ) { val name = "addVoiceNote" - val description = - "Adds a voice note or reminder to a trip and links it to matching itinerary events (e.g. airport flight or museum visit). Call this when the user asks to save a voice note, visitor tips, or reminder (e.g., 'Please remind me to buy a sim card when I get to the airport' or 'Save the museum visitor tips as a voice note')." + val description = "Adds a voice note or reminder to a trip and links it to matching itinerary events (e.g. airport flight or museum visit). Call this when the user asks to save a voice note, visitor tips, or reminder (e.g., 'Please remind me to buy a sim card when I get to the airport' or 'Save the museum visitor tips as a voice note')." val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, @@ -717,8 +1204,7 @@ class AddVoiceNoteTool( /** Tool to search for trips by destination, title, or ID. */ class SearchTripTool(private val tripDao: TripDao) { val name = "searchTrip" - val description = - "Searches for trips matching a title, destination location, or ID. Call this first when the user refers to a trip by city or name and you need its tripId." + val description = "Searches for trips matching a title, destination location, or ID. Call this first when the user refers to a trip by city or name and you need its tripId." val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, @@ -863,8 +1349,7 @@ class GetItineraryTool( private val activeTripIdProvider: () -> String? = { null }, ) { val name = "getItinerary" - val description = - "Retrieves the list of itinerary events (with their eventId, title, type, and location) for a specific trip. Note: getItinerary returns event summaries only; call getEventDetails(eventId) with a specific event's ID to retrieve ticket prices, hotel rates, baggage allowance, or visitor tips." + val description = "Retrieves the list of itinerary events (with their eventId, title, type, and location) for a specific trip. Note: getItinerary returns event summaries only; call getEventDetails(eventId) with a specific event's ID to retrieve ticket prices, hotel rates, baggage allowance, or visitor tips." val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, @@ -923,8 +1408,7 @@ class GetEventDetailsTool( private val tourDetailDao: TourDetailDao? = null, ) { val name = "getEventDetails" - val description = - "Retrieves rich subtype details for a specific itinerary event by its eventId, including museum admission ticket price and opening hours, hotel nightly rate and confirmation number, flight terminal/gate/baggage allowance, dining reservation info, and visitor tips. Call this after getItinerary(tripId) when you need the price, rate, baggage allowance, or visitor tips of a specific event." + val description = "Retrieves rich subtype details for a specific itinerary event by its eventId, including museum admission ticket price and opening hours, hotel nightly rate and confirmation number, flight terminal/gate/baggage allowance, dining reservation info, and visitor tips. Call this after getItinerary(tripId) when you need the price, rate, baggage allowance, or visitor tips of a specific event." val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, @@ -1061,6 +1545,10 @@ class GetEventDetailsTool( /** Aggregates Room-backed travel tools for injection into the Agent. */ class TravelDaoTools( val getFlightItinerary: GetFlightItineraryTool, + val getTravelDocument: GetTravelDocumentTool, + val checkDocumentValidity: CheckDocumentValidityTool, + val readDocumentContent: ReadDocumentContentTool? = null, + val addTravelDocument: AddTravelDocumentTool? = null, val addExpense: AddExpenseTool? = null, val addItineraryEvent: AddItineraryEventTool? = null, val addVoiceNote: AddVoiceNoteTool? = null, @@ -1072,11 +1560,21 @@ class TravelDaoTools( constructor( eventDao: EventDao, tripDao: TripDao, + travelDocumentDao: TravelDocumentDao, expenseDao: ExpenseDao? = null, + travelDocumentManager: TravelDocumentManager? = null, + documentSemanticSearch: DocumentSemanticSearch? = null, tourDetailDao: TourDetailDao? = null, activeTripIdProvider: () -> String? = { null }, ) : this( getFlightItinerary = GetFlightItineraryTool(eventDao, tripDao, activeTripIdProvider), + getTravelDocument = GetTravelDocumentTool(travelDocumentDao, documentSemanticSearch), + checkDocumentValidity = CheckDocumentValidityTool(travelDocumentDao, tripDao), + readDocumentContent = + travelDocumentManager?.let { + ReadDocumentContentTool(it, travelDocumentDao, documentSemanticSearch) + }, + addTravelDocument = travelDocumentManager?.let { AddTravelDocumentTool(it) }, addExpense = expenseDao?.let { AddExpenseTool(it, tripDao, activeTripIdProvider) }, addItineraryEvent = AddItineraryEventTool(eventDao, tripDao, activeTripIdProvider), addVoiceNote = AddVoiceNoteTool(eventDao, tripDao, activeTripIdProvider), @@ -1088,6 +1586,10 @@ class TravelDaoTools( fun asList(): List = buildList { add(getFlightItinerary) + add(getTravelDocument) + add(checkDocumentValidity) + readDocumentContent?.let { add(it) } + addTravelDocument?.let { add(it) } addExpense?.let { add(it) } addItineraryEvent?.let { add(it) } addVoiceNote?.let { add(it) } diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt index c39f04cf..ba743f01 100644 --- a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt @@ -108,6 +108,7 @@ import com.example.jetpacker.feature.assistant.model.HighlightedEntity.ExpenseHi import com.example.jetpacker.feature.assistant.model.HighlightedEntity.FlightDetailHighlight import com.example.jetpacker.feature.assistant.model.HighlightedEntity.ItineraryEventHighlight import com.example.jetpacker.feature.assistant.model.HighlightedEntity.MultiActionHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.TravelDocumentHighlight import com.example.jetpacker.feature.assistant.model.HighlightedEntity.VoiceNoteHighlight import com.example.jetpacker.feature.assistant.model.ToolExecutionRecord import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Error @@ -384,6 +385,10 @@ fun TravelAssistantCard( @Composable private fun HighlightedEntitySection(highlight: HighlightedEntity?) { when (highlight) { + is TravelDocumentHighlight -> { + DocumentHighlightCard(highlight = highlight) + Spacer(modifier = Modifier.height(8.dp)) + } is FlightDetailHighlight -> { FlightHighlightCard(highlight = highlight) Spacer(modifier = Modifier.height(8.dp)) @@ -408,6 +413,99 @@ private fun HighlightedEntitySection(highlight: HighlightedEntity?) { } } +/** Structured highlight card displaying travel document validity details. */ +@Composable +private fun DocumentHighlightCard( + highlight: TravelDocumentHighlight, + modifier: Modifier = Modifier, +) { + val (badgeColor, textColor, icon) = + when (highlight.validityStatus) { + "VALID" -> Triple(Color(0xFF01AF57), Color(0xFF061B0E), Icons.Default.CheckCircle) + "EXPIRING_SOON" -> Triple(Color(0xFFE65100), Color.White, Icons.Default.Warning) + "EXPIRED" -> Triple(MaterialTheme.colorScheme.error, Color.White, Icons.Default.ErrorOutline) + else -> + Triple( + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant, + Icons.Default.Warning, + ) + } + + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + "${highlight.document.issuingCountry} ${highlight.document.documentType.replaceFirstChar { it.uppercase() }}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Surface(shape = MaterialTheme.shapes.small, color = badgeColor) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = highlight.validityStatus.replace('_', ' '), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = textColor, + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + val formattedExpiry = + if (highlight.document.expiryDate > 0L) { + try { + Instant.ofEpochMilli(highlight.document.expiryDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + } catch (e: Exception) { + highlight.document.expiryDate.toString() + } + } else { + "Unknown" + } + + Text( + text = "Number: ${highlight.document.documentNumberMasked} • Expires: $formattedExpiry", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (highlight.validityExplanation.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = highlight.validityExplanation, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + /** Structured highlight card displaying flight itinerary details. */ @Composable private fun FlightHighlightCard(highlight: FlightDetailHighlight, modifier: Modifier = Modifier) { @@ -794,7 +892,7 @@ private fun AgentEventStepper( Icon( imageVector = Icons.Default.Check, contentDescription = "Completed", - tint = Color(0xFF2E7D32), + tint = Color(0xFF01AF57), modifier = Modifier.size(14.dp), ) } @@ -811,6 +909,15 @@ private fun getHumanizedToolInfo( val cleanName = toolName.removePrefix("BaseJetPackerAppFunctionService_").substringAfterLast('.').substringAfterLast('#') return when (cleanName) { + "get_travel_document" -> + (if (isCompleted) "Checked travel documents" else "Checking travel documents…") to + Icons.Default.Search + "read_document_content" -> + (if (isCompleted) "Read document details" else "Reading document details…") to + Icons.Default.DocumentScanner + "check_document_validity" -> + (if (isCompleted) "Verified document expiry" else "Verifying document expiry…") to + Icons.Default.Verified "get_flight_itinerary" -> (if (isCompleted) "Checked flight schedule" else "Checking flight schedule…") to Icons.Default.Flight @@ -1154,7 +1261,7 @@ private fun ActionProgressFeed( Icon( imageVector = if (isCompleted) Icons.Default.CheckCircle else Icons.Default.AutoAwesome, contentDescription = null, - tint = if (isCompleted) Color(0xFF2E7D32) else MaterialTheme.colorScheme.primary, + tint = if (isCompleted) Color(0xFF01AF57) else MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) @@ -1173,7 +1280,7 @@ private fun ActionProgressFeed( Surface( shape = RoundedCornerShape(12.dp), color = - if (isCompleted) Color(0xFF2E7D32).copy(alpha = 0.14f) + if (isCompleted) Color(0xFF01AF57).copy(alpha = 0.14f) else MaterialTheme.colorScheme.primaryContainer, ) { Text( @@ -1183,7 +1290,7 @@ private fun ActionProgressFeed( style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = - if (isCompleted) Color(0xFF2E7D32) + if (isCompleted) Color(0xFF01AF57) else MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), ) @@ -1299,7 +1406,7 @@ private fun UserActionStepRow(step: UserActionStep, stepNumber: Int? = null) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = "Done", - tint = Color(0xFF2E7D32), + tint = Color(0xFF01AF57), modifier = Modifier.size(20.dp), ) } diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentAppSearchManagerTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentAppSearchManagerTest.kt new file mode 100644 index 00000000..75b48e99 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentAppSearchManagerTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.feature.assistant.documents.DocumentAppSearchManager +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +class DocumentAppSearchManagerTest { + + private lateinit var context: Context + private var _manager: DocumentAppSearchManager? = null + private val manager: DocumentAppSearchManager get() = checkNotNull(_manager) + + @Before + fun setUp() { + try { + System.loadLibrary("icing") + } catch (t: Throwable) { + Assume.assumeNoException( + "Icing native library requires an Android runtime; skipping on host JVM", + t, + ) + } + context = ApplicationProvider.getApplicationContext() + _manager = DocumentAppSearchManager(context) + } + + @After fun tearDown() = runTest { _manager?.clearAll() } + + @Test + fun testIndexAndSearchDocument_byTextPrefix() = runTest { + val doc = + TravelDocument( + id = "doc-passport-1", + title = "US Passport", + documentType = "passport", + issuingCountry = "United States", + notes = "Issued in 2022, valid for 10 years", + ) + + manager.indexDocument(doc, textContent = "US Passport passport United States Issued in 2022") + + val results = manager.search(query = "Passport", maxResults = 5) + assertThat(results).contains("doc-passport-1") + } + + @Test + fun testRemoveDocument_removesFromIndex() = runTest { + val doc = + TravelDocument( + id = "doc-license-1", + title = "Driver's License", + documentType = "driver_license", + issuingCountry = "Netherlands", + notes = "Category B", + ) + + manager.indexDocument( + doc, + textContent = "Driver's License driver_license Netherlands Category B", + ) + + val beforeResults = manager.search(query = "License", maxResults = 5) + assertThat(beforeResults).contains("doc-license-1") + + manager.removeDocument("doc-license-1") + + val afterResults = manager.search(query = "License", maxResults = 5) + assertThat(afterResults).doesNotContain("doc-license-1") + } + + @Test + fun testModelSignature_recordsAndRetrieves() = runTest { + val signature = "embeddinggemma_v2_cpu_float32" + manager.setStoredModelSignature(signature) + + val stored = manager.getStoredModelSignature() + assertThat(stored).isEqualTo(signature) + } + + @Test + fun testIndexAndSearchDocument_withEmbeddingVector() = runTest { + val signature = "gemma_embed_v2_test" + val doc1 = + TravelDocument( + id = "doc-tokyo-hotel", + title = "Tokyo Grand Hotel Reservation", + documentType = "hotel_reservation", + issuingCountry = "Japan", + notes = "Confirmation code 98765, check in at 3pm", + ) + val doc2 = + TravelDocument( + id = "doc-berlin-museum", + title = "Berlin Pergamon Pass", + documentType = "museum_ticket", + issuingCountry = "Germany", + notes = "Valid on weekends", + ) + + // Vector 1 points along dimension 0, vector 2 points along dimension 1 + val vec1 = floatArrayOf(1.0f, 0.0f, 0.0f, 0.0f) + val vec2 = floatArrayOf(0.0f, 1.0f, 0.0f, 0.0f) + + manager.indexDocument( + doc1, + textContent = "Tokyo Hotel Reservation", + vector = vec1, + modelSignature = signature, + ) + manager.indexDocument( + doc2, + textContent = "Berlin Museum Pass", + vector = vec2, + modelSignature = signature, + ) + + // Query vector matches vec1 + val queryVec = floatArrayOf(0.95f, 0.05f, 0.0f, 0.0f) + val results = + manager.search( + query = "hotel in Tokyo", + queryVector = queryVec, + modelSignature = signature, + maxResults = 1, + ) + + assertThat(results).containsExactly("doc-tokyo-hotel") + } + + @Test + fun testSearch_scopedToTrip_includesGlobalDocumentsAndExcludesOtherTrips() = runTest { + val tripDoc = + TravelDocument( + id = "doc-paris-hotel", + title = "Hotel Lumiere Reservation", + documentType = "hotel_reservation", + notes = "Wifi network LUMIERE-GUEST", + tripId = "2026-3", + ) + val globalDoc = + TravelDocument( + id = "doc-global-passport", + title = "Dutch Passport", + documentType = "passport", + notes = "Valid until 2031", + ) + val otherTripDoc = + TravelDocument( + id = "doc-tokyo-hotel-2", + title = "Tokyo Capsule Reservation", + documentType = "hotel_reservation", + notes = "Wifi network TOKYO-GUEST", + tripId = "2026-9", + ) + + manager.indexDocument(tripDoc, textContent = "Hotel Lumiere Reservation wifi passport") + manager.indexDocument(globalDoc, textContent = "Dutch Passport wifi passport") + manager.indexDocument(otherTripDoc, textContent = "Tokyo Capsule Reservation wifi passport") + + val results = manager.search(query = "passport OR wifi", tripId = "2026-3", maxResults = 10) + + assertThat(results).containsAtLeast("doc-paris-hotel", "doc-global-passport") + assertThat(results).doesNotContain("doc-tokyo-hotel-2") + } + + @Test + fun testGetIndexedDocumentIds_returnsEveryIndexedDocument() = runTest { + manager.indexDocument( + TravelDocument(id = "doc-a", title = "Alpha", documentType = "note"), + textContent = "Alpha", + ) + manager.indexDocument( + TravelDocument(id = "doc-b", title = "Beta", documentType = "note"), + textContent = "Beta", + ) + + assertThat(manager.getIndexedDocumentIds()).containsAtLeast("doc-a", "doc-b") + + manager.removeDocument("doc-a") + + assertThat(manager.getIndexedDocumentIds()).doesNotContain("doc-a") + } +} diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentSemanticSearchTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentSemanticSearchTest.kt new file mode 100644 index 00000000..e2521ac5 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentSemanticSearchTest.kt @@ -0,0 +1,431 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager +import com.example.jetpacker.feature.assistant.documents.DocumentAppSearchManager +import com.example.jetpacker.feature.assistant.documents.DocumentSemanticSearch +import com.google.common.truth.Truth.assertThat +import com.google.mlkit.genai.common.DownloadStatus +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.embedding.EmbeddingMetadata +import com.google.mlkit.genai.embedding.EmbeddingModel +import com.google.mlkit.genai.embedding.EmbeddingRequest +import com.google.mlkit.genai.embedding.EmbeddingResult +import com.google.mlkit.genai.embedding.EmbeddingVector +import com.google.mlkit.genai.embedding.ImagePart +import com.google.mlkit.genai.embedding.Modality +import com.google.mlkit.genai.embedding.TextPart +import java.io.File +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Covers the vault indexing contract behind Demo 1.B: documents must become retrievable as soon as + * they are added (not only on the very first query), and must disappear from the index the moment + * they are deleted. Also verifies multimodal `ImagePart`, PDF, and email (`.eml`) support. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +class DocumentSemanticSearchTest { + + private lateinit var context: Context + private lateinit var documentDao: FakeSemanticSearchDocumentDao + private lateinit var documentManager: TravelDocumentManager + private var _appSearchManager: DocumentAppSearchManager? = null + private val appSearchManager: DocumentAppSearchManager get() = checkNotNull(_appSearchManager) + private lateinit var semanticSearch: DocumentSemanticSearch + + private fun assumeAppSearchAvailable() { + Assume.assumeTrue( + "AppSearch LocalStorage requires native libicing runtime; skipping on host JVM", + _appSearchManager != null, + ) + } + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + documentDao = FakeSemanticSearchDocumentDao() + documentManager = TravelDocumentManager(context, documentDao) + val manager = + try { + System.loadLibrary("icing") + DocumentAppSearchManager(context) + } catch (_: Throwable) { + null + } + _appSearchManager = manager + semanticSearch = + DocumentSemanticSearch( + travelDocumentDao = documentDao, + travelDocumentManager = documentManager, + appSearchManager = manager, + ) + } + + @After fun tearDown() = runTest { _appSearchManager?.clearAll() } + + @Test + fun ensureIndexUpToDate_indexesEveryStoredDocument() = runTest { + assumeAppSearchAvailable() + documentDao.documents += passport() + documentDao.documents += hotelWifiNote() + + semanticSearch.ensureIndexUpToDate() + + assertThat(appSearchManager.getIndexedDocumentIds()) + .containsAtLeast("doc-passport", "doc-hotel-wifi") + } + + @Test + fun ensureIndexUpToDate_picksUpDocumentAddedAfterFirstQuery() = runTest { + assumeAppSearchAvailable() + documentDao.documents += passport() + semanticSearch.findRelevantDocuments(query = "passport") + + // Simulates scanning a document live, after the index has already been warmed once. + documentDao.documents += hotelWifiNote() + semanticSearch.ensureIndexUpToDate() + + assertThat(appSearchManager.getIndexedDocumentIds()).contains("doc-hotel-wifi") + } + + @Test + fun indexDocument_makesSingleDocumentRetrievableImmediately() = runTest { + assumeAppSearchAvailable() + val scanned = hotelWifiNote() + documentDao.documents += scanned + + semanticSearch.indexDocument(scanned) + + assertThat(appSearchManager.getIndexedDocumentIds()).contains("doc-hotel-wifi") + val matches = semanticSearch.findRelevantDocuments(query = "wifi password") + assertThat(matches.map { it.id }).contains("doc-hotel-wifi") + } + + @Test + fun removeFromIndex_dropsDeletedDocument() = runTest { + assumeAppSearchAvailable() + documentDao.documents += passport() + documentDao.documents += hotelWifiNote() + semanticSearch.ensureIndexUpToDate() + + documentDao.documents.removeAll { it.id == "doc-hotel-wifi" } + semanticSearch.removeFromIndex("doc-hotel-wifi") + + val indexed = appSearchManager.getIndexedDocumentIds() + assertThat(indexed).contains("doc-passport") + assertThat(indexed).doesNotContain("doc-hotel-wifi") + } + + @Test + fun ensureIndexUpToDate_prunesDocumentsDeletedOutsideTheViewModel() = runTest { + assumeAppSearchAvailable() + documentDao.documents += passport() + documentDao.documents += hotelWifiNote() + semanticSearch.ensureIndexUpToDate() + + documentDao.documents.removeAll { it.id == "doc-passport" } + semanticSearch.ensureIndexUpToDate() + + assertThat(appSearchManager.getIndexedDocumentIds()).doesNotContain("doc-passport") + } + + @Test + fun findRelevantDocuments_excludesOtherTrips() = runTest { + documentDao.documents += hotelWifiNote(tripId = "trip-paris") + documentDao.documents += + TravelDocument( + id = "doc-tokyo-wifi", + documentId = "doc-tokyo-wifi", + title = "Tokyo Capsule Hotel Wifi", + documentType = "note", + notes = "Network TOKYO-GUEST, password sakura2026", + tripId = "trip-tokyo", + ) + documentDao.documents += passport() + + val matches = + semanticSearch.findRelevantDocuments(query = "wifi password", tripId = "trip-paris") + + assertThat(matches.map { it.id }).doesNotContain("doc-tokyo-wifi") + } + + @Test + fun findRelevantDocuments_returnsEmptyForBlankQuery() = runTest { + documentDao.documents += passport() + + assertThat(semanticSearch.findRelevantDocuments(query = " ")).isEmpty() + } + + @Test + fun generateDocumentEmbedding_includesImagePartWhenModelSupportsImageModality() = runTest { + val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + val savedImageDoc = + documentManager.saveDocumentBitmap( + bitmap = bitmap, + title = "Boarding Pass Photo", + documentType = "TICKET", + notes = "Gate K42 Seat 14A", + ) + val multimodalModel = + FakeEmbeddingModel(supportedModalities = listOf(Modality.TEXT, Modality.IMAGE)) + + val vector = semanticSearch.generateDocumentEmbedding(savedImageDoc, multimodalModel) + + assertThat(vector).isNotNull() + val parts = multimodalModel.lastRequest?.content?.parts.orEmpty() + assertThat(parts.any { it is TextPart }).isTrue() + assertThat(parts.any { it is ImagePart }).isTrue() + } + + @Test + fun generateDocumentEmbedding_omitsImagePartWhenModelIsTextOnly() = runTest { + val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + val savedImageDoc = + documentManager.saveDocumentBitmap( + bitmap = bitmap, + title = "Boarding Pass Photo", + documentType = "TICKET", + notes = "Gate K42 Seat 14A", + ) + val textOnlyModel = FakeEmbeddingModel(supportedModalities = listOf(Modality.TEXT)) + + val vector = semanticSearch.generateDocumentEmbedding(savedImageDoc, textOnlyModel) + + assertThat(vector).isNotNull() + val parts = textOnlyModel.lastRequest?.content?.parts.orEmpty() + assertThat(parts).hasSize(1) + assertThat(parts.single()).isInstanceOf(TextPart::class.java) + } + + @Test + fun importEmailDocument_parsesHeadersAndBodyAndIsSearchable() = runTest { + val emlFile = File(context.cacheDir, "eurostar_booking.eml") + emlFile.writeText( + """ + From: bookings@eurostar.com + To: alex.chen@example.com + Subject: Eurostar Booking Confirmation #ES9921 + Date: Mon, 5 Oct 2026 09:00:00 +0000 + Content-Type: text/plain; charset="UTF-8" + + Your train from Paris Gare du Nord to London St Pancras is confirmed. + Coach 08, Seat 61. PNR: ZX88Q1. + """ + .trimIndent() + ) + + val imported = documentManager.importDocumentFromUri(Uri.fromFile(emlFile)) + semanticSearch.indexDocument(imported) + + assertThat(imported.title).isEqualTo("Eurostar Booking Confirmation #ES9921") + assertThat(imported.documentType).isEqualTo("TICKET") + val matches = semanticSearch.findRelevantDocuments(query = "St Pancras Coach 08") + assertThat(matches.map { it.id }).contains(imported.id) + } + + @Test + fun importPdfDocument_extractsTextStreamAndIsSearchable() = runTest { + val pdfFile = File(context.cacheDir, "flight_receipt.pdf") + pdfFile.writeText( + """ + %PDF-1.4 + 1 0 obj << /Type /Page >> endobj + 2 0 obj << /Length 88 >> + stream + BT /F1 12 Tf 72 712 Td (Lufthansa Flight LH454 Boarding Gate Z50 Confirmation MUC2026) Tj ET + endstream + endobj + %%EOF + """ + .trimIndent() + ) + + val imported = documentManager.importDocumentFromUri(Uri.fromFile(pdfFile)) + semanticSearch.indexDocument(imported) + + assertThat(imported.documentType).isEqualTo("TICKET") + assertThat(imported.notes).contains("Lufthansa Flight LH454") + val matches = semanticSearch.findRelevantDocuments(query = "Lufthansa Gate Z50") + assertThat(matches.map { it.id }).contains(imported.id) + } + + @Test + fun seedDefaultDocumentsIfEmpty_populatesAndIndexesDiverseFormatsAcrossModalities() = runTest { + documentManager.seedDefaultDocumentsIfEmpty() + semanticSearch.ensureIndexUpToDate() + + val seeded = documentDao.documents.toList() + assertThat(seeded).hasSize(8) + assertThat(seeded.map { it.mimeType }.toSet()) + .containsExactly("text/plain", "application/pdf", "message/rfc822", "image/jpeg") + + // Verify PDF text extraction works on the seeded PDF files + val pdfDoc = seeded.first { it.title.contains("Zephyr Wheels") } + val extractedPdfText = documentManager.extractPdfText(File(pdfDoc.filePath)) + assertThat(extractedPdfText).contains("Polestar 2 Long Range Dual Motor") + + // Verify searching across Email, PDF, Image, and Text seeded docs + val emailMatches = semanticSearch.findRelevantDocuments(query = "SEQUOIA2026") + assertThat(emailMatches.map { it.title }).contains("The Redwood Grove Hotel — Booking Email") + + val pdfMatches = semanticSearch.findRelevantDocuments(query = "FasTrak") + assertThat(pdfMatches.map { it.title }).contains("Zephyr Wheels Confirmation") + + val imageMatches = semanticSearch.findRelevantDocuments(query = "SFMOMA") + assertThat(imageMatches.map { it.title }).contains("SFMOMA VIP Museum & Exhibition Pass") + } + + @Test + fun syncIndex_whenVectorIsNull_doesNotCacheHashUntilEmbeddingModelAvailable() = runTest { + assumeAppSearchAvailable() + val doc = passport() + documentDao.documents += doc + + // 1. Model is null (e.g. not loaded or unavailable) + semanticSearch.setEmbeddingModelForTesting(null) + semanticSearch.ensureIndexUpToDate() + + // Comment #4: Hash must not be cached when vector is null + assertThat(semanticSearch.isDocumentHashIndexed(doc.id)).isFalse() + + // 2. Model becomes available later + val model = FakeEmbeddingModel(supportedModalities = listOf(Modality.TEXT)) + semanticSearch.setEmbeddingModelForTesting(model) + semanticSearch.ensureIndexUpToDate() + + // Now vector was generated, so hash is cached and embedding request occurred + assertThat(semanticSearch.isDocumentHashIndexed(doc.id)).isTrue() + assertThat(model.lastRequest).isNotNull() + } + + private fun passport() = + TravelDocument( + id = "doc-passport", + documentId = "doc-passport", + title = "Dutch Passport", + documentType = "passport", + issuingCountry = "Netherlands", + notes = "Issued 2021, valid ten years", + ) + + private fun hotelWifiNote(tripId: String? = null) = + TravelDocument( + id = "doc-hotel-wifi", + documentId = "doc-hotel-wifi", + title = "Hotel Lumiere Wifi", + documentType = "note", + notes = "Network LUMIERE-GUEST, password bonjour2026", + tripId = tripId, + ) +} + +private class FakeEmbeddingModel(private val supportedModalities: List) : EmbeddingModel { + var lastRequest: EmbeddingRequest? = null + + override suspend fun checkStatus(): Int = FeatureStatus.AVAILABLE + + override fun download(): Flow = flowOf(DownloadStatus.DownloadCompleted) + + override suspend fun warmup() {} + + override suspend fun getTokenLimit(): Int = 256 + + override suspend fun generateEmbedding(request: EmbeddingRequest): EmbeddingResult { + lastRequest = request + return EmbeddingResult( + embedding = EmbeddingVector(floatArrayOf(0.5f, 0.5f, 0.5f)), + modelSignature = "fake-embedding-gemma-v2", + ) + } + + override suspend fun getModelSignature(): String = "fake-embedding-gemma-v2" + + override suspend fun getMetadata(): EmbeddingMetadata = + EmbeddingMetadata(dimension = 3, supportedModalities = supportedModalities) + + override suspend fun isDeprecated(): Boolean = false + + override fun close() {} +} + +/** Minimal in-memory [TravelDocumentDao] so the tests never touch Room. */ +private class FakeSemanticSearchDocumentDao : TravelDocumentDao { + val documents = mutableListOf() + + override fun getAllDocuments(): Flow> = flowOf(documents.toList()) + + override fun getDocumentsByType(documentType: String): Flow> = + flowOf(documents.filter { it.documentType.equals(documentType, ignoreCase = true) }) + + override fun getDocumentsForTrip(tripId: String): Flow> = + flowOf(documents.filter { it.tripId == tripId }) + + override fun getDocumentsForEvent(eventId: String): Flow> = + flowOf(documents.filter { it.eventId == eventId }) + + override suspend fun getDocumentById(documentId: String): TravelDocument? = documents.find { + it.documentId == documentId || it.id == documentId + } + + override suspend fun insertDocument(document: TravelDocument) { + documents.add(document) + } + + override suspend fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String?) { + val index = documents.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (index != -1) { + val existing = documents[index] + documents[index] = existing.copy(eventId = eventId, tripId = tripId ?: existing.tripId) + } + } + + override suspend fun linkDocumentToTrip(documentId: String, tripId: String?) { + val index = documents.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (index != -1) { + documents[index] = documents[index].copy(tripId = tripId) + } + } + + override suspend fun deleteDocument(document: TravelDocument) { + documents.remove(document) + } + + override suspend fun deleteDocumentById(id: String) { + documents.removeAll { it.id == id } + } + + override suspend fun deleteAllDocuments() { + documents.clear() + } +} diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentsViewModelTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentsViewModelTest.kt new file mode 100644 index 00000000..963c2b32 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentsViewModelTest.kt @@ -0,0 +1,367 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetpacker.data.itinerary.ActivityDetail +import com.example.jetpacker.data.itinerary.DiningDetail +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.FlightDetail +import com.example.jetpacker.data.itinerary.HotelDetail +import com.example.jetpacker.data.itinerary.MuseumDetail +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager +import com.example.jetpacker.data.itinerary.TripSessionIdentifier +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.feature.assistant.documents.DocumentAppSearchManager +import com.example.jetpacker.feature.assistant.documents.DocumentSemanticSearch +import com.example.jetpacker.feature.assistant.documents.DocumentVisionScanner +import com.example.jetpacker.feature.assistant.documents.DocumentsViewModel +import com.google.common.truth.Truth.assertThat +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Covers the write-through indexing hooks that make Demo 1.B's "save it now, ask about it now" + * story work. + * + * [DocumentSemanticSearchTest] proves the index itself behaves; these tests prove the ViewModel + * actually calls into it on every mutation. Without them a dropped `indexInVault` call would leave + * every unit test green while the live demo silently failed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +class DocumentsViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var context: Context + private lateinit var documentDao: FakeVaultDocumentDao + private lateinit var appSearchManager: DocumentAppSearchManager + private lateinit var semanticSearch: DocumentSemanticSearch + private lateinit var viewModel: DocumentsViewModel + + @Before + fun setUp() { + try { + System.loadLibrary("icing") + } catch (t: Throwable) { + Assume.assumeNoException( + "Icing native library requires an Android runtime; skipping on host JVM", + t, + ) + } + Dispatchers.setMain(testDispatcher) + context = ApplicationProvider.getApplicationContext() + documentDao = FakeVaultDocumentDao() + appSearchManager = DocumentAppSearchManager(context) + val manager = TravelDocumentManager(context, documentDao) + semanticSearch = + DocumentSemanticSearch( + travelDocumentDao = documentDao, + travelDocumentManager = manager, + appSearchManager = appSearchManager, + ) + viewModel = + DocumentsViewModel( + travelDocumentManager = manager, + eventDao = FakeVaultEventDao(), + documentVisionScanner = DocumentVisionScanner(), + documentSemanticSearch = semanticSearch, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun createTextDocument_isIndexedWithoutWaitingForASearch() = runTest { + settleUntil { documentDao.snapshot().isNotEmpty() } + + viewModel.createTextDocument( + title = "Hotel Lumiere Booking", + content = "Wifi network LUMIERE-GUEST, password bonjour2026", + documentType = "note", + tripId = TRIP_ID, + ) + settleUntil { documentDao.snapshot().any { it.title == "Hotel Lumiere Booking" } } + val created = documentDao.snapshot().single { it.title == "Hotel Lumiere Booking" } + + // The write-through hook is the only thing that can put this id in the index; nothing else + // re-syncs after a create. + settleUntil { appSearchManager.getIndexedDocumentIds().contains(created.id) } + + assertThat(appSearchManager.getIndexedDocumentIds()).contains(created.id) + } + + @Test + fun searchVault_findsADocumentCreatedMomentsEarlier() = runTest { + createLumiereNote() + + viewModel.onVaultQueryChange("lumiere wifi") + viewModel.searchVault(TRIP_ID) + settleUntil { viewModel.vaultResults.value != null } + + assertThat(viewModel.vaultResults.value?.map { it.title }).contains("Hotel Lumiere Booking") + } + + @Test + fun searchVault_reportsMatchCountAndOnDeviceProvenance() = runTest { + createLumiereNote() + + viewModel.onVaultQueryChange("lumiere wifi") + viewModel.searchVault(TRIP_ID) + settleUntil { viewModel.vaultSearchStatus.value != null } + + assertThat(viewModel.vaultSearchStatus.value).contains("100% on-device") + assertThat(viewModel.vaultSearchStatus.value).contains("ms") + } + + @Test + fun deleteDocument_dropsItFromTheIndexAndFromLiveResults() = runTest { + val created = createLumiereNote() + + viewModel.onVaultQueryChange("lumiere wifi") + viewModel.searchVault(TRIP_ID) + settleUntil { viewModel.vaultResults.value != null } + + viewModel.deleteDocument(created) + settleUntil { !appSearchManager.getIndexedDocumentIds().contains(created.id) } + + assertThat(appSearchManager.getIndexedDocumentIds()).doesNotContain(created.id) + assertThat(viewModel.vaultResults.value?.map { it.id } ?: emptyList()) + .doesNotContain(created.id) + } + + @Test + fun clearVaultSearch_restoresTheBrowsingList() = runTest { + advanceUntilIdle() + + viewModel.onVaultQueryChange("lumiere wifi") + viewModel.searchVault(TRIP_ID) + advanceUntilIdle() + + viewModel.clearVaultSearch() + + assertThat(viewModel.vaultQuery.value).isEmpty() + assertThat(viewModel.vaultResults.value).isNull() + assertThat(viewModel.vaultSearchStatus.value).isNull() + } + + @Test + fun importDocument_indexesEmailAndFindsItInVaultSearch() = runTest { + settleUntil { documentDao.snapshot().isNotEmpty() } + val emlFile = File(context.cacheDir, "shinkansen_ticket.eml") + emlFile.writeText( + """ + From: reserve@jr-central.co.jp + Subject: Shinkansen Nozomi 204 E-Ticket + Content-Type: text/html; charset="UTF-8" + +

Car 7 Seat 12E

QR Pickup Code: JR994021

+ """ + .trimIndent() + ) + + viewModel.importDocument(uri = Uri.fromFile(emlFile), tripId = TRIP_ID) + settleUntil { documentDao.snapshot().any { it.title == "Shinkansen Nozomi 204 E-Ticket" } } + val imported = documentDao.snapshot().single { it.title == "Shinkansen Nozomi 204 E-Ticket" } + settleUntil { appSearchManager.getIndexedDocumentIds().contains(imported.id) } + + viewModel.onVaultQueryChange("JR994021") + viewModel.searchVault(TRIP_ID) + settleUntil { viewModel.vaultResults.value != null } + + assertThat(viewModel.vaultResults.value?.map { it.title }) + .contains("Shinkansen Nozomi 204 E-Ticket") + } + + /** Creates the demo note and waits until the write-through hook has indexed it. */ + private suspend fun TestScope.createLumiereNote(): TravelDocument { + settleUntil { documentDao.snapshot().isNotEmpty() } + viewModel.createTextDocument( + title = "Hotel Lumiere Booking", + content = "Wifi network LUMIERE-GUEST, password bonjour2026", + documentType = "note", + tripId = TRIP_ID, + ) + settleUntil { documentDao.snapshot().any { it.title == "Hotel Lumiere Booking" } } + val created = documentDao.snapshot().single { it.title == "Hotel Lumiere Booking" } + settleUntil { appSearchManager.getIndexedDocumentIds().contains(created.id) } + return created + } + + /** + * Drains both clocks until [condition] holds. + * + * The ViewModel runs on the virtual test scheduler but the indexing and search paths hop to the + * IO dispatcher, which that scheduler does not own. `advanceUntilIdle()` alone therefore returns + * while real work is still in flight, so each pass advances the scheduler and then parks this + * thread to give the IO side real wall-clock time to catch up. + */ + private suspend fun TestScope.settleUntil(condition: suspend () -> Boolean) { + repeat(SETTLE_ATTEMPTS) { + advanceUntilIdle() + if (condition()) return + Thread.sleep(SETTLE_STEP_MS) + } + advanceUntilIdle() + } + + private companion object { + const val TRIP_ID = "2026-3" + const val SETTLE_ATTEMPTS = 100 + const val SETTLE_STEP_MS = 50L + } +} + +/** + * Minimal in-memory [TravelDocumentDao] so the tests never touch Room. + * + * Every access is synchronized: the ViewModel writes from IO threads while the test thread reads, + * and an unguarded [ArrayList] throws [java.util.ConcurrentModificationException] under that race. + */ +private class FakeVaultDocumentDao : TravelDocumentDao { + private val backing = mutableListOf() + + /** Immutable copy, safe to iterate from the test thread. */ + fun snapshot(): List = synchronized(backing) { backing.toList() } + + override fun getAllDocuments(): Flow> = flowOf(snapshot()) + + override fun getDocumentsByType(documentType: String): Flow> = + flowOf(snapshot().filter { it.documentType.equals(documentType, ignoreCase = true) }) + + override fun getDocumentsForTrip(tripId: String): Flow> = + flowOf(snapshot().filter { it.tripId == tripId }) + + override fun getDocumentsForEvent(eventId: String): Flow> = + flowOf(snapshot().filter { it.eventId == eventId }) + + override suspend fun getDocumentById(documentId: String): TravelDocument? = + snapshot().find { it.documentId == documentId || it.id == documentId } + + override suspend fun insertDocument(document: TravelDocument) { + synchronized(backing) { backing.add(document) } + } + + override suspend fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String?) { + synchronized(backing) { + val index = backing.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (index != -1) { + val existing = backing[index] + backing[index] = existing.copy(eventId = eventId, tripId = tripId ?: existing.tripId) + } + } + } + + override suspend fun linkDocumentToTrip(documentId: String, tripId: String?) { + synchronized(backing) { + val index = backing.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (index != -1) { + backing[index] = backing[index].copy(tripId = tripId) + } + } + } + + override suspend fun deleteDocument(document: TravelDocument) { + synchronized(backing) { backing.removeAll { it.id == document.id } } + } + + override suspend fun deleteDocumentById(id: String) { + synchronized(backing) { backing.removeAll { it.id == id } } + } + + override suspend fun deleteAllDocuments() { + synchronized(backing) { backing.clear() } + } +} + +/** [EventDao] stub; this suite only needs the document side of the ViewModel. */ +private class FakeVaultEventDao : EventDao { + override fun getAllSessionIds(): Flow> = flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String): Flow> = flowOf(emptyList()) + + override suspend fun insertEvent(event: TimelineEvent) {} + + override suspend fun insertEvents(events: List) {} + + override suspend fun deleteEventsForTrip(tripId: String) {} + + override suspend fun deleteEvent(event: TimelineEvent) {} + + override suspend fun insertFlightDetail(detail: FlightDetail) {} + + override suspend fun insertHotelDetail(detail: HotelDetail) {} + + override suspend fun insertDiningDetail(detail: DiningDetail) {} + + override suspend fun insertActivityDetail(detail: ActivityDetail) {} + + override suspend fun insertMuseumDetail(detail: MuseumDetail) {} + + override fun getFlightDetail(eventId: String): Flow = flowOf(null) + + override fun getHotelDetail(eventId: String): Flow = flowOf(null) + + override fun getDiningDetail(eventId: String): Flow = flowOf(null) + + override fun getMuseumDetail(eventId: String): Flow = flowOf(null) + + override fun getEventById(eventId: String): Flow = flowOf(null) + + override suspend fun deleteAllEvents() {} + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun deleteAllActivityDetails() {} + + override fun getVoiceNotesForTrip(tripId: String): Flow> = + flowOf(emptyList()) + + override suspend fun insertVoiceNote(note: VoiceNoteEntity) {} + + override suspend fun deleteVoiceNoteById(id: String) {} +} diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt index 5c9f8525..8b011cc6 100644 --- a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt @@ -26,6 +26,8 @@ import com.example.jetpacker.data.itinerary.FlightDetail import com.example.jetpacker.data.itinerary.HotelDetail import com.example.jetpacker.data.itinerary.MuseumDetail import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao import com.example.jetpacker.data.itinerary.TripSessionIdentifier import com.example.jetpacker.data.itinerary.VoiceNoteEntity import com.example.jetpacker.data.trips.Trip @@ -33,8 +35,10 @@ import com.example.jetpacker.data.trips.TripDao import com.example.jetpacker.feature.assistant.tools.AddExpenseTool import com.example.jetpacker.feature.assistant.tools.AddItineraryEventTool import com.example.jetpacker.feature.assistant.tools.AddVoiceNoteTool +import com.example.jetpacker.feature.assistant.tools.CheckDocumentValidityTool import com.example.jetpacker.feature.assistant.tools.GetEventDetailsTool import com.example.jetpacker.feature.assistant.tools.GetFlightItineraryTool +import com.example.jetpacker.feature.assistant.tools.GetTravelDocumentTool import com.example.jetpacker.feature.assistant.tools.SearchTripTool import com.google.common.truth.Truth.assertThat import java.time.LocalDate @@ -49,9 +53,12 @@ class TravelDaoToolsTest { private lateinit var fakeEventDao: FakeEventDao private lateinit var fakeTripDao: FakeTripDao + private lateinit var fakeDocumentDao: FakeTravelDocumentDao private lateinit var fakeExpenseDao: FakeExpenseDao private lateinit var flightTool: GetFlightItineraryTool + private lateinit var documentTool: GetTravelDocumentTool + private lateinit var validityTool: CheckDocumentValidityTool private lateinit var expenseTool: AddExpenseTool private lateinit var eventTool: AddItineraryEventTool private lateinit var eventDetailsTool: GetEventDetailsTool @@ -62,9 +69,12 @@ class TravelDaoToolsTest { fun setUp() { fakeEventDao = FakeEventDao() fakeTripDao = FakeTripDao() + fakeDocumentDao = FakeTravelDocumentDao() fakeExpenseDao = FakeExpenseDao() flightTool = GetFlightItineraryTool(fakeEventDao, fakeTripDao) + documentTool = GetTravelDocumentTool(fakeDocumentDao) + validityTool = CheckDocumentValidityTool(fakeDocumentDao, fakeTripDao) expenseTool = AddExpenseTool(fakeExpenseDao, fakeTripDao) eventTool = AddItineraryEventTool(fakeEventDao, fakeTripDao) eventDetailsTool = GetEventDetailsTool(fakeEventDao) @@ -117,6 +127,97 @@ class TravelDaoToolsTest { assertThat(first["gate"]).isEqualTo("A4") } + @Test + fun getTravelDocument_returnsDocumentByType() = runTest { + val doc = + TravelDocument( + documentId = "pass-1", + documentType = "passport", + issuingCountry = "United States", + expiryDate = 1899849600000L, + documentNumberMasked = "•••• 4321", + ) + fakeDocumentDao.documents.add(doc) + + val result = documentTool.execute(mapOf("documentType" to "passport")) + + assertThat(result["count"]).isEqualTo(1) + val docs = result["documents"] as List<*> + val firstDoc = docs.first() as Map<*, *> + assertThat(firstDoc["documentId"]).isEqualTo("pass-1") + assertThat(firstDoc["issuingCountry"]).isEqualTo("United States") + assertThat(firstDoc["documentNumberMasked"]).isEqualTo("•••• 4321") + } + + @Test + fun checkDocumentValidity_validWhenOverSixMonths() = runTest { + val futureExpiry = + LocalDate.now().plusMonths(12).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val doc = + TravelDocument( + documentId = "pass-valid", + documentType = "passport", + issuingCountry = "Netherlands", + expiryDate = futureExpiry, + documentNumberMasked = "•••• 9999", + ) + fakeDocumentDao.documents.add(doc) + + val result = validityTool.execute(mapOf("documentType" to "passport")) + + assertThat(result["status"]).isEqualTo("VALID") + assertThat(result["validForTravel"]).isEqualTo(true) + assertThat(result["issuingCountry"]).isEqualTo("Netherlands") + } + + @Test + fun checkDocumentValidity_expiringSoonWhenUnderSixMonths() = runTest { + val soonExpiry = + LocalDate.now().plusMonths(3).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val doc = + TravelDocument( + documentId = "pass-soon", + documentType = "passport", + issuingCountry = "United States", + expiryDate = soonExpiry, + documentNumberMasked = "•••• 1111", + ) + fakeDocumentDao.documents.add(doc) + + val result = validityTool.execute(mapOf("documentType" to "passport")) + + assertThat(result["status"]).isEqualTo("EXPIRING_SOON") + assertThat(result["validForTravel"]).isEqualTo(false) + } + + @Test + fun checkDocumentValidity_expiredWhenPastDate() = runTest { + val pastExpiry = + LocalDate.now().minusMonths(2).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val doc = + TravelDocument( + documentId = "pass-expired", + documentType = "passport", + issuingCountry = "United States", + expiryDate = pastExpiry, + documentNumberMasked = "•••• 0000", + ) + fakeDocumentDao.documents.add(doc) + + val result = validityTool.execute(mapOf("documentType" to "passport")) + + assertThat(result["status"]).isEqualTo("EXPIRED") + assertThat(result["validForTravel"]).isEqualTo(false) + } + + @Test + fun checkDocumentValidity_notFoundWhenMissing() = runTest { + val result = validityTool.execute(mapOf("documentType" to "visa")) + + assertThat(result["status"]).isEqualTo("NOT_FOUND") + assertThat(result["validForTravel"]).isEqualTo(false) + } + @Test fun addExpenseTool_insertsExpenseAndDefaultsTrip() = runTest { val trip = @@ -314,6 +415,57 @@ private class FakeTripDao : TripDao { } } +private class FakeTravelDocumentDao : TravelDocumentDao { + val documents = mutableListOf() + + override fun getAllDocuments(): Flow> = flowOf(documents.toList()) + + override fun getDocumentsByType(documentType: String): Flow> = + flowOf(documents.filter { it.documentType.equals(documentType, ignoreCase = true) }) + + override fun getDocumentsForTrip(tripId: String): Flow> = + flowOf(documents.filter { it.tripId == tripId }) + + override fun getDocumentsForEvent(eventId: String): Flow> = + flowOf(documents.filter { it.eventId == eventId }) + + override suspend fun getDocumentById(documentId: String): TravelDocument? = documents.find { + it.documentId == documentId || it.id == documentId + } + + override suspend fun insertDocument(document: TravelDocument) { + documents.add(document) + } + + override suspend fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String?) { + val idx = documents.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (idx != -1) { + val existing = documents[idx] + documents[idx] = existing.copy(eventId = eventId, tripId = tripId ?: existing.tripId) + } + } + + override suspend fun linkDocumentToTrip(documentId: String, tripId: String?) { + val idx = documents.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (idx != -1) { + val existing = documents[idx] + documents[idx] = existing.copy(tripId = tripId) + } + } + + override suspend fun deleteDocument(document: TravelDocument) { + documents.remove(document) + } + + override suspend fun deleteDocumentById(id: String) { + documents.removeAll { it.id == id } + } + + override suspend fun deleteAllDocuments() { + documents.clear() + } +} + private class FakeEventDao : EventDao { val events = mutableMapOf>() val flights = mutableMapOf() diff --git a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt index 02707602..fdcb2972 100644 --- a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt +++ b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.Event import androidx.compose.material.icons.rounded.Wallet import androidx.compose.material3.MaterialTheme @@ -73,6 +74,7 @@ import com.example.jetpacker.core.ui.components.JetPackerToolbar import com.example.jetpacker.core.ui.components.JetPackerToolbarAction import com.example.jetpacker.data.itinerary.EventType import com.example.jetpacker.feature.assistant.TravelAssistantViewModel +import com.example.jetpacker.feature.assistant.documents.DocumentsScreen import com.example.jetpacker.feature.assistant.history.ChatHistoryScreen import com.example.jetpacker.feature.assistant.model.AssistantOverlayState import com.example.jetpacker.feature.assistant.ui.AmbientTripAssistantSheet @@ -83,6 +85,7 @@ import com.example.jetpacker.feature.voice_notes.VoiceNotesScreen enum class TripTab { ITINERARY, + DOCUMENTS, EXPENSES, VOICE_NOTES, BOOKING, @@ -183,6 +186,15 @@ fun TripScreen( ) } + TripTab.DOCUMENTS -> { + DocumentsScreen( + tripId = tripId, + contentPadding = innerPadding, + onBack = { selectedTab = TripTab.ITINERARY }, + onFabConfigChange = { fabConfigs[TripTab.DOCUMENTS] = it }, + ) + } + TripTab.EXPENSES -> { ManageExpensesScreen( tripId = tripId, @@ -264,12 +276,14 @@ fun JetPackerBottomBar( fabConfig: JetPackerFabConfig?, modifier: Modifier = Modifier, ) { + val showDocuments = FeatureFlags.ENABLE_DOCUMENTS val showExpenses = FeatureFlags.ENABLE_EXPENSE_MANAGEMENT val showVoiceNotes = FeatureFlags.ENABLE_VOICE_NOTES val showBooking = FeatureFlags.ENABLE_BOOKING_ASSISTANT val showChatHistory = FeatureFlags.ENABLE_TRAVEL_ASSISTANT val visibleTabCount = 1 + + (if (showDocuments) 1 else 0) + (if (showExpenses) 1 else 0) + (if (showVoiceNotes) 1 else 0) + (if (showBooking) 1 else 0) + @@ -305,6 +319,14 @@ fun JetPackerBottomBar( selected = selectedTab == TripTab.ITINERARY, contentDescription = "Itinerary", ) + if (showDocuments) { + JetPackerToolbarAction( + icon = Icons.Rounded.Description, + onClick = { onTabSelected(TripTab.DOCUMENTS) }, + selected = selectedTab == TripTab.DOCUMENTS, + contentDescription = "Documents", + ) + } if (showExpenses) { JetPackerToolbarAction( icon = Icons.Rounded.Wallet, diff --git a/jetpacker/android/gradle/libs.versions.toml b/jetpacker/android/gradle/libs.versions.toml index dfe397f0..30af6375 100644 --- a/jetpacker/android/gradle/libs.versions.toml +++ b/jetpacker/android/gradle/libs.versions.toml @@ -60,7 +60,7 @@ targetSdk = "37" truth = "1.4.2" versionCode = "1" versionName = "1.0" -appfunctions = "1.0.0-alpha10" +appfunctions = "1.0.0-alpha13" [libraries] androidx-a2ui-model = { group = "androidx.a2ui", name = "a2ui-model", version.ref = "a2ui" }