Phone numbers in an event's title, location and description are now links. Tapping one opens the dialer with the number filled in. Nothing is called until you press call, so the app needs no new permission.
- Android's own matcher finds the numbers (Linkify, backed by libphonenumber and the SIM's region). Its loose setting links too much in event text, so a cleanup step drops meeting IDs and PINs after a label, dates, dotted times like "9.30 – 12.00", amounts, anything under 7 digits and digits inside URLs.
- "+49 (0)151 …" dials without the (0). An extension ("ext. 567") becomes a pause, and a Zoom-style dial-in suffix (",,850…#") is kept.
- Detection runs in the detail ViewModel while the event loads, so the links are there on the first frame and survive rotation.
- Long-pressing a field copies it even when the press lands on a link. Before, the link swallowed the press.
- PhoneLinksTest covers the cleanup on the JVM. PhoneLinksPlatformTest (androidTest) runs the real matcher and passes on an emulator with a US SIM.
Known limit: on a device without a SIM (Wi-Fi tablets, some emulators) Android gives the matcher no region, so only numbers starting with + are linked.
Closes #339
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/395
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The platform phone matcher on real text. It reads the region off the SIM,
|
||||
* so these stick to international numbers and to text that must never match
|
||||
* anywhere — domestic formats depend on the device they run on.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PhoneLinksPlatformTest {
|
||||
|
||||
private fun numbers(text: String) = findPhoneLinks(text).map { it.number }
|
||||
|
||||
@Test
|
||||
fun internationalNumbers_areFound() {
|
||||
assertThat(numbers("Call +49 30 1234567 or +1 646-558-8656"))
|
||||
.containsExactly("+49301234567", "+16465588656")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trunkZero_isDroppedWithOrWithoutSpace() {
|
||||
assertThat(numbers("+49 (0)151 2345678")).containsExactly("+491512345678")
|
||||
assertThat(numbers("+49 (0) 151 2345678")).containsExactly("+491512345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun neighbouringNumbers_doNotJoinTheNumber() {
|
||||
assertThat(numbers("Start 10:00 +49 151 2345678")).containsExactly("+491512345678")
|
||||
assertThat(numbers("Call +49 151 2345678 2 times")).containsExactly("+491512345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conferenceDialIn_keepsMeetingId() {
|
||||
assertThat(numbers("One tap mobile: +16465588656,,85012345678# US"))
|
||||
.containsExactly("+16465588656,,85012345678#")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun meetingIds_areNotNumbers() {
|
||||
assertThat(numbers("Meeting ID: 850 1234 5678")).isEmpty()
|
||||
assertThat(numbers("Conference ID: 123 456 789#")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datesAndTimes_areNotNumbers() {
|
||||
assertThat(numbers("2026-10-14 10:00")).isEmpty()
|
||||
assertThat(numbers("Am 14.10.2026 10:00 Uhr")).isEmpty()
|
||||
assertThat(numbers("14.10.26 - 16.10.26")).isEmpty()
|
||||
assertThat(numbers("10:00 - 11:30")).isEmpty()
|
||||
assertThat(numbers("Schuljahr 2025/2026, Season 2025-2026")).isEmpty()
|
||||
assertThat(numbers("1.10.2026 - 3.10.2026")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun amounts_areNotNumbers() {
|
||||
assertThat(numbers("Budget 1.250.000 EUR, 1 250 000 EUR")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isbn_isNotANumber() {
|
||||
assertThat(numbers("ISBN 978-3-16-148410-0")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extension_becomesAPause() {
|
||||
assertThat(numbers("+1 212 555 1234 ext. 567")).containsExactly("+12125551234,567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webexAccessCode_isNotANumber() {
|
||||
assertThat(numbers("Meeting number (access code): 2732 123 4567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commaSeparatedNumbers_stayApart() {
|
||||
assertThat(numbers("+49 30 1234567,+49 151 2345678"))
|
||||
.containsExactly("+49301234567", "+491512345678")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dottedTimes_areNotNumbers() {
|
||||
assertThat(numbers("Beginn 9.30 – 12.00 Uhr")).isEmpty()
|
||||
assertThat(numbers("10.00-11.30")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schemelessUrlDigits_areNotNumbers() {
|
||||
assertThat(numbers("zoom.us/j/85012345678")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webLinkDigits_areNotNumbers() {
|
||||
assertThat(numbers("https://zoom.us/j/85012345678?pwd=1")).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.ClipData
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
@@ -67,9 +69,12 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
|
||||
*
|
||||
* [onTap] carries a field's existing tap action through, announced as
|
||||
* [onTapLabel]; a field without one stays unclickable rather than growing a
|
||||
* ripple that leads nowhere, and gets its long press announced through semantics instead. That branch merges the
|
||||
* node it sits on, so a screen reader lands on the field itself and finds the
|
||||
* action there — a bare container is never focused.
|
||||
* ripple that leads nowhere. The long press is announced through semantics,
|
||||
* merging the node it sits on, so a screen reader lands on the field itself
|
||||
* and finds the action there — a bare container is never focused.
|
||||
*
|
||||
* The press is caught ahead of the field's children, so a long press on a
|
||||
* link inside the text copies too instead of leaving that stretch dead.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.copyOnLongPress(
|
||||
@@ -80,27 +85,45 @@ fun Modifier.copyOnLongPress(
|
||||
onTapLabel: String? = null,
|
||||
): Modifier {
|
||||
val actionLabel = stringResource(R.string.field_copy_action)
|
||||
return if (onTap != null) {
|
||||
combinedClickable(
|
||||
onClick = onTap,
|
||||
onClickLabel = onTapLabel,
|
||||
onLongClickLabel = actionLabel,
|
||||
onLongClick = { copy(label, text) },
|
||||
)
|
||||
} else {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
pointerInput(label, text, copy) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copy(label, text)
|
||||
},
|
||||
)
|
||||
}.semantics(mergeDescendants = true) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
return longPressAheadOfChildren(label, text, copy) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copy(label, text)
|
||||
}
|
||||
.then(if (onTap != null) Modifier.clickable(onClickLabel = onTapLabel, onClick = onTap) else Modifier)
|
||||
.semantics(mergeDescendants = true) {
|
||||
onLongClick(actionLabel) {
|
||||
copy(label, text)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [onLongPress] once a press has held still for the long-press timeout,
|
||||
* watching on the initial pass — before the children see the events — and
|
||||
* then swallow the rest of that press, so neither a link under the finger nor
|
||||
* a tap handler fires on release.
|
||||
*/
|
||||
private fun Modifier.longPressAheadOfChildren(vararg keys: Any?, onLongPress: () -> Unit): Modifier =
|
||||
pointerInput(*keys) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
val endedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
||||
while (true) {
|
||||
val change = awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.firstOrNull { it.id == down.id }
|
||||
val moved = change != null &&
|
||||
(change.position - down.position).getDistance() > viewConfiguration.touchSlop
|
||||
if (change == null || !change.pressed || moved) break
|
||||
}
|
||||
true
|
||||
}
|
||||
if (endedEarly != null) return@awaitEachGesture
|
||||
onLongPress()
|
||||
do {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,15 +311,26 @@ private fun EventDetailContent(
|
||||
// cancelled event strikes through its title.
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
val titleLabel = stringResource(R.string.event_detail_title)
|
||||
val titleDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||
TextDecoration.LineThrough
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Text(
|
||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
// A phone number in the title ("Call Anna 0151 …") dials too.
|
||||
text = if (instance.title.isBlank()) {
|
||||
AnnotatedString(stringResource(R.string.event_untitled))
|
||||
} else {
|
||||
linkify(
|
||||
instance.title,
|
||||
state.phoneLinks.title,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
titleDecoration,
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||
TextDecoration.LineThrough
|
||||
} else {
|
||||
null
|
||||
},
|
||||
textDecoration = titleDecoration,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
@@ -436,7 +447,8 @@ private fun EventDetailContent(
|
||||
)
|
||||
}
|
||||
|
||||
// Location (conditional). URLs open in the browser, the rest in maps.
|
||||
// Location (conditional). URLs open in the browser, phone numbers in the
|
||||
// dialer, the rest in maps.
|
||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
val context = LocalContext.current
|
||||
val locationLabel = stringResource(R.string.event_detail_location)
|
||||
@@ -446,7 +458,7 @@ private fun EventDetailContent(
|
||||
iconContentDescription = locationLabel,
|
||||
) {
|
||||
Text(
|
||||
text = linkifyUrls(location, MaterialTheme.colorScheme.primary),
|
||||
text = linkify(location, state.phoneLinks.location, MaterialTheme.colorScheme.primary),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
@@ -463,7 +475,7 @@ private fun EventDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Description (conditional). URLs are auto-linked.
|
||||
// Description (conditional). URLs and phone numbers are auto-linked.
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||
Spacer(Modifier.height(gap))
|
||||
@@ -476,7 +488,7 @@ private fun EventDetailContent(
|
||||
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
||||
) {
|
||||
Text(
|
||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||
text = linkify(description, state.phoneLinks.description, MaterialTheme.colorScheme.primary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -749,14 +761,25 @@ private fun foreignTimeZone(
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap http(s) URLs in [text] as links tinted [linkColor] that open in the browser. */
|
||||
/**
|
||||
* Wrap http(s) URLs in [text] as links that open in the browser, and its
|
||||
* [phoneLinks] as links that open the dialer, both tinted [linkColor]. A
|
||||
* link's underline joins [textDecoration] rather than replacing it, so a
|
||||
* cancelled title stays struck through across its links.
|
||||
*/
|
||||
@Composable
|
||||
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
|
||||
private fun linkify(
|
||||
text: String,
|
||||
phoneLinks: List<PhoneLink>,
|
||||
linkColor: Color,
|
||||
textDecoration: TextDecoration? = null,
|
||||
): AnnotatedString {
|
||||
val context = LocalContext.current
|
||||
return remember(text, linkColor, context) {
|
||||
val styles = TextLinkStyles(
|
||||
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
||||
)
|
||||
return remember(text, phoneLinks, linkColor, textDecoration, context) {
|
||||
val decoration = textDecoration
|
||||
?.let { TextDecoration.combine(listOf(it, TextDecoration.Underline)) }
|
||||
?: TextDecoration.Underline
|
||||
val styles = TextLinkStyles(style = SpanStyle(color = linkColor, textDecoration = decoration))
|
||||
buildAnnotatedString {
|
||||
append(text)
|
||||
for (link in findWebLinks(text)) {
|
||||
@@ -766,6 +789,13 @@ private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
|
||||
link.end,
|
||||
)
|
||||
}
|
||||
for (link in phoneLinks) {
|
||||
addLink(
|
||||
LinkAnnotation.Url("tel:${link.number}", styles) { dial(context, link.number) },
|
||||
link.start,
|
||||
link.end,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -822,7 +852,15 @@ private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
||||
|
||||
/** Open [url] in a browsable app, or say so when none can take it. */
|
||||
private fun openWebLink(context: Context, url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
startOrSayNoApp(context, Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
|
||||
}
|
||||
|
||||
/** Open the dialer with [number] filled in, or say so when none can take it. */
|
||||
private fun dial(context: Context, number: String) {
|
||||
startOrSayNoApp(context, Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", number, null)))
|
||||
}
|
||||
|
||||
private fun startOrSayNoApp(context: Context, intent: Intent) {
|
||||
if (runCatching { context.startActivity(intent) }.isFailure) {
|
||||
Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ sealed interface EventDetailUiState {
|
||||
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/** Phone numbers to link in the title, location and description. */
|
||||
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
|
||||
) : EventDetailUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,13 @@ class EventDetailViewModel @Inject constructor(
|
||||
calendarName = calendar?.displayName,
|
||||
canModify = calendar?.canModifyContents == true,
|
||||
isManaged = calendar?.isManaged == true,
|
||||
// Found here, off the main thread and once per load: the platform
|
||||
// matcher loads its metadata on first use.
|
||||
phoneLinks = findEventPhoneLinks(
|
||||
title = corrected.instance.title,
|
||||
location = corrected.instance.location,
|
||||
description = corrected.description,
|
||||
),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
|
||||
/**
|
||||
* A phone number found in free text, spanning [start] until [end] (exclusive).
|
||||
* [number] is what the dialer gets: digits, a leading `+`, and any `,`/`#`
|
||||
* dial-in suffix — the separators that only made it readable are gone.
|
||||
*/
|
||||
data class PhoneLink(val start: Int, val end: Int, val number: String)
|
||||
|
||||
/** The phone numbers in an event's free-text fields, each against its own text. */
|
||||
data class EventPhoneLinks(
|
||||
val title: List<PhoneLink> = emptyList(),
|
||||
val location: List<PhoneLink> = emptyList(),
|
||||
val description: List<PhoneLink> = emptyList(),
|
||||
)
|
||||
|
||||
/** Where the platform matcher saw a phone number: [start] until [end] (exclusive). */
|
||||
internal data class PhoneMatch(val start: Int, val end: Int)
|
||||
|
||||
// A conference dial-in's pauses and meeting id right after the number:
|
||||
// "+16465588656,,85012345678#" or Zoom's ",,850…#,,,,*123456#". It opens on a
|
||||
// double comma or closes on "#", so "030 1234567,0151 2345678" stays a list.
|
||||
private val dialSuffixRegex = Regex("""^(?:,,+[\d*]+#?|,[\d*]+#)(?:,+[\d*]+#?)*""")
|
||||
|
||||
// Whitespace as invites write it: Java's \s misses the no-break space and the
|
||||
// invisible direction marks Meet and Outlook wrap around numbers.
|
||||
private const val GAP = """\s\u00A0\u200E\u200F\u202A-\u202E\u2066-\u2069"""
|
||||
|
||||
// A number right after one of these labels is an id to type in, not one to
|
||||
// call. English and German cover the Zoom/Teams/Meet/Webex invites seen so far.
|
||||
// "Conference number" is left out: invites use it for the dial-in itself.
|
||||
private val notAPhoneLabelRegex = Regex(
|
||||
"""(?:(?:meeting|webinar|besprechungs)[ -]?(?:id|number|nummer)|(?:conference|konferenz)[ -]?id|""" +
|
||||
"""\bpin|passcode|password|passwort|kenncode|access code|zugangscode)[$GAP:#.()]*$""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
// A day.month.year (or year-month-day, or US month/day/year) date: exactly
|
||||
// three groups, so a French "01.23.45.67.89" chain doesn't read as one.
|
||||
private val dateRegex = Regex("""(?<![\d./-])(\d{1,4})([./-])(\d{1,2})\2(\d{4}|\d{2})(?![\d]|[./-]\d)""")
|
||||
|
||||
// Thousands groups. A short lead ("1.250.000", "12 500 000") is an amount; a
|
||||
// three-digit lead is one only beside a currency — "612 345 678" and
|
||||
// "612.345.678" are Spanish mobiles, "125.000.000 €" a budget.
|
||||
private val shortLeadAmountRegex = Regex("""[1-9]\d?(?:([. ])\d{3})(?:\1\d{3})*""")
|
||||
private val amountRegex = Regex("""[1-9]\d{0,2}(?:([. ])\d{3})(?:\1\d{3})*""")
|
||||
private val currencyBeforeRegex = Regex("""(?:[€$£]|\b(?:eur|euro|usd|chf|gbp))\s*$""", RegexOption.IGNORE_CASE)
|
||||
private val currencyAfterRegex = Regex("""^\s*(?:[€$£]|(?:eur|euro|usd|chf|gbp)\b)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
// A short group set off by a space after a long one, with a word straight
|
||||
// after — "… 2345678 2 times" — is the prose's count, not the number's tail.
|
||||
// Without the word ("030 123456 0") or after a short group ("0221 1234 56")
|
||||
// it is the number's own.
|
||||
private val strayTailRegex = Regex("""(?<=\d{6})\s+\d{1,2}$""")
|
||||
private val wordAfterRegex = Regex("""[ \t\u00A0]+\p{L}""")
|
||||
|
||||
// "9.30 – 12.00", "10.00-11.30": times of day written the German way.
|
||||
private val dottedTimesRegex = Regex("""(\d{1,2})\.(\d{2})(?:\s*[-–]\s*(\d{1,2})\.(\d{2}))?""")
|
||||
|
||||
// "ext. 567", "x567", "Durchwahl 567": the dialer reaches an extension after a pause.
|
||||
private val extensionRegex = Regex("""\s*(?:ext\.?|extension|x|durchwahl|dw\.?)\s*(?=\d)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val trunkPrefixRegex = Regex("""\(\s*0\s*\)""")
|
||||
private val strandedCountryCodeRegex = Regex("""\+\d{1,3}\s*\(\s*0\s*\)\s*$""")
|
||||
|
||||
// Short local numbers exist, but in event text a 5- or 6-digit run is far more
|
||||
// often a postcode, house number or booking reference.
|
||||
private const val MIN_DIGITS = 7
|
||||
|
||||
/**
|
||||
* Find the phone numbers in [text]. The platform's libphonenumber-backed
|
||||
* matcher does the finding — it knows the device's region and real number
|
||||
* lengths; [refinePhoneMatches] then fits what it found to an event's text.
|
||||
* Call off the main thread: the matcher loads its metadata on first use.
|
||||
* No match at all when the platform matcher fails.
|
||||
*/
|
||||
internal fun findPhoneLinks(text: String): List<PhoneLink> =
|
||||
refinePhoneMatches(text, platformPhoneMatches(text), findWebLinks(text))
|
||||
|
||||
/** [findPhoneLinks] for each of an event's free-text fields. */
|
||||
internal fun findEventPhoneLinks(title: String, location: String?, description: String?) = EventPhoneLinks(
|
||||
title = findPhoneLinks(title),
|
||||
location = location?.let(::findPhoneLinks).orEmpty(),
|
||||
description = description?.let(::findPhoneLinks).orEmpty(),
|
||||
)
|
||||
|
||||
// The matcher reads the region off the SIM, which throws on a device without
|
||||
// telephony; a field then just goes without phone links.
|
||||
private fun platformPhoneMatches(text: String): List<PhoneMatch> = runCatching {
|
||||
val spannable = SpannableString(text)
|
||||
if (!Linkify.addLinks(spannable, Linkify.PHONE_NUMBERS)) return emptyList()
|
||||
spannable.getSpans(0, spannable.length, URLSpan::class.java)
|
||||
.map { PhoneMatch(spannable.getSpanStart(it), spannable.getSpanEnd(it)) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/**
|
||||
* Turn the platform's [matches] in [text] into dialable links. The matcher is
|
||||
* lenient, so a match is dropped when it sits inside one of the [webLinks],
|
||||
* follows a meeting id or PIN label, holds a date, reads as an amount or is
|
||||
* too short. What's kept loses a stray count off its end, regains an
|
||||
* international "+49 (0)" prefix the matcher left behind, and carries a
|
||||
* conference dial-in suffix along.
|
||||
*/
|
||||
internal fun refinePhoneMatches(
|
||||
text: String,
|
||||
matches: List<PhoneMatch>,
|
||||
webLinks: List<WebLink>,
|
||||
): List<PhoneLink> {
|
||||
val links = mutableListOf<PhoneLink>()
|
||||
for (match in matches.sortedBy { it.start }) {
|
||||
var start = match.start
|
||||
var end = match.end
|
||||
val matched = text.substring(start, end)
|
||||
if (webLinks.any { start < it.end && it.start < end }) continue
|
||||
if (gluedIntoAWord(text, start, end)) continue
|
||||
val lineBefore = text.window(lineStart(text, start).coerceAtLeast(start - LOOK_BEHIND), start)
|
||||
if (notAPhoneLabelRegex.containsMatchIn(lineBefore)) continue
|
||||
if (dateRegex.findAll(matched).any { it.isDate() }) continue
|
||||
if (isDottedTimes(matched)) continue
|
||||
if (isAmount(text, start, end)) continue
|
||||
strayTailRegex.find(matched)?.let { tail ->
|
||||
if (wordAfterRegex.matchesAt(text, end)) end = start + tail.range.first
|
||||
}
|
||||
// "+49 (0)151 …": a matcher that stops at the "(0)" leaves the domestic
|
||||
// tail, which would dial without the country code.
|
||||
strandedCountryCodeRegex.find(text.window(start - LOOK_BEHIND, start))
|
||||
?.let { start -= it.value.length }
|
||||
val number = dialable(text.substring(start, end))
|
||||
if (number.count { it.isDigit() } < MIN_DIGITS) continue
|
||||
val suffix = dialSuffixRegex.find(text.window(end, end + LOOK_AHEAD))?.value.orEmpty()
|
||||
// A suffix swallowed what the matcher saw as the next number.
|
||||
if (links.lastOrNull()?.let { start < it.end } == true) continue
|
||||
links += PhoneLink(start, end + suffix.length, number + dialable(suffix))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
// How far around a match the label, prefix, currency and suffix checks look.
|
||||
// Enough for any of them, and it keeps a long invite from being re-read whole
|
||||
// for every number in it.
|
||||
private const val LOOK_BEHIND = 40
|
||||
private const val LOOK_AHEAD = 60
|
||||
|
||||
private fun String.window(from: Int, to: Int): String =
|
||||
substring(from.coerceAtLeast(0), to.coerceAtMost(length))
|
||||
|
||||
/**
|
||||
* Digits run straight into a letter or a URL path — "zoom.us/j/850…" without
|
||||
* its https:// — which no web link covered. A phone number stands apart.
|
||||
*/
|
||||
private fun gluedIntoAWord(text: String, start: Int, end: Int): Boolean {
|
||||
val before = text.getOrNull(start - 1)
|
||||
val after = text.getOrNull(end)
|
||||
return (before != null && (before.isLetter() || before in "/=?&")) ||
|
||||
(after != null && (after.isLetter() || after == '/'))
|
||||
}
|
||||
|
||||
private fun isDottedTimes(matched: String): Boolean {
|
||||
val times = dottedTimesRegex.matchEntire(matched) ?: return false
|
||||
return times.groupValues.drop(1).chunked(2).all { (hour, minute) ->
|
||||
hour.isEmpty() || (hour.toInt() in 0..24 && minute.toInt() in 0..59)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAmount(text: String, start: Int, end: Int): Boolean {
|
||||
val matched = text.substring(start, end)
|
||||
if (shortLeadAmountRegex.matches(matched)) return true
|
||||
return amountRegex.matches(matched) &&
|
||||
(currencyBeforeRegex.containsMatchIn(text.window(start - LOOK_BEHIND, start)) ||
|
||||
currencyAfterRegex.containsMatchIn(text.window(end, end + LOOK_AHEAD)))
|
||||
}
|
||||
|
||||
private fun MatchResult.isDate(): Boolean {
|
||||
val (a, _, b, c) = destructured
|
||||
val first = a.toInt()
|
||||
val second = b.toInt()
|
||||
val last = c.toInt()
|
||||
return when {
|
||||
a.length == 4 -> second in 1..12 && last in 1..31 && c.length <= 2
|
||||
a.length > 2 -> false
|
||||
// Day first (most of the world) or month first (US).
|
||||
else -> (first in 1..31 && second in 1..12) || (first in 1..12 && second in 1..31)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lineStart(text: String, index: Int): Int = text.lastIndexOf('\n', index - 1) + 1
|
||||
|
||||
/**
|
||||
* Strip [raw] down to what a dialer takes. An international number's "(0)"
|
||||
* trunk prefix — "+49 (0) 151 …" — is a domestic-only hint and would break the
|
||||
* call, so it goes too; an extension becomes a pause.
|
||||
*/
|
||||
private fun dialable(raw: String): String {
|
||||
val international = raw.trimStart('(').startsWith("+")
|
||||
val withoutTrunk = if (international) raw.replace(trunkPrefixRegex, "") else raw
|
||||
return withoutTrunk.replace(extensionRegex, ",")
|
||||
.filter { it.isDigit() || it == '+' || it == ',' || it == '#' || it == '*' }
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The fitting applied on top of the platform matcher. What the matcher itself
|
||||
* finds is covered on a device by PhoneLinksPlatformTest.
|
||||
*/
|
||||
class PhoneLinksTest {
|
||||
|
||||
/** Refine a match the platform found at [found] in [text]. */
|
||||
private fun refine(text: String, found: String): List<PhoneLink> {
|
||||
val start = text.indexOf(found)
|
||||
return refinePhoneMatches(
|
||||
text,
|
||||
listOf(PhoneMatch(start, start + found.length)),
|
||||
findWebLinks(text),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a match keeps its span and dials only digits`() {
|
||||
val text = "Call Anna on 0151 234-5678 before"
|
||||
val link = refine(text, "0151 234-5678").single()
|
||||
assertThat(link.number).isEqualTo("01512345678")
|
||||
assertThat(text.substring(link.start, link.end)).isEqualTo("0151 234-5678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an international number keeps its plus`() {
|
||||
assertThat(refine("Tel. +49 30 1234567", "+49 30 1234567").single().number)
|
||||
.isEqualTo("+49301234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the trunk zero of an international number is dropped`() {
|
||||
assertThat(refine("+49 (0) 151 2345678", "+49 (0) 151 2345678").single().number)
|
||||
.isEqualTo("+491512345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a country code stranded before the trunk zero is pulled back in`() {
|
||||
val text = "Mobil: +49 (0)151 2345678"
|
||||
val link = refine(text, "151 2345678").single()
|
||||
assertThat(link.number).isEqualTo("+491512345678")
|
||||
assertThat(text.substring(link.start, link.end)).isEqualTo("+49 (0)151 2345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a conference dial-in keeps its pauses and meeting id`() {
|
||||
val text = "One tap mobile: +16465588656,,85012345678#,,,,*123456# (US)"
|
||||
val link = refine(text, "+16465588656").single()
|
||||
assertThat(link.number).isEqualTo("+16465588656,,85012345678#,,,,*123456#")
|
||||
assertThat(text.substring(link.start, link.end))
|
||||
.isEqualTo("+16465588656,,85012345678#,,,,*123456#")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `meeting ids and PINs are not numbers to call`() {
|
||||
assertThat(refine("Meeting ID: 850 1234 5678", "850 1234 5678")).isEmpty()
|
||||
assertThat(refine("Conference ID: 123 456 789#", "123 456 789")).isEmpty()
|
||||
assertThat(refine("PIN: 123 456 789#", "123 456 789")).isEmpty()
|
||||
assertThat(refine("Konferenz-ID: 123 456 789#", "123 456 789")).isEmpty()
|
||||
assertThat(refine("Access code: 2345 678 9012", "2345 678 9012")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a label on an earlier line does not hide a number`() {
|
||||
assertThat(refine("Meeting ID: 850 1234 5678\nDial +1 646 558 8656", "+1 646 558 8656"))
|
||||
.hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a label earlier on the same line does not hide a later number`() {
|
||||
assertThat(refine("PIN 1234, call +1 646 558 8656", "+1 646 558 8656")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a match holding a date is not a number`() {
|
||||
assertThat(refine("Am 14.10.2026 10:00 Uhr", "14.10.2026 10")).isEmpty()
|
||||
assertThat(refine("2026-10-14 10:00", "2026-10-14 10")).isEmpty()
|
||||
assertThat(refine("10/14/2026 9:00", "10/14/2026 9")).isEmpty()
|
||||
assertThat(refine("14.10.26 - 16.10.26", "14.10.26 - 16.10.26")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amounts are not numbers`() {
|
||||
assertThat(refine("Budget 1.250.000 EUR", "1.250.000")).isEmpty()
|
||||
assertThat(refine("Budget 1 250 000 EUR", "1 250 000")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an area code in threes is still a number`() {
|
||||
assertThat(refine("030 123 456", "030 123 456")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers in dotted pairs are not dates`() {
|
||||
assertThat(refine("01.23.45.67.89", "01.23.45.67.89").single().number)
|
||||
.isEqualTo("0123456789")
|
||||
assertThat(refine("555.123.4567", "555.123.4567")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stray count after the number is trimmed`() {
|
||||
val text = "Call +49 151 2345678 2 times"
|
||||
val link = refine(text, "+49 151 2345678 2").single()
|
||||
assertThat(link.number).isEqualTo("+491512345678")
|
||||
assertThat(text.substring(link.start, link.end)).isEqualTo("+49 151 2345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short trailing groups of a real number stay`() {
|
||||
assertThat(refine("030 123 45 67", "030 123 45 67").single().number).isEqualTo("0301234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spaced threes without a currency are a number`() {
|
||||
assertThat(refine("Móvil 612 345 678", "612 345 678").single().number).isEqualTo("612345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing pair after a short group stays`() {
|
||||
val text = "Büro 0221 1234 56"
|
||||
val link = refine(text, "0221 1234 56").single()
|
||||
assertThat(link.number).isEqualTo("0221123456")
|
||||
assertThat(text.substring(link.start, link.end)).isEqualTo("0221 1234 56")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an extension becomes a pause`() {
|
||||
assertThat(refine("+1 212 555 1234 ext. 567", "+1 212 555 1234 ext. 567").single().number)
|
||||
.isEqualTo("+12125551234,567")
|
||||
assertThat(refine("+1 212 555 1234 x567", "+1 212 555 1234 x567").single().number)
|
||||
.isEqualTo("+12125551234,567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `postcodes and other short runs are not numbers`() {
|
||||
assertThat(refine("Hauptstraße 12, 10115 Berlin", "10115")).isEmpty()
|
||||
assertThat(refine("Raum 123456", "123456")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Webex access code is not a number to call`() {
|
||||
assertThat(refine("Meeting number (access code): 2732 123 4567", "2732 123 4567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a conference number is a number to call`() {
|
||||
assertThat(refine("Conference number: +49 30 1234567", "+49 30 1234567")).hasSize(1)
|
||||
assertThat(refine("Konferenznummer: +49 30 1234567", "+49 30 1234567")).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a comma-separated list stays separate numbers`() {
|
||||
val text = "Tel 030 1234567,0151 2345678"
|
||||
val first = text.indexOf("030")
|
||||
val second = text.indexOf("0151")
|
||||
val links = refinePhoneMatches(
|
||||
text,
|
||||
listOf(PhoneMatch(first, first + 11), PhoneMatch(second, text.length)),
|
||||
emptyList(),
|
||||
)
|
||||
assertThat(links.map { it.number }).containsExactly("0301234567", "01512345678").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-comma suffix closed by a hash is a dial-in`() {
|
||||
assertThat(refine("+1 555 123 4567,123456#", "+1 555 123 4567").single().number)
|
||||
.isEqualTo("+15551234567,123456#")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labels behind invisible spacing still hide an id`() {
|
||||
assertThat(refine("PIN: \u202A123 456 789\u202C#", "123 456 789")).isEmpty()
|
||||
assertThat(refine("Meeting ID:\u00A0850 1234 5678", "850 1234 5678")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dotted times of day are not numbers`() {
|
||||
assertThat(refine("Beginn 9.30 – 12.00 Uhr", "9.30 – 12.00")).isEmpty()
|
||||
assertThat(refine("10.00-11.30", "10.00-11.30")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a trailing digit with no word after it stays`() {
|
||||
assertThat(refine("Zentrale 030 123456 0", "030 123456 0").single().number)
|
||||
.isEqualTo("0301234560")
|
||||
assertThat(refine("0221 9876543 21\nnext line", "0221 9876543 21").single().number)
|
||||
.isEqualTo("0221987654321")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `digits in a URL without its scheme are not a number`() {
|
||||
assertThat(refine("zoom.us/j/85012345678", "85012345678")).isEmpty()
|
||||
assertThat(refine("room?id=0301234567", "0301234567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dotted threes without a currency are a number`() {
|
||||
assertThat(refine("Móvil 612.345.678", "612.345.678")).hasSize(1)
|
||||
assertThat(refine("Budget 125.000.000 €", "125.000.000")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `digits inside a web link are not a number`() {
|
||||
assertThat(refine("https://x.example/030-1234567", "030-1234567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a number beside a web link is still found`() {
|
||||
assertThat(refine("https://zoom.us/j/85012345678 or dial +1 646 558 8656", "+1 646 558 8656"))
|
||||
.hasSize(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user