Tap a phone number in an event to call it (#339) (#395)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped

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:
Jean-Luc Makiola
2026-10-14 15:24:03 +02:00
co-authored by makiolaj
parent 951ad9cd6d
commit 9fe1ebb0fb
8 changed files with 632 additions and 40 deletions
@@ -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)
}
}