diff --git a/CHANGELOG.md b/CHANGELOG.md index f3803d8..937ba43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]). - **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]). +- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]). - **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]). ### Fixed @@ -1861,3 +1862,4 @@ automatically, with zero telemetry and no internet permission. [#373]: https://codeberg.org/jlmakiola/calendula/issues/373 [#141]: https://codeberg.org/jlmakiola/calendula/issues/141 [#153]: https://codeberg.org/jlmakiola/calendula/issues/153 +[#339]: https://codeberg.org/jlmakiola/calendula/issues/339 diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksPlatformTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksPlatformTest.kt new file mode 100644 index 0000000..254ae5e --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksPlatformTest.kt @@ -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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt index f8920c5..8163de6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CopyToClipboard.kt @@ -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 }) + } + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 7e94e35..5255d78 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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, + 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() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt index 6f6dd97..f4a7573 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 76da166..8dcace1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinks.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinks.kt new file mode 100644 index 0000000..ed562f9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinks.kt @@ -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 = emptyList(), + val location: List = emptyList(), + val description: List = 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("""(? = + 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 = 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, + webLinks: List, +): List { + val links = mutableListOf() + 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 == '*' } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksTest.kt new file mode 100644 index 0000000..662236e --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/PhoneLinksTest.kt @@ -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 { + 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) + } +}