mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2025-12-23 23:17:50 -05:00
Compare commits
35 Commits
v4.5.6-ose
...
main-ose
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9754770238 | ||
|
|
6be15fd366 | ||
|
|
0cb27f0c2f | ||
|
|
776305bd12 | ||
|
|
01f54df3c0 | ||
|
|
4944ce59b1 | ||
|
|
0e455d8371 | ||
|
|
a938b511cd | ||
|
|
d32b86789b | ||
|
|
84d58f73db | ||
|
|
cc43998148 | ||
|
|
b354bfebc2 | ||
|
|
29240ea16f | ||
|
|
e7b88f9aa8 | ||
|
|
4d71517cde | ||
|
|
0d4f154baf | ||
|
|
9eb70a5564 | ||
|
|
24e0a864bd | ||
|
|
10ec0c3b6d | ||
|
|
bd3349cc38 | ||
|
|
c7bc2b317b | ||
|
|
2d10cbb07d | ||
|
|
88928792af | ||
|
|
b5e8c80db1 | ||
|
|
6f09f55e1a | ||
|
|
b08f10a98f | ||
|
|
a3a952d875 | ||
|
|
e9fc570895 | ||
|
|
098b7d5b12 | ||
|
|
a38dc29cca | ||
|
|
84b9a14ba1 | ||
|
|
cda95dc789 | ||
|
|
f64882ca2a | ||
|
|
7c2dcf3d70 | ||
|
|
66a34ebd9f |
9
.github/workflows/codeql.yml
vendored
9
.github/workflows/codeql.yml
vendored
@@ -3,11 +3,12 @@ name: "CodeQL"
|
||||
on:
|
||||
push:
|
||||
branches: [ main-ose ]
|
||||
pull_request:
|
||||
# pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main-ose ]
|
||||
# branches: [ main-ose ]
|
||||
schedule:
|
||||
- cron: '22 10 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -28,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -50,7 +51,7 @@ jobs:
|
||||
# uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --build-cache --configuration-cache --no-daemon app:assembleOseDebug
|
||||
run: ./gradlew --no-daemon app:compileOseDebugSource
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
24
.github/workflows/dependency-submission.yml
vendored
Normal file
24
.github/workflows/dependency-submission.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Dependency Submission
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main-ose' ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
dependency-submission:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Generate and submit dependency graph
|
||||
uses: gradle/actions/dependency-submission@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph-exclude-configurations: '.*[Tt]est.* .*[cC]heck.*'
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
discussions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
|
||||
97
.github/workflows/test-dev.yml
vendored
97
.github/workflows/test-dev.yml
vendored
@@ -9,33 +9,54 @@ concurrency:
|
||||
group: test-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# We provide a remote gradle build cache. Take the settings from the secrets and enable
|
||||
# configuration and build cache for all gradle jobs.
|
||||
env:
|
||||
GRADLE_BUILDCACHE_URL: ${{ secrets.gradle_buildcache_url }}
|
||||
GRADLE_BUILDCACHE_USERNAME: ${{ secrets.gradle_buildcache_username }}
|
||||
GRADLE_BUILDCACHE_PASSWORD: ${{ secrets.gradle_buildcache_password }}
|
||||
GRADLE_OPTS: -Dorg.gradle.caching=true -Dorg.gradle.configuration-cache=true
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
name: Compile for build cache
|
||||
if: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
name: Compile
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||
- uses: gradle/actions/setup-gradle@v5 # creates build cache when on main branch
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||
cache-read-only: false # allow branches to update their configuration cache
|
||||
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
|
||||
|
||||
- run: ./gradlew --build-cache --configuration-cache app:compileOseDebugSource
|
||||
- name: Cache Android environment
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.config/.android # needs to be cached so that configuration cache can work
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
test:
|
||||
- name: Compile
|
||||
run: ./gradlew app:compileOseDebugSource
|
||||
|
||||
# Cache configurations for the other jobs
|
||||
- name: Populate configuration cache
|
||||
run: |
|
||||
./gradlew --dry-run app:lintOseDebug
|
||||
./gradlew --dry-run app:testOseDebugUnitTest
|
||||
./gradlew --dry-run app:virtualOseDebugAndroidTest
|
||||
|
||||
unit_tests:
|
||||
needs: compile
|
||||
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -45,18 +66,24 @@ jobs:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Run lint
|
||||
run: ./gradlew --build-cache --configuration-cache app:lintOseDebug
|
||||
- name: Run unit tests
|
||||
run: ./gradlew --build-cache --configuration-cache app:testOseDebugUnitTest
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
test_on_emulator:
|
||||
- name: Lint checks
|
||||
run: ./gradlew app:lintOseDebug
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew app:testOseDebugUnitTest
|
||||
|
||||
instrumented_tests:
|
||||
needs: compile
|
||||
if: ${{ !cancelled() }} # even if compile didn't run (because not on main branch)
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -66,17 +93,41 @@ jobs:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
# gradle and Android SDK often take more space than what is available on the default runner.
|
||||
# We try to free a few GB here to make gradle-managed devices more reliable.
|
||||
- name: Free some disk space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
android: false # we need the Android SDK
|
||||
large-packages: false # apt takes too long
|
||||
swap-storage: false # gradle needs much memory
|
||||
|
||||
- name: Restore AVD
|
||||
id: restore-avd
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
# Enable virtualization for Android emulator
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Cache AVD
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.config/.android/avd
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
- name: Instrumented tests
|
||||
run: ./gradlew app:virtualOseDebugAndroidTest
|
||||
|
||||
- name: Run device tests
|
||||
run: ./gradlew --build-cache --configuration-cache app:virtualCheck
|
||||
- name: Cache AVD
|
||||
uses: actions/cache/save@v5
|
||||
if: steps.restore-avd.outputs.cache-hit != 'true'
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
@@ -7,8 +7,8 @@ plugins {
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
|
||||
alias(libs.plugins.mikepenz.aboutLibraries.android)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 405060004
|
||||
versionName = "4.5.6"
|
||||
versionCode = 405080000
|
||||
versionName = "4.5.8-alpha.1"
|
||||
|
||||
base.archivesName = "davx5-ose-$versionName"
|
||||
|
||||
@@ -190,8 +190,10 @@ dependencies {
|
||||
implementation(libs.conscrypt)
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.mikepenz.aboutLibraries.m3)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
@@ -7,4 +8,9 @@
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!--
|
||||
Since Mockk 1.14.7 it's required to use minSdk 26. We use 24, so override for tests.
|
||||
-->
|
||||
<uses-sdk tools:overrideLibrary="io.mockk.android,io.mockk.proxy.android" />
|
||||
|
||||
</manifest>
|
||||
@@ -7,7 +7,7 @@ package at.bitfire.davdroid.db
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
@@ -65,7 +65,7 @@ class CollectionTest {
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
@@ -121,7 +121,7 @@ class CollectionTest {
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
@@ -157,7 +157,7 @@ class CollectionTest {
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response)!!
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
@@ -191,7 +191,7 @@ class CollectionTest {
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
|
||||
@@ -174,7 +174,7 @@ class AccountRepositoryTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { localAddressBookStore.updateAccount(account, newAccount) }
|
||||
coVerify { localAddressBookStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -182,7 +182,7 @@ class AccountRepositoryTest {
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { localCalendarStore.updateAccount(account, newAccount) }
|
||||
coVerify { localCalendarStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -191,7 +191,8 @@ class AccountRepositoryTest {
|
||||
every { tasksAppManager.getDataStore() } returns mockDataStore
|
||||
accountRepository.rename(account.name, newName)
|
||||
|
||||
coVerify { mockDataStore.updateAccount(account, accountRepository.fromName(newName)) }
|
||||
val newAccount = accountRepository.fromName(newName)
|
||||
coVerify { mockDataStore.updateAccount(account, newAccount, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -20,6 +20,7 @@ import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
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
|
||||
@@ -63,27 +64,28 @@ class LocalCalendarStoreTest {
|
||||
|
||||
@Test
|
||||
fun testUpdateAccount_updatesOwnerAccount() {
|
||||
// Verify initial state
|
||||
verifyOwnerAccountIs("InitialAccountName")
|
||||
// Verify initial state (assume to skip and prevent flaky test failures)
|
||||
Assume.assumeTrue("InitialAccountName" == getOwnerAccount())
|
||||
|
||||
// Rename account
|
||||
val oldAccount = account
|
||||
account = TestAccount.rename(account, "ChangedAccountName")
|
||||
|
||||
// Update account name in local calendar
|
||||
localCalendarStore.updateAccount(oldAccount, account)
|
||||
localCalendarStore.updateAccount(oldAccount, account, provider)
|
||||
|
||||
// Verify [Calendar.OWNER_ACCOUNT] of local calendar was updated
|
||||
verifyOwnerAccountIs("ChangedAccountName")
|
||||
assertEquals("ChangedAccountName", getOwnerAccount())
|
||||
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun createCalendarForAccount(account: Account): Uri {
|
||||
var uri: Uri? = null
|
||||
provider.use { providerClient ->
|
||||
val values = contentValuesOf(
|
||||
private fun createCalendarForAccount(account: Account): Uri =
|
||||
provider.insert(
|
||||
Calendars.CONTENT_URI.asSyncAdapter(account),
|
||||
contentValuesOf(
|
||||
Calendars.ACCOUNT_NAME to account.name,
|
||||
Calendars.ACCOUNT_TYPE to account.type,
|
||||
Calendars.OWNER_ACCOUNT to account.name,
|
||||
@@ -92,17 +94,10 @@ class LocalCalendarStoreTest {
|
||||
Calendars._SYNC_ID to 999,
|
||||
Calendars.CALENDAR_DISPLAY_NAME to "displayName",
|
||||
)
|
||||
)!!.asSyncAdapter(account)
|
||||
|
||||
uri = providerClient.insert(
|
||||
Calendars.CONTENT_URI.asSyncAdapter(account),
|
||||
values
|
||||
)!!.asSyncAdapter(account)
|
||||
}
|
||||
return uri!!
|
||||
}
|
||||
|
||||
private fun verifyOwnerAccountIs(expectedOwnerAccount: String) = provider.use {
|
||||
it.query(
|
||||
private fun getOwnerAccount(): String {
|
||||
provider.query(
|
||||
calendarUri,
|
||||
arrayOf(Calendars.OWNER_ACCOUNT),
|
||||
"${Calendars.ACCOUNT_NAME}=?",
|
||||
@@ -110,8 +105,7 @@ class LocalCalendarStoreTest {
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
val ownerAccount = cursor.getString(0)
|
||||
assertEquals(expectedOwnerAccount, ownerAccount)
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
@@ -74,7 +74,7 @@ class DavResourceFinderTest {
|
||||
|
||||
val credentials = Credentials(username = "mock", password = "12345".toSensitiveString())
|
||||
client = httpClientBuilder
|
||||
.authenticate(host = null, getCredentials = { credentials })
|
||||
.authenticate(domain = null, getCredentials = { credentials })
|
||||
.build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
|
||||
@@ -93,8 +93,8 @@ class DavResourceFinderTest {
|
||||
// recognize home set
|
||||
var info = ServiceInfo()
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
.propfind(0, CardDAV.AddressbookHomeSet) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
}
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
@@ -103,8 +103,8 @@ class DavResourceFinderTest {
|
||||
// recognize address book
|
||||
info = ServiceInfo()
|
||||
DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
finder.scanResponse(ResourceType.ADDRESSBOOK, response, info)
|
||||
.propfind(0, WebDAV.ResourceType) { response, _ ->
|
||||
finder.scanResponse(CardDAV.Addressbook, response, info)
|
||||
}
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
|
||||
@@ -15,15 +15,14 @@ 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 kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
@@ -50,17 +49,8 @@ class AccountSettingsMigration21Test {
|
||||
lateinit var account: Account
|
||||
val authority = CalendarContract.AUTHORITY
|
||||
|
||||
private val inPendingState = callbackFlow {
|
||||
val stateChangeListener = ContentResolver.addStatusChangeListener(
|
||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
|
||||
) {
|
||||
trySend(ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
trySend(ContentResolver.isSyncPending(account, authority))
|
||||
awaitClose {
|
||||
ContentResolver.removeStatusChangeListener(stateChangeListener)
|
||||
}
|
||||
}
|
||||
private val inPendingState = MutableStateFlow(false)
|
||||
private var statusChangeListener: Any? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -70,10 +60,14 @@ class AccountSettingsMigration21Test {
|
||||
|
||||
// Enable sync globally and for the test account
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
|
||||
// Start hot flow
|
||||
registerSyncStateObserver()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unregisterSyncStateObserver()
|
||||
TestAccount.remove(account)
|
||||
}
|
||||
|
||||
@@ -89,8 +83,8 @@ class AccountSettingsMigration21Test {
|
||||
inPendingState.first { it }
|
||||
}
|
||||
|
||||
// Assert again that we are now in the forever pending state
|
||||
assertTrue(ContentResolver.isSyncPending(account, authority))
|
||||
// Assume that we are now in the forever pending state (Skips test otherwise)
|
||||
Assume.assumeTrue(ContentResolver.isSyncPending(account, authority))
|
||||
|
||||
// Run the migration which should cancel the forever pending sync for all accounts
|
||||
migration.migrate(account)
|
||||
@@ -115,6 +109,22 @@ class AccountSettingsMigration21Test {
|
||||
.setManual(true) // equivalent of setting both SYNC_EXTRAS_IGNORE_SETTINGS and SYNC_EXTRAS_IGNORE_BACKOFF
|
||||
.build()
|
||||
|
||||
private fun registerSyncStateObserver() {
|
||||
// listener pushes updates immediately when sync status changes
|
||||
statusChangeListener = ContentResolver.addStatusChangeListener(
|
||||
ContentResolver.SYNC_OBSERVER_TYPE_PENDING or ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
|
||||
) {
|
||||
inPendingState.tryEmit(ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
|
||||
// Emit initial state
|
||||
inPendingState.tryEmit(ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
|
||||
private fun unregisterSyncStateObserver() {
|
||||
statusChangeListener?.let { ContentResolver.removeStatusChangeListener(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
var globalAutoSyncBeforeTest = false
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.dav4jvm.HttpUtils.toKtorUrl
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.sync.account.TestAccount
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.ktor.http.HttpHeaders
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.net.InetAddress
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class ResourceDownloaderTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var resourceDownloaderFactory: ResourceDownloader.Factory
|
||||
|
||||
lateinit var account: Account
|
||||
lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
server = MockWebServer().apply {
|
||||
start()
|
||||
}
|
||||
|
||||
account = TestAccount.create()
|
||||
|
||||
// add credentials to test account so that we can check whether they have been sent
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
settings.credentials(Credentials("test", "test".toSensitiveString()))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccount.remove(account)
|
||||
server.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDownload_ExternalDomain() = runTest {
|
||||
val baseUrl = server.url("/")
|
||||
|
||||
// URL should be http://localhost, replace with http://127.0.0.1 to have other domain
|
||||
Assume.assumeTrue(baseUrl.host == "localhost")
|
||||
val baseUrlIp = baseUrl.newBuilder()
|
||||
.host(InetAddress.getByName(baseUrl.host).hostAddress!!)
|
||||
.build()
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("TEST"))
|
||||
|
||||
val downloader = resourceDownloaderFactory.create(account, baseUrl.host)
|
||||
val result = downloader.download(baseUrlIp.toKtorUrl())
|
||||
|
||||
// authentication was NOT sent because request is not for original domain
|
||||
val sentAuth = server.takeRequest().getHeader(HttpHeaders.Authorization)
|
||||
assertNull(sentAuth)
|
||||
|
||||
// and result is OK
|
||||
assertArrayEquals("TEST".toByteArray(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDownload_SameDomain() = runTest {
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("TEST"))
|
||||
|
||||
val baseUrl = server.url("/")
|
||||
val downloader = resourceDownloaderFactory.create(account, baseUrl.host)
|
||||
val result = downloader.download(baseUrl.toKtorUrl())
|
||||
|
||||
// authentication was sent
|
||||
val sentAuth = server.takeRequest().getHeader(HttpHeaders.Authorization)
|
||||
assertEquals("Basic dGVzdDp0ZXN0", sentAuth)
|
||||
|
||||
// and result is OK
|
||||
assertArrayEquals("TEST".toByteArray(), result)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -194,7 +194,7 @@ class SyncerTest {
|
||||
}
|
||||
|
||||
override fun create(
|
||||
provider: ContentProviderClient,
|
||||
client: ContentProviderClient,
|
||||
fromCollection: Collection
|
||||
): LocalTestCollection? {
|
||||
throw NotImplementedError()
|
||||
@@ -202,13 +202,13 @@ class SyncerTest {
|
||||
|
||||
override fun getAll(
|
||||
account: Account,
|
||||
provider: ContentProviderClient
|
||||
client: ContentProviderClient
|
||||
): List<LocalTestCollection> {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun update(
|
||||
provider: ContentProviderClient,
|
||||
client: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
fromCollection: Collection
|
||||
) {
|
||||
@@ -219,7 +219,7 @@ class SyncerTest {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.accounts.Account
|
||||
import at.bitfire.dav4jvm.okhttp.DavCollection
|
||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.di.SyncDispatcher
|
||||
@@ -64,7 +65,7 @@ class TestSyncManager @AssistedInject constructor(
|
||||
didQueryCapabilities = true
|
||||
|
||||
var cTag: SyncState? = null
|
||||
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
|
||||
davCollection.propfind(0, CalDAV.GetCTag) { response, rel ->
|
||||
if (rel == Response.HrefRelation.SELF)
|
||||
response[GetCTag::class.java]?.cTag?.let {
|
||||
cTag = SyncState(SyncState.Type.CTAG, it)
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
@@ -19,6 +20,7 @@ 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.CardDAV
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.push.WebPush
|
||||
@@ -166,9 +168,9 @@ data class Collection(
|
||||
val url = UrlUtils.withTrailingSlash(dav.href)
|
||||
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
||||
when {
|
||||
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
|
||||
resourceType.types.contains(CardDAV.Addressbook) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(CalDAV.Calendar) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(CalDAV.Subscribed) -> TYPE_WEBCAL
|
||||
else -> null
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
@@ -12,6 +12,7 @@ import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@@ -46,7 +47,7 @@ data class Principal(
|
||||
fun fromDavResponse(serviceId: Long, dav: Response): Principal? {
|
||||
// Check if response is a principal
|
||||
val resourceType = dav[ResourceType::class.java] ?: return null
|
||||
if (!resourceType.types.contains(ResourceType.PRINCIPAL))
|
||||
if (!resourceType.types.contains(WebDAV.Principal))
|
||||
return null
|
||||
|
||||
// Try getting the display name of the principal
|
||||
|
||||
@@ -29,8 +29,7 @@ class ConscryptIntegration {
|
||||
if (initialized)
|
||||
return
|
||||
|
||||
val alreadyInstalled = conscryptInstalled()
|
||||
if (!alreadyInstalled) {
|
||||
if (Conscrypt.isAvailable() && !conscryptInstalled()) {
|
||||
// install Conscrypt as most preferred provider
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import com.google.errorprone.annotations.MustBeClosed
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.openid.appauth.AuthState
|
||||
@@ -49,7 +51,7 @@ import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* Builder for the [OkHttpClient].
|
||||
* Builder for the HTTP client.
|
||||
*
|
||||
* **Attention:** If the builder is injected, it shouldn't be used from multiple locations to generate different clients because then
|
||||
* there's only one [HttpClientBuilder] object and setting properties from one location would influence the others.
|
||||
@@ -105,7 +107,7 @@ class HttpClientBuilder @Inject constructor(
|
||||
private var authenticator: Authenticator? = null
|
||||
private var certificateAlias: String? = null
|
||||
|
||||
fun authenticate(host: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): HttpClientBuilder {
|
||||
fun authenticate(domain: String?, getCredentials: () -> Credentials, updateAuthState: ((AuthState) -> Unit)? = null): HttpClientBuilder {
|
||||
val credentials = getCredentials()
|
||||
if (credentials.authState != null) {
|
||||
// OAuth
|
||||
@@ -124,7 +126,7 @@ class HttpClientBuilder @Inject constructor(
|
||||
} else if (credentials.username != null && credentials.password != null) {
|
||||
// basic/digest auth
|
||||
val authHandler = BasicDigestAuthHandler(
|
||||
domain = UrlUtils.hostToDomain(host),
|
||||
domain = domain,
|
||||
username = credentials.username,
|
||||
password = credentials.password.asCharArray(),
|
||||
insecurePreemptive = true
|
||||
@@ -155,16 +157,20 @@ class HttpClientBuilder @Inject constructor(
|
||||
*
|
||||
* **Must not be run on main thread, because it creates [AccountSettings]!** Use [fromAccountAsync] if possible.
|
||||
*
|
||||
* @param account the account to take authentication from
|
||||
* @param onlyHost if set: only authenticate for this host name
|
||||
* @param account the account to take authentication from
|
||||
* @param authDomain (optional) Send credentials only for the hosts of the given domain. Can be:
|
||||
*
|
||||
* - a full host name (`caldav.example.com`): then credentials are only sent for the domain of that host name (`example.com`), or
|
||||
* - a domain name (`example.com`): then credentials are only sent for the given domain, or
|
||||
* - or _null_: then credentials are always sent, regardless of the resource host name.
|
||||
*
|
||||
* @throws at.bitfire.davdroid.sync.account.InvalidAccountException when the account doesn't exist
|
||||
*/
|
||||
@WorkerThread
|
||||
fun fromAccount(account: Account, onlyHost: String? = null): HttpClientBuilder {
|
||||
fun fromAccount(account: Account, authDomain: String? = null): HttpClientBuilder {
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
authenticate(
|
||||
host = onlyHost,
|
||||
domain = UrlUtils.hostToDomain(authDomain),
|
||||
getCredentials = {
|
||||
accountSettings.credentials()
|
||||
},
|
||||
@@ -190,7 +196,7 @@ class HttpClientBuilder @Inject constructor(
|
||||
/**
|
||||
* Builds an [OkHttpClient] with the configured settings.
|
||||
*
|
||||
* [build] or [buildKtor] must be called only once because multiple calls indicate this wrong usage pattern:
|
||||
* [build] or [buildKtor] is usually called only once because multiple calls indicate this wrong usage pattern:
|
||||
*
|
||||
* ```
|
||||
* val builder = HttpClientBuilder(/*injected*/)
|
||||
@@ -200,12 +206,10 @@ class HttpClientBuilder @Inject constructor(
|
||||
*
|
||||
* However in this case the configuration of `client1` is still in `builder` and would be reused for `client2`,
|
||||
* which is usually not desired.
|
||||
*
|
||||
* @throws IllegalStateException on second and later calls
|
||||
*/
|
||||
fun build(): OkHttpClient {
|
||||
if (alreadyBuilt)
|
||||
throw IllegalStateException("build() must only be called once; use Provider<HttpClientBuilder>")
|
||||
logger.warning("build() should only be called once; use Provider<HttpClientBuilder> instead")
|
||||
|
||||
val builder = OkHttpClient.Builder()
|
||||
configureOkHttp(builder)
|
||||
@@ -384,11 +388,16 @@ class HttpClientBuilder @Inject constructor(
|
||||
@MustBeClosed
|
||||
fun buildKtor(): HttpClient {
|
||||
if (alreadyBuilt)
|
||||
throw IllegalStateException("build() must only be called once; use Provider<HttpClientBuilder>")
|
||||
logger.warning("buildKtor() should only be called once; use Provider<HttpClientBuilder> instead")
|
||||
|
||||
val client = HttpClient(OkHttp) {
|
||||
// Ktor-level configuration here
|
||||
|
||||
// automatically convert JSON from/into data classes (if requested in respective code)
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
|
||||
engine {
|
||||
// okhttp engine configuration here
|
||||
|
||||
|
||||
@@ -4,27 +4,28 @@
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.dav4jvm.ktor.exception.HttpException
|
||||
import at.bitfire.davdroid.settings.Credentials
|
||||
import at.bitfire.davdroid.ui.setup.LoginInfo
|
||||
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
|
||||
import at.bitfire.davdroid.util.withTrailingSlash
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.appendPathSegments
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.http.path
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Implements Nextcloud Login Flow v2.
|
||||
@@ -32,9 +33,134 @@ import javax.inject.Inject
|
||||
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
|
||||
*/
|
||||
class NextcloudLoginFlow @Inject constructor(
|
||||
httpClientBuilder: HttpClientBuilder
|
||||
private val httpClientBuilder: Provider<HttpClientBuilder>
|
||||
) {
|
||||
|
||||
// Login flow state
|
||||
var pollUrl: Url? = null
|
||||
var token: String? = null
|
||||
|
||||
/**
|
||||
* Starts Nextcloud Login Flow v2.
|
||||
*
|
||||
* @param baseUrl Nextcloud login flow or base URL
|
||||
*
|
||||
* @return URL that should be opened in the browser (login screen)
|
||||
*
|
||||
* @throws HttpException on non-successful HTTP status
|
||||
*/
|
||||
suspend fun start(baseUrl: Url): Url {
|
||||
// reset fields in case something goes wrong
|
||||
pollUrl = null
|
||||
token = null
|
||||
|
||||
// POST to login flow URL in order to receive endpoint data
|
||||
createClient().use { client ->
|
||||
val result = client.post(loginFlowUrl(baseUrl))
|
||||
if (!result.status.isSuccess())
|
||||
throw HttpException.fromResponse(result)
|
||||
|
||||
// save endpoint data for polling
|
||||
val endpointData: EndpointData = result.body()
|
||||
pollUrl = Url(endpointData.poll.endpoint)
|
||||
token = endpointData.poll.token
|
||||
|
||||
return Url(endpointData.login)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun loginFlowUrl(baseUrl: Url): Url {
|
||||
return when {
|
||||
// already a Login Flow v2 URL
|
||||
baseUrl.encodedPath.endsWith(FLOW_V2_PATH) ->
|
||||
baseUrl
|
||||
|
||||
// Login Flow v1 URL, rewrite to v2
|
||||
baseUrl.encodedPath.endsWith(FLOW_V1_PATH) -> {
|
||||
// drop "[index.php/login]/flow" from the end and append "/v2"
|
||||
val v2Segments = baseUrl.segments.dropLast(1) + "v2"
|
||||
val builder = URLBuilder(baseUrl)
|
||||
builder.path(*v2Segments.toTypedArray())
|
||||
builder.build()
|
||||
}
|
||||
|
||||
// other URL, make it a Login Flow v2 URL
|
||||
else ->
|
||||
URLBuilder(baseUrl)
|
||||
.appendPathSegments(FLOW_V2_PATH.split('/'))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
|
||||
*
|
||||
* @throws HttpException on non-successful HTTP status
|
||||
*/
|
||||
suspend fun fetchLoginInfo(): LoginInfo {
|
||||
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
||||
val token = token ?: throw IllegalArgumentException("Missing token")
|
||||
|
||||
// send HTTP request to request server, login name and app password
|
||||
createClient().use { client ->
|
||||
val result = client.post(pollUrl) {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
setBody("token=$token")
|
||||
}
|
||||
if (!result.status.isSuccess())
|
||||
throw HttpException.fromResponse(result)
|
||||
|
||||
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
||||
val loginData: LoginData = result.body()
|
||||
val serverUrl = loginData.server.withTrailingSlash()
|
||||
|
||||
return LoginInfo(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
credentials = Credentials(
|
||||
username = loginData.loginName,
|
||||
password = loginData.appPassword.toSensitiveString()
|
||||
),
|
||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Ktor HTTP client that follows redirects.
|
||||
*/
|
||||
private fun createClient(): HttpClient =
|
||||
httpClientBuilder.get()
|
||||
.followRedirects(true)
|
||||
.buildKtor()
|
||||
|
||||
|
||||
/**
|
||||
* Represents the JSON response that is returned on the first call to `/login/v2`.
|
||||
*/
|
||||
@Serializable
|
||||
private data class EndpointData(
|
||||
val poll: Poll,
|
||||
val login: String
|
||||
) {
|
||||
@Serializable
|
||||
data class Poll(
|
||||
val token: String,
|
||||
val endpoint: String
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the JSON response that is returned by the polling endpoint.
|
||||
*/
|
||||
@Serializable
|
||||
private data class LoginData(
|
||||
val server: String,
|
||||
val loginName: String,
|
||||
val appPassword: String
|
||||
)
|
||||
|
||||
|
||||
companion object {
|
||||
const val FLOW_V1_PATH = "index.php/login/flow"
|
||||
const val FLOW_V2_PATH = "index.php/login/v2"
|
||||
@@ -43,92 +169,4 @@ class NextcloudLoginFlow @Inject constructor(
|
||||
const val DAV_PATH = "remote.php/dav"
|
||||
}
|
||||
|
||||
val httpClient = httpClientBuilder.build()
|
||||
|
||||
|
||||
// Login flow state
|
||||
var loginUrl: HttpUrl? = null
|
||||
var pollUrl: HttpUrl? = null
|
||||
var token: String? = null
|
||||
|
||||
|
||||
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
|
||||
loginUrl = null
|
||||
pollUrl = null
|
||||
token = null
|
||||
|
||||
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
|
||||
|
||||
loginUrl = json.getString("login").toHttpUrlOrNull()
|
||||
json.getJSONObject("poll").let { poll ->
|
||||
pollUrl = poll.getString("endpoint").toHttpUrl()
|
||||
token = poll.getString("token")
|
||||
}
|
||||
|
||||
return loginUrl
|
||||
}
|
||||
|
||||
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
|
||||
val path = baseUrl.encodedPath
|
||||
|
||||
if (path.endsWith(FLOW_V2_PATH))
|
||||
// already a Login Flow v2 URL
|
||||
return baseUrl
|
||||
|
||||
if (path.endsWith(FLOW_V1_PATH))
|
||||
// Login Flow v1 URL, rewrite to v2
|
||||
return baseUrl.newBuilder()
|
||||
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
|
||||
.build()
|
||||
|
||||
// other URL, make it a Login Flow v2 URL
|
||||
return baseUrl.newBuilder()
|
||||
.addPathSegments(FLOW_V2_PATH)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
suspend fun fetchLoginInfo(): LoginInfo {
|
||||
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
|
||||
val token = token ?: throw IllegalArgumentException("Missing token")
|
||||
|
||||
// send HTTP request to request server, login name and app password
|
||||
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
|
||||
|
||||
// make sure server URL ends with a slash so that DAV_PATH can be appended
|
||||
val serverUrl = json.getString("server").withTrailingSlash()
|
||||
|
||||
return LoginInfo(
|
||||
baseUri = URI(serverUrl).resolve(DAV_PATH),
|
||||
credentials = Credentials(
|
||||
username = json.getString("loginName"),
|
||||
password = json.getString("appPassword").toSensitiveString()
|
||||
),
|
||||
suggestedGroupMethod = GroupMethod.CATEGORIES
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
|
||||
val postRq = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
val response = runInterruptible {
|
||||
httpClient.newCall(postRq).execute()
|
||||
}
|
||||
|
||||
if (response.code != HttpURLConnection.HTTP_OK)
|
||||
throw HttpException(response)
|
||||
|
||||
response.body.use { body ->
|
||||
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
|
||||
if (mimeType.type != "application" || mimeType.subtype != "json")
|
||||
throw DavException("Invalid Login Flow response (not JSON)")
|
||||
|
||||
// decode JSON
|
||||
return@withContext JSONObject(body.string())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.push
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.dav4jvm.XmlReader
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
||||
import at.bitfire.davdroid.db.Collection.Companion.TYPE_ADDRESSBOOK
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
@@ -105,7 +106,7 @@ class PushMessageHandler @Inject constructor(
|
||||
try {
|
||||
parser.setInput(StringReader(message))
|
||||
|
||||
XmlReader(parser).processTag(DavPushMessage.NAME) {
|
||||
XmlReader(parser).processTag(WebDAVPush.PushMessage) {
|
||||
val pushMessage = DavPushMessage.Factory.create(parser)
|
||||
topic = pushMessage.topic?.topic
|
||||
}
|
||||
|
||||
@@ -17,12 +17,7 @@ import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.okhttp.DavCollection
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.push.AuthSecret
|
||||
import at.bitfire.dav4jvm.property.push.PushRegister
|
||||
import at.bitfire.dav4jvm.property.push.PushResource
|
||||
import at.bitfire.dav4jvm.property.push.Subscription
|
||||
import at.bitfire.dav4jvm.property.push.SubscriptionPublicKey
|
||||
import at.bitfire.dav4jvm.property.push.WebPushSubscription
|
||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.di.IoDispatcher
|
||||
@@ -237,26 +232,26 @@ class PushRegistrationManager @Inject constructor(
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", true)
|
||||
serializer.insertTag(PushRegister.NAME) {
|
||||
serializer.insertTag(Subscription.NAME) {
|
||||
serializer.insertTag(WebDAVPush.PushRegister) {
|
||||
serializer.insertTag(WebDAVPush.Subscription) {
|
||||
// subscription URL
|
||||
serializer.insertTag(WebPushSubscription.NAME) {
|
||||
serializer.insertTag(PushResource.NAME) {
|
||||
serializer.insertTag(WebDAVPush.WebPushSubscription) {
|
||||
serializer.insertTag(WebDAVPush.PushResource) {
|
||||
text(endpoint.url)
|
||||
}
|
||||
endpoint.pubKeySet?.let { pubKeySet ->
|
||||
serializer.insertTag(SubscriptionPublicKey.NAME) {
|
||||
serializer.insertTag(WebDAVPush.SubscriptionPublicKey) {
|
||||
attribute(null, "type", "p256dh")
|
||||
text(pubKeySet.pubKey)
|
||||
}
|
||||
serializer.insertTag(AuthSecret.NAME) {
|
||||
serializer.insertTag(WebDAVPush.AuthSecret) {
|
||||
text(pubKeySet.auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// requested expiration
|
||||
serializer.insertTag(PushRegister.EXPIRES) {
|
||||
serializer.insertTag(WebDAVPush.Expires) {
|
||||
text(HttpUtils.formatDate(requestedExpiration))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,22 +218,27 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
try {
|
||||
// update address books
|
||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount)
|
||||
localAddressBookStore.get().updateAccount(oldAccount, newAccount, null)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change address books to renamed account", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// update calendar events
|
||||
localCalendarStore.get().updateAccount(oldAccount, newAccount)
|
||||
val store = localCalendarStore.get()
|
||||
store.acquireContentProvider(true)?.use { client ->
|
||||
store.updateAccount(oldAccount, newAccount, client)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change calendars to renamed account", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// update account_name of local tasks
|
||||
val dataStore = tasksAppManager.get().getDataStore()
|
||||
dataStore?.updateAccount(oldAccount, newAccount)
|
||||
val store = tasksAppManager.get().getDataStore()
|
||||
store?.acquireContentProvider(true)?.use { client ->
|
||||
store.updateAccount(oldAccount, newAccount, client)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't change task lists to renamed account", e)
|
||||
}
|
||||
|
||||
@@ -12,17 +12,9 @@ import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.GoneException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.NotFoundException
|
||||
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.NS_CALDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
@@ -318,27 +310,27 @@ class DavCollectionRepository @Inject constructor(
|
||||
setOutput(writer)
|
||||
|
||||
startDocument("UTF-8", null)
|
||||
setPrefix("", NS_WEBDAV)
|
||||
setPrefix("CAL", NS_CALDAV)
|
||||
setPrefix("CARD", NS_CARDDAV)
|
||||
setPrefix("", WebDAV.NS_WEBDAV)
|
||||
setPrefix("CAL", CalDAV.NS_CALDAV)
|
||||
setPrefix("CARD", CardDAV.NS_CARDDAV)
|
||||
|
||||
if (addressBook)
|
||||
startTag(NS_WEBDAV, "mkcol")
|
||||
startTag(WebDAV.NS_WEBDAV, "mkcol")
|
||||
else
|
||||
startTag(NS_CALDAV, "mkcalendar")
|
||||
startTag(CalDAV.NS_CALDAV, "mkcalendar")
|
||||
|
||||
insertTag(DavResource.SET) {
|
||||
insertTag(DavResource.PROP) {
|
||||
insertTag(ResourceType.NAME) {
|
||||
insertTag(ResourceType.COLLECTION)
|
||||
insertTag(WebDAV.Set) {
|
||||
insertTag(WebDAV.Prop) {
|
||||
insertTag(WebDAV.ResourceType) {
|
||||
insertTag(WebDAV.Collection)
|
||||
if (addressBook)
|
||||
insertTag(ResourceType.ADDRESSBOOK)
|
||||
insertTag(CardDAV.Addressbook)
|
||||
else
|
||||
insertTag(ResourceType.CALENDAR)
|
||||
insertTag(CalDAV.Calendar)
|
||||
}
|
||||
|
||||
displayName?.let {
|
||||
insertTag(DisplayName.NAME) {
|
||||
insertTag(WebDAV.DisplayName) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
@@ -346,7 +338,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
if (addressBook) {
|
||||
// addressbook-specific properties
|
||||
description?.let {
|
||||
insertTag(AddressbookDescription.NAME) {
|
||||
insertTag(CardDAV.AddressbookDescription) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
@@ -354,21 +346,21 @@ class DavCollectionRepository @Inject constructor(
|
||||
} else {
|
||||
// calendar-specific properties
|
||||
description?.let {
|
||||
insertTag(CalendarDescription.NAME) {
|
||||
insertTag(CalDAV.CalendarDescription) {
|
||||
text(it)
|
||||
}
|
||||
}
|
||||
color?.let {
|
||||
insertTag(CalendarColor.NAME) {
|
||||
insertTag(CalDAV.CalendarColor) {
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
}
|
||||
}
|
||||
timezoneId?.let { id ->
|
||||
insertTag(CalendarTimezoneId.NAME) {
|
||||
insertTag(CalDAV.CalendarTimezoneId) {
|
||||
text(id)
|
||||
}
|
||||
getVTimeZone(id)?.let { vTimezone ->
|
||||
insertTag(CalendarTimezone.NAME) {
|
||||
insertTag(CalDAV.CalendarTimezone) {
|
||||
text(
|
||||
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
|
||||
Calendar(
|
||||
@@ -386,19 +378,19 @@ class DavCollectionRepository @Inject constructor(
|
||||
}
|
||||
|
||||
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
|
||||
insertTag(SupportedCalendarComponentSet.NAME) {
|
||||
insertTag(CalDAV.SupportedCalendarComponentSet) {
|
||||
// Only if there's at least one not explicitly supported calendar component set,
|
||||
// otherwise don't include the property, which means "supports everything".
|
||||
if (supportsVEVENT)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VEVENT)
|
||||
}
|
||||
if (supportsVTODO)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VTODO)
|
||||
}
|
||||
if (supportsVJOURNAL)
|
||||
insertTag(SupportedCalendarComponentSet.COMP) {
|
||||
insertTag(CalDAV.Comp) {
|
||||
attribute(null, "name", Component.VJOURNAL)
|
||||
}
|
||||
}
|
||||
@@ -407,9 +399,9 @@ class DavCollectionRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
if (addressBook)
|
||||
endTag(NS_WEBDAV, "mkcol")
|
||||
endTag(WebDAV.NS_WEBDAV, "mkcol")
|
||||
else
|
||||
endTag(NS_CALDAV, "mkcalendar")
|
||||
endTag(CalDAV.NS_CALDAV, "mkcalendar")
|
||||
endDocument()
|
||||
}
|
||||
return writer.toString()
|
||||
|
||||
@@ -87,7 +87,7 @@ class LocalAddressBookStore @Inject constructor(
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
@@ -98,7 +98,7 @@ class LocalAddressBookStore @Inject constructor(
|
||||
id = fromCollection.id
|
||||
) ?: return null
|
||||
|
||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, client)
|
||||
|
||||
// update settings
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
@@ -125,12 +125,12 @@ class LocalAddressBookStore @Inject constructor(
|
||||
return addressBookAccount
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
|
||||
override fun getAll(account: Account, client: ContentProviderClient): List<LocalAddressBook> =
|
||||
getAddressBookAccounts(account).map { addressBookAccount ->
|
||||
localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
localAddressBookFactory.create(account, addressBookAccount, client)
|
||||
}
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
var currentAccount = localCollection.addressBookAccount
|
||||
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
|
||||
|
||||
@@ -167,8 +167,9 @@ class LocalAddressBookStore @Inject constructor(
|
||||
*
|
||||
* @param oldAccount The old account
|
||||
* @param newAccount The new account
|
||||
* @param client content provider client (not needed/does not exist for address books)
|
||||
*/
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, client: ContentProviderClient?) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { addressBookAccount ->
|
||||
|
||||
@@ -26,6 +26,7 @@ import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillNotClose
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalCalendarStore @Inject constructor(
|
||||
@@ -138,7 +139,9 @@ class LocalCalendarStore @Inject constructor(
|
||||
return values
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
||||
if (client == null)
|
||||
return
|
||||
val values = contentValuesOf(
|
||||
// Account name to be changed
|
||||
Calendars.ACCOUNT_NAME to newAccount.name,
|
||||
@@ -147,9 +150,7 @@ class LocalCalendarStore @Inject constructor(
|
||||
Calendars.OWNER_ACCOUNT to newAccount.name
|
||||
)
|
||||
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
|
||||
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
client.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalCalendar) {
|
||||
|
||||
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.resource
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import javax.annotation.WillNotClose
|
||||
|
||||
/**
|
||||
* Represents a local data store for a specific collection type.
|
||||
@@ -76,7 +77,8 @@ interface LocalDataStore<T: LocalCollection<*>> {
|
||||
*
|
||||
* @param oldAccount The old account.
|
||||
* @param newAccount The new account.
|
||||
* @param client Content provider client for the local data store type or *null* when not needed for that data type.
|
||||
*/
|
||||
fun updateAccount(oldAccount: Account, newAccount: Account)
|
||||
fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?)
|
||||
|
||||
}
|
||||
@@ -18,11 +18,11 @@ import at.bitfire.davdroid.repository.PrincipalRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillNotClose
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalJtxCollectionStore @Inject constructor(
|
||||
@@ -46,7 +46,7 @@ class LocalJtxCollectionStore @Inject constructor(
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalJtxCollection {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
@@ -63,8 +63,8 @@ class LocalJtxCollectionStore @Inject constructor(
|
||||
withColor = true
|
||||
)
|
||||
|
||||
val uri = JtxCollection.create(account, provider, values)
|
||||
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
|
||||
val uri = JtxCollection.create(account, client, values)
|
||||
return LocalJtxCollection(account, client, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
|
||||
@@ -94,21 +94,21 @@ class LocalJtxCollectionStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalJtxCollection> =
|
||||
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||
override fun getAll(account: Account, client: ContentProviderClient): List<LocalJtxCollection> =
|
||||
JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null)
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
|
||||
localCollection.update(values)
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
|
||||
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
||||
if (client == null)
|
||||
return
|
||||
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalJtxCollection) {
|
||||
|
||||
@@ -10,108 +10,84 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.DmfsTaskFactory
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.techbee.jtx.JtxContract
|
||||
import com.google.common.base.MoreObjects
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.Optional
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Represents a Dmfs Task (OpenTasks and Tasks.org) entry
|
||||
*/
|
||||
class LocalTask: DmfsTask, LocalResource {
|
||||
class LocalTask(
|
||||
val dmfsTask: DmfsTask
|
||||
): LocalResource {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
val logger: Logger = Logger.getLogger(javaClass.name)
|
||||
|
||||
override var fileName: String? = null
|
||||
|
||||
// LocalResource implementation
|
||||
|
||||
override val id: Long?
|
||||
get() = dmfsTask.id
|
||||
|
||||
override var fileName: String?
|
||||
get() = dmfsTask.syncId
|
||||
set(value) { dmfsTask.syncId = value }
|
||||
|
||||
override var eTag: String?
|
||||
get() = dmfsTask.eTag
|
||||
set(value) { dmfsTask.eTag = value }
|
||||
|
||||
/**
|
||||
* Note: Schedule-Tag for tasks is not supported
|
||||
*/
|
||||
override var scheduleTag: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
private set
|
||||
override val flags: Int
|
||||
get() = dmfsTask.flags
|
||||
|
||||
fun add() = dmfsTask.add()
|
||||
|
||||
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
fun update(data: Task) = dmfsTask.update(data)
|
||||
|
||||
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
|
||||
id = values.getAsLong(Tasks._ID)
|
||||
fileName = values.getAsString(Tasks._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
fun delete() = dmfsTask.delete()
|
||||
|
||||
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")
|
||||
logger.fine("Schedule-Tag for tasks not supported, won't save")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName.isPresent)
|
||||
values.put(Tasks._SYNC_ID, fileName.get())
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
values.put(DmfsTask.COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, dmfsTask.task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
dmfsTask.update(values)
|
||||
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
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)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
|
||||
dmfsTask.update(values)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
dmfsTask.flags = flags
|
||||
}
|
||||
|
||||
override fun updateSequence(sequence: Int) = throw NotImplementedError()
|
||||
|
||||
override fun updateUid(uid: String) {
|
||||
val values = contentValuesOf(Tasks._UID to uid)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
dmfsTask.update(values)
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
dmfsTask.delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
@@ -123,7 +99,6 @@ class LocalTask: DmfsTask, LocalResource {
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("scheduleTag", scheduleTag)
|
||||
.add("flags", flags)
|
||||
/*.add("task",
|
||||
try {
|
||||
@@ -136,9 +111,9 @@ class LocalTask: DmfsTask, LocalResource {
|
||||
.toString()
|
||||
|
||||
override fun getViewUri(context: Context): Uri? = id?.let { id ->
|
||||
when (taskList.providerName) {
|
||||
when (dmfsTask.taskList.providerName) {
|
||||
TaskProvider.ProviderName.OpenTasks -> {
|
||||
val contentUri = Tasks.getContentUri(taskList.providerName.authority)
|
||||
val contentUri = Tasks.getContentUri(dmfsTask.taskList.providerName.authority)
|
||||
ContentUris.withAppendedId(contentUri, id)
|
||||
}
|
||||
// Tasks.org can't handle view content URIs (missing intent-filter)
|
||||
@@ -147,9 +122,4 @@ class LocalTask: DmfsTask, LocalResource {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskFactory<LocalTask> {
|
||||
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,10 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.DmfsTaskListFactory
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
@@ -22,62 +17,38 @@ import java.util.logging.Logger
|
||||
*
|
||||
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
class LocalTaskList (
|
||||
val dmfsTaskList: DmfsTaskList
|
||||
): LocalCollection<LocalTask> {
|
||||
|
||||
private val logger = Logger.getGlobal()
|
||||
|
||||
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
|
||||
override val readOnly
|
||||
get() =
|
||||
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
get() = dmfsTaskList.accessLevel?.let {
|
||||
it != TaskListColumns.ACCESS_LEVEL_UNDEFINED && it <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
} ?: false
|
||||
|
||||
override val dbCollectionId: Long?
|
||||
get() = syncId?.toLongOrNull()
|
||||
get() = dmfsTaskList.syncId?.toLongOrNull()
|
||||
|
||||
override val tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
get() = "tasks-${dmfsTaskList.account.name}-${dmfsTaskList.id}"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
get() = dmfsTaskList.name ?: dmfsTaskList.id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
get() = dmfsTaskList.readSyncState()?.let { SyncState.fromString(it) }
|
||||
set(state) {
|
||||
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
|
||||
provider.update(taskListSyncUri(), values, null, null)
|
||||
dmfsTaskList.writeSyncState(state.toString())
|
||||
}
|
||||
|
||||
|
||||
override fun populate(values: ContentValues) {
|
||||
super.populate(values)
|
||||
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
|
||||
}
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||
override fun findDeleted() = dmfsTaskList.queryTasks(Tasks._DELETED, null)
|
||||
.map { LocalTask(it) }
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in tasks) {
|
||||
val dmfsTasks = dmfsTaskList.queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in dmfsTasks) {
|
||||
try {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
@@ -89,41 +60,32 @@ class LocalTaskList private constructor(
|
||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
return dmfsTasks.map { LocalTask(it) }
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
dmfsTaskList.queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name))
|
||||
.firstOrNull()?.let {
|
||||
LocalTask(it)
|
||||
}
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
|
||||
return provider.update(tasksSyncUri(), values,
|
||||
val values = contentValuesOf(DmfsTask.COLUMN_FLAGS to flags)
|
||||
return dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
arrayOf(dmfsTaskList.id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
dmfsTaskList.provider.delete(dmfsTaskList.tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${DmfsTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(dmfsTaskList.id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
|
||||
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
) = LocalTaskList(account, provider, providerName, id)
|
||||
|
||||
val values = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
|
||||
dmfsTaskList.provider.update(dmfsTaskList.tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(dmfsTaskList.id.toString()))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.annotation.WillNotClose
|
||||
|
||||
class LocalTaskListStore @AssistedInject constructor(
|
||||
@Assisted private val providerName: TaskProvider.ProviderName,
|
||||
@@ -56,13 +57,13 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
logger.log(Level.INFO, "Adding local task list", fromCollection)
|
||||
val uri = create(account, provider, providerName, fromCollection)
|
||||
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
val uri = create(account, client, providerName, fromCollection)
|
||||
return LocalTaskList(DmfsTaskList.findByID(account, client, providerName, ContentUris.parseId(uri)))
|
||||
}
|
||||
|
||||
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
|
||||
@@ -80,7 +81,7 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
put(TaskLists.SYNC_ENABLED, 1)
|
||||
put(TaskLists.VISIBLE, 1)
|
||||
}
|
||||
return DmfsTaskList.Companion.create(account, provider, providerName, values)
|
||||
return DmfsTaskList.create(account, provider, providerName, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
@@ -100,25 +101,26 @@ class LocalTaskListStore @AssistedInject constructor(
|
||||
return values
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
|
||||
override fun getAll(account: Account, client: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, client, providerName, null, null)
|
||||
.map { LocalTaskList(it) }
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.dmfsTaskList.account)
|
||||
localCollection.dmfsTaskList.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, providerName)?.use { provider ->
|
||||
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = Tasks.getContentUri(providerName.authority)
|
||||
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account, @WillNotClose client: ContentProviderClient?) {
|
||||
if (client == null)
|
||||
return
|
||||
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = Tasks.getContentUri(providerName.authority)
|
||||
client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalTaskList) {
|
||||
localCollection.delete()
|
||||
localCollection.dmfsTaskList.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,19 +13,15 @@ import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
|
||||
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.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.common.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.log.StringHandler
|
||||
import at.bitfire.davdroid.network.DnsRecordResolver
|
||||
@@ -87,7 +83,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
.apply {
|
||||
if (credentials != null)
|
||||
authenticate(
|
||||
host = null,
|
||||
domain = null,
|
||||
getCredentials = { credentials }
|
||||
)
|
||||
}
|
||||
@@ -230,21 +226,23 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
Service.CARDDAV -> {
|
||||
davBaseURL.propfind(
|
||||
0,
|
||||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
|
||||
AddressbookHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
WebDAV.ResourceType, WebDAV.DisplayName,
|
||||
WebDAV.CurrentUserPrincipal,
|
||||
CardDAV.AddressbookHomeSet,
|
||||
CardDAV.AddressbookDescription
|
||||
) { response, _ ->
|
||||
scanResponse(ResourceType.ADDRESSBOOK, response, config)
|
||||
scanResponse(CardDAV.Addressbook, response, config)
|
||||
}
|
||||
}
|
||||
Service.CALDAV -> {
|
||||
davBaseURL.propfind(
|
||||
0,
|
||||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||
CalendarHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
WebDAV.ResourceType, WebDAV.DisplayName,
|
||||
WebDAV.CurrentUserPrincipal, WebDAV.CurrentUserPrivilegeSet,
|
||||
CalDAV.CalendarHomeSet,
|
||||
CalDAV.SupportedCalendarComponentSet, CalDAV.CalendarColor, CalDAV.CalendarDescription, CalDAV.CalendarTimezone
|
||||
) { response, _ ->
|
||||
scanResponse(ResourceType.CALENDAR, response, config)
|
||||
scanResponse(CalDAV.Calendar, response, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,7 +260,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
fun queryEmailAddress(principal: HttpUrl): List<String> {
|
||||
val mailboxes = LinkedList<String>()
|
||||
try {
|
||||
DavResource(httpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
|
||||
DavResource(httpClient, principal, log).propfind(0, CalDAV.CalendarUserAddressSet) { response, _ ->
|
||||
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
|
||||
for (href in addressSet.hrefs)
|
||||
try {
|
||||
@@ -301,11 +299,11 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
val homeSetClass: Class<out HrefListProperty>
|
||||
val serviceType: Service
|
||||
when (resourceType) {
|
||||
ResourceType.ADDRESSBOOK -> {
|
||||
CardDAV.Addressbook -> {
|
||||
homeSetClass = AddressbookHomeSet::class.java
|
||||
serviceType = Service.CARDDAV
|
||||
}
|
||||
ResourceType.CALENDAR -> {
|
||||
CalDAV.Calendar -> {
|
||||
homeSetClass = CalendarHomeSet::class.java
|
||||
serviceType = Service.CALDAV
|
||||
}
|
||||
@@ -326,7 +324,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
}
|
||||
|
||||
// ... and/or a principal?
|
||||
if (it.types.contains(ResourceType.PRINCIPAL))
|
||||
if (it.types.contains(WebDAV.Principal))
|
||||
principal = davResponse.href
|
||||
}
|
||||
|
||||
@@ -446,7 +444,7 @@ class DavResourceFinder @AssistedInject constructor(
|
||||
*/
|
||||
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
|
||||
var principal: HttpUrl? = null
|
||||
DavResource(httpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
|
||||
DavResource(httpClient, url, log).propfind(0, WebDAV.CurrentUserPrincipal) { response, _ ->
|
||||
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
|
||||
response.requestedUrl.resolve(href)?.let {
|
||||
log.info("Found current-user-principal: $it")
|
||||
|
||||
@@ -6,8 +6,7 @@ package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
@@ -36,8 +35,8 @@ class PrincipalsRefresher @AssistedInject constructor(
|
||||
* Principal properties to ask the server for.
|
||||
*/
|
||||
private val principalProperties = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.ResourceType
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,19 +5,10 @@
|
||||
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.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.db.ServiceType
|
||||
@@ -29,24 +20,24 @@ object ServiceDetectionUtils {
|
||||
*/
|
||||
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
|
||||
WebDAV.CurrentUserPrivilegeSet,
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.Owner,
|
||||
WebDAV.ResourceType,
|
||||
WebDAVPush.Transports,
|
||||
WebDAVPush.Topic
|
||||
) + when (serviceType) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookDescription.NAME
|
||||
CardDAV.AddressbookDescription
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarColor.NAME,
|
||||
CalendarDescription.NAME,
|
||||
CalendarTimezone.NAME,
|
||||
CalendarTimezoneId.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
CalDAV.CalendarColor,
|
||||
CalDAV.CalendarDescription,
|
||||
CalDAV.CalendarTimezone,
|
||||
CalDAV.CalendarTimezoneId,
|
||||
CalDAV.SupportedCalendarComponentSet,
|
||||
CalDAV.Source
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
|
||||
@@ -8,14 +8,16 @@ import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.okhttp.DavResource
|
||||
import at.bitfire.dav4jvm.okhttp.UrlUtils
|
||||
import at.bitfire.dav4jvm.okhttp.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
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.carddav.CardDAV
|
||||
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.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.DavHomeSetRepository
|
||||
@@ -59,18 +61,18 @@ class ServiceRefresher @AssistedInject constructor(
|
||||
*/
|
||||
private val homeSetProperties: Array<Property.Name> =
|
||||
arrayOf( // generic WebDAV properties
|
||||
DisplayName.NAME,
|
||||
GroupMembership.NAME,
|
||||
ResourceType.NAME
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.GroupMembership,
|
||||
WebDAV.ResourceType
|
||||
) + when (service.type) { // service-specific CalDAV/CardDAV properties
|
||||
Service.TYPE_CARDDAV -> arrayOf(
|
||||
AddressbookHomeSet.NAME,
|
||||
CardDAV.AddressbookHomeSet,
|
||||
)
|
||||
|
||||
Service.TYPE_CALDAV -> arrayOf(
|
||||
CalendarHomeSet.NAME,
|
||||
CalendarProxyReadFor.NAME,
|
||||
CalendarProxyWriteFor.NAME
|
||||
CalDAV.CalendarHomeSet,
|
||||
CalDAV.CalendarProxyReadFor,
|
||||
CalDAV.CalendarProxyWriteFor
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException()
|
||||
@@ -147,8 +149,8 @@ class ServiceRefresher @AssistedInject constructor(
|
||||
// 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,
|
||||
CalDAV.CalendarProxyRead,
|
||||
CalDAV.CalendarProxyWrite
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
|
||||
@@ -12,7 +12,7 @@ import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Reminders
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Binds
|
||||
@@ -39,7 +39,7 @@ class AccountSettingsMigration10 @Inject constructor(
|
||||
override fun migrate(account: Account) {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||
val tasksUri = provider.tasksUri().asSyncAdapter(account)
|
||||
val emptyETag = contentValuesOf(LocalTask.COLUMN_ETAG to null)
|
||||
val emptyETag = contentValuesOf(DmfsTask.COLUMN_ETAG to null)
|
||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||
}
|
||||
|
||||
|
||||
@@ -98,9 +98,9 @@ class AccountSettingsMigration20 @Inject constructor(
|
||||
for (taskList in taskListStore.getAll(account, provider)) {
|
||||
when (taskList) {
|
||||
is LocalTaskList -> { // tasks.org, OpenTasks
|
||||
val url = taskList.syncId ?: continue
|
||||
val url = taskList.dmfsTaskList.syncId ?: continue
|
||||
collectionRepository.getByServiceAndUrl(calDavServiceId, url)?.let { collection ->
|
||||
taskList.update(contentValuesOf(
|
||||
taskList.dmfsTaskList.update(contentValuesOf(
|
||||
TaskLists._SYNC_ID to collection.id.toString()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ import at.bitfire.dav4jvm.okhttp.DavCalendar
|
||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
@@ -109,14 +109,20 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(
|
||||
0,
|
||||
CalDAV.MaxResourceSize,
|
||||
WebDAV.SupportedReportSet,
|
||||
CalDAV.GetCTag,
|
||||
WebDAV.SyncToken
|
||||
) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
logger.info("Calendar accepts events up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||
}
|
||||
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
hasCollectionSync = supported.reports.contains(WebDAV.SyncCollection)
|
||||
}
|
||||
syncState = syncState(response)
|
||||
}
|
||||
|
||||
@@ -7,24 +7,24 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.text.format.Formatter
|
||||
import at.bitfire.dav4jvm.ktor.toUrlOrNull
|
||||
import at.bitfire.dav4jvm.okhttp.DavAddressBook
|
||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressData
|
||||
import at.bitfire.dav4jvm.property.carddav.CardDAV
|
||||
import at.bitfire.dav4jvm.property.carddav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.di.SyncDispatcher
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import at.bitfire.davdroid.resource.LocalAddress
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
@@ -46,16 +46,14 @@ import dagger.assisted.AssistedInject
|
||||
import ezvcard.VCardVersion
|
||||
import ezvcard.io.CannotParseException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.Optional
|
||||
@@ -110,7 +108,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
@Assisted val syncFrameworkUpload: Boolean,
|
||||
val dirtyVerifier: Optional<ContactDirtyVerifier>,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
private val httpClientBuilder: HttpClientBuilder,
|
||||
private val resourceDownloaderFactory: ResourceDownloader.Factory,
|
||||
@SyncDispatcher syncDispatcher: CoroutineDispatcher
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(
|
||||
account,
|
||||
@@ -150,11 +148,6 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to download images which are referenced by URL
|
||||
*/
|
||||
private lateinit var resourceDownloader: ResourceDownloader
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
if (dirtyVerifier.isPresent) {
|
||||
@@ -164,7 +157,6 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
davCollection = DavAddressBook(httpClient, collection.url)
|
||||
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||
|
||||
logger.info("Contact group strategy: ${groupStrategy::class.java.simpleName}")
|
||||
return true
|
||||
@@ -174,7 +166,14 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
return SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, MaxResourceSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(
|
||||
0,
|
||||
CardDAV.MaxResourceSize,
|
||||
CardDAV.SupportedAddressData,
|
||||
WebDAV.SupportedReportSet,
|
||||
CalDAV.GetCTag,
|
||||
WebDAV.SyncToken
|
||||
) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
logger.info("Address book accepts vCards up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||
@@ -187,7 +186,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
// hasJCard = supported.hasJCard()
|
||||
}
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
hasCollectionSync = supported.reports.contains(WebDAV.SyncCollection)
|
||||
}
|
||||
syncState = syncState(response)
|
||||
}
|
||||
@@ -316,7 +315,7 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
override suspend fun listAllRemote(callback: MultiResponseCallback) =
|
||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
runInterruptible {
|
||||
davCollection.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
|
||||
davCollection.propfind(1, WebDAV.ResourceType, WebDAV.GetETag, callback = callback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,11 +362,20 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
processCard(
|
||||
response.href.lastSegment,
|
||||
eTag,
|
||||
StringReader(card),
|
||||
isJCard,
|
||||
resourceDownloader
|
||||
fileName = response.href.lastSegment,
|
||||
eTag = eTag,
|
||||
reader = StringReader(card),
|
||||
jCard = isJCard,
|
||||
downloader = object : Contact.Downloader {
|
||||
override fun download(url: String, accepts: String): ByteArray? {
|
||||
// download external resource (like a photo) from an URL
|
||||
val httpUrl = url.toUrlOrNull() ?: return null
|
||||
val downloader = resourceDownloaderFactory.create(account, davCollection.location.host)
|
||||
return runBlocking(syncDispatcher) {
|
||||
downloader.download(httpUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -473,42 +481,6 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
|
||||
// downloader helper class
|
||||
|
||||
private inner class ResourceDownloader(
|
||||
val baseUrl: HttpUrl
|
||||
): Contact.Downloader {
|
||||
|
||||
override fun download(url: String, accepts: String): ByteArray? {
|
||||
val httpUrl = url.toHttpUrlOrNull()
|
||||
if (httpUrl == null) {
|
||||
logger.log(Level.SEVERE, "Invalid external resource URL", url)
|
||||
return null
|
||||
}
|
||||
|
||||
// authenticate only against a certain host, and only upon request
|
||||
val hostHttpClient = httpClientBuilder
|
||||
.fromAccount(account, onlyHost = baseUrl.host)
|
||||
.followRedirects(true) // allow redirects
|
||||
.build()
|
||||
try {
|
||||
val response = hostHttpClient.newCall(Request.Builder()
|
||||
.get()
|
||||
.url(httpUrl)
|
||||
.build()).execute()
|
||||
|
||||
if (response.isSuccessful)
|
||||
return response.body.bytes()
|
||||
else
|
||||
logger.warning("Couldn't download external resource")
|
||||
} catch(e: IOException) {
|
||||
logger.log(Level.SEVERE, "Couldn't download external resource", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_contact)
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ import at.bitfire.dav4jvm.okhttp.DavCalendar
|
||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
@@ -84,7 +84,7 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, GetCTag.NAME, MaxResourceSize.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(0, CalDAV.GetCTag, CalDAV.MaxResourceSize, WebDAV.SyncToken) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
logger.info("Collection accepts resources up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import at.bitfire.davdroid.network.HttpClientBuilder
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsBytes
|
||||
import io.ktor.http.Url
|
||||
import io.ktor.http.isSuccess
|
||||
import java.io.IOException
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Downloads a separate resource that is referenced during synchronization, for instance in
|
||||
* a vCard with `PHOTO:<external URL>`.
|
||||
*
|
||||
* The [ResourceDownloader] only sends authentication for URLs on the same domain as the
|
||||
* original URL. For instance, if the vCard that references a photo is taken from
|
||||
* `example.com` ([originalHost]), then [download] will send authentication
|
||||
* when downloading `https://example.com/photo.jpg`, but not for `https://external-hoster.com/photo.jpg`.
|
||||
*
|
||||
* @param account account to build authentication from
|
||||
* @param originalHost client only authenticates for the domain of this host
|
||||
*/
|
||||
class ResourceDownloader @AssistedInject constructor(
|
||||
@Assisted private val account: Account,
|
||||
@Assisted private val originalHost: String,
|
||||
private val httpClientBuilder: Provider<HttpClientBuilder>,
|
||||
private val logger: Logger
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, originalHost: String): ResourceDownloader
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the given resource and returns it as an in-memory blob.
|
||||
*
|
||||
* Authentication is handled as described in [ResourceDownloader].
|
||||
*
|
||||
* @param url URL of the resource to download
|
||||
*
|
||||
* @return blob of requested resource, or `null` on error
|
||||
*/
|
||||
suspend fun download(url: Url): ByteArray? {
|
||||
httpClientBuilder
|
||||
.get()
|
||||
.fromAccount(account, authDomain = originalHost) // restricts authentication to original domain
|
||||
.followRedirects(true) // allow redirects
|
||||
.buildKtor()
|
||||
.use { httpClient ->
|
||||
try {
|
||||
val response = httpClient.get(url)
|
||||
if (response.status.isSuccess())
|
||||
return response.bodyAsBytes()
|
||||
else
|
||||
logger.warning("Couldn't download external resource (${response.status})")
|
||||
} catch(e: IOException) {
|
||||
logger.log(Level.SEVERE, "Couldn't download external resource", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,10 +24,13 @@ import at.bitfire.dav4jvm.okhttp.exception.NotFoundException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.PreconditionFailedException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.ServiceUnavailableException
|
||||
import at.bitfire.dav4jvm.okhttp.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
@@ -62,19 +65,19 @@ import javax.net.ssl.SSLHandshakeException
|
||||
/**
|
||||
* Synchronizes a local collection with a remote collection.
|
||||
*
|
||||
* @param ResourceType type of local resources
|
||||
* @param LocalType type of local resources
|
||||
* @param CollectionType type of local collection
|
||||
* @param RemoteType type of remote collection
|
||||
*
|
||||
* @param account account to synchronize
|
||||
* @param httpClient HTTP client to use for network requests, already authenticated with credentials from [account]
|
||||
* @param dataType data type to synchronize
|
||||
* @param syncResult receiver for result of the synchronization (will be updated by [performSync])
|
||||
* @param localCollection local collection to synchronize (interface to content provider)
|
||||
* @param collection collection info in the database
|
||||
* @param resync whether re-synchronization is requested
|
||||
* @param account account to synchronize
|
||||
* @param httpClient HTTP client to use for network requests, already authenticated with credentials from [account]
|
||||
* @param dataType data type to synchronize
|
||||
* @param syncResult receiver for result of the synchronization (will be updated by [performSync])
|
||||
* @param localCollection local collection to synchronize (interface to content provider)
|
||||
* @param collection collection info in the database
|
||||
* @param resync whether re-synchronization is requested
|
||||
*/
|
||||
abstract class SyncManager<ResourceType: LocalResource, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
|
||||
abstract class SyncManager<LocalType: LocalResource, out CollectionType: LocalCollection<LocalType>, RemoteType: DavCollection>(
|
||||
val account: Account,
|
||||
val httpClient: OkHttpClient,
|
||||
val dataType: SyncDataType,
|
||||
@@ -133,7 +136,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
|
||||
suspend fun performSync() = withContext(syncDispatcher) {
|
||||
// dismiss previous error notifications
|
||||
syncNotificationManager.dismissInvalidResource(localCollectionTag = localCollection.tag)
|
||||
syncNotificationManager.dismissCollectionError(localCollectionTag = localCollection.tag)
|
||||
|
||||
try {
|
||||
logger.info("Preparing synchronization")
|
||||
@@ -209,7 +212,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} catch (e: HttpException) {
|
||||
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
|
||||
if (e.errors.contains(Error(WebDAV.ValidSyncToken))) {
|
||||
logger.info("Sync token invalid, performing initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
@@ -387,7 +390,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
* @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) {
|
||||
protected open suspend fun uploadDirty(local: LocalType, forceAsNew: Boolean = false) {
|
||||
val existingFileName = local.fileName
|
||||
|
||||
val upload = generateUpload(local)
|
||||
@@ -451,7 +454,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
is ForbiddenException -> {
|
||||
// HTTP 403 Forbidden
|
||||
// If and only if the upload failed because of missing permissions, treat it like 412.
|
||||
if (ex.errors.contains(Error.NEED_PRIVILEGES))
|
||||
if (ex.errors.contains(Error(WebDAV.NeedPrivileges)))
|
||||
logger.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", ex)
|
||||
else
|
||||
throw e
|
||||
@@ -490,7 +493,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
* @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal abstract fun generateUpload(resource: ResourceType): GeneratedResource
|
||||
internal abstract fun generateUpload(resource: LocalType): GeneratedResource
|
||||
|
||||
/**
|
||||
* Called after a successful upload (either of a new or an updated resource) so that the local
|
||||
@@ -503,7 +506,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
* @param context properties that have been generated before the upload and that shall be persisted by this method
|
||||
*/
|
||||
private fun onSuccessfulUpload(
|
||||
local: ResourceType,
|
||||
local: LocalType,
|
||||
newFileName: String,
|
||||
eTag: String?,
|
||||
scheduleTag: String?,
|
||||
@@ -612,7 +615,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
return@listRemote
|
||||
|
||||
// ignore collections
|
||||
if (response[at.bitfire.dav4jvm.property.webdav.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.webdav.ResourceType.COLLECTION) == true)
|
||||
if (response[ResourceType::class.java]?.types?.contains(WebDAV.Collection) == true)
|
||||
return@listRemote
|
||||
|
||||
val name = response.hrefName()
|
||||
@@ -670,7 +673,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
davCollection.reportChanges(
|
||||
syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, null,
|
||||
GetETag.NAME
|
||||
WebDAV.GetETag
|
||||
) { response, relation ->
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF ->
|
||||
@@ -747,7 +750,7 @@ abstract class SyncManager<ResourceType: LocalResource, out CollectionType: Loca
|
||||
private suspend fun querySyncState(): SyncState? {
|
||||
var state: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(0, CalDAV.GetCTag, WebDAV.SyncToken) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
state = syncState(response)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class SyncNotificationManager @AssistedInject constructor(
|
||||
/**
|
||||
* Sends a notification to inform the user that a push notification has been received, the
|
||||
* sync has been scheduled, but it still has not run.
|
||||
* Use [dismissInvalidResource] to dismiss the notification.
|
||||
* Use [dismissCollectionError] to dismiss the notification.
|
||||
*
|
||||
* @param dataType The type of data which was synced.
|
||||
* @param notificationTag The tag to use for the notification.
|
||||
@@ -200,7 +200,7 @@ class SyncNotificationManager @AssistedInject constructor(
|
||||
*
|
||||
* @param localCollectionTag The tag of the local collection which is used as notification tag also.
|
||||
*/
|
||||
fun dismissInvalidResource(localCollectionTag: String) =
|
||||
fun dismissCollectionError(localCollectionTag: String) =
|
||||
dismissNotification(localCollectionTag)
|
||||
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class TaskSyncer @AssistedInject constructor(
|
||||
collectionRepository.getSyncTaskLists(serviceId)
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalTaskList, remoteCollection: Collection) {
|
||||
logger.info("Synchronizing task list ${localCollection.id} with database collection ID: ${localCollection.dbCollectionId}")
|
||||
logger.info("Synchronizing task list ${localCollection.dmfsTaskList.id} with database collection ID: ${localCollection.dbCollectionId}")
|
||||
|
||||
val syncManager = tasksSyncManagerFactory.tasksSyncManager(
|
||||
account,
|
||||
|
||||
@@ -10,11 +10,11 @@ import at.bitfire.dav4jvm.okhttp.DavCalendar
|
||||
import at.bitfire.dav4jvm.okhttp.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.okhttp.Response
|
||||
import at.bitfire.dav4jvm.okhttp.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalDAV
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
@@ -25,6 +25,7 @@ import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.resource.SyncState
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.synctools.exception.InvalidICalendarException
|
||||
import dagger.assisted.Assisted
|
||||
@@ -86,7 +87,7 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
SyncException.wrapWithRemoteResourceSuspending(collection.url) {
|
||||
var syncState: SyncState? = null
|
||||
runInterruptible {
|
||||
davCollection.propfind(0, MaxResourceSize.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
davCollection.propfind(0, CalDAV.MaxResourceSize, CalDAV.GetCTag, WebDAV.SyncToken) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
logger.info("Calendar accepts tasks up to ${Formatter.formatFileSize(context, maxSize)}")
|
||||
@@ -103,7 +104,7 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun generateUpload(resource: LocalTask): GeneratedResource {
|
||||
val task = requireNotNull(resource.task)
|
||||
val task = requireNotNull(resource.dmfsTask.task)
|
||||
logger.log(Level.FINE, "Preparing upload of task ${resource.id}", task)
|
||||
|
||||
// get/create UID
|
||||
@@ -163,7 +164,7 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
val touched = localCollection.touchRelations()
|
||||
val touched = localCollection.dmfsTaskList.touchRelations()
|
||||
logger.info("Touched $touched relations")
|
||||
}
|
||||
|
||||
@@ -191,7 +192,7 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
local.update(newData)
|
||||
} else {
|
||||
logger.log(Level.INFO, "Adding $fileName to local task list", newData)
|
||||
val newLocal = LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
val newLocal = LocalTask(DmfsTask(localCollection.dmfsTaskList, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT))
|
||||
SyncException.wrapWithLocalResource(newLocal) {
|
||||
newLocal.add()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -118,6 +119,7 @@ fun TasksCard(
|
||||
context.startActivity(intent)
|
||||
else
|
||||
coroutineScope.launch {
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.intro_tasks_no_app_store),
|
||||
duration = SnackbarDuration.Long
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
@@ -420,6 +421,7 @@ fun AccountScreen(
|
||||
}
|
||||
|
||||
idxWebcal -> {
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
LaunchedEffect(showNoWebcalApp) {
|
||||
if (showNoWebcalApp) {
|
||||
if (snackbarHostState.showSnackbar(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.ui.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.security.KeyChain
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -609,6 +610,7 @@ fun AuthenticationSettings(
|
||||
onUpdateCredentials(credentials.copy(certificateAlias = newAlias))
|
||||
else
|
||||
scope.launch {
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
if (snackbarHostState.showSnackbar(
|
||||
context.getString(R.string.settings_certificate_alias_empty),
|
||||
actionLabel = context.getString(R.string.settings_certificate_install)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.ui.account
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -189,6 +190,7 @@ fun CollectionsList_Item_Standard(
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp, top = 4.dp, bottom = 4.dp)
|
||||
.semantics {
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
contentDescription = context.getString(R.string.account_synchronize_this_collection)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.ui.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
@@ -210,6 +211,7 @@ fun CreateCalendarScreen(
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.semantics {
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
contentDescription = context.getString(R.string.create_collection_color)
|
||||
}
|
||||
) { /* no content */ }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.ui.composable
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Error
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -48,6 +49,7 @@ fun ExceptionInfoDialog(
|
||||
Icon(Icons.Rounded.Error, null)
|
||||
},
|
||||
text = {
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
val message = if (exception is HttpException) {
|
||||
when (exception.statusCode) {
|
||||
403 -> context.getString(R.string.debug_info_http_403_description)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -57,6 +58,7 @@ fun AccountDetailsPage(
|
||||
uiState.createdAccount?.let(onAccountCreated)
|
||||
|
||||
val context = LocalContext.current
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
LaunchedEffect(uiState.couldNotCreateAccount) {
|
||||
if (uiState.couldNotCreateAccount) {
|
||||
snackbarHostState.showSnackbar(context.getString(R.string.login_account_not_added))
|
||||
|
||||
@@ -213,7 +213,7 @@ fun GoogleLoginScreen(
|
||||
val privacyPolicyNote = HtmlCompat.fromHtml(
|
||||
stringResource(
|
||||
R.string.login_google_client_privacy_policy,
|
||||
context.getString(R.string.app_name),
|
||||
stringResource(R.string.app_name),
|
||||
privacyPolicyUrl.toString()
|
||||
), 0
|
||||
).toAnnotatedString()
|
||||
@@ -223,7 +223,11 @@ fun GoogleLoginScreen(
|
||||
)
|
||||
|
||||
val limitedUseNote = HtmlCompat.fromHtml(
|
||||
stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GOOGLE_POLICY_URL), 0
|
||||
stringResource(
|
||||
R.string.login_google_client_limited_use,
|
||||
stringResource(R.string.app_name),
|
||||
GOOGLE_POLICY_URL
|
||||
), 0
|
||||
).toAnnotatedString()
|
||||
Text(
|
||||
text = limitedUseNote,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Browser
|
||||
@@ -104,6 +105,7 @@ object NextcloudLogin : LoginType {
|
||||
checkResultCallback.launch(browser)
|
||||
} else
|
||||
this@LaunchedEffect.launch {
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
snackbarHostState.showSnackbar(context.getString(R.string.install_browser))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.bitfire.dav4jvm.ktor.toUrlOrNull
|
||||
import at.bitfire.davdroid.network.NextcloudLoginFlow
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@@ -46,24 +46,19 @@ class NextcloudLoginModel @AssistedInject constructor(
|
||||
val error: String? = null,
|
||||
|
||||
/** URL to open in the browser (set during Login Flow) */
|
||||
val loginUrl: HttpUrl? = null,
|
||||
val loginUrl: Url? = null,
|
||||
|
||||
/** login info (set after successful login) */
|
||||
val result: LoginInfo? = null
|
||||
) {
|
||||
val baseUrlWithPrefix =
|
||||
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
|
||||
baseUrl
|
||||
else
|
||||
"https://$baseUrl"
|
||||
val baseKtorUrl = baseUrlWithPrefix.toUrlOrNull()
|
||||
|
||||
val baseHttpUrl: HttpUrl? = run {
|
||||
val baseUrlWithPrefix =
|
||||
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
|
||||
baseUrl
|
||||
else
|
||||
"https://$baseUrl"
|
||||
|
||||
baseUrlWithPrefix.toHttpUrlOrNull()
|
||||
}
|
||||
|
||||
val canContinue = !inProgress && baseHttpUrl != null
|
||||
|
||||
val canContinue = !inProgress && baseKtorUrl != null
|
||||
}
|
||||
|
||||
var uiState by mutableStateOf(UiState())
|
||||
@@ -107,7 +102,7 @@ class NextcloudLoginModel @AssistedInject constructor(
|
||||
* Starts the Login Flow.
|
||||
*/
|
||||
fun startLoginFlow() {
|
||||
val baseUrl = uiState.baseHttpUrl
|
||||
val baseUrl = uiState.baseKtorUrl
|
||||
if (uiState.inProgress || baseUrl == null)
|
||||
return
|
||||
|
||||
@@ -118,19 +113,18 @@ class NextcloudLoginModel @AssistedInject constructor(
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val loginUrl = loginFlow.initiate(baseUrl)
|
||||
val loginUrl = loginFlow.start(baseUrl)
|
||||
|
||||
uiState = uiState.copy(
|
||||
loginUrl = loginUrl,
|
||||
inProgress = false
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Initiating Login Flow failed", e)
|
||||
|
||||
uiState = uiState.copy(
|
||||
inProgress = false,
|
||||
error = e.toString()
|
||||
error = e.localizedMessage ?: e.javaClass.simpleName
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -155,7 +149,7 @@ class NextcloudLoginModel @AssistedInject constructor(
|
||||
logger.log(Level.WARNING, "Fetching login info failed", e)
|
||||
uiState = uiState.copy(
|
||||
inProgress = false,
|
||||
error = e.toString()
|
||||
error = e.localizedMessage ?: e.javaClass.simpleName
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.ui.webdav
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -107,6 +108,7 @@ fun WebdavMountsScreen(
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
delay(300)
|
||||
@@ -324,7 +326,11 @@ fun WebdavMountsItem(
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
}
|
||||
val uri = DocumentsContract.buildRootUri(context.getString(R.string.webdav_authority), info.mount.id.toString())
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
val uri = DocumentsContract.buildRootUri(
|
||||
context.getString(R.string.webdav_authority),
|
||||
info.mount.id.toString()
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ class DavHttpClientBuilder @Inject constructor(
|
||||
.setCookieStore(cookieStore)
|
||||
|
||||
credentialsStore.getCredentials(mountId)?.let { credentials ->
|
||||
builder.authenticate(host = null, getCredentials = { credentials })
|
||||
builder.authenticate(
|
||||
domain = null,
|
||||
getCredentials = { credentials }
|
||||
)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
|
||||
@@ -129,7 +129,7 @@ class WebDavMountRepository @Inject constructor(
|
||||
val builder = httpClientBuilder.get()
|
||||
if (credentials != null)
|
||||
builder.authenticate(
|
||||
host = null,
|
||||
domain = null,
|
||||
getCredentials = { credentials }
|
||||
)
|
||||
val httpClient = builder.build()
|
||||
|
||||
@@ -18,6 +18,7 @@ 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.dav4jvm.property.webdav.WebDAV
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavDocument
|
||||
@@ -153,7 +154,7 @@ class QueryChildDocumentsOperation @Inject constructor(
|
||||
}
|
||||
|
||||
val updatedResource = resource.copy(
|
||||
isDirectory = response[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION)
|
||||
isDirectory = response[ResourceType::class.java]?.types?.contains(WebDAV.Collection)
|
||||
?: resource.isDirectory,
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
mimeType = response[GetContentType::class.java]?.type?.toMediaTypeOrNull(),
|
||||
@@ -191,15 +192,15 @@ class QueryChildDocumentsOperation @Inject constructor(
|
||||
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,
|
||||
WebDAV.ResourceType,
|
||||
WebDAV.CurrentUserPrivilegeSet,
|
||||
WebDAV.DisplayName,
|
||||
WebDAV.GetETag,
|
||||
WebDAV.GetContentType,
|
||||
WebDAV.GetContentLength,
|
||||
WebDAV.GetLastModified,
|
||||
WebDAV.QuotaAvailableBytes,
|
||||
WebDAV.QuotaUsedBytes,
|
||||
)
|
||||
|
||||
/** List of currently active [queryChildDocuments] runners.
|
||||
|
||||
@@ -408,6 +408,8 @@
|
||||
<string name="debug_info_server_error">Error del servidor</string>
|
||||
<string name="debug_info_webdav_error">Error del WebDAV</string>
|
||||
<string name="debug_info_io_error">Error d\'E/S</string>
|
||||
<string name="debug_info_http_403_description">El servidor ha denegat la petició</string>
|
||||
<string name="debug_info_http_404_description">El recurs sol·licitat no existeix (mai mes).</string>
|
||||
<string name="debug_info_http_405_description">El servidor no permet el tipus d\'operació sol·licitat.</string>
|
||||
<string name="debug_info_http_5xx_description">S\'ha produït un problema a la banda del servidor. Poseu-vos en contacte amb l\'assistència del servidor.</string>
|
||||
<string name="debug_info_unexpected_error">S\'ha produït un error inesperat. Vegeu els detalls a la informació de depuració.</string>
|
||||
@@ -421,8 +423,10 @@
|
||||
<string name="debug_info_logs_subtitle">Hi ha registres detallats disponibles</string>
|
||||
<string name="debug_info_logs_view">Visualitza els registres</string>
|
||||
<string name="debug_info_copy_remote_url">Copia l\'URL</string>
|
||||
<string name="debug_info_view_local_resource">Inspecciona el recurs</string>
|
||||
<string name="debug_info_privacy_warning_title">Avís de privadesa</string>
|
||||
<string name="debug_info_privacy_warning_description">Els registres i la informació de depuració poden contenir informació privada. Tingueu en compte això quan ho compartiu públicament.</string>
|
||||
<string name="debug_info_can_not_view_resource">No s\'ha pogut veure el recurs</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">S\'ha produït un error.</string>
|
||||
<string name="exception_httpexception">S\'ha produït un error HTTP.</string>
|
||||
|
||||
@@ -409,6 +409,11 @@
|
||||
<string name="debug_info_server_error">Zerbitzari errorea</string>
|
||||
<string name="debug_info_webdav_error">WebDAV errorea</string>
|
||||
<string name="debug_info_io_error">S/I errorea</string>
|
||||
<string name="debug_info_http_403_description">Zerbitzariak eskaera ukatu du.</string>
|
||||
<string name="debug_info_http_404_description">Eskatutako baliabidea ez da existitzen (jada).</string>
|
||||
<string name="debug_info_http_405_description">Zerbitzariak ez du baimentzen eskatutako eragiketa mota.</string>
|
||||
<string name="debug_info_http_5xx_description">Zerbitzariaren aldetik arazo bat gertatu da. Mesedez, jarri harremanetan zure zerbitzariaren mantenuarekin.</string>
|
||||
<string name="debug_info_unexpected_error">Ustekabeko errore bat gertatu da. Ikusi arazketa-informazioa xehetasunetarako.</string>
|
||||
<string name="debug_info_view_details">Ikusi xehetasunak</string>
|
||||
<string name="debug_info_subtitle">Arazketa informazioa lortu da</string>
|
||||
<string name="debug_info_involved_caption">Parte hartzen duten baliabideak</string>
|
||||
@@ -419,8 +424,10 @@
|
||||
<string name="debug_info_logs_subtitle">Erregistro xehetuak eskuragarri daude</string>
|
||||
<string name="debug_info_logs_view">Ikusi egunkariak</string>
|
||||
<string name="debug_info_copy_remote_url">Kopiatu URL</string>
|
||||
<string name="debug_info_view_local_resource">Ikuskatu baliabidea</string>
|
||||
<string name="debug_info_privacy_warning_title">Pribatutasun oharra</string>
|
||||
<string name="debug_info_privacy_warning_description">Erregistroek eta arazketa-informazioak informazio pribatua izan dezakete. Kontuan izan hau publikoki partekatzerakoan.</string>
|
||||
<string name="debug_info_can_not_view_resource">Ezin da ikusi baliabidea</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">Errore bat gertatu da</string>
|
||||
<string name="exception_httpexception">HTTP errore bat gertatu da.</string>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import io.ktor.http.Url
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class NextcloudLoginFlowTest {
|
||||
|
||||
private val flow = NextcloudLoginFlow(mockk(relaxed = true))
|
||||
|
||||
@Test
|
||||
fun `loginFlowUrl accepts v2 URL`() {
|
||||
assertEquals(
|
||||
Url("http://example.com/index.php/login/v2"),
|
||||
flow.loginFlowUrl(Url("http://example.com/index.php/login/v2"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loginFlowUrl rewrites root URL to v2 URL`() {
|
||||
assertEquals(
|
||||
Url("http://example.com/index.php/login/v2"),
|
||||
flow.loginFlowUrl(Url("http://example.com/"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loginFlowUrl rewrites v1 URL to v2 URL`() {
|
||||
assertEquals(
|
||||
Url("http://example.com/index.php/login/v2"),
|
||||
flow.loginFlowUrl(Url("http://example.com/index.php/login/flow"))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
# Comments apply to next line
|
||||
|
||||
[versions]
|
||||
android-agp = "8.13.1"
|
||||
android-agp = "8.13.2"
|
||||
android-desugaring = "2.1.5"
|
||||
androidx-activityCompose = "1.11.0"
|
||||
androidx-activityCompose = "1.12.1"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-arch = "2.2.0"
|
||||
androidx-browser = "1.9.0"
|
||||
androidx-core = "1.17.0"
|
||||
androidx-hilt = "1.3.0"
|
||||
androidx-lifecycle = "2.9.4"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-paging = "3.3.6"
|
||||
androidx-preference = "1.2.1"
|
||||
androidx-security = "1.1.0"
|
||||
@@ -19,10 +19,10 @@ androidx-test-rules = "1.7.0"
|
||||
androidx-test-junit = "1.3.0"
|
||||
androidx-work = "2.11.0"
|
||||
bitfire-cert4android = "42d883e958"
|
||||
bitfire-dav4jvm = "ad80cdccac"
|
||||
bitfire-synctools = "017187c6d8"
|
||||
bitfire-dav4jvm = "57321c95ad"
|
||||
bitfire-synctools = "42e82f4769"
|
||||
compose-accompanist = "0.37.3"
|
||||
compose-bom = "2025.11.00"
|
||||
compose-bom = "2025.12.00"
|
||||
conscrypt = "2.5.3"
|
||||
dnsjava = "3.6.3"
|
||||
glance = "1.1.1"
|
||||
@@ -31,14 +31,14 @@ hilt = "2.57.2"
|
||||
# keep in sync with ksp version
|
||||
kotlin = "2.2.21"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
ksp = "2.3.2"
|
||||
ktor = "3.3.2"
|
||||
ksp = "2.3.3"
|
||||
ktor = "3.3.3"
|
||||
mikepenz-aboutLibraries = "13.1.0"
|
||||
mockk = "1.14.5"
|
||||
okhttp = "5.3.0"
|
||||
mockk = "1.14.7"
|
||||
okhttp = "5.3.2"
|
||||
openid-appauth = "0.11.1"
|
||||
robolectric = "4.16"
|
||||
room = "2.8.3"
|
||||
room = "2.8.4"
|
||||
unifiedpush = "3.1.2"
|
||||
unifiedpush-fcm = "3.0.0"
|
||||
|
||||
@@ -74,7 +74,7 @@ androidx-work-base = { module = "androidx.work:work-runtime-ktx", version.ref =
|
||||
androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" }
|
||||
bitfire-cert4android = { module = "com.github.bitfireat:cert4android", version.ref = "bitfire-cert4android" }
|
||||
bitfire-dav4jvm = { module = "com.github.bitfireat:dav4jvm", version.ref = "bitfire-dav4jvm" }
|
||||
bitfire-synctools = { module = "com.github.bitfireat:synctools", version.ref = "bitfire-synctools" }
|
||||
bitfire-synctools = { module = "com.github.bitfireAT:synctools", version.ref = "bitfire-synctools" }
|
||||
commons-codec = { module = "commons-codec:commons-codec", version.ref = "commons-codec" }
|
||||
commons-lang = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang" }
|
||||
compose-accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "compose-accompanist" }
|
||||
@@ -95,8 +95,10 @@ junit = { module = "junit:junit", version = "4.13.2" }
|
||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
|
||||
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||
@@ -119,5 +121,6 @@ android-application = { id = "com.android.application", version.ref = "android-a
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
mikepenz-aboutLibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "mikepenz-aboutLibraries" }
|
||||
|
||||
@@ -19,4 +19,18 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
// use remote build cache, if configured
|
||||
if (System.getenv("GRADLE_BUILDCACHE_URL") != null) {
|
||||
buildCache {
|
||||
remote<HttpBuildCache> {
|
||||
url = uri(System.getenv("GRADLE_BUILDCACHE_URL"))
|
||||
credentials {
|
||||
username = System.getenv("GRADLE_BUILDCACHE_USERNAME")
|
||||
password = System.getenv("GRADLE_BUILDCACHE_PASSWORD")
|
||||
}
|
||||
isPush = true // read/write
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
Reference in New Issue
Block a user