diff --git a/README.md b/README.md index 0bc81b6..e5d043e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Offline-first, ad-free QR/barcode scanner built with Kotlin, Jetpack Compose, CameraX, and on-device ML Kit. +## Platform Assumption +- Android mobile app first (phone/tablet). +- Prefer Android-compatible libraries and APIs for parsing/integration decisions. + ## Architektur - `ui/`: Compose screens/components + ViewModels (MVVM) - `data/`: ML Kit analyzer, Room entities/DAO, repository diff --git a/ROADMAP.md b/ROADMAP.md index e64ed3c..f292280 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,9 @@ # Product Roadmap +## Platform Assumption +- Android mobile app first (phone/tablet). +- Feature and dependency choices should prioritize Android compatibility and UX. + ## Quick Wins (1-3 days) - [x] Duplicate UX polish diff --git a/USE_CASES.md b/USE_CASES.md index bac7540..387bd84 100644 --- a/USE_CASES.md +++ b/USE_CASES.md @@ -1,53 +1,53 @@ # Clean Scanner Use Cases ## 1. Everyday Personal Use -- Scan restaurant menus, product QR labels, and website links quickly. -- Copy/share scanned values to chat apps or notes. -- Open links directly with local risk warning support. +- [Done] Scan restaurant menus, product QR labels, and website links quickly. +- [Done] Copy/share scanned values to chat apps or notes. +- [Done] Open links directly with local risk warning support. ## 2. Event & Ticketing -- Scan tickets at venues and quickly validate repeated entries. -- Use batch mode to process multiple attendees without leaving the camera. -- Share batch captures to organizers for quick reconciliation. +- [Done] Scan tickets at venues and quickly validate repeated entries. +- [Done] Use batch mode to process multiple attendees without leaving the camera. +- [Done] Share batch captures to organizers for quick reconciliation. ## 3. Inventory & Operations -- Scan product barcodes in stock rooms. -- Use batch mode for continuous scanning of many items. -- Export and share history (TXT/CSV/JSON) for downstream reporting. +- [Done] Scan product barcodes in stock rooms. +- [Done] Use batch mode for continuous scanning of many items. +- [Done] Export and share history (TXT/CSV/JSON) for downstream reporting. ## 4. Field Work & Service Teams -- Scan device labels/serials on-site. -- Save local history for audit trails when enabled. -- Share captured codes with support teams in real time. +- [Done] Scan device labels/serials on-site. +- [Done] Save local history for audit trails when enabled. +- [Done] Share captured codes with support teams in real time. ## 5. Office & Admin Workflows -- Scan contact QR/vCard and add to contacts. -- Scan Wi-Fi setup QR and jump to Wi-Fi settings. -- Scan calendar/event data and create calendar entries. +- [Done] Scan contact QR/vCard and add to contacts. +- [Done] Scan Wi-Fi setup QR and jump to Wi-Fi settings. +- [Done] Scan calendar/event data and create calendar entries. ## 6. Communication Shortcuts -- Scan phone/SMS/email QR data. -- One-tap actions: call, send SMS, send email. -- Reduce manual entry errors for phone numbers and addresses. +- [Done] Scan phone/SMS/email QR data. +- [Done] One-tap actions: call, send SMS, send email. +- [Done] Reduce manual entry errors for phone numbers and addresses. ## 7. Security-Conscious Browsing -- Scan URL QR codes and get local warning prompts for suspicious patterns. -- Decide whether to open risky links with explicit confirmation. -- Keep scanning offline-first without backend calls. +- [Done] Scan URL QR codes and get local warning prompts for suspicious patterns. +- [Done] Decide whether to open risky links with explicit confirmation. +- [Done] Keep scanning offline-first without backend calls. ## 8. Offline / Low-Connectivity Scenarios -- Use the scanner with no internet dependency for core scanning. -- Keep data local-first and share outputs when connectivity returns. -- Useful for travel, warehouses, and remote job sites. +- [Done] Use the scanner with no internet dependency for core scanning. +- [Done] Keep data local-first and share outputs when connectivity returns. +- [Done] Useful for travel, warehouses, and remote job sites. ## 9. Accessibility & Speed -- Launch directly into camera for 0-click scan flow. -- Pinch-to-zoom for small or distant QR/barcodes. -- Friendly scanner guide with immediate feedback on successful scans. +- [Done] Launch directly into camera for 0-click scan flow. +- [Done] Pinch-to-zoom for small or distant QR/barcodes. +- [Done] Friendly scanner guide with immediate feedback on successful scans. ## 10. Team Handover & Data Transfer - Export scan history in multiple formats: - - TXT for human-readable logs - - CSV for spreadsheets/BI tools - - JSON for system integrations -- Share exports to teammates via native Android share sheet. + - [Done] TXT for human-readable logs + - [Done] CSV for spreadsheets/BI tools + - [Done] JSON for system integrations +- [Done] Share exports to teammates via native Android share sheet. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e70f197..5fa9737 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,6 +38,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } buildFeatures { @@ -62,6 +63,8 @@ dependencies { val composeBom = platform("androidx.compose:compose-bom:2024.09.00") implementation(composeBom) androidTestImplementation(composeBom) + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.2") implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") @@ -84,12 +87,14 @@ dependencies { implementation("androidx.camera:camera-view:1.5.3") implementation("com.google.mlkit:barcode-scanning:17.3.0") + implementation("com.github.bitfireAT:vcard4android:main-SNAPSHOT") implementation("androidx.room:room-runtime:2.8.4") implementation("androidx.room:room-ktx:2.8.4") ksp("androidx.room:room-compiler:2.8.4") implementation("androidx.datastore:datastore-preferences:1.2.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") diff --git a/app/src/androidTest/java/com/clean/scanner/util/IntentsTest.kt b/app/src/androidTest/java/com/clean/scanner/util/IntentsTest.kt new file mode 100644 index 0000000..50bbe35 --- /dev/null +++ b/app/src/androidTest/java/com/clean/scanner/util/IntentsTest.kt @@ -0,0 +1,74 @@ +package com.clean.scanner.util + +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.Organization +import android.provider.ContactsContract.CommonDataKinds.StructuredName +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class IntentsTest { + + @Test + fun buildAddContactIntent_setsCompanyAndTitleExtras() { + val parsed = ParsedContact( + fullName = "John Doe", + organization = "TEC-IT", + title = "Position", + phones = listOf("+43 7252 72720"), + emails = listOf("support@tec-it.com"), + address = "203 New York Ave, New York, NY 11377, USA" + ) + + val intent = Intents.buildAddContactIntent(parsed, "raw") + + assertEquals("TEC-IT", intent.getStringExtra(ContactsContract.Intents.Insert.COMPANY)) + assertEquals("TEC-IT", intent.getStringExtra(Organization.COMPANY)) + assertEquals("Position", intent.getStringExtra(ContactsContract.Intents.Insert.JOB_TITLE)) + assertEquals("Position", intent.getStringExtra(Organization.TITLE)) + } + + @Test + fun buildAddContactIntent_keepsOnlyOriginalNoteText() { + val parsed = ParsedContact( + fullName = "John Doe", + organization = "TEC-IT", + title = "Position", + note = "Existing note" + ) + + val intent = Intents.buildAddContactIntent(parsed, "raw") + val notes = intent.getStringExtra(ContactsContract.Intents.Insert.NOTES).orEmpty() + + assertEquals("Existing note", notes) + } + + @Test + fun buildAddContactIntent_normalizesDuplicatedLastName() { + val parsed = ParsedContact( + fullName = "Alice Mason Mason", + givenName = "Alice", + familyName = "Mason" + ) + + val intent = Intents.buildAddContactIntent(parsed, "raw") + val insertedName = intent.getStringExtra(ContactsContract.Intents.Insert.NAME) + val dataRows = intent.getParcelableArrayListExtra( + ContactsContract.Intents.Insert.DATA + ) + + // When structured name is present, do not send plain NAME to avoid OEM split issues. + assertNull(insertedName) + val structured = dataRows?.firstOrNull() + assertEquals(StructuredName.CONTENT_ITEM_TYPE, structured?.getAsString(ContactsContract.Data.MIMETYPE)) + assertEquals("Alice", structured?.getAsString(StructuredName.GIVEN_NAME)) + assertEquals("Mason", structured?.getAsString(StructuredName.FAMILY_NAME)) + assertEquals("Alice Mason", structured?.getAsString(StructuredName.DISPLAY_NAME)) + + // Keep explicit check for normalized display form. + assertEquals("Alice Mason", structured?.getAsString(StructuredName.DISPLAY_NAME)) + } +} diff --git a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt index 6abd17b..338e966 100644 --- a/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt +++ b/app/src/main/java/com/clean/scanner/ui/screens/ScannerScreen.kt @@ -36,14 +36,18 @@ import androidx.compose.material.icons.automirrored.filled.ViewList import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.ViewModule import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -64,6 +68,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asImageBitmap @@ -93,6 +98,7 @@ import com.clean.scanner.ui.BatchScanRecord import com.clean.scanner.ui.components.CameraPreview import com.clean.scanner.util.ClipboardUtil import com.clean.scanner.util.Intents +import com.clean.scanner.util.ParsedContact import com.clean.scanner.util.ScanContentParsers import com.clean.scanner.util.UrlRiskScorer import com.google.mlkit.vision.barcode.BarcodeScanning @@ -481,6 +487,9 @@ fun ScannerScreen( } if (lastResult != null && !batchMode) { + val parsedContact = remember(lastResult.content) { ScanContentParsers.parseContact(lastResult.content) } + val parsedEvent = remember(lastResult.content) { ScanContentParsers.parseCalendarEvent(lastResult.content) } + ModalBottomSheet(onDismissRequest = onScanAgain) { Column( modifier = Modifier @@ -488,14 +497,26 @@ fun ScannerScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}") - Text(text = "${stringResource(R.string.content_value)}: ${lastResult.content}") + if (parsedContact == null) { + Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}") + } + ResultVisualCard(result = lastResult) Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + if (parsedContact != null) { + IconButton(onClick = { + Intents.addContact(context, parsedContact, lastResult.content) + }) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = stringResource(R.string.add_contact) + ) + } + } IconButton(onClick = { ClipboardUtil.copy(context, lastResult.content) }) { Icon( imageVector = Icons.Default.ContentCopy, @@ -556,14 +577,10 @@ fun ScannerScreen( } } - "Contact" -> { - Button(onClick = { Intents.addContact(context, lastResult.content) }) { - Text(stringResource(R.string.add_contact)) - } - } - "Calendar" -> { - Button(onClick = { Intents.addCalendarEvent(context, lastResult.content) }) { + Button(onClick = { + Intents.addCalendarEvent(context, parsedEvent, lastResult.content) + }) { Text(stringResource(R.string.add_calendar_event)) } } @@ -620,6 +637,297 @@ fun ScannerScreen( } } +private data class ResultField( + val label: String, + val value: String +) + +@Composable +private fun ResultVisualCard( + result: ScanResult, + modifier: Modifier = Modifier +) { + val contact = remember(result.content) { ScanContentParsers.parseContact(result.content) } + if (contact != null || result.type == "Contact") { + ContactVisualCard( + contact = contact, + rawContent = result.content, + modifier = modifier + ) + return + } + + val fields = remember(result) { buildResultFields(result) } + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF2F7FF)), + shape = RoundedCornerShape(14.dp) + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = result.type, + style = MaterialTheme.typography.titleMedium + ) + if (fields.isEmpty()) { + Text( + text = result.content, + style = MaterialTheme.typography.bodyMedium + ) + } else { + fields.forEach { field -> + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = field.label, + style = MaterialTheme.typography.labelMedium, + color = Color(0xFF4F6277) + ) + Text( + text = field.value, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } +} + +private enum class ContactCardTemplate { + Minimal, + Corporate, + Playful +} + +@Composable +private fun ContactVisualCard( + contact: ParsedContact?, + rawContent: String, + modifier: Modifier = Modifier +) { + val template = remember(contact, rawContent) { selectContactTemplate(contact, rawContent) } + val background = when (template) { + ContactCardTemplate.Corporate -> Brush.linearGradient( + listOf(Color(0xFF081C3B), Color(0xFF0F2E58), Color(0xFF134B73)) + ) + ContactCardTemplate.Playful -> Brush.linearGradient( + listOf(Color(0xFFFFF9EC), Color(0xFFF8F1FF), Color(0xFFEFF6FF)) + ) + ContactCardTemplate.Minimal -> Brush.linearGradient( + listOf(Color(0xFFF9FAFC), Color(0xFFF1F5F9)) + ) + } + val accentColor = when (template) { + ContactCardTemplate.Corporate -> Color(0xFF7AF7CF) + ContactCardTemplate.Playful -> Color(0xFF304FFE) + ContactCardTemplate.Minimal -> Color(0xFF1E293B) + } + val textPrimary = when (template) { + ContactCardTemplate.Corporate -> Color.White + ContactCardTemplate.Playful -> Color(0xFF16181D) + ContactCardTemplate.Minimal -> Color(0xFF0F172A) + } + val textMuted = when (template) { + ContactCardTemplate.Corporate -> Color(0xFFC7D6E8) + ContactCardTemplate.Playful -> Color(0xFF55657B) + ContactCardTemplate.Minimal -> Color(0xFF475569) + } + val name = contact?.fullName ?: "Contact" + val subtitle = listOfNotNull(contact?.title, contact?.organization).distinct().joinToString(" • ") + val initials = remember(name) { initialsFromName(name) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .background(background) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .background( + color = accentColor.copy( + alpha = when (template) { + ContactCardTemplate.Corporate -> 0.18f + ContactCardTemplate.Playful -> 0.15f + ContactCardTemplate.Minimal -> 0.12f + } + ), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = initials, + color = accentColor, + style = MaterialTheme.typography.titleMedium + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = name, + color = textPrimary, + style = MaterialTheme.typography.headlineSmall + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + color = textMuted, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + ContactLine("Phone", contact?.phones?.firstOrNull(), textPrimary, textMuted) + ContactLine("Email", contact?.emails?.firstOrNull(), textPrimary, textMuted) + ContactLine("Address", contact?.address, textPrimary, textMuted) + ContactLine("Note", contact?.note, textPrimary, textMuted) + if (contact == null) { + ContactLine("Raw", rawContent, textPrimary, textMuted) + } + } + } +} + +private fun initialsFromName(name: String): String { + val parts = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() } + if (parts.isEmpty()) return "?" + return parts.take(2) + .mapNotNull { it.firstOrNull()?.uppercaseChar() } + .joinToString("") + .ifBlank { "?" } +} + +@Composable +private fun ContactLine( + label: String, + value: String?, + textPrimary: Color, + textMuted: Color +) { + if (value.isNullOrBlank()) return + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = label, + color = textMuted, + style = MaterialTheme.typography.labelSmall + ) + Text( + text = value, + color = textPrimary, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +private fun selectContactTemplate(contact: ParsedContact?, rawContent: String): ContactCardTemplate { + val isCardPayload = rawContent.contains("BEGIN:VCARD", ignoreCase = true) || + rawContent.startsWith("MECARD:", ignoreCase = true) + val looksCorporate = !contact?.organization.isNullOrBlank() || + !contact?.title.isNullOrBlank() + val denseStructuredData = listOf( + contact?.phones?.firstOrNull(), + contact?.emails?.firstOrNull(), + contact?.address, + contact?.note + ).count { !it.isNullOrBlank() } >= 3 + + return when { + looksCorporate || isCardPayload -> ContactCardTemplate.Corporate + denseStructuredData -> ContactCardTemplate.Minimal + else -> ContactCardTemplate.Playful + } +} + +private fun buildResultFields(result: ScanResult): List { + return when (result.type) { + "Contact" -> { + val contact = ScanContentParsers.parseContact(result.content) + listOfNotNull( + contact?.fullName?.let { ResultField("Name", it) }, + contact?.organization?.let { ResultField("Company", it) }, + contact?.title?.let { ResultField("Title", it) }, + contact?.phones?.firstOrNull()?.let { ResultField("Phone", it) }, + contact?.emails?.firstOrNull()?.let { ResultField("Email", it) }, + contact?.address?.let { ResultField("Address", it) } + ) + } + "Calendar" -> { + val event = ScanContentParsers.parseCalendarEvent(result.content) + val dateTime = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + listOfNotNull( + event?.title?.let { ResultField("Title", it) }, + event?.location?.let { ResultField("Location", it) }, + event?.startMillis?.let { ResultField("Start", dateTime.format(Date(it))) }, + event?.endMillis?.let { ResultField("End", dateTime.format(Date(it))) }, + event?.description?.let { ResultField("Details", it) } + ) + } + "WiFi" -> { + val map = parseWifiFields(result.content) + listOfNotNull( + map["S"]?.takeIf { it.isNotBlank() }?.let { ResultField("SSID", it) }, + map["T"]?.takeIf { it.isNotBlank() }?.let { ResultField("Security", it) }, + map["P"]?.takeIf { it.isNotBlank() }?.let { ResultField("Password", it) } + ) + } + "URL" -> { + val score = UrlRiskScorer.score(result.content) + listOf( + ResultField("Link", result.content), + ResultField("Risk score", score.score.toString()) + ) + } + "SMS" -> { + val (number, body) = ScanContentParsers.parseSms(result.content) + listOfNotNull( + number.takeIf { it.isNotBlank() }?.let { ResultField("To", it) }, + body?.takeIf { it.isNotBlank() }?.let { ResultField("Message", it) } + ) + } + "Email" -> { + val email = ScanContentParsers.extractEmail(result.content) + listOf(ResultField("Email", email)) + } + "Phone" -> { + val phone = ScanContentParsers.extractPhoneNumber(result.content) + listOf(ResultField("Phone", phone)) + } + else -> emptyList() + } +} + +private fun parseWifiFields(raw: String): Map { + val cleaned = raw.trim() + if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return emptyMap() + val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';') + val values = mutableMapOf() + payload.split(';').forEach { token -> + val key = token.substringBefore(':', "").trim() + if (key.isBlank()) return@forEach + val value = token.substringAfter(':', "").trim() + values[key] = value + } + return values +} + @Composable private fun OverlayIconToggle( checked: Boolean, diff --git a/app/src/main/java/com/clean/scanner/util/Intents.kt b/app/src/main/java/com/clean/scanner/util/Intents.kt index 8a0cfab..7b26c14 100644 --- a/app/src/main/java/com/clean/scanner/util/Intents.kt +++ b/app/src/main/java/com/clean/scanner/util/Intents.kt @@ -1,9 +1,12 @@ package com.clean.scanner.util +import android.content.ContentValues import android.content.Context import android.content.Intent import android.provider.CalendarContract import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.StructuredName +import android.provider.ContactsContract.CommonDataKinds.Organization import android.provider.Settings import androidx.core.net.toUri @@ -60,20 +63,87 @@ object Intents { context.startActivity(intent) } - fun addContact(context: Context, rawContent: String) { - val intent = Intent(Intent.ACTION_INSERT).apply { - type = ContactsContract.Contacts.CONTENT_TYPE - putExtra(ContactsContract.Intents.Insert.NOTES, rawContent) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } + fun addContact(context: Context, parsed: ParsedContact?, rawContent: String) { + val intent = buildAddContactIntent(parsed, rawContent) context.startActivity(intent) } - fun addCalendarEvent(context: Context, rawContent: String) { + internal fun buildAddContactIntent(parsed: ParsedContact?, rawContent: String): Intent { + return Intent(Intent.ACTION_INSERT).apply { + type = ContactsContract.Contacts.CONTENT_TYPE + val fullName = parsed?.fullName + ?.let(::normalizeContactNameForInsert) + ?.takeIf { it.isNotBlank() } + val givenName = parsed?.givenName?.trim().takeIf { !it.isNullOrBlank() } + val familyName = parsed?.familyName?.trim().takeIf { !it.isNullOrBlank() } + val company = parsed?.organization?.trim().takeIf { !it.isNullOrBlank() } + val jobTitle = parsed?.title?.trim().takeIf { !it.isNullOrBlank() } + val note = parsed?.note?.trim().takeIf { !it.isNullOrBlank() } + + if (givenName == null && familyName == null) { + fullName?.let { putExtra(ContactsContract.Intents.Insert.NAME, it) } + } + company?.let { + // Some OEM contacts apps honor one key but not the other. + putExtra(ContactsContract.Intents.Insert.COMPANY, it) + putExtra(Organization.COMPANY, it) + } + jobTitle?.let { + putExtra(ContactsContract.Intents.Insert.JOB_TITLE, it) + putExtra(Organization.TITLE, it) + } + parsed?.phones?.firstOrNull()?.let { putExtra(ContactsContract.Intents.Insert.PHONE, it) } + parsed?.emails?.firstOrNull()?.let { putExtra(ContactsContract.Intents.Insert.EMAIL, it) } + parsed?.address?.let { putExtra(ContactsContract.Intents.Insert.POSTAL, it) } + + if (!note.isNullOrBlank()) { + putExtra(ContactsContract.Intents.Insert.NOTES, note) + } + if (parsed == null) { + putExtra(ContactsContract.Intents.Insert.NOTES, rawContent) + } + + if (givenName != null || familyName != null) { + val dataRows = ArrayList() + val structuredName = ContentValues().apply { + put(ContactsContract.Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE) + givenName?.let { put(StructuredName.GIVEN_NAME, it) } + familyName?.let { put(StructuredName.FAMILY_NAME, it) } + fullName?.let { put(StructuredName.DISPLAY_NAME, it) } + } + dataRows.add(structuredName) + putParcelableArrayListExtra(ContactsContract.Intents.Insert.DATA, dataRows) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + private fun normalizeContactNameForInsert(rawName: String): String { + val tokens = rawName.trim() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + .toMutableList() + if (tokens.size >= 2) { + val last = tokens[tokens.lastIndex] + val beforeLast = tokens[tokens.lastIndex - 1] + if (last.equals(beforeLast, ignoreCase = true)) { + tokens.removeAt(tokens.lastIndex) + } + } + return tokens.joinToString(" ") + } + + fun addCalendarEvent(context: Context, parsed: ParsedCalendarEvent?, rawContent: String) { val intent = Intent(Intent.ACTION_INSERT).apply { data = CalendarContract.Events.CONTENT_URI - putExtra(CalendarContract.Events.TITLE, "Scanned event") - putExtra(CalendarContract.Events.DESCRIPTION, rawContent) + putExtra(CalendarContract.Events.TITLE, parsed?.title ?: "Scanned event") + putExtra(CalendarContract.Events.DESCRIPTION, parsed?.description ?: rawContent) + parsed?.location?.let { putExtra(CalendarContract.Events.EVENT_LOCATION, it) } + parsed?.startMillis?.let { putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, it) } + parsed?.endMillis?.let { putExtra(CalendarContract.EXTRA_EVENT_END_TIME, it) } + if (parsed?.allDay == true) { + putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, true) + } addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) diff --git a/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt b/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt index 6761e7b..435edaa 100644 --- a/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt +++ b/app/src/main/java/com/clean/scanner/util/ScanContentParsers.kt @@ -1,5 +1,32 @@ package com.clean.scanner.util +import at.bitfire.vcard4android.Contact +import java.io.StringReader +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +data class ParsedContact( + val fullName: String? = null, + val givenName: String? = null, + val familyName: String? = null, + val organization: String? = null, + val title: String? = null, + val phones: List = emptyList(), + val emails: List = emptyList(), + val address: String? = null, + val note: String? = null +) + +data class ParsedCalendarEvent( + val title: String? = null, + val description: String? = null, + val location: String? = null, + val startMillis: Long? = null, + val endMillis: Long? = null, + val allDay: Boolean = false +) + object ScanContentParsers { fun extractPhoneNumber(raw: String): String { return raw.substringAfter("tel:", raw) @@ -28,4 +55,410 @@ object ScanContentParsers { .find(cleaned) return match?.value ?: cleaned } + + fun parseContact(raw: String): ParsedContact? { + val cleaned = raw.trim() + if (cleaned.isBlank()) return null + + return when { + cleaned.contains("BEGIN:VCARD", ignoreCase = true) -> parseVCard(cleaned) + cleaned.startsWith("MECARD:", ignoreCase = true) -> parseMeCard(cleaned) + else -> null + } + } + + fun parseCalendarEvent(raw: String): ParsedCalendarEvent? { + val cleaned = raw.trim() + if (cleaned.isBlank()) return null + if (!cleaned.contains("BEGIN:VEVENT", ignoreCase = true)) return null + + val unfolded = unfoldLines(cleaned) + val lines = unfolded.lines() + + var inEvent = false + var summary: String? = null + var description: String? = null + var location: String? = null + var startMillis: Long? = null + var endMillis: Long? = null + var allDay = false + + lines.forEach { line -> + val entry = parseKeyValueLine(line) ?: return@forEach + val key = entry.key.uppercase(Locale.US) + val value = entry.value + + if (key == "BEGIN" && value.equals("VEVENT", ignoreCase = true)) { + inEvent = true + return@forEach + } + if (key == "END" && value.equals("VEVENT", ignoreCase = true)) { + inEvent = false + return@forEach + } + if (!inEvent) return@forEach + + when { + key == "SUMMARY" -> summary = value + key == "DESCRIPTION" -> description = value + key == "LOCATION" -> location = value + key.startsWith("DTSTART") -> { + val tzId = key.substringAfter("TZID=", missingDelimiterValue = "") + .substringBefore(';') + .ifBlank { null } + val parsed = parseIcsDate(value, tzId) + startMillis = parsed?.first + if (parsed?.second == true) allDay = true + } + key.startsWith("DTEND") -> { + val tzId = key.substringAfter("TZID=", missingDelimiterValue = "") + .substringBefore(';') + .ifBlank { null } + val parsed = parseIcsDate(value, tzId) + endMillis = parsed?.first + if (parsed?.second == true) allDay = true + } + } + } + + if (summary == null && description == null && location == null && startMillis == null && endMillis == null) { + return null + } + return ParsedCalendarEvent( + title = summary, + description = description, + location = location, + startMillis = startMillis, + endMillis = endMillis, + allDay = allDay + ) + } + + private fun parseVCard(raw: String): ParsedContact? { + val fromLibrary = parseVCardWithLibrary(raw) + val fromFallback = parseVCardFallback(raw) + return mergeParsedContacts(primary = fromLibrary, secondary = fromFallback) + } + + private fun parseVCardWithLibrary(raw: String): ParsedContact? { + val contacts = try { + Contact.fromReader( + reader = StringReader(raw), + jCard = false, + downloader = null + ) + } catch (_: Throwable) { + return null + } + val contact = contacts.firstOrNull() ?: return null + + val fullName = listOfNotNull( + contact.prefix?.trim().takeIf { !it.isNullOrBlank() }, + contact.givenName?.trim().takeIf { !it.isNullOrBlank() }, + contact.middleName?.trim().takeIf { !it.isNullOrBlank() }, + contact.familyName?.trim().takeIf { !it.isNullOrBlank() }, + contact.suffix?.trim().takeIf { !it.isNullOrBlank() } + ).joinToString(" ").ifBlank { contact.displayName?.trim().takeIf { !it.isNullOrBlank() } } + + val organization = contact.organization?.values + ?.map { it?.trim().orEmpty() } + ?.filter { it.isNotBlank() } + ?.joinToString(" / ") + ?.ifBlank { null } + + val title = contact.jobTitle?.trim().takeIf { !it.isNullOrBlank() } + + val phones = contact.phoneNumbers + .mapNotNull { it.property.text?.trim() } + .filter { it.isNotBlank() } + .distinct() + + val emails = contact.emails + .mapNotNull { it.property.value?.trim() } + .filter { it.isNotBlank() } + .distinct() + + val address = contact.addresses.firstOrNull()?.property?.let { adr -> + listOf( + adr.streetAddress, + adr.extendedAddress, + adr.locality, + adr.region, + adr.postalCode, + adr.country + ).mapNotNull { it?.trim() } + .filter { it.isNotBlank() } + .joinToString(", ") + .ifBlank { null } + } + + val note = contact.note?.trim().takeIf { !it.isNullOrBlank() } + + if ( + fullName == null && + organization == null && + title == null && + phones.isEmpty() && + emails.isEmpty() && + address == null && + note == null + ) return null + + return ParsedContact( + fullName = fullName, + givenName = contact.givenName?.trim().takeIf { !it.isNullOrBlank() }, + familyName = contact.familyName?.trim().takeIf { !it.isNullOrBlank() }, + organization = organization, + title = title, + phones = phones, + emails = emails, + address = address, + note = note + ) + } + + private fun parseVCardFallback(raw: String): ParsedContact? { + val unfolded = unfoldLines(raw) + val lines = unfolded.lines() + + var fullName: String? = null + var givenName: String? = null + var familyName: String? = null + var organization: String? = null + var title: String? = null + val phones = mutableListOf() + val emails = mutableListOf() + var address: String? = null + var note: String? = null + + lines.forEach { line -> + val entry = parseKeyValueLine(line) ?: return@forEach + val key = entry.key.uppercase(Locale.US) + val property = key.substringBefore(';') + val value = unescapeVCardValue(entry.value) + when { + property == "FN" -> fullName = value + property == "N" && fullName == null -> { + val nameParts = parseVCardName(value) + fullName = nameParts.fullName + givenName = nameParts.givenName + familyName = nameParts.familyName + } + property == "ORG" -> { + organization = value.split(';') + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString(" / ") + .ifBlank { null } + } + property == "TITLE" -> title = value + property == "TEL" -> phones += value + property == "EMAIL" -> emails += value + property == "ADR" && address == null -> { + address = value.split(';') + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString(", ") + .ifBlank { null } + } + property == "NOTE" -> note = value + } + } + + if ( + fullName == null && + organization == null && + title == null && + phones.isEmpty() && + emails.isEmpty() && + address == null && + note == null + ) return null + + return ParsedContact( + fullName = fullName, + givenName = givenName, + familyName = familyName, + organization = organization, + title = title, + phones = phones.distinct(), + emails = emails.distinct(), + address = address, + note = note + ) + } + + private fun parseMeCard(raw: String): ParsedContact? { + val payload = raw.substringAfter("MECARD:", "").trim().trimEnd(';') + if (payload.isBlank()) return null + + var fullName: String? = null + var givenName: String? = null + var familyName: String? = null + var organization: String? = null + var title: String? = null + val phones = mutableListOf() + val emails = mutableListOf() + var address: String? = null + var note: String? = null + + payload.split(';').forEach { token -> + val key = token.substringBefore(':', "").uppercase(Locale.US) + val value = token.substringAfter(':', "").trim() + if (value.isBlank()) return@forEach + when (key) { + "N" -> { + familyName = value.substringBefore(',', "").trim().ifBlank { null } + givenName = value.substringAfter(',', "").trim().ifBlank { null } + fullName = listOf(givenName, familyName) + .filterNotNull() + .filter { it.isNotBlank() } + .joinToString(" ") + .ifBlank { null } + } + "TEL" -> phones += value + "EMAIL" -> emails += value + "ADR" -> address = value + "ORG" -> organization = value + "TITLE" -> title = value + "NOTE" -> note = value + } + } + + if ( + fullName == null && + organization == null && + title == null && + phones.isEmpty() && + emails.isEmpty() && + address == null && + note == null + ) { + return null + } + return ParsedContact( + fullName = fullName, + givenName = givenName, + familyName = familyName, + organization = organization, + title = title, + phones = phones.distinct(), + emails = emails.distinct(), + address = address, + note = note + ) + } + + private data class KeyValueLine( + val key: String, + val value: String + ) + + private fun parseKeyValueLine(line: String): KeyValueLine? { + val delimiter = line.indexOf(':') + if (delimiter <= 0) return null + val key = line.substring(0, delimiter).trim() + val value = line.substring(delimiter + 1).trim() + if (key.isBlank() || value.isBlank()) return null + return KeyValueLine(key = key, value = value) + } + + private fun unfoldLines(raw: String): String { + return raw + .replace("\r\n", "\n") + .replace(Regex("\n[ \t]"), "") + } + + private data class NameParts( + val fullName: String?, + val givenName: String?, + val familyName: String? + ) + + private fun parseVCardName(nValue: String): NameParts { + val parts = nValue.split(';') + val family = parts.getOrNull(0).orEmpty().trim() + val given = parts.getOrNull(1).orEmpty().trim() + val additional = parts.getOrNull(2).orEmpty().trim() + val prefix = parts.getOrNull(3).orEmpty().trim() + val suffix = parts.getOrNull(4).orEmpty().trim() + val full = listOf(prefix, given, additional, family, suffix) + .filter { it.isNotBlank() } + .joinToString(" ") + .ifBlank { null } + return NameParts( + fullName = full, + givenName = given.ifBlank { null }, + familyName = family.ifBlank { null } + ) + } + + private fun unescapeVCardValue(value: String): String { + return value + .replace("\\n", "\n") + .replace("\\N", "\n") + .replace("\\;", ";") + .replace("\\,", ",") + .replace("\\\\", "\\") + .trim() + } + + private fun parseIcsDate(value: String, tzId: String?): Pair? { + val trimmed = value.trim() + if (trimmed.isBlank()) return null + + if (trimmed.length == 8 && trimmed.all { it.isDigit() }) { + val format = SimpleDateFormat("yyyyMMdd", Locale.US).apply { + timeZone = timeZoneOrDefault(tzId) + isLenient = false + } + val millis = format.parse(trimmed)?.time ?: return null + return millis to true + } + + if (trimmed.endsWith("Z")) { + val format = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + isLenient = false + } + val millis = format.parse(trimmed)?.time ?: return null + return millis to false + } + + val withSeconds = if (trimmed.length == 13) "${trimmed}00" else trimmed + val format = SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US).apply { + timeZone = timeZoneOrDefault(tzId) + isLenient = false + } + val millis = format.parse(withSeconds)?.time ?: return null + return millis to false + } + + private fun timeZoneOrDefault(tzId: String?): TimeZone { + if (tzId.isNullOrBlank()) return TimeZone.getDefault() + return TimeZone.getTimeZone(tzId) + } + + private fun mergeParsedContacts( + primary: ParsedContact?, + secondary: ParsedContact? + ): ParsedContact? { + if (primary == null) return secondary + if (secondary == null) return primary + + val phones = (primary.phones + secondary.phones).filter { it.isNotBlank() }.distinct() + val emails = (primary.emails + secondary.emails).filter { it.isNotBlank() }.distinct() + + return ParsedContact( + fullName = primary.fullName ?: secondary.fullName, + givenName = primary.givenName ?: secondary.givenName, + familyName = primary.familyName ?: secondary.familyName, + organization = primary.organization ?: secondary.organization, + title = primary.title ?: secondary.title, + phones = phones, + emails = emails, + address = primary.address ?: secondary.address, + note = primary.note ?: secondary.note + ) + } } diff --git a/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt b/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt index 867eb1d..da00ac8 100644 --- a/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt +++ b/app/src/test/java/com/clean/scanner/util/ScanContentParsersTest.kt @@ -1,6 +1,9 @@ package com.clean.scanner.util import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test class ScanContentParsersTest { @@ -23,4 +26,141 @@ class ScanContentParsersTest { assertEquals("x@y.com", ScanContentParsers.extractEmail("mailto:x@y.com?subject=hi")) assertEquals("a.b+c@d.dev", ScanContentParsers.extractEmail("contact me at a.b+c@d.dev now")) } + + @Test + fun parseContact_handlesVCard() { + val raw = """ + BEGIN:VCARD + VERSION:3.0 + FN:Jane Doe + ORG:Acme Corp + TITLE:Ops Lead + TEL;TYPE=CELL:+1234567 + EMAIL:jane@acme.com + ADR:;;Main Street 1;Berlin;;;Germany + NOTE:VIP + END:VCARD + """.trimIndent() + + val parsed = ScanContentParsers.parseContact(raw) + assertNotNull(parsed) + assertEquals("Jane Doe", parsed?.fullName) + assertEquals("Acme Corp", parsed?.organization) + assertEquals("Ops Lead", parsed?.title) + assertEquals("+1234567", parsed?.phones?.firstOrNull()) + assertEquals("jane@acme.com", parsed?.emails?.firstOrNull()) + assertTrue(parsed?.address?.contains("Main Street 1") == true) + assertEquals("VIP", parsed?.note) + } + + @Test + fun parseContact_handlesMeCard() { + val raw = "MECARD:N:Doe,John;TEL:+49111;EMAIL:john@doe.dev;ADR:Street 9;NOTE:Friend;;" + + val parsed = ScanContentParsers.parseContact(raw) + assertNotNull(parsed) + assertEquals("John Doe", parsed?.fullName) + assertEquals("+49111", parsed?.phones?.firstOrNull()) + assertEquals("john@doe.dev", parsed?.emails?.firstOrNull()) + assertEquals("Street 9", parsed?.address) + assertEquals("Friend", parsed?.note) + } + + @Test + fun parseContact_handlesParameterizedVCardKeysAndNameOrder() { + val raw = """ + BEGIN:VCARD + VERSION:3.0 + N:Doe;John;;Dr.; + ORG;CHARSET=UTF-8:ACME Inc + TITLE;LANGUAGE=en:Engineering Manager + TEL;TYPE=WORK,VOICE:+12025550123 + EMAIL;TYPE=INTERNET:john.doe@acme.com + END:VCARD + """.trimIndent() + + val parsed = ScanContentParsers.parseContact(raw) + assertNotNull(parsed) + assertEquals("Dr. John Doe", parsed?.fullName) + assertEquals("ACME Inc", parsed?.organization) + assertEquals("Engineering Manager", parsed?.title) + assertEquals("+12025550123", parsed?.phones?.firstOrNull()) + assertEquals("john.doe@acme.com", parsed?.emails?.firstOrNull()) + } + + @Test + fun parseCalendarEvent_handlesVEvent() { + val raw = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Team Sync + DESCRIPTION:Weekly planning + LOCATION:Room A + DTSTART:20270110T103000Z + DTEND:20270110T110000Z + END:VEVENT + END:VCALENDAR + """.trimIndent() + + val parsed = ScanContentParsers.parseCalendarEvent(raw) + assertNotNull(parsed) + assertEquals("Team Sync", parsed?.title) + assertEquals("Weekly planning", parsed?.description) + assertEquals("Room A", parsed?.location) + assertNotNull(parsed?.startMillis) + assertNotNull(parsed?.endMillis) + assertFalse(parsed?.allDay ?: true) + } + + @Test + fun parseContact_handlesTecItVCardPayload() { + val raw = """ + BEGIN:VCARD + VERSION:3.0 + N:Doe;John;;; + FN:John Doe + ORG:TEC-IT + TITLE:Position + ADR;TYPE=WORK:;;203 New York Ave;New York;NY;11377;USA + TEL;TYPE=WORK,VOICE:+43 7252 72720 + TEL;TYPE=CELL:+43 660 1234567 + TEL;TYPE=FAX:+43 7252 72720-77 + EMAIL;TYPE=INTERNET:support@tec-it.com + URL:http://www.tec-it.com + LOGO;VALUE=URI:http://lh5.googleusercontent.com/-7RwzuzOVTsw/SypSXHmWr7I/AAAAAAAAAB0/5vE5NqugtLQ/TEC-IT_Banner_120x29.gif + END:VCARD + """.trimIndent() + + val parsed = ScanContentParsers.parseContact(raw) + assertNotNull(parsed) + assertEquals("John Doe", parsed?.fullName) + assertEquals("TEC-IT", parsed?.organization) + assertEquals("Position", parsed?.title) + assertEquals("support@tec-it.com", parsed?.emails?.firstOrNull()) + assertTrue(parsed?.phones?.contains("+43 7252 72720") == true) + assertTrue(parsed?.phones?.contains("+43 660 1234567") == true) + assertTrue(parsed?.phones?.contains("+43 7252 72720-77") == true) + assertTrue(parsed?.address?.contains("203 New York Ave") == true) + assertTrue(parsed?.address?.contains("New York") == true) + assertTrue(parsed?.address?.contains("NY") == true) + assertTrue(parsed?.address?.contains("11377") == true) + assertTrue(parsed?.address?.contains("USA") == true) + } + + @Test + fun parseContact_handlesTecItMeCardPayload() { + val raw = "MECARD:N:Doe,John;ORG:TEC-IT;TITLE:Position;ADR:203 New York Ave, New York, NY 11377, USA;TEL:+43 7252 72720;TEL:+43 660 1234567;TEL:+43 7252 72720-77;EMAIL:support@tec-it.com;URL:http://www.tec-it.com;NOTE:Logo http://lh5.googleusercontent.com/-7RwzuzOVTsw/SypSXHmWr7I/AAAAAAAAAB0/5vE5NqugtLQ/TEC-IT_Banner_120x29.gif;;" + + val parsed = ScanContentParsers.parseContact(raw) + assertNotNull(parsed) + assertEquals("John Doe", parsed?.fullName) + assertEquals("TEC-IT", parsed?.organization) + assertEquals("Position", parsed?.title) + assertEquals("support@tec-it.com", parsed?.emails?.firstOrNull()) + assertTrue(parsed?.phones?.contains("+43 7252 72720") == true) + assertTrue(parsed?.phones?.contains("+43 660 1234567") == true) + assertTrue(parsed?.phones?.contains("+43 7252 72720-77") == true) + assertEquals("203 New York Ave, New York, NY 11377, USA", parsed?.address) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 070a854..d236bcb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,6 +14,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven(url = "https://jitpack.io") } }