1
0
Fork 0
mirror of https://github.com/MatomoCamp/matomocamp-companion-android.git synced 2024-09-19 16:13:46 +02:00

Refactor AppAlarmManager (#75)

Remove AlarmIntentService (which was based on deprecated JobIntentService) and use use simple coroutines queued using a Mutex instead.
The execution time is so quick that there is no need to create expedited background Jobs for the alarm scheduling tasks.
Use BroadcastReceiver.goAsync() to build the notification asynchronously from a coroutine without the need to launch a Service or acquiring a special wake lock.
This commit is contained in:
Christophe Beyls 2022-01-08 22:58:26 +01:00 committed by GitHub
parent fb58598937
commit 61f8e2fca3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 386 additions and 409 deletions

View file

@ -8,14 +8,8 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<!-- Permissions required for alarms --> <!-- Permission required for alarms -->
<uses-permission
android:name="android.permission.WAKE_LOCK"
android:maxSdkVersion="25" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission
android:name="android.permission.VIBRATE"
android:maxSdkVersion="18" />
<!-- Make touch screen optional since all screens can be used with a pad --> <!-- Make touch screen optional since all screens can be used with a pad -->
<uses-feature <uses-feature
@ -123,11 +117,6 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<service
android:name=".services.AlarmIntentService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<provider <provider
android:name=".providers.BookmarksExportProvider" android:name=".providers.BookmarksExportProvider"
android:authorities="${applicationId}.bookmarks" android:authorities="${applicationId}.bookmarks"

View file

@ -1,86 +1,332 @@
package be.digitalia.fosdem.alarms package be.digitalia.fosdem.alarms
import android.annotation.SuppressLint
import android.app.AlarmManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.annotation.RequiresApi
import androidx.core.app.AlarmManagerCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.core.text.buildSpannedString
import androidx.core.text.italic
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.EventDetailsActivity
import be.digitalia.fosdem.activities.MainActivity
import be.digitalia.fosdem.activities.RoomImageDialogActivity
import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.AlarmInfo import be.digitalia.fosdem.model.AlarmInfo
import be.digitalia.fosdem.services.AlarmIntentService import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.receivers.AlarmReceiver
import be.digitalia.fosdem.settings.UserSettingsProvider import be.digitalia.fosdem.settings.UserSettingsProvider
import be.digitalia.fosdem.utils.BackgroundWorkScope import be.digitalia.fosdem.utils.BackgroundWorkScope
import be.digitalia.fosdem.utils.roomNameToResourceName
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* This class monitors bookmarks and settings changes to dispatch alarm update work to AlarmIntentService. * This class monitors incoming broadcasts and bookmarks and settings changes to dispatch background alarm update work.
*/ */
@Singleton @Singleton
class AppAlarmManager @Inject constructor( class AppAlarmManager @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val userSettingsProvider: UserSettingsProvider private val userSettingsProvider: UserSettingsProvider,
private val bookmarksDao: BookmarksDao,
private val scheduleDao: ScheduleDao
) { ) {
private val alarmManager: AlarmManager by lazy(LazyThreadSafetyMode.NONE) {
requireNotNull(context.getSystemService())
}
private val queueMutex = Mutex()
private suspend fun isNotificationsEnabled(): Boolean =
userSettingsProvider.isNotificationsEnabled.first()
init { init {
// Skip initial values and only act on changes
BackgroundWorkScope.launch { BackgroundWorkScope.launch {
// Skip initial value and only act on changes
userSettingsProvider.isNotificationsEnabled.drop(1).collect { isEnabled -> userSettingsProvider.isNotificationsEnabled.drop(1).collect { isEnabled ->
onEnabledChanged(isEnabled) onEnabledChanged(isEnabled)
} }
} }
BackgroundWorkScope.launch { BackgroundWorkScope.launch {
userSettingsProvider.notificationsDelayInMillis.drop(1).collect { userSettingsProvider.notificationsDelayInMillis.drop(1).collect {
if (userSettingsProvider.isNotificationsEnabled.first()) { if (isNotificationsEnabled()) {
startUpdateAlarms() updateAlarms()
} }
} }
} }
} }
suspend fun onBootCompleted() { suspend fun onBootCompleted() {
onEnabledChanged(userSettingsProvider.isNotificationsEnabled.first()) onEnabledChanged(isNotificationsEnabled())
} }
suspend fun onScheduleRefreshed() { suspend fun onScheduleRefreshed() {
if (userSettingsProvider.isNotificationsEnabled.first()) { if (isNotificationsEnabled()) {
startUpdateAlarms() updateAlarms()
} }
} }
suspend fun onBookmarksAdded(alarmInfos: List<AlarmInfo>) { suspend fun onBookmarksAdded(alarmInfos: List<AlarmInfo>) {
if (userSettingsProvider.isNotificationsEnabled.first()) { if (alarmInfos.isEmpty() || !isNotificationsEnabled()) return
val arrayList = if (alarmInfos is ArrayList<AlarmInfo>) alarmInfos else ArrayList(alarmInfos)
val serviceIntent = Intent(AlarmIntentService.ACTION_ADD_BOOKMARKS) queueMutex.withLock {
.putParcelableArrayListExtra(AlarmIntentService.EXTRA_ALARM_INFOS, arrayList) val delay = userSettingsProvider.notificationsDelayInMillis.first()
AlarmIntentService.enqueueWork(context, serviceIntent) val now = System.currentTimeMillis()
var isFirstAlarm = true
for ((eventId, startTime) in alarmInfos) {
// Only schedule future events. If they start before the delay, the alarm will go off immediately
if (startTime != null && startTime.toEpochMilli() >= now) {
if (isFirstAlarm) {
setAlarmReceiverEnabled(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(context)
}
isFirstAlarm = false
}
AlarmManagerCompat.setExactAndAllowWhileIdle(
alarmManager, AlarmManager.RTC_WAKEUP,
startTime.toEpochMilli() - delay, getAlarmPendingIntent(eventId)
)
}
}
} }
} }
suspend fun onBookmarksRemoved(eventIds: LongArray) { suspend fun onBookmarksRemoved(eventIds: LongArray) {
if (userSettingsProvider.isNotificationsEnabled.first()) { if (eventIds.isEmpty() || !isNotificationsEnabled()) return
val serviceIntent = Intent(AlarmIntentService.ACTION_REMOVE_BOOKMARKS)
.putExtra(AlarmIntentService.EXTRA_EVENT_IDS, eventIds) queueMutex.withLock {
AlarmIntentService.enqueueWork(context, serviceIntent) // Cancel matching alarms, might they exist or not
for (eventId in eventIds) {
alarmManager.cancel(getAlarmPendingIntent(eventId))
}
} }
} }
private fun onEnabledChanged(isEnabled: Boolean) { suspend fun notifyEvent(eventId: Long) {
if (isEnabled) { scheduleDao.getEvent(eventId)?.let { event ->
startUpdateAlarms() NotificationManagerCompat.from(context)
.notify(eventId.toInt(), buildNotification(event))
}
}
private suspend fun onEnabledChanged(isEnabled: Boolean) {
return if (isEnabled) {
updateAlarms()
} else { } else {
startDisableAlarms() disableAlarms()
} }
} }
private fun startUpdateAlarms() { private suspend fun updateAlarms() {
val serviceIntent = Intent(AlarmIntentService.ACTION_UPDATE_ALARMS) queueMutex.withLock {
AlarmIntentService.enqueueWork(context, serviceIntent) // Create/update all alarms
val delay = userSettingsProvider.notificationsDelayInMillis.first()
val now = System.currentTimeMillis()
var hasAlarms = false
for (info in bookmarksDao.getBookmarksAlarmInfo(Instant.EPOCH)) {
val startTime = info.startTime
val notificationTime =
if (startTime == null) -1L else startTime.toEpochMilli() - delay
val pi = getAlarmPendingIntent(info.eventId)
if (notificationTime < now) {
// Cancel pending alarms that are now scheduled in the past, if any
alarmManager.cancel(pi)
} else {
AlarmManagerCompat.setExactAndAllowWhileIdle(
alarmManager, AlarmManager.RTC_WAKEUP, notificationTime, pi
)
hasAlarms = true
}
}
setAlarmReceiverEnabled(hasAlarms)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasAlarms) {
createNotificationChannel(context)
}
}
} }
private fun startDisableAlarms() { private suspend fun disableAlarms() {
val serviceIntent = Intent(AlarmIntentService.ACTION_DISABLE_ALARMS) queueMutex.withLock {
AlarmIntentService.enqueueWork(context, serviceIntent) // Cancel alarms of every bookmark in the future
for (info in bookmarksDao.getBookmarksAlarmInfo(Instant.now())) {
alarmManager.cancel(getAlarmPendingIntent(info.eventId))
}
setAlarmReceiverEnabled(false)
}
}
@SuppressLint("InlinedApi")
private fun getAlarmPendingIntent(eventId: Long): PendingIntent {
val intent = Intent(context, AlarmReceiver::class.java)
.setAction(AlarmReceiver.ACTION_NOTIFY_EVENT)
.setData(eventId.toString().toUri())
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
/**
* Allows disabling the Alarm Receiver so the app is not loaded at boot when it's not necessary.
*/
private fun setAlarmReceiverEnabled(isEnabled: Boolean) {
val componentName = ComponentName(context, AlarmReceiver::class.java)
val flag =
if (isEnabled) PackageManager.COMPONENT_ENABLED_STATE_DEFAULT else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
context.packageManager.setComponentEnabledSetting(
componentName, flag, PackageManager.DONT_KILL_APP
)
}
@SuppressLint("InlinedApi")
private suspend fun buildNotification(event: Event): Notification {
val eventPendingIntent = TaskStackBuilder
.create(context)
.addNextIntent(Intent(context, MainActivity::class.java))
.addNextIntent(
Intent(context, EventDetailsActivity::class.java)
.setData(event.id.toString().toUri())
)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
var defaultFlags = Notification.DEFAULT_SOUND
val isVibrationEnabled = userSettingsProvider.isNotificationsVibrationEnabled.first()
if (isVibrationEnabled) {
defaultFlags = defaultFlags or Notification.DEFAULT_VIBRATE
}
val personsSummary = event.personsSummary
val trackName = event.track.name
val subTitle = event.subTitle
val contentText: String
val bigText: CharSequence?
if (personsSummary.isNullOrEmpty()) {
contentText = trackName
bigText = subTitle
} else {
contentText = "$trackName - $personsSummary"
bigText = buildSpannedString {
if (!subTitle.isNullOrEmpty()) {
append(subTitle)
append('\n')
}
italic {
append(personsSummary)
}
}
}
val notificationColor = ContextCompat.getColor(context, R.color.light_color_primary)
val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL)
.setSmallIcon(R.drawable.ic_stat_fosdem)
.setColor(notificationColor)
.setWhen(event.startTime?.toEpochMilli() ?: System.currentTimeMillis())
.setContentTitle(event.title)
.setContentText(contentText)
.setStyle(NotificationCompat.BigTextStyle().bigText(bigText).setSummaryText(trackName))
.setContentInfo(event.roomName)
.setContentIntent(eventPendingIntent)
.setAutoCancel(true)
.setDefaults(defaultFlags)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
// Blink the LED with FOSDEM color if enabled in the options
if (userSettingsProvider.isNotificationsLedEnabled.first()) {
notificationBuilder.setLights(notificationColor, 1000, 5000)
}
// Android Wear extensions
val wearableExtender = NotificationCompat.WearableExtender()
// Add an optional action button to show the room map image
val roomName = event.roomName
val roomImageResId = roomName?.let {
context.resources.getIdentifier(
roomNameToResourceName(it), "drawable", context.packageName
)
} ?: 0
if (roomName != null && roomImageResId != 0) {
// The room name is the unique Id of a RoomImageDialogActivity
val mapIntent = Intent(context, RoomImageDialogActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(roomName.toUri())
.putExtra(RoomImageDialogActivity.EXTRA_ROOM_NAME, roomName)
.putExtra(RoomImageDialogActivity.EXTRA_ROOM_IMAGE_RESOURCE_ID, roomImageResId)
val mapPendingIntent = PendingIntent.getActivity(
context, 0, mapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val mapTitle = context.getString(R.string.room_map)
notificationBuilder.addAction(
NotificationCompat.Action(
R.drawable.ic_place_white_24dp,
mapTitle,
mapPendingIntent
)
)
// Use bigger action icon for wearable notification
wearableExtender.addAction(
NotificationCompat.Action(
R.drawable.ic_place_white_wear,
mapTitle,
mapPendingIntent
)
)
}
notificationBuilder.extend(wearableExtender)
return notificationBuilder.build()
}
companion object {
private const val NOTIFICATION_CHANNEL = "event_alarms"
@RequiresApi(api = Build.VERSION_CODES.O)
private fun createNotificationChannel(context: Context) {
val notificationManager: NotificationManager? = context.getSystemService()
val channel = NotificationChannel(
NOTIFICATION_CHANNEL,
context.getString(R.string.notification_events_channel_name),
NotificationManager.IMPORTANCE_HIGH
).apply {
setShowBadge(false)
lightColor = ContextCompat.getColor(context, R.color.light_color_primary)
enableVibration(true)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
notificationManager?.createNotificationChannel(channel)
}
@RequiresApi(api = Build.VERSION_CODES.O)
fun startChannelNotificationSettingsActivity(context: Context) {
createNotificationChannel(context)
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
context.startActivity(intent)
}
} }
} }

View file

@ -5,7 +5,6 @@ import androidx.datastore.preferences.core.Preferences
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.converters.GlobalTypeConverters import be.digitalia.fosdem.db.converters.GlobalTypeConverters
import be.digitalia.fosdem.db.entities.Bookmark import be.digitalia.fosdem.db.entities.Bookmark
import be.digitalia.fosdem.db.entities.EventEntity import be.digitalia.fosdem.db.entities.EventEntity
@ -28,5 +27,4 @@ abstract class AppDatabase : RoomDatabase() {
// Manually injected fields, used by Daos // Manually injected fields, used by Daos
lateinit var dataStore: DataStore<Preferences> lateinit var dataStore: DataStore<Preferences>
lateinit var alarmManager: AppAlarmManager
} }

View file

@ -6,14 +6,12 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.withTransaction
import be.digitalia.fosdem.db.converters.NonNullInstantTypeConverters import be.digitalia.fosdem.db.converters.NonNullInstantTypeConverters
import be.digitalia.fosdem.db.entities.Bookmark import be.digitalia.fosdem.db.entities.Bookmark
import be.digitalia.fosdem.model.AlarmInfo import be.digitalia.fosdem.model.AlarmInfo
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.utils.BackgroundWorkScope
import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
@Dao @Dao
@ -50,44 +48,33 @@ abstract class BookmarksDao(private val appDatabase: AppDatabase) {
GROUP BY e.id GROUP BY e.id
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
@WorkerThread @WorkerThread
abstract fun getBookmarks(): Array<Event> abstract fun getBookmarks(): List<Event>
@Query("""SELECT b.event_id, e.start_time @Query("""SELECT b.event_id, e.start_time
FROM bookmarks b FROM bookmarks b
JOIN events e ON b.event_id = e.id JOIN events e ON b.event_id = e.id
WHERE e.start_time > :minStartTime WHERE e.start_time > :minStartTime
ORDER BY e.start_time ASC""") ORDER BY e.start_time ASC""")
@WorkerThread
@TypeConverters(NonNullInstantTypeConverters::class) @TypeConverters(NonNullInstantTypeConverters::class)
abstract fun getBookmarksAlarmInfo(minStartTime: Instant): Array<AlarmInfo> abstract suspend fun getBookmarksAlarmInfo(minStartTime: Instant): List<AlarmInfo>
@Query("SELECT COUNT(*) FROM bookmarks WHERE event_id = :event") @Query("SELECT COUNT(*) FROM bookmarks WHERE event_id = :event")
abstract fun getBookmarkStatus(event: Event): LiveData<Boolean> abstract fun getBookmarkStatus(event: Event): LiveData<Boolean>
fun addBookmarkAsync(event: Event) { suspend fun addBookmark(event: Event): AlarmInfo? {
BackgroundWorkScope.launch { val ids = addBookmarksInternal(listOf(Bookmark(event.id)))
val ids = addBookmarksInternal(listOf(Bookmark(event.id))) return if (ids[0] != -1L) AlarmInfo(event.id, event.startTime) else null
if (ids[0] != -1L) {
appDatabase.alarmManager.onBookmarksAdded(listOf(AlarmInfo(eventId = event.id, startTime = event.startTime)))
}
}
} }
fun addBookmarksAsync(eventIds: LongArray) { @Transaction
BackgroundWorkScope.launch { open suspend fun addBookmarks(eventIds: LongArray): List<AlarmInfo> {
appDatabase.withTransaction { // Get AlarmInfos first to filter out non-existing items
// Get AlarmInfos first to filter out non-existing items val alarmInfos = getAlarmInfos(eventIds)
val alarmInfos = getAlarmInfos(eventIds) alarmInfos.isNotEmpty() || return emptyList()
alarmInfos.isNotEmpty() || return@withTransaction
val ids = addBookmarksInternal(alarmInfos.map { Bookmark(it.eventId) }) val ids = addBookmarksInternal(alarmInfos.map { Bookmark(it.eventId) })
// Filter out items that were already in bookmarks // Filter out items that were already in bookmarks
val addedAlarmInfos = alarmInfos.filterIndexed { index, _ -> ids[index] != -1L } return alarmInfos.filterIndexed { index, _ -> ids[index] != -1L }
if (addedAlarmInfos.isNotEmpty()) {
appDatabase.alarmManager.onBookmarksAdded(addedAlarmInfos)
}
}
}
} }
@Query("""SELECT id as event_id, start_time @Query("""SELECT id as event_id, start_time
@ -99,18 +86,6 @@ abstract class BookmarksDao(private val appDatabase: AppDatabase) {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
protected abstract suspend fun addBookmarksInternal(bookmarks: List<Bookmark>): LongArray protected abstract suspend fun addBookmarksInternal(bookmarks: List<Bookmark>): LongArray
fun removeBookmarkAsync(event: Event) {
removeBookmarksAsync(longArrayOf(event.id))
}
fun removeBookmarksAsync(eventIds: LongArray) {
BackgroundWorkScope.launch {
if (removeBookmarksInternal(eventIds) > 0) {
appDatabase.alarmManager.onBookmarksRemoved(eventIds)
}
}
}
@Query("DELETE FROM bookmarks WHERE event_id IN (:eventIds)") @Query("DELETE FROM bookmarks WHERE event_id IN (:eventIds)")
protected abstract suspend fun removeBookmarksInternal(eventIds: LongArray): Int abstract suspend fun removeBookmarks(eventIds: LongArray): Int
} }

View file

@ -141,9 +141,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
val persons = details.persons val persons = details.persons
insertPersons(persons) insertPersons(persons)
val eventsToPersons = Array(persons.size) { val eventsToPersons = persons.map { EventToPerson(eventId, it.id) }
EventToPerson(eventId, persons[it].id)
}
insertEventsToPersons(eventsToPersons) insertEventsToPersons(eventsToPersons)
insertLinks(details.links) insertLinks(details.links)
@ -175,7 +173,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
protected abstract fun insertPersons(persons: List<Person>) protected abstract fun insertPersons(persons: List<Person>)
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
protected abstract fun insertEventsToPersons(eventsToPersons: Array<EventToPerson>) protected abstract fun insertEventsToPersons(eventsToPersons: List<EventToPerson>)
@Insert @Insert
protected abstract fun insertLinks(links: List<Link>) protected abstract fun insertLinks(links: List<Link>)

View file

@ -118,7 +118,7 @@ class EventDetailsFragment : Fragment(R.layout.fragment_event_details) {
if (roomImageResId != 0) { if (roomImageResId != 0) {
roomText[0, roomText.length] = object : ClickableSpan() { roomText[0, roomText.length] = object : ClickableSpan() {
override fun onClick(view: View) { override fun onClick(view: View) {
parentFragmentManager.commit { parentFragmentManager.commit(allowStateLoss = true) {
add<RoomImageDialogFragment>(RoomImageDialogFragment.TAG, add<RoomImageDialogFragment>(RoomImageDialogFragment.TAG,
args = RoomImageDialogFragment.createArguments(roomName, roomImageResId)) args = RoomImageDialogFragment.createArguments(roomName, roomImageResId))
} }

View file

@ -11,7 +11,7 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import be.digitalia.fosdem.BuildConfig import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.R import be.digitalia.fosdem.R
import be.digitalia.fosdem.services.AlarmIntentService import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.settings.PreferenceKeys import be.digitalia.fosdem.settings.PreferenceKeys
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -29,7 +29,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
@RequiresApi(api = Build.VERSION_CODES.O) @RequiresApi(api = Build.VERSION_CODES.O)
private fun setupNotificationsChannel() { private fun setupNotificationsChannel() {
findPreference<Preference>(PreferenceKeys.NOTIFICATIONS_CHANNEL)?.onPreferenceClickListener = Preference.OnPreferenceClickListener { findPreference<Preference>(PreferenceKeys.NOTIFICATIONS_CHANNEL)?.onPreferenceClickListener = Preference.OnPreferenceClickListener {
AlarmIntentService.startChannelNotificationSettingsActivity(requireContext()) AppAlarmManager.startChannelNotificationSettingsActivity(requireContext())
true true
} }
} }

View file

@ -10,7 +10,6 @@ import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.AppDatabase import be.digitalia.fosdem.db.AppDatabase
import be.digitalia.fosdem.db.BookmarksDao import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
@ -39,26 +38,26 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideAppDatabase(@ApplicationContext context: Context, fun provideAppDatabase(
@Named("Database") dataStore: DataStore<Preferences>, @ApplicationContext context: Context,
alarmManager: AppAlarmManager): AppDatabase { @Named("Database") dataStore: DataStore<Preferences>
): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, DB_FILE) return Room.databaseBuilder(context, AppDatabase::class.java, DB_FILE)
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE) .setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.addCallback(object : RoomDatabase.Callback() { .addCallback(object : RoomDatabase.Callback() {
@WorkerThread @WorkerThread
override fun onDestructiveMigration(db: SupportSQLiteDatabase) { override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
runBlocking { runBlocking {
dataStore.edit { it.clear() } dataStore.edit { it.clear() }
}
} }
})
.build()
.also {
// Manual dependency injection
it.dataStore = dataStore
it.alarmManager = alarmManager
} }
})
.build()
.also {
// Manual dependency injection
it.dataStore = dataStore
}
} }
@Provides @Provides

View file

@ -5,10 +5,9 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import be.digitalia.fosdem.BuildConfig import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.alarms.AppAlarmManager import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.services.AlarmIntentService
import be.digitalia.fosdem.utils.BackgroundWorkScope import be.digitalia.fosdem.utils.BackgroundWorkScope
import be.digitalia.fosdem.utils.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -24,16 +23,14 @@ class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
when (intent.action) { when (intent.action) {
ACTION_NOTIFY_EVENT -> { ACTION_NOTIFY_EVENT -> intent.dataString?.toLongOrNull()?.let { eventId ->
val serviceIntent = Intent(ACTION_NOTIFY_EVENT) goAsync(BackgroundWorkScope) {
.setData(intent.data) alarmManager.notifyEvent(eventId)
AlarmIntentService.enqueueWork(context, serviceIntent)
}
Intent.ACTION_BOOT_COMPLETED -> {
BackgroundWorkScope.launch {
alarmManager.onBootCompleted()
} }
} }
Intent.ACTION_BOOT_COMPLETED -> goAsync(BackgroundWorkScope) {
alarmManager.onBootCompleted()
}
} }
} }

View file

@ -1,284 +0,0 @@
package be.digitalia.fosdem.services
import android.app.AlarmManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Typeface
import android.os.Build
import android.provider.Settings
import android.text.SpannableString
import android.text.style.StyleSpan
import androidx.annotation.RequiresApi
import androidx.annotation.WorkerThread
import androidx.core.app.AlarmManagerCompat
import androidx.core.app.JobIntentService
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.core.text.set
import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.EventDetailsActivity
import be.digitalia.fosdem.activities.MainActivity
import be.digitalia.fosdem.activities.RoomImageDialogActivity
import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.AlarmInfo
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.receivers.AlarmReceiver
import be.digitalia.fosdem.settings.UserSettingsProvider
import be.digitalia.fosdem.utils.roomNameToResourceName
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.time.Instant
import javax.inject.Inject
/**
* A service to schedule or unschedule alarms in the background, keeping the app responsive.
*
* @author Christophe Beyls
*/
@AndroidEntryPoint
class AlarmIntentService : JobIntentService() {
@Inject
lateinit var userSettingsProvider: UserSettingsProvider
@Inject
lateinit var bookmarksDao: BookmarksDao
@Inject
lateinit var scheduleDao: ScheduleDao
private val alarmManager by lazy<AlarmManager> {
getSystemService()!!
}
private fun getAlarmPendingIntent(eventId: Long): PendingIntent {
val intent = Intent(this, AlarmReceiver::class.java)
.setAction(AlarmReceiver.ACTION_NOTIFY_EVENT)
.setData(eventId.toString().toUri())
return PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
override fun onHandleWork(intent: Intent) {
when (intent.action) {
ACTION_UPDATE_ALARMS -> {
// Create/update all alarms
val delay = runBlocking { userSettingsProvider.notificationsDelayInMillis.first() }
val now = System.currentTimeMillis()
var hasAlarms = false
for (info in bookmarksDao.getBookmarksAlarmInfo(Instant.EPOCH)) {
val startTime = info.startTime
val notificationTime = if (startTime == null) -1L else startTime.toEpochMilli() - delay
val pi = getAlarmPendingIntent(info.eventId)
if (notificationTime < now) {
// Cancel pending alarms that are now scheduled in the past, if any
alarmManager.cancel(pi)
} else {
AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, notificationTime, pi)
hasAlarms = true
}
}
setAlarmReceiverEnabled(hasAlarms)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasAlarms) {
createNotificationChannel(this)
}
}
ACTION_DISABLE_ALARMS -> {
// Cancel alarms of every bookmark in the future
for (info in bookmarksDao.getBookmarksAlarmInfo(Instant.now())) {
alarmManager.cancel(getAlarmPendingIntent(info.eventId))
}
setAlarmReceiverEnabled(false)
}
ACTION_ADD_BOOKMARKS -> {
val delay = runBlocking { userSettingsProvider.notificationsDelayInMillis.first() }
@Suppress("UNCHECKED_CAST")
val alarmInfos = intent.getParcelableArrayListExtra<AlarmInfo>(EXTRA_ALARM_INFOS)!!
val now = System.currentTimeMillis()
var isFirstAlarm = true
for ((eventId, startTime) in alarmInfos) {
// Only schedule future events. If they start before the delay, the alarm will go off immediately
if (startTime != null && startTime.toEpochMilli() >= now) {
if (isFirstAlarm) {
setAlarmReceiverEnabled(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(this)
}
isFirstAlarm = false
}
AlarmManagerCompat.setExactAndAllowWhileIdle(
alarmManager, AlarmManager.RTC_WAKEUP,
startTime.toEpochMilli() - delay, getAlarmPendingIntent(eventId)
)
}
}
}
ACTION_REMOVE_BOOKMARKS -> {
// Cancel matching alarms, might they exist or not
val eventIds = intent.getLongArrayExtra(EXTRA_EVENT_IDS)!!
for (eventId in eventIds) {
alarmManager.cancel(getAlarmPendingIntent(eventId))
}
}
AlarmReceiver.ACTION_NOTIFY_EVENT -> {
val eventId = intent.dataString!!.toLong()
val event = runBlocking { scheduleDao.getEvent(eventId) }
if (event != null) {
NotificationManagerCompat.from(this).notify(eventId.toInt(), buildNotification(event))
}
}
}
}
/**
* Allows disabling the Alarm Receiver so the app is not loaded at boot when it's not necessary.
*/
private fun setAlarmReceiverEnabled(isEnabled: Boolean) {
val componentName = ComponentName(this, AlarmReceiver::class.java)
val flag = if (isEnabled) PackageManager.COMPONENT_ENABLED_STATE_DEFAULT else PackageManager.COMPONENT_ENABLED_STATE_DISABLED
packageManager.setComponentEnabledSetting(componentName, flag, PackageManager.DONT_KILL_APP)
}
@WorkerThread
private fun buildNotification(event: Event): Notification {
val eventPendingIntent = TaskStackBuilder
.create(this)
.addNextIntent(Intent(this, MainActivity::class.java))
.addNextIntent(
Intent(this, EventDetailsActivity::class.java)
.setData(event.id.toString().toUri())
)
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
var defaultFlags = Notification.DEFAULT_SOUND
val isVibrationEnabled = runBlocking { userSettingsProvider.isNotificationsVibrationEnabled.first() }
if (isVibrationEnabled) {
defaultFlags = defaultFlags or Notification.DEFAULT_VIBRATE
}
val personsSummary = event.personsSummary
val trackName = event.track.name
val contentText: String
val bigText: CharSequence?
if (personsSummary.isNullOrEmpty()) {
contentText = trackName
bigText = event.subTitle
} else {
contentText = "$trackName - $personsSummary"
val subTitle = event.subTitle
val spannableBigText = if (subTitle.isNullOrEmpty()) {
SpannableString(personsSummary)
} else {
SpannableString("$subTitle\n$personsSummary")
}
// Set the persons summary in italic
spannableBigText[spannableBigText.length - personsSummary.length, spannableBigText.length] = StyleSpan(Typeface.ITALIC)
bigText = spannableBigText
}
val notificationColor = ContextCompat.getColor(this, R.color.light_color_primary)
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL)
.setSmallIcon(R.drawable.ic_stat_fosdem)
.setColor(notificationColor)
.setWhen(event.startTime?.toEpochMilli() ?: System.currentTimeMillis())
.setContentTitle(event.title)
.setContentText(contentText)
.setStyle(NotificationCompat.BigTextStyle().bigText(bigText).setSummaryText(trackName))
.setContentInfo(event.roomName)
.setContentIntent(eventPendingIntent)
.setAutoCancel(true)
.setDefaults(defaultFlags)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
// Blink the LED with FOSDEM color if enabled in the options
val isLedEnabled = runBlocking { userSettingsProvider.isNotificationsLedEnabled.first() }
if (isLedEnabled) {
notificationBuilder.setLights(notificationColor, 1000, 5000)
}
// Android Wear extensions
val wearableExtender = NotificationCompat.WearableExtender()
// Add an optional action button to show the room map image
val roomName = event.roomName
val roomImageResId = roomName?.let { resources.getIdentifier(roomNameToResourceName(it), "drawable", packageName) }
?: 0
if (roomName != null && roomImageResId != 0) {
// The room name is the unique Id of a RoomImageDialogActivity
val mapIntent = Intent(this, RoomImageDialogActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData(roomName.toUri())
.putExtra(RoomImageDialogActivity.EXTRA_ROOM_NAME, roomName)
.putExtra(RoomImageDialogActivity.EXTRA_ROOM_IMAGE_RESOURCE_ID, roomImageResId)
val mapPendingIntent = PendingIntent.getActivity(
this, 0, mapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val mapTitle = getString(R.string.room_map)
notificationBuilder.addAction(NotificationCompat.Action(R.drawable.ic_place_white_24dp, mapTitle, mapPendingIntent))
// Use bigger action icon for wearable notification
wearableExtender.addAction(NotificationCompat.Action(R.drawable.ic_place_white_wear, mapTitle, mapPendingIntent))
}
notificationBuilder.extend(wearableExtender)
return notificationBuilder.build()
}
companion object {
/**
* Unique job ID for this service.
*/
private const val JOB_ID = 1000
private const val NOTIFICATION_CHANNEL = "event_alarms"
const val ACTION_UPDATE_ALARMS = "${BuildConfig.APPLICATION_ID}.action.UPDATE_ALARMS"
const val ACTION_DISABLE_ALARMS = "${BuildConfig.APPLICATION_ID}.action.DISABLE_ALARMS"
const val ACTION_ADD_BOOKMARKS = "${BuildConfig.APPLICATION_ID}.action.ADD_BOOKMARK"
const val EXTRA_ALARM_INFOS = "alarm_info"
const val ACTION_REMOVE_BOOKMARKS = "${BuildConfig.APPLICATION_ID}.action.REMOVE_BOOKMARKS"
const val EXTRA_EVENT_IDS = "event_ids"
/**
* Convenience method for enqueuing work in to this service.
*/
fun enqueueWork(context: Context, work: Intent) {
enqueueWork(context, AlarmIntentService::class.java, JOB_ID, work)
}
@RequiresApi(api = Build.VERSION_CODES.O)
private fun createNotificationChannel(context: Context) {
val notificationManager: NotificationManager? = context.getSystemService()
val channel = NotificationChannel(NOTIFICATION_CHANNEL,
context.getString(R.string.notification_events_channel_name),
NotificationManager.IMPORTANCE_HIGH).apply {
setShowBadge(false)
lightColor = ContextCompat.getColor(context, R.color.light_color_primary)
enableVibration(true)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
notificationManager?.createNotificationChannel(channel)
}
@RequiresApi(api = Build.VERSION_CODES.O)
fun startChannelNotificationSettingsActivity(context: Context) {
createNotificationChannel(context)
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
context.startActivity(intent)
}
}
}

View file

@ -0,0 +1,20 @@
package be.digitalia.fosdem.utils
import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
inline fun BroadcastReceiver.goAsync(
coroutineScope: CoroutineScope,
crossinline block: suspend () -> Unit
) {
val result = goAsync()
coroutineScope.launch {
try {
block()
} finally {
// Always call finish(), even if the coroutineScope was cancelled
result.finish()
}
}
}

View file

@ -6,14 +6,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map import androidx.lifecycle.map
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.BookmarksDao import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.model.BookmarkStatus import be.digitalia.fosdem.model.BookmarkStatus
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.utils.BackgroundWorkScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BookmarkStatusViewModel @Inject constructor(private val bookmarksDao: BookmarksDao) : ViewModel() { class BookmarkStatusViewModel @Inject constructor(
private val bookmarksDao: BookmarksDao,
private val alarmManager: AppAlarmManager
) : ViewModel() {
private val eventLiveData = MutableLiveData<Event?>() private val eventLiveData = MutableLiveData<Event?>()
private var firstResultReceived = false private var firstResultReceived = false
@ -47,9 +53,26 @@ class BookmarkStatusViewModel @Inject constructor(private val bookmarksDao: Book
// Ignore the action if the status for the current event hasn't been received yet // Ignore the action if the status for the current event hasn't been received yet
if (event != null && currentStatus != null && firstResultReceived) { if (event != null && currentStatus != null && firstResultReceived) {
if (currentStatus.isBookmarked) { if (currentStatus.isBookmarked) {
bookmarksDao.removeBookmarkAsync(event) removeBookmark(event)
} else { } else {
bookmarksDao.addBookmarkAsync(event) addBookmark(event)
}
}
}
private fun removeBookmark(event: Event) {
val eventIds = longArrayOf(event.id)
BackgroundWorkScope.launch {
if (bookmarksDao.removeBookmarks(eventIds) > 0) {
alarmManager.onBookmarksRemoved(eventIds)
}
}
}
private fun addBookmark(event: Event) {
BackgroundWorkScope.launch {
bookmarksDao.addBookmark(event)?.let { alarmInfo ->
alarmManager.onBookmarksAdded(listOf(alarmInfo))
} }
} }
} }

View file

@ -7,13 +7,16 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import be.digitalia.fosdem.BuildConfig import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.BookmarksDao import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.livedata.LiveDataFactory import be.digitalia.fosdem.livedata.LiveDataFactory
import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.parsers.ExportedBookmarksParser import be.digitalia.fosdem.parsers.ExportedBookmarksParser
import be.digitalia.fosdem.utils.BackgroundWorkScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.buffer import okio.buffer
import okio.source import okio.source
@ -26,6 +29,7 @@ import javax.inject.Inject
class BookmarksViewModel @Inject constructor( class BookmarksViewModel @Inject constructor(
private val bookmarksDao: BookmarksDao, private val bookmarksDao: BookmarksDao,
private val scheduleDao: ScheduleDao, private val scheduleDao: ScheduleDao,
private val alarmManager: AppAlarmManager,
private val application: Application private val application: Application
) : ViewModel() { ) : ViewModel() {
@ -52,7 +56,11 @@ class BookmarksViewModel @Inject constructor(
} }
fun removeBookmarks(eventIds: LongArray) { fun removeBookmarks(eventIds: LongArray) {
bookmarksDao.removeBookmarksAsync(eventIds) BackgroundWorkScope.launch {
if (bookmarksDao.removeBookmarks(eventIds) > 0) {
alarmManager.onBookmarksRemoved(eventIds)
}
}
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")

View file

@ -6,16 +6,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import androidx.paging.PagedList import androidx.paging.PagedList
import androidx.paging.toLiveData import androidx.paging.toLiveData
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.BookmarksDao import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.db.ScheduleDao import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.model.StatusEvent import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.utils.BackgroundWorkScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ExternalBookmarksViewModel @Inject constructor( class ExternalBookmarksViewModel @Inject constructor(
scheduleDao: ScheduleDao, scheduleDao: ScheduleDao,
private val bookmarksDao: BookmarksDao private val bookmarksDao: BookmarksDao,
private val alarmManager: AppAlarmManager
) : ViewModel() { ) : ViewModel() {
private val bookmarkIdsLiveData = MutableLiveData<LongArray>() private val bookmarkIdsLiveData = MutableLiveData<LongArray>()
@ -33,6 +37,10 @@ class ExternalBookmarksViewModel @Inject constructor(
fun addAll() { fun addAll() {
val bookmarkIds = bookmarkIdsLiveData.value ?: return val bookmarkIds = bookmarkIdsLiveData.value ?: return
bookmarksDao.addBookmarksAsync(bookmarkIds) BackgroundWorkScope.launch {
bookmarksDao.addBookmarks(bookmarkIds).let { alarmInfos ->
alarmManager.onBookmarksAdded(alarmInfos)
}
}
} }
} }