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.NFC" />
<!-- Permissions required for alarms -->
<uses-permission
android:name="android.permission.WAKE_LOCK"
android:maxSdkVersion="25" />
<!-- Permission required for alarms -->
<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 -->
<uses-feature
@ -123,11 +117,6 @@
</intent-filter>
</receiver>
<service
android:name=".services.AlarmIntentService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<provider
android:name=".providers.BookmarksExportProvider"
android:authorities="${applicationId}.bookmarks"

View file

@ -1,86 +1,332 @@
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.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.services.AlarmIntentService
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.receivers.AlarmReceiver
import be.digitalia.fosdem.settings.UserSettingsProvider
import be.digitalia.fosdem.utils.BackgroundWorkScope
import be.digitalia.fosdem.utils.roomNameToResourceName
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
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.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
class AppAlarmManager @Inject constructor(
@ApplicationContext private val context: Context,
private val userSettingsProvider: UserSettingsProvider
@ApplicationContext private val context: Context,
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 {
// Skip initial values and only act on changes
BackgroundWorkScope.launch {
// Skip initial value and only act on changes
userSettingsProvider.isNotificationsEnabled.drop(1).collect { isEnabled ->
onEnabledChanged(isEnabled)
}
}
BackgroundWorkScope.launch {
userSettingsProvider.notificationsDelayInMillis.drop(1).collect {
if (userSettingsProvider.isNotificationsEnabled.first()) {
startUpdateAlarms()
if (isNotificationsEnabled()) {
updateAlarms()
}
}
}
}
suspend fun onBootCompleted() {
onEnabledChanged(userSettingsProvider.isNotificationsEnabled.first())
onEnabledChanged(isNotificationsEnabled())
}
suspend fun onScheduleRefreshed() {
if (userSettingsProvider.isNotificationsEnabled.first()) {
startUpdateAlarms()
if (isNotificationsEnabled()) {
updateAlarms()
}
}
suspend fun onBookmarksAdded(alarmInfos: List<AlarmInfo>) {
if (userSettingsProvider.isNotificationsEnabled.first()) {
val arrayList = if (alarmInfos is ArrayList<AlarmInfo>) alarmInfos else ArrayList(alarmInfos)
val serviceIntent = Intent(AlarmIntentService.ACTION_ADD_BOOKMARKS)
.putParcelableArrayListExtra(AlarmIntentService.EXTRA_ALARM_INFOS, arrayList)
AlarmIntentService.enqueueWork(context, serviceIntent)
if (alarmInfos.isEmpty() || !isNotificationsEnabled()) return
queueMutex.withLock {
val delay = userSettingsProvider.notificationsDelayInMillis.first()
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) {
if (userSettingsProvider.isNotificationsEnabled.first()) {
val serviceIntent = Intent(AlarmIntentService.ACTION_REMOVE_BOOKMARKS)
.putExtra(AlarmIntentService.EXTRA_EVENT_IDS, eventIds)
AlarmIntentService.enqueueWork(context, serviceIntent)
if (eventIds.isEmpty() || !isNotificationsEnabled()) return
queueMutex.withLock {
// Cancel matching alarms, might they exist or not
for (eventId in eventIds) {
alarmManager.cancel(getAlarmPendingIntent(eventId))
}
}
}
private fun onEnabledChanged(isEnabled: Boolean) {
if (isEnabled) {
startUpdateAlarms()
suspend fun notifyEvent(eventId: Long) {
scheduleDao.getEvent(eventId)?.let { event ->
NotificationManagerCompat.from(context)
.notify(eventId.toInt(), buildNotification(event))
}
}
private suspend fun onEnabledChanged(isEnabled: Boolean) {
return if (isEnabled) {
updateAlarms()
} else {
startDisableAlarms()
disableAlarms()
}
}
private fun startUpdateAlarms() {
val serviceIntent = Intent(AlarmIntentService.ACTION_UPDATE_ALARMS)
AlarmIntentService.enqueueWork(context, serviceIntent)
private suspend fun updateAlarms() {
queueMutex.withLock {
// 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() {
val serviceIntent = Intent(AlarmIntentService.ACTION_DISABLE_ALARMS)
AlarmIntentService.enqueueWork(context, serviceIntent)
private suspend fun disableAlarms() {
queueMutex.withLock {
// 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.RoomDatabase
import androidx.room.TypeConverters
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.converters.GlobalTypeConverters
import be.digitalia.fosdem.db.entities.Bookmark
import be.digitalia.fosdem.db.entities.EventEntity
@ -28,5 +27,4 @@ abstract class AppDatabase : RoomDatabase() {
// Manually injected fields, used by Daos
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.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.TypeConverters
import androidx.room.withTransaction
import be.digitalia.fosdem.db.converters.NonNullInstantTypeConverters
import be.digitalia.fosdem.db.entities.Bookmark
import be.digitalia.fosdem.model.AlarmInfo
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.utils.BackgroundWorkScope
import kotlinx.coroutines.launch
import java.time.Instant
@Dao
@ -50,44 +48,33 @@ abstract class BookmarksDao(private val appDatabase: AppDatabase) {
GROUP BY e.id
ORDER BY e.start_time ASC""")
@WorkerThread
abstract fun getBookmarks(): Array<Event>
abstract fun getBookmarks(): List<Event>
@Query("""SELECT b.event_id, e.start_time
FROM bookmarks b
JOIN events e ON b.event_id = e.id
WHERE e.start_time > :minStartTime
ORDER BY e.start_time ASC""")
@WorkerThread
@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")
abstract fun getBookmarkStatus(event: Event): LiveData<Boolean>
fun addBookmarkAsync(event: Event) {
BackgroundWorkScope.launch {
val ids = addBookmarksInternal(listOf(Bookmark(event.id)))
if (ids[0] != -1L) {
appDatabase.alarmManager.onBookmarksAdded(listOf(AlarmInfo(eventId = event.id, startTime = event.startTime)))
}
}
suspend fun addBookmark(event: Event): AlarmInfo? {
val ids = addBookmarksInternal(listOf(Bookmark(event.id)))
return if (ids[0] != -1L) AlarmInfo(event.id, event.startTime) else null
}
fun addBookmarksAsync(eventIds: LongArray) {
BackgroundWorkScope.launch {
appDatabase.withTransaction {
// Get AlarmInfos first to filter out non-existing items
val alarmInfos = getAlarmInfos(eventIds)
alarmInfos.isNotEmpty() || return@withTransaction
@Transaction
open suspend fun addBookmarks(eventIds: LongArray): List<AlarmInfo> {
// Get AlarmInfos first to filter out non-existing items
val alarmInfos = getAlarmInfos(eventIds)
alarmInfos.isNotEmpty() || return emptyList()
val ids = addBookmarksInternal(alarmInfos.map { Bookmark(it.eventId) })
// Filter out items that were already in bookmarks
val addedAlarmInfos = alarmInfos.filterIndexed { index, _ -> ids[index] != -1L }
if (addedAlarmInfos.isNotEmpty()) {
appDatabase.alarmManager.onBookmarksAdded(addedAlarmInfos)
}
}
}
val ids = addBookmarksInternal(alarmInfos.map { Bookmark(it.eventId) })
// Filter out items that were already in bookmarks
return alarmInfos.filterIndexed { index, _ -> ids[index] != -1L }
}
@Query("""SELECT id as event_id, start_time
@ -99,18 +86,6 @@ abstract class BookmarksDao(private val appDatabase: AppDatabase) {
@Insert(onConflict = OnConflictStrategy.IGNORE)
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)")
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
insertPersons(persons)
val eventsToPersons = Array(persons.size) {
EventToPerson(eventId, persons[it].id)
}
val eventsToPersons = persons.map { EventToPerson(eventId, it.id) }
insertEventsToPersons(eventsToPersons)
insertLinks(details.links)
@ -175,7 +173,7 @@ abstract class ScheduleDao(private val appDatabase: AppDatabase) {
protected abstract fun insertPersons(persons: List<Person>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
protected abstract fun insertEventsToPersons(eventsToPersons: Array<EventToPerson>)
protected abstract fun insertEventsToPersons(eventsToPersons: List<EventToPerson>)
@Insert
protected abstract fun insertLinks(links: List<Link>)

View file

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

View file

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

View file

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

View file

@ -5,10 +5,9 @@ import android.content.Context
import android.content.Intent
import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.services.AlarmIntentService
import be.digitalia.fosdem.utils.BackgroundWorkScope
import be.digitalia.fosdem.utils.goAsync
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@ -24,16 +23,14 @@ class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_NOTIFY_EVENT -> {
val serviceIntent = Intent(ACTION_NOTIFY_EVENT)
.setData(intent.data)
AlarmIntentService.enqueueWork(context, serviceIntent)
}
Intent.ACTION_BOOT_COMPLETED -> {
BackgroundWorkScope.launch {
alarmManager.onBootCompleted()
ACTION_NOTIFY_EVENT -> intent.dataString?.toLongOrNull()?.let { eventId ->
goAsync(BackgroundWorkScope) {
alarmManager.notifyEvent(eventId)
}
}
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.map
import androidx.lifecycle.switchMap
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.model.BookmarkStatus
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.utils.BackgroundWorkScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@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 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
if (event != null && currentStatus != null && firstResultReceived) {
if (currentStatus.isBookmarked) {
bookmarksDao.removeBookmarkAsync(event)
removeBookmark(event)
} 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.switchMap
import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.alarms.AppAlarmManager
import be.digitalia.fosdem.db.BookmarksDao
import be.digitalia.fosdem.db.ScheduleDao
import be.digitalia.fosdem.livedata.LiveDataFactory
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.parsers.ExportedBookmarksParser
import be.digitalia.fosdem.utils.BackgroundWorkScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
@ -26,6 +29,7 @@ import javax.inject.Inject
class BookmarksViewModel @Inject constructor(
private val bookmarksDao: BookmarksDao,
private val scheduleDao: ScheduleDao,
private val alarmManager: AppAlarmManager,
private val application: Application
) : ViewModel() {
@ -52,7 +56,11 @@ class BookmarksViewModel @Inject constructor(
}
fun removeBookmarks(eventIds: LongArray) {
bookmarksDao.removeBookmarksAsync(eventIds)
BackgroundWorkScope.launch {
if (bookmarksDao.removeBookmarks(eventIds) > 0) {
alarmManager.onBookmarksRemoved(eventIds)
}
}
}
@Suppress("BlockingMethodInNonBlockingContext")

View file

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