From 258598e730c14ce7cc0254d1aa111f109fb8ef4f Mon Sep 17 00:00:00 2001 From: Christophe Beyls Date: Sat, 6 Feb 2021 01:33:41 +0100 Subject: [PATCH] Import bookmarks feature (#65) - move ICalendarWriter to ical package - create ICalendarReader and share CRLF constant with ICalendarWriter - add import icon - implement bookmarks parser and add file picker to send parsed bookmark ids to ExternalBookmarksActivity - add feature to import all bookmarks shown in the external bookmarks list at once, with confirmation dialog. --- .../activities/ExternalBookmarksActivity.kt | 12 ++- .../fosdem/alarms/FosdemAlarmManager.kt | 17 ++-- .../be/digitalia/fosdem/db/BookmarksDao.kt | 41 ++++++-- .../fosdem/fragments/BookmarksListFragment.kt | 52 ++++++++++ .../ExternalBookmarksListFragment.kt | 52 ++++++++++ .../digitalia/fosdem/ical/ICalendarReader.kt | 98 +++++++++++++++++++ .../fosdem/{utils => ical}/ICalendarWriter.kt | 15 +-- .../fosdem/ical/internal/Constants.kt | 5 + .../be/digitalia/fosdem/model/AlarmInfo.kt | 11 ++- .../fosdem/parsers/ExportedBookmarksParser.kt | 45 +++++++++ .../providers/BookmarksExportProvider.kt | 4 +- .../fosdem/services/AlarmIntentService.kt | 31 +++--- .../fosdem/viewmodels/BookmarksViewModel.kt | 18 +++- .../viewmodels/ExternalBookmarksViewModel.kt | 5 + .../drawable/ic_file_download_white_24dp.xml | 9 ++ app/src/main/res/menu/bookmarks.xml | 6 ++ app/src/main/res/menu/external_bookmarks.xml | 10 ++ app/src/main/res/values/strings.xml | 7 +- 18 files changed, 389 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/be/digitalia/fosdem/ical/ICalendarReader.kt rename app/src/main/java/be/digitalia/fosdem/{utils => ical}/ICalendarWriter.kt (79%) create mode 100644 app/src/main/java/be/digitalia/fosdem/ical/internal/Constants.kt create mode 100644 app/src/main/java/be/digitalia/fosdem/parsers/ExportedBookmarksParser.kt create mode 100644 app/src/main/res/drawable/ic_file_download_white_24dp.xml create mode 100644 app/src/main/res/menu/external_bookmarks.xml diff --git a/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.kt index 779a6a6..83bfadb 100644 --- a/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.kt +++ b/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.kt @@ -18,9 +18,11 @@ class ExternalBookmarksActivity : SimpleToolbarActivity() { if (savedInstanceState == null) { val intent = intent - val bookmarkIds = if (intent.hasNfcAppData()) { - intent.extractNfcAppData().toBookmarks() - } else null + val bookmarkIds = when { + intent.hasExtra(EXTRA_BOOKMARK_IDS) -> intent.getLongArrayExtra(EXTRA_BOOKMARK_IDS) + intent.hasNfcAppData() -> intent.extractNfcAppData().toBookmarks() + else -> null + } if (bookmarkIds == null) { // Invalid data format, exit finish() @@ -33,4 +35,8 @@ class ExternalBookmarksActivity : SimpleToolbarActivity() { } } } + + companion object { + const val EXTRA_BOOKMARK_IDS = "bookmark_ids" + } } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.kt b/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.kt index 41522de..a162af5 100644 --- a/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.kt +++ b/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.kt @@ -1,11 +1,12 @@ package be.digitalia.fosdem.alarms +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.SharedPreferences.OnSharedPreferenceChangeListener import androidx.annotation.MainThread import androidx.preference.PreferenceManager -import be.digitalia.fosdem.model.Event +import be.digitalia.fosdem.model.AlarmInfo import be.digitalia.fosdem.services.AlarmIntentService import be.digitalia.fosdem.utils.PreferenceKeys @@ -14,6 +15,7 @@ import be.digitalia.fosdem.utils.PreferenceKeys * * @author Christophe Beyls */ +@SuppressLint("StaticFieldLeak") object FosdemAlarmManager { private lateinit var context: Context @@ -51,20 +53,17 @@ object FosdemAlarmManager { } @MainThread - fun onBookmarkAdded(event: Event) { + fun onBookmarksAdded(alarmInfos: List) { if (isEnabled) { - val serviceIntent = Intent(AlarmIntentService.ACTION_ADD_BOOKMARK).apply { - putExtra(AlarmIntentService.EXTRA_EVENT_ID, event.id) - event.startTime?.let { - putExtra(AlarmIntentService.EXTRA_EVENT_START_TIME, it.time) - } - } + val arrayList = if (alarmInfos is ArrayList) alarmInfos else ArrayList(alarmInfos) + val serviceIntent = Intent(AlarmIntentService.ACTION_ADD_BOOKMARKS) + .putParcelableArrayListExtra(AlarmIntentService.EXTRA_ALARM_INFOS, arrayList) AlarmIntentService.enqueueWork(context, serviceIntent) } } @MainThread - fun onBookmarksRemoved(eventIds: LongArray?) { + fun onBookmarksRemoved(eventIds: LongArray) { if (isEnabled) { val serviceIntent = Intent(AlarmIntentService.ACTION_REMOVE_BOOKMARKS) .putExtra(AlarmIntentService.EXTRA_EVENT_IDS, eventIds) diff --git a/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt b/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt index 430b62a..1b3b188 100644 --- a/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt +++ b/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt @@ -6,6 +6,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.withTransaction import be.digitalia.fosdem.alarms.FosdemAlarmManager import be.digitalia.fosdem.db.entities.Bookmark import be.digitalia.fosdem.model.AlarmInfo @@ -14,7 +15,7 @@ import be.digitalia.fosdem.utils.BackgroundWorkScope import kotlinx.coroutines.launch @Dao -abstract class BookmarksDao { +abstract class BookmarksDao(private val appDatabase: AppDatabase) { /** * Returns the bookmarks. @@ -62,20 +63,44 @@ abstract class BookmarksDao { fun addBookmarkAsync(event: Event) { BackgroundWorkScope.launch { - if (addBookmarkInternal(Bookmark(event.id)) != -1L) { - FosdemAlarmManager.onBookmarkAdded(event) + val ids = addBookmarksInternal(listOf(Bookmark(event.id))) + if (ids[0] != -1L) { + FosdemAlarmManager.onBookmarksAdded(listOf(AlarmInfo(eventId = event.id, startTime = event.startTime))) } } } - @Insert(onConflict = OnConflictStrategy.IGNORE) - protected abstract suspend fun addBookmarkInternal(bookmark: Bookmark): Long + 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 - fun removeBookmarkAsync(event: Event) { - removeBookmarksAsync(event.id) + 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()) { + FosdemAlarmManager.onBookmarksAdded(addedAlarmInfos) + } + } + } } - fun removeBookmarksAsync(vararg eventIds: Long) { + @Query("""SELECT id as event_id, start_time + FROM events + WHERE id IN (:ids) + ORDER BY start_time ASC""") + protected abstract suspend fun getAlarmInfos(ids: LongArray): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun addBookmarksInternal(bookmarks: List): LongArray + + fun removeBookmarkAsync(event: Event) { + removeBookmarksAsync(longArrayOf(event.id)) + } + + fun removeBookmarksAsync(eventIds: LongArray) { BackgroundWorkScope.launch { if (removeBookmarksInternal(eventIds) > 0) { FosdemAlarmManager.onBookmarksRemoved(eventIds) diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt index 429f222..6ae8aed 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt +++ b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt @@ -1,7 +1,10 @@ package be.digitalia.fosdem.fragments +import android.app.Activity +import android.app.Dialog import android.content.Context import android.content.Intent +import android.net.Uri import android.nfc.NdefRecord import android.os.Bundle import android.view.Menu @@ -11,17 +14,22 @@ import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.core.content.edit +import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import be.digitalia.fosdem.R +import be.digitalia.fosdem.activities.ExternalBookmarksActivity import be.digitalia.fosdem.adapters.BookmarksAdapter import be.digitalia.fosdem.providers.BookmarksExportProvider import be.digitalia.fosdem.utils.CreateNfcAppDataCallback import be.digitalia.fosdem.utils.toBookmarksNfcAppData import be.digitalia.fosdem.viewmodels.BookmarksViewModel import be.digitalia.fosdem.widgets.MultiChoiceHelper +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.util.concurrent.CancellationException /** * Bookmarks list, optionally filterable. @@ -133,9 +141,52 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC startActivity(Intent.createChooser(exportIntent, getString(R.string.export_bookmarks))) true } + R.id.import_bookmarks -> { + val importIntent = Intent(Intent.ACTION_GET_CONTENT) + .setType(BookmarksExportProvider.TYPE) + try { + startActivityForResult(importIntent, IMPORT_REQUEST_CODE) + } catch (ignore: Exception) { + } + true + } else -> false } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == IMPORT_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + data?.data?.let { + importBookmarks(it) + } + } + } + + private fun importBookmarks(uri: Uri) { + lifecycleScope.launchWhenStarted { + try { + val bookmarkIds = viewModel.readBookmarkIds(uri) + val intent = Intent(requireContext(), ExternalBookmarksActivity::class.java) + .putExtra(ExternalBookmarksActivity.EXTRA_BOOKMARK_IDS, bookmarkIds) + startActivity(intent) + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + ImportBookmarksErrorDialogFragment().show(parentFragmentManager, "importBookmarksError") + } + } + } + + class ImportBookmarksErrorDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.import_bookmarks) + .setMessage(R.string.import_bookmarks_error) + .setPositiveButton(android.R.string.ok, null) + .create() + } + } + override fun createNfcAppData(): NdefRecord? { val context = context ?: return null val bookmarks = viewModel.bookmarks.value @@ -146,5 +197,6 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC companion object { private const val PREF_UPCOMING_ONLY = "bookmarks_upcoming_only" + private const val IMPORT_REQUEST_CODE = 1 } } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.kt index 6ed559a..cb540cd 100644 --- a/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.kt +++ b/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.kt @@ -1,7 +1,12 @@ package be.digitalia.fosdem.fragments +import android.app.Dialog import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View +import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.recyclerview.widget.DividerItemDecoration @@ -9,10 +14,17 @@ import androidx.recyclerview.widget.LinearLayoutManager import be.digitalia.fosdem.R import be.digitalia.fosdem.adapters.EventsAdapter import be.digitalia.fosdem.viewmodels.ExternalBookmarksViewModel +import com.google.android.material.dialog.MaterialAlertDialogBuilder class ExternalBookmarksListFragment : Fragment(R.layout.recyclerview) { private val viewModel: ExternalBookmarksViewModel by viewModels() + private var addAllMenuItem: MenuItem? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -34,11 +46,51 @@ class ExternalBookmarksListFragment : Fragment(R.layout.recyclerview) { setBookmarkIds(bookmarkIds) bookmarks.observe(viewLifecycleOwner) { bookmarks -> adapter.submitList(bookmarks) + addAllMenuItem?.isEnabled = bookmarks.isNotEmpty() holder.isProgressBarVisible = false } } } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.external_bookmarks, menu) + menu.findItem(R.id.add_all)?.let { item -> + val bookmarks = viewModel.bookmarks.value + item.isEnabled = bookmarks != null && bookmarks.isNotEmpty() + addAllMenuItem = item + } + } + + override fun onDestroyOptionsMenu() { + super.onDestroyOptionsMenu() + addAllMenuItem = null + } + + override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { + R.id.add_all -> { + val dialogFragment = ConfirmAddAllDialogFragment() + dialogFragment.setTargetFragment(this, 0) + dialogFragment.show(parentFragmentManager, "confirmAddAll") + true + } + else -> false + } + + class ConfirmAddAllDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.external_bookmarks_add_all_confirmation_title) + .setMessage(R.string.external_bookmarks_add_all_confirmation_text) + .setPositiveButton(android.R.string.ok) { _, _ -> (targetFragment as ExternalBookmarksListFragment).onConfirmAddAll() } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + } + + fun onConfirmAddAll() { + viewModel.addAll() + } + companion object { private const val ARG_BOOKMARK_IDS = "bookmark_ids" diff --git a/app/src/main/java/be/digitalia/fosdem/ical/ICalendarReader.kt b/app/src/main/java/be/digitalia/fosdem/ical/ICalendarReader.kt new file mode 100644 index 0000000..59aacd3 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/ical/ICalendarReader.kt @@ -0,0 +1,98 @@ +package be.digitalia.fosdem.ical + +import be.digitalia.fosdem.ical.internal.CRLF +import okio.Buffer +import okio.BufferedSource +import java.io.Closeable +import java.io.IOException + +/** + * Optimized streaming ICalendar reader. + */ +class ICalendarReader(private val source: BufferedSource) : Closeable { + private var state = STATE_BEGIN_KEY + + class Options private constructor(internal val suffixedKey: okio.Options) { + companion object { + fun of(vararg keys: String): Options { + val buffer = Buffer() + val result = Array(keys.size) { + buffer.writeUtf8(keys[it]) + buffer.writeByte(':'.toInt()) + buffer.readByteString() + } + return Options(okio.Options.of(*result)) + } + } + } + + fun hasNext(): Boolean = !source.exhausted() + + fun nextKey(): String { + check(state == STATE_BEGIN_KEY) + val endPosition = source.indexOf(':'.toByte()) + endPosition >= 0L || throw IOException("Invalid key") + val result = source.readUtf8(endPosition) + source.skip(1L) + state = STATE_BEGIN_VALUE + return result + } + + fun skipKey() { + check(state == STATE_BEGIN_KEY) + val endPosition = source.indexOf(':'.toByte()) + endPosition >= 0L || throw IOException("Invalid key") + source.skip(endPosition + 1L) + state = STATE_BEGIN_VALUE + } + + fun selectKey(options: Options): Int { + check(state == STATE_BEGIN_KEY) + val result = source.select(options.suffixedKey) + if (result >= 0) { + state = STATE_BEGIN_VALUE + } + return result + } + + private inline fun BufferedSource.unfoldLines(lineLambda: BufferedSource.(endPosition: Long) -> Unit) { + while (true) { + val endPosition = indexOf(CRLF) + if (endPosition < 0L) { + // buffer now contains the rest of the file until the end + lineLambda(buffer.size) + break + } + lineLambda(endPosition) + skip(2L) + if (!request(1L) || buffer[0L] != ' '.toByte()) { + break + } + skip(1L) + } + } + + fun nextValue(): String { + check(state == STATE_BEGIN_VALUE) + val resultBuffer = Buffer() + source.unfoldLines { read(resultBuffer, it) } + state = STATE_BEGIN_KEY + return resultBuffer.readUtf8() + } + + fun skipValue() { + check(state == STATE_BEGIN_VALUE) + source.unfoldLines { skip(it) } + state = STATE_BEGIN_KEY + } + + @Throws(IOException::class) + override fun close() { + source.close() + } + + companion object { + private const val STATE_BEGIN_KEY = 0 + private const val STATE_BEGIN_VALUE = 1 + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.kt b/app/src/main/java/be/digitalia/fosdem/ical/ICalendarWriter.kt similarity index 79% rename from app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.kt rename to app/src/main/java/be/digitalia/fosdem/ical/ICalendarWriter.kt index 02068df..6e2a4ca 100644 --- a/app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.kt +++ b/app/src/main/java/be/digitalia/fosdem/ical/ICalendarWriter.kt @@ -1,7 +1,7 @@ -package be.digitalia.fosdem.utils +package be.digitalia.fosdem.ical +import be.digitalia.fosdem.ical.internal.CRLF import okio.BufferedSink -import okio.ByteString.Companion.encodeUtf8 import java.io.Closeable import java.io.IOException @@ -15,9 +15,9 @@ class ICalendarWriter(private val sink: BufferedSink) : Closeable { if (value != null) { with(sink) { writeUtf8(key) - writeUtf8CodePoint(':'.toInt()) + writeByte(':'.toInt()) - // Escape line break sequences + // Fold line break sequences val length = value.length var start = 0 var end = 0 @@ -26,7 +26,7 @@ class ICalendarWriter(private val sink: BufferedSink) : Closeable { if (c == '\r' || c == '\n') { writeUtf8(value, start, end) write(CRLF) - writeUtf8CodePoint(' '.toInt()) + writeByte(' '.toInt()) do { end++ } while (end < length && (value[end] == '\r' || value[end] == '\n')) @@ -46,9 +46,4 @@ class ICalendarWriter(private val sink: BufferedSink) : Closeable { override fun close() { sink.close() } - - companion object { - private val CRLF = "\r\n".encodeUtf8() - } - } \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/ical/internal/Constants.kt b/app/src/main/java/be/digitalia/fosdem/ical/internal/Constants.kt new file mode 100644 index 0000000..6514ca4 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/ical/internal/Constants.kt @@ -0,0 +1,5 @@ +package be.digitalia.fosdem.ical.internal + +import okio.ByteString.Companion.encodeUtf8 + +internal val CRLF = "\r\n".encodeUtf8() \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt b/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt index b4f0f62..112296c 100644 --- a/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt +++ b/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt @@ -1,14 +1,19 @@ package be.digitalia.fosdem.model +import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.TypeConverters import be.digitalia.fosdem.db.converters.NullableDateTypeConverters +import be.digitalia.fosdem.utils.DateParceler +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith import java.util.Date -class AlarmInfo( +@Parcelize +data class AlarmInfo( @ColumnInfo(name = "event_id") val eventId: Long, @ColumnInfo(name = "start_time") @field:TypeConverters(NullableDateTypeConverters::class) - val startTime: Date? -) \ No newline at end of file + val startTime: @WriteWith Date? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/ExportedBookmarksParser.kt b/app/src/main/java/be/digitalia/fosdem/parsers/ExportedBookmarksParser.kt new file mode 100644 index 0000000..781794d --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/parsers/ExportedBookmarksParser.kt @@ -0,0 +1,45 @@ +package be.digitalia.fosdem.parsers + +import be.digitalia.fosdem.ical.ICalendarReader +import okio.BufferedSource + +/** + * Extract event ids from an exported bookmarks file and validate that events match the given application id and year. + */ +class ExportedBookmarksParser(private val applicationId: String, year: Int) : Parser { + private val yearString = year.toString() + + override fun parse(source: BufferedSource): LongArray { + val reader = ICalendarReader(source) + val eventIdList = mutableListOf() + + while (reader.hasNext()) { + if (reader.selectKey(KEYS) == -1) { + reader.skipKey() + reader.skipValue() + } else { + val uid = reader.nextValue() + val parts = uid.split("@") + // validate UID + if (parts.size < 3) { + throw DataException("Invalid UID format: $uid") + } + if (parts[2] != applicationId) { + throw DataException("Invalid application id. Expected: $applicationId, actual: ${parts[2]}") + } + if (parts[1] != yearString) { + throw DataException("Invalid conference year. Expected: $yearString, actual: ${parts[1]}") + } + eventIdList += parts[0] + } + } + + return LongArray(eventIdList.size) { eventIdList[it].toLong() } + } + + class DataException(message: String) : RuntimeException(message) + + companion object { + private val KEYS = ICalendarReader.Options.of("UID") + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt index a4118d8..b215719 100644 --- a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt +++ b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt @@ -14,9 +14,9 @@ import be.digitalia.fosdem.BuildConfig import be.digitalia.fosdem.R import be.digitalia.fosdem.api.FosdemUrls import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.ical.ICalendarWriter import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.utils.DateUtils -import be.digitalia.fosdem.utils.ICalendarWriter import be.digitalia.fosdem.utils.stripHtml import be.digitalia.fosdem.utils.toSlug import okio.buffer @@ -144,12 +144,12 @@ class BookmarksExportProvider : ContentProvider() { } companion object { + const val TYPE = "text/calendar" private val URI = Uri.Builder() .scheme("content") .authority("${BuildConfig.APPLICATION_ID}.bookmarks") .appendPath("bookmarks.ics") .build() - private const val TYPE = "text/calendar" private val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) fun getIntent(activity: Activity?): Intent { diff --git a/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.kt b/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.kt index eec5b2b..14779fc 100644 --- a/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.kt +++ b/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.kt @@ -32,6 +32,7 @@ import be.digitalia.fosdem.activities.EventDetailsActivity import be.digitalia.fosdem.activities.MainActivity import be.digitalia.fosdem.activities.RoomImageDialogActivity import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.AlarmInfo import be.digitalia.fosdem.model.Event import be.digitalia.fosdem.receivers.AlarmReceiver import be.digitalia.fosdem.utils.PreferenceKeys @@ -86,17 +87,24 @@ class AlarmIntentService : JobIntentService() { } setAlarmReceiverEnabled(false) } - ACTION_ADD_BOOKMARK -> { + ACTION_ADD_BOOKMARKS -> { val delay = delay - val eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1L) - val startTime = intent.getLongExtra(EXTRA_EVENT_START_TIME, -1L) - // Only schedule future events. If they start before the delay, the alarm will go off immediately - if (startTime != -1L && startTime >= System.currentTimeMillis()) { - setAlarmReceiverEnabled(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(this) + @Suppress("UNCHECKED_CAST") + val alarmInfos = intent.getParcelableArrayListExtra(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.time >= now) { + if (isFirstAlarm) { + setAlarmReceiverEnabled(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(this) + } + isFirstAlarm = false + } + AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, startTime.time - delay, getAlarmPendingIntent(eventId)) } - AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, startTime - delay, getAlarmPendingIntent(eventId)) } } ACTION_REMOVE_BOOKMARKS -> { @@ -226,9 +234,8 @@ class AlarmIntentService : JobIntentService() { 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_BOOKMARK = "${BuildConfig.APPLICATION_ID}.action.ADD_BOOKMARK" - const val EXTRA_EVENT_ID = "event_id" - const val EXTRA_EVENT_START_TIME = "event_start" + 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" diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.kt index 8bf74e5..a10524e 100644 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.kt +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.kt @@ -1,14 +1,21 @@ package be.digitalia.fosdem.viewmodels import android.app.Application +import android.net.Uri import android.text.format.DateUtils import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.switchMap +import be.digitalia.fosdem.BuildConfig import be.digitalia.fosdem.db.AppDatabase import be.digitalia.fosdem.livedata.LiveDataFactory import be.digitalia.fosdem.model.Event +import be.digitalia.fosdem.parsers.ExportedBookmarksParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.buffer +import okio.source import java.util.concurrent.TimeUnit class BookmarksViewModel(application: Application) : AndroidViewModel(application) { @@ -37,7 +44,16 @@ class BookmarksViewModel(application: Application) : AndroidViewModel(applicatio } fun removeBookmarks(eventIds: LongArray) { - appDatabase.bookmarksDao.removeBookmarksAsync(*eventIds) + appDatabase.bookmarksDao.removeBookmarksAsync(eventIds) + } + + suspend fun readBookmarkIds(uri: Uri): LongArray { + return withContext(Dispatchers.IO) { + val parser = ExportedBookmarksParser(BuildConfig.APPLICATION_ID, appDatabase.scheduleDao.getYear()) + checkNotNull(getApplication().contentResolver.openInputStream(uri)).source().buffer().use { + parser.parse(it) + } + } } companion object { diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.kt index ee15d9a..70307f4 100644 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.kt +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.kt @@ -25,4 +25,9 @@ class ExternalBookmarksViewModel(application: Application) : AndroidViewModel(ap bookmarkIdsLiveData.value = bookmarkIds } } + + fun addAll() { + val bookmarkIds = bookmarkIdsLiveData.value ?: return + appDatabase.bookmarksDao.addBookmarksAsync(bookmarkIds) + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_file_download_white_24dp.xml b/app/src/main/res/drawable/ic_file_download_white_24dp.xml new file mode 100644 index 0000000..e43b864 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_download_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/menu/bookmarks.xml b/app/src/main/res/menu/bookmarks.xml index 5159d3d..e04f097 100644 --- a/app/src/main/res/menu/bookmarks.xml +++ b/app/src/main/res/menu/bookmarks.xml @@ -20,5 +20,11 @@ android:title="@string/export_bookmarks" app:iconTint="?colorControlNormal" app:showAsAction="ifRoom" /> + \ No newline at end of file diff --git a/app/src/main/res/menu/external_bookmarks.xml b/app/src/main/res/menu/external_bookmarks.xml new file mode 100644 index 0000000..94d9713 --- /dev/null +++ b/app/src/main/res/menu/external_bookmarks.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f65023..dd0c3e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,8 @@ Upcoming only Export bookmarks FOSDEM %1$d bookmarks.ics + Import bookmarks + Unable to import bookmarks from the selected file.\n\nMake sure the file was created using this application and the conference year is matching. No bookmark. Remove bookmarks %1$s\n Other bookmarks are scheduled at the same time. @@ -62,7 +64,10 @@ No result. - Your friend\'s bookmarks + External bookmarks + Add all + Confirmation + Add all events to your local bookmarks? Unable to load the session details.\nMake sure the database has been updated to the latest version.