Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Customizable alarms per subscription #78

Merged
merged 34 commits into from
Dec 14, 2022
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
29d747d
Added alerts section
ArnyminerZ Dec 2, 2022
c5910d1
Added allowed reminders fetching
ArnyminerZ Dec 2, 2022
c912915
Added `LifecycleViewHolder`
ArnyminerZ Dec 2, 2022
31b4e1e
Added row for custom alerts
ArnyminerZ Dec 2, 2022
1bf2b62
Added alerts section displaying
ArnyminerZ Dec 2, 2022
6917365
Added allowed reminders passing
ArnyminerZ Dec 2, 2022
2d6609d
Added `ColorPickerActivity.Contract`
ArnyminerZ Dec 2, 2022
c9bb20b
Added strings
ArnyminerZ Dec 2, 2022
7cae684
Added reminders and ignore alerts storage
ArnyminerZ Dec 2, 2022
3b77d77
Moved class declaration
ArnyminerZ Dec 2, 2022
0b04050
Added storage of custom alarm preferences
ArnyminerZ Dec 2, 2022
f8dcca8
Added check for blank reminders
ArnyminerZ Dec 2, 2022
a52b5a3
Added alarms removal when loading
ArnyminerZ Dec 2, 2022
daa8f39
Simplified calendar alerts to just one
ArnyminerZ Dec 2, 2022
76a0405
Updated strings
ArnyminerZ Dec 2, 2022
af81361
Simplified UI to have only one default alarm
ArnyminerZ Dec 5, 2022
ef4aec4
Added new strings
ArnyminerZ Dec 5, 2022
691345e
Removed no longer necessary `CalendarReminder`
ArnyminerZ Dec 5, 2022
2d5c233
Sketched workflow
ArnyminerZ Dec 5, 2022
c265be3
Implemented default alarm adder
ArnyminerZ Dec 5, 2022
8d2ffe2
Just a small deprecation
ArnyminerZ Dec 5, 2022
6c077b5
Added ignore cache option
ArnyminerZ Dec 5, 2022
207c939
Added ignore cache refresh of saving
ArnyminerZ Dec 5, 2022
ce254da
Optimized event updating
ArnyminerZ Dec 5, 2022
42744ba
Make forceResync explicit
rfc2822 Dec 6, 2022
b45f78c
Minor changes
rfc2822 Dec 6, 2022
0044069
No longer used
ArnyminerZ Dec 8, 2022
20640a8
Merge remote-tracking branch 'origin/1-customizable-alarms-per-subscr…
ArnyminerZ Dec 8, 2022
4d66d24
Moved alarms logic to `ProcessEventsTask`
ArnyminerZ Dec 8, 2022
77ea706
Cleanup
ArnyminerZ Dec 8, 2022
b88c537
Use Joda for time interval formatting; minor changes
rfc2822 Dec 10, 2022
7046afe
Merge branch 'dev' into 1-customizable-alarms-per-subscription
ArnyminerZ Dec 11, 2022
c233aee
Removed `firstCheck` variable
ArnyminerZ Dec 11, 2022
a68b168
Refactored check
ArnyminerZ Dec 11, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions app/src/main/java/at/bitfire/icsdroid/ProcessEventsTask.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,24 @@ import java.io.InputStream
import java.io.InputStreamReader
import java.net.MalformedURLException

/**
* Fetches the .ics for a given Webcal subscription and stores the events
* in the local calendar provider.
*
* By default, caches will be used:
*
* - for fetching a calendar by HTTP (ETag/Last-Modified),
* - for updating the local events (will only be updated when LAST-MODIFIED is newer).
*
* @param context context to work in
* @param calendar represents the subscription to be checked
* @param forceResync enforces that the calendar is fetched and all events are fully processed
* (useful when subscription settings have been changed)
*/
class ProcessEventsTask(
val context: Context,
val calendar: LocalCalendar
val context: Context,
val calendar: LocalCalendar,
val forceResync: Boolean
) {

suspend fun sync() {
Expand All @@ -53,7 +68,7 @@ class ProcessEventsTask(
calendar.updateStatusError(e.localizedMessage ?: e.toString())
return
}
Log.i(Constants.TAG, "Synchronizing $uri")
Log.i(Constants.TAG, "Synchronizing $uri, forceResync=$forceResync")

// dismiss old notifications
val notificationManager = NotificationUtils.createChannels(context)
Expand All @@ -65,7 +80,7 @@ class ProcessEventsTask(
InputStreamReader(data, contentType?.charset() ?: Charsets.UTF_8).use { reader ->
try {
val events = Event.eventsFromReader(reader)
processEvents(events)
processEvents(events, forceResync)

Log.i(Constants.TAG, "Calendar sync successful, ETag=$eTag, lastModified=$lastModified")
calendar.updateStatusSuccess(eTag, lastModified ?: 0L)
Expand Down Expand Up @@ -98,9 +113,9 @@ class ProcessEventsTask(
downloader.password = password
}

if (calendar.eTag != null)
if (calendar.eTag != null && !forceResync)
downloader.ifNoneMatch = calendar.eTag
if (calendar.lastModified != 0L)
if (calendar.lastModified != 0L && !forceResync)
downloader.ifModifiedSince = calendar.lastModified

downloader.fetch()
Expand Down Expand Up @@ -131,8 +146,8 @@ class ProcessEventsTask(
}
}

private fun processEvents(events: List<Event>) {
Log.i(Constants.TAG, "Processing ${events.size} events")
private fun processEvents(events: List<Event>, ignoreLastModified: Boolean) {
Log.i(Constants.TAG, "Processing ${events.size} events (ignoreLastModified=$ignoreLastModified)")
val uids = HashSet<String>(events.size)

for (event in events) {
Expand All @@ -147,7 +162,7 @@ class ProcessEventsTask(

} else {
val localEvent = localEvents.first()
var lastModified = event.lastModified
var lastModified = if (ignoreLastModified) null else event.lastModified
Log.d(Constants.TAG, "$uid already in local calendar, lastModified = $lastModified")

if (lastModified != null) {
Expand Down
21 changes: 13 additions & 8 deletions app/src/main/java/at/bitfire/icsdroid/SyncWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ class SyncWorker(

const val NAME = "SyncWorker"

const val FORCE_RESYNC = "forceResync"


/**
* Enqueues a sync job for immediate execution. If the sync is forced,
* the "requires network connection" constraint won't be set.
*
* @param context required for managing work
* @param force *true* enqueues the sync regardless of the network state; *false* adds a [NetworkType.CONNECTED] constraint
* @param context required for managing work
* @param force *true* enqueues the sync regardless of the network state; *false* adds a [NetworkType.CONNECTED] constraint
* @param forceResync *true* ignores all locally stored data and fetched everything from the server again
*/
fun run(context: Context, force: Boolean = false) {
fun run(context: Context, force: Boolean = false, forceResync: Boolean = false) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf(FORCE_RESYNC to forceResync))

val policy: ExistingWorkPolicy
if (force) {
Expand Down Expand Up @@ -67,10 +71,11 @@ class SyncWorker(

@SuppressLint("Recycle")
override suspend fun doWork(): Result {
val forceResync = inputData.getBoolean(FORCE_RESYNC, false)
applicationContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { providerClient ->
try {
return withContext(Dispatchers.Default) {
performSync(AppAccount.get(applicationContext), providerClient)
performSync(AppAccount.get(applicationContext), providerClient, forceResync)
}
} finally {
providerClient.closeCompat()
Expand All @@ -79,12 +84,12 @@ class SyncWorker(
return Result.failure()
}

private suspend fun performSync(account: Account, provider: ContentProviderClient): Result {
Log.i(Constants.TAG, "Synchronizing ${account.name}")
private suspend fun performSync(account: Account, provider: ContentProviderClient, forceResync: Boolean): Result {
Log.i(Constants.TAG, "Synchronizing ${account.name} (forceResync=$forceResync)")
try {
LocalCalendar.findAll(account, provider)
.filter { it.isSynced }
.forEach { ProcessEventsTask(applicationContext, it).sync() }
.filter { it.isSynced }
.forEach { ProcessEventsTask(applicationContext, it, forceResync).sync() }

} catch (e: CalendarStorageException) {
Log.e(Constants.TAG, "Calendar storage exception", e)
Expand Down
127 changes: 106 additions & 21 deletions app/src/main/java/at/bitfire/icsdroid/db/LocalCalendar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,25 @@ import android.content.ContentValues
import android.os.RemoteException
import android.provider.CalendarContract.Calendars
import android.provider.CalendarContract.Events
import android.util.Log
import at.bitfire.ical4android.AndroidCalendar
import at.bitfire.ical4android.AndroidCalendarFactory
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.util.MiscUtils.UriHelper.asSyncAdapter
import at.bitfire.icsdroid.Constants
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.PropertyList
import net.fortuna.ical4j.model.component.VAlarm
import net.fortuna.ical4j.model.property.Action
import net.fortuna.ical4j.model.property.Description
import net.fortuna.ical4j.model.property.Trigger
import java.time.Duration

class LocalCalendar private constructor(
account: Account,
provider: ContentProviderClient,
id: Long
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id) {
account: Account,
provider: ContentProviderClient,
id: Long
) : AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id) {

companion object {

Expand All @@ -31,20 +40,43 @@ class LocalCalendar private constructor(
const val COLUMN_LAST_SYNC = Calendars.CAL_SYNC5
const val COLUMN_ERROR_MESSAGE = Calendars.CAL_SYNC6

/**
* Stores if the calendar's embedded alerts should be ignored.
* @since 20221202
*/
const val COLUMN_IGNORE_EMBEDDED = Calendars.CAL_SYNC8

/**
* Stores the default alarm to set to all events in the given calendar.
* @since 20221202
*/
const val COLUMN_DEFAULT_ALARM = Calendars.CAL_SYNC7

fun findById(account: Account, provider: ContentProviderClient, id: Long) =
findByID(account, provider, Factory, id)
findByID(account, provider, Factory, id)

fun findAll(account: Account, provider: ContentProviderClient) =
find(account, provider, Factory, null, null)
find(account, provider, Factory, null, null)

}

var url: String? = null // URL of iCalendar file
var eTag: String? = null // iCalendar ETag at last successful sync
/** URL of iCalendar file */
var url: String? = null
/** iCalendar ETag at last successful sync */
var eTag: String? = null

var lastModified = 0L // iCalendar Last-Modified at last successful sync (or 0 for none)
var lastSync = 0L // time of last sync (0 if none)
var errorMessage: String? = null // error message (HTTP status or exception name) of last sync (or null)
/** iCalendar Last-Modified at last successful sync (or 0 for none) */
var lastModified = 0L
/** time of last sync (0 if none) */
var lastSync = 0L
/** error message (HTTP status or exception name) of last sync (or null) */
var errorMessage: String? = null

/** Setting: whether to ignore alarms embedded in the Webcal */
var ignoreEmbeddedAlerts: Boolean? = null
/** Setting: Shall a default alarm be added to every event in the calendar? If yes, this
* field contains the minutes before the event. If no, it is *null*. */
var defaultAlarmMinutes: Long? = null


override fun populate(info: ContentValues) {
Expand All @@ -56,19 +88,66 @@ class LocalCalendar private constructor(

info.getAsLong(COLUMN_LAST_SYNC)?.let { lastSync = it }
errorMessage = info.getAsString(COLUMN_ERROR_MESSAGE)

info.getAsBoolean(COLUMN_IGNORE_EMBEDDED)?.let { ignoreEmbeddedAlerts = it }
info.getAsLong(COLUMN_DEFAULT_ALARM)?.let { defaultAlarmMinutes = it }
}

private fun updateAlarms() {
queryEvents(null, null)
.also { if (ignoreEmbeddedAlerts == true) Log.d(Constants.TAG, "Removing all alarms for ${it.size} events.") }
.filter { it.event != null }
.forEach { ev ->
val event = ev.event!!

// according to setting: remove all alerts for every event
if (ignoreEmbeddedAlerts == true) {
Log.d(Constants.TAG, "Removing all alarms from ${event.uid}: $event")
event.alarms.clear()
}

// according to setting: add default alarm for every event
defaultAlarmMinutes?.let { minutes ->
// Check if already added alarm
val alarm = event.alarms.find { it.description.value.contains("*added by ICSx5") }
if (alarm != null) return@let
// Add the default alarm to the event
Log.d(Constants.TAG, "Adding the default alarm to ${event.uid}.")
event.alarms.add(
// Create the new VAlarm
VAlarm.Factory().createComponent(
// Set all the properties for the alarm
PropertyList<Property>().apply {
// Set action to DISPLAY
add(Action.DISPLAY)
// Add the trigger x minutes before
val duration = Duration.ofMinutes(minutes * -1)
add(Trigger(duration))
// Set a default description for checking if added
add(Description("*added by ICSx5"))
}
)
)
}
ev.update(event)
}
}

fun updateStatusSuccess(eTag: String?, lastModified: Long) {
this.eTag = eTag
this.lastModified = lastModified
lastSync = System.currentTimeMillis()

val values = ContentValues(4)
val values = ContentValues(7)
values.put(COLUMN_ETAG, eTag)
values.put(COLUMN_LAST_MODIFIED, lastModified)
values.put(COLUMN_LAST_SYNC, lastSync)
values.putNull(COLUMN_ERROR_MESSAGE)
values.put(COLUMN_DEFAULT_ALARM, defaultAlarmMinutes)
values.put(COLUMN_IGNORE_EMBEDDED, ignoreEmbeddedAlerts)
update(values)

updateAlarms()
}

fun updateStatusNotModified() {
Expand All @@ -77,6 +156,8 @@ class LocalCalendar private constructor(
val values = ContentValues(1)
values.put(COLUMN_LAST_SYNC, lastSync)
update(values)

updateAlarms()
}

fun updateStatusError(message: String) {
Expand All @@ -85,11 +166,13 @@ class LocalCalendar private constructor(
lastSync = System.currentTimeMillis()
errorMessage = message

val values = ContentValues(4)
val values = ContentValues(7)
values.putNull(COLUMN_ETAG)
values.putNull(COLUMN_LAST_MODIFIED)
values.put(COLUMN_LAST_SYNC, lastSync)
values.put(COLUMN_ERROR_MESSAGE, message)
values.put(COLUMN_DEFAULT_ALARM, defaultAlarmMinutes)
values.put(COLUMN_IGNORE_EMBEDDED, ignoreEmbeddedAlerts)
update(values)
}

Expand All @@ -102,36 +185,38 @@ class LocalCalendar private constructor(
}

fun queryByUID(uid: String) =
queryEvents("${Events._SYNC_ID}=?", arrayOf(uid))
queryEvents("${Events._SYNC_ID}=?", arrayOf(uid))

fun retainByUID(uids: MutableSet<String>): Int {
var deleted = 0
try {
provider.query(Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events._SYNC_ID, Events.ORIGINAL_SYNC_ID),
"${Events.CALENDAR_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS NULL", arrayOf(id.toString()), null)?.use { row ->
provider.query(
Events.CONTENT_URI.asSyncAdapter(account),
arrayOf(Events._ID, Events._SYNC_ID, Events.ORIGINAL_SYNC_ID),
"${Events.CALENDAR_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS NULL", arrayOf(id.toString()), null
)?.use { row ->
while (row.moveToNext()) {
val eventId = row.getLong(0)
val syncId = row.getString(1)
if (!uids.contains(syncId)) {
provider.delete(ContentUris.withAppendedId(Events.CONTENT_URI, eventId).asSyncAdapter(account), null, null)
deleted++

uids -= syncId
}
}
}
return deleted
} catch(e: RemoteException) {
} catch (e: RemoteException) {
throw CalendarStorageException("Couldn't delete local events")
}
}


object Factory: AndroidCalendarFactory<LocalCalendar> {
object Factory : AndroidCalendarFactory<LocalCalendar> {

override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
LocalCalendar(account, provider, id)
LocalCalendar(account, provider, id)

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class AddCalendarDetailsFragment: Fragment() {
}
titleColorModel.title.observe(this, invalidateOptionsMenu)
titleColorModel.color.observe(this, invalidateOptionsMenu)
titleColorModel.ignoreAlerts.observe(this, invalidateOptionsMenu)
titleColorModel.defaultAlarmMinutes.observe(this, invalidateOptionsMenu)

// Set the default value to null so that the visibility of the summary is updated
titleColorModel.defaultAlarmMinutes.postValue(null)
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, inState: Bundle?): View {
Expand Down Expand Up @@ -77,6 +82,8 @@ class AddCalendarDetailsFragment: Fragment() {
calInfo.put(Calendars.SYNC_EVENTS, 1)
calInfo.put(Calendars.VISIBLE, 1)
calInfo.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
calInfo.put(LocalCalendar.COLUMN_IGNORE_EMBEDDED, titleColorModel.ignoreAlerts.value)
calInfo.put(LocalCalendar.COLUMN_DEFAULT_ALARM, titleColorModel.defaultAlarmMinutes.value)

val client: ContentProviderClient? = requireActivity().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
return try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.view.*
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
Expand Down Expand Up @@ -56,7 +57,7 @@ class CalendarListActivity: AppCompatActivity(), SwipeRefreshLayout.OnRefreshLis
binding.lifecycleOwner = this
binding.model = model

val defaultRefreshColor = resources.getColor(R.color.lightblue)
val defaultRefreshColor = ContextCompat.getColor(this, R.color.lightblue)
binding.refresh.setColorSchemeColors(defaultRefreshColor)
binding.refresh.setOnRefreshListener(this)
binding.refresh.setSize(SwipeRefreshLayout.LARGE)
Expand Down
Loading