Compare commits

...
Author SHA1 Message Date
makiolaj 16bfda49ef Add localized 22201 changelogs (#357) 2026-10-02 18:33:14 +02:00
makiolaj 34e35d9ad7 Release v2.22.1 changelog (#357) 2026-10-02 18:31:44 +02:00
makiolaj c5ed499cb0 Inline location links, harden link handling (#357) 2026-10-02 18:28:22 +02:00
makiolaj 630b8e2cd7 Open location links in the browser instead of maps (#357)
Bump to 2.22.1.
2026-10-02 18:18:23 +02:00
24 changed files with 149 additions and 34 deletions
+6
View File
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [2.22.0] — 2026-10-01
### Added ### Added
@@ -1817,3 +1822,4 @@ automatically, with zero telemetry and no internet permission.
[#322]: https://codeberg.org/jlmakiola/calendula/issues/322 [#322]: https://codeberg.org/jlmakiola/calendula/issues/322
[#331]: https://codeberg.org/jlmakiola/calendula/issues/331 [#331]: https://codeberg.org/jlmakiola/calendula/issues/331
[#336]: https://codeberg.org/jlmakiola/calendula/issues/336 [#336]: https://codeberg.org/jlmakiola/calendula/issues/336
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
+2 -2
View File
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 22200 versionCode = 22201
versionName = "2.22.0" versionName = "2.22.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -65,9 +65,9 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
/** /**
* Long-press to copy [text] whole, filed on the clipboard under [label]. * 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 * [onTap] carries a field's existing tap action through, announced as
* stays unclickable rather than growing a ripple that leads nowhere, and gets * [onTapLabel]; a field without one stays unclickable rather than growing a
* its long press announced through semantics instead. That branch merges the * 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 * node it sits on, so a screen reader lands on the field itself and finds the
* action there — a bare container is never focused. * action there — a bare container is never focused.
*/ */
@@ -77,11 +77,13 @@ fun Modifier.copyOnLongPress(
text: String, text: String,
copy: FieldCopier, copy: FieldCopier,
onTap: (() -> Unit)? = null, onTap: (() -> Unit)? = null,
onTapLabel: String? = null,
): Modifier { ): Modifier {
val actionLabel = stringResource(R.string.field_copy_action) val actionLabel = stringResource(R.string.field_copy_action)
return if (onTap != null) { return if (onTap != null) {
combinedClickable( combinedClickable(
onClick = onTap, onClick = onTap,
onClickLabel = onTapLabel,
onLongClickLabel = actionLabel, onLongClickLabel = actionLabel,
onLongClick = { copy(label, text) }, onLongClick = { copy(label, text) },
) )
@@ -1,11 +1,11 @@
package de.jeanlucmakiola.calendula.ui.detail package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background 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 -> instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location) val locationLabel = stringResource(R.string.event_detail_location)
@@ -544,7 +544,7 @@ private fun EventDetailContent(
iconContentDescription = locationLabel, iconContentDescription = locationLabel,
) { ) {
Text( Text(
text = location, text = linkifyUrls(location, MaterialTheme.colorScheme.primary),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier modifier = Modifier
@@ -554,6 +554,7 @@ private fun EventDetailContent(
text = location, text = location,
copy = copyField, copy = copyField,
onTap = { openInMaps(context, location) }, onTap = { openInMaps(context, location) },
onTapLabel = stringResource(R.string.event_detail_open_in_maps),
) )
.padding(vertical = 2.dp), .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 @Composable
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) { private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
val regex = Regex("""https?://\S+""") val context = LocalContext.current
val styles = TextLinkStyles( return remember(text, linkColor, context) {
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), val styles = TextLinkStyles(
) style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
buildAnnotatedString { )
append(text) buildAnnotatedString {
for (match in regex.findAll(text)) { append(text)
// Trim trailing punctuation that commonly abuts a URL in prose. for (link in findWebLinks(text)) {
val raw = match.value addLink(
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'') LinkAnnotation.Url(link.url, styles) { openWebLink(context, link.url) },
val end = match.range.first + url.length link.start,
addLink(LinkAnnotation.Url(url, styles), match.range.first, end) link.end,
)
}
} }
} }
} }
@@ -915,21 +918,19 @@ private fun formatWhen(
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime = private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid) 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:. */ /** 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) { private fun openInMaps(context: Context, query: String) {
val encoded = Uri.encode(query) val encoded = Uri.encode(query)
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded")) val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
try { if (runCatching { context.startActivity(geo) }.isFailure) {
context.startActivity(geo) openWebLink(context, "https://www.google.com/maps/search/?api=1&query=$encoded")
} 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.
}
} }
} }
@@ -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<WebLink> = 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()
+2
View File
@@ -211,6 +211,8 @@
<string name="event_detail_calendar_unknown">Unknown calendar</string> <string name="event_detail_calendar_unknown">Unknown calendar</string>
<string name="event_detail_title">Title</string> <string name="event_detail_title">Title</string>
<string name="event_detail_location">Location</string> <string name="event_detail_location">Location</string>
<string name="event_detail_open_in_maps">Open in maps</string>
<string name="link_open_failed">No app can open this link</string>
<string name="event_detail_description">Description</string> <string name="event_detail_description">Description</string>
<string name="event_detail_attendees">Attendees</string> <string name="event_detail_attendees">Attendees</string>
<string name="event_detail_recurrence">Recurrence</string> <string name="event_detail_recurrence">Recurrence</string>
@@ -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 <https://teams.microsoft.com/l/meetup-join/x>"
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()
}
}
@@ -0,0 +1,2 @@
إصلاحات
• النقر على رابط في موقع الحدث يفتحه الآن في المتصفح بدلًا من تطبيق الخرائط. النقر على العنوان ما زال يفتح الخرائط.
@@ -0,0 +1,2 @@
Выпраўлена
• Націск на спасылку ў месцы падзеі адкрывае яе ў браўзеры, а не ў праграме карт. Націск на адрас па-ранейшаму адкрывае карты.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -0,0 +1,2 @@
修正
• 予定の場所にあるリンクをタップすると、地図アプリではなくブラウザで開くようになりました。住所をタップすると引き続き地図が開きます。
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -0,0 +1,2 @@
Исправлено
• Нажатие на ссылку в месте события открывает её в браузере, а не в приложении карт. Нажатие на адрес по-прежнему открывает карты.
@@ -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.
@@ -0,0 +1,2 @@
修复
• 点按日程地点中的链接现在会在浏览器中打开,而不是地图应用。点按地址仍会打开地图。