Compare commits

..
6 changed files with 93 additions and 6 deletions

No files matched your search

@@ -5,6 +5,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
@@ -18,7 +19,9 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.reminders.reminderMapQuery
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.android.resolvableMapsSearchIntent
import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek
@@ -110,6 +113,11 @@ class ReminderNotifier @Inject constructor(
context.getString(R.string.reminder_action_dismiss),
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
)
.apply {
mapIntent(alert)?.let {
addAction(R.drawable.ic_notification_map, context.getString(R.string.event_detail_open_in_maps), it)
}
}
.build()
val manager = NotificationManagerCompat.from(context)
cancelLegacy(alert)
@@ -181,6 +189,24 @@ class ReminderNotifier @Inject constructor(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/**
* Opens [alert]'s location in maps like the detail screen does, started
* directly (no receiver trampoline). Leaves the notification up for a later
* snooze or dismiss. Null without a place to look up (a bare meeting link
* isn't one) or an app to open it.
*/
private fun mapIntent(alert: ReminderAlert): PendingIntent? {
val query = reminderMapQuery(alert.location) ?: return null
val intent = context.resolvableMapsSearchIntent(query) ?: return null
return PendingIntent.getActivity(
context,
// An implicit activity intent: filterEquals already keeps it apart from the others.
/* requestCode = */ 0,
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** Channel creation is idempotent; re-running refreshes the localized name. */
private fun ensureChannel() {
val channel = NotificationChannel(
@@ -0,0 +1,14 @@
package de.jeanlucmakiola.calendula.domain.reminders
// Same link shape the detail screen linkifies (ui/detail/WebLinks.kt).
private val webLinkRegex = Regex("""https?://[^\s<>"]+""", RegexOption.IGNORE_CASE)
/**
* What a reminder's "Open in maps" button looks up for [location] — the same
* text the detail screen's location card opens in maps — or null when there is
* no place to find: blank, or nothing but web (meeting) links.
*/
fun reminderMapQuery(location: String?): String? {
val trimmed = location?.trim()?.takeIf { it.isNotEmpty() } ?: return null
return trimmed.takeIf { webLinkRegex.replace(it, "").any(Char::isLetterOrDigit) }
}
@@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.android.mapsSearchIntents
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -884,9 +885,6 @@ private fun startOrSayNoApp(context: Context, intent: Intent) {
/** 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"))
if (runCatching { context.startActivity(geo) }.isFailure) {
openWebLink(context, "https://www.google.com/maps/search/?api=1&query=$encoded")
}
val opened = mapsSearchIntents(query).any { runCatching { context.startActivity(it) }.isSuccess }
if (!opened) Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Monochrome notification-action mark: Material "place" glyph. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ReminderMapQueryTest {
@Test
fun `an address is looked up as written`() {
assertThat(reminderMapQuery(" Main St 1, Berlin ")).isEqualTo("Main St 1, Berlin")
}
@Test
fun `no location means no lookup`() {
assertThat(reminderMapQuery(null)).isNull()
assertThat(reminderMapQuery(" ")).isNull()
}
@Test
fun `a bare meeting link is not a place`() {
assertThat(reminderMapQuery("https://meet.google.com/abc-defg-hij")).isNull()
assertThat(reminderMapQuery("HTTP://example.com")).isNull()
assertThat(reminderMapQuery("<https://teams.microsoft.com/l/meetup-join/x>")).isNull()
assertThat(reminderMapQuery("https://a.example\nhttps://b.example")).isNull()
}
@Test
fun `dotted place names are still places`() {
assertThat(reminderMapQuery("Hauptstr. 12/3, Wien")).isEqualTo("Hauptstr. 12/3, Wien")
assertThat(reminderMapQuery("St.Gallen/Zentrum")).isEqualTo("St.Gallen/Zentrum")
}
@Test
fun `a place with a link beside it is still looked up`() {
assertThat(reminderMapQuery("Room 4, https://example.com/map")).isEqualTo("Room 4, https://example.com/map")
}
}