From 16311708f8778a530c710f5c891478675db6858c Mon Sep 17 00:00:00 2001 From: Patrick Lang <72232737+patrickunterwegs@users.noreply.github.com> Date: Wed, 9 Feb 2022 13:35:25 +0100 Subject: [PATCH] Jtx Board synchronization (#56) Support tasks and notes synchronization with Jtx Board Co-authored-by: Ricki Hirner --- .github/workflows/test-dev.yml | 2 +- app/build.gradle | 1 + .../davdroid/resource/LocalCalendarTest.kt | 2 + .../davdroid/resource/LocalEventTest.kt | 5 +- app/src/main/AndroidManifest.xml | 12 ++ .../bitfire/davdroid/model/CollectionDao.kt | 5 +- .../bitfire/davdroid/resource/LocalEvent.kt | 4 +- .../davdroid/resource/LocalJtxCollection.kt | 93 +++++++++++ .../davdroid/resource/LocalJtxICalObject.kt | 48 ++++++ .../syncadapter/JtxSyncAdapterService.kt | 87 ++++++++++ .../davdroid/syncadapter/JtxSyncManager.kt | 158 ++++++++++++++++++ .../bitfire/davdroid/ui/DebugInfoActivity.kt | 3 + .../davdroid/ui/PermissionsFragment.kt | 22 ++- .../at/bitfire/davdroid/ui/TasksFragment.kt | 20 +++ .../davdroid/ui/account/CalendarsFragment.kt | 1 + .../ui/account/CreateCalendarActivity.kt | 2 +- app/src/main/res/drawable/ic_journals.xml | 10 ++ .../main/res/layout/account_caldav_item.xml | 7 + .../main/res/layout/activity_permissions.xml | 38 ++++- app/src/main/res/layout/activity_tasks.xml | 39 ++++- app/src/main/res/values/strings.xml | 9 +- app/src/main/res/xml/sync_notes.xml | 5 + 22 files changed, 559 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt create mode 100644 app/src/main/java/at/bitfire/davdroid/resource/LocalJtxICalObject.kt create mode 100644 app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt create mode 100644 app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt create mode 100644 app/src/main/res/drawable/ic_journals.xml create mode 100644 app/src/main/res/xml/sync_notes.xml diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 5f1ceda3..42650717 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -51,7 +51,7 @@ jobs: - name: Start emulator run: start-emulator.sh - name: Run connected tests - run: ./gradlew app:connectedCheck + run: ./gradlew app:connectedCheck -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.FlakyTest - name: Archive results if: always() uses: actions/upload-artifact@v2 diff --git a/app/build.gradle b/app/build.gradle index 1dc9c0cc..0b7832bb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -23,6 +23,7 @@ android { buildConfigField "String", "userAgent", "\"DAVx5\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + //testInstrumentationRunnerArgument "notAnnotation", "androidx.test.filters.FlakyTest" kapt { arguments { diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt index 5e150d82..730a7e8c 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalCalendarTest.kt @@ -12,6 +12,7 @@ import android.content.ContentValues import android.provider.CalendarContract import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL import android.provider.CalendarContract.Events +import androidx.test.filters.FlakyTest import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.AndroidCalendar @@ -113,6 +114,7 @@ class LocalCalendarTest { } @Test + @FlakyTest(detail = "Fails when calendar storage is accessed the first time; probably some initialization thread") fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") diff --git a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt index b124b06a..4c3d4bd8 100644 --- a/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt +++ b/app/src/androidTest/java/at/bitfire/davdroid/resource/LocalEventTest.kt @@ -13,6 +13,7 @@ import android.os.Build import android.provider.CalendarContract import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL import android.provider.CalendarContract.Events +import androidx.test.filters.FlakyTest import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.AndroidCalendar @@ -66,6 +67,7 @@ class LocalEventTest { @Test + @FlakyTest(detail = "Fails when calendar storage is accessed the first time; probably some initialization thread") fun testNumDirectInstances_SingleInstance() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") @@ -306,7 +308,7 @@ class LocalEventTest { @Test fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() { - + // TODO } @Test @@ -358,6 +360,7 @@ class LocalEventTest { } @Test + @FlakyTest(detail = "Fails when calendar storage is accessed the first time; probably some initialization thread") fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() { val event = Event().apply { dtStart = DtStart("20220120T010203Z") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index df38b4bf..f430e42d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -162,6 +162,17 @@ android:name="android.content.SyncAdapter" android:resource="@xml/sync_calendars"/> + + + + + + + diff --git a/app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt b/app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt index bc89117a..641bc721 100644 --- a/app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt +++ b/app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt @@ -32,7 +32,7 @@ interface CollectionDao: SyncableDao { * - have supportsVEVENT = supportsVTODO = null (= address books) */ @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " + - "AND (supportsVTODO OR supportsVEVENT OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL)) ORDER BY displayName, URL") + "AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY displayName, URL") fun pageByServiceAndType(serviceId: Long, type: String): PagingSource @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync") @@ -47,6 +47,9 @@ interface CollectionDao: SyncableDao { @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName, url") fun getSyncCalendars(serviceId: Long): List + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND (supportsVTODO OR supportsVJOURNAL) AND sync ORDER BY displayName, url") + fun getSyncJtxCollections(serviceId: Long): List + @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName, url") fun getSyncTaskLists(serviceId: Long): List diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt index 06e2bf19..e5a64e02 100644 --- a/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt @@ -63,7 +63,7 @@ class LocalEvent: AndroidEvent, LocalResource { ContentUris.withAppendedId( Events.CONTENT_URI, eventID - ).asSyncAdapter(account), + ), arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null )?.use { cursor -> cursor.moveToNext() @@ -110,7 +110,7 @@ class LocalEvent: AndroidEvent, LocalResource { // add the number of instances of every main event's exception provider.query( - Events.CONTENT_URI.asSyncAdapter(account), + Events.CONTENT_URI, arrayOf(Events._ID), "${Events.ORIGINAL_ID}=?", // get exception events of the main event arrayOf("$eventID"), null diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt new file mode 100644 index 00000000..17eee9db --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxCollection.kt @@ -0,0 +1,93 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentValues +import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.model.Collection +import at.bitfire.davdroid.model.SyncState +import at.bitfire.ical4android.JtxCollection +import at.bitfire.ical4android.JtxCollectionFactory +import at.bitfire.ical4android.JtxICalObject +import at.techbee.jtx.JtxContract + +class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long): + JtxCollection(account, client, LocalJtxICalObject.Factory, id), + LocalCollection{ + + companion object { + + fun create(account: Account, client: ContentProviderClient, info: Collection) { + val values = valuesFromCollection(info, account) + create(account, client, values) + } + + fun valuesFromCollection(info: Collection, account: Account) = + ContentValues().apply { + put(JtxContract.JtxCollection.URL, info.url.toString()) + put(JtxContract.JtxCollection.DISPLAYNAME, info.displayName ?: DavUtils.lastSegmentOfUrl(info.url)) + put(JtxContract.JtxCollection.DESCRIPTION, info.description) + put(JtxContract.JtxCollection.OWNER, info.owner?.toString()) + put(JtxContract.JtxCollection.COLOR, info.color) + put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT) + put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL) + put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO) + put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name) + put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type) + put(JtxContract.JtxCollection.READONLY, info.forceReadOnly) + } + + } + + override val tag: String + get() = "jtx-${account.name}-$id" + override val title: String + get() = displayname ?: id.toString() + override var lastSyncState: SyncState? + get() = SyncState.fromString(syncstate) + set(value) { syncstate = value.toString() } + + fun updateCollection(info: Collection) { + val values = valuesFromCollection(info, account) + update(values) + } + + override fun findDeleted(): List { + val values = queryDeletedICalObjects() + val localJtxICalObjects = mutableListOf() + values.forEach { + localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it)) + } + return localJtxICalObjects + } + + override fun findDirty(): List { + val values = queryDirtyICalObjects() + val localJtxICalObjects = mutableListOf() + values.forEach { + localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it)) + } + return localJtxICalObjects + } + + override fun findByName(name: String): LocalJtxICalObject? { + val values = queryByFilename(name) ?: return null + return LocalJtxICalObject.Factory.fromProvider(this, values) + } + + override fun markNotDirty(flags: Int)= updateSetFlags(flags) + + override fun removeNotDirtyMarked(flags: Int) = deleteByFlags(flags) + + override fun forgetETags() = updateSetETag(null) + + + object Factory: JtxCollectionFactory { + override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id) + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxICalObject.kt b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxICalObject.kt new file mode 100644 index 00000000..9c262d13 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/resource/LocalJtxICalObject.kt @@ -0,0 +1,48 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.resource + +import android.content.ContentValues +import at.bitfire.ical4android.* +import at.techbee.jtx.JtxContract + +class LocalJtxICalObject( + collection: JtxCollection<*>, + fileName: String?, + eTag: String?, + scheduleTag: String?, + flags: Int +) : + JtxICalObject(collection), + LocalResource { + + + init { + this.fileName = fileName + this.eTag = eTag + this.flags = flags + this.scheduleTag = scheduleTag + } + + + object Factory : JtxICalObjectFactory { + + override fun fromProvider( + collection: JtxCollection, + values: ContentValues + ): LocalJtxICalObject { + val fileName = values.getAsString(JtxContract.JtxICalObject.FILENAME) + val eTag = values.getAsString(JtxContract.JtxICalObject.ETAG) + val scheduleTag = values.getAsString(JtxContract.JtxICalObject.SCHEDULETAG) + val flags = values.getAsInteger(JtxContract.JtxICalObject.FLAGS)?: 0 + + val localJtxICalObject = LocalJtxICalObject(collection, fileName, eTag, scheduleTag, flags) + localJtxICalObject.populateFromContentValues(values) + + return localJtxICalObject + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt new file mode 100644 index 00000000..9562dfc1 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncAdapterService.kt @@ -0,0 +1,87 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentProviderClient +import android.content.Context +import android.content.SyncResult +import android.os.Build +import android.os.Bundle +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.model.AppDatabase +import at.bitfire.davdroid.model.Collection +import at.bitfire.davdroid.model.Service +import at.bitfire.davdroid.resource.LocalJtxCollection +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.JtxCollection +import at.bitfire.ical4android.TaskProvider +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.util.logging.Level + +class JtxSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = JtxSyncAdapter(this) + + + class JtxSyncAdapter(context: Context): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + val accountSettings = AccountSettings(context, account) + + // make sure account can be seen by task provider + if (Build.VERSION.SDK_INT >= 26) + AccountManager.get(context).setAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName, AccountManager.VISIBILITY_VISIBLE) + + //sync list of collections + updateLocalCollections(account, provider) + + // sync contents of collections + val collections = JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null) + for (collection in collections) { + Logger.log.info("Synchronizing $collection") + JtxSyncManager(context, account, accountSettings, extras, authority, syncResult, collection).use { + it.performSync() + } + } + } + + private fun updateLocalCollections(account: Account, client: ContentProviderClient) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteCollections = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncJtxCollections(service.id)) + remoteCollections[collection.url] = collection + + for (jtxCollection in JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null)) + jtxCollection.url?.let { strUrl -> + val url = strUrl.toHttpUrl() + val info = remoteCollections[url] + if (info == null) { + Logger.log.fine("Deleting obsolete local collection $url") + jtxCollection.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local collection $url", info) + jtxCollection.updateCollection(info) + // we already have a local task list for this remote collection, don't take into consideration anymore + remoteCollections -= url + } + } + + // create new local collections + for ((_,info) in remoteCollections) { + Logger.log.log(Level.INFO, "Adding local collections", info) + LocalJtxCollection.create(account, client, info) + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt new file mode 100644 index 00000000..4d419eed --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt @@ -0,0 +1,158 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.davdroid.syncadapter + +import android.accounts.Account +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import at.bitfire.dav4jvm.DavCalendar +import at.bitfire.dav4jvm.DavResponseCallback +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.property.* +import at.bitfire.davdroid.DavUtils +import at.bitfire.davdroid.R +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.model.SyncState +import at.bitfire.davdroid.resource.LocalJtxCollection +import at.bitfire.davdroid.resource.LocalJtxICalObject +import at.bitfire.davdroid.resource.LocalResource +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.ical4android.InvalidCalendarException +import at.bitfire.ical4android.JtxICalObject +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.ByteArrayOutputStream +import java.io.Reader +import java.io.StringReader +import java.util.logging.Level + +class JtxSyncManager( + context: Context, + account: Account, + accountSettings: AccountSettings, + extras: Bundle, + authority: String, + syncResult: SyncResult, + localCollection: LocalJtxCollection +): SyncManager(context, account, accountSettings, extras, authority, syncResult, localCollection) { + + override fun prepare(): Boolean { + collectionURL = (localCollection.url ?: return false).toHttpUrlOrNull() ?: return false + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + + return true + } + + override fun queryCapabilities(): SyncState? = + remoteExceptionContext { + var syncState: SyncState? = null + it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) { + response[SupportedReportSet::class.java]?.let { supported -> + hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) + } + syncState = syncState(response) + } + } + + Logger.log.info("Server supports Collection Sync: $hasCollectionSync") + syncState + } + + override fun generateUpload(resource: LocalJtxICalObject): RequestBody = localExceptionContext(resource) { + Logger.log.log(Level.FINE, "Preparing upload of icalobject ${resource.fileName}", resource) + + val os = ByteArrayOutputStream() + resource.write(os) + + os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + } + + override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT + + override fun listAllRemote(callback: DavResponseCallback) { + remoteExceptionContext { remote -> + Logger.log.info("Querying tasks") + remote.calendarQuery("VTODO", null, null, callback) + Logger.log.info("Querying journals") + remote.calendarQuery("VJOURNAL", null, null, callback) + } + } + + override fun downloadRemote(bunch: List) { + Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch") + // multiple iCalendars, use calendar-multi-get + remoteExceptionContext { + it.multiget(bunch) { response, _ -> + responseExceptionContext(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@responseExceptionContext + } + + val eTag = response[GetETag::class.java]?.eTag + ?: throw DavException("Received multi-get response without ETag") + + val calendarData = response[CalendarData::class.java] + val iCal = calendarData?.iCalendar + ?: throw DavException("Received multi-get response without address data") + + processICalObject(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal)) + } + } + } + } + + override fun postProcess() { + /* related-to entries must be updated. The linkedICalObjectId is set to 0 for synced entries as we cannot be sure that the linked entry is already + there when the related-to entry is written. therefore we have to update it here in the postProcess() method. */ + + localCollection.updateRelatedTo() + } + + override fun notifyInvalidResourceTitle(): String = + context.getString(R.string.sync_invalid_event) + + + private fun processICalObject(fileName: String, eTag: String, reader: Reader) { + val icalobjects: MutableList = mutableListOf() + try { + // parse the reader content and return the list of ICalObjects + icalobjects.addAll(JtxICalObject.fromReader(reader, localCollection)) + } catch (e: InvalidCalendarException) { + Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e) + notifyInvalidResource(e, fileName) + return + } + + if (icalobjects.size == 1) { + val newData = icalobjects.first() + + // update local task, if it exists + localExceptionContext(localCollection.findByName(fileName)) { local -> + if (local != null) { + Logger.log.log(Level.INFO, "Updating $fileName in local task list", newData) + local.eTag = eTag + local.update(newData) + syncResult.stats.numUpdates++ + } else { + Logger.log.log(Level.INFO, "Adding $fileName to local task list", newData) + + localExceptionContext(LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)) { + it.applyNewData(newData) + it.add() + } + syncResult.stats.numInserts++ + } + } + } else + Logger.log.info("Received VCALENDAR with not exactly one VTODO or VJOURNAL; ignoring $fileName") + } + +} \ No newline at end of file diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt index e8567c7e..1882600d 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -46,6 +46,7 @@ import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.ical4android.MiscUtils.ContentProviderClientHelper.closeCompat import at.bitfire.ical4android.TaskProvider.ProviderName +import at.techbee.jtx.JtxContract import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -284,6 +285,7 @@ class DebugInfoActivity: AppCompatActivity() { val packageNames = mutableSetOf( // we always want info about these packages: BuildConfig.APPLICATION_ID, // DAVx5 + ProviderName.JtxBoard.packageName, // jtx Board ProviderName.OpenTasks.packageName, // OpenTasks ProviderName.TasksOrg.packageName // tasks.org ) @@ -603,6 +605,7 @@ class DebugInfoActivity: AppCompatActivity() { fun mainAccount(context: Context) = listOf( AccountDumpInfo(context.getString(R.string.address_books_authority), null, null), AccountDumpInfo(CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI, "event(s)"), + AccountDumpInfo(ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI, "jtx Board ICalObject(s)"), AccountDumpInfo(ProviderName.OpenTasks.authority, TaskContract.Tasks.getContentUri(ProviderName.OpenTasks.authority), "OpenTasks task(s)"), AccountDumpInfo(ProviderName.TasksOrg.authority, TaskContract.Tasks.getContentUri(ProviderName.TasksOrg.authority), "tasks.org task(s)"), AccountDumpInfo(ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI, "wrongly assigned raw contact(s)") diff --git a/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt index 0c7ca5d9..9a914b7e 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/PermissionsFragment.kt @@ -64,6 +64,10 @@ class PermissionsFragment: Fragment() { if (needTasksOrg == true && model.haveTasksOrgPermissions.value == false) requestPermissions(TaskProvider.PERMISSIONS_TASKS_ORG, 0) }) + model.needJtxPermissions.observe(viewLifecycleOwner, { needJtx -> + if (needJtx == true && model.haveJtxPermissions.value == false) + requestPermissions(TaskProvider.PERMISSIONS_JTX, 0) + }) model.needAllPermissions.observe(viewLifecycleOwner, { needAll -> if (needAll && model.haveAllPermissions.value == false) { val all = mutableSetOf(*CONTACT_PERMISSIONS, *CALENDAR_PERMISSIONS) @@ -71,6 +75,8 @@ class PermissionsFragment: Fragment() { all.addAll(TaskProvider.PERMISSIONS_OPENTASKS) if (model.haveTasksOrgPermissions.value != null) all.addAll(TaskProvider.PERMISSIONS_TASKS_ORG) + if (model.haveJtxPermissions.value != null) + all.addAll(TaskProvider.PERMISSIONS_JTX) requestPermissions(all.toTypedArray(), 0) } }) @@ -107,6 +113,8 @@ class PermissionsFragment: Fragment() { val needOpenTasksPermissions = MutableLiveData() val haveTasksOrgPermissions = MutableLiveData() val needTasksOrgPermissions = MutableLiveData() + val haveJtxPermissions = MutableLiveData() + val needJtxPermissions = MutableLiveData() val tasksWatcher = object: PackageChangedReceiver(app) { @MainThread override fun onReceive(context: Context?, intent: Intent?) { @@ -167,12 +175,24 @@ class PermissionsFragment: Fragment() { haveTasksOrgPermissions.value = null needTasksOrgPermissions.value = null } + // jtx Board + val jtxAvailable = pm.resolveContentProvider(ProviderName.JtxBoard.authority, 0) != null + var jtxPermissions: Boolean? = null + if (jtxAvailable) { + jtxPermissions = havePermissions(getApplication(), TaskProvider.PERMISSIONS_JTX) + haveJtxPermissions.value = jtxPermissions + needJtxPermissions.value = jtxPermissions + } else { + haveJtxPermissions.value = null + needJtxPermissions.value = null + } // "all permissions" switch val allPermissions = contactPermissions && calendarPermissions && (!openTasksAvailable || openTasksPermissions == true) && - (!tasksOrgAvailable || tasksOrgPermissions == true) + (!tasksOrgAvailable || tasksOrgPermissions == true) && + (!jtxAvailable || jtxPermissions == true) haveAllPermissions.value = allPermissions needAllPermissions.value = allPermissions } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/TasksFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/TasksFragment.kt index 732830e0..ce37b833 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/TasksFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/TasksFragment.kt @@ -62,6 +62,18 @@ class TasksFragment: Fragment() { model.selectPreferredProvider(ProviderName.TasksOrg) } + + model.jtxRequested.observe(viewLifecycleOwner) { shallBeInstalled -> + if (shallBeInstalled && model.jtxInstalled.value == false) { + model.jtxRequested.value = false + installApp(ProviderName.JtxBoard.packageName) + } + } + model.jtxSelected.observe(viewLifecycleOwner) { selected -> + if (selected && model.currentProvider.value != ProviderName.JtxBoard) + model.selectPreferredProvider(ProviderName.JtxBoard) + } + binding.infoLeaveUnchecked.text = getString(R.string.intro_leave_unchecked, getString(R.string.app_settings_reset_hints)) return binding.root @@ -104,6 +116,9 @@ class TasksFragment: Fragment() { val tasksOrgInstalled = MutableLiveData() val tasksOrgRequested = MutableLiveData() val tasksOrgSelected = MutableLiveData() + val jtxInstalled = MutableLiveData() + val jtxRequested = MutableLiveData() + val jtxSelected = MutableLiveData() val tasksWatcher = object: PackageChangedReceiver(app) { override fun onReceive(context: Context?, intent: Intent?) { checkInstalled() @@ -146,6 +161,11 @@ class TasksFragment: Fragment() { tasksOrgInstalled.postValue(tasksOrg) tasksOrgRequested.postValue(tasksOrg) tasksOrgSelected.postValue(taskProvider == ProviderName.TasksOrg) + + val jtxBoard = isInstalled(ProviderName.JtxBoard.packageName) + jtxInstalled.postValue(jtxBoard) + jtxRequested.postValue(jtxBoard) + jtxSelected.postValue(taskProvider == ProviderName.JtxBoard) } private fun isInstalled(packageName: String): Boolean = diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/CalendarsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CalendarsFragment.kt index b13c8d0a..9086672b 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/CalendarsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CalendarsFragment.kt @@ -80,6 +80,7 @@ class CalendarsFragment: CollectionsFragment() { binding.readOnly.visibility = if (item.readOnly()) View.VISIBLE else View.GONE binding.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE binding.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE + binding.journals.visibility = if (item.supportsVJOURNAL == true) View.VISIBLE else View.GONE itemView.setOnClickListener { accountModel.toggleSync(item) diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt index 5b5b8965..65e0272b 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt @@ -167,7 +167,7 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener { } else model.typeError.value = null - if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) { + if (supportsVEVENT || supportsVTODO || supportsVJOURNAL) { // only if there's at least one component set not supported; don't include // information about supported components otherwise (means: everything supported) args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VEVENT, supportsVEVENT) diff --git a/app/src/main/res/drawable/ic_journals.xml b/app/src/main/res/drawable/ic_journals.xml new file mode 100644 index 00000000..97187f4d --- /dev/null +++ b/app/src/main/res/drawable/ic_journals.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/account_caldav_item.xml b/app/src/main/res/layout/account_caldav_item.xml index 61cdf3fb..a0496294 100644 --- a/app/src/main/res/layout/account_caldav_item.xml +++ b/app/src/main/res/layout/account_caldav_item.xml @@ -78,6 +78,13 @@ android:contentDescription="@string/account_task_list" app:srcCompat="@drawable/ic_playlist_add_check"/> + + + + + + + + app:layout_constraintTop_toBottomOf="@id/jtxSwitch" />