vcard visualiation
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user