From c5ed499cb011d7e2c7f2845fb56458d6f74f59dd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 2 Oct 2026 18:28:22 +0200 Subject: [PATCH] Inline location links, harden link handling (#357) --- CHANGELOG.md | 2 +- .../calendula/ui/common/CopyToClipboard.kt | 8 ++- .../calendula/ui/detail/EventDetailScreen.kt | 72 ++++++++----------- .../calendula/ui/detail/WebLinks.kt | 18 +++++ app/src/main/res/values/strings.xml | 2 + .../calendula/ui/detail/WebLinksTest.kt | 52 ++++++++++++++ 6 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/WebLinks.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/WebLinksTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fe971..cdcf809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.22.1] — 2026-10-02 ### Fixed -- **Links in an event's location open in the browser.** Tapping a location that holds a URL, such as a video-call link, opened it in a maps app; maps is now only used for plain addresses ([#357]). +- **Links in an event's location open in the browser.** Links in the location are now underlined and open in the browser, as in the description, while tapping the rest of the location still opens maps. Links written with a capitalised `Https://` or wrapped in angle brackets (as Outlook and Teams do) now open correctly too ([#357]). ## [2.22.0] — 2026-10-01 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 14dcdfc..f8920c5 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 @@ -65,9 +65,9 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier { /** * Long-press to copy [text] whole, filed on the clipboard under [label]. * - * [onTap] carries a field's existing tap action through; 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 + * [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. */ @@ -77,11 +77,13 @@ fun Modifier.copyOnLongPress( text: String, copy: FieldCopier, onTap: (() -> Unit)? = null, + 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) }, ) 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 0b79bfa..5c5c7dc 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 @@ -1,11 +1,11 @@ package de.jeanlucmakiola.calendula.ui.detail import android.Manifest -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -534,7 +534,7 @@ private fun EventDetailContent( ) } - // Location (conditional, tap → link if it holds a URL, else maps). + // Location (conditional). URLs open in the browser, the rest in maps. instance.location?.takeIf { it.isNotBlank() }?.let { location -> val context = LocalContext.current val locationLabel = stringResource(R.string.event_detail_location) @@ -544,7 +544,7 @@ private fun EventDetailContent( iconContentDescription = locationLabel, ) { Text( - text = location, + text = linkifyUrls(location, MaterialTheme.colorScheme.primary), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier @@ -553,7 +553,8 @@ private fun EventDetailContent( label = locationLabel, text = location, copy = copyField, - onTap = { openLocation(context, location) }, + onTap = { openInMaps(context, location) }, + onTapLabel = stringResource(R.string.event_detail_open_in_maps), ) .padding(vertical = 2.dp), ) @@ -846,24 +847,23 @@ private fun foreignTimeZone( ) } -private val urlRegex = Regex("""https?://\S+""") - -/** Strip trailing punctuation that commonly abuts a URL in prose. */ -private fun String.trimUrlPunctuation(): String = - trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'') - -/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */ +/** Wrap http(s) URLs in [text] as links tinted [linkColor] that open in the browser. */ @Composable -private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) { - val styles = TextLinkStyles( - style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), - ) - buildAnnotatedString { - append(text) - for (match in urlRegex.findAll(text)) { - val url = match.value.trimUrlPunctuation() - val end = match.range.first + url.length - addLink(LinkAnnotation.Url(url, styles), match.range.first, end) +private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString { + val context = LocalContext.current + return remember(text, linkColor, context) { + val styles = TextLinkStyles( + style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), + ) + buildAnnotatedString { + append(text) + for (link in findWebLinks(text)) { + addLink( + LinkAnnotation.Url(link.url, styles) { openWebLink(context, link.url) }, + link.start, + link.end, + ) + } } } } @@ -918,17 +918,11 @@ private fun formatWhen( private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime = java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid) -/** Open the first URL in [location] (e.g. a meeting link), otherwise search it in maps. */ -private fun openLocation(context: Context, location: String) { - val url = urlRegex.find(location)?.value?.trimUrlPunctuation() - if (url == null) { - openInMaps(context, location) - return - } - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) - } catch (e: ActivityNotFoundException) { - // No app handles the link — nothing sensible to do; swallow. +/** 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) + if (runCatching { context.startActivity(intent) }.isFailure) { + Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show() } } @@ -936,17 +930,7 @@ private fun openLocation(context: Context, location: String) { private fun openInMaps(context: Context, query: String) { val encoded = Uri.encode(query) val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded")) - try { - context.startActivity(geo) - } catch (e: ActivityNotFoundException) { - val web = Intent( - Intent.ACTION_VIEW, - Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"), - ) - try { - context.startActivity(web) - } catch (e2: ActivityNotFoundException) { - // No browser either — nothing sensible to do; swallow. - } + if (runCatching { context.startActivity(geo) }.isFailure) { + openWebLink(context, "https://www.google.com/maps/search/?api=1&query=$encoded") } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/WebLinks.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/WebLinks.kt new file mode 100644 index 0000000..8cd6dbd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/WebLinks.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.calendula.ui.detail + +/** An http(s) URL found in free text, spanning [start] until [end] (exclusive). */ +internal data class WebLink(val start: Int, val end: Int, val url: String) + +private val webLinkRegex = Regex("""https?://[^\s<>"]+""", RegexOption.IGNORE_CASE) + +/** + * Find the http(s) URLs in [text]. Trailing prose punctuation is left out of + * each match, and the scheme is lower-cased so intent filters match it. + */ +internal fun findWebLinks(text: String): List = webLinkRegex.findAll(text).mapNotNull { match -> + val raw = match.value.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '\'') + val schemeEnd = raw.indexOf(':') + if (raw.length <= schemeEnd + 3) return@mapNotNull null + val url = raw.substring(0, schemeEnd).lowercase() + raw.substring(schemeEnd) + WebLink(match.range.first, match.range.first + raw.length, url) +}.toList() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf9413d..52c5050 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -211,6 +211,8 @@ Unknown calendar Title Location + Open in maps + No app can open this link Description Attendees Recurrence diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/WebLinksTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/WebLinksTest.kt new file mode 100644 index 0000000..eb97aa6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/WebLinksTest.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.calendula.ui.detail + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WebLinksTest { + + @Test + fun `a plain address has no links`() { + assertThat(findWebLinks("Office B, 12 Main St.")).isEmpty() + } + + @Test + fun `a bare meeting link spans the whole text`() { + val text = "https://meet.google.com/abc-defg-hij" + assertThat(findWebLinks(text)).containsExactly(WebLink(0, text.length, text)) + } + + @Test + fun `a capitalised scheme is matched and lower-cased`() { + val links = findWebLinks("Https://zoom.us/j/123") + assertThat(links.single().url).isEqualTo("https://zoom.us/j/123") + assertThat(links.single().end).isEqualTo(21) + } + + @Test + fun `angle brackets around a link stay outside it`() { + val text = "Microsoft Teams Meeting " + val link = findWebLinks(text).single() + assertThat(link.url).isEqualTo("https://teams.microsoft.com/l/meetup-join/x") + assertThat(text.substring(link.start, link.end)).isEqualTo(link.url) + } + + @Test + fun `trailing punctuation is left out of the link`() { + val link = findWebLinks("Office B (https://office.example.com).").single() + assertThat(link.url).isEqualTo("https://office.example.com") + } + + @Test + fun `every link in the text is found`() { + val links = findWebLinks("Room 3 / https://zoom.us/j/1 / https://meet.google.com/x") + assertThat(links.map { it.url }) + .containsExactly("https://zoom.us/j/1", "https://meet.google.com/x") + .inOrder() + } + + @Test + fun `other schemes and a bare scheme are not links`() { + assertThat(findWebLinks("ftp://example.com mailto:a@b.c https://")).isEmpty() + } +}