mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-10-06 19:34:23 +00:00
Jtx Board synchronization (#56)
Support tasks and notes synchronization with Jtx Board Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
parent
6ab291bdb2
commit
16311708f8
2
.github/workflows/test-dev.yml
vendored
2
.github/workflows/test-dev.yml
vendored
|
@ -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
|
||||
|
|
|
@ -23,6 +23,7 @@ android {
|
|||
buildConfigField "String", "userAgent", "\"DAVx5\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
//testInstrumentationRunnerArgument "notAnnotation", "androidx.test.filters.FlakyTest"
|
||||
|
||||
kapt {
|
||||
arguments {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -162,6 +162,17 @@
|
|||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.JtxSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_notes"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.OpenTasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
|
@ -265,6 +276,7 @@
|
|||
<!-- package visiblity – which apps do we need to see? -->
|
||||
<queries>
|
||||
<!-- task providers -->
|
||||
<package android:name="at.techbee.jtx" />
|
||||
<package android:name="org.dmfs.tasks" />
|
||||
<package android:name="org.tasks" />
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ interface CollectionDao: SyncableDao<Collection> {
|
|||
* - 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<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync")
|
||||
|
@ -47,6 +47,9 @@ interface CollectionDao: SyncableDao<Collection> {
|
|||
@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<Collection>
|
||||
|
||||
@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<Collection>
|
||||
|
||||
@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<Collection>
|
||||
|
||||
|
|
|
@ -63,7 +63,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
|||
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<Event> {
|
|||
|
||||
// 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
|
||||
|
|
|
@ -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<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
|
||||
LocalCollection<LocalJtxICalObject>{
|
||||
|
||||
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<LocalJtxICalObject> {
|
||||
val values = queryDeletedICalObjects()
|
||||
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
|
||||
values.forEach {
|
||||
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
|
||||
}
|
||||
return localJtxICalObjects
|
||||
}
|
||||
|
||||
override fun findDirty(): List<LocalJtxICalObject> {
|
||||
val values = queryDirtyICalObjects()
|
||||
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
|
||||
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<LocalJtxCollection> {
|
||||
override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<JtxICalObject> {
|
||||
|
||||
|
||||
init {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
|
||||
object Factory : JtxICalObjectFactory<LocalJtxICalObject> {
|
||||
|
||||
override fun fromProvider(
|
||||
collection: JtxCollection<JtxICalObject>,
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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<HttpUrl, Collection>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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<LocalJtxICalObject, LocalJtxCollection, DavCalendar>(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<HttpUrl>) {
|
||||
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<JtxICalObject> = 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")
|
||||
}
|
||||
|
||||
}
|
|
@ -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)")
|
||||
|
|
|
@ -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<Boolean>()
|
||||
val haveTasksOrgPermissions = MutableLiveData<Boolean>()
|
||||
val needTasksOrgPermissions = MutableLiveData<Boolean>()
|
||||
val haveJtxPermissions = MutableLiveData<Boolean>()
|
||||
val needJtxPermissions = MutableLiveData<Boolean>()
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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<Boolean>()
|
||||
val tasksOrgRequested = MutableLiveData<Boolean>()
|
||||
val tasksOrgSelected = MutableLiveData<Boolean>()
|
||||
val jtxInstalled = MutableLiveData<Boolean>()
|
||||
val jtxRequested = MutableLiveData<Boolean>()
|
||||
val jtxSelected = MutableLiveData<Boolean>()
|
||||
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 =
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
10
app/src/main/res/drawable/ic_journals.xml
Normal file
10
app/src/main/res/drawable/ic_journals.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18,2L6,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,4c0,-1.1 -0.9,-2 -2,-2zM9,4h2v5l-1,-0.75L9,9L9,4zM18,20L6,20L6,4h1v9l3,-2.25L13,13L13,4h5v16z"/>
|
||||
</vector>
|
|
@ -78,6 +78,13 @@
|
|||
android:contentDescription="@string/account_task_list"
|
||||
app:srcCompat="@drawable/ic_playlist_add_check"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/journals"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@string/account_journal"
|
||||
app:srcCompat="@drawable/ic_journals"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/action_overflow"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
@ -287,6 +287,42 @@
|
|||
android:clickable="@{!model.haveTasksOrgPermissions}"
|
||||
android:checked="@={model.needTasksOrgPermissions}" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/jtxHeading"
|
||||
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/card_margin_title_text"
|
||||
android:text="@string/permissions_jtx_title"
|
||||
android:textAlignment="viewStart"
|
||||
android:visibility="@{model.haveJtxPermissions != null ? View.VISIBLE : View.GONE}"
|
||||
app:layout_constraintBottom_toTopOf="@id/jtxStatus"
|
||||
app:layout_constraintEnd_toStartOf="@id/jtxSwitch"
|
||||
app:layout_constraintStart_toEndOf="@id/start"
|
||||
app:layout_constraintTop_toBottomOf="@id/tasksOrgStatus" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/jtxStatus"
|
||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{model.haveJtxPermissions != null ? (model.haveJtxPermissions ? @string/permissions_jtx_status_on : @string/permissions_jtx_status_off) : @string/permissions_jtx_status_not_installed}"
|
||||
android:textAlignment="viewStart"
|
||||
android:visibility="@{model.haveJtxPermissions != null ? View.VISIBLE : View.GONE}"
|
||||
app:layout_constraintEnd_toStartOf="@id/jtxSwitch"
|
||||
app:layout_constraintStart_toEndOf="@id/start"
|
||||
app:layout_constraintTop_toBottomOf="@id/jtxHeading" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/jtxSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_constraintTop_toTopOf="@id/jtxHeading"
|
||||
app:layout_constraintBottom_toBottomOf="@id/jtxStatus"
|
||||
app:layout_constraintEnd_toStartOf="@id/end"
|
||||
android:visibility="@{model.haveJtxPermissions != null ? View.VISIBLE : View.GONE}"
|
||||
android:clickable="@{!model.haveJtxPermissions}"
|
||||
android:checked="@={model.needJtxPermissions}" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appSettingsHint"
|
||||
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||
|
@ -297,7 +333,7 @@
|
|||
android:textAlignment="viewStart"
|
||||
app:layout_constraintEnd_toEndOf="@id/end"
|
||||
app:layout_constraintStart_toStartOf="@id/start"
|
||||
app:layout_constraintTop_toBottomOf="@id/tasksOrgSwitch" />
|
||||
app:layout_constraintTop_toBottomOf="@id/jtxSwitch" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/appSettings"
|
||||
|
|
|
@ -92,7 +92,6 @@
|
|||
android:clickable="@{model.openTasksInstalled}"
|
||||
android:text="@string/intro_tasks_opentasks"
|
||||
android:textAlignment="viewStart"
|
||||
app:layout_constraintBottom_toTopOf="@id/tasksOrgRadio"
|
||||
app:layout_constraintEnd_toStartOf="@id/openTasksSwitch"
|
||||
app:layout_constraintStart_toStartOf="@id/start"
|
||||
app:layout_constraintTop_toBottomOf="@id/text1" />
|
||||
|
@ -101,7 +100,6 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/openTasksRadio"
|
||||
app:layout_constraintBottom_toTopOf="@id/tasksOrgRadio"
|
||||
app:layout_constraintStart_toStartOf="@id/start"
|
||||
app:layout_constraintEnd_toStartOf="@id/end"
|
||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
||||
|
@ -127,7 +125,6 @@
|
|||
android:clickable="@{model.tasksOrgInstalled}"
|
||||
android:text="@string/intro_tasks_tasks_org"
|
||||
android:textAlignment="viewStart"
|
||||
app:layout_constraintBottom_toTopOf="@id/tasksOrgInfo"
|
||||
app:layout_constraintEnd_toStartOf="@id/tasksOrgSwitch"
|
||||
app:layout_constraintStart_toStartOf="@id/start"
|
||||
app:layout_constraintTop_toBottomOf="@id/openTasksInfo" />
|
||||
|
@ -136,7 +133,6 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/tasksOrgRadio"
|
||||
app:layout_constraintBottom_toTopOf="@id/dontShow"
|
||||
app:layout_constraintStart_toStartOf="@id/start"
|
||||
app:layout_constraintEnd_toStartOf="@id/end"
|
||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
||||
|
@ -152,6 +148,39 @@
|
|||
app:layout_constraintStart_toEndOf="@id/tasksOrgRadio"
|
||||
app:layout_constraintEnd_toEndOf="@id/end"/>
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/jtxRadio"
|
||||
style="@style/TextAppearance.MaterialComponents.Body1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/card_margin_title_text"
|
||||
android:checked="@={model.jtxSelected}"
|
||||
android:clickable="@{model.jtxInstalled}"
|
||||
android:text="@string/intro_tasks_jtx"
|
||||
android:textAlignment="viewStart"
|
||||
app:layout_constraintEnd_toStartOf="@id/jtxSwitch"
|
||||
app:layout_constraintStart_toStartOf="@id/start"
|
||||
app:layout_constraintTop_toBottomOf="@id/tasksOrgInfo" />
|
||||
<TextView
|
||||
android:id="@+id/jtxInfo"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/jtxRadio"
|
||||
app:layout_constraintStart_toStartOf="@id/start"
|
||||
app:layout_constraintEnd_toStartOf="@id/end"
|
||||
style="@style/TextAppearance.MaterialComponents.Body2"
|
||||
app:html="@{@string/intro_tasks_jtx_info}" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/jtxSwitch"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="@={model.jtxRequested}"
|
||||
android:clickable="@{!model.jtxInstalled}"
|
||||
app:layout_constraintTop_toTopOf="@id/jtxRadio"
|
||||
app:layout_constraintBottom_toBottomOf="@id/jtxRadio"
|
||||
app:layout_constraintStart_toEndOf="@id/jtxRadio"
|
||||
app:layout_constraintEnd_toEndOf="@id/end"/>
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/dontShow"
|
||||
android:layout_width="0dp"
|
||||
|
@ -163,7 +192,7 @@
|
|||
android:visibility="@{model.openTasksInstalled ? View.GONE : View.VISIBLE}"
|
||||
app:layout_constraintEnd_toEndOf="@id/end"
|
||||
app:layout_constraintStart_toStartOf="@id/start"
|
||||
app:layout_constraintTop_toBottomOf="@id/tasksOrgInfo" />
|
||||
app:layout_constraintTop_toBottomOf="@id/jtxInfo" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
|
|
@ -43,6 +43,8 @@
|
|||
<string name="intro_autostart_dont_show">I have done the required settings. Don\'t remind me anymore.*</string>
|
||||
<string name="intro_leave_unchecked">* Leave unchecked to be reminded later. Can be reset in app settings / %s.</string>
|
||||
<string name="intro_more_info">More information</string>
|
||||
<string name="intro_tasks_jtx">jtx Board</string>
|
||||
<string name="intro_tasks_jtx_info"><![CDATA[Supports sync of Tasks, Journals and Notes.]]></string>
|
||||
<string name="intro_tasks_title">Tasks support</string>
|
||||
<string name="intro_tasks_text1">If tasks are supported by your server, they can be synchronized with a supported tasks app:</string>
|
||||
<string name="intro_tasks_opentasks">OpenTasks</string>
|
||||
|
@ -68,10 +70,14 @@
|
|||
<string name="permissions_calendar_title">Calendar permissions</string>
|
||||
<string name="permissions_calendar_status_off">No calendar sync (not recommended)</string>
|
||||
<string name="permissions_calendar_status_on">Calendar sync possible</string>
|
||||
<string name="permissions_jtx_title">jtx Board permissions</string>
|
||||
<string name="permissions_jtx_status_not_installed">No task, journals & notes sync (not installed)</string>
|
||||
<string name="permissions_jtx_status_off">No tasks, journals, notes sync</string>
|
||||
<string name="permissions_jtx_status_on">Tasks, journals, notes sync possible</string>
|
||||
<string name="permissions_opentasks_title">OpenTasks permissions</string>
|
||||
<string name="permissions_tasksorg_title">Tasks permissions</string>
|
||||
<string name="permissions_tasks_status_not_installed">No task sync (not installed)</string>
|
||||
<string name="permissions_tasks_status_off">No task sync (not recommended)</string>
|
||||
<string name="permissions_tasks_status_off">No task sync</string>
|
||||
<string name="permissions_tasks_status_on">Task sync possible</string>
|
||||
<string name="permissions_autoreset_title">Keep permissions</string>
|
||||
<string name="permissions_autoreset_status_off">Permissions may be reset automatically (not recommended)</string>
|
||||
|
@ -227,6 +233,7 @@
|
|||
<string name="account_read_only">read-only</string>
|
||||
<string name="account_calendar">calendar</string>
|
||||
<string name="account_task_list">task list</string>
|
||||
<string name="account_journal">journal</string>
|
||||
<string name="account_only_personal">Show only personal</string>
|
||||
<string name="account_refresh_address_book_list">Refresh address book list</string>
|
||||
<string name="account_create_new_address_book">Create new address book</string>
|
||||
|
|
5
app/src/main/res/xml/sync_notes.xml
Normal file
5
app/src/main/res/xml/sync_notes.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/account_type"
|
||||
android:contentAuthority="at.techbee.jtx.provider"
|
||||
android:allowParallelSyncs="true"
|
||||
android:supportsUploading="true" />
|
Loading…
Reference in a new issue