diff --git a/CHANGELOG.md b/CHANGELOG.md index c08fa65a..54a45345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.22.1] — 2026-10-02 + +### Fixed +- **Tapping a link in an event's location opens it in the browser instead of a maps app.** Links are underlined, as in the description, and tapping the rest of the location still opens maps. A link with a capitalised `Https://`, or wrapped in `<…>` the way Outlook and Teams write them, opens correctly too. When no app can open a link, a short message says so instead of the tap doing nothing ([#357]). + ## [2.22.0] — 2026-10-01 ### Added @@ -1817,3 +1822,4 @@ automatically, with zero telemetry and no internet permission. [#322]: https://codeberg.org/jlmakiola/calendula/issues/322 [#331]: https://codeberg.org/jlmakiola/calendula/issues/331 [#336]: https://codeberg.org/jlmakiola/calendula/issues/336 +[#357]: https://codeberg.org/jlmakiola/calendula/issues/357 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17ed8628..306f2d88 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 22200 - versionName = "2.22.0" + versionCode = 22201 + versionName = "2.22.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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 14dcdfcf..f8920c57 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 db61df95..5c5c7dcb 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 → 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 @@ -554,6 +554,7 @@ private fun EventDetailContent( text = location, copy = copyField, onTap = { openInMaps(context, location) }, + onTapLabel = stringResource(R.string.event_detail_open_in_maps), ) .padding(vertical = 2.dp), ) @@ -846,21 +847,23 @@ private fun foreignTimeZone( ) } -/** 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 regex = Regex("""https?://\S+""") - val styles = TextLinkStyles( - style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), - ) - buildAnnotatedString { - append(text) - for (match in regex.findAll(text)) { - // Trim trailing punctuation that commonly abuts a URL in prose. - val raw = match.value - val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'') - 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, + ) + } } } } @@ -915,21 +918,19 @@ private fun formatWhen( private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime = java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid) +/** 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() + } +} + /** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */ 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 00000000..8cd6dbdb --- /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 cf9413d0..52c50504 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 00000000..eb97aa6c --- /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() + } +} diff --git a/fastlane/metadata/android/ar/changelogs/22201.txt b/fastlane/metadata/android/ar/changelogs/22201.txt new file mode 100644 index 00000000..1e422d11 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/22201.txt @@ -0,0 +1,2 @@ +إصلاحات +• النقر على رابط في موقع الحدث يفتحه الآن في المتصفح بدلًا من تطبيق الخرائط. النقر على العنوان ما زال يفتح الخرائط. diff --git a/fastlane/metadata/android/be/changelogs/22201.txt b/fastlane/metadata/android/be/changelogs/22201.txt new file mode 100644 index 00000000..cad8c7e8 --- /dev/null +++ b/fastlane/metadata/android/be/changelogs/22201.txt @@ -0,0 +1,2 @@ +Выпраўлена +• Націск на спасылку ў месцы падзеі адкрывае яе ў браўзеры, а не ў праграме карт. Націск на адрас па-ранейшаму адкрывае карты. diff --git a/fastlane/metadata/android/cs-CZ/changelogs/22201.txt b/fastlane/metadata/android/cs-CZ/changelogs/22201.txt new file mode 100644 index 00000000..84f188ef --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/22201.txt @@ -0,0 +1,2 @@ +Opraveno +• Klepnutí na odkaz v místě události ho otevře v prohlížeči místo v mapách. Klepnutí na adresu dál otevírá mapy. diff --git a/fastlane/metadata/android/de-DE/changelogs/22201.txt b/fastlane/metadata/android/de-DE/changelogs/22201.txt new file mode 100644 index 00000000..f9a992a1 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/22201.txt @@ -0,0 +1,2 @@ +Behoben +• Ein Tipp auf einen Link im Ort eines Termins öffnet ihn im Browser statt in einer Karten-App. Ein Tipp auf die Adresse öffnet weiterhin die Karte. diff --git a/fastlane/metadata/android/en-US/changelogs/22201.txt b/fastlane/metadata/android/en-US/changelogs/22201.txt new file mode 100644 index 00000000..db83a52b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/22201.txt @@ -0,0 +1,2 @@ +Fixed +• Tapping a link in an event's location opens it in the browser instead of a maps app. Tapping the address still opens maps. diff --git a/fastlane/metadata/android/es-ES/changelogs/22201.txt b/fastlane/metadata/android/es-ES/changelogs/22201.txt new file mode 100644 index 00000000..09a73761 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/22201.txt @@ -0,0 +1,2 @@ +Corregido +• Al tocar un enlace en la ubicación de un evento, se abre en el navegador en lugar de en una app de mapas. Tocar la dirección sigue abriendo los mapas. diff --git a/fastlane/metadata/android/fr-FR/changelogs/22201.txt b/fastlane/metadata/android/fr-FR/changelogs/22201.txt new file mode 100644 index 00000000..58db7130 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/22201.txt @@ -0,0 +1,2 @@ +Corrigé +• Toucher un lien dans le lieu d'un événement l'ouvre dans le navigateur et non plus dans une app de cartes. Toucher l'adresse ouvre toujours les cartes. diff --git a/fastlane/metadata/android/hu-HU/changelogs/22201.txt b/fastlane/metadata/android/hu-HU/changelogs/22201.txt new file mode 100644 index 00000000..c8d327a0 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/22201.txt @@ -0,0 +1,2 @@ +Javítva +• Az esemény helyszínében lévő linkre koppintva az a böngészőben nyílik meg térképalkalmazás helyett. A címre koppintva továbbra is a térkép nyílik meg. diff --git a/fastlane/metadata/android/it-IT/changelogs/22201.txt b/fastlane/metadata/android/it-IT/changelogs/22201.txt new file mode 100644 index 00000000..38900080 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/22201.txt @@ -0,0 +1,2 @@ +Corretto +• Toccare un link nel luogo di un evento lo apre nel browser invece che in un'app di mappe. Toccare l'indirizzo apre ancora le mappe. diff --git a/fastlane/metadata/android/ja-JP/changelogs/22201.txt b/fastlane/metadata/android/ja-JP/changelogs/22201.txt new file mode 100644 index 00000000..32826fd3 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/changelogs/22201.txt @@ -0,0 +1,2 @@ +修正 +• 予定の場所にあるリンクをタップすると、地図アプリではなくブラウザで開くようになりました。住所をタップすると引き続き地図が開きます。 diff --git a/fastlane/metadata/android/nl/changelogs/22201.txt b/fastlane/metadata/android/nl/changelogs/22201.txt new file mode 100644 index 00000000..af449c23 --- /dev/null +++ b/fastlane/metadata/android/nl/changelogs/22201.txt @@ -0,0 +1,2 @@ +Opgelost +• Tikken op een link in de locatie van een afspraak opent die in de browser in plaats van in een kaarten-app. Tikken op het adres opent nog steeds de kaart. diff --git a/fastlane/metadata/android/pl-PL/changelogs/22201.txt b/fastlane/metadata/android/pl-PL/changelogs/22201.txt new file mode 100644 index 00000000..190a39ca --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/22201.txt @@ -0,0 +1,2 @@ +Poprawki +• Dotknięcie linku w miejscu wydarzenia otwiera go w przeglądarce zamiast w aplikacji map. Dotknięcie adresu nadal otwiera mapy. diff --git a/fastlane/metadata/android/pt-BR/changelogs/22201.txt b/fastlane/metadata/android/pt-BR/changelogs/22201.txt new file mode 100644 index 00000000..ad248c69 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/22201.txt @@ -0,0 +1,2 @@ +Corrigido +• Tocar em um link no local de um evento agora o abre no navegador em vez de em um app de mapas. Tocar no endereço continua abrindo os mapas. diff --git a/fastlane/metadata/android/pt-PT/changelogs/22201.txt b/fastlane/metadata/android/pt-PT/changelogs/22201.txt new file mode 100644 index 00000000..ae7917c6 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/22201.txt @@ -0,0 +1,2 @@ +Corrigido +• Tocar numa ligação no local de um evento abre-a no navegador em vez de numa app de mapas. Tocar na morada continua a abrir os mapas. diff --git a/fastlane/metadata/android/ru-RU/changelogs/22201.txt b/fastlane/metadata/android/ru-RU/changelogs/22201.txt new file mode 100644 index 00000000..a6a28b62 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/22201.txt @@ -0,0 +1,2 @@ +Исправлено +• Нажатие на ссылку в месте события открывает её в браузере, а не в приложении карт. Нажатие на адрес по-прежнему открывает карты. diff --git a/fastlane/metadata/android/sk/changelogs/22201.txt b/fastlane/metadata/android/sk/changelogs/22201.txt new file mode 100644 index 00000000..694da1a9 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/22201.txt @@ -0,0 +1,2 @@ +Opravené +• Ťuknutie na odkaz v mieste udalosti ho otvorí v prehliadači namiesto v mapách. Ťuknutie na adresu naďalej otvára mapy. diff --git a/fastlane/metadata/android/zh-CN/changelogs/22201.txt b/fastlane/metadata/android/zh-CN/changelogs/22201.txt new file mode 100644 index 00000000..64c58cb4 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/22201.txt @@ -0,0 +1,2 @@ +修复 +• 点按日程地点中的链接现在会在浏览器中打开,而不是地图应用。点按地址仍会打开地图。