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. 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 ## Architektur
- `ui/`: Compose screens/components + ViewModels (MVVM) - `ui/`: Compose screens/components + ViewModels (MVVM)
- `data/`: ML Kit analyzer, Room entities/DAO, repository - `data/`: ML Kit analyzer, Room entities/DAO, repository
+4
View File
@@ -1,5 +1,9 @@
# Product Roadmap # 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) ## Quick Wins (1-3 days)
- [x] Duplicate UX polish - [x] Duplicate UX polish
+31 -31
View File
@@ -1,53 +1,53 @@
# Clean Scanner Use Cases # Clean Scanner Use Cases
## 1. Everyday Personal Use ## 1. Everyday Personal Use
- Scan restaurant menus, product QR labels, and website links quickly. - [Done] Scan restaurant menus, product QR labels, and website links quickly.
- Copy/share scanned values to chat apps or notes. - [Done] Copy/share scanned values to chat apps or notes.
- Open links directly with local risk warning support. - [Done] Open links directly with local risk warning support.
## 2. Event & Ticketing ## 2. Event & Ticketing
- Scan tickets at venues and quickly validate repeated entries. - [Done] Scan tickets at venues and quickly validate repeated entries.
- Use batch mode to process multiple attendees without leaving the camera. - [Done] Use batch mode to process multiple attendees without leaving the camera.
- Share batch captures to organizers for quick reconciliation. - [Done] Share batch captures to organizers for quick reconciliation.
## 3. Inventory & Operations ## 3. Inventory & Operations
- Scan product barcodes in stock rooms. - [Done] Scan product barcodes in stock rooms.
- Use batch mode for continuous scanning of many items. - [Done] Use batch mode for continuous scanning of many items.
- Export and share history (TXT/CSV/JSON) for downstream reporting. - [Done] Export and share history (TXT/CSV/JSON) for downstream reporting.
## 4. Field Work & Service Teams ## 4. Field Work & Service Teams
- Scan device labels/serials on-site. - [Done] Scan device labels/serials on-site.
- Save local history for audit trails when enabled. - [Done] Save local history for audit trails when enabled.
- Share captured codes with support teams in real time. - [Done] Share captured codes with support teams in real time.
## 5. Office & Admin Workflows ## 5. Office & Admin Workflows
- Scan contact QR/vCard and add to contacts. - [Done] Scan contact QR/vCard and add to contacts.
- Scan Wi-Fi setup QR and jump to Wi-Fi settings. - [Done] Scan Wi-Fi setup QR and jump to Wi-Fi settings.
- Scan calendar/event data and create calendar entries. - [Done] Scan calendar/event data and create calendar entries.
## 6. Communication Shortcuts ## 6. Communication Shortcuts
- Scan phone/SMS/email QR data. - [Done] Scan phone/SMS/email QR data.
- One-tap actions: call, send SMS, send email. - [Done] One-tap actions: call, send SMS, send email.
- Reduce manual entry errors for phone numbers and addresses. - [Done] Reduce manual entry errors for phone numbers and addresses.
## 7. Security-Conscious Browsing ## 7. Security-Conscious Browsing
- Scan URL QR codes and get local warning prompts for suspicious patterns. - [Done] Scan URL QR codes and get local warning prompts for suspicious patterns.
- Decide whether to open risky links with explicit confirmation. - [Done] Decide whether to open risky links with explicit confirmation.
- Keep scanning offline-first without backend calls. - [Done] Keep scanning offline-first without backend calls.
## 8. Offline / Low-Connectivity Scenarios ## 8. Offline / Low-Connectivity Scenarios
- Use the scanner with no internet dependency for core scanning. - [Done] Use the scanner with no internet dependency for core scanning.
- Keep data local-first and share outputs when connectivity returns. - [Done] Keep data local-first and share outputs when connectivity returns.
- Useful for travel, warehouses, and remote job sites. - [Done] Useful for travel, warehouses, and remote job sites.
## 9. Accessibility & Speed ## 9. Accessibility & Speed
- Launch directly into camera for 0-click scan flow. - [Done] Launch directly into camera for 0-click scan flow.
- Pinch-to-zoom for small or distant QR/barcodes. - [Done] Pinch-to-zoom for small or distant QR/barcodes.
- Friendly scanner guide with immediate feedback on successful scans. - [Done] Friendly scanner guide with immediate feedback on successful scans.
## 10. Team Handover & Data Transfer ## 10. Team Handover & Data Transfer
- Export scan history in multiple formats: - Export scan history in multiple formats:
- TXT for human-readable logs - [Done] TXT for human-readable logs
- CSV for spreadsheets/BI tools - [Done] CSV for spreadsheets/BI tools
- JSON for system integrations - [Done] JSON for system integrations
- Share exports to teammates via native Android share sheet. - [Done] Share exports to teammates via native Android share sheet.
+5
View File
@@ -38,6 +38,7 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
} }
buildFeatures { buildFeatures {
@@ -62,6 +63,8 @@ dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.00") val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
implementation(composeBom) implementation(composeBom)
androidTestImplementation(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.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
@@ -84,12 +87,14 @@ dependencies {
implementation("androidx.camera:camera-view:1.5.3") implementation("androidx.camera:camera-view:1.5.3")
implementation("com.google.mlkit:barcode-scanning:17.3.0") 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-runtime:2.8.4")
implementation("androidx.room:room-ktx:2.8.4") implementation("androidx.room:room-ktx:2.8.4")
ksp("androidx.room:room-compiler:2.8.4") ksp("androidx.room:room-compiler:2.8.4")
implementation("androidx.datastore:datastore-preferences:1.2.0") 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("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.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.ContentCopy
import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn 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.Share
import androidx.compose.material.icons.filled.ViewModule import androidx.compose.material.icons.filled.ViewModule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState 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.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap 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.ui.components.CameraPreview
import com.clean.scanner.util.ClipboardUtil import com.clean.scanner.util.ClipboardUtil
import com.clean.scanner.util.Intents import com.clean.scanner.util.Intents
import com.clean.scanner.util.ParsedContact
import com.clean.scanner.util.ScanContentParsers import com.clean.scanner.util.ScanContentParsers
import com.clean.scanner.util.UrlRiskScorer import com.clean.scanner.util.UrlRiskScorer
import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.BarcodeScanning
@@ -481,6 +487,9 @@ fun ScannerScreen(
} }
if (lastResult != null && !batchMode) { 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) { ModalBottomSheet(onDismissRequest = onScanAgain) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -488,14 +497,26 @@ fun ScannerScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}") if (parsedContact == null) {
Text(text = "${stringResource(R.string.content_value)}: ${lastResult.content}") Text(text = "${stringResource(R.string.content_type)}: ${lastResult.type}")
}
ResultVisualCard(result = lastResult)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()), .horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp) 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) }) { IconButton(onClick = { ClipboardUtil.copy(context, lastResult.content) }) {
Icon( Icon(
imageVector = Icons.Default.ContentCopy, 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" -> { "Calendar" -> {
Button(onClick = { Intents.addCalendarEvent(context, lastResult.content) }) { Button(onClick = {
Intents.addCalendarEvent(context, parsedEvent, lastResult.content)
}) {
Text(stringResource(R.string.add_calendar_event)) 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 @Composable
private fun OverlayIconToggle( private fun OverlayIconToggle(
checked: Boolean, checked: Boolean,
@@ -1,9 +1,12 @@
package com.clean.scanner.util package com.clean.scanner.util
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.CalendarContract import android.provider.CalendarContract
import android.provider.ContactsContract import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.StructuredName
import android.provider.ContactsContract.CommonDataKinds.Organization
import android.provider.Settings import android.provider.Settings
import androidx.core.net.toUri import androidx.core.net.toUri
@@ -60,20 +63,87 @@ object Intents {
context.startActivity(intent) context.startActivity(intent)
} }
fun addContact(context: Context, rawContent: String) { fun addContact(context: Context, parsed: ParsedContact?, rawContent: String) {
val intent = Intent(Intent.ACTION_INSERT).apply { val intent = buildAddContactIntent(parsed, rawContent)
type = ContactsContract.Contacts.CONTENT_TYPE
putExtra(ContactsContract.Intents.Insert.NOTES, rawContent)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent) 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 { val intent = Intent(Intent.ACTION_INSERT).apply {
data = CalendarContract.Events.CONTENT_URI data = CalendarContract.Events.CONTENT_URI
putExtra(CalendarContract.Events.TITLE, "Scanned event") putExtra(CalendarContract.Events.TITLE, parsed?.title ?: "Scanned event")
putExtra(CalendarContract.Events.DESCRIPTION, rawContent) 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) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity(intent) context.startActivity(intent)
@@ -1,5 +1,32 @@
package com.clean.scanner.util 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 { object ScanContentParsers {
fun extractPhoneNumber(raw: String): String { fun extractPhoneNumber(raw: String): String {
return raw.substringAfter("tel:", raw) return raw.substringAfter("tel:", raw)
@@ -28,4 +55,410 @@ object ScanContentParsers {
.find(cleaned) .find(cleaned)
return match?.value ?: 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 package com.clean.scanner.util
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class ScanContentParsersTest { class ScanContentParsersTest {
@@ -23,4 +26,141 @@ class ScanContentParsersTest {
assertEquals("x@y.com", ScanContentParsers.extractEmail("mailto:x@y.com?subject=hi")) 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")) 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 { repositories {
google() google()
mavenCentral() mavenCentral()
maven(url = "https://jitpack.io")
} }
} }