mirror of
https://github.com/MatomoCamp/matomocamp-companion-android.git
synced 2024-09-19 16:13:46 +02:00
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.
This commit is contained in:
parent
d7e9dcf63b
commit
258598e730
18 changed files with 389 additions and 49 deletions
|
@ -18,9 +18,11 @@ class ExternalBookmarksActivity : SimpleToolbarActivity() {
|
||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
val intent = intent
|
val intent = intent
|
||||||
val bookmarkIds = if (intent.hasNfcAppData()) {
|
val bookmarkIds = when {
|
||||||
intent.extractNfcAppData().toBookmarks()
|
intent.hasExtra(EXTRA_BOOKMARK_IDS) -> intent.getLongArrayExtra(EXTRA_BOOKMARK_IDS)
|
||||||
} else null
|
intent.hasNfcAppData() -> intent.extractNfcAppData().toBookmarks()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
if (bookmarkIds == null) {
|
if (bookmarkIds == null) {
|
||||||
// Invalid data format, exit
|
// Invalid data format, exit
|
||||||
finish()
|
finish()
|
||||||
|
@ -33,4 +35,8 @@ class ExternalBookmarksActivity : SimpleToolbarActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_BOOKMARK_IDS = "bookmark_ids"
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,11 +1,12 @@
|
||||||
package be.digitalia.fosdem.alarms
|
package be.digitalia.fosdem.alarms
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
import androidx.preference.PreferenceManager
|
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.services.AlarmIntentService
|
||||||
import be.digitalia.fosdem.utils.PreferenceKeys
|
import be.digitalia.fosdem.utils.PreferenceKeys
|
||||||
|
|
||||||
|
@ -14,6 +15,7 @@ import be.digitalia.fosdem.utils.PreferenceKeys
|
||||||
*
|
*
|
||||||
* @author Christophe Beyls
|
* @author Christophe Beyls
|
||||||
*/
|
*/
|
||||||
|
@SuppressLint("StaticFieldLeak")
|
||||||
object FosdemAlarmManager {
|
object FosdemAlarmManager {
|
||||||
|
|
||||||
private lateinit var context: Context
|
private lateinit var context: Context
|
||||||
|
@ -51,20 +53,17 @@ object FosdemAlarmManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
fun onBookmarkAdded(event: Event) {
|
fun onBookmarksAdded(alarmInfos: List<AlarmInfo>) {
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
val serviceIntent = Intent(AlarmIntentService.ACTION_ADD_BOOKMARK).apply {
|
val arrayList = if (alarmInfos is ArrayList<AlarmInfo>) alarmInfos else ArrayList(alarmInfos)
|
||||||
putExtra(AlarmIntentService.EXTRA_EVENT_ID, event.id)
|
val serviceIntent = Intent(AlarmIntentService.ACTION_ADD_BOOKMARKS)
|
||||||
event.startTime?.let {
|
.putParcelableArrayListExtra(AlarmIntentService.EXTRA_ALARM_INFOS, arrayList)
|
||||||
putExtra(AlarmIntentService.EXTRA_EVENT_START_TIME, it.time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AlarmIntentService.enqueueWork(context, serviceIntent)
|
AlarmIntentService.enqueueWork(context, serviceIntent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
fun onBookmarksRemoved(eventIds: LongArray?) {
|
fun onBookmarksRemoved(eventIds: LongArray) {
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
val serviceIntent = Intent(AlarmIntentService.ACTION_REMOVE_BOOKMARKS)
|
val serviceIntent = Intent(AlarmIntentService.ACTION_REMOVE_BOOKMARKS)
|
||||||
.putExtra(AlarmIntentService.EXTRA_EVENT_IDS, eventIds)
|
.putExtra(AlarmIntentService.EXTRA_EVENT_IDS, eventIds)
|
||||||
|
|
|
@ -6,6 +6,7 @@ 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.withTransaction
|
||||||
import be.digitalia.fosdem.alarms.FosdemAlarmManager
|
import be.digitalia.fosdem.alarms.FosdemAlarmManager
|
||||||
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
|
||||||
|
@ -14,7 +15,7 @@ import be.digitalia.fosdem.utils.BackgroundWorkScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class BookmarksDao {
|
abstract class BookmarksDao(private val appDatabase: AppDatabase) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the bookmarks.
|
* Returns the bookmarks.
|
||||||
|
@ -62,20 +63,44 @@ abstract class BookmarksDao {
|
||||||
|
|
||||||
fun addBookmarkAsync(event: Event) {
|
fun addBookmarkAsync(event: Event) {
|
||||||
BackgroundWorkScope.launch {
|
BackgroundWorkScope.launch {
|
||||||
if (addBookmarkInternal(Bookmark(event.id)) != -1L) {
|
val ids = addBookmarksInternal(listOf(Bookmark(event.id)))
|
||||||
FosdemAlarmManager.onBookmarkAdded(event)
|
if (ids[0] != -1L) {
|
||||||
|
FosdemAlarmManager.onBookmarksAdded(listOf(AlarmInfo(eventId = event.id, startTime = event.startTime)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<AlarmInfo>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
protected abstract suspend fun addBookmarkInternal(bookmark: Bookmark): Long
|
protected abstract suspend fun addBookmarksInternal(bookmarks: List<Bookmark>): LongArray
|
||||||
|
|
||||||
fun removeBookmarkAsync(event: Event) {
|
fun removeBookmarkAsync(event: Event) {
|
||||||
removeBookmarksAsync(event.id)
|
removeBookmarksAsync(longArrayOf(event.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeBookmarksAsync(vararg eventIds: Long) {
|
fun removeBookmarksAsync(eventIds: LongArray) {
|
||||||
BackgroundWorkScope.launch {
|
BackgroundWorkScope.launch {
|
||||||
if (removeBookmarksInternal(eventIds) > 0) {
|
if (removeBookmarksInternal(eventIds) > 0) {
|
||||||
FosdemAlarmManager.onBookmarksRemoved(eventIds)
|
FosdemAlarmManager.onBookmarksRemoved(eventIds)
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
package be.digitalia.fosdem.fragments
|
package be.digitalia.fosdem.fragments
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Dialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.nfc.NdefRecord
|
import android.nfc.NdefRecord
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
|
@ -11,17 +14,22 @@ import android.view.View
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import be.digitalia.fosdem.R
|
import be.digitalia.fosdem.R
|
||||||
|
import be.digitalia.fosdem.activities.ExternalBookmarksActivity
|
||||||
import be.digitalia.fosdem.adapters.BookmarksAdapter
|
import be.digitalia.fosdem.adapters.BookmarksAdapter
|
||||||
import be.digitalia.fosdem.providers.BookmarksExportProvider
|
import be.digitalia.fosdem.providers.BookmarksExportProvider
|
||||||
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
|
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
|
||||||
import be.digitalia.fosdem.utils.toBookmarksNfcAppData
|
import be.digitalia.fosdem.utils.toBookmarksNfcAppData
|
||||||
import be.digitalia.fosdem.viewmodels.BookmarksViewModel
|
import be.digitalia.fosdem.viewmodels.BookmarksViewModel
|
||||||
import be.digitalia.fosdem.widgets.MultiChoiceHelper
|
import be.digitalia.fosdem.widgets.MultiChoiceHelper
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import java.util.concurrent.CancellationException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookmarks list, optionally filterable.
|
* Bookmarks list, optionally filterable.
|
||||||
|
@ -133,9 +141,52 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC
|
||||||
startActivity(Intent.createChooser(exportIntent, getString(R.string.export_bookmarks)))
|
startActivity(Intent.createChooser(exportIntent, getString(R.string.export_bookmarks)))
|
||||||
true
|
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
|
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? {
|
override fun createNfcAppData(): NdefRecord? {
|
||||||
val context = context ?: return null
|
val context = context ?: return null
|
||||||
val bookmarks = viewModel.bookmarks.value
|
val bookmarks = viewModel.bookmarks.value
|
||||||
|
@ -146,5 +197,6 @@ class BookmarksListFragment : Fragment(R.layout.recyclerview), CreateNfcAppDataC
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREF_UPCOMING_ONLY = "bookmarks_upcoming_only"
|
private const val PREF_UPCOMING_ONLY = "bookmarks_upcoming_only"
|
||||||
|
private const val IMPORT_REQUEST_CODE = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,12 @@
|
||||||
package be.digitalia.fosdem.fragments
|
package be.digitalia.fosdem.fragments
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
@ -9,10 +14,17 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import be.digitalia.fosdem.R
|
import be.digitalia.fosdem.R
|
||||||
import be.digitalia.fosdem.adapters.EventsAdapter
|
import be.digitalia.fosdem.adapters.EventsAdapter
|
||||||
import be.digitalia.fosdem.viewmodels.ExternalBookmarksViewModel
|
import be.digitalia.fosdem.viewmodels.ExternalBookmarksViewModel
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
|
||||||
class ExternalBookmarksListFragment : Fragment(R.layout.recyclerview) {
|
class ExternalBookmarksListFragment : Fragment(R.layout.recyclerview) {
|
||||||
|
|
||||||
private val viewModel: ExternalBookmarksViewModel by viewModels()
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
@ -34,11 +46,51 @@ class ExternalBookmarksListFragment : Fragment(R.layout.recyclerview) {
|
||||||
setBookmarkIds(bookmarkIds)
|
setBookmarkIds(bookmarkIds)
|
||||||
bookmarks.observe(viewLifecycleOwner) { bookmarks ->
|
bookmarks.observe(viewLifecycleOwner) { bookmarks ->
|
||||||
adapter.submitList(bookmarks)
|
adapter.submitList(bookmarks)
|
||||||
|
addAllMenuItem?.isEnabled = bookmarks.isNotEmpty()
|
||||||
holder.isProgressBarVisible = false
|
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 {
|
companion object {
|
||||||
private const val ARG_BOOKMARK_IDS = "bookmark_ids"
|
private const val ARG_BOOKMARK_IDS = "bookmark_ids"
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.BufferedSink
|
||||||
import okio.ByteString.Companion.encodeUtf8
|
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
|
@ -15,9 +15,9 @@ class ICalendarWriter(private val sink: BufferedSink) : Closeable {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
with(sink) {
|
with(sink) {
|
||||||
writeUtf8(key)
|
writeUtf8(key)
|
||||||
writeUtf8CodePoint(':'.toInt())
|
writeByte(':'.toInt())
|
||||||
|
|
||||||
// Escape line break sequences
|
// Fold line break sequences
|
||||||
val length = value.length
|
val length = value.length
|
||||||
var start = 0
|
var start = 0
|
||||||
var end = 0
|
var end = 0
|
||||||
|
@ -26,7 +26,7 @@ class ICalendarWriter(private val sink: BufferedSink) : Closeable {
|
||||||
if (c == '\r' || c == '\n') {
|
if (c == '\r' || c == '\n') {
|
||||||
writeUtf8(value, start, end)
|
writeUtf8(value, start, end)
|
||||||
write(CRLF)
|
write(CRLF)
|
||||||
writeUtf8CodePoint(' '.toInt())
|
writeByte(' '.toInt())
|
||||||
do {
|
do {
|
||||||
end++
|
end++
|
||||||
} while (end < length && (value[end] == '\r' || value[end] == '\n'))
|
} while (end < length && (value[end] == '\r' || value[end] == '\n'))
|
||||||
|
@ -46,9 +46,4 @@ class ICalendarWriter(private val sink: BufferedSink) : Closeable {
|
||||||
override fun close() {
|
override fun close() {
|
||||||
sink.close()
|
sink.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val CRLF = "\r\n".encodeUtf8()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package be.digitalia.fosdem.ical.internal
|
||||||
|
|
||||||
|
import okio.ByteString.Companion.encodeUtf8
|
||||||
|
|
||||||
|
internal val CRLF = "\r\n".encodeUtf8()
|
|
@ -1,14 +1,19 @@
|
||||||
package be.digitalia.fosdem.model
|
package be.digitalia.fosdem.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import be.digitalia.fosdem.db.converters.NullableDateTypeConverters
|
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
|
import java.util.Date
|
||||||
|
|
||||||
class AlarmInfo(
|
@Parcelize
|
||||||
|
data class AlarmInfo(
|
||||||
@ColumnInfo(name = "event_id")
|
@ColumnInfo(name = "event_id")
|
||||||
val eventId: Long,
|
val eventId: Long,
|
||||||
@ColumnInfo(name = "start_time")
|
@ColumnInfo(name = "start_time")
|
||||||
@field:TypeConverters(NullableDateTypeConverters::class)
|
@field:TypeConverters(NullableDateTypeConverters::class)
|
||||||
val startTime: Date?
|
val startTime: @WriteWith<DateParceler> Date?
|
||||||
)
|
) : Parcelable
|
|
@ -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<LongArray> {
|
||||||
|
private val yearString = year.toString()
|
||||||
|
|
||||||
|
override fun parse(source: BufferedSource): LongArray {
|
||||||
|
val reader = ICalendarReader(source)
|
||||||
|
val eventIdList = mutableListOf<String>()
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,9 +14,9 @@ import be.digitalia.fosdem.BuildConfig
|
||||||
import be.digitalia.fosdem.R
|
import be.digitalia.fosdem.R
|
||||||
import be.digitalia.fosdem.api.FosdemUrls
|
import be.digitalia.fosdem.api.FosdemUrls
|
||||||
import be.digitalia.fosdem.db.AppDatabase
|
import be.digitalia.fosdem.db.AppDatabase
|
||||||
|
import be.digitalia.fosdem.ical.ICalendarWriter
|
||||||
import be.digitalia.fosdem.model.Event
|
import be.digitalia.fosdem.model.Event
|
||||||
import be.digitalia.fosdem.utils.DateUtils
|
import be.digitalia.fosdem.utils.DateUtils
|
||||||
import be.digitalia.fosdem.utils.ICalendarWriter
|
|
||||||
import be.digitalia.fosdem.utils.stripHtml
|
import be.digitalia.fosdem.utils.stripHtml
|
||||||
import be.digitalia.fosdem.utils.toSlug
|
import be.digitalia.fosdem.utils.toSlug
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
|
@ -144,12 +144,12 @@ class BookmarksExportProvider : ContentProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val TYPE = "text/calendar"
|
||||||
private val URI = Uri.Builder()
|
private val URI = Uri.Builder()
|
||||||
.scheme("content")
|
.scheme("content")
|
||||||
.authority("${BuildConfig.APPLICATION_ID}.bookmarks")
|
.authority("${BuildConfig.APPLICATION_ID}.bookmarks")
|
||||||
.appendPath("bookmarks.ics")
|
.appendPath("bookmarks.ics")
|
||||||
.build()
|
.build()
|
||||||
private const val TYPE = "text/calendar"
|
|
||||||
private val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
private val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||||
|
|
||||||
fun getIntent(activity: Activity?): Intent {
|
fun getIntent(activity: Activity?): Intent {
|
||||||
|
|
|
@ -32,6 +32,7 @@ import be.digitalia.fosdem.activities.EventDetailsActivity
|
||||||
import be.digitalia.fosdem.activities.MainActivity
|
import be.digitalia.fosdem.activities.MainActivity
|
||||||
import be.digitalia.fosdem.activities.RoomImageDialogActivity
|
import be.digitalia.fosdem.activities.RoomImageDialogActivity
|
||||||
import be.digitalia.fosdem.db.AppDatabase
|
import be.digitalia.fosdem.db.AppDatabase
|
||||||
|
import be.digitalia.fosdem.model.AlarmInfo
|
||||||
import be.digitalia.fosdem.model.Event
|
import be.digitalia.fosdem.model.Event
|
||||||
import be.digitalia.fosdem.receivers.AlarmReceiver
|
import be.digitalia.fosdem.receivers.AlarmReceiver
|
||||||
import be.digitalia.fosdem.utils.PreferenceKeys
|
import be.digitalia.fosdem.utils.PreferenceKeys
|
||||||
|
@ -86,17 +87,24 @@ class AlarmIntentService : JobIntentService() {
|
||||||
}
|
}
|
||||||
setAlarmReceiverEnabled(false)
|
setAlarmReceiverEnabled(false)
|
||||||
}
|
}
|
||||||
ACTION_ADD_BOOKMARK -> {
|
ACTION_ADD_BOOKMARKS -> {
|
||||||
val delay = delay
|
val delay = delay
|
||||||
val eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1L)
|
@Suppress("UNCHECKED_CAST")
|
||||||
val startTime = intent.getLongExtra(EXTRA_EVENT_START_TIME, -1L)
|
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
|
// Only schedule future events. If they start before the delay, the alarm will go off immediately
|
||||||
if (startTime != -1L && startTime >= System.currentTimeMillis()) {
|
if (startTime != null && startTime.time >= now) {
|
||||||
|
if (isFirstAlarm) {
|
||||||
setAlarmReceiverEnabled(true)
|
setAlarmReceiverEnabled(true)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
createNotificationChannel(this)
|
createNotificationChannel(this)
|
||||||
}
|
}
|
||||||
AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, startTime - delay, getAlarmPendingIntent(eventId))
|
isFirstAlarm = false
|
||||||
|
}
|
||||||
|
AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, startTime.time - delay, getAlarmPendingIntent(eventId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ACTION_REMOVE_BOOKMARKS -> {
|
ACTION_REMOVE_BOOKMARKS -> {
|
||||||
|
@ -226,9 +234,8 @@ class AlarmIntentService : JobIntentService() {
|
||||||
|
|
||||||
const val ACTION_UPDATE_ALARMS = "${BuildConfig.APPLICATION_ID}.action.UPDATE_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_DISABLE_ALARMS = "${BuildConfig.APPLICATION_ID}.action.DISABLE_ALARMS"
|
||||||
const val ACTION_ADD_BOOKMARK = "${BuildConfig.APPLICATION_ID}.action.ADD_BOOKMARK"
|
const val ACTION_ADD_BOOKMARKS = "${BuildConfig.APPLICATION_ID}.action.ADD_BOOKMARK"
|
||||||
const val EXTRA_EVENT_ID = "event_id"
|
const val EXTRA_ALARM_INFOS = "alarm_info"
|
||||||
const val EXTRA_EVENT_START_TIME = "event_start"
|
|
||||||
const val ACTION_REMOVE_BOOKMARKS = "${BuildConfig.APPLICATION_ID}.action.REMOVE_BOOKMARKS"
|
const val ACTION_REMOVE_BOOKMARKS = "${BuildConfig.APPLICATION_ID}.action.REMOVE_BOOKMARKS"
|
||||||
const val EXTRA_EVENT_IDS = "event_ids"
|
const val EXTRA_EVENT_IDS = "event_ids"
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,21 @@
|
||||||
package be.digitalia.fosdem.viewmodels
|
package be.digitalia.fosdem.viewmodels
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.switchMap
|
import androidx.lifecycle.switchMap
|
||||||
|
import be.digitalia.fosdem.BuildConfig
|
||||||
import be.digitalia.fosdem.db.AppDatabase
|
import be.digitalia.fosdem.db.AppDatabase
|
||||||
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 kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class BookmarksViewModel(application: Application) : AndroidViewModel(application) {
|
class BookmarksViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
@ -37,7 +44,16 @@ class BookmarksViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeBookmarks(eventIds: LongArray) {
|
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<Application>().contentResolver.openInputStream(uri)).source().buffer().use {
|
||||||
|
parser.parse(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -25,4 +25,9 @@ class ExternalBookmarksViewModel(application: Application) : AndroidViewModel(ap
|
||||||
bookmarkIdsLiveData.value = bookmarkIds
|
bookmarkIdsLiveData.value = bookmarkIds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addAll() {
|
||||||
|
val bookmarkIds = bookmarkIdsLiveData.value ?: return
|
||||||
|
appDatabase.bookmarksDao.addBookmarksAsync(bookmarkIds)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z"/>
|
||||||
|
</vector>
|
|
@ -20,5 +20,11 @@
|
||||||
android:title="@string/export_bookmarks"
|
android:title="@string/export_bookmarks"
|
||||||
app:iconTint="?colorControlNormal"
|
app:iconTint="?colorControlNormal"
|
||||||
app:showAsAction="ifRoom" />
|
app:showAsAction="ifRoom" />
|
||||||
|
<item
|
||||||
|
android:id="@+id/import_bookmarks"
|
||||||
|
android:icon="@drawable/ic_file_download_white_24dp"
|
||||||
|
android:title="@string/import_bookmarks"
|
||||||
|
app:iconTint="?colorControlNormal"
|
||||||
|
app:showAsAction="ifRoom" />
|
||||||
|
|
||||||
</menu>
|
</menu>
|
10
app/src/main/res/menu/external_bookmarks.xml
Normal file
10
app/src/main/res/menu/external_bookmarks.xml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/add_all"
|
||||||
|
android:title="@string/external_bookmarks_add_all"
|
||||||
|
app:showAsAction="ifRoom" />
|
||||||
|
|
||||||
|
</menu>
|
|
@ -28,6 +28,8 @@
|
||||||
<string name="upcoming_only">Upcoming only</string>
|
<string name="upcoming_only">Upcoming only</string>
|
||||||
<string name="export_bookmarks">Export bookmarks</string>
|
<string name="export_bookmarks">Export bookmarks</string>
|
||||||
<string name="export_bookmarks_file_name">FOSDEM %1$d bookmarks.ics</string>
|
<string name="export_bookmarks_file_name">FOSDEM %1$d bookmarks.ics</string>
|
||||||
|
<string name="import_bookmarks">Import bookmarks</string>
|
||||||
|
<string name="import_bookmarks_error">Unable to import bookmarks from the selected file.\n\nMake sure the file was created using this application and the conference year is matching.</string>
|
||||||
<string name="no_bookmark">No bookmark.</string>
|
<string name="no_bookmark">No bookmark.</string>
|
||||||
<string name="remove_bookmarks">Remove bookmarks</string>
|
<string name="remove_bookmarks">Remove bookmarks</string>
|
||||||
<string name="bookmark_conflict_content_description">%1$s\n Other bookmarks are scheduled at the same time.</string>
|
<string name="bookmark_conflict_content_description">%1$s\n Other bookmarks are scheduled at the same time.</string>
|
||||||
|
@ -62,7 +64,10 @@
|
||||||
<string name="no_search_result">No result.</string>
|
<string name="no_search_result">No result.</string>
|
||||||
|
|
||||||
<!-- External bookmarks -->
|
<!-- External bookmarks -->
|
||||||
<string name="external_bookmarks_title">Your friend\'s bookmarks</string>
|
<string name="external_bookmarks_title">External bookmarks</string>
|
||||||
|
<string name="external_bookmarks_add_all">Add all</string>
|
||||||
|
<string name="external_bookmarks_add_all_confirmation_title">Confirmation</string>
|
||||||
|
<string name="external_bookmarks_add_all_confirmation_text">Add all events to your local bookmarks?</string>
|
||||||
|
|
||||||
<!-- Errors -->
|
<!-- Errors -->
|
||||||
<string name="event_not_found_error">Unable to load the session details.\nMake sure the database has been updated to the latest version.</string>
|
<string name="event_not_found_error">Unable to load the session details.\nMake sure the database has been updated to the latest version.</string>
|
||||||
|
|
Loading…
Reference in a new issue