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:
Patrick Lang 2022-02-09 13:35:25 +01:00 committed by Ricki Hirner
parent 6ab291bdb2
commit 16311708f8
22 changed files with 559 additions and 14 deletions

View File

@ -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

View File

@ -23,6 +23,7 @@ android {
buildConfigField "String", "userAgent", "\"DAVx5\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
//testInstrumentationRunnerArgument "notAnnotation", "androidx.test.filters.FlakyTest"
kapt {
arguments {

View File

@ -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")

View File

@ -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")

View File

@ -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" />

View File

@ -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>

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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")
}
}

View File

@ -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)")

View File

@ -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
}

View File

@ -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 =

View File

@ -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)

View File

@ -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)

View 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>

View File

@ -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"

View File

@ -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"

View File

@ -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>

View File

@ -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 &amp; 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>

View 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" />