vcard visualiation

This commit is contained in:
Hadrian Burkhardt
2026-02-26 05:11:34 +01:00
parent 027d2391b7
commit 3d2d451815
10 changed files with 1088 additions and 49 deletions
+4
View File
@@ -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
+4
View File
@@ -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
+31 -31
View File
@@ -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.
+5
View File
@@ -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")
@@ -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<android.content.ContentValues>(
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))
}
}
@@ -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<ResultField> {
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<String, String> {
val cleaned = raw.trim()
if (!cleaned.startsWith("WIFI:", ignoreCase = true)) return emptyMap()
val payload = cleaned.substringAfter("WIFI:", "").trim().trimEnd(';')
val values = mutableMapOf<String, String>()
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,
@@ -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<ContentValues>()
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)
@@ -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<String> = emptyList(),
val emails: List<String> = 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<String>()
val emails = mutableListOf<String>()
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<String>()
val emails = mutableListOf<String>()
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<Long, Boolean>? {
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
)
}
}
@@ -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)
}
}
+1
View File
@@ -14,6 +14,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
}