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:
parent
fb58598937
commit
61f8e2fca3
14 changed files with 386 additions and 409 deletions
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
|
||||||
}
|
}
|
|
@ -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
|
||||||
}
|
}
|
|
@ -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>)
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in a new issue