mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-03-02 05:48:07 -05:00
Compare commits
70 Commits
v4.5.2-ose
...
testing-sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f36e826d8 | ||
|
|
3737d69397 | ||
|
|
2dbd5c02b6 | ||
|
|
c12e9311f7 | ||
|
|
b663912feb | ||
|
|
3c484f253f | ||
|
|
de7f8d2964 | ||
|
|
a79a39c25d | ||
|
|
20675ed71b | ||
|
|
881588f8e8 | ||
|
|
0c31758880 | ||
|
|
9ffd59cd00 | ||
|
|
c40b2b38bc | ||
|
|
3025ea7491 | ||
|
|
b84a812d7a | ||
|
|
562afc5666 | ||
|
|
8992859b63 | ||
|
|
03013b5576 | ||
|
|
0028fc8722 | ||
|
|
1b4ebde896 | ||
|
|
0cc84dfd01 | ||
|
|
87239daaf6 | ||
|
|
81ceb57842 | ||
|
|
cd0b0c0804 | ||
|
|
48cbd4a05d | ||
|
|
beccc7a0d4 | ||
|
|
2b629c8b18 | ||
|
|
cd725479cd | ||
|
|
44666d2138 | ||
|
|
8e67db7d54 | ||
|
|
a58e3b9036 | ||
|
|
d63918ff42 | ||
|
|
f21c3de94a | ||
|
|
24d4ba65e5 | ||
|
|
ae96f1ffbb | ||
|
|
a08ecae635 | ||
|
|
eb4224780a | ||
|
|
0240e67dab | ||
|
|
0ccd9d5eb3 | ||
|
|
438f967152 | ||
|
|
a093238864 | ||
|
|
293daf1e82 | ||
|
|
3b50747ce9 | ||
|
|
51d6ed279a | ||
|
|
2c6842ac0c | ||
|
|
0e6644305a | ||
|
|
10e3b0a723 | ||
|
|
2f45b705b3 | ||
|
|
be6c3311d7 | ||
|
|
755863778b | ||
|
|
0e81866d3a | ||
|
|
93a256ee75 | ||
|
|
61e9d60b7c | ||
|
|
dc9fb7b608 | ||
|
|
44b52f65a2 | ||
|
|
e13c140554 | ||
|
|
cdb50205f4 | ||
|
|
2ba4a2a510 | ||
|
|
38b2377760 | ||
|
|
10f6356a6e | ||
|
|
df4b6d3fbc | ||
|
|
dab948730e | ||
|
|
288583bfad | ||
|
|
98c0b0c36a | ||
|
|
ed7a477d3f | ||
|
|
b0609fafb2 | ||
|
|
94a85833bc | ||
|
|
4c5c8c3ed0 | ||
|
|
4685ab6d0c | ||
|
|
62a0ba3520 |
5
.github/dependabot.yml
vendored
5
.github/dependabot.yml
vendored
@@ -8,4 +8,7 @@ updates:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "[CI] "
|
||||
prefix: "[CI] "
|
||||
groups:
|
||||
ci-actions:
|
||||
patterns: ["*"]
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -28,8 +28,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -19,8 +19,8 @@ jobs:
|
||||
discussions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
12
.github/workflows/test-dev.yml
vendored
12
.github/workflows/test-dev.yml
vendored
@@ -15,8 +15,8 @@ jobs:
|
||||
if: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -35,8 +35,8 @@ jobs:
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
@@ -56,8 +56,8 @@ jobs:
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
@@ -19,10 +19,10 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 405020002
|
||||
versionName = "4.5.2"
|
||||
versionCode = 405040002
|
||||
versionName = "4.5.4-rc.1"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
base.archivesName = "davx5-ose-$versionName"
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 36 // Android 16
|
||||
@@ -188,7 +188,6 @@ dependencies {
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.mikepenz.aboutLibraries)
|
||||
implementation(libs.nsk90.kstatemachine)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.util.Xml
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class Dav4jvmTest {
|
||||
|
||||
@Test
|
||||
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.util.Xml
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExternalLibrariesTest {
|
||||
|
||||
@Test
|
||||
fun test_Dav4jvm_XmlUtils_NewPullParser_RelaxedParsing() {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
assertTrue(parser.getFeature(Xml.FEATURE_RELAXED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOkhttpHttpUrl_PublicSuffixList() {
|
||||
// HttpUrl.topPrivateDomain() requires okhttp's internal PublicSuffixList.
|
||||
// In Android, loading the PublicSuffixList is done over AndroidX startup.
|
||||
// This test verifies that everything is working.
|
||||
assertEquals("example.com", "http://example.com".toHttpUrl().topPrivateDomain())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import at.bitfire.davdroid.di.TestCoroutineDispatchersModule
|
||||
import at.bitfire.davdroid.sync.SyncAdapterService
|
||||
import at.bitfire.davdroid.test.BuildConfig
|
||||
import at.bitfire.synctools.log.LogcatHandler
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
@@ -36,9 +35,6 @@ class HiltTestRunner : AndroidJUnitRunner() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
|
||||
throw AssertionError("MockK requires Android P [https://mockk.io/ANDROID.html]")
|
||||
|
||||
// disable sync adapters
|
||||
SyncAdapterService.syncActive.set(false)
|
||||
|
||||
// set main dispatcher for tests (especially runTest)
|
||||
TestCoroutineDispatchersModule.initMainDispatcher()
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ abstract class DatabaseMigrationTest(
|
||||
/**
|
||||
* Used for testing the migration process from [toVersion]-1 to [toVersion].
|
||||
*
|
||||
* Note: SQLite's foreign key constraint enforcement is not enabled in tests. We need
|
||||
* to enable it ourselves using setting "PRAGMA foreign_keys=ON" directly after opening
|
||||
* a new database connection (works per connection). In tests it's usually more practical
|
||||
* not to do so, however. In production database connections room enables it for us.
|
||||
*
|
||||
* @param prepare Callback to prepare the database. Will be run with database schema in version [toVersion] - 1.
|
||||
* @param validate Callback to validate the migration result. Will be run with database schema in version [toVersion].
|
||||
*/
|
||||
@@ -61,6 +66,8 @@ abstract class DatabaseMigrationTest(
|
||||
// Prepare the database with the initial version.
|
||||
val dbName = "test"
|
||||
helper.createDatabase(dbName, version = toVersion - 1).apply {
|
||||
// We could enable foreign key constraint enforcement here
|
||||
// by setting "PRAGMA foreign_keys=ON".
|
||||
prepare(this)
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.di
|
||||
|
||||
import at.bitfire.davdroid.sync.FakeSyncAdapter
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapter
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
|
||||
@Module
|
||||
@TestInstallIn(components = [SingletonComponent::class], replaces = [SyncAdapterImpl.RealSyncAdapterModule::class])
|
||||
abstract class FakeSyncAdapterModule {
|
||||
@Binds
|
||||
abstract fun provide(impl: FakeSyncAdapter): SyncAdapter
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@@ -98,7 +99,7 @@ class LocalAddressBookTest {
|
||||
val id = ContentUris.parseId(uri)
|
||||
|
||||
// make sure it's not dirty
|
||||
localGroup.clearDirty(null, null, null)
|
||||
localGroup.clearDirty(Optional.empty(), null, null)
|
||||
assertFalse("Group is dirty before moving", isGroupDirty(addressBook, id))
|
||||
|
||||
// rename address book
|
||||
@@ -127,7 +128,7 @@ class LocalAddressBookTest {
|
||||
*/
|
||||
fun isContactDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(adddressBook.rawContactsSyncUri(), id)
|
||||
provider!!.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
|
||||
provider.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
@@ -143,7 +144,7 @@ class LocalAddressBookTest {
|
||||
*/
|
||||
fun isGroupDirty(adddressBook: LocalAddressBook, id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(adddressBook.groupsSyncUri(), id)
|
||||
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
|
||||
provider.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Entity
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||
import at.bitfire.synctools.test.InitCalendarProviderRule
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
@@ -26,6 +29,8 @@ import net.fortuna.ical4j.model.property.RecurrenceId
|
||||
import net.fortuna.ical4j.model.property.Status
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -45,6 +50,7 @@ class LocalCalendarTest {
|
||||
lateinit var localCalendarFactory: LocalCalendar.Factory
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var androidCalendar: AndroidCalendar
|
||||
private lateinit var client: ContentProviderClient
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@@ -56,12 +62,13 @@ class LocalCalendarTest {
|
||||
client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
|
||||
val provider = AndroidCalendarProvider(account, client)
|
||||
calendar = localCalendarFactory.create(provider.createAndGetCalendar(ContentValues()))
|
||||
androidCalendar = provider.createAndGetCalendar(ContentValues())
|
||||
calendar = localCalendarFactory.create(androidCalendar)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
calendar.androidCalendar.delete()
|
||||
androidCalendar.delete()
|
||||
client.closeCompat()
|
||||
}
|
||||
|
||||
@@ -92,9 +99,15 @@ class LocalCalendarTest {
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
val eventId = localEvent.id
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
@@ -122,26 +135,102 @@ class LocalCalendarTest {
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
val eventUrl = androidCalendar.eventUri(localEvent.id)
|
||||
|
||||
// set event as dirty
|
||||
client.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
client.update(eventUrl, contentValuesOf(
|
||||
Events.DIRTY to 1
|
||||
), null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
client.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
client.query(eventUrl, arrayOf(Events.DELETED), null, null, null)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that [LocalCalendar.removeNotDirtyMarked] works as expected.
|
||||
* @param contentValues values to set on the event. Required:
|
||||
* - [Events._ID]
|
||||
* - [Events.DIRTY]
|
||||
*/
|
||||
private fun testRemoveNotDirtyMarked(contentValues: ContentValues) {
|
||||
val id = androidCalendar.addEvent(Entity(
|
||||
contentValuesOf(
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.DTEND to System.currentTimeMillis(),
|
||||
Events.TITLE to "Some Event",
|
||||
AndroidEvent2.COLUMN_FLAGS to 123
|
||||
).apply { putAll(contentValues) }
|
||||
))
|
||||
|
||||
calendar.removeNotDirtyMarked(123)
|
||||
|
||||
assertNull(androidCalendar.getEvent(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRemoveNotDirtyMarked_IdLargerThanIntMaxValue() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to Int.MAX_VALUE.toLong() + 10, Events.DIRTY to 0)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testRemoveNotDirtyMarked_DirtyIs0() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to 1, Events.DIRTY to 0)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testRemoveNotDirtyMarked_DirtyNull() = testRemoveNotDirtyMarked(
|
||||
contentValuesOf(Events._ID to 1, Events.DIRTY to null)
|
||||
)
|
||||
|
||||
/**
|
||||
* Verifies that [LocalCalendar.markNotDirty] works as expected.
|
||||
* @param contentValues values to set on the event. Required:
|
||||
* - [Events.DIRTY]
|
||||
*/
|
||||
private fun testMarkNotDirty(contentValues: ContentValues) {
|
||||
val id = androidCalendar.addEvent(Entity(
|
||||
contentValuesOf(
|
||||
Events.CALENDAR_ID to androidCalendar.id,
|
||||
Events._ID to 1,
|
||||
Events.DTSTART to System.currentTimeMillis(),
|
||||
Events.DTEND to System.currentTimeMillis(),
|
||||
Events.TITLE to "Some Event",
|
||||
AndroidEvent2.COLUMN_FLAGS to 123
|
||||
).apply { putAll(contentValues) }
|
||||
))
|
||||
|
||||
val updated = calendar.markNotDirty(321)
|
||||
assertEquals(1, updated)
|
||||
assertEquals(321, androidCalendar.getEvent(id)?.flags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_markNotDirty_DirtyIs0() = testMarkNotDirty(
|
||||
contentValuesOf(
|
||||
Events.DIRTY to 0
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun test_markNotDirty_DirtyIsNull() = testMarkNotDirty(
|
||||
contentValuesOf(
|
||||
Events.DIRTY to null
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
@@ -74,8 +73,15 @@ class LocalEventTest {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without uid"
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
@@ -102,8 +108,14 @@ class LocalEventTest {
|
||||
summary = "Event with normal uid"
|
||||
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
|
||||
localEvent.add() // save it to calendar storage
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
|
||||
// prepare for upload - this should use the UID for the file name
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
@@ -129,8 +141,14 @@ class LocalEventTest {
|
||||
summary = "Event with funny uid"
|
||||
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, null))
|
||||
localEvent.add() // save it to calendar storage
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
@@ -181,8 +199,14 @@ class LocalEventTest {
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT))
|
||||
localEvent.add()
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
@@ -210,8 +234,14 @@ class LocalEventTest {
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = LocalEvent(AndroidEvent(calendar.androidCalendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT))
|
||||
localEvent.add()
|
||||
calendar.add(
|
||||
event = event,
|
||||
fileName = "filename.ics",
|
||||
eTag = null,
|
||||
scheduleTag = null,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
val localEvent = calendar.findByName("filename.ics")!!
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.Optional
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
@@ -164,7 +165,7 @@ class LocalGroupTest {
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
group.clearDirty(Optional.empty(), null)
|
||||
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
@@ -200,7 +201,7 @@ class LocalGroupTest {
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
group.clearDirty(Optional.empty(), null)
|
||||
|
||||
// cached group membership should be gone
|
||||
ab.provider!!.query(
|
||||
|
||||
@@ -10,7 +10,7 @@ import android.content.Context
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
|
||||
@@ -1,749 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionListRefresherTest {
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionListRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
refresherFactory.create(service, client.okHttpClient).discoverHomesets(baseUrl)
|
||||
|
||||
// Check home set has been saved correctly to database
|
||||
val savedHomesets = db.homeSetDao().getByService(service.id)
|
||||
assertEquals(2, savedHomesets.size)
|
||||
|
||||
// Home set from current-user-principal
|
||||
val personalHomeset = savedHomesets[1]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
|
||||
assertEquals(service.id, personalHomeset.serviceId)
|
||||
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
|
||||
assertEquals(true, personalHomeset.personal)
|
||||
|
||||
// Home set found in a group principal
|
||||
val groupHomeset = savedHomesets[0]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
|
||||
assertEquals(service.id, groupHomeset.serviceId)
|
||||
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
|
||||
assertEquals(false, groupHomeset.personal)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection defined in homeset is now in the database
|
||||
assertEquals(
|
||||
Collection(
|
||||
1,
|
||||
service.id,
|
||||
homesetId,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().getByService(service.id).first()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
|
||||
// save "old" collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
|
||||
// save "old" collection in DB - with set flags
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
|
||||
// save homeset in DB - which is empty (zero address books) on the serverside
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should mark collection as homeless, because serverside homeset is empty.
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection, is now marked as homeless
|
||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId, // part of above home set
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - homesets and their collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomelessCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_updatesExistingCollection() {
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got updated - with display name and description
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_deletesInaccessibleCollections() {
|
||||
// place homeless collection in DB - it is also inaccessible
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should delete collection
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_addsOwnerUrls() {
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh homeless collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
refresherFactory.create(service, client.okHttpClient).refreshHomelessCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// refreshPrincipals
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_inaccessiblePrincipal() {
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was not updated
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_updatesPrincipal() {
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal now got a display name
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals("Mr. Wobbles", principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
|
||||
// place principal without collections in DB
|
||||
db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals - detecting it does not own collections
|
||||
refresherFactory.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(0, principals.size)
|
||||
}
|
||||
|
||||
// Others
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = refresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
|
||||
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
|
||||
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CALDAV,
|
||||
PATH_CARDDAV ->
|
||||
"<current-user-principal>" +
|
||||
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
|
||||
"</current-user-principal>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<group-membership>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>All address books</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>Freds Contacts (not mine)</displayname>" +
|
||||
"<CARD:addressbook-description>Not personal contacts</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" + // OK, user is allowed to own non-personal contacts
|
||||
"</owner>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
"<CAL:calendar-user-address-set>" +
|
||||
" <href>urn:unknown-entry</href>" +
|
||||
" <href>mailto:email1@example.com</href>" +
|
||||
" <href>mailto:email2@example.com</href>" +
|
||||
"</CAL:calendar-user-address-set>"
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
var responseBody = ""
|
||||
var responseCode = 207
|
||||
when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
|
||||
responseCode = 404
|
||||
|
||||
else ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>"+
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
logger.info("Response: $responseBody")
|
||||
return MockResponse()
|
||||
.setResponseCode(responseCode)
|
||||
.setBody(responseBody)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionsWithoutHomeSetRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var refresherFactory: CollectionsWithoutHomeSetRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
// refreshCollectionsWithoutHomeSet
|
||||
|
||||
@Test
|
||||
fun refreshCollectionsWithoutHomeSet_updatesExistingCollection() {
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Check the collection got updated - with display name and description
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshCollectionsWithoutHomeSet_deletesInaccessibleCollectionsWithoutHomeSet() {
|
||||
// place homeless collection in DB - it is also inaccessible
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should delete collection
|
||||
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshCollectionsWithoutHomeSet_addsOwnerUrls() {
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh homeless collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
refresherFactory.create(service, client.okHttpClient).refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
): Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
logger.info("${request.method} on $path")
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>"+
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class HomeSetRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var homeSetRefresherFactory: HomeSetRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() = runTest {
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection defined in homeset is now in the database
|
||||
assertEquals(
|
||||
Collection(
|
||||
1,
|
||||
service.id,
|
||||
homesetId,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().getByService(service.id).first()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
|
||||
// save "old" collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
|
||||
// save "old" collection in DB - with set flags
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
|
||||
// save homeset in DB - which is empty (zero address books) on the serverside
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should mark collection as homeless, because serverside homeset is empty.
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection, is now marked as homeless
|
||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id = 0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId, // part of above home set
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - homesets and their collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
homeSetRefresherFactory.create(service, client.okHttpClient).refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// other
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = false,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val collectionUrl = mockServer.url("/addressbook-homeset/addressbook/")
|
||||
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
homeSetId = 0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(
|
||||
id = 0,
|
||||
serviceId = service.id,
|
||||
personal = true,
|
||||
url = mockServer.url("/addressbook-homeset/")
|
||||
)
|
||||
)
|
||||
|
||||
val refresher = homeSetRefresherFactory.create(service, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class PrincipalsRefresherTest {
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var principalsRefresher: PrincipalsRefresher.Factory
|
||||
|
||||
@BindValue
|
||||
@MockK(relaxed = true)
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockKRule = MockKRule(this)
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_inaccessiblePrincipal() {
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was not updated
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_updatesPrincipal() {
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal now got a display name
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals("Mr. Wobbles", principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
|
||||
// place principal without collections in DB
|
||||
db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals - detecting it does not own collections
|
||||
principalsRefresher.create(service, client.okHttpClient).refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(0, principals.size)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
|
||||
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
|
||||
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" + "<CARD:addressbook-home-set>" + " <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" + "</CARD:addressbook-home-set>" + "<group-membership>" + " <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
logger.info("Queried: $path")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class ServiceRefresherTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRefresherFactory: ServiceRefresher.Factory
|
||||
|
||||
private lateinit var client: HttpClient
|
||||
private lateinit var mockServer: MockWebServer
|
||||
private lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Start mock web server
|
||||
mockServer = MockWebServer().apply {
|
||||
dispatcher = TestDispatcher(logger)
|
||||
start()
|
||||
}
|
||||
|
||||
// build HTTP client
|
||||
client = httpClientBuilder.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// insert test service
|
||||
val serviceId = db.serviceDao().insertOrReplace(
|
||||
Service(id = 0, accountName = "test", type = Service.TYPE_CARDDAV, principal = null)
|
||||
)
|
||||
service = db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
serviceRefresherFactory.create(service, client.okHttpClient)
|
||||
.discoverHomesets(baseUrl)
|
||||
|
||||
// Check home set has been saved correctly to database
|
||||
val savedHomesets = db.homeSetDao().getByService(service.id)
|
||||
assertEquals(2, savedHomesets.size)
|
||||
|
||||
// Home set from current-user-principal
|
||||
val personalHomeset = savedHomesets[1]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL/"), personalHomeset.url)
|
||||
assertEquals(service.id, personalHomeset.serviceId)
|
||||
// personal should be true for homesets detected at first query of current-user-principal (Even if they occur in a group principal as well!!!)
|
||||
assertEquals(true, personalHomeset.personal)
|
||||
|
||||
// Home set found in a group principal
|
||||
val groupHomeset = savedHomesets[0]
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL/"), groupHomeset.url)
|
||||
assertEquals(service.id, groupHomeset.serviceId)
|
||||
// personal should be false for homesets not detected at the first query of current-user-principal (IE. in groups)
|
||||
assertEquals(false, groupHomeset.personal)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_GROUPPRINCIPAL_0 = "/groups/0"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL = "/addressbooks-homeset-non-personal"
|
||||
|
||||
}
|
||||
|
||||
class TestDispatcher(
|
||||
private val logger: Logger
|
||||
) : Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path!!.trimEnd('/')
|
||||
logger.info("Query: ${request.method} on $path ")
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<group-membership>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_GROUPPRINCIPAL_0}</href>" +
|
||||
"</group-membership>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_GROUPPRINCIPAL_0 ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>All address books</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_PERSONAL}</href>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_NON_PERSONAL}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
else -> ""
|
||||
}
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody(
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncRequest
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.test.filters.SdkSuppress
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import junit.framework.AssertionFailedError
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.Collections
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@HiltAndroidTest
|
||||
class AndroidSyncFrameworkTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
lateinit var account: Account
|
||||
val authority = CalendarContract.AUTHORITY
|
||||
|
||||
private lateinit var stateChangeListener: Any
|
||||
private val recordedStates = Collections.synchronizedList(LinkedList<State>())
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
// Enable sync globally and for the test account
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
|
||||
// Remember states the sync framework reports as pairs of (sync pending, sync active).
|
||||
recordedStates.clear()
|
||||
onStatusChanged(0) // record first entry (pending = false, active = false)
|
||||
stateChangeListener = ContentResolver.addStatusChangeListener(
|
||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE,
|
||||
::onStatusChanged
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ContentResolver.removeStatusChangeListener(stateChangeListener)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Correct behaviour of the sync framework on Android 13 and below.
|
||||
* Pending state is correctly reflected
|
||||
*/
|
||||
@SdkSuppress(maxSdkVersion = 33)
|
||||
@Test
|
||||
fun testVerifySyncAlwaysPending_correctBehaviour_android13() {
|
||||
verifySyncStates(
|
||||
listOf(
|
||||
State(pending = false, active = false), // no sync pending or active
|
||||
State(pending = true, active = false, optional = true), // sync becomes pending
|
||||
State(pending = true, active = true), // ... and pending and active at the same time
|
||||
State(pending = false, active = true), // ... and then only active
|
||||
State(pending = false, active = false) // sync finished
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrong behaviour of the sync framework on Android 14+.
|
||||
* Pending state stays true forever (after initial run), active state behaves correctly
|
||||
*/
|
||||
@SdkSuppress(minSdkVersion = 34 /*, maxSdkVersion = 36 */)
|
||||
@Test
|
||||
fun testVerifySyncAlwaysPending_wrongBehaviour_android14() {
|
||||
verifySyncStates(
|
||||
listOf(
|
||||
State(pending = false, active = false), // no sync pending or active
|
||||
State(pending = true, active = false, optional = true), // sync becomes pending
|
||||
State(pending = true, active = true), // ... and pending and active at the same time
|
||||
State(pending = true, active = false) // ... and finishes, but stays pending
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun syncRequest() = SyncRequest.Builder()
|
||||
.setSyncAdapter(account, authority)
|
||||
.syncOnce()
|
||||
.setExtras(Bundle()) // needed for Android 9
|
||||
.setExpedited(true) // sync request will be scheduled at the front of the sync request queue
|
||||
.setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Verifies that the given expected states match the recorded states.
|
||||
*/
|
||||
private fun verifySyncStates(expectedStates: List<State>) = runBlocking {
|
||||
// We use runBlocking for these tests because it uses the default dispatcher
|
||||
// which does not auto-advance virtual time and we need real system time to
|
||||
// test the sync framework behavior.
|
||||
|
||||
ContentResolver.requestSync(syncRequest())
|
||||
|
||||
// Even though the always-pending-bug is present on Android 14+, the sync active
|
||||
// state behaves correctly, so we can record the state changes as pairs (pending,
|
||||
// active) and expect a certain sequence of state pairs to verify the presence or
|
||||
// absence of the bug on different Android versions.
|
||||
withTimeout(60.seconds) { // Usually takes less than 30 seconds
|
||||
while (recordedStates.size < expectedStates.size) {
|
||||
// verify already known states
|
||||
if (recordedStates.isNotEmpty())
|
||||
assertStatesEqual(expectedStates.subList(0, recordedStates.size), recordedStates)
|
||||
|
||||
delay(500) // avoid busy-waiting
|
||||
}
|
||||
|
||||
assertStatesEqual(expectedStates, recordedStates)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts whether [actualStates] and [expectedStates] are the same, under the condition
|
||||
* that expected states with the [State.optional] flag can be skipped.
|
||||
*/
|
||||
private fun assertStatesEqual(expectedStates: List<State>, actualStates: List<State>) {
|
||||
fun fail() {
|
||||
throw AssertionFailedError("Expected states=$expectedStates, actual=$actualStates")
|
||||
}
|
||||
|
||||
// iterate through entries
|
||||
val expectedIterator = expectedStates.iterator()
|
||||
for (actual in actualStates) {
|
||||
if (!expectedIterator.hasNext())
|
||||
fail()
|
||||
var expected = expectedIterator.next()
|
||||
|
||||
// skip optional expected entries if they don't match the actual entry
|
||||
while (!actual.stateEquals(expected) && expected.optional) {
|
||||
if (!expectedIterator.hasNext())
|
||||
fail()
|
||||
expected = expectedIterator.next()
|
||||
}
|
||||
|
||||
if (!actual.stateEquals(expected))
|
||||
fail()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SyncStatusObserver implementation and data class
|
||||
|
||||
fun onStatusChanged(which: Int) {
|
||||
val state = State(
|
||||
pending = ContentResolver.isSyncPending(account, authority),
|
||||
active = ContentResolver.isSyncActive(account, authority)
|
||||
)
|
||||
synchronized(recordedStates) {
|
||||
if (recordedStates.lastOrNull() != state) {
|
||||
logger.info("$account syncState = $state")
|
||||
recordedStates += state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class State(
|
||||
val pending: Boolean,
|
||||
val active: Boolean,
|
||||
val optional: Boolean = false
|
||||
) {
|
||||
fun stateEquals(other: State) =
|
||||
pending == other.pending && active == other.active
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
var globalAutoSyncBeforeTest = false
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun before() {
|
||||
globalAutoSyncBeforeTest = ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
// We'll request syncs explicitly and with SYNC_EXTRAS_IGNORE_SETTINGS
|
||||
ContentResolver.setMasterSyncAutomatically(false)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun after() {
|
||||
ContentResolver.setMasterSyncAutomatically(globalAutoSyncBeforeTest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class FakeSyncAdapter @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger
|
||||
): AbstractThreadedSyncAdapter(context, true), SyncAdapter {
|
||||
|
||||
init {
|
||||
logger.info("FakeSyncAdapter created")
|
||||
}
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
logger.log(
|
||||
Level.INFO,
|
||||
"onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)",
|
||||
extras.keySet().map { key -> "extras[$key] = ${extras[key]}" }
|
||||
)
|
||||
|
||||
// fake 5 sec sync
|
||||
try {
|
||||
Thread.sleep(5000)
|
||||
} catch (_: InterruptedException) {
|
||||
logger.info("onPerformSync($account) cancelled")
|
||||
}
|
||||
|
||||
logger.info("onPerformSync($account) finished")
|
||||
}
|
||||
|
||||
|
||||
// SyncAdapter implementation and Hilt module
|
||||
|
||||
override fun getBinder(): IBinder = syncAdapterBinder
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import java.util.Optional
|
||||
|
||||
class LocalTestResource: LocalResource<Any> {
|
||||
|
||||
@@ -19,10 +20,10 @@ class LocalTestResource: LocalResource<Any> {
|
||||
|
||||
override fun prepareForUpload() = "generated-file.txt"
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
dirty = false
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
@@ -31,9 +32,8 @@ class LocalTestResource: LocalResource<Any> {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun add() = throw NotImplementedError()
|
||||
override fun update(data: Any) = throw NotImplementedError()
|
||||
override fun delete() = throw NotImplementedError()
|
||||
override fun update(data: Any, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) = throw NotImplementedError()
|
||||
override fun deleteLocal() = throw NotImplementedError()
|
||||
override fun resetDeleted() = throw NotImplementedError()
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
@@ -13,17 +14,17 @@ import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.sync.adapter.SyncAdapterImpl
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.BindValue
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.Awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
@@ -39,36 +40,12 @@ import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.Timeout
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncAdapterServicesTest {
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var syncConditionsFactory: SyncConditions.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
class SyncAdapterImplTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -76,10 +53,21 @@ class SyncAdapterServicesTest {
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
|
||||
@get:Rule
|
||||
val timeoutRule: Timeout = Timeout.seconds(5)
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncAdapterImplProvider: Provider<SyncAdapterImpl>
|
||||
|
||||
@BindValue @MockK
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
private var masterSyncStateBeforeTest = ContentResolver.getMasterSyncAutomatically()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -87,33 +75,23 @@ class SyncAdapterServicesTest {
|
||||
TestUtils.setUpWorkManager(context, workerFactory)
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
ContentResolver.setMasterSyncAutomatically(true)
|
||||
ContentResolver.setSyncAutomatically(account, CalendarContract.AUTHORITY, true)
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ContentResolver.setMasterSyncAutomatically(masterSyncStateBeforeTest)
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
|
||||
private fun syncAdapter(
|
||||
syncWorkerManager: SyncWorkerManager
|
||||
): SyncAdapterService.SyncAdapter =
|
||||
SyncAdapterService.SyncAdapter(
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
context = context,
|
||||
logger = logger,
|
||||
syncConditionsFactory = syncConditionsFactory,
|
||||
syncWorkerManager = syncWorkerManager
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_cancellation() = runTest {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -136,9 +114,8 @@ class SyncAdapterServicesTest {
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -158,9 +135,8 @@ class SyncAdapterServicesTest {
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_runsInTime() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val syncAdapter = syncAdapterImplProvider.get()
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
@@ -179,4 +155,4 @@ class SyncAdapterServicesTest {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ class SyncerTest {
|
||||
override val authority: String
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun acquireContentProvider(): ContentProviderClient? {
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean): ContentProviderClient? {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ package at.bitfire.davdroid.webdav
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -36,7 +36,7 @@ class WebDavMountRepositoryTest {
|
||||
@Test
|
||||
fun testHasWebDav_NoDavHeader() = runTest {
|
||||
web.enqueue(MockResponse().setResponseCode(200))
|
||||
assertFalse(repository.hasWebDav(url, null))
|
||||
assertNull(repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -44,7 +44,7 @@ class WebDavMountRepositoryTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1"))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
assertEquals(url, repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -52,7 +52,7 @@ class WebDavMountRepositoryTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 2"))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
assertEquals(url,repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,7 +60,7 @@ class WebDavMountRepositoryTest {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV: 1, 3"))
|
||||
assertTrue(repository.hasWebDav(url, null))
|
||||
assertEquals(url,repository.hasWebDav(url, null))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
@@ -14,6 +14,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
@@ -29,29 +30,7 @@ import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class DavDocumentsProviderTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var credentialsStore: CredentialsStore
|
||||
|
||||
@Inject
|
||||
lateinit var davDocumentsActorFactory: DavDocumentsProvider.DavDocumentsActor.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
class QueryChildDocumentsOperationTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@@ -59,13 +38,32 @@ class DavDocumentsProviderTest {
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var operation: QueryChildDocumentsOperation
|
||||
|
||||
@Inject
|
||||
lateinit var httpClientBuilder: HttpClient.Builder
|
||||
|
||||
@Inject
|
||||
lateinit var testDispatcher: TestDispatcher
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: HttpClient
|
||||
|
||||
private lateinit var mount: WebDavMount
|
||||
private lateinit var rootDocument: WebDavDocument
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// create server and client
|
||||
server = MockWebServer().apply {
|
||||
dispatcher = testDispatcher
|
||||
start()
|
||||
@@ -75,50 +73,49 @@ class DavDocumentsProviderTest {
|
||||
|
||||
// mock server delivers HTTP without encryption
|
||||
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
// create WebDAV mount and root document in DB
|
||||
runBlocking {
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
mount = db.webDavMountDao().getById(mountId)
|
||||
rootDocument = db.webDavDocumentDao().getOrCreateRoot(mount)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.close()
|
||||
server.shutdown()
|
||||
|
||||
runBlocking {
|
||||
db.webDavMountDao().deleteAsync(mount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_insert() = runTest {
|
||||
// Create parent and root in database
|
||||
val id = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(id)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
|
||||
// Query
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert new children were inserted into db
|
||||
assertEquals(3, db.webDavDocumentDao().getChildren(parent.id).size)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
|
||||
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(parent.id)[1].displayName)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(parent.id)[2].displayName)
|
||||
assertEquals(3, db.webDavDocumentDao().getChildren(rootDocument.id).size)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
|
||||
assertEquals("MeowMeow_Cats.docx", db.webDavDocumentDao().getChildren(rootDocument.id)[1].displayName)
|
||||
assertEquals("Secret_Document.pages", db.webDavDocumentDao().getChildren(rootDocument.id)[2].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_update() = runTest {
|
||||
// Create parent and root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
assertEquals("Cat food storage", db.webDavDocumentDao().get(parent.id)!!.displayName)
|
||||
assertEquals("Cat food storage", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
|
||||
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
WebDavDocument(
|
||||
0,
|
||||
mountId,
|
||||
parent.id,
|
||||
mount.id,
|
||||
rootDocument.id,
|
||||
"My_Books",
|
||||
true,
|
||||
"My Books",
|
||||
@@ -128,38 +125,25 @@ class DavDocumentsProviderTest {
|
||||
assertEquals("My Books", db.webDavDocumentDao().get(folderId)!!.displayName)
|
||||
|
||||
// Query - should update the parent displayname and folder name
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert parent and children were updated in database
|
||||
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(parent.id)!!.displayName)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].name)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(parent.id)[0].displayName)
|
||||
assertEquals("Cats WebDAV", db.webDavDocumentDao().get(rootDocument.id)!!.displayName)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].name)
|
||||
assertEquals("Library", db.webDavDocumentDao().getChildren(rootDocument.id)[0].displayName)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_delete() = runTest {
|
||||
// Create parent and root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val parent = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
|
||||
// Create a folder
|
||||
val folderId = db.webDavDocumentDao().insert(
|
||||
WebDavDocument(0, mountId, parent.id, "deleteme", true, "Should be deleted")
|
||||
WebDavDocument(0, mount.id, rootDocument.id, "deleteme", true, "Should be deleted")
|
||||
)
|
||||
assertEquals("deleteme", db.webDavDocumentDao().get(folderId)!!.name)
|
||||
|
||||
// Query - discovers serverside deletion
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent)
|
||||
operation.queryChildren(rootDocument)
|
||||
|
||||
// Assert folder got deleted
|
||||
assertEquals(null, db.webDavDocumentDao().get(folderId))
|
||||
@@ -167,26 +151,17 @@ class DavDocumentsProviderTest {
|
||||
|
||||
@Test
|
||||
fun testDoQueryChildren_updateTwoDirectoriesSimultaneously() = runTest {
|
||||
// Create root in database
|
||||
val mountId = db.webDavMountDao().insert(WebDavMount(0, "Cat food storage", server.url(PATH_WEBDAV_ROOT)))
|
||||
val webDavMount = db.webDavMountDao().getById(mountId)
|
||||
val root = db.webDavDocumentDao().getOrCreateRoot(webDavMount)
|
||||
|
||||
// Create two directories
|
||||
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent1", true))
|
||||
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mountId, root.id, "parent2", true))
|
||||
val parent1Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent1", true))
|
||||
val parent2Id = db.webDavDocumentDao().insert(WebDavDocument(0, mount.id, rootDocument.id, "parent2", true))
|
||||
val parent1 = db.webDavDocumentDao().get(parent1Id)!!
|
||||
val parent2 = db.webDavDocumentDao().get(parent2Id)!!
|
||||
assertEquals("parent1", parent1.name)
|
||||
assertEquals("parent2", parent2.name)
|
||||
|
||||
// Query - find children of two nodes simultaneously
|
||||
val actor = davDocumentsActorFactory.create(
|
||||
cookieStore = mutableMapOf(),
|
||||
credentialsStore = credentialsStore
|
||||
)
|
||||
actor.queryChildren(parent1)
|
||||
actor.queryChildren(parent2)
|
||||
operation.queryChildren(parent1)
|
||||
operation.queryChildren(parent2)
|
||||
|
||||
// Assert the two folders names have changed
|
||||
assertEquals("childOne.txt", db.webDavDocumentDao().getChildren(parent1Id)[0].name)
|
||||
@@ -214,7 +189,7 @@ class DavDocumentsProviderTest {
|
||||
PATH_WEBDAV_ROOT to arrayOf(
|
||||
Resource("",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
"<displayname>Cats WebDAV</displayname>"
|
||||
),
|
||||
Resource("Secret_Document.pages",
|
||||
"<displayname>Secret_Document.pages</displayname>",
|
||||
@@ -224,7 +199,7 @@ class DavDocumentsProviderTest {
|
||||
),
|
||||
Resource("Library",
|
||||
"<resourcetype><collection/></resourcetype>" +
|
||||
"<displayname>Library</displayname>"
|
||||
"<displayname>Library</displayname>"
|
||||
)
|
||||
),
|
||||
|
||||
@@ -243,15 +218,15 @@ class DavDocumentsProviderTest {
|
||||
val responses = propsMap[requestPath]?.joinToString { resource ->
|
||||
"<response><href>$requestPath/${resource.name}</href><propstat><prop>" +
|
||||
resource.props +
|
||||
"</prop></propstat></response>"
|
||||
"</prop></propstat></response>"
|
||||
}
|
||||
|
||||
val multistatus =
|
||||
"<multistatus xmlns='DAV:' " +
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
"xmlns:CARD='urn:ietf:params:xml:ns:carddav' " +
|
||||
"xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
responses +
|
||||
"</multistatus>"
|
||||
|
||||
logger.info("Response: $multistatus")
|
||||
return MockResponse()
|
||||
@@ -264,4 +239,9 @@ class DavDocumentsProviderTest {
|
||||
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val PATH_WEBDAV_ROOT = "/webdav"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,11 +53,19 @@
|
||||
tools:ignore="UnusedAttribute"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- required for Hilt/WorkManager integration -->
|
||||
<!-- Required for Hilt/WorkManager integration. See
|
||||
- https://developer.android.com/develop/background-work/background-tasks/persistent/configuration/custom-configuration#remove-default
|
||||
- https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager
|
||||
However, we must not disable AndroidX startup completely, as it's needed by other libraries like okhttp. -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove">
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
|
||||
@@ -178,7 +186,7 @@
|
||||
android:resource="@xml/account_authenticator"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.CalendarsSyncAdapterService"
|
||||
android:name=".sync.adapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -189,7 +197,7 @@
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.JtxSyncAdapterService"
|
||||
android:name=".sync.adapter.JtxSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -200,7 +208,7 @@
|
||||
android:resource="@xml/sync_notes"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.OpenTasksSyncAdapterService"
|
||||
android:name=".sync.adapter.OpenTasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -211,7 +219,7 @@
|
||||
android:resource="@xml/sync_opentasks"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.TasksOrgSyncAdapterService"
|
||||
android:name=".sync.adapter.TasksOrgSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
@@ -246,7 +254,7 @@
|
||||
android:resource="@xml/account_authenticator_address_book"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".sync.ContactsSyncAdapterService"
|
||||
android:name=".sync.adapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import at.bitfire.ical4android.ical4jVersion
|
||||
import at.bitfire.synctools.icalendar.ical4jVersion
|
||||
import ezvcard.Ezvcard
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ import dagger.hilt.components.SingletonComponent
|
||||
import java.io.Writer
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The app database. Managed via android jetpack room. Room provides an abstraction
|
||||
* layer over SQLite.
|
||||
*
|
||||
* Note: In SQLite PRAGMA foreign_keys is off by default. Room activates it for
|
||||
* production (non-test) databases.
|
||||
*/
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
|
||||
@@ -23,9 +23,9 @@ import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ON
|
||||
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
@@ -234,7 +234,7 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
if (syncInterval != null)
|
||||
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
else
|
||||
syncFramework.disableSyncAbility(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
syncFramework.disableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -78,8 +78,14 @@ class LocalAddressBookStore @Inject constructor(
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
|
||||
@@ -8,16 +8,18 @@ import android.content.ContentUris
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
||||
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
@@ -26,16 +28,18 @@ import java.util.logging.Logger
|
||||
* [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
class LocalCalendar @AssistedInject constructor(
|
||||
@Assisted val androidCalendar: AndroidCalendar,
|
||||
@Assisted internal val androidCalendar: AndroidCalendar,
|
||||
private val logger: Logger
|
||||
) : LocalCollection<LocalEvent> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(androidCalendar: AndroidCalendar): LocalCalendar
|
||||
fun create(calendar: AndroidCalendar): LocalCalendar
|
||||
}
|
||||
|
||||
|
||||
// properties
|
||||
|
||||
override val dbCollectionId: Long?
|
||||
get() = androidCalendar.syncId?.toLongOrNull()
|
||||
|
||||
@@ -56,11 +60,29 @@ class LocalCalendar @AssistedInject constructor(
|
||||
androidCalendar.writeSyncState(state.toString())
|
||||
}
|
||||
|
||||
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
|
||||
|
||||
override fun findDeleted() =
|
||||
androidCalendar
|
||||
.findEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
.map { LocalEvent(it) }
|
||||
|
||||
fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
val mapped = LegacyAndroidEventBuilder2(
|
||||
calendar = androidCalendar,
|
||||
event = event,
|
||||
id = null,
|
||||
syncId = fileName,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
flags = flags
|
||||
).build()
|
||||
recurringCalendar.addEventAndExceptions(mapped)
|
||||
}
|
||||
|
||||
override fun findDeleted(): List<LocalEvent> {
|
||||
val result = LinkedList<LocalEvent>()
|
||||
androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity ->
|
||||
result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
@@ -70,62 +92,57 @@ class LocalCalendar @AssistedInject constructor(
|
||||
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
|
||||
* CUA each time the "Organizer" makes a significant revision to the calendar component.
|
||||
*/
|
||||
for (androidEvent in androidCalendar.findEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
val localEvent = LocalEvent(androidEvent)
|
||||
try {
|
||||
val event = requireNotNull(androidEvent.event)
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = localEvent.weAreOrganizer
|
||||
|
||||
val sequence = event.sequence
|
||||
if (sequence == null)
|
||||
// sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
|
||||
event.sequence = sequence + 1
|
||||
|
||||
} catch(e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
dirty += localEvent
|
||||
androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
|
||||
dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values))
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
androidCalendar.findEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()?.let { LocalEvent(it) }
|
||||
|
||||
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
|
||||
LocalEvent(recurringCalendar, it)
|
||||
}
|
||||
|
||||
override fun markNotDirty(flags: Int) =
|
||||
androidCalendar.updateEvents(
|
||||
contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags),
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
androidCalendar.updateEventRows(
|
||||
contentValuesOf(AndroidEvent2.COLUMN_FLAGS to flags),
|
||||
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
||||
"""
|
||||
${Events.CALENDAR_ID}=?
|
||||
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
||||
AND ${Events.ORIGINAL_ID} IS NULL
|
||||
""".trimIndent(),
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
)
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
// list all non-dirty events with the given flags and delete every row + its exceptions
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
androidCalendar.iterateEvents(
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID),
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${AndroidEvent.COLUMN_FLAGS}=?",
|
||||
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
||||
"""
|
||||
${Events.CALENDAR_ID}=?
|
||||
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
||||
AND ${Events.ORIGINAL_ID} IS NULL
|
||||
AND ${AndroidEvent2.COLUMN_FLAGS}=?
|
||||
""".trimIndent(),
|
||||
arrayOf(androidCalendar.id.toString(), flags.toString())
|
||||
) { values ->
|
||||
val id = values.getAsInteger(Events._ID)
|
||||
val id = values.getAsLong(Events._ID)
|
||||
|
||||
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newDelete(Events.CONTENT_URI.asSyncAdapter(androidCalendar.account))
|
||||
.newDelete(androidCalendar.eventsUri)
|
||||
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
|
||||
}
|
||||
return batch.commit()
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
androidCalendar.updateEvents(
|
||||
contentValuesOf(AndroidEvent.COLUMN_ETAG to null),
|
||||
androidCalendar.updateEventRows(
|
||||
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
|
||||
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
|
||||
)
|
||||
}
|
||||
@@ -135,8 +152,8 @@ class LocalCalendar @AssistedInject constructor(
|
||||
// process deleted exceptions
|
||||
logger.info("Processing deleted exceptions")
|
||||
|
||||
androidCalendar.iterateEvents(
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
) { values ->
|
||||
@@ -148,12 +165,12 @@ class LocalCalendar @AssistedInject constructor(
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
|
||||
// enqueue: increase sequence of main event
|
||||
val originalEventValues = androidCalendar.getEventValues(originalID, arrayOf(AndroidEvent.COLUMN_SEQUENCE))
|
||||
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent.COLUMN_SEQUENCE) ?: 0
|
||||
val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE))
|
||||
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
|
||||
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
|
||||
.withValue(AndroidEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1)
|
||||
|
||||
// completely remove deleted exception
|
||||
@@ -163,8 +180,8 @@ class LocalCalendar @AssistedInject constructor(
|
||||
|
||||
// process dirty exceptions
|
||||
logger.info("Processing dirty exceptions")
|
||||
androidCalendar.iterateEvents(
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent.COLUMN_SEQUENCE),
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
) { values ->
|
||||
@@ -172,7 +189,7 @@ class LocalCalendar @AssistedInject constructor(
|
||||
|
||||
val id = values.getAsLong(Events._ID) // can't be null (by definition)
|
||||
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
|
||||
val sequence = values.getAsInteger(AndroidEvent.COLUMN_SEQUENCE) ?: 0
|
||||
val sequence = values.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
|
||||
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
|
||||
@@ -184,7 +201,7 @@ class LocalCalendar @AssistedInject constructor(
|
||||
// enqueue: increase exception SEQUENCE and set DIRTY to 0
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(androidCalendar.eventUri(id))
|
||||
.withValue(AndroidEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
|
||||
batch.commit()
|
||||
@@ -198,7 +215,7 @@ class LocalCalendar @AssistedInject constructor(
|
||||
*/
|
||||
fun deleteDirtyEventsWithoutInstances() {
|
||||
// Iterate dirty main events without exceptions
|
||||
androidCalendar.iterateEvents(
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID),
|
||||
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
null
|
||||
@@ -211,7 +228,7 @@ class LocalCalendar @AssistedInject constructor(
|
||||
// delete event if there are no instances
|
||||
if (numEventInstances == 0) {
|
||||
logger.fine("Marking event #$eventId without instances as deleted")
|
||||
androidCalendar.updateEvent(eventId, contentValuesOf(Events.DELETED to 1))
|
||||
androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,14 @@ class LocalCalendarStore @Inject constructor(
|
||||
override val authority: String
|
||||
get() = CalendarContract.AUTHORITY
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
|
||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
|
||||
@@ -48,10 +48,8 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
*/
|
||||
fun findByName(name: String): T?
|
||||
|
||||
|
||||
/**
|
||||
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
|
||||
* and have an [Events.ORIGINAL_ID] of null.
|
||||
* Updates the flags value for entries which are not dirty.
|
||||
*
|
||||
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
|
||||
*
|
||||
@@ -60,8 +58,7 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
fun markNotDirty(flags: Int): Int
|
||||
|
||||
/**
|
||||
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
|
||||
* a given flag combination.
|
||||
* Removes entries which are not dirty with a given flag combination.
|
||||
*
|
||||
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
|
||||
* all entries with exactly this flag will be removed)
|
||||
@@ -76,4 +73,4 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
*/
|
||||
fun forgetETags()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,13 @@ import at.bitfire.vcard4android.AndroidContactFactory
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
companion object {
|
||||
|
||||
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
}
|
||||
@@ -40,9 +40,8 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
internal val cachedGroupMemberships = HashSet<Long>()
|
||||
internal val groupMemberships = HashSet<Long>()
|
||||
|
||||
override var scheduleTag: String?
|
||||
override val scheduleTag: String?
|
||||
get() = null
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
@@ -90,13 +89,13 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
_contact = null
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(COLUMN_FILENAME, fileName)
|
||||
if (fileName.isPresent)
|
||||
values.put(COLUMN_FILENAME, fileName.get())
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
@@ -105,21 +104,25 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
fun resetDirty() {
|
||||
val values = contentValuesOf(ContactsContract.RawContacts.DIRTY to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
@@ -127,6 +130,15 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
fun addToGroup(batch: ContactsBatchOperation, groupID: Long) {
|
||||
batch += BatchOperation.CpoBuilder
|
||||
|
||||
@@ -25,16 +25,19 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
||||
*
|
||||
* **The caller is responsible for closing the content provider client!**
|
||||
*
|
||||
* @return the content provider client, or `null` if the content provider could not be acquired
|
||||
* @param throwOnMissingPermissions If `true`, the function will throw [SecurityException] if permissions are not granted.
|
||||
*
|
||||
* @return the content provider client, or `null` if the content provider could not be acquired (or permissions are not
|
||||
* granted and [throwOnMissingPermissions] is `false`)
|
||||
*
|
||||
* @throws SecurityException on missing permissions
|
||||
*/
|
||||
fun acquireContentProvider(): ContentProviderClient?
|
||||
fun acquireContentProvider(throwOnMissingPermissions: Boolean = false): ContentProviderClient?
|
||||
|
||||
/**
|
||||
* Creates a new local collection from the given (remote) collection info.
|
||||
*
|
||||
* @param provider the content provider client
|
||||
* @param client the content provider client
|
||||
* @param fromCollection collection info
|
||||
*
|
||||
* @return the new local collection, or `null` if creation failed
|
||||
@@ -46,7 +49,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
||||
* [Collection] entry.
|
||||
*
|
||||
* @param account the account that the data store is associated with
|
||||
* @param provider the content provider client
|
||||
* @param client the content provider client
|
||||
*
|
||||
* @return a list of all local collections
|
||||
*/
|
||||
@@ -55,7 +58,7 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
||||
/**
|
||||
* Updates the local collection with the data from the given (remote) collection info.
|
||||
*
|
||||
* @param provider the content provider client
|
||||
* @param client the content provider client
|
||||
* @param localCollection the local collection to update
|
||||
* @param fromCollection collection info
|
||||
*/
|
||||
|
||||
@@ -4,50 +4,110 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.LegacyAndroidCalendar
|
||||
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
|
||||
import at.bitfire.synctools.storage.LocalStorageException
|
||||
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class LocalEvent(
|
||||
val androidEvent: AndroidEvent
|
||||
val recurringCalendar: AndroidRecurringCalendar,
|
||||
val androidEvent: AndroidEvent2
|
||||
) : LocalResource<Event> {
|
||||
|
||||
// LocalResource implementation
|
||||
|
||||
override val id: Long?
|
||||
override val id: Long
|
||||
get() = androidEvent.id
|
||||
|
||||
override var fileName: String?
|
||||
override val fileName: String?
|
||||
get() = androidEvent.syncId
|
||||
private set(value) {
|
||||
androidEvent.syncId = value
|
||||
}
|
||||
|
||||
override var eTag: String?
|
||||
override val eTag: String?
|
||||
get() = androidEvent.eTag
|
||||
set(value) { androidEvent.eTag = value }
|
||||
|
||||
override var scheduleTag: String?
|
||||
override val scheduleTag: String?
|
||||
get() = androidEvent.scheduleTag
|
||||
set(value) { androidEvent.scheduleTag = value }
|
||||
|
||||
override val flags: Int
|
||||
get() = androidEvent.flags
|
||||
|
||||
override fun add() = androidEvent.add()
|
||||
|
||||
override fun update(data: Event) = androidEvent.update(data)
|
||||
|
||||
override fun delete() = androidEvent.delete()
|
||||
override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
val eventAndExceptions = LegacyAndroidEventBuilder2(
|
||||
calendar = androidEvent.calendar,
|
||||
event = data,
|
||||
id = id,
|
||||
syncId = fileName,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
flags = flags
|
||||
).build()
|
||||
recurringCalendar.updateEventAndExceptions(id, eventAndExceptions)
|
||||
}
|
||||
|
||||
|
||||
// other methods
|
||||
private var _event: Event? = null
|
||||
/**
|
||||
* Retrieves the event from the content provider and converts it to a legacy data object.
|
||||
*
|
||||
* Caches the result: the content provider is only queried at the first call and then
|
||||
* this method always returns the same object.
|
||||
*
|
||||
* @throws LocalStorageException if there is no local event with the ID from [androidEvent]
|
||||
*/
|
||||
@Synchronized
|
||||
fun getCachedEvent(): Event {
|
||||
_event?.let { return it }
|
||||
|
||||
val weAreOrganizer
|
||||
get() = androidEvent.event!!.isOrganizer == true
|
||||
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
|
||||
val event = legacyCalendar.getEvent(androidEvent.id)
|
||||
?: throw LocalStorageException("Event ${androidEvent.id} not found")
|
||||
|
||||
_event = event
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the [Event] that should actually be uploaded:
|
||||
*
|
||||
* 1. Takes the [getCachedEvent].
|
||||
* 2. Calculates the new SEQUENCE.
|
||||
*
|
||||
* _Note: This method currently modifies the object returned by [getCachedEvent], but
|
||||
* this may change in the future._
|
||||
*
|
||||
* @return data object that should be used for uploading
|
||||
*/
|
||||
fun eventToUpload(): Event {
|
||||
val event = getCachedEvent()
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = event.isOrganizer == true
|
||||
|
||||
// Increase sequence (event.sequence null/non-null behavior is defined by the Event, see KDoc of event.sequence):
|
||||
// - If it's null, the event has just been created in the database, so we can start with SEQUENCE:0 (default).
|
||||
// - If it's non-null, the event already exists on the server, so increase by one.
|
||||
val sequence = event.sequence
|
||||
if (sequence != null && (nonGroupScheduled || weAreOrganizer))
|
||||
event.sequence = sequence + 1
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the SEQUENCE of the event in the content provider.
|
||||
*
|
||||
* @param sequence new sequence value
|
||||
*/
|
||||
fun updateSequence(sequence: Int?) {
|
||||
androidEvent.update(contentValuesOf(
|
||||
AndroidEvent2.COLUMN_SEQUENCE to sequence
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@@ -58,16 +118,16 @@ class LocalEvent(
|
||||
*/
|
||||
override fun prepareForUpload(): String {
|
||||
// make sure that UID is set
|
||||
val uid: String = androidEvent.event!!.uid ?: run {
|
||||
val uid: String = getCachedEvent().uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in calendar provider
|
||||
// persist to calendar provider
|
||||
val values = contentValuesOf(Events.UID_2445 to newUid)
|
||||
androidEvent.update(values)
|
||||
|
||||
// update this event
|
||||
androidEvent.event?.uid = newUid
|
||||
// update in cached event data object
|
||||
getCachedEvent().uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
@@ -85,33 +145,31 @@ class LocalEvent(
|
||||
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
|
||||
}
|
||||
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
val values = ContentValues(5)
|
||||
if (fileName != null)
|
||||
values.put(Events._SYNC_ID, fileName)
|
||||
values.put(AndroidEvent.COLUMN_ETAG, eTag)
|
||||
values.put(AndroidEvent.COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
values.put(AndroidEvent.COLUMN_SEQUENCE, androidEvent.event!!.sequence)
|
||||
values.put(Events.DIRTY, 0)
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
val values = contentValuesOf(
|
||||
Events.DIRTY to 0,
|
||||
AndroidEvent2.COLUMN_ETAG to eTag,
|
||||
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
|
||||
)
|
||||
if (fileName.isPresent)
|
||||
values.put(Events._SYNC_ID, fileName.get())
|
||||
androidEvent.update(values)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = contentValuesOf(AndroidEvent.COLUMN_FLAGS to flags)
|
||||
androidEvent.update(values)
|
||||
androidEvent.update(contentValuesOf(
|
||||
AndroidEvent2.COLUMN_FLAGS to flags
|
||||
))
|
||||
}
|
||||
|
||||
androidEvent.flags = flags
|
||||
override fun deleteLocal() {
|
||||
recurringCalendar.deleteEventAndExceptions(id)
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(Events.DELETED to 0)
|
||||
androidEvent.update(values)
|
||||
androidEvent.update(contentValuesOf(
|
||||
Events.DELETED to 0
|
||||
))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import at.bitfire.vcard4android.AndroidGroupFactory
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import java.util.logging.Logger
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
@@ -111,7 +112,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
override var scheduleTag: String?
|
||||
get() = null
|
||||
set(value) = throw NotImplementedError()
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
@@ -159,20 +160,20 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
|
||||
val id = requireNotNull(id)
|
||||
|
||||
val values = ContentValues(3)
|
||||
if (fileName != null)
|
||||
values.put(COLUMN_FILENAME, fileName)
|
||||
if (fileName.isPresent)
|
||||
values.put(COLUMN_FILENAME, fileName.get())
|
||||
values.putNull(COLUMN_ETAG) // don't save changed ETag but null, so that the group is downloaded again, so that pendingMembers is updated
|
||||
values.put(Groups.DIRTY, 0)
|
||||
update(values)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = null
|
||||
|
||||
// update cached group memberships
|
||||
@@ -211,9 +212,13 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
|
||||
// processes this.{fileName, eTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
@@ -223,6 +228,15 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
|
||||
@@ -37,8 +37,14 @@ class LocalJtxCollectionStore @Inject constructor(
|
||||
override val authority: String
|
||||
get() = JtxContract.AUTHORITY
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
context.contentResolver.acquireContentProviderClient(JtxContract.AUTHORITY)
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
|
||||
@@ -9,6 +9,8 @@ import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.JtxICalObject
|
||||
import at.bitfire.ical4android.JtxICalObjectFactory
|
||||
import at.techbee.jtx.JtxContract
|
||||
import java.util.Optional
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalJtxICalObject(
|
||||
collection: JtxCollection<*>,
|
||||
@@ -48,6 +50,24 @@ class LocalJtxICalObject(
|
||||
|
||||
}
|
||||
|
||||
override fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Defines operations that are used by SyncManager for all sync data types.
|
||||
*/
|
||||
interface LocalResource<in TData: Any> {
|
||||
|
||||
companion object {
|
||||
@@ -32,10 +36,10 @@ interface LocalResource<in TData: Any> {
|
||||
val fileName: String?
|
||||
|
||||
/** remote ETag for the resource */
|
||||
var eTag: String?
|
||||
val eTag: String?
|
||||
|
||||
/** remote Schedule-Tag for the resource */
|
||||
var scheduleTag: String?
|
||||
val scheduleTag: String?
|
||||
|
||||
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
|
||||
val flags: Int
|
||||
@@ -48,47 +52,41 @@ interface LocalResource<in TData: Any> {
|
||||
* saved to the content provider. The sync manager is responsible for saving the file name that
|
||||
* was actually used.
|
||||
*
|
||||
* @return new file name of the resource (like "<uid>.vcf")
|
||||
* @return suggestion for new file name of the resource (like "<uid>.vcf")
|
||||
*/
|
||||
fun prepareForUpload(): String
|
||||
|
||||
/**
|
||||
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
|
||||
* locally modified resource.
|
||||
* Unsets the _dirty_ field of the resource and updates other sync-related fields in the content provider.
|
||||
* Does not affect `this` object itself (which is immutable).
|
||||
*
|
||||
* @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value.
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
* @param scheduleTag CalDAV Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one)
|
||||
* @param fileName If this optional argument is present, [LocalResource.fileName] will be set to its value.
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
* @param scheduleTag CalDAV only: `Schedule-Tag` of the uploaded resource as returned by the server
|
||||
* (null if not applicable or if the server didn't return one)
|
||||
*/
|
||||
fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null)
|
||||
fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String? = null)
|
||||
|
||||
/**
|
||||
* Sets (local) flags of the resource. At the moment, the only allowed values are
|
||||
* 0 and [FLAG_REMOTELY_PRESENT].
|
||||
* Sets (local) flags of the resource in the content provider.
|
||||
* Does not affect `this` object itself (which is immutable).
|
||||
*
|
||||
* At the moment, the only allowed values are 0 and [FLAG_REMOTELY_PRESENT].
|
||||
*/
|
||||
fun updateFlags(flags: Int)
|
||||
|
||||
|
||||
/**
|
||||
* Adds the data object to the content provider and ensures that the dirty flag is clear.
|
||||
*
|
||||
* @return content URI of the created row (e.g. event URI)
|
||||
*/
|
||||
fun add(): Uri
|
||||
|
||||
/**
|
||||
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||
* Does not affect `this` or the [data] object (which are both immutable).
|
||||
*
|
||||
* @return content URI of the updated row (e.g. event URI)
|
||||
*/
|
||||
fun update(data: TData): Uri
|
||||
fun update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
|
||||
|
||||
/**
|
||||
* Deletes the data object from the content provider.
|
||||
*
|
||||
* @return number of affected rows
|
||||
*/
|
||||
fun delete(): Int
|
||||
fun deleteLocal()
|
||||
|
||||
/**
|
||||
* Undoes deletion of the data object from the content provider.
|
||||
|
||||
@@ -12,6 +12,7 @@ import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
@@ -76,23 +77,33 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
logger.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(Tasks._SYNC_ID, fileName)
|
||||
if (fileName.isPresent)
|
||||
values.put(Tasks._SYNC_ID, fileName.get())
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
@@ -102,6 +113,10 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
@@ -111,4 +126,4 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,14 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
override val authority: String
|
||||
get() = providerName.authority
|
||||
|
||||
override fun acquireContentProvider() =
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.util.DavUtils.parent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Logic for refreshing the list of collections and home-sets and related information.
|
||||
*/
|
||||
class CollectionListRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val logger: Logger,
|
||||
private val settings: SettingsManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): CollectionListRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
|
||||
/**
|
||||
* Home-set class to use depending on the given service type.
|
||||
*/
|
||||
private val homeSetClass: Class<out HrefListProperty> =
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
|
||||
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set properties to ask for in a PROPFIND request to the principal URL,
|
||||
* depending on the given service type.
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
DisplayName.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookHomeSet.NAME,
|
||||
)
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection properties to ask for in a PROPFIND request on a collection.
|
||||
*/
|
||||
private val collectionProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
ResourceType.NAME,
|
||||
PushTransports.NAME, // WebDAV-Push
|
||||
Topic.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookDescription.NAME
|
||||
)
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarColor.NAME,
|
||||
CalendarDescription.NAME,
|
||||
CalendarTimezone.NAME,
|
||||
CalendarTimezoneId.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
|
||||
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
|
||||
* more than once, which could overwrite the already set "personal" flag with `false`.
|
||||
*
|
||||
* @throws java.io.IOException on I/O errors
|
||||
* @throws HttpException on HTTP errors
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
|
||||
*/
|
||||
internal fun discoverHomesets(
|
||||
principalUrl: HttpUrl,
|
||||
level: Int = 0,
|
||||
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
|
||||
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
|
||||
) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
|
||||
alreadyQueriedPrincipals += davResponse.href
|
||||
|
||||
// If response holds home sets, save them
|
||||
davResponse[homeSetClass]?.let { homeSets ->
|
||||
for (homeSetHref in homeSets.hrefs)
|
||||
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
|
||||
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
|
||||
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(
|
||||
// HomeSet is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals while still being considered "personal" (belonging to the current-user-principal)
|
||||
// and an owned home set need not always be personal either.
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
alreadySavedHomeSets += resolvedHomeSetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add related principals to be queried afterwards
|
||||
if (personal) {
|
||||
val relatedResourcesTypes = listOf(
|
||||
// current resource is a read/write-proxy for other principals
|
||||
CalendarProxyReadFor::class.java,
|
||||
CalendarProxyWriteFor::class.java,
|
||||
// current resource is a member of a group (principal that can also have proxies)
|
||||
GroupMembership::class.java
|
||||
)
|
||||
for (type in relatedResourcesTypes)
|
||||
davResponse[type]?.let {
|
||||
for (href in it.hrefs)
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
relatedResources += url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
|
||||
davResponse[ResourceType::class.java]?.let { resourceType ->
|
||||
val proxyProperties = arrayOf(
|
||||
ResourceType.CALENDAR_PROXY_READ,
|
||||
ResourceType.CALENDAR_PROXY_WRITE,
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
// query related resources
|
||||
if (level <= 1)
|
||||
for (resource in relatedResources)
|
||||
if (alreadyQueriedPrincipals.contains(resource))
|
||||
logger.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(
|
||||
principalUrl = resource,
|
||||
level = level + 1,
|
||||
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
|
||||
alreadySavedHomeSets = alreadySavedHomeSets
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes home-sets and their collections.
|
||||
*
|
||||
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
|
||||
* or marked as homeless - in case a collection was removed from its home-set.
|
||||
*
|
||||
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
|
||||
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [refreshHomelessCollections].
|
||||
*/
|
||||
internal fun refreshHomesetsAndTheirCollections() {
|
||||
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
|
||||
for((homeSetUrl, localHomeset) in homesets) {
|
||||
logger.fine("Listing home set $homeSetUrl")
|
||||
|
||||
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
|
||||
// is successfully rediscovered. If there are collections left, after processing is done, these are marked homeless.
|
||||
val localHomesetCollections = db.collectionDao()
|
||||
.getByServiceAndHomeset(service.id, localHomeset.id)
|
||||
.associateBy { it.url }
|
||||
.toMutableMap()
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
|
||||
// Note: This callback may be called multiple times ([MultiResponseCallback])
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
// this response is about the home set itself
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(localHomeset.copy(
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
|
||||
))
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
var collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection = collection.copy(
|
||||
serviceId = service.id,
|
||||
homeSetId = localHomeset.id,
|
||||
sync = shouldPreselect(collection, homesets.values),
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
)
|
||||
logger.log(Level.FINE, "Found collection", collection)
|
||||
|
||||
// save or update collection if usable (ignore it otherwise)
|
||||
if (isUsableCollection(collection))
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection)
|
||||
|
||||
// Remove this collection from queue - because it was found in the home set
|
||||
localHomesetCollections.remove(collection.url)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete home set locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
homeSetRepository.deleteBlocking(localHomeset)
|
||||
}
|
||||
|
||||
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
|
||||
for ((_, homelessCollection) in localHomesetCollections)
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(
|
||||
homelessCollection.copy(homeSetId = null)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes collections which don't have a homeset.
|
||||
*
|
||||
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
|
||||
*/
|
||||
internal fun refreshHomelessCollections() {
|
||||
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
|
||||
for((url, localCollection) in homelessCollections) try {
|
||||
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
|
||||
if (!response.isSuccess()) {
|
||||
collectionRepository.delete(localCollection)
|
||||
return@propfind
|
||||
}
|
||||
|
||||
// Save or update the collection, if usable, otherwise delete it
|
||||
Collection.fromDavResponse(response)?.let { collection ->
|
||||
if (!isUsableCollection(collection))
|
||||
return@let
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
|
||||
serviceId = localCollection.serviceId, // use same service ID as previous entry
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
))
|
||||
} ?: collectionRepository.delete(localCollection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete collection locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
collectionRepository.delete(localCollection)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the principals (get their current display names).
|
||||
* Also removes principals which do not own any collections anymore.
|
||||
*/
|
||||
internal fun refreshPrincipals() {
|
||||
// Refresh principals (collection owner urls)
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
for (oldPrincipal in principals) {
|
||||
val principalUrl = oldPrincipal.url
|
||||
logger.fine("Querying principal $principalUrl")
|
||||
try {
|
||||
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
Principal.fromDavResponse(service.id, response)?.let { principal ->
|
||||
logger.fine("Got principal: $principal")
|
||||
db.principalDao().insertOrUpdate(service.id, principal)
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
logger.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete principals which don't own any collections
|
||||
db.principalDao().getAllWithoutCollections().forEach {principal ->
|
||||
db.principalDao().delete(principal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out whether given collection is usable, by checking that either
|
||||
* - CalDAV/CardDAV: service and collection type match, or
|
||||
* - WebCal: subscription source URL is not empty
|
||||
*/
|
||||
private fun isUsableCollection(collection: Collection) =
|
||||
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
|
||||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
|
||||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
|
||||
|
||||
/**
|
||||
* Whether to preselect the given collection for synchronisation, according to the
|
||||
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
|
||||
*
|
||||
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
|
||||
*
|
||||
* Before a collection is pre-selected, we check whether its URL matches the regexp in
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
|
||||
*
|
||||
* @param collection the collection to check
|
||||
* @param homeSets list of personal home-sets
|
||||
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
|
||||
*/
|
||||
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
|
||||
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
|
||||
|
||||
val excluded by lazy {
|
||||
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
|
||||
if (!excludedRegex.isNullOrEmpty())
|
||||
Regex(excludedRegex).containsMatchIn(collection.url.toString())
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
return when (shouldPreselect) {
|
||||
Settings.PRESELECT_COLLECTIONS_ALL ->
|
||||
// preselect if collection url is not excluded
|
||||
!excluded
|
||||
|
||||
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
|
||||
// preselect if is personal (in a personal home-set), but not excluded
|
||||
homeSets
|
||||
.filter { homeset -> homeset.personal }
|
||||
.map { homeset -> homeset.id }
|
||||
.contains(collection.homeSetId)
|
||||
&& !excluded
|
||||
|
||||
else -> // don't preselect
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Logic for refreshing the list of collections (and their related information)
|
||||
* which do not belong to a home set.
|
||||
*/
|
||||
class CollectionsWithoutHomeSetRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): CollectionsWithoutHomeSetRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes collections which don't have a homeset.
|
||||
*
|
||||
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
|
||||
*/
|
||||
internal fun refreshCollectionsWithoutHomeSet() {
|
||||
val withoutHomeSet = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
|
||||
for ((url, localCollection) in withoutHomeSet) try {
|
||||
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
|
||||
DavResource(httpClient, url).propfind(0, *collectionProperties) { response, _ ->
|
||||
if (!response.isSuccess()) {
|
||||
collectionRepository.delete(localCollection)
|
||||
return@propfind
|
||||
}
|
||||
|
||||
// Save or update the collection, if usable, otherwise delete it
|
||||
Collection.fromDavResponse(response)?.let { collection ->
|
||||
if (!ServiceDetectionUtils.isUsableCollection(service, collection))
|
||||
return@let
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection.copy(
|
||||
serviceId = localCollection.serviceId, // use same service ID as previous entry
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
))
|
||||
} ?: collectionRepository.delete(localCollection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete collection locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
collectionRepository.delete(localCollection)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Used to update the list of synchronizable collections
|
||||
*/
|
||||
class HomeSetRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val homeSetRepository: DavHomeSetRepository,
|
||||
private val settings: SettingsManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): HomeSetRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes home-sets and their collections.
|
||||
*
|
||||
* Each stored home-set URL is queried (`PROPFIND`) and its collections are either saved, updated
|
||||
* or marked as "without home-set" - in case a collection was removed from its home-set.
|
||||
*
|
||||
* If a home-set URL in fact points to a collection directly, the collection will be saved with this URL,
|
||||
* and a null value for it's home-set. Refreshing of collections without home-sets is then handled by [CollectionsWithoutHomeSetRefresher.refreshCollectionsWithoutHomeSet].
|
||||
*/
|
||||
internal fun refreshHomesetsAndTheirCollections() {
|
||||
val homesets = homeSetRepository.getByServiceBlocking(service.id).associateBy { it.url }.toMutableMap()
|
||||
for ((homeSetUrl, localHomeset) in homesets) {
|
||||
logger.fine("Listing home set $homeSetUrl")
|
||||
|
||||
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
|
||||
// is successfully rediscovered. If there are collections left, after processing is done, these are marked as "without home-set".
|
||||
val localHomesetCollections = db.collectionDao()
|
||||
.getByServiceAndHomeset(service.id, localHomeset.id)
|
||||
.associateBy { it.url }
|
||||
.toMutableMap()
|
||||
|
||||
try {
|
||||
val collectionProperties = ServiceDetectionUtils.collectionQueryProperties(service.type)
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *collectionProperties) { response, relation ->
|
||||
// Note: This callback may be called multiple times ([MultiResponseCallback])
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
// this response is about the home set itself
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(
|
||||
localHomeset.copy(
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind != false
|
||||
)
|
||||
)
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
var collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection = collection.copy(
|
||||
serviceId = service.id,
|
||||
homeSetId = localHomeset.id,
|
||||
sync = shouldPreselect(collection, homesets.values),
|
||||
ownerId = response[Owner::class.java]?.href // save the principal id (collection owner)
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl -> Principal.fromServiceAndUrl(service, principalUrl) }
|
||||
?.let { principal -> db.principalDao().insertOrUpdate(service.id, principal) }
|
||||
)
|
||||
logger.log(Level.FINE, "Found collection", collection)
|
||||
|
||||
// save or update collection if usable (ignore it otherwise)
|
||||
if (ServiceDetectionUtils.isUsableCollection(service, collection))
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(collection)
|
||||
|
||||
// Remove this collection from queue - because it was found in the home set
|
||||
localHomesetCollections.remove(collection.url)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete home set locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
homeSetRepository.deleteBlocking(localHomeset)
|
||||
}
|
||||
|
||||
// Mark leftover (not rediscovered) collections from queue as "without home-set" (remove association)
|
||||
for ((_, collection) in localHomesetCollections)
|
||||
collectionRepository.insertOrUpdateByUrlRememberSync(
|
||||
collection.copy(homeSetId = null)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to preselect the given collection for synchronisation, according to the
|
||||
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
|
||||
*
|
||||
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
|
||||
*
|
||||
* Before a collection is pre-selected, we check whether its URL matches the regexp in
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
|
||||
*
|
||||
* @param collection the collection to check
|
||||
* @param homeSets list of personal home-sets
|
||||
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
|
||||
*/
|
||||
internal fun shouldPreselect(collection: Collection, homeSets: Iterable<HomeSet>): Boolean {
|
||||
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
|
||||
|
||||
val excluded by lazy {
|
||||
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
|
||||
if (!excludedRegex.isNullOrEmpty())
|
||||
Regex(excludedRegex).containsMatchIn(collection.url.toString())
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
return when (shouldPreselect) {
|
||||
Settings.PRESELECT_COLLECTIONS_ALL ->
|
||||
// preselect if collection url is not excluded
|
||||
!excluded
|
||||
|
||||
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
|
||||
// preselect if is personal (in a personal home-set), but not excluded
|
||||
homeSets
|
||||
.filter { homeset -> homeset.personal }
|
||||
.map { homeset -> homeset.id }
|
||||
.contains(collection.homeSetId)
|
||||
&& !excluded
|
||||
|
||||
else -> // don't preselect
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Used to update the principals (their current display names) and delete those without collections.
|
||||
*/
|
||||
class PrincipalsRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): PrincipalsRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
|
||||
/**
|
||||
* Refreshes the principals (get their current display names).
|
||||
* Also removes principals which do not own any collections anymore.
|
||||
*/
|
||||
fun refreshPrincipals() {
|
||||
// Refresh principals (collection owner urls)
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
for (oldPrincipal in principals) {
|
||||
val principalUrl = oldPrincipal.url
|
||||
logger.fine("Querying principal $principalUrl")
|
||||
try {
|
||||
DavResource(httpClient, principalUrl).propfind(0, *principalProperties) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
Principal.fromDavResponse(service.id, response)?.let { principal ->
|
||||
logger.fine("Got principal: $principal")
|
||||
db.principalDao().insertOrUpdate(service.id, principal)
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
logger.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete principals which don't own any collections
|
||||
db.principalDao().getAllWithoutCollections().forEach { principal ->
|
||||
db.principalDao().delete(principal)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -62,11 +62,14 @@ import java.util.logging.Logger
|
||||
class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
private val collectionListRefresherFactory: CollectionListRefresher.Factory,
|
||||
private val collectionsWithoutHomeSetRefresherFactory: CollectionsWithoutHomeSetRefresher.Factory,
|
||||
private val homeSetRefresherFactory: HomeSetRefresher.Factory,
|
||||
private val httpClientBuilder: HttpClient.Builder,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry,
|
||||
private val principalsRefresherFactory: PrincipalsRefresher.Factory,
|
||||
private val pushRegistrationManager: PushRegistrationManager,
|
||||
private val serviceRefresherFactory: ServiceRefresher.Factory,
|
||||
serviceRepository: DavServiceRepository
|
||||
): CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
@@ -156,22 +159,25 @@ class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
.use { httpClient ->
|
||||
runInterruptible {
|
||||
val httpClient = httpClient.okHttpClient
|
||||
val refresher = collectionListRefresherFactory.create(service, httpClient)
|
||||
val refresher = collectionsWithoutHomeSetRefresherFactory.create(service, httpClient)
|
||||
|
||||
// refresh home set list (from principal url)
|
||||
service.principal?.let { principalUrl ->
|
||||
logger.fine("Querying principal $principalUrl for home sets")
|
||||
refresher.discoverHomesets(principalUrl)
|
||||
val serviceRefresher = serviceRefresherFactory.create(service, httpClient)
|
||||
serviceRefresher.discoverHomesets(principalUrl)
|
||||
}
|
||||
|
||||
// refresh home sets and their member collections
|
||||
refresher.refreshHomesetsAndTheirCollections()
|
||||
homeSetRefresherFactory.create(service, httpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// also refresh collections without a home set
|
||||
refresher.refreshHomelessCollections()
|
||||
refresher.refreshCollectionsWithoutHomeSet()
|
||||
|
||||
// Lastly, refresh the principals (collection owners)
|
||||
refresher.refreshPrincipals()
|
||||
val principalsRefresher = principalsRefresherFactory.create(service, httpClient)
|
||||
principalsRefresher.refreshPrincipals()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezoneId
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.db.ServiceType
|
||||
|
||||
object ServiceDetectionUtils {
|
||||
|
||||
/**
|
||||
* WebDAV properties to ask for in a PROPFIND request on a collection.
|
||||
*/
|
||||
fun collectionQueryProperties(@ServiceType serviceType: String): Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
ResourceType.NAME,
|
||||
PushTransports.NAME, // WebDAV-Push
|
||||
Topic.NAME
|
||||
) + when (serviceType) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookDescription.NAME
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarColor.NAME,
|
||||
CalendarDescription.NAME,
|
||||
CalendarTimezone.NAME,
|
||||
CalendarTimezoneId.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out whether given collection is usable for synchronization, by checking that either
|
||||
*
|
||||
* - CalDAV/CardDAV: service and collection type match, or
|
||||
* - WebCal: subscription source URL is not empty.
|
||||
*/
|
||||
fun isUsableCollection(service: Service, collection: Collection) =
|
||||
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
|
||||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
|
||||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
import at.bitfire.davdroid.util.DavUtils.parent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* ServiceRefresher is used to discover and save home sets of a given service.
|
||||
*/
|
||||
class ServiceRefresher @AssistedInject constructor(
|
||||
@Assisted private val service: Service,
|
||||
@Assisted private val httpClient: OkHttpClient,
|
||||
private val logger: Logger,
|
||||
private val homeSetRepository: DavHomeSetRepository
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(service: Service, httpClient: OkHttpClient): ServiceRefresher
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set class to use depending on the given service type.
|
||||
*/
|
||||
private val homeSetClass: Class<out HrefListProperty> =
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> AddressbookHomeSet::class.java
|
||||
Service.TYPE_CALDAV -> CalendarHomeSet::class.java
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-set properties to ask for in a PROPFIND request to the principal URL,
|
||||
* depending on the given service type.
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
DisplayName.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookHomeSet.NAME,
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at given principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
* @param alreadyQueriedPrincipals The HttpUrls of principals which have been queried already, to avoid querying principals more than once.
|
||||
* @param alreadySavedHomeSets The HttpUrls of home sets which have been saved to database already, to avoid saving home sets
|
||||
* more than once, which could overwrite the already set "personal" flag with `false`.
|
||||
*
|
||||
* @throws java.io.IOException on I/O errors
|
||||
* @throws HttpException on HTTP errors
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException on application-level or logical errors
|
||||
*/
|
||||
internal fun discoverHomesets(
|
||||
principalUrl: HttpUrl,
|
||||
level: Int = 0,
|
||||
alreadyQueriedPrincipals: MutableSet<HttpUrl> = mutableSetOf(),
|
||||
alreadySavedHomeSets: MutableSet<HttpUrl> = mutableSetOf()
|
||||
) {
|
||||
logger.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *homeSetProperties) { davResponse, _ ->
|
||||
alreadyQueriedPrincipals += davResponse.href
|
||||
|
||||
// If response holds home sets, save them
|
||||
davResponse[homeSetClass]?.let { homeSets ->
|
||||
for (homeSetHref in homeSets.hrefs)
|
||||
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
|
||||
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
|
||||
if (!alreadySavedHomeSets.contains(resolvedHomeSetUrl)) {
|
||||
homeSetRepository.insertOrUpdateByUrlBlocking(
|
||||
// HomeSet is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals while still being considered "personal" (belonging to the current-user-principal)
|
||||
// and an owned home set need not always be personal either.
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
alreadySavedHomeSets += resolvedHomeSetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add related principals to be queried afterwards
|
||||
if (personal) {
|
||||
val relatedResourcesTypes = listOf(
|
||||
// current resource is a read/write-proxy for other principals
|
||||
CalendarProxyReadFor::class.java,
|
||||
CalendarProxyWriteFor::class.java,
|
||||
// current resource is a member of a group (principal that can also have proxies)
|
||||
GroupMembership::class.java
|
||||
)
|
||||
for (type in relatedResourcesTypes)
|
||||
davResponse[type]?.let {
|
||||
for (href in it.hrefs)
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
relatedResources += url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
|
||||
davResponse[ResourceType::class.java]?.let { resourceType ->
|
||||
val proxyProperties = arrayOf(
|
||||
ResourceType.CALENDAR_PROXY_READ,
|
||||
ResourceType.CALENDAR_PROXY_WRITE,
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code / 100 == 4)
|
||||
logger.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
// query related resources
|
||||
if (level <= 1)
|
||||
for (resource in relatedResources)
|
||||
if (alreadyQueriedPrincipals.contains(resource))
|
||||
logger.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(
|
||||
principalUrl = resource,
|
||||
level = level + 1,
|
||||
alreadyQueriedPrincipals = alreadyQueriedPrincipals,
|
||||
alreadySavedHomeSets = alreadySavedHomeSets
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -354,7 +354,12 @@ class AccountSettings @AssistedInject constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 20
|
||||
/**
|
||||
* Current (usually the newest) account settings version. It's used to
|
||||
* determine whether a migration ([AccountSettingsMigration])
|
||||
* should be performed.
|
||||
*/
|
||||
const val CURRENT_VERSION = 21
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
|
||||
@@ -10,7 +10,8 @@ import at.bitfire.davdroid.settings.AccountSettings
|
||||
interface AccountSettingsMigration {
|
||||
|
||||
/**
|
||||
* Migrate the account settings from the old version to the new version.
|
||||
* Migrate the account settings from the old version to the new version which
|
||||
* is set in [AccountSettings.CURRENT_VERSION].
|
||||
*
|
||||
* **The new (target) version number is registered in the Hilt module as [Int] key of the multi-binding of [AccountSettings].**
|
||||
*
|
||||
|
||||
@@ -12,8 +12,8 @@ import android.provider.CalendarContract
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.UnknownProperty
|
||||
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
@@ -69,7 +69,7 @@ class AccountSettingsMigration12 @Inject constructor(
|
||||
val property = UnknownProperty.fromJsonString(rawValue)
|
||||
if (property is Url) { // rewrite to MIMETYPE_URL
|
||||
val newValues = contentValuesOf(
|
||||
CalendarContract.ExtendedProperties.NAME to AndroidEvent.EXTNAME_URL,
|
||||
CalendarContract.ExtendedProperties.NAME to AndroidEvent2.EXTNAME_URL,
|
||||
CalendarContract.ExtendedProperties.VALUE to property.value
|
||||
)
|
||||
provider.update(uri, newValues, null, null)
|
||||
@@ -77,7 +77,7 @@ class AccountSettingsMigration12 @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
logger.log(
|
||||
Level.WARNING,
|
||||
"Couldn't rewrite URL from unknown property to ${AndroidEvent.EXTNAME_URL}",
|
||||
"Couldn't rewrite URL from unknown property to ${AndroidEvent2.EXTNAME_URL}",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,12 +65,7 @@ class AccountSettingsMigration20 @Inject constructor(
|
||||
|
||||
@OpenForTesting
|
||||
internal fun migrateAddressBooks(account: Account, cardDavServiceId: Long) {
|
||||
try {
|
||||
addressBookStore.acquireContentProvider()
|
||||
} catch (_: SecurityException) {
|
||||
// no contacts permission
|
||||
null
|
||||
}?.use { provider ->
|
||||
addressBookStore.acquireContentProvider()?.use { provider ->
|
||||
for (addressBook in addressBookStore.getAll(account, provider)) {
|
||||
val url = accountManager.getUserData(addressBook.addressBookAccount, ADDRESS_BOOK_USER_DATA_URL) ?: continue
|
||||
val collection = collectionRepository.getByServiceAndUrl(cardDavServiceId, url) ?: continue
|
||||
@@ -81,12 +76,7 @@ class AccountSettingsMigration20 @Inject constructor(
|
||||
|
||||
@OpenForTesting
|
||||
internal fun migrateCalendars(account: Account, calDavServiceId: Long) {
|
||||
try {
|
||||
calendarStore.acquireContentProvider()
|
||||
} catch (_: SecurityException) {
|
||||
// no contacts permission
|
||||
null
|
||||
}?.use { client ->
|
||||
calendarStore.acquireContentProvider()?.use { client ->
|
||||
val calendarProvider = AndroidCalendarProvider(account, client)
|
||||
// for each calendar, assign _SYNC_ID := ID if collection (identified by NAME field = URL)
|
||||
for (calendar in calendarProvider.findCalendars()) {
|
||||
@@ -104,12 +94,7 @@ class AccountSettingsMigration20 @Inject constructor(
|
||||
@OpenForTesting
|
||||
internal fun migrateTaskLists(account: Account, calDavServiceId: Long) {
|
||||
val taskListStore = tasksAppManager.getDataStore() ?: /* no tasks app */ return
|
||||
try {
|
||||
taskListStore.acquireContentProvider()
|
||||
} catch (_: SecurityException) {
|
||||
// no tasks permission
|
||||
null
|
||||
}?.use { provider ->
|
||||
taskListStore.acquireContentProvider()?.use { provider ->
|
||||
for (taskList in taskListStore.getAll(account, provider)) {
|
||||
when (taskList) {
|
||||
is LocalTaskList -> { // tasks.org, OpenTasks
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings.migration
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* On Android 14+ the pending sync state of the Sync Adapter Framework is not handled correctly.
|
||||
* As a workaround we cancel incoming sync requests (clears pending flag) after enqueuing our own
|
||||
* sync worker (work manager). With version 4.5.3 we started cancelling pending syncs for DAVx5
|
||||
* accounts, but forgot to do that for address book accounts. With version 4.5.4 we also cancel
|
||||
* those, but only when contact data of an address book has been edited.
|
||||
*
|
||||
* This migration cancels (once only) any possibly still wrongly pending address book and calendar
|
||||
* (+tasks) account syncs.
|
||||
*/
|
||||
class AccountSettingsMigration21 @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val syncFrameworkIntegration: SyncFrameworkIntegration,
|
||||
private val logger: Logger
|
||||
): AccountSettingsMigration {
|
||||
|
||||
private val accountManager = AccountManager.get(context)
|
||||
|
||||
private val calendarAccountType = context.getString(R.string.account_type)
|
||||
private val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
|
||||
override fun migrate(account: Account) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
// Cancel any (after an update) possibly forever pending calendar (+tasks) account syncs
|
||||
cancelSyncs(calendarAccountType, CalendarContract.AUTHORITY)
|
||||
|
||||
// Cancel any (after an update) possibly forever pending address book account syncs
|
||||
cancelSyncs(addressBookAccountType, ContactsContract.AUTHORITY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels any (possibly forever pending) syncs for the accounts of given account type for all
|
||||
* authorities.
|
||||
*/
|
||||
private fun cancelSyncs(accountType: String, authority: String) {
|
||||
accountManager.getAccountsByType(accountType).forEach { account ->
|
||||
logger.info("Android 14+: Canceling all (possibly forever pending) syncs for $account")
|
||||
syncFrameworkIntegration.cancelSync(account, authority, Bundle())
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AccountSettingsMigrationModule {
|
||||
@Binds @IntoMap
|
||||
@IntKey(21)
|
||||
abstract fun provide(impl: AccountSettingsMigration21): AccountSettingsMigration
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -28,7 +28,6 @@ import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.EventReader
|
||||
import at.bitfire.ical4android.EventWriter
|
||||
@@ -50,6 +49,7 @@ import java.io.StringReader
|
||||
import java.io.StringWriter
|
||||
import java.time.Duration
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.Optional
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
@@ -159,7 +159,7 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
for (event in localCollection.findDirty()) {
|
||||
logger.warning("Resetting locally modified event to ETag=null (read-only calendar!)")
|
||||
SyncException.wrapWithLocalResource(event) {
|
||||
event.clearDirty(null, null)
|
||||
event.clearDirty(Optional.empty(), null, null)
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
@@ -178,9 +178,16 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
return modified or superModified
|
||||
}
|
||||
|
||||
override fun onSuccessfulUpload(local: LocalEvent, newFileName: String, eTag: String?, scheduleTag: String?) {
|
||||
super.onSuccessfulUpload(local, newFileName, eTag, scheduleTag)
|
||||
|
||||
// update local SEQUENCE to new value after successful upload
|
||||
local.updateSequence(local.getCachedEvent().sequence)
|
||||
}
|
||||
|
||||
override fun generateUpload(resource: LocalEvent): RequestBody =
|
||||
SyncException.wrapWithLocalResource(resource) {
|
||||
val event = requireNotNull(resource.androidEvent.event)
|
||||
val event = resource.eventToUpload()
|
||||
logger.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
// write iCalendar to string and convert to request body
|
||||
@@ -284,15 +291,22 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
if (local != null) {
|
||||
logger.log(Level.INFO, "Updating $fileName in local calendar", event)
|
||||
local.eTag = eTag
|
||||
local.scheduleTag = scheduleTag
|
||||
local.update(event)
|
||||
local.update(
|
||||
data = event,
|
||||
fileName = fileName,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
} else {
|
||||
logger.log(Level.INFO, "Adding $fileName to local calendar", event)
|
||||
val newLocal = LocalEvent(AndroidEvent(localCollection.androidCalendar, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT))
|
||||
SyncException.wrapWithLocalResource(newLocal) {
|
||||
newLocal.add()
|
||||
}
|
||||
localCollection.add(
|
||||
event = event,
|
||||
fileName = fileName,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
)
|
||||
}
|
||||
}
|
||||
} else
|
||||
|
||||
@@ -244,7 +244,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
logger.warning("Resetting locally modified group to ETag=null (read-only address book!)")
|
||||
SyncException.wrapWithLocalResource(group) {
|
||||
group.clearDirty(null, null)
|
||||
group.clearDirty(Optional.empty(), null)
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
@@ -252,7 +252,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
for (contact in localCollection.findDirtyContacts()) {
|
||||
logger.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
|
||||
SyncException.wrapWithLocalResource(contact) {
|
||||
contact.clearDirty(null, null)
|
||||
contact.clearDirty(Optional.empty(), null)
|
||||
}
|
||||
modified = true
|
||||
}
|
||||
@@ -391,56 +391,73 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
val newData = contacts.first()
|
||||
groupStrategy.verifyContactBeforeSaving(newData)
|
||||
|
||||
// update local contact, if it exists
|
||||
val localOrNull = localCollection.findByName(fileName)
|
||||
SyncException.wrapWithLocalResource(localOrNull) {
|
||||
var local = localOrNull
|
||||
if (local != null) {
|
||||
logger.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
var updated: LocalAddress? = null
|
||||
|
||||
if (local is LocalGroup && newData.group) {
|
||||
// update group
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
val existing = localCollection.findByName(fileName)
|
||||
if (existing == null) {
|
||||
// create new contact/group
|
||||
if (newData.group) {
|
||||
logger.log(Level.INFO, "Creating local group", newData)
|
||||
val newGroup = LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newGroup) {
|
||||
newGroup.add()
|
||||
updated = newGroup
|
||||
}
|
||||
|
||||
} else if (local is LocalContact && !newData.group) {
|
||||
// update contact
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
} else {
|
||||
logger.log(Level.INFO, "Creating local contact", newData)
|
||||
val newContact = LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newContact) {
|
||||
newContact.add()
|
||||
updated = newContact
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// update existing local contact/group
|
||||
logger.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
|
||||
SyncException.wrapWithLocalResource(existing) {
|
||||
if ((existing is LocalGroup && newData.group) || (existing is LocalContact && !newData.group)) {
|
||||
// update contact / group
|
||||
|
||||
existing.update(
|
||||
data = newData,
|
||||
fileName = fileName,
|
||||
eTag = eTag,
|
||||
flags = LocalResource.FLAG_REMOTELY_PRESENT,
|
||||
scheduleTag = null
|
||||
)
|
||||
updated = existing
|
||||
|
||||
} else {
|
||||
// group has become an individual contact or vice versa, delete and create with new type
|
||||
local.delete()
|
||||
local = null
|
||||
}
|
||||
}
|
||||
existing.deleteLocal()
|
||||
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
logger.log(Level.INFO, "Creating local group", newData)
|
||||
val newGroup = LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newGroup) {
|
||||
newGroup.add()
|
||||
local = newGroup
|
||||
}
|
||||
} else {
|
||||
logger.log(Level.INFO, "Creating local contact", newData)
|
||||
val newContact = LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newContact) {
|
||||
newContact.add()
|
||||
local = newContact
|
||||
if (newData.group) {
|
||||
logger.log(Level.INFO, "Creating local group (was contact before)", newData)
|
||||
val newGroup = LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newGroup) {
|
||||
newGroup.add()
|
||||
updated = newGroup
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.log(Level.INFO, "Creating local contact (was group before)", newData)
|
||||
val newContact = LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
SyncException.wrapWithLocalResource(newContact) {
|
||||
newContact.add()
|
||||
updated = newContact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirtyVerifier.getOrNull()?.let { verifier ->
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
(local as? LocalContact)?.let { localContact ->
|
||||
verifier.updateHashCode(localCollection, localContact)
|
||||
}
|
||||
}
|
||||
// update hash code of updated contact, if applicable
|
||||
(updated as? LocalContact)?.let { updatedContact ->
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
dirtyVerifier.getOrNull()?.updateHashCode(localCollection, updatedContact)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
/**
|
||||
* We don't use @AndroidEntryPoint / @Inject because it's unavoidable that instrumented tests sometimes accidentally / asynchronously
|
||||
* create a [SyncAdapterService] instance before Hilt is initialized during the tests.
|
||||
*/
|
||||
@dagger.hilt.EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface EntryPoint {
|
||||
fun syncAdapter(): SyncAdapter
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
if (BuildConfig.DEBUG && !syncActive.get()) {
|
||||
// only for debug builds/testing: syncActive flag
|
||||
val logger = Logger.getLogger(this@SyncAdapterService::class.java.name)
|
||||
logger.log(Level.WARNING, "SyncAdapterService.onBind() was called but syncActive = false. Ignoring")
|
||||
|
||||
val fakeAdapter = object: AbstractThreadedSyncAdapter(this, false) {
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
val message = StringBuilder()
|
||||
message.append("FakeSyncAdapter onPerformSync(account=$account, extras=$extras, authority=$authority, syncResult=$syncResult)")
|
||||
for (key in extras.keySet())
|
||||
message.append("\n\textras[$key] = ${extras[key]}")
|
||||
logger.warning(message.toString())
|
||||
}
|
||||
}
|
||||
return fakeAdapter.syncAdapterBinder
|
||||
}
|
||||
|
||||
// create sync adapter via Hilt
|
||||
val entryPoint = EntryPointAccessors.fromApplication<EntryPoint>(this)
|
||||
val syncAdapter = entryPoint.syncAdapter()
|
||||
return syncAdapter.syncAdapterBinder
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Flag to indicate whether the sync adapter should be active. When it is `false`, synchronization will not be run
|
||||
* (only intended for tests).
|
||||
*/
|
||||
val syncActive = AtomicBoolean(true)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Entry point for the Sync Adapter Framework.
|
||||
*
|
||||
* Handles incoming sync requests from the Sync Adapter Framework.
|
||||
*
|
||||
* Although we do not use the sync adapter for syncing anymore, we keep this sole
|
||||
* adapter to provide exported services, which allow android system components and calendar,
|
||||
* contacts or task apps to sync via DAVx5.
|
||||
*
|
||||
* All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration].
|
||||
*/
|
||||
class SyncAdapter @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger,
|
||||
private val syncConditionsFactory: SyncConditions.Factory,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AbstractThreadedSyncAdapter(
|
||||
/* context = */ context,
|
||||
/* autoInitialize = */ true // Sets isSyncable=1 when isSyncable=-1 and SYNC_EXTRAS_INITIALIZE is set.
|
||||
// Doesn't matter for us because we have android:isAlwaysSyncable="true" for all sync adapters.
|
||||
) {
|
||||
|
||||
/**
|
||||
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
|
||||
* requests cancellation.
|
||||
*/
|
||||
private val waitScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
// We have to pass this old SyncFramework extra for an Android 7 workaround
|
||||
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
|
||||
|
||||
// If we should sync an address book account - find the account storing the settings
|
||||
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
|
||||
AccountManager.get(context)
|
||||
.getUserData(accountOrAddressBookAccount, USER_DATA_COLLECTION_ID)
|
||||
?.toLongOrNull()
|
||||
?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
accountOrAddressBookAccount
|
||||
|
||||
if (account == null) {
|
||||
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
|
||||
return
|
||||
}
|
||||
|
||||
// Check sync conditions
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
|
||||
return
|
||||
}
|
||||
val syncConditions = syncConditionsFactory.create(accountSettings)
|
||||
// Should we run the sync at all?
|
||||
if (!syncConditions.wifiConditionsMet()) {
|
||||
logger.info("Sync conditions not met. Aborting sync framework initiated sync")
|
||||
return
|
||||
}
|
||||
|
||||
logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it")
|
||||
val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.fromAuthority(authority), fromUpload = upload)
|
||||
|
||||
|
||||
/* Because we are not allowed to observe worker state on a background thread, we can not
|
||||
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
|
||||
has finished. */
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
try {
|
||||
val waitJob = waitScope.launch {
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { infoList ->
|
||||
for (info in infoList)
|
||||
if (info.state.isFinished) {
|
||||
if (info.state == WorkInfo.State.FAILED) {
|
||||
if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false))
|
||||
syncResult.tooManyRetries = true
|
||||
else
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
cancel("$workerName has finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
|
||||
waitJob.join() // wait until worker has finished
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
// waiting for work was cancelled, either by timeout or because the worker has finished
|
||||
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Returning to sync framework.", syncResult)
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
logger.log(Level.WARNING, "Security exception for $account/$authority")
|
||||
}
|
||||
|
||||
override fun onSyncCanceled() {
|
||||
logger.info("Sync adapter requested cancellation – won't cancel sync, but also won't block sync framework anymore")
|
||||
|
||||
// unblock sync framework
|
||||
waitScope.cancel()
|
||||
}
|
||||
|
||||
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// exported sync adapter services; we need a separate class for each authority
|
||||
class CalendarsSyncAdapterService: SyncAdapterService()
|
||||
class ContactsSyncAdapterService: SyncAdapterService()
|
||||
class JtxSyncAdapterService: SyncAdapterService()
|
||||
class OpenTasksSyncAdapterService: SyncAdapterService()
|
||||
class TasksOrgSyncAdapterService: SyncAdapterService()
|
||||
@@ -4,11 +4,13 @@
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
enum class SyncDataType {
|
||||
@@ -23,19 +25,38 @@ enum class SyncDataType {
|
||||
fun tasksAppManager(): TasksAppManager
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns authorities which exist for this sync data type. Used on [TASKS] the method
|
||||
* may return an empty list if there are no tasks providers (installed tasks apps).
|
||||
*
|
||||
* @return list of authorities matching this data type
|
||||
*/
|
||||
fun possibleAuthorities(): List<String> =
|
||||
when (this) {
|
||||
CONTACTS -> listOf(
|
||||
ContactsContract.AUTHORITY
|
||||
)
|
||||
EVENTS -> listOf(
|
||||
CalendarContract.AUTHORITY
|
||||
)
|
||||
TASKS ->
|
||||
TaskProvider.ProviderName.entries.map { it.authority }
|
||||
CONTACTS -> listOf(ContactsContract.AUTHORITY)
|
||||
EVENTS -> listOf(CalendarContract.AUTHORITY)
|
||||
TASKS -> TaskProvider.ProviderName.entries.map { it.authority }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authority corresponding to this datatype.
|
||||
* When more than one tasks provider exists (tasks apps installed) the authority for the active
|
||||
* tasks provider (user selected tasks app) is returned.
|
||||
*
|
||||
* @param context android context used to determine the active/selected tasks provider
|
||||
* @return the authority matching this data type or *null* for [TASKS] if no tasks app is installed
|
||||
*/
|
||||
fun currentAuthority(context: Context): String? =
|
||||
when (this) {
|
||||
CONTACTS -> ContactsContract.AUTHORITY
|
||||
EVENTS -> CalendarContract.AUTHORITY
|
||||
TASKS -> EntryPointAccessors.fromApplication<SyncDataTypeEntryPoint>(context)
|
||||
.tasksAppManager()
|
||||
.currentProvider()
|
||||
?.authority
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
fun fromAuthority(authority: String): SyncDataType {
|
||||
|
||||
@@ -51,6 +51,7 @@ import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.security.cert.CertificateException
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.logging.Level
|
||||
@@ -347,7 +348,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
} else
|
||||
logger.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
|
||||
local.delete()
|
||||
local.deleteLocal()
|
||||
}
|
||||
}
|
||||
logger.info("Removed $numDeleted record(s) from server")
|
||||
@@ -378,57 +379,83 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
return numUploaded > 0
|
||||
}
|
||||
|
||||
protected suspend fun uploadDirty(local: ResourceType) {
|
||||
/**
|
||||
* Uploads a dirty local resource.
|
||||
*
|
||||
* @param local resource to upload
|
||||
* @param forceAsNew whether the ETag (and Schedule-Tag) of [local] are ignored and the resource
|
||||
* is created as a new resource on the server
|
||||
*/
|
||||
protected open suspend fun uploadDirty(local: ResourceType, forceAsNew: Boolean = false) {
|
||||
val existingFileName = local.fileName
|
||||
|
||||
var newFileName: String? = null
|
||||
var eTag: String? = null
|
||||
var scheduleTag: String? = null
|
||||
val readTagsFromResponse: (okhttp3.Response) -> Unit = { response ->
|
||||
eTag = GetETag.fromResponse(response)?.eTag
|
||||
scheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
|
||||
val fileName = if (existingFileName != null) {
|
||||
// prepare upload (for UID etc), but ignore returned file name suggestion
|
||||
local.prepareForUpload()
|
||||
existingFileName
|
||||
} else {
|
||||
// prepare upload and use returned file name suggestion as new file name
|
||||
local.prepareForUpload()
|
||||
}
|
||||
|
||||
val uploadUrl = collection.url.newBuilder().addPathSegment(fileName).build()
|
||||
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
|
||||
|
||||
try {
|
||||
if (existingFileName == null) { // new resource
|
||||
newFileName = local.prepareForUpload()
|
||||
|
||||
val uploadUrl = collection.url.newBuilder().addPathSegment(newFileName).build()
|
||||
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
|
||||
SyncException.wrapWithRemoteResourceSuspending(uploadUrl) {
|
||||
logger.info("Uploading new record ${local.id} -> $newFileName")
|
||||
SyncException.wrapWithRemoteResourceSuspending(uploadUrl) {
|
||||
if (existingFileName == null || forceAsNew) {
|
||||
// create new resource on server
|
||||
logger.info("Uploading new resource ${local.id} -> $fileName")
|
||||
val bodyToUpload = generateUpload(local)
|
||||
|
||||
var newETag: String? = null
|
||||
var newScheduleTag: String? = null
|
||||
runInterruptible {
|
||||
remote.put(
|
||||
bodyToUpload,
|
||||
ifNoneMatch = true,
|
||||
callback = readTagsFromResponse,
|
||||
ifNoneMatch = true, // fails if there's already a resource with that name
|
||||
callback = { response ->
|
||||
newETag = GetETag.fromResponse(response)?.eTag
|
||||
newScheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
|
||||
},
|
||||
headers = pushDontNotifyHeader
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
} else /* existingFileName != null */ { // updated resource
|
||||
local.prepareForUpload()
|
||||
logger.fine("Upload successful; new ETag=$newETag / Schedule-Tag=$newScheduleTag")
|
||||
|
||||
val uploadUrl = collection.url.newBuilder().addPathSegment(existingFileName).build()
|
||||
val remote = DavResource(httpClient.okHttpClient, uploadUrl)
|
||||
SyncException.wrapWithRemoteResourceSuspending(uploadUrl) {
|
||||
val lastScheduleTag = local.scheduleTag
|
||||
val lastETag = if (lastScheduleTag == null) local.eTag else null
|
||||
logger.info("Uploading modified record ${local.id} -> $existingFileName (ETag=$lastETag, Schedule-Tag=$lastScheduleTag)")
|
||||
// success (no exception thrown)
|
||||
onSuccessfulUpload(local, fileName, newETag, newScheduleTag)
|
||||
|
||||
} else {
|
||||
// update resource on server
|
||||
val ifScheduleTag = local.scheduleTag
|
||||
val ifETag = if (ifScheduleTag == null) local.eTag else null
|
||||
|
||||
logger.info("Uploading modified resource ${local.id} -> $fileName (if ETag=$ifETag / Schedule-Tag=$ifScheduleTag)")
|
||||
val bodyToUpload = generateUpload(local)
|
||||
|
||||
var updatedETag: String? = null
|
||||
var updatedScheduleTag: String? = null
|
||||
runInterruptible {
|
||||
remote.put(
|
||||
bodyToUpload,
|
||||
ifETag = lastETag,
|
||||
ifScheduleTag = lastScheduleTag,
|
||||
callback = readTagsFromResponse,
|
||||
ifETag = ifETag,
|
||||
ifScheduleTag = ifScheduleTag,
|
||||
callback = { response ->
|
||||
updatedETag = GetETag.fromResponse(response)?.eTag
|
||||
updatedScheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
|
||||
},
|
||||
headers = pushDontNotifyHeader
|
||||
)
|
||||
}
|
||||
|
||||
logger.fine("Upload successful; updated ETag=$updatedETag / Schedule-Tag=$updatedScheduleTag")
|
||||
|
||||
// success (no exception thrown)
|
||||
onSuccessfulUpload(local, fileName, updatedETag, updatedScheduleTag)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: SyncException) {
|
||||
when (val ex = e.cause) {
|
||||
is ForbiddenException -> {
|
||||
@@ -441,15 +468,14 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
is NotFoundException, is GoneException -> {
|
||||
// HTTP 404 Not Found (i.e. either original resource or the whole collection is not there anymore)
|
||||
if (local.scheduleTag != null || local.eTag != null) { // this was an update of a previously existing resource
|
||||
if (!forceAsNew) { // first try; if this fails with 404, too, the collection is gone
|
||||
logger.info("Original version of locally modified resource is not there (anymore), trying as fresh upload")
|
||||
if (local.scheduleTag != null) // contacts don't support scheduleTag, don't try to set it without check
|
||||
local.scheduleTag = null
|
||||
local.eTag = null
|
||||
uploadDirty(local) // if this fails with 404, too, the collection is gone
|
||||
uploadDirty(local, forceAsNew = true)
|
||||
return
|
||||
} else
|
||||
throw e // the collection is probably gone
|
||||
} else {
|
||||
// we tried with forceAsNew, collection probably gone
|
||||
throw e
|
||||
}
|
||||
}
|
||||
is ConflictException -> {
|
||||
// HTTP 409 Conflict
|
||||
@@ -464,13 +490,16 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eTag != null)
|
||||
logger.fine("Received new ETag=$eTag after uploading")
|
||||
else
|
||||
logger.fine("Didn't receive new ETag after uploading, setting to null")
|
||||
|
||||
local.clearDirty(newFileName, eTag, scheduleTag)
|
||||
/**
|
||||
* Called after a successful upload (either of a new or an updated resource) so that the local
|
||||
* _dirty_ state can be reset.
|
||||
*
|
||||
* Note: [CalendarSyncManager] overrides this method to additionally store the updated SEQUENCE.
|
||||
*/
|
||||
protected open fun onSuccessfulUpload(local: ResourceType, newFileName: String, eTag: String?, scheduleTag: String?) {
|
||||
local.clearDirty(Optional.of(newFileName), eTag, scheduleTag)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -607,7 +636,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
localCollection.findByName(name)?.let { local ->
|
||||
SyncException.wrapWithLocalResource(local) {
|
||||
logger.info("$name has been deleted on server, deleting locally")
|
||||
local.delete()
|
||||
local.deleteLocal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ abstract class Syncer<StoreType: LocalDataStore<CollectionType>, CollectionType:
|
||||
logger.info("${dataStore.authority} sync of $account initiated (resync=$resync)")
|
||||
|
||||
try {
|
||||
dataStore.acquireContentProvider()
|
||||
dataStore.acquireContentProvider(throwOnMissingPermissions = true)
|
||||
} catch (e: SecurityException) {
|
||||
logger.log(Level.WARNING, "Missing permissions for content provider authority ${dataStore.authority}", e)
|
||||
/* Don't show a notification here without possibility to permanently dismiss it!
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.adapter
|
||||
|
||||
import android.os.IBinder
|
||||
|
||||
/**
|
||||
* Interface for an Android sync adapter, as created by [SyncAdapterService].
|
||||
*
|
||||
* Sync adapters are bound services that communicate over IPC, so the only method is
|
||||
* [getBinder], which returns the sync adapter binder.
|
||||
*/
|
||||
interface SyncAdapter {
|
||||
|
||||
fun getBinder(): IBinder
|
||||
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.adapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncConditions
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Entry point for the Sync Adapter Framework.
|
||||
*
|
||||
* Handles incoming sync requests from the Sync Adapter Framework.
|
||||
*
|
||||
* Although we do not use the sync adapter for syncing anymore, we keep this sole
|
||||
* adapter to provide exported services, which allow android system components and calendar,
|
||||
* contacts or task apps to sync via DAVx5.
|
||||
*
|
||||
* All Sync Adapter Framework related interaction should happen inside [SyncFrameworkIntegration].
|
||||
*/
|
||||
class SyncAdapterImpl @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger,
|
||||
private val syncConditionsFactory: SyncConditions.Factory,
|
||||
private val syncFrameworkIntegration: SyncFrameworkIntegration,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AbstractThreadedSyncAdapter(
|
||||
/* context = */ context,
|
||||
/* autoInitialize = */ true // Sets isSyncable=1 when isSyncable=-1 and SYNC_EXTRAS_INITIALIZE is set.
|
||||
// Doesn't matter for us because we have android:isAlwaysSyncable="true" for all sync adapters.
|
||||
), SyncAdapter {
|
||||
|
||||
/**
|
||||
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
|
||||
* requests cancellation.
|
||||
*/
|
||||
private val waitScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
// We have to pass this old SyncFramework extra for an Android 7 workaround
|
||||
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
|
||||
|
||||
// If we should sync an address book account - find the account storing the settings
|
||||
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
|
||||
AccountManager.get(context)
|
||||
.getUserData(accountOrAddressBookAccount, LocalAddressBook.USER_DATA_COLLECTION_ID)
|
||||
?.toLongOrNull()
|
||||
?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
accountOrAddressBookAccount
|
||||
|
||||
if (account == null) {
|
||||
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
|
||||
return
|
||||
}
|
||||
|
||||
// Check sync conditions
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
|
||||
return
|
||||
}
|
||||
val syncConditions = syncConditionsFactory.create(accountSettings)
|
||||
// Should we run the sync at all?
|
||||
if (!syncConditions.wifiConditionsMet()) {
|
||||
logger.info("Sync conditions not met. Aborting sync framework initiated sync")
|
||||
return
|
||||
}
|
||||
|
||||
logger.fine("Starting OneTimeSyncWorker for $account $authority and waiting for it")
|
||||
val workerName = syncWorkerManager.enqueueOneTime(account, dataType = SyncDataType.Companion.fromAuthority(authority), fromUpload = upload)
|
||||
|
||||
// Android 14+ does not handle pending sync state correctly.
|
||||
// As a defensive workaround, we can cancel specifically this still pending sync only
|
||||
// See: https://github.com/bitfireAT/davx5-ose/issues/1458
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
logger.fine("Android 14+ bug: Canceling forever pending sync adapter framework sync request for " +
|
||||
"account=$accountOrAddressBookAccount authority=$authority upload=$upload")
|
||||
syncFrameworkIntegration.cancelSync(accountOrAddressBookAccount, authority, extras)
|
||||
}
|
||||
|
||||
/* Because we are not allowed to observe worker state on a background thread, we can not
|
||||
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
|
||||
has finished. */
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
try {
|
||||
val waitJob = waitScope.launch {
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { infoList ->
|
||||
for (info in infoList)
|
||||
if (info.state.isFinished) {
|
||||
if (info.state == WorkInfo.State.FAILED) {
|
||||
if (info.outputData.getBoolean(BaseSyncWorker.OUTPUT_TOO_MANY_RETRIES, false))
|
||||
syncResult.tooManyRetries = true
|
||||
else
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
cancel("$workerName has finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
|
||||
waitJob.join() // wait until worker has finished
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
// waiting for work was cancelled, either by timeout or because the worker has finished
|
||||
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Returning to sync framework.", syncResult)
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
logger.log(Level.WARNING, "Security exception for $account/$authority")
|
||||
}
|
||||
|
||||
override fun onSyncCanceled() {
|
||||
logger.info("Sync adapter requested cancellation – won't cancel sync, but also won't block sync framework anymore")
|
||||
|
||||
// unblock sync framework
|
||||
waitScope.cancel()
|
||||
}
|
||||
|
||||
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
|
||||
|
||||
|
||||
// SyncAdapter implementation and Hilt module
|
||||
|
||||
override fun getBinder(): IBinder = syncAdapterBinder
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RealSyncAdapterModule {
|
||||
@Binds
|
||||
abstract fun provide(impl: SyncAdapterImpl): SyncAdapter
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.adapter
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EarlyEntryPoint
|
||||
import dagger.hilt.android.EarlyEntryPoints
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
/**
|
||||
* We don't use @AndroidEntryPoint / @Inject because it's unavoidable that instrumented tests sometimes accidentally / asynchronously
|
||||
* create a [SyncAdapterService] instance before Hilt is initialized by the HiltTestRunner.
|
||||
*/
|
||||
@EarlyEntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncAdapterServicesEntryPoint {
|
||||
fun syncAdapter(): SyncAdapter
|
||||
}
|
||||
|
||||
// create syncAdapter on demand and cache it
|
||||
val syncAdapter by lazy {
|
||||
val entryPoint = EarlyEntryPoints.get(applicationContext, SyncAdapterServicesEntryPoint::class.java)
|
||||
entryPoint.syncAdapter()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = syncAdapter.getBinder()
|
||||
|
||||
}
|
||||
|
||||
// exported sync adapter services; we need a separate class for each authority
|
||||
class CalendarsSyncAdapterService: SyncAdapterService()
|
||||
class ContactsSyncAdapterService: SyncAdapterService()
|
||||
class JtxSyncAdapterService: SyncAdapterService()
|
||||
class OpenTasksSyncAdapterService: SyncAdapterService()
|
||||
class TasksOrgSyncAdapterService: SyncAdapterService()
|
||||
@@ -2,19 +2,24 @@
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
package at.bitfire.davdroid.sync.adapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncRequest
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.resource.LocalAddressBookStore
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -29,6 +34,7 @@ import javax.inject.Inject
|
||||
* Sync requests from the Sync Adapter Framework are handled by [SyncAdapterService].
|
||||
*/
|
||||
class SyncFrameworkIntegration @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val localAddressBookStore: Lazy<LocalAddressBookStore>,
|
||||
private val logger: Logger
|
||||
) {
|
||||
@@ -94,6 +100,27 @@ class SyncFrameworkIntegration @Inject constructor(
|
||||
setSyncOnContentChange(account, authority, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the sync request in the Sync Adapter Framework by sync request. This
|
||||
* is the defensive approach canceling only one specific sync request with matching
|
||||
* sync extras.
|
||||
*
|
||||
* @param account The account for which the sync request should be canceled.
|
||||
* @param authority The authority for which the sync request should be canceled.
|
||||
* @param extras The original extras Bundle used to start the sync.
|
||||
*/
|
||||
fun cancelSync(account: Account, authority: String, extras: Bundle) {
|
||||
// Recreate the sync request which was used to start this sync
|
||||
val syncRequest = SyncRequest.Builder()
|
||||
.setSyncAdapter(account, authority)
|
||||
.setExtras(extras)
|
||||
.syncOnce()
|
||||
.build()
|
||||
|
||||
// Cancel it
|
||||
ContentResolver.cancelSync(syncRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
|
||||
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
|
||||
@@ -107,7 +134,7 @@ class SyncFrameworkIntegration @Inject constructor(
|
||||
*
|
||||
* @param account account to enable/disable content change sync triggers for
|
||||
* @param enable *true* enables automatic sync; *false* disables it
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @param authority sync authority (like [android.provider.CalendarContract.AUTHORITY])
|
||||
* @return whether the content triggered sync was enabled successfully
|
||||
*/
|
||||
@WorkerThread
|
||||
@@ -138,7 +165,7 @@ class SyncFrameworkIntegration @Inject constructor(
|
||||
*
|
||||
* @param account account to enable/disable content change sync triggers for
|
||||
* @param enable *true* enables automatic sync; *false* disables it
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @param authority sync authority (like [android.provider.CalendarContract.AUTHORITY])
|
||||
* @return whether the content triggered sync was enabled successfully
|
||||
*/
|
||||
private fun setContentTrigger(account: Account, authority: String, enable: Boolean): Boolean =
|
||||
@@ -153,55 +180,82 @@ class SyncFrameworkIntegration @Inject constructor(
|
||||
/**
|
||||
* Observe whether any of the given data types is currently pending for sync.
|
||||
*
|
||||
* Note: On Android 14+ finished syncs stay by default pending. This is why we
|
||||
* explicitly cancel the active sync in [SyncAdapterImpl] for Android 14+. Doing
|
||||
* so allows us to have a reliable "pending" flag again, which is used in this method.
|
||||
*
|
||||
* @param account account to observe sync status for
|
||||
* @param dataTypes data types to observe sync status for
|
||||
*
|
||||
* @return flow emitting true if any of the given data types has a sync pending, false otherwise
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> =
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
// On Android 14+ pending sync checks always return true (bug), so we don't need to check.
|
||||
// See: https://github.com/bitfireAT/davx5-ose/issues/1458
|
||||
flowOf(false)
|
||||
} else {
|
||||
val authorities = dataTypes.flatMap { it.possibleAuthorities() }
|
||||
|
||||
// Use address book accounts if needed
|
||||
val accountsFlow = if (dataTypes.contains(SyncDataType.CONTACTS))
|
||||
localAddressBookStore.get().getAddressBookAccountsFlow(account)
|
||||
else
|
||||
flowOf(listOf(account))
|
||||
|
||||
// Observe sync pending state for the given accounts and authorities
|
||||
accountsFlow.flatMapLatest { accounts ->
|
||||
callbackFlow {
|
||||
// Observe sync pending state
|
||||
val listener = ContentResolver.addStatusChangeListener(
|
||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING
|
||||
) {
|
||||
trySend(anyPendingSync(accounts, authorities))
|
||||
}
|
||||
|
||||
// Emit initial value
|
||||
trySend(anyPendingSync(accounts, authorities))
|
||||
|
||||
// Clean up listener on close
|
||||
awaitClose { ContentResolver.removeStatusChangeListener(listener) }
|
||||
fun isSyncPending(account: Account, dataTypes: Iterable<SyncDataType>): Flow<Boolean> {
|
||||
// Determine the pending state for each data type of the account as separate flows
|
||||
val pendingStateFlows: List<Flow<Boolean>> = dataTypes.mapNotNull { dataType ->
|
||||
// Map datatype to authority
|
||||
dataType.currentAuthority(context)?.let { authority ->
|
||||
// If checking contacts, we need to check all address book accounts instead of the single main account
|
||||
val accountsFlow: Flow<List<Account>> = when (dataType) {
|
||||
SyncDataType.CONTACTS -> localAddressBookStore.get().getAddressBookAccountsFlow(account)
|
||||
else -> flowOf(listOf(account))
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
// Return the pending state flow for accounts with this authority
|
||||
anyPendingSyncFlow(accountsFlow, authority)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine the different per data type pending state flows into one
|
||||
return combine(pendingStateFlows) { pendingStates ->
|
||||
pendingStates.any { pending -> pending }
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any of the given accounts and authorities have a sync pending.
|
||||
* Maps the given accounts flow to a simple boolean flow telling us whether any of the accounts
|
||||
* has a pending sync for given authority.
|
||||
*
|
||||
* @param accountsFlow accounts to check sync status for
|
||||
* @param authority authority to check sync status for
|
||||
*
|
||||
* @return returns flow which emits *true* if any of the accounts has a sync pending for
|
||||
* the given authority and *false* otherwise
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun anyPendingSyncFlow(
|
||||
accountsFlow: Flow<List<Account>>,
|
||||
authority: String
|
||||
): Flow<Boolean> = accountsFlow.flatMapLatest { accounts ->
|
||||
// Observe sync pending state for the given accounts and data types
|
||||
callbackFlow {
|
||||
// Observe sync pending state
|
||||
val listener = ContentResolver.addStatusChangeListener(
|
||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING
|
||||
) {
|
||||
trySend(anyPendingSync(accounts, authority))
|
||||
}
|
||||
|
||||
// Emit initial value
|
||||
trySend(anyPendingSync(accounts, authority))
|
||||
|
||||
// Clean up listener on close
|
||||
awaitClose { ContentResolver.removeStatusChangeListener(listener) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any of the given accounts have a sync pending for given authority.
|
||||
*
|
||||
* @param accounts accounts to check sync status for
|
||||
* @param authorities authorities to check sync status for
|
||||
* @return true if any of the given accounts and authorities has a sync pending, false otherwise
|
||||
* @param authority authority to check sync status for
|
||||
*
|
||||
* @return *true* if any of the given accounts has a sync pending for given authority; *false* otherwise
|
||||
*/
|
||||
private fun anyPendingSync(accounts: List<Account>, authorities: List<String>): Boolean =
|
||||
private fun anyPendingSync(accounts: List<Account>, authority: String): Boolean =
|
||||
accounts.any { account ->
|
||||
authorities.any { authority ->
|
||||
ContentResolver.isSyncPending(account, authority)
|
||||
ContentResolver.isSyncPending(account, authority).also { pending ->
|
||||
logger.finer("Sync pending($account, $authority) = $pending")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.sync.groups
|
||||
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import java.util.Optional
|
||||
import java.util.logging.Logger
|
||||
|
||||
class CategoriesStrategy(val addressBook: LocalAddressBook): ContactGroupStrategy {
|
||||
@@ -25,7 +26,7 @@ class CategoriesStrategy(val addressBook: LocalAddressBook): ContactGroupStrateg
|
||||
for (group in addressBook.findDirtyGroups()) {
|
||||
logger.fine("Marking members of modified group $group as dirty")
|
||||
group.markMembersDirty()
|
||||
group.clearDirty(null, null)
|
||||
group.clearDirty(Optional.empty(), null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
@@ -13,6 +15,7 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
@@ -22,13 +25,14 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
@@ -296,4 +300,34 @@ class AccountsModel @AssistedInject constructor(
|
||||
false
|
||||
}
|
||||
|
||||
fun cancelSyncAdapterSyncs() {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val calendarAccountType = context.getString(R.string.account_type)
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
|
||||
// Cancel any (after an update) possibly forever pending calendar account syncs
|
||||
cancelSyncs(calendarAccountType, SyncDataType.EVENTS.possibleAuthorities())
|
||||
|
||||
// Cancel any (after an update) possibly forever pending tasks account syncs
|
||||
cancelSyncs(calendarAccountType, SyncDataType.TASKS.possibleAuthorities())
|
||||
|
||||
// Cancel any (after an update) possibly forever pending address book account syncs
|
||||
cancelSyncs(addressBookAccountType, SyncDataType.CONTACTS.possibleAuthorities())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels any (possibly forever pending) syncs for the accounts of given account type for all
|
||||
* authorities.
|
||||
*/
|
||||
private fun cancelSyncs(accountType: String, authorities: List<String>) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.getAccountsByType(accountType).forEach { account ->
|
||||
logger.info("Android 14+: Canceling all (possibly forever pending) syncs for $account")
|
||||
for (authority in authorities)
|
||||
ContentResolver.cancelSync(account, authority)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.BatterySaver
|
||||
import androidx.compose.material.icons.filled.CancelScheduleSend
|
||||
import androidx.compose.material.icons.filled.DataSaverOn
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
@@ -111,6 +112,7 @@ fun AccountsScreen(
|
||||
}
|
||||
|
||||
AccountsScreen(
|
||||
cancelSyncAdapterSyncs = { model.cancelSyncAdapterSyncs() },
|
||||
accountsDrawerHandler = accountsDrawerHandler,
|
||||
accounts = accounts,
|
||||
showSyncAll = showSyncAll,
|
||||
@@ -131,6 +133,7 @@ fun AccountsScreen(
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun AccountsScreen(
|
||||
cancelSyncAdapterSyncs: () -> Unit,
|
||||
accountsDrawerHandler: AccountsDrawerHandler,
|
||||
accounts: List<AccountsModel.AccountInfo>,
|
||||
showSyncAll: Boolean = true,
|
||||
@@ -228,6 +231,17 @@ fun AccountsScreen(
|
||||
contentDescription = stringResource(R.string.accounts_sync_all)
|
||||
)
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = cancelSyncAdapterSyncs,
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondary,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CancelScheduleSend,
|
||||
contentDescription = stringResource(R.string.accounts_sync_all)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
@@ -321,6 +335,7 @@ fun AccountsScreen(
|
||||
@Preview
|
||||
fun AccountsScreen_Preview_Empty() {
|
||||
AccountsScreen(
|
||||
cancelSyncAdapterSyncs = {},
|
||||
accountsDrawerHandler = object: AccountsDrawerHandler() {
|
||||
@Composable
|
||||
override fun MenuEntries(snackbarHostState: SnackbarHostState) {
|
||||
@@ -337,6 +352,7 @@ fun AccountsScreen_Preview_Empty() {
|
||||
@Preview
|
||||
fun AccountsScreen_Preview_OneAccount() {
|
||||
AccountsScreen(
|
||||
cancelSyncAdapterSyncs = {},
|
||||
accountsDrawerHandler = object: AccountsDrawerHandler() {
|
||||
@Composable
|
||||
override fun MenuEntries(snackbarHostState: SnackbarHostState) {
|
||||
|
||||
@@ -41,8 +41,8 @@ import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.account.InvalidAccountException
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
|
||||
@@ -47,7 +47,6 @@ class NotificationRegistry @Inject constructor(
|
||||
const val NOTIFY_DATABASE_CORRUPTED = 4
|
||||
const val NOTIFY_SYNC_ERROR = 10
|
||||
const val NOTIFY_INVALID_RESOURCE = 11
|
||||
const val NOTIFY_WEBDAV_ACCESS = 12
|
||||
const val NOTIFY_SYNC_EXPEDITED = 14
|
||||
const val NOTIFY_TASKS_PROVIDER_TOO_OLD = 20
|
||||
const val NOTIFY_PERMISSIONS = 21
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.work.WorkInfo
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
||||
@@ -7,18 +7,29 @@ import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CalendarToday
|
||||
import androidx.compose.material.icons.filled.CreateNewFolder
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DriveFileRenameOutline
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.SyncProblem
|
||||
@@ -56,6 +67,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -78,6 +90,7 @@ import at.bitfire.davdroid.ui.account.CollectionsList
|
||||
import at.bitfire.davdroid.ui.account.RenameAccountDialog
|
||||
import at.bitfire.davdroid.ui.composable.ActionCard
|
||||
import at.bitfire.davdroid.ui.composable.ProgressBar
|
||||
import at.bitfire.davdroid.ui.icon.CalendarImport
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
@@ -161,7 +174,7 @@ fun AccountScreen(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
accountName: String,
|
||||
@@ -238,6 +251,10 @@ fun AccountScreen(
|
||||
(if (idxWebcal != null) 1 else 0)
|
||||
val pagerState = rememberPagerState(pageCount = { nrPages })
|
||||
|
||||
val calDavScrollState = rememberLazyListState()
|
||||
val cardDavScrollState = rememberLazyListState()
|
||||
val webcalScrollState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -310,52 +327,61 @@ fun AccountScreen(
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
if (nrPages > 0) {
|
||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
||||
if (idxCalDav != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxCalDav,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxCalDav)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_caldav),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
SharedTransitionLayout {
|
||||
val idxCurrentPage = pagerState.currentPage
|
||||
|
||||
if (idxCardDav != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxCardDav,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxCardDav)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_carddav),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
// The icon shall be shown when the scroll state is at the top (= we can't scroll backward)
|
||||
val currentPageScrollState = when (idxCurrentPage) {
|
||||
idxCalDav -> calDavScrollState
|
||||
idxCardDav -> cardDavScrollState
|
||||
idxWebcal -> webcalScrollState
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (idxWebcal != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxWebcal,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxWebcal)
|
||||
AnimatedContent(
|
||||
targetState = currentPageScrollState?.canScrollBackward != true
|
||||
) { showIcon ->
|
||||
TabRow(selectedTabIndex = idxCurrentPage) {
|
||||
if (idxCalDav != null)
|
||||
AccountScreen_Tab(
|
||||
selected = idxCurrentPage == idxCalDav,
|
||||
showIcon = showIcon,
|
||||
icon = Icons.Default.CalendarToday,
|
||||
text = stringResource(R.string.account_caldav),
|
||||
animatedVisibilityScope = this@AnimatedContent,
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
) {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxCalDav)
|
||||
}
|
||||
}
|
||||
|
||||
if (idxCardDav != null)
|
||||
AccountScreen_Tab(
|
||||
selected = idxCurrentPage == idxCardDav,
|
||||
showIcon = showIcon,
|
||||
icon = Icons.Default.Group,
|
||||
text = stringResource(R.string.account_carddav),
|
||||
animatedVisibilityScope = this@AnimatedContent,
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
) {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxCardDav)
|
||||
}
|
||||
}
|
||||
|
||||
if (idxWebcal != null)
|
||||
AccountScreen_Tab(
|
||||
selected = idxCurrentPage == idxWebcal,
|
||||
showIcon = showIcon,
|
||||
icon = Icons.Default.Link,
|
||||
text = stringResource(R.string.account_webcal),
|
||||
animatedVisibilityScope = this@AnimatedContent,
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
) {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxWebcal)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_webcal),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,7 +404,8 @@ fun AccountScreen(
|
||||
progress = cardDavProgress,
|
||||
collections = addressBooks,
|
||||
onUpdateCollectionSync = onUpdateCollectionSync,
|
||||
onCollectionDetails = onCollectionDetails
|
||||
onCollectionDetails = onCollectionDetails,
|
||||
state = cardDavScrollState
|
||||
)
|
||||
|
||||
idxCalDav -> {
|
||||
@@ -390,7 +417,8 @@ fun AccountScreen(
|
||||
progress = calDavProgress,
|
||||
collections = calendars,
|
||||
onUpdateCollectionSync = onUpdateCollectionSync,
|
||||
onCollectionDetails = onCollectionDetails
|
||||
onCollectionDetails = onCollectionDetails,
|
||||
state = calDavScrollState
|
||||
)
|
||||
}
|
||||
|
||||
@@ -425,7 +453,8 @@ fun AccountScreen(
|
||||
requiredPermissions = listOf(Manifest.permission.WRITE_CALENDAR),
|
||||
progress = calDavProgress,
|
||||
collections = subscriptions,
|
||||
onSubscribe = onSubscribe
|
||||
onSubscribe = onSubscribe,
|
||||
state = webcalScrollState
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -438,6 +467,55 @@ fun AccountScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
fun AccountScreen_Tab(
|
||||
selected: Boolean,
|
||||
showIcon: Boolean,
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
with(sharedTransitionScope) {
|
||||
if (showIcon) {
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
icon = { Icon(imageVector = icon, contentDescription = text) },
|
||||
text = {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.sharedBounds(
|
||||
rememberSharedContentState(key = text),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
content = {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.sharedBounds(
|
||||
rememberSharedContentState(key = text),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AccountScreen_Actions(
|
||||
accountName: String,
|
||||
@@ -601,7 +679,8 @@ fun AccountScreen_ServiceTab(
|
||||
collections: LazyPagingItems<Collection>?,
|
||||
onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> },
|
||||
onSubscribe: (Collection) -> Unit = {},
|
||||
onCollectionDetails: ((Collection) -> Unit)? = null
|
||||
onCollectionDetails: ((Collection) -> Unit)? = null,
|
||||
state: LazyListState = rememberLazyListState()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -646,7 +725,8 @@ fun AccountScreen_ServiceTab(
|
||||
onChangeSync = onUpdateCollectionSync,
|
||||
onSubscribe = onSubscribe,
|
||||
onCollectionDetails = onCollectionDetails,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
state = state
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.EventNote
|
||||
@@ -61,12 +63,14 @@ fun CollectionsList(
|
||||
onChangeSync: (collectionId: Long, sync: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onSubscribe: (collection: Collection) -> Unit = {},
|
||||
onCollectionDetails: ((collection: Collection) -> Unit)? = null
|
||||
onCollectionDetails: ((collection: Collection) -> Unit)? = null,
|
||||
state: LazyListState = rememberLazyListState()
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top),
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
state = state
|
||||
) {
|
||||
items(
|
||||
count = collections.itemCount,
|
||||
|
||||
@@ -150,7 +150,6 @@ fun CreateAddressBookScreen(
|
||||
value = description,
|
||||
onValueChange = onSetDescription,
|
||||
label = { Text(stringResource(R.string.create_collection_description_optional)) },
|
||||
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
|
||||
singleLine = true,
|
||||
enabled = !isCreating,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
|
||||
@@ -228,7 +228,6 @@ fun CreateCalendarScreen(
|
||||
value = description,
|
||||
onValueChange = onSetDescription,
|
||||
label = { Text(stringResource(R.string.create_collection_description_optional)) },
|
||||
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done
|
||||
@@ -251,7 +250,6 @@ fun CreateCalendarScreen(
|
||||
label = { Text(stringResource(R.string.create_calendar_time_zone_optional)) },
|
||||
value = timeZone ?: stringResource(R.string.create_calendar_time_zone_none),
|
||||
onValueChange = { /* read-only */ },
|
||||
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
readOnly = true,
|
||||
modifier = Modifier
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.icon
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Icons.Filled.CalendarImport: ImageVector
|
||||
get() {
|
||||
if (_CalendarImport != null) {
|
||||
return _CalendarImport!!
|
||||
}
|
||||
_CalendarImport = ImageVector.Builder(
|
||||
name = "Filled.CalendarImport",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
).apply {
|
||||
path(fill = SolidColor(Color.Black)) {
|
||||
moveTo(12f, 12f)
|
||||
lineTo(8f, 16f)
|
||||
horizontalLineTo(11f)
|
||||
verticalLineTo(22f)
|
||||
horizontalLineTo(13f)
|
||||
verticalLineTo(16f)
|
||||
horizontalLineTo(16f)
|
||||
moveTo(19f, 3f)
|
||||
horizontalLineTo(18f)
|
||||
verticalLineTo(1f)
|
||||
horizontalLineTo(16f)
|
||||
verticalLineTo(3f)
|
||||
horizontalLineTo(8f)
|
||||
verticalLineTo(1f)
|
||||
horizontalLineTo(6f)
|
||||
verticalLineTo(3f)
|
||||
horizontalLineTo(5f)
|
||||
curveTo(3.9f, 3f, 3f, 3.9f, 3f, 5f)
|
||||
verticalLineTo(19f)
|
||||
curveTo(3f, 20.11f, 3.9f, 21f, 5f, 21f)
|
||||
horizontalLineTo(9f)
|
||||
verticalLineTo(19f)
|
||||
horizontalLineTo(5f)
|
||||
verticalLineTo(8f)
|
||||
horizontalLineTo(19f)
|
||||
verticalLineTo(19f)
|
||||
horizontalLineTo(15f)
|
||||
verticalLineTo(21f)
|
||||
horizontalLineTo(19f)
|
||||
curveTo(20.11f, 21f, 21f, 20.11f, 21f, 19f)
|
||||
verticalLineTo(5f)
|
||||
curveTo(21f, 3.9f, 20.11f, 3f, 19f, 3f)
|
||||
close()
|
||||
}
|
||||
}.build()
|
||||
|
||||
return _CalendarImport!!
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _CalendarImport: ImageVector? = null
|
||||
@@ -179,12 +179,6 @@ fun AdvancedLoginScreen(
|
||||
chosenAlias = certAlias,
|
||||
onAliasChosen = onSetCertAlias
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.optional_label),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,6 @@ object NextcloudLogin : LoginType {
|
||||
// Custom Tabs are available
|
||||
@Suppress("DEPRECATION")
|
||||
val browser = CustomTabsIntent.Builder()
|
||||
.setToolbarColor(context.resources.getColor(R.color.primaryColor))
|
||||
.build()
|
||||
browser.intent.data = loginUri
|
||||
browser.intent.putExtra(
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
package at.bitfire.davdroid.ui.webdav
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material.icons.filled.Password
|
||||
import androidx.compose.material.icons.filled.Sell
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -42,8 +41,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppTheme
|
||||
import at.bitfire.davdroid.ui.composable.Assistant
|
||||
import at.bitfire.davdroid.ui.composable.PasswordTextField
|
||||
import at.bitfire.davdroid.ui.composable.ProgressBar
|
||||
import at.bitfire.davdroid.ui.composable.SelectClientCertificateCard
|
||||
|
||||
@Composable
|
||||
@@ -136,21 +135,27 @@ fun AddWebDavMountScreen(
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
Assistant(
|
||||
nextLabel = stringResource(R.string.webdav_add_mount_add),
|
||||
nextEnabled = canContinue && !isLoading,
|
||||
isLoading = isLoading,
|
||||
onNext = onAddMount
|
||||
) {
|
||||
if (isLoading)
|
||||
ProgressBar(modifier = Modifier.fillMaxWidth())
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(paddingValues)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.webdav_add_mount_mountpoint_displayname),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(stringResource(R.string.webdav_add_mount_url)) },
|
||||
leadingIcon = { Icon(Icons.Default.Cloud, contentDescription = null) },
|
||||
@@ -177,6 +182,9 @@ fun AddWebDavMountScreen(
|
||||
value = displayName,
|
||||
onValueChange = onSetDisplayName,
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Sell, null)
|
||||
},
|
||||
readOnly = isLoading,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
modifier = Modifier
|
||||
@@ -192,10 +200,13 @@ fun AddWebDavMountScreen(
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
OutlinedTextField(
|
||||
label = { Text(stringResource(R.string.login_user_name)) },
|
||||
label = { Text(stringResource(R.string.login_user_name_optional)) },
|
||||
value = username,
|
||||
onValueChange = onSetUsername,
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.AccountCircle, null)
|
||||
},
|
||||
readOnly = isLoading,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Next,
|
||||
@@ -208,8 +219,11 @@ fun AddWebDavMountScreen(
|
||||
PasswordTextField(
|
||||
password = password,
|
||||
onPasswordChange = onSetPassword,
|
||||
labelText = stringResource(R.string.login_password),
|
||||
labelText = stringResource(R.string.login_password_optional),
|
||||
readOnly = isLoading,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Password, null)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
@@ -229,15 +243,6 @@ fun AddWebDavMountScreen(
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
enabled = canContinue && !isLoading,
|
||||
onClick = { onAddMount() }
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.webdav_add_mount_add)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,817 +4,103 @@
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.app.AuthenticationRequiredException
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Point
|
||||
import android.media.ThumbnailUtils
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.Root
|
||||
import android.provider.DocumentsContract.buildChildDocumentsUri
|
||||
import android.provider.DocumentsContract.buildRootsUri
|
||||
import android.provider.DocumentsProvider
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentLength
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetLastModified
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.db.WebDavDocumentDao
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.MemoryCookieStore
|
||||
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
|
||||
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import at.bitfire.davdroid.webdav.operation.CopyDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.CreateDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.DeleteDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.IsChildDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.MoveDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.OpenDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.OpenDocumentThumbnailOperation
|
||||
import at.bitfire.davdroid.webdav.operation.QueryChildDocumentsOperation
|
||||
import at.bitfire.davdroid.webdav.operation.QueryDocumentOperation
|
||||
import at.bitfire.davdroid.webdav.operation.QueryRootsOperation
|
||||
import at.bitfire.davdroid.webdav.operation.RenameDocumentOperation
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.EntryPoints
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Provides functionality on WebDav documents.
|
||||
*
|
||||
* Actual implementation should go into [DavDocumentsActor].
|
||||
* Hilt constructor injection can't be used for content providers because SingletonComponent
|
||||
* may not ready yet when the content provider is created. So we use an explicit EntryPoint.
|
||||
*
|
||||
* Note: A DocumentsProvider is a ContentProvider and thus has no well-defined lifecycle. It
|
||||
* is created by Android when it's first accessed and then stays in memory until the process
|
||||
* is killed.
|
||||
*/
|
||||
class DavDocumentsProvider(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
): DocumentsProvider() {
|
||||
class DavDocumentsProvider: DocumentsProvider() {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DavDocumentsProviderEntryPoint {
|
||||
fun appDatabase(): AppDatabase
|
||||
fun davDocumentsActorFactory(): DavDocumentsActor.Factory
|
||||
fun documentSortByMapper(): DocumentSortByMapper
|
||||
fun logger(): Logger
|
||||
fun randomAccessCallbackWrapperFactory(): RandomAccessCallbackWrapper.Factory
|
||||
fun streamingFileDescriptorFactory(): StreamingFileDescriptor.Factory
|
||||
fun webdavComponentBuilder(): WebdavComponentBuilder
|
||||
fun copyDocumentOperation(): CopyDocumentOperation
|
||||
fun createDocumentOperation(): CreateDocumentOperation
|
||||
fun deleteDocumentOperation(): DeleteDocumentOperation
|
||||
fun isChildDocumentOperation(): IsChildDocumentOperation
|
||||
fun moveDocumentOperation(): MoveDocumentOperation
|
||||
fun openDocumentOperation(): OpenDocumentOperation
|
||||
fun openDocumentThumbnailOperation(): OpenDocumentThumbnailOperation
|
||||
fun queryChildDocumentsOperation(): QueryChildDocumentsOperation
|
||||
fun queryDocumentOperation(): QueryDocumentOperation
|
||||
fun queryRootsOperation(): QueryRootsOperation
|
||||
fun renameDocumentOperation(): RenameDocumentOperation
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(WebdavComponent::class)
|
||||
interface DavDocumentsProviderWebdavEntryPoint {
|
||||
fun credentialsStore(): CredentialsStore
|
||||
fun thumbnailCache(): ThumbnailCache
|
||||
private val entryPoint: DavDocumentsProviderEntryPoint by lazy {
|
||||
EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(context!!)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DAV_FILE_FIELDS = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
GetETag.NAME,
|
||||
GetContentType.NAME,
|
||||
GetContentLength.NAME,
|
||||
GetLastModified.NAME,
|
||||
QuotaAvailableBytes.NAME,
|
||||
QuotaUsedBytes.NAME,
|
||||
)
|
||||
|
||||
const val MAX_NAME_ATTEMPTS = 5
|
||||
const val THUMBNAIL_TIMEOUT_MS = 15000L
|
||||
|
||||
fun notifyMountsChanged(context: Context) {
|
||||
context.contentResolver.notifyChange(buildRootsUri(context.getString(R.string.webdav_authority)), null)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val documentProviderScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
private val ourContext by lazy { context!! } // requireContext() requires API level 30
|
||||
private val authority by lazy { ourContext.getString(R.string.webdav_authority) }
|
||||
private val globalEntryPoint by lazy { EntryPointAccessors.fromApplication<DavDocumentsProviderEntryPoint>(ourContext) }
|
||||
private val webdavEntryPoint by lazy {
|
||||
EntryPoints.get(
|
||||
globalEntryPoint.webdavComponentBuilder().build(),
|
||||
DavDocumentsProviderWebdavEntryPoint::class.java
|
||||
)
|
||||
}
|
||||
|
||||
private val logger by lazy { globalEntryPoint.logger() }
|
||||
|
||||
private val db by lazy { globalEntryPoint.appDatabase() }
|
||||
private val mountDao by lazy { db.webDavMountDao() }
|
||||
private val documentDao by lazy { db.webDavDocumentDao() }
|
||||
|
||||
private val thumbnailCache by lazy { webdavEntryPoint.thumbnailCache() }
|
||||
|
||||
private val connectivityManager by lazy { ourContext.getSystemService<ConnectivityManager>()!! }
|
||||
private val storageManager by lazy { ourContext.getSystemService<StorageManager>()!! }
|
||||
|
||||
/** List of currently active [queryChildDocuments] runners.
|
||||
*
|
||||
* Key: document ID (directory) for which children are listed.
|
||||
* Value: whether the runner is still running (*true*) or has already finished (*false*).
|
||||
*/
|
||||
private val runningQueryChildren = ConcurrentHashMap<Long, Boolean>()
|
||||
|
||||
private val credentialsStore by lazy { webdavEntryPoint.credentialsStore() }
|
||||
private val cookieStore by lazy { mutableMapOf<Long, CookieJar>() }
|
||||
private val actor by lazy { globalEntryPoint.davDocumentsActorFactory().create(cookieStore, credentialsStore) }
|
||||
|
||||
override fun onCreate() = true
|
||||
|
||||
override fun shutdown() {
|
||||
documentProviderScope.cancel()
|
||||
}
|
||||
/* Note: shutdown() is NOT called automatically by Android; a content provider lives until
|
||||
the process is killed. */
|
||||
|
||||
|
||||
/*** query ***/
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryRoots")
|
||||
val roots = MatrixCursor(projection ?: arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_ICON,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_SUMMARY
|
||||
))
|
||||
override fun queryRoots(projection: Array<out String>?) =
|
||||
entryPoint.queryRootsOperation().invoke(projection)
|
||||
|
||||
runBlocking {
|
||||
for (mount in mountDao.getAll()) {
|
||||
val rootDocument = documentDao.getOrCreateRoot(mount)
|
||||
logger.info("Root ID: $rootDocument")
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?) =
|
||||
entryPoint.queryDocumentOperation().invoke(documentId, projection)
|
||||
|
||||
roots.newRow().apply {
|
||||
add(Root.COLUMN_ROOT_ID, mount.id)
|
||||
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
add(Root.COLUMN_TITLE, ourContext.getString(R.string.webdav_provider_root_title))
|
||||
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
|
||||
add(Root.COLUMN_SUMMARY, mount.name)
|
||||
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
|
||||
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?) =
|
||||
entryPoint.queryChildDocumentsOperation().invoke(parentDocumentId, projection, sortOrder)
|
||||
|
||||
val quotaAvailable = rootDocument.quotaAvailable
|
||||
if (quotaAvailable != null)
|
||||
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
|
||||
|
||||
val quotaUsed = rootDocument.quotaUsed
|
||||
if (quotaAvailable != null && quotaUsed != null)
|
||||
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val parent = doc.parentId?.let { parentId ->
|
||||
documentDao.get(parentId)
|
||||
}
|
||||
|
||||
return DocumentsCursor(projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
Document.COLUMN_ICON,
|
||||
Document.COLUMN_SUMMARY
|
||||
)).apply {
|
||||
val bundle = doc.toBundle(parent)
|
||||
logger.fine("queryDocument($documentId) = $bundle")
|
||||
|
||||
// override display names of root documents
|
||||
if (parent == null) {
|
||||
val mount = runBlocking { mountDao.getById(doc.mountId) }
|
||||
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
|
||||
}
|
||||
|
||||
addRow(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets old or new children of given parent.
|
||||
*
|
||||
* Dispatches a worker querying the server for new children of given parent, and instantly
|
||||
* returns old children (or nothing, on initial call).
|
||||
* Once the worker finishes its query, it notifies the [android.content.ContentResolver] about
|
||||
* change, which calls this method again. The worker being done
|
||||
*/
|
||||
@Synchronized
|
||||
override fun queryChildDocuments(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?): Cursor {
|
||||
logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
|
||||
val parentId = parentDocumentId.toLong()
|
||||
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
|
||||
|
||||
val columns = projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
|
||||
// Register watcher
|
||||
val result = DocumentsCursor(columns)
|
||||
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
|
||||
result.setNotificationUri(ourContext.contentResolver, notificationUri)
|
||||
|
||||
// Dispatch worker querying for the children and keep track of it
|
||||
val running = runningQueryChildren.getOrPut(parentId) {
|
||||
documentProviderScope.launch {
|
||||
actor.queryChildren(parent)
|
||||
// Once the query is done, set query as finished (not running)
|
||||
runningQueryChildren[parentId] = false
|
||||
// .. and notify - effectively calling this method again
|
||||
ourContext.contentResolver.notifyChange(notificationUri, null)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
if (running) // worker still running
|
||||
result.loading = true
|
||||
else // remove worker from list if done
|
||||
runningQueryChildren.remove(parentId)
|
||||
|
||||
// Prepare SORT BY clause
|
||||
val mapper = globalEntryPoint.documentSortByMapper()
|
||||
val sqlSortBy = if (sortOrder != null)
|
||||
mapper.mapContentProviderToSql(sortOrder)
|
||||
else
|
||||
WebDavDocumentDao.DEFAULT_ORDER
|
||||
|
||||
// Regardless of whether the worker is done, return the children we already have
|
||||
val children = documentDao.getChildren(parentId, sqlSortBy)
|
||||
for (child in children) {
|
||||
val bundle = child.toBundle(parent)
|
||||
result.addRow(bundle)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
|
||||
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
while (iter != null) {
|
||||
val currentParentId = iter.parentId
|
||||
if (currentParentId == parent.id)
|
||||
return true
|
||||
|
||||
iter = if (currentParentId != null)
|
||||
documentDao.get(currentParentId)
|
||||
else
|
||||
null
|
||||
}
|
||||
return false
|
||||
}
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String) =
|
||||
entryPoint.isChildDocumentOperation().invoke(parentDocumentId, documentId)
|
||||
|
||||
|
||||
/*** copy/create/delete/move/rename ***/
|
||||
|
||||
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
|
||||
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
|
||||
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val name = srcDoc.name
|
||||
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String) =
|
||||
entryPoint.copyDocumentOperation().invoke(sourceDocumentId, targetParentDocumentId)
|
||||
|
||||
if (srcDoc.mountId != dstFolder.mountId)
|
||||
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
|
||||
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? =
|
||||
entryPoint.createDocumentOperation().invoke(parentDocumentId, mimeType, displayName)
|
||||
|
||||
actor.httpClient(srcDoc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
|
||||
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(name)
|
||||
.build()
|
||||
override fun deleteDocument(documentId: String) =
|
||||
entryPoint.deleteDocumentOperation().invoke(documentId)
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.copy(dstUrl, false) {
|
||||
// successfully copied
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String) =
|
||||
entryPoint.moveDocumentOperation().invoke(sourceDocumentId, sourceParentDocumentId, targetParentDocumentId)
|
||||
|
||||
val dstDocId = documentDao.insertOrReplace(
|
||||
WebDavDocument(
|
||||
mountId = dstFolder.mountId,
|
||||
parentId = dstFolder.id,
|
||||
name = name,
|
||||
isDirectory = srcDoc.isDirectory,
|
||||
displayName = srcDoc.displayName,
|
||||
mimeType = srcDoc.mimeType,
|
||||
size = srcDoc.size
|
||||
)
|
||||
).toString()
|
||||
|
||||
actor.notifyFolderChanged(targetParentDocumentId)
|
||||
|
||||
/* return */ dstDocId
|
||||
}
|
||||
}
|
||||
|
||||
override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val createDirectory = mimeType == Document.MIME_TYPE_DIR
|
||||
|
||||
var docId: Long? = null
|
||||
actor.httpClient(parent.mountId).use { client ->
|
||||
for (attempt in 0..MAX_NAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
val newLocation = parentUrl.newBuilder()
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
val doc = DavResource(client.okHttpClient, newLocation)
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
if (createDirectory)
|
||||
doc.mkCol(null) {
|
||||
// directory successfully created
|
||||
}
|
||||
else
|
||||
doc.put("".toRequestBody(null), ifNoneMatch = true) {
|
||||
// document successfully created
|
||||
}
|
||||
}
|
||||
|
||||
docId = documentDao.insertOrReplace(
|
||||
WebDavDocument(
|
||||
mountId = parent.mountId,
|
||||
parentId = parent.id,
|
||||
name = newName,
|
||||
mimeType = mimeType.toMediaTypeOrNull(),
|
||||
isDirectory = createDirectory
|
||||
)
|
||||
)
|
||||
|
||||
actor.notifyFolderChanged(parentDocumentId)
|
||||
|
||||
return@runBlocking docId.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(ignorePreconditionFailed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String) = runBlocking {
|
||||
logger.fine("WebDAV removeDocument $documentId")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.delete {
|
||||
// successfully deleted
|
||||
}
|
||||
}
|
||||
logger.fine("Successfully removed")
|
||||
documentDao.delete(doc)
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun moveDocument(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
|
||||
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
|
||||
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
if (doc.mountId != dstParent.mountId)
|
||||
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
|
||||
|
||||
val newLocation = dstParent.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(doc.name)
|
||||
.build()
|
||||
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully moved
|
||||
}
|
||||
}
|
||||
|
||||
documentDao.update(doc.copy(parentId = dstParent.id))
|
||||
|
||||
actor.notifyFolderChanged(sourceParentDocumentId)
|
||||
actor.notifyFolderChanged(targetParentDocumentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider()
|
||||
}
|
||||
}
|
||||
|
||||
doc.id.toString()
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV renameDocument $documentId $displayName")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
actor.httpClient(doc.mountId).use { client ->
|
||||
for (attempt in 0..MAX_NAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val oldUrl = doc.toHttpUrl(db)
|
||||
val newLocation = oldUrl.newBuilder()
|
||||
.removePathSegment(oldUrl.pathSegments.lastIndex)
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
try {
|
||||
val dav = DavResource(client.okHttpClient, oldUrl)
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully renamed
|
||||
}
|
||||
}
|
||||
documentDao.update(doc.copy(name = newName))
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
|
||||
return@runBlocking doc.id.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
private fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
|
||||
val safeName = displayName.filterNot { it.isISOControl() }
|
||||
|
||||
if (appendNumber != 0) {
|
||||
val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName)
|
||||
if (extension != null) {
|
||||
val baseName = safeName.removeSuffix(".$extension")
|
||||
return "${baseName}_$appendNumber.$extension"
|
||||
} else
|
||||
return "${safeName}_$appendNumber"
|
||||
} else
|
||||
return safeName
|
||||
}
|
||||
override fun renameDocument(documentId: String, displayName: String): String? =
|
||||
entryPoint.renameDocumentOperation().invoke(documentId, displayName)
|
||||
|
||||
|
||||
/*** read/write ***/
|
||||
|
||||
private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
|
||||
HeadResponse.fromUrl(client, url)
|
||||
}
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?) =
|
||||
entryPoint.openDocumentOperation().invoke(documentId, mode, signal)
|
||||
|
||||
override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
|
||||
logger.fine("WebDAV openDocument $documentId $mode $signal")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val url = doc.toHttpUrl(db)
|
||||
val client = actor.httpClient(doc.mountId, logBody = false)
|
||||
|
||||
val modeFlags = ParcelFileDescriptor.parseMode(mode)
|
||||
val readAccess = when (mode) {
|
||||
"r" -> true
|
||||
"w", "wt" -> false
|
||||
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
|
||||
}
|
||||
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal?.setOnCancelListener {
|
||||
logger.fine("Cancelling WebDAV access to $url")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val fileInfo = accessScope.async {
|
||||
headRequest(client, url)
|
||||
}.await()
|
||||
logger.fine("Received file info: $fileInfo")
|
||||
|
||||
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
|
||||
return@runBlocking if (
|
||||
Build.VERSION.SDK_INT >= 26 && // openProxyFileDescriptor exists since Android 8.0
|
||||
readAccess && // WebDAV doesn't support random write access natively
|
||||
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
|
||||
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine whether the document has changed during access
|
||||
fileInfo.supportsPartial == true // WebDAV server must support random access
|
||||
) {
|
||||
logger.fine("Creating RandomAccessCallback for $url")
|
||||
val factory = globalEntryPoint.randomAccessCallbackWrapperFactory()
|
||||
val accessor = factory.create(client, url, doc.mimeType, fileInfo, accessScope)
|
||||
storageManager.openProxyFileDescriptor(modeFlags, accessor, accessor.workerHandler)
|
||||
} else {
|
||||
logger.fine("Creating StreamingFileDescriptor for $url")
|
||||
val factory = globalEntryPoint.streamingFileDescriptorFactory()
|
||||
val fd = factory.create(client, url, doc.mimeType, accessScope) { transferred ->
|
||||
// called when transfer is finished
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (!readAccess /* write access */) {
|
||||
// write access, update file size
|
||||
documentDao.update(doc.copy(size = transferred, lastModified = now))
|
||||
}
|
||||
|
||||
actor.notifyFolderChanged(doc.parentId)
|
||||
}
|
||||
|
||||
if (readAccess)
|
||||
fd.download()
|
||||
else
|
||||
fd.upload()
|
||||
}
|
||||
}
|
||||
|
||||
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
|
||||
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
|
||||
|
||||
if (connectivityManager.isActiveNetworkMetered)
|
||||
// don't download the large images just to create a thumbnail on metered networks
|
||||
return null
|
||||
|
||||
if (signal == null) {
|
||||
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
|
||||
return null
|
||||
}
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal.setOnCancelListener {
|
||||
logger.fine("Cancelling thumbnail generation for $documentId")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
val docCacheKey = doc.cacheKey()
|
||||
if (docCacheKey == null) {
|
||||
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
|
||||
return null
|
||||
}
|
||||
|
||||
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
|
||||
// create thumbnail
|
||||
val job = accessScope.async {
|
||||
withTimeout(THUMBNAIL_TIMEOUT_MS) {
|
||||
actor.httpClient(doc.mountId, logBody = false).use { client ->
|
||||
val url = doc.toHttpUrl(db)
|
||||
val dav = DavResource(client.okHttpClient, url)
|
||||
var result: ByteArray? = null
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.get("image/*", null) { response ->
|
||||
response.body.byteStream().use { data ->
|
||||
BitmapFactory.decodeStream(data)?.let { bitmap ->
|
||||
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
|
||||
val baos = ByteArrayOutputStream()
|
||||
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
|
||||
result = baos.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
job.await()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (thumbFile != null)
|
||||
return AssetFileDescriptor(
|
||||
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
|
||||
0, thumbFile.length()
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Acts on behalf of [DavDocumentsProvider].
|
||||
*
|
||||
* Encapsulates functionality to make it easily testable without generating lots of
|
||||
* DocumentProviders during the tests.
|
||||
*
|
||||
* By containing the actual implementation logic of [DavDocumentsProvider], it adds a layer of separation
|
||||
* to make the methods of [DavDocumentsProvider] more easily testable.
|
||||
* [DavDocumentsProvider]s methods should do nothing more, but to call [DavDocumentsActor]s methods.
|
||||
*/
|
||||
class DavDocumentsActor @AssistedInject constructor(
|
||||
@Assisted private val cookieStores: MutableMap<Long, CookieJar>,
|
||||
@Assisted private val credentialsStore: CredentialsStore,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(cookieStore: MutableMap<Long, CookieJar>, credentialsStore: CredentialsStore): DavDocumentsActor
|
||||
}
|
||||
|
||||
private val authority = context.getString(R.string.webdav_authority)
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
/**
|
||||
* Finds children of given parent [WebDavDocument]. After querying, it
|
||||
* updates existing children, adds new ones or removes deleted ones.
|
||||
*
|
||||
* There must never be more than one running instance per [parent]!
|
||||
*
|
||||
* @param parent folder to search for children
|
||||
*/
|
||||
internal suspend fun queryChildren(parent: WebDavDocument) {
|
||||
val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique
|
||||
val newChildrenList = hashMapOf<String, WebDavDocument>()
|
||||
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
httpClient(parent.mountId).use { client ->
|
||||
val folder = DavCollection(client.okHttpClient, parentUrl)
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
|
||||
logger.fine("$relation $response")
|
||||
|
||||
val resource: WebDavDocument =
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF -> // it's about the parent
|
||||
parent
|
||||
|
||||
Response.HrefRelation.MEMBER -> // it's about a member
|
||||
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
|
||||
|
||||
else -> {
|
||||
// we didn't request this; log a warning and ignore it
|
||||
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
|
||||
return@propfind
|
||||
}
|
||||
}
|
||||
|
||||
val updatedResource = resource.copy(
|
||||
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
|
||||
?: resource.isDirectory,
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
mimeType = response[GetContentType::class.java]?.type,
|
||||
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.let { resource.eTag },
|
||||
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
|
||||
size = response[GetContentLength::class.java]?.contentLength,
|
||||
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
|
||||
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
|
||||
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
|
||||
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
|
||||
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
|
||||
)
|
||||
|
||||
if (resource == parent)
|
||||
documentDao.update(updatedResource)
|
||||
else {
|
||||
documentDao.insertOrUpdate(updatedResource)
|
||||
newChildrenList[resource.name] = updatedResource
|
||||
}
|
||||
|
||||
// remove resource from known child nodes, because not found on server
|
||||
oldChildren.remove(resource.name)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't query children", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete child nodes which were not rediscovered (deleted serverside)
|
||||
for ((_, oldChild) in oldChildren)
|
||||
documentDao.delete(oldChild)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Creates a HTTP client that can be used to access resources in the given mount.
|
||||
*
|
||||
* @param mountId ID of the mount to access
|
||||
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
|
||||
*/
|
||||
internal fun httpClient(mountId: Long, logBody: Boolean = true): HttpClient {
|
||||
val builder = httpClientBuilder.get()
|
||||
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
|
||||
.setCookieStore(
|
||||
cookieStores.getOrPut(mountId) { MemoryCookieStore() }
|
||||
)
|
||||
|
||||
credentialsStore.getCredentials(mountId)?.let { credentials ->
|
||||
builder.authenticate(host = null, getCredentials = { credentials })
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
internal fun notifyFolderChanged(parentDocumentId: Long?) {
|
||||
if (parentDocumentId != null)
|
||||
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId.toString()), null)
|
||||
}
|
||||
|
||||
internal fun notifyFolderChanged(parentDocumentId: String) {
|
||||
context.contentResolver.notifyChange(buildChildDocumentsUri(authority, parentDocumentId), null)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun HttpException.throwForDocumentProvider(ignorePreconditionFailed: Boolean = false) {
|
||||
when (code) {
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED -> {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
// TODO edit mount
|
||||
val intent = Intent(ourContext, WebdavMountsActivity::class.java)
|
||||
throw AuthenticationRequiredException(
|
||||
this,
|
||||
TaskStackBuilder.create(ourContext)
|
||||
.addNextIntentWithParentStack(intent)
|
||||
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
}
|
||||
}
|
||||
HttpURLConnection.HTTP_NOT_FOUND ->
|
||||
throw FileNotFoundException()
|
||||
HttpURLConnection.HTTP_PRECON_FAILED ->
|
||||
if (ignorePreconditionFailed)
|
||||
return
|
||||
}
|
||||
|
||||
// re-throw
|
||||
throw this
|
||||
}
|
||||
override fun openDocumentThumbnail(documentId: String, sizeHint: Point, signal: CancellationSignal?) =
|
||||
entryPoint.openDocumentThumbnailOperation().invoke(documentId, sizeHint, signal)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.network.MemoryCookieStore
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
class DavHttpClientBuilder @Inject constructor(
|
||||
private val credentialsStore: CredentialsStore,
|
||||
private val httpClientBuilder: Provider<HttpClient.Builder>,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Creates an HTTP client that can be used to access resources in the given mount.
|
||||
*
|
||||
* @param mountId ID of the mount to access
|
||||
* @param logBody whether to log the body of HTTP requests (disable for potentially large files)
|
||||
*/
|
||||
fun build(mountId: Long, logBody: Boolean = true): HttpClient {
|
||||
val cookieStore = cookieStores.getOrPut(mountId) {
|
||||
MemoryCookieStore()
|
||||
}
|
||||
val builder = httpClientBuilder.get()
|
||||
.loggerInterceptorLevel(if (logBody) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.HEADERS)
|
||||
.setCookieStore(cookieStore)
|
||||
|
||||
credentialsStore.getCredentials(mountId)?.let { credentials ->
|
||||
builder.authenticate(host = null, getCredentials = { credentials })
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/** in-memory cookie stores (one per mount ID) that are available until the content
|
||||
* provider (= process) is terminated */
|
||||
private val cookieStores = mutableMapOf<Long, CookieJar>()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.app.AuthenticationRequiredException
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract.buildChildDocumentsUri
|
||||
import android.provider.DocumentsContract.buildRootsUri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.webdav.WebdavMountsActivity
|
||||
import java.io.FileNotFoundException
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
object DocumentProviderUtils {
|
||||
|
||||
const val MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS = 5
|
||||
|
||||
internal fun displayNameToMemberName(displayName: String, appendNumber: Int = 0): String {
|
||||
val safeName = displayName.filterNot { it.isISOControl() }
|
||||
|
||||
if (appendNumber != 0) {
|
||||
val extension: String? = MimeTypeMap.getFileExtensionFromUrl(displayName)
|
||||
if (extension != null) {
|
||||
val baseName = safeName.removeSuffix(".$extension")
|
||||
return "${baseName}_$appendNumber.$extension"
|
||||
} else
|
||||
return "${safeName}_$appendNumber"
|
||||
} else
|
||||
return safeName
|
||||
}
|
||||
|
||||
internal fun notifyFolderChanged(context: Context, parentDocumentId: Long?) {
|
||||
if (parentDocumentId != null)
|
||||
context.contentResolver.notifyChange(
|
||||
buildChildDocumentsUri(
|
||||
context.getString(R.string.webdav_authority),
|
||||
parentDocumentId.toString()
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
internal fun notifyFolderChanged(context: Context, parentDocumentId: String) {
|
||||
context.contentResolver.notifyChange(
|
||||
buildChildDocumentsUri(
|
||||
context.getString(R.string.webdav_authority),
|
||||
parentDocumentId
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
internal fun notifyMountsChanged(context: Context) {
|
||||
context.contentResolver.notifyChange(
|
||||
buildRootsUri(context.getString(R.string.webdav_authority)),
|
||||
null)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal fun HttpException.throwForDocumentProvider(context: Context, ignorePreconditionFailed: Boolean = false) {
|
||||
when (code) {
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED -> {
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val intent = Intent(context, WebdavMountsActivity::class.java)
|
||||
throw AuthenticationRequiredException(
|
||||
this,
|
||||
TaskStackBuilder.create(context)
|
||||
.addNextIntentWithParentStack(intent)
|
||||
.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
}
|
||||
}
|
||||
HttpURLConnection.HTTP_NOT_FOUND ->
|
||||
throw FileNotFoundException()
|
||||
HttpURLConnection.HTTP_PRECON_FAILED ->
|
||||
if (ignorePreconditionFailed)
|
||||
return
|
||||
}
|
||||
|
||||
// re-throw
|
||||
throw this
|
||||
}
|
||||
@@ -5,20 +5,20 @@
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.ProxyFileDescriptorCallback
|
||||
import android.os.storage.StorageManager
|
||||
import android.system.ErrnoException
|
||||
import android.system.OsConstants
|
||||
import android.text.format.Formatter
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import com.google.common.cache.CacheLoader
|
||||
@@ -40,17 +40,17 @@ import okhttp3.MediaType
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillClose
|
||||
|
||||
@RequiresApi(26)
|
||||
class RandomAccessCallback @AssistedInject constructor(
|
||||
@Assisted val httpClient: HttpClient,
|
||||
@Assisted val url: HttpUrl,
|
||||
@Assisted val mimeType: MediaType?,
|
||||
@Assisted @WillClose private val httpClient: HttpClient,
|
||||
@Assisted private val url: HttpUrl,
|
||||
@Assisted private val mimeType: MediaType?,
|
||||
@Assisted headResponse: HeadResponse,
|
||||
@Assisted private val externalScope: CoroutineScope,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
@ApplicationContext private val context: Context,
|
||||
private val logger: Logger
|
||||
): ProxyFileDescriptorCallback() {
|
||||
|
||||
companion object {
|
||||
@@ -77,26 +77,34 @@ class RandomAccessCallback @AssistedInject constructor(
|
||||
private val fileSize = headResponse.size ?: throw IllegalArgumentException("Can only be used with given file size")
|
||||
private val documentState = headResponse.toDocumentState() ?: throw IllegalArgumentException("Can only be used with ETag/Last-Modified")
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private val notification = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setContentTitle(context.getString(R.string.webdav_notification_access))
|
||||
.setContentText(dav.fileName())
|
||||
.setSubText(Formatter.formatFileSize(context, fileSize))
|
||||
.setSmallIcon(R.drawable.ic_storage_notify)
|
||||
.setOngoing(true)
|
||||
private val notificationTag = url.toString()
|
||||
|
||||
private val pageLoader = PageLoader(externalScope)
|
||||
private val pageCache: LoadingCache<PageIdentifier, ByteArray> = CacheBuilder.newBuilder()
|
||||
.maximumSize(10) // don't cache more than 10 entries (MAX_PAGE_SIZE each)
|
||||
.softValues() // use SoftReference for the page contents so they will be garbage collected if memory is needed
|
||||
.softValues() // use SoftReference for the page contents so they will be garbage-collected if memory is needed
|
||||
.build(pageLoader) // fetch actual content using pageLoader
|
||||
|
||||
/** This thread will be used for I/O operations like [onRead]. Using the main looper would cause ANRs. */
|
||||
private val ioThread = HandlerThread("WebDAV I/O").apply {
|
||||
start()
|
||||
}
|
||||
|
||||
private val pagingReader = PagingReader(fileSize, MAX_PAGE_SIZE, pageCache)
|
||||
|
||||
|
||||
// file descriptor
|
||||
|
||||
/**
|
||||
* Returns a random-access file descriptor that can be used in a DocumentsProvider.
|
||||
*/
|
||||
fun fileDescriptor(): ParcelFileDescriptor {
|
||||
val storageManager = context.getSystemService<StorageManager>()!!
|
||||
val ioHandler = Handler(ioThread.looper)
|
||||
return storageManager.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, this, ioHandler)
|
||||
}
|
||||
|
||||
|
||||
// implementation
|
||||
|
||||
override fun onFsync() { /* not used */ }
|
||||
|
||||
override fun onGetSize(): Long = runBlockingFd("onGetFileSize") {
|
||||
@@ -117,7 +125,10 @@ class RandomAccessCallback @AssistedInject constructor(
|
||||
|
||||
override fun onRelease() {
|
||||
logger.fine("onRelease")
|
||||
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
|
||||
|
||||
// free resources
|
||||
ioThread.quitSafely()
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -185,16 +196,6 @@ class RandomAccessCallback @AssistedInject constructor(
|
||||
val size = key.size
|
||||
logger.fine("Loading page $url $offset/$size")
|
||||
|
||||
// update notification
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, tag = notificationTag) {
|
||||
val progress =
|
||||
if (fileSize == 0L) // avoid division by zero
|
||||
100
|
||||
else
|
||||
(offset * 100 / fileSize).toInt()
|
||||
notification.setProgress(100, progress, false).build()
|
||||
}
|
||||
|
||||
val ifMatch: Headers =
|
||||
documentState.eTag?.let { eTag ->
|
||||
Headers.headersOf("If-Match", "\"$eTag\"")
|
||||
|
||||
@@ -4,183 +4,85 @@
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.ProxyFileDescriptorCallback
|
||||
import android.system.ErrnoException
|
||||
import android.system.OsConstants
|
||||
import androidx.annotation.RequiresApi
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper.Companion.TIMEOUT_INTERVAL
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType
|
||||
import ru.nsk.kstatemachine.event.Event
|
||||
import ru.nsk.kstatemachine.state.State
|
||||
import ru.nsk.kstatemachine.state.finalState
|
||||
import ru.nsk.kstatemachine.state.initialState
|
||||
import ru.nsk.kstatemachine.state.onEntry
|
||||
import ru.nsk.kstatemachine.state.onExit
|
||||
import ru.nsk.kstatemachine.state.onFinished
|
||||
import ru.nsk.kstatemachine.state.state
|
||||
import ru.nsk.kstatemachine.state.transitionOn
|
||||
import ru.nsk.kstatemachine.statemachine.StateMachine
|
||||
import ru.nsk.kstatemachine.statemachine.createStdLibStateMachine
|
||||
import ru.nsk.kstatemachine.statemachine.processEventBlocking
|
||||
import java.util.Timer
|
||||
import java.util.TimerTask
|
||||
import java.util.logging.Logger
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
/**
|
||||
* (2021/12/02) Currently Android's `StorageManager.openProxyFileDescriptor` has a memory leak:
|
||||
* Use this wrapper to ensure that all memory is released as soon as [onRelease] is called.
|
||||
*
|
||||
* - (2021/12/02) Currently Android's `StorageManager.openProxyFileDescriptor` has a memory leak:
|
||||
* the given callback is registered in `com.android.internal.os.AppFuseMount` (which adds it to
|
||||
* a [Map]), but is not unregistered anymore. So it stays in the memory until the whole mount
|
||||
* is unloaded. See https://issuetracker.google.com/issues/208788568
|
||||
* is unloaded. See https://issuetracker.google.com/issues/208788568.
|
||||
* - (2024/08/24) [Fixed in Android.](https://android.googlesource.com/platform/frameworks/base/+/e7dbf78143ba083af7a8ecadd839a9dbf6f01655%5E%21/#F0)
|
||||
*
|
||||
* Use this wrapper to
|
||||
* **All fields of objects of this class must be set to `null` when [onRelease] is called!**
|
||||
* Otherwise they will leak memory.
|
||||
*
|
||||
* - ensure that all memory is released as soon as [onRelease] is called,
|
||||
* - provide timeout functionality: [RandomAccessCallback] will be closed when not
|
||||
*
|
||||
* used for more than [TIMEOUT_INTERVAL] ms and re-created when necessary.
|
||||
*
|
||||
* @param httpClient HTTP client – [RandomAccessCallbackWrapper] is responsible to close it
|
||||
* @param httpClient HTTP client ([RandomAccessCallbackWrapper] is responsible to close it)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@RequiresApi(26)
|
||||
class RandomAccessCallbackWrapper @AssistedInject constructor(
|
||||
@Assisted private val httpClient: HttpClient,
|
||||
@Assisted private val url: HttpUrl,
|
||||
@Assisted private val mimeType: MediaType?,
|
||||
@Assisted private val headResponse: HeadResponse,
|
||||
@Assisted private val externalScope: CoroutineScope,
|
||||
private val logger: Logger,
|
||||
private val callbackFactory: RandomAccessCallback.Factory
|
||||
@Assisted httpClient: HttpClient,
|
||||
@Assisted url: HttpUrl,
|
||||
@Assisted mimeType: MediaType?,
|
||||
@Assisted headResponse: HeadResponse,
|
||||
@Assisted externalScope: CoroutineScope,
|
||||
callbackFactory: RandomAccessCallback.Factory
|
||||
): ProxyFileDescriptorCallback() {
|
||||
|
||||
companion object {
|
||||
const val TIMEOUT_INTERVAL = 15000L
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(httpClient: HttpClient, url: HttpUrl, mimeType: MediaType?, headResponse: HeadResponse, externalScope: CoroutineScope): RandomAccessCallbackWrapper
|
||||
}
|
||||
|
||||
sealed class Events {
|
||||
object Transfer : Event
|
||||
object NowIdle : Event
|
||||
object GoStandby : Event
|
||||
object Close : Event
|
||||
}
|
||||
/* We don't use a sealed class for states here because the states would then be singletons, while we can have
|
||||
multiple instances of the state machine (which require multiple instances of the states, too). */
|
||||
private val machine = createStdLibStateMachine {
|
||||
lateinit var activeIdleState: State
|
||||
lateinit var activeTransferringState: State
|
||||
lateinit var standbyState: State
|
||||
lateinit var closedState: State
|
||||
|
||||
initialState("active") {
|
||||
onEntry {
|
||||
_callback = callbackFactory.create(httpClient, url, mimeType, headResponse, externalScope)
|
||||
}
|
||||
onExit {
|
||||
_callback?.onRelease()
|
||||
_callback = null
|
||||
}
|
||||
// callback reference
|
||||
|
||||
transitionOn<Events.GoStandby> { targetState = { standbyState } }
|
||||
transitionOn<Events.Close> { targetState = { closedState } }
|
||||
/**
|
||||
* This field is initialized with a strong reference to the callback. It is cleared when
|
||||
* [onRelease] is called so that the garbage collector can remove the actual [RandomAccessCallback].
|
||||
*/
|
||||
private var callbackRef: RandomAccessCallback? =
|
||||
callbackFactory.create(httpClient, url, mimeType, headResponse, externalScope)
|
||||
|
||||
// active has two nested states: transferring (I/O running) and idle (starts timeout timer)
|
||||
activeIdleState = initialState("idle") {
|
||||
val timer: Timer = Timer(true)
|
||||
var timeout: TimerTask? = null
|
||||
|
||||
onEntry {
|
||||
timeout = timer.schedule(TIMEOUT_INTERVAL) {
|
||||
machine.processEventBlocking(Events.GoStandby)
|
||||
}
|
||||
}
|
||||
onExit {
|
||||
timeout?.cancel()
|
||||
timeout = null
|
||||
}
|
||||
onFinished {
|
||||
timer.cancel()
|
||||
}
|
||||
|
||||
transitionOn<Events.Transfer> { targetState = { activeTransferringState } }
|
||||
}
|
||||
|
||||
activeTransferringState = state("transferring") {
|
||||
transitionOn<Events.NowIdle> { targetState = { activeIdleState } }
|
||||
}
|
||||
}
|
||||
|
||||
standbyState = state("standby") {
|
||||
transitionOn<Events.Transfer> { targetState = { activeTransferringState } }
|
||||
transitionOn<Events.NowIdle> { targetState = { activeIdleState } }
|
||||
transitionOn<Events.Close> { targetState = { closedState } }
|
||||
}
|
||||
|
||||
closedState = finalState("closed")
|
||||
onFinished {
|
||||
shutdown()
|
||||
}
|
||||
|
||||
logger = StateMachine.Logger { message ->
|
||||
this@RandomAccessCallbackWrapper.logger.finer(message())
|
||||
}
|
||||
}
|
||||
|
||||
private val workerThread = HandlerThread(javaClass.simpleName).apply { start() }
|
||||
val workerHandler: Handler = Handler(workerThread.looper)
|
||||
|
||||
private var _callback: RandomAccessCallback? = null
|
||||
|
||||
fun<T> requireCallback(block: (callback: RandomAccessCallback) -> T): T {
|
||||
machine.processEventBlocking(Events.Transfer)
|
||||
try {
|
||||
return block(_callback ?: throw IllegalStateException())
|
||||
} finally {
|
||||
machine.processEventBlocking(Events.NowIdle)
|
||||
}
|
||||
}
|
||||
private fun requireCallback(functionName: String): RandomAccessCallback =
|
||||
callbackRef ?: throw ErrnoException(functionName, OsConstants.EBADF)
|
||||
|
||||
|
||||
/// states ///
|
||||
// non-interface delegates
|
||||
|
||||
@Synchronized
|
||||
private fun shutdown() {
|
||||
httpClient.close()
|
||||
workerThread.quit()
|
||||
}
|
||||
fun fileDescriptor() =
|
||||
requireCallback("fileDescriptor").fileDescriptor()
|
||||
|
||||
|
||||
/// delegating implementation of ProxyFileDescriptorCallback ///
|
||||
// delegating implementation of ProxyFileDescriptorCallback
|
||||
|
||||
@Synchronized
|
||||
override fun onFsync() { /* not used */ }
|
||||
|
||||
@Synchronized
|
||||
override fun onGetSize() =
|
||||
requireCallback { it.onGetSize() }
|
||||
requireCallback("onGetSize").onGetSize()
|
||||
|
||||
@Synchronized
|
||||
override fun onRead(offset: Long, size: Int, data: ByteArray) =
|
||||
requireCallback { it.onRead(offset, size, data) }
|
||||
requireCallback("onRead").onRead(offset, size, data)
|
||||
|
||||
@Synchronized
|
||||
override fun onWrite(offset: Long, size: Int, data: ByteArray) =
|
||||
requireCallback { it.onWrite(offset, size, data) }
|
||||
requireCallback("onWrite").onWrite(offset, size, data)
|
||||
|
||||
@Synchronized
|
||||
override fun onRelease() {
|
||||
machine.processEventBlocking(Events.Close)
|
||||
requireCallback("onRelease").onRelease()
|
||||
|
||||
// remove reference to allow garbage collection
|
||||
callbackRef = null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,23 +4,15 @@
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.text.format.Formatter
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -32,27 +24,21 @@ import okio.BufferedSink
|
||||
import java.io.IOException
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillClose
|
||||
|
||||
/**
|
||||
* @param client HTTP client– [StreamingFileDescriptor] is responsible to close it
|
||||
* @param client HTTP client ([StreamingFileDescriptor] is responsible to close it)
|
||||
*/
|
||||
class StreamingFileDescriptor @AssistedInject constructor(
|
||||
@Assisted private val client: HttpClient,
|
||||
@Assisted @WillClose private val client: HttpClient,
|
||||
@Assisted private val url: HttpUrl,
|
||||
@Assisted private val mimeType: MediaType?,
|
||||
@Assisted private val externalScope: CoroutineScope,
|
||||
@Assisted private val finishedCallback: OnSuccessCallback,
|
||||
@ApplicationContext private val context: Context,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/** 1 MB transfer buffer */
|
||||
private const val BUFFER_SIZE = 1024*1024
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(client: HttpClient, url: HttpUrl, mimeType: MediaType?, externalScope: CoroutineScope, finishedCallback: OnSuccessCallback): StreamingFileDescriptor
|
||||
@@ -61,28 +47,21 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
val dav = DavResource(client.okHttpClient, url)
|
||||
var transferred: Long = 0
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(context)
|
||||
private val notification = NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setContentText(dav.fileName())
|
||||
.setSmallIcon(R.drawable.ic_storage_notify)
|
||||
.setOngoing(true)
|
||||
val notificationTag = url.toString()
|
||||
|
||||
|
||||
fun download() = doStreaming(false)
|
||||
fun upload() = doStreaming(true)
|
||||
|
||||
private fun doStreaming(upload: Boolean): ParcelFileDescriptor {
|
||||
val (readFd, writeFd) = ParcelFileDescriptor.createReliablePipe()
|
||||
|
||||
externalScope.launch(ioDispatcher) {
|
||||
var success = false
|
||||
externalScope.launch {
|
||||
try {
|
||||
if (upload)
|
||||
uploadNow(readFd)
|
||||
else
|
||||
downloadNow(writeFd)
|
||||
|
||||
success = true
|
||||
} catch (e: HttpException) {
|
||||
logger.log(Level.WARNING, "HTTP error when opening remote file", e)
|
||||
writeFd.closeWithError("${e.code} ${e.message}")
|
||||
@@ -90,17 +69,15 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
logger.log(Level.INFO, "Couldn't serve file (not necessarily an error)", e)
|
||||
writeFd.closeWithError(e.message)
|
||||
} finally {
|
||||
// close pipe
|
||||
try {
|
||||
readFd.close()
|
||||
writeFd.close()
|
||||
} catch (_: IOException) {}
|
||||
|
||||
client.close()
|
||||
finishedCallback.onFinished(transferred, success)
|
||||
}
|
||||
|
||||
try {
|
||||
readFd.close()
|
||||
writeFd.close()
|
||||
} catch (_: IOException) {}
|
||||
|
||||
notificationManager.cancel(notificationTag, NotificationRegistry.NOTIFY_WEBDAV_ACCESS)
|
||||
|
||||
finishedCallback.onSuccess(transferred)
|
||||
}
|
||||
|
||||
return if (upload)
|
||||
@@ -109,49 +86,20 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
readFd
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun downloadNow(writeFd: ParcelFileDescriptor) = runInterruptible {
|
||||
/**
|
||||
* Downloads a WebDAV resource.
|
||||
*
|
||||
* @param writeFd destination file descriptor (could for instance represent a local file)
|
||||
*/
|
||||
private suspend fun downloadNow(writeFd: ParcelFileDescriptor) = runInterruptible(ioDispatcher) {
|
||||
dav.get(DavUtils.acceptAnything(preferred = mimeType), null) { response ->
|
||||
response.body.use { body ->
|
||||
if (response.isSuccessful) {
|
||||
val length = body.contentLength()
|
||||
|
||||
notification.setContentTitle(context.getString(R.string.webdav_notification_download))
|
||||
if (length == -1L)
|
||||
// unknown file size, show notification now (no updates on progress)
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
notification
|
||||
.setProgress(100, 0, true)
|
||||
.build()
|
||||
}
|
||||
else
|
||||
// known file size
|
||||
notification.setSubText(Formatter.formatFileSize(context, length))
|
||||
|
||||
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { output ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { destination ->
|
||||
body.byteStream().use { source ->
|
||||
// read first chunk
|
||||
var bytes = source.read(buffer)
|
||||
while (bytes != -1) {
|
||||
// update notification (if file size is known)
|
||||
if (length > 0)
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
val progress = (transferred*100/length).toInt()
|
||||
notification
|
||||
.setProgress(100, progress, false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// write chunk
|
||||
output.write(buffer, 0, bytes)
|
||||
transferred += bytes
|
||||
|
||||
// read next chunk
|
||||
bytes = source.read(buffer)
|
||||
}
|
||||
logger.finer("Downloaded $transferred byte(s) from $url")
|
||||
transferred += source.copyTo(destination)
|
||||
}
|
||||
logger.finer("Downloaded $transferred byte(s) from $url")
|
||||
}
|
||||
|
||||
} else
|
||||
@@ -160,31 +108,18 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun uploadNow(readFd: ParcelFileDescriptor) = runInterruptible {
|
||||
/**
|
||||
* Uploads a WebDAV resource.
|
||||
*
|
||||
* @param readFd source file descriptor (could for instance represent a local file)
|
||||
*/
|
||||
private suspend fun uploadNow(readFd: ParcelFileDescriptor) = runInterruptible(ioDispatcher) {
|
||||
val body = object: RequestBody() {
|
||||
override fun contentType(): MediaType? = mimeType
|
||||
override fun isOneShot() = true
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
notificationRegistry.notifyIfPossible(NotificationRegistry.NOTIFY_WEBDAV_ACCESS, notificationTag) {
|
||||
notification
|
||||
.setContentTitle(context.getString(R.string.webdav_notification_upload))
|
||||
.build()
|
||||
}
|
||||
|
||||
ParcelFileDescriptor.AutoCloseInputStream(readFd).use { input ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
|
||||
// read first chunk
|
||||
var size = input.read(buffer)
|
||||
while (size != -1) {
|
||||
// write chunk
|
||||
sink.write(buffer, 0, size)
|
||||
transferred += size
|
||||
|
||||
// read next chunk
|
||||
size = input.read(buffer)
|
||||
}
|
||||
transferred += input.copyTo(sink.outputStream())
|
||||
logger.finer("Uploaded $transferred byte(s) to $url")
|
||||
}
|
||||
}
|
||||
@@ -196,7 +131,7 @@ class StreamingFileDescriptor @AssistedInject constructor(
|
||||
|
||||
|
||||
fun interface OnSuccessCallback {
|
||||
fun onSuccess(transferred: Long)
|
||||
fun onFinished(transferred: Long, success: Boolean)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -50,12 +50,13 @@ class WebDavMountRepository @Inject constructor(
|
||||
displayName: String,
|
||||
credentials: Credentials?
|
||||
): Boolean {
|
||||
if (!hasWebDav(url, credentials))
|
||||
val webdavUrl = hasWebDav(url, credentials)
|
||||
if (webdavUrl == null)
|
||||
return false
|
||||
|
||||
// create in database
|
||||
val mount = WebDavMount(
|
||||
url = url,
|
||||
url = webdavUrl,
|
||||
name = displayName
|
||||
)
|
||||
val id = db.webDavMountDao().insert(mount)
|
||||
@@ -65,7 +66,7 @@ class WebDavMountRepository @Inject constructor(
|
||||
credentialsStore.setCredentials(id, credentials)
|
||||
|
||||
// notify content URI listeners
|
||||
DavDocumentsProvider.notifyMountsChanged(context)
|
||||
DocumentProviderUtils.notifyMountsChanged(context)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -78,7 +79,7 @@ class WebDavMountRepository @Inject constructor(
|
||||
CredentialsStore(context).setCredentials(mount.id, null)
|
||||
|
||||
// notify content URI listeners
|
||||
DavDocumentsProvider.notifyMountsChanged(context)
|
||||
DocumentProviderUtils.notifyMountsChanged(context)
|
||||
}
|
||||
|
||||
fun getAllFlow() = mountDao.getAllFlow()
|
||||
@@ -110,11 +111,19 @@ class WebDavMountRepository @Inject constructor(
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Checks whether WebDAV is supported at given URL with given credentials
|
||||
* and returns the resulting if following a few redirects.
|
||||
*
|
||||
* @param url The URL to check
|
||||
* @param credentials The credentials to use for the request
|
||||
* @return The URL at which WebDAV support was found
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal suspend fun hasWebDav(
|
||||
url: HttpUrl,
|
||||
credentials: Credentials?
|
||||
): Boolean = withContext(ioDispatcher) {
|
||||
): HttpUrl? = withContext(ioDispatcher) {
|
||||
val validVersions = arrayOf("1", "2", "3")
|
||||
|
||||
val builder = httpClientBuilder.get()
|
||||
@@ -125,18 +134,18 @@ class WebDavMountRepository @Inject constructor(
|
||||
getCredentials = { credentials }
|
||||
)
|
||||
|
||||
var supported = false
|
||||
var webdavUrl: HttpUrl? = null
|
||||
builder.build().use { httpClient ->
|
||||
val dav = DavResource(httpClient.okHttpClient, url)
|
||||
runInterruptible {
|
||||
dav.options { davCapabilities, _ ->
|
||||
dav.options(followRedirects = true) { davCapabilities, response ->
|
||||
if (davCapabilities.any { it in validVersions })
|
||||
supported = true
|
||||
webdavUrl = dav.location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supported
|
||||
webdavUrl
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import dagger.hilt.DefineComponent
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Scope
|
||||
|
||||
@Scope
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class WebdavScoped
|
||||
|
||||
@WebdavScoped
|
||||
@DefineComponent(parent = SingletonComponent::class)
|
||||
interface WebdavComponent
|
||||
|
||||
@DefineComponent.Builder
|
||||
interface WebdavComponentBuilder {
|
||||
fun build(): WebdavComponent
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import android.os.storage.StorageManager
|
||||
import android.text.format.Formatter
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.webdav.WebdavScoped
|
||||
import com.google.common.hash.Hashing
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
@@ -21,7 +20,6 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Simple disk cache for image thumbnails.
|
||||
*/
|
||||
@WebdavScoped
|
||||
class ThumbnailCache @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
logger: Logger
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class CopyDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(sourceDocumentId: String, targetParentDocumentId: String): String = runBlocking {
|
||||
logger.fine("WebDAV copyDocument $sourceDocumentId $targetParentDocumentId")
|
||||
val srcDoc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstFolder = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val name = srcDoc.name
|
||||
|
||||
if (srcDoc.mountId != dstFolder.mountId)
|
||||
throw UnsupportedOperationException("Can't COPY between WebDAV servers")
|
||||
|
||||
httpClientBuilder.build(srcDoc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, srcDoc.toHttpUrl(db))
|
||||
val dstUrl = dstFolder.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(name)
|
||||
.build()
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.copy(dstUrl, false) {
|
||||
// successfully copied
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context)
|
||||
}
|
||||
|
||||
val dstDocId = documentDao.insertOrReplace(
|
||||
WebDavDocument(
|
||||
mountId = dstFolder.mountId,
|
||||
parentId = dstFolder.id,
|
||||
name = name,
|
||||
isDirectory = srcDoc.isDirectory,
|
||||
displayName = srcDoc.displayName,
|
||||
mimeType = srcDoc.mimeType,
|
||||
size = srcDoc.size
|
||||
)
|
||||
).toString()
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId)
|
||||
|
||||
/* return */ dstDocId
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.DocumentsContract.Document
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils.displayNameToMemberName
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class CreateDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(parentDocumentId: String, mimeType: String, displayName: String): String? = runBlocking {
|
||||
logger.fine("WebDAV createDocument $parentDocumentId $mimeType $displayName")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val createDirectory = mimeType == Document.MIME_TYPE_DIR
|
||||
|
||||
var docId: Long?
|
||||
httpClientBuilder.build(parent.mountId).use { client ->
|
||||
for (attempt in 0..DocumentProviderUtils.MAX_DISPLAYNAME_TO_MEMBERNAME_ATTEMPTS) {
|
||||
val newName = displayNameToMemberName(displayName, attempt)
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
val newLocation = parentUrl.newBuilder()
|
||||
.addPathSegment(newName)
|
||||
.build()
|
||||
val doc = DavResource(client.okHttpClient, newLocation)
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
if (createDirectory)
|
||||
doc.mkCol(null) {
|
||||
// directory successfully created
|
||||
}
|
||||
else
|
||||
doc.put(RequestBody.EMPTY, ifNoneMatch = true) {
|
||||
// document successfully created
|
||||
}
|
||||
}
|
||||
|
||||
docId = documentDao.insertOrReplace(
|
||||
WebDavDocument(
|
||||
mountId = parent.mountId,
|
||||
parentId = parent.id,
|
||||
name = newName,
|
||||
isDirectory = createDirectory,
|
||||
mimeType = mimeType.toMediaTypeOrNull(),
|
||||
eTag = null,
|
||||
lastModified = null,
|
||||
size = if (createDirectory) null else 0
|
||||
)
|
||||
)
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, parentDocumentId)
|
||||
|
||||
return@runBlocking docId.toString()
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context, ignorePreconditionFailed = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class DeleteDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(documentId: String) = runBlocking {
|
||||
logger.fine("WebDAV removeDocument $documentId")
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
httpClientBuilder.build(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.delete {
|
||||
// successfully deleted
|
||||
}
|
||||
}
|
||||
logger.fine("Successfully removed")
|
||||
documentDao.delete(doc)
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class IsChildDocumentOperation @Inject constructor(
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(parentDocumentId: String, documentId: String): Boolean {
|
||||
logger.fine("WebDAV isChildDocument $parentDocumentId $documentId")
|
||||
val parent = documentDao.get(parentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
var iter: WebDavDocument? = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
while (iter != null) {
|
||||
val currentParentId = iter.parentId
|
||||
if (currentParentId == parent.id)
|
||||
return true
|
||||
|
||||
iter = if (currentParentId != null)
|
||||
documentDao.get(currentParentId)
|
||||
else
|
||||
null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.throwForDocumentProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class MoveDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String): String = runBlocking {
|
||||
logger.fine("WebDAV moveDocument $sourceDocumentId $sourceParentDocumentId $targetParentDocumentId")
|
||||
val doc = documentDao.get(sourceDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
val dstParent = documentDao.get(targetParentDocumentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
if (doc.mountId != dstParent.mountId)
|
||||
throw UnsupportedOperationException("Can't MOVE between WebDAV servers")
|
||||
|
||||
val newLocation = dstParent.toHttpUrl(db).newBuilder()
|
||||
.addPathSegment(doc.name)
|
||||
.build()
|
||||
|
||||
httpClientBuilder.build(doc.mountId).use { client ->
|
||||
val dav = DavResource(client.okHttpClient, doc.toHttpUrl(db))
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.move(newLocation, false) {
|
||||
// successfully moved
|
||||
}
|
||||
}
|
||||
|
||||
documentDao.update(doc.copy(parentId = dstParent.id))
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, sourceParentDocumentId)
|
||||
DocumentProviderUtils.notifyFolderChanged(context, targetParentDocumentId)
|
||||
} catch (e: HttpException) {
|
||||
e.throwForDocumentProvider(context)
|
||||
}
|
||||
}
|
||||
|
||||
doc.id.toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentProviderUtils
|
||||
import at.bitfire.davdroid.webdav.HeadResponse
|
||||
import at.bitfire.davdroid.webdav.RandomAccessCallbackWrapper
|
||||
import at.bitfire.davdroid.webdav.StreamingFileDescriptor
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class OpenDocumentOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val randomAccessCallbackWrapperFactory: RandomAccessCallbackWrapper.Factory,
|
||||
private val streamingFileDescriptorFactory: StreamingFileDescriptor.Factory
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking {
|
||||
logger.fine("WebDAV openDocument $documentId $mode $signal")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val url = doc.toHttpUrl(db)
|
||||
val client = httpClientBuilder.build(doc.mountId, logBody = false)
|
||||
|
||||
val readOnlyMode = when (mode) {
|
||||
"r" -> true
|
||||
"w", "wt" -> false
|
||||
else -> throw UnsupportedOperationException("Mode $mode not supported by WebDAV")
|
||||
}
|
||||
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal?.setOnCancelListener {
|
||||
logger.fine("Cancelling WebDAV access to $url")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val fileInfo = accessScope.async {
|
||||
headRequest(client, url)
|
||||
}.await()
|
||||
logger.fine("Received file info: $fileInfo")
|
||||
|
||||
// RandomAccessCallback.Wrapper / StreamingFileDescriptor are responsible for closing httpClient
|
||||
return@runBlocking if (
|
||||
androidSupportsRandomAccess &&
|
||||
readOnlyMode && // WebDAV doesn't support random write access (natively)
|
||||
fileInfo.size != null && // file descriptor must return a useful value on getFileSize()
|
||||
(fileInfo.eTag != null || fileInfo.lastModified != null) && // we need a method to determine when the document changes during access
|
||||
fileInfo.supportsPartial == true // WebDAV server must advertise random access
|
||||
) {
|
||||
logger.fine("Creating RandomAccessCallback for $url")
|
||||
val accessor = randomAccessCallbackWrapperFactory.create(client, url, doc.mimeType, fileInfo, accessScope)
|
||||
accessor.fileDescriptor()
|
||||
|
||||
} else {
|
||||
logger.fine("Creating StreamingFileDescriptor for $url")
|
||||
val fd = streamingFileDescriptorFactory.create(client, url, doc.mimeType, accessScope) { transferred, success ->
|
||||
// called when transfer is finished
|
||||
if (!success)
|
||||
return@create
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (!readOnlyMode /* write access */) {
|
||||
// write access, update file size
|
||||
documentDao.update(doc.copy(size = transferred, lastModified = now))
|
||||
}
|
||||
|
||||
DocumentProviderUtils.notifyFolderChanged(context, doc.parentId)
|
||||
}
|
||||
|
||||
if (readOnlyMode)
|
||||
fd.download()
|
||||
else
|
||||
fd.upload()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun headRequest(client: HttpClient, url: HttpUrl): HeadResponse = runInterruptible(ioDispatcher) {
|
||||
HeadResponse.fromUrl(client, url)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/** openProxyFileDescriptor (required for random access) exists since Android 8.0 */
|
||||
val androidSupportsRandomAccess = Build.VERSION.SDK_INT >= 26
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Point
|
||||
import android.media.ThumbnailUtils
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.cache.ThumbnailCache
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.use
|
||||
|
||||
class OpenDocumentThumbnailOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger,
|
||||
private val thumbnailCache: ThumbnailCache
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
operator fun invoke(documentId: String, sizeHint: Point, signal: CancellationSignal?): AssetFileDescriptor? {
|
||||
logger.info("openDocumentThumbnail documentId=$documentId sizeHint=$sizeHint signal=$signal")
|
||||
|
||||
// don't download the large images just to create a thumbnail on metered networks
|
||||
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
|
||||
if (connectivityManager.isActiveNetworkMetered)
|
||||
return null
|
||||
|
||||
if (signal == null) {
|
||||
logger.warning("openDocumentThumbnail without cancellationSignal causes too much problems, please fix calling app")
|
||||
return null
|
||||
}
|
||||
val accessScope = CoroutineScope(SupervisorJob())
|
||||
signal.setOnCancelListener {
|
||||
logger.fine("Cancelling thumbnail generation for $documentId")
|
||||
accessScope.cancel()
|
||||
}
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
|
||||
val docCacheKey = doc.cacheKey()
|
||||
if (docCacheKey == null) {
|
||||
logger.warning("openDocumentThumbnail won't generate thumbnails when document state (ETag/Last-Modified) is unknown")
|
||||
return null
|
||||
}
|
||||
|
||||
val thumbFile = thumbnailCache.get(docCacheKey, sizeHint) {
|
||||
// create thumbnail
|
||||
val job = accessScope.async {
|
||||
withTimeout(THUMBNAIL_TIMEOUT_MS) {
|
||||
httpClientBuilder.build(doc.mountId, logBody = false).use { client ->
|
||||
val url = doc.toHttpUrl(db)
|
||||
val dav = DavResource(client.okHttpClient, url)
|
||||
var result: ByteArray? = null
|
||||
runInterruptible(ioDispatcher) {
|
||||
dav.get("image/*", null) { response ->
|
||||
response.body.byteStream().use { data ->
|
||||
BitmapFactory.decodeStream(data)?.let { bitmap ->
|
||||
val thumb = ThumbnailUtils.extractThumbnail(bitmap, sizeHint.x, sizeHint.y)
|
||||
val baos = ByteArrayOutputStream()
|
||||
thumb.compress(Bitmap.CompressFormat.JPEG, 95, baos)
|
||||
result = baos.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
job.await()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't generate thumbnail", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (thumbFile != null)
|
||||
return AssetFileDescriptor(
|
||||
ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY),
|
||||
0, thumbFile.length()
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val THUMBNAIL_TIMEOUT_MS = 15000L
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.DocumentsContract.Document
|
||||
import android.provider.DocumentsContract.buildChildDocumentsUri
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentLength
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetLastModified
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.QuotaUsedBytes
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
import at.bitfire.davdroid.db.WebDavDocumentDao
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
import at.bitfire.davdroid.webdav.DavHttpClientBuilder
|
||||
import at.bitfire.davdroid.webdav.DocumentSortByMapper
|
||||
import at.bitfire.davdroid.webdav.DocumentsCursor
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class QueryChildDocumentsOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val db: AppDatabase,
|
||||
private val documentSortByMapper: Lazy<DocumentSortByMapper>,
|
||||
private val httpClientBuilder: DavHttpClientBuilder,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val authority = context.getString(R.string.webdav_authority)
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
|
||||
private val backgroundScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
operator fun invoke(parentDocumentId: String, projection: Array<out String>?, sortOrder: String?) =
|
||||
synchronized(QueryChildDocumentsOperation::class.java) {
|
||||
queryChildDocuments(parentDocumentId, projection, sortOrder)
|
||||
}
|
||||
|
||||
private fun queryChildDocuments(
|
||||
parentDocumentId: String,
|
||||
projection: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): DocumentsCursor {
|
||||
logger.fine("WebDAV queryChildDocuments $parentDocumentId $projection $sortOrder")
|
||||
val parentId = parentDocumentId.toLong()
|
||||
val parent = documentDao.get(parentId) ?: throw FileNotFoundException()
|
||||
|
||||
val columns = projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
|
||||
// Register watcher
|
||||
val result = DocumentsCursor(columns)
|
||||
val notificationUri = buildChildDocumentsUri(authority, parentDocumentId)
|
||||
result.setNotificationUri(context.contentResolver, notificationUri)
|
||||
|
||||
// Dispatch worker querying for the children and keep track of it
|
||||
val running = runningQueryChildren.getOrPut(parentId) {
|
||||
backgroundScope.launch {
|
||||
queryChildren(parent)
|
||||
// Once the query is done, set query as finished (not running)
|
||||
runningQueryChildren[parentId] = false
|
||||
// .. and notify - effectively calling this method again
|
||||
context.contentResolver.notifyChange(notificationUri, null)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
if (running) // worker still running
|
||||
result.loading = true
|
||||
else // remove worker from list if done
|
||||
runningQueryChildren.remove(parentId)
|
||||
|
||||
// Prepare SORT BY clause
|
||||
val mapper = documentSortByMapper.get()
|
||||
val sqlSortBy = if (sortOrder != null)
|
||||
mapper.mapContentProviderToSql(sortOrder)
|
||||
else
|
||||
WebDavDocumentDao.DEFAULT_ORDER
|
||||
|
||||
// Regardless of whether the worker is done, return the children we already have
|
||||
val children = documentDao.getChildren(parentId, sqlSortBy)
|
||||
for (child in children) {
|
||||
val bundle = child.toBundle(parent)
|
||||
result.addRow(bundle)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds children of given parent [WebDavDocument]. After querying, it
|
||||
* updates existing children, adds new ones or removes deleted ones.
|
||||
*
|
||||
* There must never be more than one running instance per [parent]!
|
||||
*
|
||||
* @param parent folder to search for children
|
||||
*/
|
||||
internal suspend fun queryChildren(parent: WebDavDocument) {
|
||||
val oldChildren = documentDao.getChildren(parent.id).associateBy { it.name }.toMutableMap() // "name" of file/folder must be unique
|
||||
val newChildrenList = hashMapOf<String, WebDavDocument>()
|
||||
|
||||
val parentUrl = parent.toHttpUrl(db)
|
||||
httpClientBuilder.build(parent.mountId).use { client ->
|
||||
val folder = DavCollection(client.okHttpClient, parentUrl)
|
||||
|
||||
try {
|
||||
runInterruptible(ioDispatcher) {
|
||||
folder.propfind(1, *DAV_FILE_FIELDS) { response, relation ->
|
||||
logger.fine("$relation $response")
|
||||
|
||||
val resource: WebDavDocument =
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF -> // it's about the parent
|
||||
parent
|
||||
|
||||
Response.HrefRelation.MEMBER -> // it's about a member
|
||||
WebDavDocument(mountId = parent.mountId, parentId = parent.id, name = response.hrefName())
|
||||
|
||||
else -> {
|
||||
// we didn't request this; log a warning and ignore it
|
||||
logger.warning("Ignoring unexpected $response $relation in $parentUrl")
|
||||
return@propfind
|
||||
}
|
||||
}
|
||||
|
||||
val updatedResource = resource.copy(
|
||||
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
|
||||
?: resource.isDirectory,
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
mimeType = response[GetContentType::class.java]?.type,
|
||||
eTag = response[GetETag::class.java]?.takeIf { !it.weak }?.eTag,
|
||||
lastModified = response[GetLastModified::class.java]?.lastModified?.toEpochMilli(),
|
||||
size = response[GetContentLength::class.java]?.contentLength,
|
||||
mayBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind,
|
||||
mayUnbind = response[CurrentUserPrivilegeSet::class.java]?.mayUnbind,
|
||||
mayWriteContent = response[CurrentUserPrivilegeSet::class.java]?.mayWriteContent,
|
||||
quotaAvailable = response[QuotaAvailableBytes::class.java]?.quotaAvailableBytes,
|
||||
quotaUsed = response[QuotaUsedBytes::class.java]?.quotaUsedBytes,
|
||||
)
|
||||
|
||||
if (resource == parent)
|
||||
documentDao.update(updatedResource)
|
||||
else {
|
||||
documentDao.insertOrUpdate(updatedResource)
|
||||
newChildrenList[resource.name] = updatedResource
|
||||
}
|
||||
|
||||
// remove resource from known child nodes, because not found on server
|
||||
oldChildren.remove(resource.name)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't query children", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete child nodes which were not rediscovered (deleted serverside)
|
||||
for ((_, oldChild) in oldChildren)
|
||||
documentDao.delete(oldChild)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
val DAV_FILE_FIELDS = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
GetETag.NAME,
|
||||
GetContentType.NAME,
|
||||
GetContentLength.NAME,
|
||||
GetLastModified.NAME,
|
||||
QuotaAvailableBytes.NAME,
|
||||
QuotaUsedBytes.NAME,
|
||||
)
|
||||
|
||||
/** List of currently active [queryChildDocuments] runners.
|
||||
*
|
||||
* Key: document ID (directory) for which children are listed.
|
||||
* Value: whether the runner is still running (*true*) or has already finished (*false*).
|
||||
*/
|
||||
private val runningQueryChildren = ConcurrentHashMap<Long, Boolean>()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.database.Cursor
|
||||
import android.provider.DocumentsContract.Document
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.webdav.DocumentsCursor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class QueryDocumentOperation @Inject constructor(
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
private val mountDao = db.webDavMountDao()
|
||||
|
||||
operator fun invoke(documentId: String, projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryDocument $documentId ${projection?.joinToString("+")}")
|
||||
|
||||
val doc = documentDao.get(documentId.toLong()) ?: throw FileNotFoundException()
|
||||
val parent = doc.parentId?.let { parentId ->
|
||||
documentDao.get(parentId)
|
||||
}
|
||||
|
||||
return DocumentsCursor(projection ?: arrayOf(
|
||||
Document.COLUMN_DOCUMENT_ID,
|
||||
Document.COLUMN_DISPLAY_NAME,
|
||||
Document.COLUMN_MIME_TYPE,
|
||||
Document.COLUMN_FLAGS,
|
||||
Document.COLUMN_SIZE,
|
||||
Document.COLUMN_LAST_MODIFIED,
|
||||
Document.COLUMN_ICON,
|
||||
Document.COLUMN_SUMMARY
|
||||
)).apply {
|
||||
val bundle = doc.toBundle(parent)
|
||||
logger.fine("queryDocument($documentId) = $bundle")
|
||||
|
||||
// override display names of root documents
|
||||
if (parent == null) {
|
||||
val mount = runBlocking { mountDao.getById(doc.mountId) }
|
||||
bundle.putString(Document.COLUMN_DISPLAY_NAME, mount.name)
|
||||
}
|
||||
|
||||
addRow(bundle)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav.operation
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.provider.DocumentsContract.Root
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class QueryRootsOperation @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
db: AppDatabase,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
private val documentDao = db.webDavDocumentDao()
|
||||
private val mountDao = db.webDavMountDao()
|
||||
|
||||
operator fun invoke(projection: Array<out String>?): Cursor {
|
||||
logger.fine("WebDAV queryRoots")
|
||||
val roots = MatrixCursor(projection ?: arrayOf(
|
||||
Root.COLUMN_ROOT_ID,
|
||||
Root.COLUMN_ICON,
|
||||
Root.COLUMN_TITLE,
|
||||
Root.COLUMN_FLAGS,
|
||||
Root.COLUMN_DOCUMENT_ID,
|
||||
Root.COLUMN_SUMMARY
|
||||
))
|
||||
|
||||
runBlocking {
|
||||
for (mount in mountDao.getAll()) {
|
||||
val rootDocument = documentDao.getOrCreateRoot(mount)
|
||||
logger.info("Root ID: $rootDocument")
|
||||
|
||||
roots.newRow().apply {
|
||||
add(Root.COLUMN_ROOT_ID, mount.id)
|
||||
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
||||
add(Root.COLUMN_TITLE, context.getString(R.string.webdav_provider_root_title))
|
||||
add(Root.COLUMN_DOCUMENT_ID, rootDocument.id.toString())
|
||||
add(Root.COLUMN_SUMMARY, mount.name)
|
||||
add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE or Root.FLAG_SUPPORTS_IS_CHILD)
|
||||
|
||||
val quotaAvailable = rootDocument.quotaAvailable
|
||||
if (quotaAvailable != null)
|
||||
add(Root.COLUMN_AVAILABLE_BYTES, quotaAvailable)
|
||||
|
||||
val quotaUsed = rootDocument.quotaUsed
|
||||
if (quotaAvailable != null && quotaUsed != null)
|
||||
add(Root.COLUMN_CAPACITY_BYTES, quotaAvailable + quotaUsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user