diff --git a/build.gradle b/build.gradle index 6d4eeb0ac4..2acbde8b54 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,7 @@ allprojects { repositories { google() jcenter() + maven { url 'https://jitpack.io' } } } @@ -158,6 +159,8 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'com.google.android.material:material:1.0.0' + implementation 'com.gitlab.bitfireAT:ical4android:640fc41119' + implementation project(':external:calendar') implementation project(':external:colorpicker') implementation project(':external:timezonepicker') diff --git a/res/values/strings.xml b/res/values/strings.xml index f55fade3e7..f4e5b1da9d 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -764,6 +764,7 @@ Unnamed calendar %1$s account Delete calendar + Import .ics file Change name Account type Color diff --git a/src/com/android/calendar/ical4android/LocalCalendar.kt b/src/com/android/calendar/ical4android/LocalCalendar.kt new file mode 100644 index 0000000000..6a55802598 --- /dev/null +++ b/src/com/android/calendar/ical4android/LocalCalendar.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package com.android.calendar.ical4android + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.os.RemoteException +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.AndroidCalendarFactory +import at.bitfire.ical4android.CalendarStorageException + +/** + * Based on ICSx5 + */ +class LocalCalendar private constructor( + account: Account, + provider: ContentProviderClient, + id: Long +): AndroidCalendar(account, provider, LocalEvent.Factory, id) { + + companion object { + + const val DEFAULT_COLOR = 0xFF2F80C7.toInt() + + const val COLUMN_ETAG = Calendars.CAL_SYNC1 + const val COLUMN_LAST_MODIFIED = Calendars.CAL_SYNC4 + const val COLUMN_LAST_SYNC = Calendars.CAL_SYNC5 + const val COLUMN_ERROR_MESSAGE = Calendars.CAL_SYNC6 + + fun findById(account: Account, provider: ContentProviderClient, id: Long) = + AndroidCalendar.findByID(account, provider, Factory, id) + + fun findAll(account: Account, provider: ContentProviderClient) = + AndroidCalendar.find(account, provider, Factory, null, null) + + } + + var url: String? = null // URL of iCalendar file + var eTag: String? = null // iCalendar ETag at last successful sync + + 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) + + + override fun populate(info: ContentValues) { + super.populate(info) + url = info.getAsString(Calendars.NAME) + + eTag = info.getAsString(COLUMN_ETAG) + info.getAsLong(COLUMN_LAST_MODIFIED)?.let { lastModified = it } + + info.getAsLong(COLUMN_LAST_SYNC)?.let { lastSync = it } + errorMessage = info.getAsString(COLUMN_ERROR_MESSAGE) + } + + fun updateStatusSuccess(eTag: String?, lastModified: Long) { + this.eTag = eTag + this.lastModified = lastModified + lastSync = System.currentTimeMillis() + + val values = ContentValues(4) + values.put(COLUMN_ETAG, eTag) + values.put(COLUMN_LAST_MODIFIED, lastModified) + values.put(COLUMN_LAST_SYNC, lastSync) + values.putNull(COLUMN_ERROR_MESSAGE) + update(values) + } + + fun updateStatusNotModified() { + lastSync = System.currentTimeMillis() + + val values = ContentValues(1) + values.put(COLUMN_LAST_SYNC, lastSync) + update(values) + } + + fun updateStatusError(message: String) { + eTag = null + lastModified = 0 + lastSync = System.currentTimeMillis() + errorMessage = message + + val values = ContentValues(4) + values.putNull(COLUMN_ETAG) + values.putNull(COLUMN_LAST_MODIFIED) + values.put(COLUMN_LAST_SYNC, lastSync) + values.put(COLUMN_ERROR_MESSAGE, message) + update(values) + } + + fun updateUrl(url: String) { + this.url = url + + val values = ContentValues(1) + values.put(Calendars.NAME, url) + update(values) + } + + fun queryByUID(uid: String) = + queryEvents("${Events._SYNC_ID}=?", arrayOf(uid)) + + fun retainByUID(uids: MutableSet): Int { + var deleted = 0 + try { + provider.query(syncAdapterURI(Events.CONTENT_URI, 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(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, eventId), account), null, null) + deleted++ + + uids -= syncId + } + } + } + return deleted + } catch(e: RemoteException) { + throw CalendarStorageException("Couldn't delete local events") + } + } + + + object Factory: AndroidCalendarFactory { + + override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) = + LocalCalendar(account, provider, id) + + } + +} diff --git a/src/com/android/calendar/ical4android/LocalEvent.kt b/src/com/android/calendar/ical4android/LocalEvent.kt new file mode 100644 index 0000000000..01e3c664c5 --- /dev/null +++ b/src/com/android/calendar/ical4android/LocalEvent.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package com.android.calendar.ical4android + +import android.content.ContentProviderOperation.Builder +import android.content.ContentValues +import android.provider.CalendarContract +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.AndroidEvent +import at.bitfire.ical4android.AndroidEventFactory +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.property.LastModified + +/** + * Based on ICSx5 + */ +class LocalEvent: AndroidEvent { + + companion object { + const val COLUMN_LAST_MODIFIED = CalendarContract.Events.SYNC_DATA2 + } + + var uid: String? = null + var lastModified = 0L + + private constructor(calendar: AndroidCalendar, values: ContentValues): super(calendar, values) { + uid = values.getAsString(CalendarContract.Events._SYNC_ID) + lastModified = values.getAsLong(COLUMN_LAST_MODIFIED) ?: 0 + } + + constructor(calendar: AndroidCalendar, event: Event): super(calendar, event) { + uid = event.uid + lastModified = event.lastModified?.dateTime?.time ?: 0 + } + + override fun populateEvent(row: ContentValues) { + super.populateEvent(row) + + val event = requireNotNull(event) + event.uid = row.getAsString(CalendarContract.Events._SYNC_ID) + + row.getAsLong(COLUMN_LAST_MODIFIED).let { + lastModified = it + event.lastModified = LastModified(DateTime(it)) + } + } + + override fun buildEvent(recurrence: Event?, builder: Builder) { + super.buildEvent(recurrence, builder) + + if (recurrence == null) { + // master event + builder .withValue(CalendarContract.Events._SYNC_ID, uid) + .withValue(COLUMN_LAST_MODIFIED, lastModified) + } else + // exception + builder.withValue(CalendarContract.Events.ORIGINAL_SYNC_ID, uid) + } + + + object Factory: AndroidEventFactory { + + override fun fromProvider(calendar: AndroidCalendar, values: ContentValues) = + LocalEvent(calendar, values) + + } + +} diff --git a/src/com/android/calendar/settings/CalendarPreferences.kt b/src/com/android/calendar/settings/CalendarPreferences.kt index 7261fb8b7e..50294ef653 100644 --- a/src/com/android/calendar/settings/CalendarPreferences.kt +++ b/src/com/android/calendar/settings/CalendarPreferences.kt @@ -20,16 +20,26 @@ package com.android.calendar.settings import android.accounts.Account import android.accounts.AccountManager import android.accounts.AuthenticatorDescription +import android.app.Activity +import android.content.ContentProviderClient +import android.content.Intent import android.graphics.drawable.Drawable +import android.net.Uri import android.os.Bundle import android.provider.CalendarContract import android.util.TypedValue import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.preference.* +import at.bitfire.ical4android.Event import com.android.calendar.Utils +import com.android.calendar.ical4android.LocalCalendar +import com.android.calendar.ical4android.LocalEvent import com.android.calendar.persistence.CalendarRepository import ws.xsoh.etar.R +import java.io.IOException +import java.io.InputStreamReader +import java.util.HashSet class CalendarPreferences : PreferenceFragmentCompat() { @@ -95,12 +105,21 @@ class CalendarPreferences : PreferenceFragmentCompat() { deleteCalendar() true } + val importPreference = Preference(context).apply { + title = getString(R.string.preferences_calendar_import) + isVisible = isLocalAccount + } + importPreference.setOnPreferenceClickListener { + openIcsFile() + true + } screen.addPreference(synchronizePreference) screen.addPreference(visiblePreference) screen.addPreference(colorPreference) screen.addPreference(displayNamePreference) screen.addPreference(deletePreference) + screen.addPreference(importPreference) val accountCategory = PreferenceCategory(context).apply { title = getString(R.string.preferences_calendar_account_category) @@ -121,6 +140,94 @@ class CalendarPreferences : PreferenceFragmentCompat() { preferenceScreen = screen } + private fun openIcsFile() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/calendar" + + // undocumented extra to show internal storage + putExtra("android.content.extra.SHOW_ADVANCED", true) + } + + startActivityForResult(intent, PICK_CALENDAR_FILE) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == PICK_CALENDAR_FILE && resultCode == Activity.RESULT_OK) { + data?.data?.also { uri -> + importIcs(uri) + } + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + @Throws(IOException::class) + private fun importIcs(uri: Uri) { + context!!.contentResolver.openInputStream(uri)?.use { inputStream -> + InputStreamReader(inputStream, Charsets.UTF_8).use { reader -> + try { + val events = Event.eventsFromReader(reader) + processEvents(events) + +// Log.i(Constants.TAG, "Calendar sync successful, ETag=$eTag, lastModified=$lastModified") +// calendar.updateStatusSuccess(eTag, lastModified ?: 0L) + } catch (e: Exception) { +// Log.e(Constants.TAG, "Couldn't process events", e) +// errorMessage = e.localizedMessage + } + } + } + } + + + private fun processEvents(events: List) { +// Log.i(Constants.TAG, "Processing ${events.size} events") + val uids = HashSet(events.size) + + for (event in events) { + val uid = event.uid!! +// Log.d(Constants.TAG, "Found VEVENT: $uid") + uids += uid + +// val localEvents = calendar.queryByUID(uid) +// if (localEvents.isEmpty()) { +// Log.d(Constants.TAG, "$uid not in local calendar, adding") + + val client: ContentProviderClient? = requireActivity().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) + val calendar = LocalCalendar.findById(account, client!!, calendarId) + LocalEvent(calendar, event).add() + +// } else { +// val localEvent = localEvents.first() +// var lastModified = event.lastModified +// +// if (lastModified != null) { +// // process LAST-MODIFIED of exceptions +// for (exception in event.exceptions) { +// val exLastModified = exception.lastModified +// if (exLastModified == null) { +// lastModified = null +// break +// } else if (lastModified != null && exLastModified.dateTime.after(lastModified.date)) +// lastModified = exLastModified +// } +// } +// +// if (lastModified == null || lastModified.dateTime.time > localEvent.lastModified) +// // either there is no LAST-MODIFIED, or LAST-MODIFIED has been increased +// localEvent.update(event) +// else +// Log.d(Constants.TAG, "$uid has not been modified since last sync") +// } + } + +// Log.i(Constants.TAG, "Deleting old events (retaining ${uids.size} events by UID) …") +// val deleted = calendar.retainByUID(uids) +// Log.i(Constants.TAG, "… $deleted events deleted") + } + + private fun getThemeDrawable(attr: Int): Drawable { val typedValue = TypedValue() context!!.theme.resolveAttribute(attr, typedValue, true) @@ -201,6 +308,8 @@ class CalendarPreferences : PreferenceFragmentCompat() { companion object { const val COLOR_PICKER_DIALOG_TAG = "CalendarColorPickerDialog" + const val PICK_CALENDAR_FILE = 2 + const val ARG_CALENDAR_ID = "calendarId" const val SYNCHRONIZE_KEY = "synchronize"