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
@@ -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
)
}
}