Inline location links, harden link handling (#357)
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -211,6 +211,8 @@
|
||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||
<string name="event_detail_title">Title</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_attendees">Attendees</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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user