vcard visualiation
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven(url = "https://jitpack.io")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user